import Domain import Foundation struct SubsonicMemory: Sendable { var seen: [EntityKind: Set] = [:] 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]) 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]) { for (kind, remotes) in previous { for remote in remotes.subtracting(memory.seen[kind] ?? []).sorted() { emit(.remove(kind, mapper.id(remote))) } } } }