149 lines
5.1 KiB
Swift
149 lines
5.1 KiB
Swift
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)
|
|
}
|
|
}
|