675 lines
23 KiB
Swift
675 lines
23 KiB
Swift
//
|
|
// 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
|
|
}
|
|
|
|
/// Maps a library item to its backing provider instance.
|
|
struct MAProviderMapping: Codable, Hashable {
|
|
let providerDomain: String
|
|
let itemId: String
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case providerDomain = "provider_domain"
|
|
case itemId = "item_id"
|
|
}
|
|
}
|
|
|
|
struct MAArtist: Codable, Identifiable, Hashable {
|
|
let uri: String
|
|
let name: String
|
|
let metadata: MediaItemMetadata?
|
|
let sortName: String?
|
|
let musicbrainzId: String?
|
|
let favorite: Bool
|
|
/// All provider instances this artist is mapped to (from MA's provider_mappings field).
|
|
let providerMappings: [MAProviderMapping]
|
|
|
|
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 providerMappings = "provider_mappings"
|
|
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
|
|
self.providerMappings = []
|
|
}
|
|
|
|
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)
|
|
providerMappings = (try? c.decodeIfPresent([MAProviderMapping].self, forKey: .providerMappings)) ?? []
|
|
|
|
// 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.encode(providerMappings, forKey: .providerMappings)
|
|
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: - Podcast
|
|
|
|
struct MAPodcast: Codable, Identifiable, Hashable {
|
|
let uri: String
|
|
let name: String
|
|
let publisher: String?
|
|
let totalEpisodes: Int?
|
|
let metadata: MediaItemMetadata?
|
|
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, publisher, metadata, favorite
|
|
case totalEpisodes = "total_episodes"
|
|
}
|
|
|
|
init(uri: String, name: String, publisher: String? = nil, totalEpisodes: Int? = nil, imageUrl: String? = nil, favorite: Bool = false) {
|
|
self.uri = uri
|
|
self.name = name
|
|
self.publisher = publisher
|
|
self.totalEpisodes = totalEpisodes
|
|
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)
|
|
publisher = try? c.decodeIfPresent(String.self, forKey: .publisher)
|
|
totalEpisodes = try? c.decodeIfPresent(Int.self, forKey: .totalEpisodes)
|
|
favorite = (try? c.decode(Bool.self, forKey: .favorite)) ?? false
|
|
metadata = try? c.decodeIfPresent(MediaItemMetadata.self, forKey: .metadata)
|
|
}
|
|
}
|
|
|
|
// MARK: - Genre
|
|
|
|
struct MAGenre: Codable, Identifiable, Hashable {
|
|
let uri: String
|
|
let name: String
|
|
let metadata: MediaItemMetadata?
|
|
|
|
var id: String { uri }
|
|
var imageUrl: String? { metadata?.thumbImage?.path }
|
|
var imageProvider: String? { metadata?.thumbImage?.provider }
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case uri, name, metadata
|
|
}
|
|
|
|
init(uri: String, name: String, metadata: MediaItemMetadata? = nil) {
|
|
self.uri = uri
|
|
self.name = name
|
|
self.metadata = metadata
|
|
}
|
|
|
|
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)
|
|
metadata = try? c.decodeIfPresent(MediaItemMetadata.self, forKey: .metadata)
|
|
}
|
|
}
|
|
|
|
// MARK: - Repeat Mode
|
|
|
|
enum RepeatMode: String, Codable, CaseIterable {
|
|
case off
|
|
case one
|
|
case all
|
|
}
|
|
|
|
// 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?
|
|
let shuffleEnabled: Bool
|
|
let repeatMode: RepeatMode
|
|
|
|
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"
|
|
case shuffleEnabled = "shuffle_enabled"
|
|
case repeatMode = "repeat_mode"
|
|
}
|
|
|
|
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)
|
|
shuffleEnabled = (try? c.decode(Bool.self, forKey: .shuffleEnabled)) ?? false
|
|
repeatMode = (try? c.decode(RepeatMode.self, forKey: .repeatMode)) ?? .off
|
|
}
|
|
}
|
|
|
|
// 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<T: Decodable>(as type: T.Type) throws -> T {
|
|
let data = try JSONEncoder().encode(self)
|
|
return try JSONDecoder().decode(T.self, from: data)
|
|
}
|
|
}
|