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,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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user