84 lines
3.2 KiB
Swift
84 lines
3.2 KiB
Swift
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<Change, Error> {
|
|
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"])
|
|
}
|
|
}
|