Files
MobileMusicAssistant/Mobile Music Assistant/ServicesMAService.swift
T

621 lines
23 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// 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]
)
}
/// 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 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 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..<sepRange.lowerBound])
guard !provider.isEmpty else { return nil }
let remainder = String(uri[sepRange.upperBound...])
guard !remainder.isEmpty else { return nil }
// remainder is "media_type/item_id" or just "item_id"
if let slashIdx = remainder.firstIndex(of: "/") {
let itemId = String(remainder[remainder.index(after: slashIdx)...])
guard !itemId.isEmpty else { return nil }
return (provider, itemId)
} else {
return (provider, remainder)
}
}
// 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 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 = "/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
}
}