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,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)))
}
}
}
}