import Domain import Foundation enum SubsonicDate { static func parse(_ raw: String?) -> Date? { guard let raw else { return nil } guard let dot = raw.firstIndex(of: ".") else { return try? Date(raw, strategy: .iso8601) } let digits = raw[raw.index(after: dot)...].prefix { $0.isNumber } let rest = raw[raw.index(dot, offsetBy: 1 + digits.count)...] let fraction = String(digits.prefix(3)).padding(toLength: 3, withPad: "0", startingAt: 0) return try? Date(raw[.. 0 ? $0 : nil }, channels: song.channelCount ?? 2 ) } private var rank: [Int] { [isLossless ? 1 : 0, sampleRate ?? 0, bitDepth ?? 0, bitrateKbps ?? 0] } func isBetter(than other: AudioFormat?) -> Bool { guard let other else { return true } return other.rank.lexicographicallyPrecedes(rank) } } extension AudioFormat.Codec { init?(suffix: String?) { switch suffix?.lowercased() { case "flac": self = .flac case "alac": self = .alac case "wav": self = .wav case "mp3": self = .mp3 case "aac", "m4a": self = .aac case "opus": self = .opus case "ogg": self = .vorbis default: return nil } } } struct SubsonicMapper: Sendable { let source: SourceID func id(_ remote: String) -> EntityID { EntityID(source: source, remote: remote) } func artwork(_ remote: String?) -> EntityID? { guard let remote, !remote.isEmpty else { return nil } return id(remote) } func artist(_ json: ArtistJSON) -> Artist { Artist(id: id(json.id), name: json.name, sortName: json.sortName, artwork: artwork(json.coverArt), albumCount: json.albumCount ?? 0) } func track(_ json: SongJSON) -> Track { Track( id: id(json.id), title: json.title, albumID: json.albumId.map(id), albumTitle: json.album ?? "", artistID: json.artistId.map(id), artistName: json.artist ?? "", discNumber: json.discNumber ?? 1, trackNumber: json.track ?? 0, duration: json.duration ?? 0, year: json.year, genre: json.genre, artwork: artwork(json.coverArt), isExplicit: json.explicitStatus == "explicit", formats: AudioFormat(song: json).map { [$0] } ?? [] ) } func album(_ json: AlbumJSON, bestFormat: AudioFormat?) -> Album { Album( id: id(json.id), title: json.name, artistID: json.artistId.map(id), artistName: json.artist ?? "", year: json.year, genre: json.genre, artwork: artwork(json.coverArt), trackCount: json.songCount ?? 0, duration: json.duration ?? 0, addedAt: SubsonicDate.parse(json.created) ?? .distantPast, bestFormat: bestFormat ) } func playlist(_ json: PlaylistJSON) -> (Playlist, [PlaylistEntry]) { let playlist = Playlist( id: id(json.id), name: json.name, summary: json.comment ?? "", artwork: artwork(json.coverArt), trackCount: json.songCount ?? json.entry?.count ?? 0, duration: json.duration ?? 0, updatedAt: SubsonicDate.parse(json.changed) ?? .distantPast ) let entries = (json.entry ?? []).enumerated().map { index, song in PlaylistEntry(id: "\(json.id):\(index)", track: id(song.id)) } return (playlist, entries) } func station(_ json: StationJSON) -> RadioStation? { guard let url = URL(string: json.streamUrl), url.scheme != nil else { return nil } return RadioStation( id: id(json.id), name: json.name, tagline: json.homePageUrl ?? "", streamURL: url, artwork: artwork(json.coverArt) ) } func annotation(_ song: SongJSON) -> Annotation? { let favoritedAt = SubsonicDate.parse(song.starred) let playCount = song.playCount ?? 0 guard favoritedAt != nil || playCount > 0 else { return nil } return Annotation(favoritedAt: favoritedAt, playCount: playCount, lastPlayedAt: SubsonicDate.parse(song.played)) } func lyrics(_ list: [StructuredLyricsJSON]) -> Lyrics? { guard let entry = list.first(where: \.synced) ?? list.first else { return nil } let offset = entry.offset ?? 0 let lines = (entry.line ?? []).map { line in LyricLine(time: entry.synced ? Double((line.start ?? 0) + offset) / 1000 : 0, text: line.value) } guard !lines.isEmpty else { return nil } return Lyrics(lines: lines, isSynced: entry.synced) } }