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