feat(*): subsonic client with avfoundation playback and apple music ui
This commit is contained in:
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user