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