451 lines
16 KiB
Swift
451 lines
16 KiB
Swift
//
|
||
// 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]
|
||
)
|
||
}
|
||
|
||
/// 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))
|
||
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 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, fromIndex: Int, toIndex: Int) async throws {
|
||
logger.debug("Moving queue item from \(fromIndex) to \(toIndex)")
|
||
_ = try await webSocketClient.sendCommand(
|
||
"player_queues/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/library_items",
|
||
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/library_items",
|
||
args: [
|
||
"limit": limit,
|
||
"offset": offset
|
||
],
|
||
resultType: [MAAlbum].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 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/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)'")
|
||
|
||
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 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 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 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
|
||
}
|
||
|
||
}
|