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 { 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"]) } }