feat(*): subsonic client with avfoundation playback and apple music ui

This commit is contained in:
hh committed 2026-09-22 06:44:44 +02:00
commit e5bb559d7f
93 files changed
+7517

No files matched your search

@@ -0,0 +1,148 @@
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[..<dot] + "." + fraction + rest, strategy: Date.ISO8601FormatStyle(includingFractionalSeconds: true))
}
}
extension AudioFormat {
init?(song: SongJSON) {
guard let codec = Codec(suffix: song.suffix) else { return nil }
self.init(
codec: codec,
bitrateKbps: song.bitRate,
sampleRate: song.samplingRate,
bitDepth: song.bitDepth.flatMap { $0 > 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)
}
}