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,100 @@
import CryptoKit
import Foundation
public enum SubsonicError: Error, Equatable, Sendable {
case server(code: Int, message: String)
case http(Int)
case malformed
}
typealias SubsonicTransport = @Sendable (URLRequest) async throws -> (Data, URLResponse)
struct SubsonicClient: Sendable {
let baseURL: URL
let username: String
let password: String
let transport: SubsonicTransport
init(baseURL: URL, username: String, password: String, transport: @escaping SubsonicTransport) {
self.baseURL = baseURL
self.username = username
self.password = password
self.transport = transport
}
init(baseURL: URL, username: String, password: String, session: URLSession) {
self.init(baseURL: baseURL, username: username, password: password) { request in
try await session.data(for: request)
}
}
func url(_ method: String, _ params: [String: String] = [:]) -> URL {
let endpoint = baseURL.appending(path: "rest/\(method).view")
var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false) ?? URLComponents()
components.queryItems = (params.sorted { $0.key < $1.key } + authParams()).map { URLQueryItem(name: $0.key, value: $0.value) }
return components.url ?? endpoint
}
func call<Payload: Decodable>(_ method: String, _ params: [String: String] = [:], as _: Payload.Type) async throws -> Payload {
let request = URLRequest(url: url(method, params))
let (data, response) = try await transport(request)
if let http = response as? HTTPURLResponse, http.statusCode >= 400 {
throw SubsonicError.http(http.statusCode)
}
return try Self.decode(data, as: Payload.self)
}
func call(_ method: String, _ params: [String: String] = [:]) async throws {
_ = try await call(method, params, as: EmptyPayload.self)
}
static func decode<Payload: Decodable>(_ data: Data, as _: Payload.Type) throws -> Payload {
let decoder = JSONDecoder()
let status: Wrapper<EnvelopeStatus>
do {
status = try decoder.decode(Wrapper<EnvelopeStatus>.self, from: data)
} catch {
throw SubsonicError.malformed
}
if status.response.status != "ok" {
let error = status.response.error
throw SubsonicError.server(code: error?.code ?? 0, message: error?.message ?? "")
}
do {
return try decoder.decode(Wrapper<Payload>.self, from: data).response
} catch {
throw SubsonicError.malformed
}
}
private func authParams() -> [(key: String, value: String)] {
let salt = Self.salt()
let token = Insecure.MD5.hash(data: Data((password + salt).utf8)).map { String(format: "%02x", $0) }.joined()
return [("u", username), ("t", token), ("s", salt), ("v", "1.16.1"), ("c", "Amphlify"), ("f", "json")]
}
private static func salt() -> String {
let alphabet = Array("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
return String((0 ..< 12).compactMap { _ in alphabet.randomElement() })
}
}
private struct Wrapper<Payload: Decodable>: Decodable {
let response: Payload
enum CodingKeys: String, CodingKey {
case response = "subsonic-response"
}
}
private struct EnvelopeStatus: Decodable {
struct Failure: Decodable {
let code: Int
let message: String?
}
let status: String
let error: Failure?
}
struct EmptyPayload: Decodable {}
@@ -0,0 +1,137 @@
import Domain
import Foundation
struct SubsonicMemory: Sendable {
var seen: [EntityKind: Set<String>] = [:]
var formats: [String: AudioFormat] = [:]
}
struct SubsonicCrawl {
static let pageSize = 500
let client: SubsonicClient
let mapper: SubsonicMapper
let emit: (Change) -> Void
private var memory = SubsonicMemory()
private var bestByAlbum: [String: AudioFormat] = [:]
private var annotations: [Change] = []
init(client: SubsonicClient, mapper: SubsonicMapper, emit: @escaping (Change) -> Void) {
self.client = client
self.mapper = mapper
self.emit = emit
}
mutating func run(previous: [EntityKind: Set<String>]) async throws -> SubsonicMemory {
try await crawlArtists()
try await crawlTracks()
try await crawlAlbums()
try await crawlPlaylists()
try await crawlStations()
try await crawlStarred()
annotations.forEach(emit)
emitRemovals(previous: previous)
return memory
}
private mutating func upsert(_ entity: Entity, kind: EntityKind, remote: String) {
memory.seen[kind, default: []].insert(remote)
emit(.upsert(entity))
}
private mutating func crawlArtists() async throws {
let payload = try await client.call("getArtists", as: ArtistsPayload.self)
for index in payload.artists.index ?? [] {
for artist in index.artist ?? [] {
upsert(.artist(mapper.artist(artist)), kind: .artist, remote: artist.id)
}
}
}
private mutating func crawlTracks() async throws {
var offset = 0
while true {
try Task.checkCancellation()
let params = [
"query": "\"\"",
"songCount": String(Self.pageSize),
"songOffset": String(offset),
"artistCount": "0",
"albumCount": "0",
]
let songs = try await client.call("search3", params, as: SearchPayload.self).searchResult3.song ?? []
songs.forEach { consume($0) }
offset += songs.count
if songs.count < Self.pageSize {
return
}
}
}
private mutating func consume(_ song: SongJSON) {
let track = mapper.track(song)
upsert(.track(track), kind: .track, remote: song.id)
if let format = track.formats.first {
memory.formats[song.id] = format
if let albumId = song.albumId, format.isBetter(than: bestByAlbum[albumId]) {
bestByAlbum[albumId] = format
}
}
if let annotation = mapper.annotation(song) {
annotations.append(.annotate(track.id, annotation))
}
}
private mutating func crawlAlbums() async throws {
var offset = 0
while true {
try Task.checkCancellation()
let params = ["type": "alphabeticalByName", "size": String(Self.pageSize), "offset": String(offset)]
let albums = try await client.call("getAlbumList2", params, as: AlbumListPayload.self).albumList2.album ?? []
for album in albums {
upsert(.album(mapper.album(album, bestFormat: bestByAlbum[album.id])), kind: .album, remote: album.id)
}
offset += albums.count
if albums.count < Self.pageSize {
return
}
}
}
private mutating func crawlPlaylists() async throws {
let playlists = try await client.call("getPlaylists", as: PlaylistsPayload.self).playlists.playlist ?? []
for summary in playlists {
try Task.checkCancellation()
let detail = try await client.call("getPlaylist", ["id": summary.id], as: PlaylistPayload.self).playlist
let (playlist, entries) = mapper.playlist(detail)
upsert(.playlist(playlist, entries: entries), kind: .playlist, remote: detail.id)
}
}
private mutating func crawlStations() async throws {
let stations = try await client.call("getInternetRadioStations", as: StationsPayload.self).internetRadioStations
for json in stations.internetRadioStation ?? [] {
if let station = mapper.station(json) {
upsert(.station(station), kind: .station, remote: json.id)
}
}
}
private mutating func crawlStarred() async throws {
let starred = try await client.call("getStarred2", as: StarredPayload.self).starred2
for artist in starred.artist ?? [] {
annotations.append(.annotate(mapper.id(artist.id), Annotation(favoritedAt: SubsonicDate.parse(artist.starred) ?? .now)))
}
for album in starred.album ?? [] {
annotations.append(.annotate(mapper.id(album.id), Annotation(favoritedAt: SubsonicDate.parse(album.starred) ?? .now)))
}
}
private func emitRemovals(previous: [EntityKind: Set<String>]) {
for (kind, remotes) in previous {
for remote in remotes.subtracting(memory.seen[kind] ?? []).sorted() {
emit(.remove(kind, mapper.id(remote)))
}
}
}
}
@@ -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)
}
}
@@ -0,0 +1,150 @@
import Foundation
struct ArtistJSON: Decodable {
let id: String
let name: String
let sortName: String?
let coverArt: String?
let albumCount: Int?
let starred: String?
}
struct AlbumJSON: Decodable {
let id: String
let song: [SongJSON]?
let name: String
let artist: String?
let artistId: String?
let coverArt: String?
let songCount: Int?
let duration: Double?
let created: String?
let year: Int?
let genre: String?
let starred: String?
}
struct SongJSON: Decodable {
let id: String
let title: String
let album: String?
let albumId: String?
let artist: String?
let artistId: String?
let track: Int?
let discNumber: Int?
let year: Int?
let genre: String?
let coverArt: String?
let suffix: String?
let bitRate: Int?
let samplingRate: Int?
let bitDepth: Int?
let channelCount: Int?
let duration: Double?
let playCount: Int?
let played: String?
let starred: String?
let explicitStatus: String?
}
struct PlaylistJSON: Decodable {
let id: String
let name: String
let comment: String?
let coverArt: String?
let songCount: Int?
let duration: Double?
let changed: String?
let entry: [SongJSON]?
}
struct StationJSON: Decodable {
let id: String
let name: String
let streamUrl: String
let homePageUrl: String?
let coverArt: String?
}
struct StructuredLyricsJSON: Decodable {
struct Line: Decodable {
let start: Int?
let value: String
}
let lang: String?
let synced: Bool
let offset: Int?
let line: [Line]?
}
struct ArtistsPayload: Decodable {
struct Index: Decodable {
let artist: [ArtistJSON]?
}
struct Artists: Decodable {
let index: [Index]?
}
let artists: Artists
}
struct AlbumListPayload: Decodable {
struct List: Decodable {
let album: [AlbumJSON]?
}
let albumList2: List
}
struct AlbumPayload: Decodable {
let album: AlbumJSON
}
struct SearchPayload: Decodable {
struct Result: Decodable {
let song: [SongJSON]?
}
let searchResult3: Result
}
struct PlaylistsPayload: Decodable {
struct List: Decodable {
let playlist: [PlaylistJSON]?
}
let playlists: List
}
struct PlaylistPayload: Decodable {
let playlist: PlaylistJSON
}
struct StationsPayload: Decodable {
struct List: Decodable {
let internetRadioStation: [StationJSON]?
}
let internetRadioStations: List
}
struct StarredPayload: Decodable {
struct Starred: Decodable {
let artist: [ArtistJSON]?
let album: [AlbumJSON]?
let song: [SongJSON]?
}
let starred2: Starred
}
struct LyricsPayload: Decodable {
struct List: Decodable {
let structuredLyrics: [StructuredLyricsJSON]?
}
let lyricsList: List
}
@@ -0,0 +1,83 @@
import Domain
import Foundation
import Synchronization
public struct SubsonicAccount: Codable, Hashable, Sendable {
public var baseURL: URL
public var username: String
public init(baseURL: URL, username: String) {
self.baseURL = baseURL
self.username = username
}
}
public final class SubsonicSource: LibrarySource, Starring, LyricsSource, Scrobbling {
public let id: SourceID
public let name: String
let client: SubsonicClient
private let mapper: SubsonicMapper
private let memory = Mutex(SubsonicMemory())
public convenience init(id: SourceID, account: SubsonicAccount, password: String, name: String, session: URLSession = .shared) {
let client = SubsonicClient(baseURL: account.baseURL, username: account.username, password: password, session: session)
self.init(id: id, name: name, client: client)
}
init(id: SourceID, name: String, client: SubsonicClient) {
self.id = id
self.name = name
self.client = client
mapper = SubsonicMapper(source: id)
}
public func verify() async throws {
try await client.call("ping")
}
public func changes(since _: Cursor?) -> AsyncThrowingStream<Change, Error> {
AsyncThrowingStream { continuation in
let task = Task {
do {
let start = Date.now
var crawl = SubsonicCrawl(client: client, mapper: mapper) { continuation.yield($0) }
let result = try await crawl.run(previous: memory.withLock(\.seen))
memory.withLock { $0 = result }
continuation.yield(.checkpoint(Cursor(start.ISO8601Format())))
continuation.finish()
} catch {
continuation.finish(throwing: error)
}
}
continuation.onTermination = { _ in task.cancel() }
}
}
public func stream(for track: EntityID) async throws -> StreamRequest {
let url = client.url("stream", ["id": track.remote, "format": "raw"])
return StreamRequest(resource: ResourceRequest(url: url), format: memory.withLock { $0.formats[track.remote] })
}
public func artwork(_ id: EntityID, pixelSize: Int) async throws -> ResourceRequest {
ResourceRequest(url: client.url("getCoverArt", ["id": id.remote, "size": String(pixelSize)]))
}
public func setFavorite(_ isFavorite: Bool, for id: EntityID, kind: EntityKind) async throws {
let param = switch kind {
case .album: "albumId"
case .artist: "artistId"
default: "id"
}
try await client.call(isFavorite ? "star" : "unstar", [param: id.remote])
}
public func lyrics(for track: EntityID) async throws -> Lyrics? {
let payload = try await client.call("getLyricsBySongId", ["id": track.remote], as: LyricsPayload.self)
return mapper.lyrics(payload.lyricsList.structuredLyrics ?? [])
}
public func scrobble(_ track: EntityID, at date: Date, submission: Bool) async throws {
let time = String(Int(date.timeIntervalSince1970 * 1000))
try await client.call("scrobble", ["id": track.remote, "time": time, "submission": submission ? "true" : "false"])
}
}