Version one on the App Store

This commit is contained in:
2026-04-05 19:44:30 +02:00
parent c780be089d
commit 3ebf1763ed
26 changed files with 2088 additions and 842 deletions
+293 -39
View File
@@ -14,12 +14,18 @@ struct MAPlayer: Codable, Identifiable, Hashable {
let name: String
let state: PlayerState
let currentItem: MAQueueItem?
let volume: Int
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
@@ -28,6 +34,34 @@ struct MAPlayer: Codable, Identifiable, Hashable {
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)) ?? []
}
}
@@ -46,9 +80,9 @@ struct MAQueueItem: Codable, Identifiable, Hashable {
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"
@@ -56,6 +90,23 @@ struct MAQueueItem: Codable, Identifiable, Hashable {
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 {
@@ -82,27 +133,121 @@ struct MAAudioFormat: Codable, Hashable {
}
}
// 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 mediaType: MediaType?
let artists: [MAArtist]?
let album: MAAlbum?
let imageUrl: String?
let metadata: MediaItemMetadata?
let duration: Int?
var id: String { uri }
var imageUrl: String? { metadata?.thumbImage?.path }
var imageProvider: String? { metadata?.thumbImage?.provider }
enum CodingKeys: String, CodingKey {
case uri
case name
case uri, name, duration, artists, album, metadata
case mediaType = "media_type"
case artists
case album
case imageUrl = "image"
case duration
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) {
self.uri = uri; self.name = name; self.mediaType = mediaType
self.artists = artists; self.album = album; self.duration = duration
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)
// 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)
}
}
@@ -112,23 +257,63 @@ enum MediaType: String, Codable {
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 imageUrl: String?
let metadata: MediaItemMetadata?
let sortName: String?
let musicbrainzId: String?
var id: String { uri }
var imageUrl: String? { metadata?.thumbImage?.path }
var imageProvider: String? { metadata?.thumbImage?.provider }
enum CodingKeys: String, CodingKey {
case uri
case name
case imageUrl = "image"
case uri, name, metadata
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) {
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
}
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)
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)
}
}
@@ -136,17 +321,50 @@ struct MAAlbum: Codable, Identifiable, Hashable {
let uri: String
let name: String
let artists: [MAArtist]?
let imageUrl: String?
let metadata: MediaItemMetadata?
let year: Int?
var id: String { uri }
var imageUrl: String? { metadata?.thumbImage?.path }
var imageProvider: String? { metadata?.thumbImage?.provider }
enum CodingKeys: String, CodingKey {
case uri
case name
case artists
case imageUrl = "image"
case year
case uri, name, artists, metadata, year
case image // Direct image field
}
init(uri: String, name: String, artists: [MAArtist]? = nil, imageUrl: String? = nil, imageProvider: String? = nil, year: Int? = nil) {
self.uri = uri; self.name = name; self.artists = artists; self.year = year
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)
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)
}
}
@@ -154,18 +372,54 @@ struct MAPlaylist: Codable, Identifiable, Hashable {
let uri: String
let name: String
let owner: String?
let imageUrl: 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
case name
case owner
case imageUrl = "image"
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?
enum CodingKeys: String, CodingKey {
case queueId = "queue_id"
case currentItem = "current_item"
case currentIndex = "current_index"
}
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)
}
}
// MARK: - WebSocket Protocol Models
@@ -185,14 +439,14 @@ struct MACommand: Encodable {
struct MAResponse: Decodable {
let messageId: String?
let result: AnyCodable?
let errorCode: String?
let errorMessage: String?
let errorCode: Int?
let details: String?
enum CodingKeys: String, CodingKey {
case messageId = "message_id"
case result
case errorCode = "error_code"
case errorMessage = "error"
case details
}
}