// // 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 // 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", 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] ) } /// 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 player queue 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/cmd/play_media", args: [ "queue_id": playerId, "media": [uri] ] ) } /// 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/cmd/play_index", args: [ "queue_id": playerId, "index": index ] ) } /// Move queue item 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", args: [ "queue_id": playerId, "queue_item_id": fromIndex, "pos_shift": toIndex - fromIndex ] ) } // 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", args: [ "limit": limit, "offset": offset ], 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", args: [ "limit": limit, "offset": offset ], resultType: [MAAlbum].self ) } /// Get playlists func getPlaylists() async throws -> [MAPlaylist] { logger.debug("Fetching playlists") return try await webSocketClient.sendCommand( "music/playlists", resultType: [MAPlaylist].self ) } /// Get album tracks func getAlbumTracks(albumUri: String) async throws -> [MAMediaItem] { logger.debug("Fetching tracks for album \(albumUri)") return try await webSocketClient.sendCommand( "music/album_tracks", args: ["uri": albumUri], resultType: [MAMediaItem].self ) } /// Search library func search(query: String, mediaTypes: [MediaType]? = nil) async throws -> [MAMediaItem] { logger.debug("Searching for '\(query)'") var args: [String: Any] = ["search": query] if let mediaTypes { args["media_types"] = mediaTypes.map { $0.rawValue } } return try await webSocketClient.sendCommand( "music/search", args: args, resultType: [MAMediaItem].self ) } // MARK: - Image Proxy /// Build URL for image proxy func imageProxyURL(path: String, size: Int = 256) -> URL? { guard let serverURL = authManager.serverURL else { return nil } 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)) ] 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") } }