// // MAModels.swift // Mobile Music Assistant // // Created by Sven Hanold on 26.03.26. // import Foundation // MARK: - Player Models struct MAPlayer: Codable, Identifiable, Hashable { let playerId: String let name: String let state: PlayerState let currentItem: MAQueueItem? let volume: Int? let powered: Bool let available: Bool /// ID of the sync leader this player follows. Empty if not synced or if this player IS the leader. let syncLeader: String /// IDs of players synced to this one. Non-empty only on the sync leader. let groupChilds: [String] var id: String { playerId } var isGroupLeader: Bool { !groupChilds.isEmpty } var isSyncMember: Bool { !syncLeader.isEmpty } enum CodingKeys: String, CodingKey { case playerId = "player_id" case name case state case currentItem = "current_item" case volume = "volume_level" case powered case available case syncLeader = "sync_leader" case groupChilds = "group_childs" } /// Resilient decoder: MA may omit or change fields across versions and event types. init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) playerId = try c.decode(String.self, forKey: .playerId) name = try c.decode(String.self, forKey: .name) // Accept any state string; fall back to .idle for unknown values (e.g. "buffering") let stateRaw = (try? c.decode(String.self, forKey: .state)) ?? "idle" state = PlayerState(rawValue: stateRaw) ?? .idle // Treat a failed sub-decode of currentItem as absent rather than throwing currentItem = try? c.decodeIfPresent(MAQueueItem.self, forKey: .currentItem) // volume_level may be Int or Double depending on MA version if let i = try? c.decode(Int.self, forKey: .volume) { volume = i } else if let d = try? c.decode(Double.self, forKey: .volume) { volume = Int(d) } else { volume = nil } // powered/available may be absent in some event payloads powered = (try? c.decode(Bool.self, forKey: .powered)) ?? false available = (try? c.decode(Bool.self, forKey: .available)) ?? true // Sync fields — absent in many event payloads, default to empty syncLeader = (try? c.decode(String.self, forKey: .syncLeader)) ?? "" groupChilds = (try? c.decode([String].self, forKey: .groupChilds)) ?? [] } } enum PlayerState: String, Codable { case playing case paused case idle case off } // MARK: - Queue Models struct MAQueueItem: Codable, Identifiable, Hashable { let queueItemId: String let mediaItem: MAMediaItem? let name: String let duration: Int? let streamDetails: MAStreamDetails? var id: String { queueItemId } enum CodingKeys: String, CodingKey { case queueItemId = "queue_item_id" case mediaItem = "media_item" case name case duration case streamDetails = "stream_details" } /// Resilient decoder: treat sub-decode failures as absent rather than throwing. init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) queueItemId = try c.decode(String.self, forKey: .queueItemId) name = try c.decode(String.self, forKey: .name) mediaItem = try? c.decodeIfPresent(MAMediaItem.self, forKey: .mediaItem) streamDetails = try? c.decodeIfPresent(MAStreamDetails.self, forKey: .streamDetails) // duration may be Int or Double if let i = try? c.decode(Int.self, forKey: .duration) { duration = i } else if let d = try? c.decode(Double.self, forKey: .duration) { duration = Int(d) } else { duration = nil } } } struct MAStreamDetails: Codable, Hashable { let providerId: String let itemId: String let audioFormat: MAAudioFormat? enum CodingKeys: String, CodingKey { case providerId = "provider" case itemId = "item_id" case audioFormat = "audio_format" } } struct MAAudioFormat: Codable, Hashable { let contentType: String let sampleRate: Int? let bitDepth: Int? enum CodingKeys: String, CodingKey { case contentType = "content_type" case sampleRate = "sample_rate" case bitDepth = "bit_depth" } } // MARK: - Media Item Image /// One entry in `metadata.images` as returned by Music Assistant. struct MediaItemImage: Codable, Hashable { let type: String? // e.g. "thumb", "fanart" let path: String // URL or server-local path let provider: String? // provider key, e.g. "spotify", "filesystem" let remotelyAccessible: Bool? enum CodingKeys: String, CodingKey { case type, path, provider case remotelyAccessible = "remotely_accessible" } } /// The `metadata` object on every MA MediaItem. struct MediaItemMetadata: Codable, Hashable { let images: [MediaItemImage]? let description: String? let cacheChecksum: String? enum CodingKeys: String, CodingKey { case images case description case cacheChecksum = "cache_checksum" } init(images: [MediaItemImage]? = nil, description: String? = nil, cacheChecksum: String? = nil) { self.images = images self.description = description self.cacheChecksum = cacheChecksum } } private extension MediaItemMetadata { /// First image of type "thumb", or the first image of any type. var thumbImage: MediaItemImage? { images?.first(where: { $0.type == "thumb" }) ?? images?.first } } // MARK: - Media Models struct MAMediaItem: Codable, Identifiable, Hashable { let uri: String let name: String let mediaType: MediaType? let artists: [MAArtist]? let album: MAAlbum? let metadata: MediaItemMetadata? let duration: Int? let favorite: Bool var id: String { uri } var imageUrl: String? { metadata?.thumbImage?.path } var imageProvider: String? { metadata?.thumbImage?.provider } enum CodingKeys: String, CodingKey { case uri, name, duration, artists, album, metadata, favorite case mediaType = "media_type" case image // Direct image field from search results } init(uri: String, name: String, mediaType: MediaType? = nil, artists: [MAArtist]? = nil, album: MAAlbum? = nil, imageUrl: String? = nil, duration: Int? = nil, favorite: Bool = false) { self.uri = uri; self.name = name; self.mediaType = mediaType self.artists = artists; self.album = album; self.duration = duration self.favorite = favorite self.metadata = imageUrl.map { MediaItemMetadata(images: [MediaItemImage(type: "thumb", path: $0, provider: nil, remotelyAccessible: nil)], cacheChecksum: nil) } } init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) uri = try c.decode(String.self, forKey: .uri) name = try c.decode(String.self, forKey: .name) favorite = (try? c.decode(Bool.self, forKey: .favorite)) ?? false // Media type is critical - decode it first let mediaTypeString = try? c.decodeIfPresent(String.self, forKey: .mediaType) mediaType = mediaTypeString.flatMap(MediaType.init) // Try to decode duration (can be Int or Double) if let i = try? c.decode(Int.self, forKey: .duration) { duration = i } else if let d = try? c.decode(Double.self, forKey: .duration) { duration = Int(d) } else { duration = nil } // Artists array - be very forgiving artists = try? c.decodeIfPresent([MAArtist].self, forKey: .artists) // Album - be very forgiving album = try? c.decodeIfPresent(MAAlbum.self, forKey: .album) // Try to decode metadata first var decodedMetadata = try? c.decodeIfPresent(MediaItemMetadata.self, forKey: .metadata) // If metadata is missing, try to get image from direct "image" field (search results) if decodedMetadata == nil { // Try to decode the image field - it can be null, missing, or an object if let imageObj = try? c.decodeIfPresent(MediaItemImage.self, forKey: .image) { decodedMetadata = MediaItemMetadata(images: [imageObj], cacheChecksum: nil) } } metadata = decodedMetadata } func encode(to encoder: Encoder) throws { var c = encoder.container(keyedBy: CodingKeys.self) try c.encode(uri, forKey: .uri) try c.encode(name, forKey: .name) try c.encodeIfPresent(mediaType?.rawValue, forKey: .mediaType) try c.encodeIfPresent(artists, forKey: .artists) try c.encodeIfPresent(album, forKey: .album) try c.encodeIfPresent(duration, forKey: .duration) try c.encodeIfPresent(metadata, forKey: .metadata) try c.encode(favorite, forKey: .favorite) } } enum MediaType: String, Codable { case track case album case artist case playlist case radio case audiobook case podcast case podcastEpisode = "podcast_episode" case unknown } struct MAArtist: Codable, Identifiable, Hashable { let uri: String let name: String let metadata: MediaItemMetadata? let sortName: String? let musicbrainzId: String? let favorite: Bool var id: String { uri } var imageUrl: String? { metadata?.thumbImage?.path } var imageProvider: String? { metadata?.thumbImage?.provider } enum CodingKeys: String, CodingKey { case uri, name, metadata, favorite case sortName = "sort_name" case musicbrainzId = "musicbrainz_id" case image // Direct image field } init(uri: String, name: String, imageUrl: String? = nil, imageProvider: String? = nil, sortName: String? = nil, musicbrainzId: String? = nil, favorite: Bool = false) { self.uri = uri; self.name = name self.metadata = imageUrl.map { MediaItemMetadata(images: [MediaItemImage(type: "thumb", path: $0, provider: imageProvider, remotelyAccessible: nil)], cacheChecksum: nil) } self.sortName = sortName; self.musicbrainzId = musicbrainzId self.favorite = favorite } init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) uri = try c.decode(String.self, forKey: .uri) name = try c.decode(String.self, forKey: .name) favorite = (try? c.decode(Bool.self, forKey: .favorite)) ?? false sortName = try? c.decodeIfPresent(String.self, forKey: .sortName) musicbrainzId = try? c.decodeIfPresent(String.self, forKey: .musicbrainzId) // Try to decode metadata first var decodedMetadata = try? c.decodeIfPresent(MediaItemMetadata.self, forKey: .metadata) // If metadata is missing, try to get image from direct "image" field if decodedMetadata == nil { if let imageObj = try? c.decodeIfPresent(MediaItemImage.self, forKey: .image) { decodedMetadata = MediaItemMetadata(images: [imageObj], cacheChecksum: nil) } } metadata = decodedMetadata } func encode(to encoder: Encoder) throws { var c = encoder.container(keyedBy: CodingKeys.self) try c.encode(uri, forKey: .uri) try c.encode(name, forKey: .name) try c.encodeIfPresent(sortName, forKey: .sortName) try c.encodeIfPresent(musicbrainzId, forKey: .musicbrainzId) try c.encodeIfPresent(metadata, forKey: .metadata) try c.encode(favorite, forKey: .favorite) } } struct MAAlbum: Codable, Identifiable, Hashable { let uri: String let name: String let artists: [MAArtist]? let metadata: MediaItemMetadata? let year: Int? let favorite: Bool var id: String { uri } var imageUrl: String? { metadata?.thumbImage?.path } var imageProvider: String? { metadata?.thumbImage?.provider } enum CodingKeys: String, CodingKey { case uri, name, artists, metadata, year, favorite case image // Direct image field } init(uri: String, name: String, artists: [MAArtist]? = nil, imageUrl: String? = nil, imageProvider: String? = nil, year: Int? = nil, favorite: Bool = false) { self.uri = uri; self.name = name; self.artists = artists; self.year = year self.favorite = favorite self.metadata = imageUrl.map { MediaItemMetadata(images: [MediaItemImage(type: "thumb", path: $0, provider: imageProvider, remotelyAccessible: nil)], cacheChecksum: nil) } } init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) uri = try c.decode(String.self, forKey: .uri) name = try c.decode(String.self, forKey: .name) favorite = (try? c.decode(Bool.self, forKey: .favorite)) ?? false artists = try? c.decodeIfPresent([MAArtist].self, forKey: .artists) year = try? c.decodeIfPresent(Int.self, forKey: .year) // Try to decode metadata first var decodedMetadata = try? c.decodeIfPresent(MediaItemMetadata.self, forKey: .metadata) // If metadata is missing, try to get image from direct "image" field if decodedMetadata == nil { if let imageObj = try? c.decodeIfPresent(MediaItemImage.self, forKey: .image) { decodedMetadata = MediaItemMetadata(images: [imageObj], cacheChecksum: nil) } } metadata = decodedMetadata } func encode(to encoder: Encoder) throws { var c = encoder.container(keyedBy: CodingKeys.self) try c.encode(uri, forKey: .uri) try c.encode(name, forKey: .name) try c.encodeIfPresent(artists, forKey: .artists) try c.encodeIfPresent(year, forKey: .year) try c.encodeIfPresent(metadata, forKey: .metadata) try c.encode(favorite, forKey: .favorite) } } struct MAPlaylist: Codable, Identifiable, Hashable { let uri: String let name: String let owner: String? let metadata: MediaItemMetadata? let isEditable: Bool var id: String { uri } var imageUrl: String? { metadata?.thumbImage?.path } var imageProvider: String? { metadata?.thumbImage?.provider } enum CodingKeys: String, CodingKey { case uri, name, owner, metadata case isEditable = "is_editable" } init(uri: String, name: String, owner: String? = nil, imageUrl: String? = nil, isEditable: Bool = false) { self.uri = uri; self.name = name; self.owner = owner; self.isEditable = isEditable self.metadata = imageUrl.map { MediaItemMetadata(images: [MediaItemImage(type: "thumb", path: $0, provider: nil, remotelyAccessible: nil)], cacheChecksum: nil) } } init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) uri = try c.decode(String.self, forKey: .uri) name = try c.decode(String.self, forKey: .name) owner = try? c.decodeIfPresent(String.self, forKey: .owner) isEditable = (try? c.decode(Bool.self, forKey: .isEditable)) ?? false metadata = try? c.decodeIfPresent(MediaItemMetadata.self, forKey: .metadata) } } // MARK: - Player Queue State /// Represents the state of a player's queue, including the currently playing item. /// Populated via `player_queues/get` and `queue_updated` events. struct MAPlayerQueue: Codable { let queueId: String let currentItem: MAQueueItem? let currentIndex: Int? /// Seconds elapsed in current track (at the time of last update). let elapsedTime: Double? /// Unix timestamp when `elapsedTime` was last set by the server. let elapsedTimeLastUpdated: Double? enum CodingKeys: String, CodingKey { case queueId = "queue_id" case currentItem = "current_item" case currentIndex = "current_index" case elapsedTime = "elapsed_time" case elapsedTimeLastUpdated = "elapsed_time_last_updated" } init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) queueId = try c.decode(String.self, forKey: .queueId) currentItem = try? c.decodeIfPresent(MAQueueItem.self, forKey: .currentItem) currentIndex = try? c.decodeIfPresent(Int.self, forKey: .currentIndex) elapsedTime = try? c.decodeIfPresent(Double.self, forKey: .elapsedTime) elapsedTimeLastUpdated = try? c.decodeIfPresent(Double.self, forKey: .elapsedTimeLastUpdated) } } // MARK: - WebSocket Protocol Models struct MACommand: Encodable { let messageId: String let command: String let args: [String: AnyCodable]? enum CodingKeys: String, CodingKey { case messageId = "message_id" case command case args } } struct MAResponse: Decodable { let messageId: String? let result: AnyCodable? let errorCode: Int? let details: String? enum CodingKeys: String, CodingKey { case messageId = "message_id" case result case errorCode = "error_code" case details } } struct MAEvent: Decodable { let event: String let data: AnyCodable? } // MARK: - Auth Models struct MALoginRequest: Encodable { let username: String let password: String } struct MALoginResponse: Decodable { let accessToken: String enum CodingKeys: String, CodingKey { case accessToken = "access_token" } } // MARK: - AnyCodable Helper /// Helper to handle dynamic JSON values struct AnyCodable: Codable, Hashable { let value: Any init(_ value: Any) { self.value = value } init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() if container.decodeNil() { value = NSNull() } else if let bool = try? container.decode(Bool.self) { value = bool } else if let int = try? container.decode(Int.self) { value = int } else if let double = try? container.decode(Double.self) { value = double } else if let string = try? container.decode(String.self) { value = string } else if let array = try? container.decode([AnyCodable].self) { value = array.map { $0.value } } else if let dict = try? container.decode([String: AnyCodable].self) { value = dict.mapValues { $0.value } } else { throw DecodingError.dataCorruptedError( in: container, debugDescription: "Unable to decode value" ) } } func encode(to encoder: Encoder) throws { var container = encoder.singleValueContainer() switch value { case is NSNull: try container.encodeNil() case let bool as Bool: try container.encode(bool) case let int as Int: try container.encode(int) case let double as Double: try container.encode(double) case let string as String: try container.encode(string) case let array as [Any]: try container.encode(array.map { AnyCodable($0) }) case let dict as [String: Any]: try container.encode(dict.mapValues { AnyCodable($0) }) default: throw EncodingError.invalidValue( value, EncodingError.Context( codingPath: container.codingPath, debugDescription: "Unable to encode value" ) ) } } static func == (lhs: AnyCodable, rhs: AnyCodable) -> Bool { // Simple comparison - extend as needed return String(describing: lhs.value) == String(describing: rhs.value) } func hash(into hasher: inout Hasher) { hasher.combine(String(describing: value)) } } extension AnyCodable { /// Decode the wrapped value to a specific type func decode(as type: T.Type) throws -> T { let data = try JSONEncoder().encode(self) return try JSONDecoder().decode(T.self, from: data) } }