Version one on the App Store

This commit is contained in:
2026-04-05 19:44:30 +02:00
parent c780be089d
commit 3ebf1763ed
26 changed files with 2088 additions and 842 deletions
+210 -91
View File
@@ -21,6 +21,10 @@ final class MAService {
let libraryManager: MALibraryManager
private(set) var isConnected = false
// Cache for artist/album detail responses keyed by URI, only stored when description is present
private var artistDetailCache: [String: MAArtist] = [:]
private var albumDetailCache: [String: MAAlbum] = [:]
// MARK: - Initialization
@@ -74,7 +78,7 @@ final class MAService {
func getPlayers() async throws -> [MAPlayer] {
logger.debug("Fetching players")
return try await webSocketClient.sendCommand(
"players",
"players/all",
resultType: [MAPlayer].self
)
}
@@ -124,6 +128,24 @@ final class MAService {
)
}
/// Sync a player to a target (target becomes the sync leader)
func syncPlayer(playerId: String, targetPlayerId: String) async throws {
logger.debug("Syncing player \(playerId) to \(targetPlayerId)")
_ = try await webSocketClient.sendCommand(
"players/cmd/sync",
args: ["player_id": playerId, "target_player_id": targetPlayerId]
)
}
/// Remove a player from its sync group (or dissolve the group if called on the leader)
func unsyncPlayer(playerId: String) async throws {
logger.debug("Unsyncing player \(playerId)")
_ = try await webSocketClient.sendCommand(
"players/cmd/unsync",
args: ["player_id": playerId]
)
}
/// Set volume (0-100)
func setVolume(playerId: String, level: Int) async throws {
let clampedLevel = max(0, min(100, level))
@@ -139,7 +161,17 @@ final class MAService {
// MARK: - Queue
/// Get player queue
/// Get the queue state for a player (includes current_item)
func getPlayerQueue(playerId: String) async throws -> MAPlayerQueue {
logger.debug("Fetching queue state for player \(playerId)")
return try await webSocketClient.sendCommand(
"player_queues/get",
args: ["queue_id": playerId],
resultType: MAPlayerQueue.self
)
}
/// Get player queue items list
func getQueue(playerId: String) async throws -> [MAQueueItem] {
logger.debug("Fetching queue for player \(playerId)")
return try await webSocketClient.sendCommand(
@@ -153,7 +185,7 @@ final class MAService {
func playMedia(playerId: String, uri: String) async throws {
logger.debug("Playing media \(uri) on player \(playerId)")
_ = try await webSocketClient.sendCommand(
"player_queues/cmd/play_media",
"player_queues/play_media",
args: [
"queue_id": playerId,
"media": [uri]
@@ -165,7 +197,7 @@ final class MAService {
func playIndex(playerId: String, index: Int) async throws {
logger.debug("Playing index \(index) on player \(playerId)")
_ = try await webSocketClient.sendCommand(
"player_queues/cmd/play_index",
"player_queues/play_index",
args: [
"queue_id": playerId,
"index": index
@@ -177,7 +209,7 @@ final class MAService {
func moveQueueItem(playerId: String, fromIndex: Int, toIndex: Int) async throws {
logger.debug("Moving queue item from \(fromIndex) to \(toIndex)")
_ = try await webSocketClient.sendCommand(
"player_queues/cmd/move_item",
"player_queues/move_item",
args: [
"queue_id": playerId,
"queue_item_id": fromIndex,
@@ -192,7 +224,7 @@ final class MAService {
func getArtists(limit: Int = 50, offset: Int = 0) async throws -> [MAArtist] {
logger.debug("Fetching artists (limit: \(limit), offset: \(offset))")
return try await webSocketClient.sendCommand(
"music/artists",
"music/artists/library_items",
args: [
"limit": limit,
"offset": offset
@@ -205,7 +237,7 @@ final class MAService {
func getAlbums(limit: Int = 50, offset: Int = 0) async throws -> [MAAlbum] {
logger.debug("Fetching albums (limit: \(limit), offset: \(offset))")
return try await webSocketClient.sendCommand(
"music/albums",
"music/albums/library_items",
args: [
"limit": limit,
"offset": offset
@@ -214,118 +246,205 @@ final class MAService {
)
}
/// Get radio stations
func getRadios() async throws -> [MAMediaItem] {
logger.debug("Fetching radios")
return try await webSocketClient.sendCommand(
"music/radios/library_items",
resultType: [MAMediaItem].self
)
}
/// Get playlists
func getPlaylists() async throws -> [MAPlaylist] {
logger.debug("Fetching playlists")
return try await webSocketClient.sendCommand(
"music/playlists",
"music/playlists/library_items",
resultType: [MAPlaylist].self
)
}
/// Get full artist details (includes biography in metadata.description).
/// Results are cached in memory once biography data is available, so repeated
/// navigation is instant. Skips the cache if the previous fetch had no biography,
/// allowing the server time to enrich the metadata.
func getArtistDetail(artistUri: String) async throws -> MAArtist {
if let cached = artistDetailCache[artistUri],
cached.metadata?.description?.isEmpty == false {
return cached
}
logger.debug("Fetching artist detail for \(artistUri)")
guard let (provider, itemId) = parseMAUri(artistUri) else {
throw MAWebSocketClient.ClientError.serverError("Invalid artist URI: \(artistUri)")
}
let detail = try await webSocketClient.sendCommand(
"music/artists/get",
args: [
"item_id": itemId,
"provider_instance_id_or_domain": provider
],
resultType: MAArtist.self
)
artistDetailCache[artistUri] = detail
return detail
}
/// Get full album details (includes description in metadata.description).
/// Results are cached once description data is available.
func getAlbumDetail(albumUri: String) async throws -> MAAlbum {
if let cached = albumDetailCache[albumUri],
cached.metadata?.description?.isEmpty == false {
return cached
}
logger.debug("Fetching album detail for \(albumUri)")
guard let (provider, itemId) = parseMAUri(albumUri) else {
throw MAWebSocketClient.ClientError.serverError("Invalid album URI: \(albumUri)")
}
let detail = try await webSocketClient.sendCommand(
"music/albums/get",
args: [
"item_id": itemId,
"provider_instance_id_or_domain": provider
],
resultType: MAAlbum.self
)
albumDetailCache[albumUri] = detail
return detail
}
/// Get albums for an artist
func getArtistAlbums(artistUri: String) async throws -> [MAAlbum] {
logger.debug("Fetching albums for artist \(artistUri)")
guard let (provider, itemId) = parseMAUri(artistUri) else {
throw MAWebSocketClient.ClientError.serverError("Invalid artist URI: \(artistUri)")
}
return try await webSocketClient.sendCommand(
"music/artists/artist_albums",
args: [
"item_id": itemId,
"provider_instance_id_or_domain": provider
],
resultType: [MAAlbum].self
)
}
/// Get album tracks
func getAlbumTracks(albumUri: String) async throws -> [MAMediaItem] {
logger.debug("Fetching tracks for album \(albumUri)")
guard let (provider, itemId) = parseMAUri(albumUri) else {
throw MAWebSocketClient.ClientError.serverError("Invalid album URI: \(albumUri)")
}
return try await webSocketClient.sendCommand(
"music/album_tracks",
args: ["uri": albumUri],
"music/albums/album_tracks",
args: [
"item_id": itemId,
"provider_instance_id_or_domain": provider
],
resultType: [MAMediaItem].self
)
}
/// Search library
func search(query: String, mediaTypes: [MediaType]? = nil) async throws -> [MAMediaItem] {
logger.debug("Searching for '\(query)'")
logger.debug("🔍 Searching for '\(query)'")
var args: [String: Any] = ["search": query]
var args: [String: Any] = ["search_query": query]
if let mediaTypes {
args["media_types"] = mediaTypes.map { $0.rawValue }
}
return try await webSocketClient.sendCommand(
"music/search",
args: args,
resultType: [MAMediaItem].self
)
// Try to get the response
let response = try await webSocketClient.sendCommand("music/search", args: args)
guard let result = response.result else {
logger.error("❌ Search returned no result")
return []
}
// Log raw result
if let jsonData = try? JSONEncoder().encode(result),
let jsonString = String(data: jsonData, encoding: .utf8) {
logger.debug("📦 Raw search response (first 500 chars): \(String(jsonString.prefix(500)))")
}
do {
// Music Assistant returns search results categorized by type
struct SearchResults: Decodable {
let albums: [MAMediaItem]?
let tracks: [MAMediaItem]?
let artists: [MAMediaItem]?
let playlists: [MAMediaItem]?
let radios: [MAMediaItem]?
}
let searchResults = try result.decode(as: SearchResults.self)
// Combine all results into a single array
var allItems: [MAMediaItem] = []
if let albums = searchResults.albums { allItems.append(contentsOf: albums) }
if let tracks = searchResults.tracks { allItems.append(contentsOf: tracks) }
if let artists = searchResults.artists { allItems.append(contentsOf: artists) }
if let playlists = searchResults.playlists { allItems.append(contentsOf: playlists) }
if let radios = searchResults.radios { allItems.append(contentsOf: radios) }
logger.info("✅ Decoded \(allItems.count) search results (albums: \(searchResults.albums?.count ?? 0), tracks: \(searchResults.tracks?.count ?? 0), artists: \(searchResults.artists?.count ?? 0), radios: \(searchResults.radios?.count ?? 0))")
return allItems
} catch let error {
logger.error("❌ Failed to decode search results: \(error)")
// Log the actual structure for debugging
if let jsonData = try? JSONEncoder().encode(result),
let jsonString = String(data: jsonData, encoding: .utf8) {
logger.error("📦 Raw search response (first 1000 chars): \(String(jsonString.prefix(1000)))")
}
// Return empty array instead of throwing
return []
}
}
/// Parse a Music Assistant URI into (provider, itemId)
/// MA URIs follow the format: scheme://media_type/item_id
private func parseMAUri(_ uri: String) -> (provider: String, itemId: String)? {
guard let url = URL(string: uri),
let provider = url.scheme,
let host = url.host else { return nil }
let itemId = url.path.isEmpty ? host : String(url.path.dropFirst())
return (provider, itemId)
}
// MARK: - Image Proxy
/// Build URL for image proxy
func imageProxyURL(path: String, size: Int = 256) -> URL? {
guard let serverURL = authManager.serverURL else { return nil }
/// Build URL for the MA image proxy.
///
/// MA uses `/imageproxy` (not `/api/image_proxy`) and requires the `path` value to be
/// double-URL-encoded equivalent to the frontend's
/// `encodeURIComponent(encodeURIComponent(img.path))`.
///
/// - Parameters:
/// - path: The image path or remote URL from `MediaItemImage.path`. Nil returns nil.
/// - provider: The provider key from `MediaItemImage.provider`.
/// - size: Desired pixel size (width and height).
func imageProxyURL(path: String?, provider: String? = nil, size: Int = 256) -> URL? {
guard let path, !path.isEmpty, let serverURL = authManager.serverURL else { return nil }
// Replicate JS encodeURIComponent (encodes everything except AZ az 09 - _ . ! ~ * ' ( ))
let uriAllowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_.!~*'()"))
let once = path.addingPercentEncoding(withAllowedCharacters: uriAllowed) ?? path
let twice = once.addingPercentEncoding(withAllowedCharacters: uriAllowed) ?? once
var components = URLComponents(url: serverURL, resolvingAgainstBaseURL: false)!
components.path = "/api/image_proxy"
components.queryItems = [
URLQueryItem(name: "path", value: path),
URLQueryItem(name: "size", value: String(size))
]
components.path = "/imageproxy"
// Build the query manually so URLComponents doesn't re-encode the already-encoded value
var queryParts = ["path=\(twice)", "size=\(size)"]
if let provider, !provider.isEmpty {
let encProvider = provider.addingPercentEncoding(withAllowedCharacters: uriAllowed) ?? provider
queryParts.append("provider=\(encProvider)")
}
components.percentEncodedQuery = queryParts.joined(separator: "&")
return components.url
}
// MARK: - Audio Streaming
/// Get stream URL for a queue item
func getStreamURL(queueId: String, queueItemId: String) async throws -> URL {
logger.debug("Getting stream URL for queue item \(queueItemId)")
// For local player, we might need to build the URL differently
if queueId == "local_player" {
// Direct stream URL from server
guard let serverURL = authManager.serverURL else {
throw MAWebSocketClient.ClientError.serverError("No server URL configured")
}
var components = URLComponents(url: serverURL, resolvingAgainstBaseURL: false)!
components.path = "/api/stream/\(queueId)/\(queueItemId)"
guard let streamURL = components.url else {
throw MAWebSocketClient.ClientError.serverError("Failed to build stream URL")
}
return streamURL
}
let response = try await webSocketClient.sendCommand(
"player_queues/cmd/get_stream_url",
args: [
"queue_id": queueId,
"queue_item_id": queueItemId
]
)
guard let result = response.result else {
throw MAWebSocketClient.ClientError.serverError("No result in stream URL response")
}
// Try to extract URL from response
if let urlString = result.value as? String {
// Handle relative URL
if urlString.starts(with: "/") {
guard let serverURL = authManager.serverURL else {
throw MAWebSocketClient.ClientError.serverError("No server URL configured")
}
var components = URLComponents(url: serverURL, resolvingAgainstBaseURL: false)!
components.path = urlString
guard let fullURL = components.url else {
throw MAWebSocketClient.ClientError.serverError("Failed to build stream URL")
}
return fullURL
}
// Handle absolute URL
guard let url = URL(string: urlString) else {
throw MAWebSocketClient.ClientError.serverError("Invalid stream URL format: \(urlString)")
}
return url
}
throw MAWebSocketClient.ClientError.serverError("Invalid stream URL format in response")
}
}