// // MAService.swift // Mobile Music Assistant // // Created by Sven Hanold on 26.03.26. // import Foundation import OSLog private let logger = Logger(subsystem: "com.musicassistant.mobile", category: "Service") /// High-level service for Music Assistant API @Observable final class MAService { // MARK: - Properties let authManager: MAAuthManager let webSocketClient: MAWebSocketClient let playerManager: MAPlayerManager 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 init() { // Initialize simple properties first self.authManager = MAAuthManager() self.webSocketClient = MAWebSocketClient() // Create a temporary service reference let tempPlayerManager = MAPlayerManager(service: nil) let tempLibraryManager = MALibraryManager(service: nil) self.playerManager = tempPlayerManager self.libraryManager = tempLibraryManager // Now set the service reference tempPlayerManager.setService(self) tempLibraryManager.setService(self) } // MARK: - Connection /// Connect to Music Assistant server using saved credentials func connectWithSavedCredentials() async throws { guard authManager.isAuthenticated, let serverURL = authManager.serverURL, let token = authManager.currentToken else { throw MAAuthManager.AuthError.noStoredCredentials } try await connect(serverURL: serverURL, token: token) } /// Connect to server with explicit credentials func connect(serverURL: URL, token: String) async throws { logger.info("Connecting to Music Assistant") try await webSocketClient.connect(serverURL: serverURL, authToken: token) isConnected = true } /// Disconnect from server func disconnect() { logger.info("Disconnecting from Music Assistant") webSocketClient.disconnect() isConnected = false } // MARK: - Players /// Get all players func getPlayers() async throws -> [MAPlayer] { logger.debug("Fetching players") return try await webSocketClient.sendCommand( "players/all", resultType: [MAPlayer].self ) } /// Play on a player func play(playerId: String) async throws { logger.debug("Playing on player \(playerId)") _ = try await webSocketClient.sendCommand( "players/cmd/play", args: ["player_id": playerId] ) } /// Pause a player func pause(playerId: String) async throws { logger.debug("Pausing player \(playerId)") _ = try await webSocketClient.sendCommand( "players/cmd/pause", args: ["player_id": playerId] ) } /// Stop a player func stop(playerId: String) async throws { logger.debug("Stopping player \(playerId)") _ = try await webSocketClient.sendCommand( "players/cmd/stop", args: ["player_id": playerId] ) } /// Next track func nextTrack(playerId: String) async throws { logger.debug("Next track on player \(playerId)") _ = try await webSocketClient.sendCommand( "players/cmd/next", args: ["player_id": playerId] ) } /// Previous track func previousTrack(playerId: String) async throws { logger.debug("Previous track on player \(playerId)") _ = try await webSocketClient.sendCommand( "players/cmd/previous", args: ["player_id": playerId] ) } /// Group a player under a target leader (MA 2.x: players/cmd/group) func syncPlayer(playerId: String, targetPlayerId: String) async throws { logger.debug("Grouping player \(playerId) under \(targetPlayerId)") _ = try await webSocketClient.sendCommand( "players/cmd/group", args: ["player_id": playerId, "target_player": targetPlayerId] ) } /// Remove a player from its group (MA 2.x: players/cmd/ungroup) func unsyncPlayer(playerId: String) async throws { logger.debug("Ungrouping player \(playerId)") _ = try await webSocketClient.sendCommand( "players/cmd/ungroup", args: ["player_id": playerId] ) } /// Seek to a position in the current track (seconds) func seek(playerId: String, position: Double) async throws { let seconds = Int(max(0, position)) logger.debug("Seeking to \(seconds)s on player \(playerId)") let response = try await webSocketClient.sendCommand( "players/cmd/seek", args: [ "player_id": playerId, "position": seconds ] ) logger.debug("Seek response: errorCode=\(String(describing: response.errorCode)) details=\(String(describing: response.details))") } /// Set group master volume (0-100) — scales all members proportionally func setGroupVolume(playerId: String, level: Int) async throws { let clampedLevel = max(0, min(100, level)) logger.debug("Setting group volume to \(clampedLevel) on leader \(playerId)") _ = try await webSocketClient.sendCommand( "players/cmd/group_volume", args: ["player_id": playerId, "volume_level": clampedLevel] ) } /// Set volume (0-100) func setVolume(playerId: String, level: Int) async throws { let clampedLevel = max(0, min(100, level)) logger.debug("Setting volume to \(clampedLevel) on player \(playerId)") _ = try await webSocketClient.sendCommand( "players/cmd/volume_set", args: [ "player_id": playerId, "volume_level": clampedLevel ] ) } // MARK: - 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( "player_queues/items", args: ["queue_id": playerId], resultType: [MAQueueItem].self ) } /// Play media item func playMedia(playerId: String, uri: String) async throws { logger.debug("Playing media \(uri) on player \(playerId)") _ = try await webSocketClient.sendCommand( "player_queues/play_media", args: [ "queue_id": playerId, "media": [uri] ] ) } /// Play a list of media items (replaces current queue) func playMedia(playerId: String, uris: [String]) async throws { logger.debug("Playing \(uris.count) tracks on player \(playerId)") _ = try await webSocketClient.sendCommand( "player_queues/play_media", args: [ "queue_id": playerId, "media": uris ] ) } /// Add media item to the end of a player's queue func enqueueMedia(playerId: String, uri: String) async throws { logger.debug("Enqueuing media \(uri) on player \(playerId)") _ = try await webSocketClient.sendCommand( "player_queues/play_media", args: [ "queue_id": playerId, "media": [uri], "option": "add" ] ) } /// Play from queue index func playIndex(playerId: String, index: Int) async throws { logger.debug("Playing index \(index) on player \(playerId)") _ = try await webSocketClient.sendCommand( "player_queues/play_index", args: [ "queue_id": playerId, "index": index ] ) } /// Move queue item func moveQueueItem(playerId: String, queueItemId: String, posShift: Int) async throws { logger.debug("Moving queue item \(queueItemId) by \(posShift) positions") _ = try await webSocketClient.sendCommand( "player_queues/move_item", args: [ "queue_id": playerId, "queue_item_id": queueItemId, "pos_shift": posShift ] ) } func setQueueShuffle(playerId: String, enabled: Bool) async throws { logger.debug("Setting shuffle \(enabled) on queue \(playerId)") _ = try await webSocketClient.sendCommand( "player_queues/shuffle", args: [ "queue_id": playerId, "shuffle_enabled": enabled ] ) } func setQueueRepeatMode(playerId: String, mode: RepeatMode) async throws { logger.debug("Setting repeat mode \(mode.rawValue) on queue \(playerId)") _ = try await webSocketClient.sendCommand( "player_queues/repeat", args: [ "queue_id": playerId, "repeat_mode": mode.rawValue ] ) } func clearQueue(playerId: String) async throws { logger.debug("Clearing queue \(playerId)") _ = try await webSocketClient.sendCommand( "player_queues/clear", args: ["queue_id": playerId] ) } // MARK: - Library /// Get artists (with pagination) 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/library_items", args: [ "limit": limit, "offset": offset ], resultType: [MAArtist].self ) } /// Get album artists (with pagination) — only artists that appear as primary album artists func getAlbumArtists(limit: Int = 50, offset: Int = 0) async throws -> [MAArtist] { logger.debug("Fetching album artists (limit: \(limit), offset: \(offset))") return try await webSocketClient.sendCommand( "music/artists/library_items", args: [ "limit": limit, "offset": offset, "album_artists_only": true ], resultType: [MAArtist].self ) } /// Get albums (with pagination) 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/library_items", args: [ "limit": limit, "offset": offset ], resultType: [MAAlbum].self ) } /// Get tracks from the library (with optional pagination and favorite filter) func getTracks(favorite: Bool? = nil, limit: Int = 50, offset: Int = 0) async throws -> [MAMediaItem] { logger.debug("Fetching tracks (limit: \(limit), offset: \(offset), favorite: \(String(describing: favorite)))") var args: [String: Any] = ["limit": limit, "offset": offset] if let favorite { args["favorite"] = favorite } return try await webSocketClient.sendCommand( "music/tracks/library_items", args: args, resultType: [MAMediaItem].self ) } /// Get genres func getGenres() async throws -> [MAGenre] { logger.debug("Fetching genres") return try await webSocketClient.sendCommand( "music/genres/library_items", resultType: [MAGenre].self ) } /// Get artists belonging to one or more genre IDs. /// Uses the native `genre` parameter of music/artists/library_items (int | list[int]). func getArtistsByGenre(genreIds: [Int], limit: Int = 500) async throws -> [MAMediaItem] { logger.debug("Fetching artists for genre ids \(genreIds)") return try await webSocketClient.sendCommand( "music/artists/library_items", args: ["limit": limit, "genre": genreIds as [Any]], resultType: [MAMediaItem].self ) } /// Get albums belonging to one or more genre IDs. func getAlbumsByGenre(genreIds: [Int], limit: Int = 500) async throws -> [MAMediaItem] { logger.debug("Fetching albums for genre ids \(genreIds)") return try await webSocketClient.sendCommand( "music/albums/library_items", args: ["limit": limit, "genre": genreIds as [Any]], resultType: [MAMediaItem].self ) } /// 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/library_items", resultType: [MAPlaylist].self ) } /// Get all podcasts in the library func getPodcasts() async throws -> [MAPodcast] { logger.debug("Fetching podcasts") return try await webSocketClient.sendCommand( "music/podcasts/library_items", resultType: [MAPodcast].self ) } /// Get all episodes for a podcast func getPodcastEpisodes(podcastUri: String) async throws -> [MAMediaItem] { logger.debug("Fetching episodes for podcast \(podcastUri)") guard let (provider, itemId) = parseMAUri(podcastUri) else { throw MAWebSocketClient.ClientError.serverError("Invalid podcast URI: \(podcastUri)") } return try await webSocketClient.sendCommand( "music/podcasts/podcast_episodes", args: [ "item_id": itemId, "provider_instance_id_or_domain": provider ], resultType: [MAMediaItem].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 playlist tracks func getPlaylistTracks(playlistUri: String) async throws -> [MAMediaItem] { logger.debug("Fetching tracks for playlist \(playlistUri)") guard let (provider, itemId) = parseMAUri(playlistUri) else { throw MAWebSocketClient.ClientError.serverError("Invalid playlist URI: \(playlistUri)") } return try await webSocketClient.sendCommand( "music/playlists/playlist_tracks", args: [ "item_id": itemId, "provider_instance_id_or_domain": provider ], resultType: [MAMediaItem].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/albums/album_tracks", args: [ "item_id": itemId, "provider_instance_id_or_domain": provider ], resultType: [MAMediaItem].self ) } // MARK: - Favorites /// Add an item to favorites by URI func addFavorite(uri: String) async throws { logger.debug("Adding favorite: \(uri)") _ = try await webSocketClient.sendCommand( "music/favorites/add_item", args: ["item": uri] ) } /// Remove an item from favorites. /// The server expects media_type (e.g. "artist") and library_item_id (e.g. "123"). /// These are extracted from the URI format: library://artist/123 func removeFavorite(uri: String) async throws { logger.debug("Removing favorite: \(uri)") guard let url = URL(string: uri), let host = url.host else { throw MAWebSocketClient.ClientError.serverError("Invalid URI for favorite removal: \(uri)") } let itemId = url.path.isEmpty ? host : String(url.path.dropFirst()) _ = try await webSocketClient.sendCommand( "music/favorites/remove_item", args: [ "media_type": host, "library_item_id": itemId ] ) } /// Search library func search(query: String, mediaTypes: [MediaType]? = nil) async throws -> [MAMediaItem] { logger.debug("🔍 Searching for '\(query)'") var args: [String: Any] = ["search_query": query] if let mediaTypes { args["media_types"] = mediaTypes.map { $0.rawValue } } // 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 podcasts: [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) } if let podcasts = searchResults.podcasts { allItems.append(contentsOf: podcasts) } 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 /// /// Uses manual string parsing because some schemes contain underscores /// (e.g. "apple_music") which are invalid per RFC 2396, causing Swift's /// URL(string:) initialiser to return nil for those URIs. private func parseMAUri(_ uri: String) -> (provider: String, itemId: String)? { guard let sepRange = uri.range(of: "://") else { return nil } let provider = String(uri[uri.startIndex.. URL? { guard let path, !path.isEmpty, let serverURL = authManager.serverURL else { return nil } // Replicate JS encodeURIComponent (encodes everything except A–Z a–z 0–9 - _ . ! ~ * ' ( )) 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 = "/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 } }