332 lines
11 KiB
Swift
332 lines
11 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
|
|
|
|
// 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")
|
|
}
|
|
}
|