Version one on the App Store
This commit is contained in:
@@ -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 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 = "/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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user