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,100 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
public enum SubsonicError: Error, Equatable, Sendable {
|
||||
case server(code: Int, message: String)
|
||||
case http(Int)
|
||||
case malformed
|
||||
}
|
||||
|
||||
typealias SubsonicTransport = @Sendable (URLRequest) async throws -> (Data, URLResponse)
|
||||
|
||||
struct SubsonicClient: Sendable {
|
||||
let baseURL: URL
|
||||
let username: String
|
||||
let password: String
|
||||
let transport: SubsonicTransport
|
||||
|
||||
init(baseURL: URL, username: String, password: String, transport: @escaping SubsonicTransport) {
|
||||
self.baseURL = baseURL
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.transport = transport
|
||||
}
|
||||
|
||||
init(baseURL: URL, username: String, password: String, session: URLSession) {
|
||||
self.init(baseURL: baseURL, username: username, password: password) { request in
|
||||
try await session.data(for: request)
|
||||
}
|
||||
}
|
||||
|
||||
func url(_ method: String, _ params: [String: String] = [:]) -> URL {
|
||||
let endpoint = baseURL.appending(path: "rest/\(method).view")
|
||||
var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false) ?? URLComponents()
|
||||
components.queryItems = (params.sorted { $0.key < $1.key } + authParams()).map { URLQueryItem(name: $0.key, value: $0.value) }
|
||||
return components.url ?? endpoint
|
||||
}
|
||||
|
||||
func call<Payload: Decodable>(_ method: String, _ params: [String: String] = [:], as _: Payload.Type) async throws -> Payload {
|
||||
let request = URLRequest(url: url(method, params))
|
||||
let (data, response) = try await transport(request)
|
||||
if let http = response as? HTTPURLResponse, http.statusCode >= 400 {
|
||||
throw SubsonicError.http(http.statusCode)
|
||||
}
|
||||
return try Self.decode(data, as: Payload.self)
|
||||
}
|
||||
|
||||
func call(_ method: String, _ params: [String: String] = [:]) async throws {
|
||||
_ = try await call(method, params, as: EmptyPayload.self)
|
||||
}
|
||||
|
||||
static func decode<Payload: Decodable>(_ data: Data, as _: Payload.Type) throws -> Payload {
|
||||
let decoder = JSONDecoder()
|
||||
let status: Wrapper<EnvelopeStatus>
|
||||
do {
|
||||
status = try decoder.decode(Wrapper<EnvelopeStatus>.self, from: data)
|
||||
} catch {
|
||||
throw SubsonicError.malformed
|
||||
}
|
||||
if status.response.status != "ok" {
|
||||
let error = status.response.error
|
||||
throw SubsonicError.server(code: error?.code ?? 0, message: error?.message ?? "")
|
||||
}
|
||||
do {
|
||||
return try decoder.decode(Wrapper<Payload>.self, from: data).response
|
||||
} catch {
|
||||
throw SubsonicError.malformed
|
||||
}
|
||||
}
|
||||
|
||||
private func authParams() -> [(key: String, value: String)] {
|
||||
let salt = Self.salt()
|
||||
let token = Insecure.MD5.hash(data: Data((password + salt).utf8)).map { String(format: "%02x", $0) }.joined()
|
||||
return [("u", username), ("t", token), ("s", salt), ("v", "1.16.1"), ("c", "Amphlify"), ("f", "json")]
|
||||
}
|
||||
|
||||
private static func salt() -> String {
|
||||
let alphabet = Array("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
|
||||
return String((0 ..< 12).compactMap { _ in alphabet.randomElement() })
|
||||
}
|
||||
}
|
||||
|
||||
private struct Wrapper<Payload: Decodable>: Decodable {
|
||||
let response: Payload
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case response = "subsonic-response"
|
||||
}
|
||||
}
|
||||
|
||||
private struct EnvelopeStatus: Decodable {
|
||||
struct Failure: Decodable {
|
||||
let code: Int
|
||||
let message: String?
|
||||
}
|
||||
|
||||
let status: String
|
||||
let error: Failure?
|
||||
}
|
||||
|
||||
struct EmptyPayload: Decodable {}
|
||||
@@ -0,0 +1,137 @@
|
||||
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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import Domain
|
||||
import Foundation
|
||||
|
||||
enum SubsonicDate {
|
||||
static func parse(_ raw: String?) -> Date? {
|
||||
guard let raw else { return nil }
|
||||
guard let dot = raw.firstIndex(of: ".") else {
|
||||
return try? Date(raw, strategy: .iso8601)
|
||||
}
|
||||
let digits = raw[raw.index(after: dot)...].prefix { $0.isNumber }
|
||||
let rest = raw[raw.index(dot, offsetBy: 1 + digits.count)...]
|
||||
let fraction = String(digits.prefix(3)).padding(toLength: 3, withPad: "0", startingAt: 0)
|
||||
return try? Date(raw[..<dot] + "." + fraction + rest, strategy: Date.ISO8601FormatStyle(includingFractionalSeconds: true))
|
||||
}
|
||||
}
|
||||
|
||||
extension AudioFormat {
|
||||
init?(song: SongJSON) {
|
||||
guard let codec = Codec(suffix: song.suffix) else { return nil }
|
||||
self.init(
|
||||
codec: codec,
|
||||
bitrateKbps: song.bitRate,
|
||||
sampleRate: song.samplingRate,
|
||||
bitDepth: song.bitDepth.flatMap { $0 > 0 ? $0 : nil },
|
||||
channels: song.channelCount ?? 2
|
||||
)
|
||||
}
|
||||
|
||||
private var rank: [Int] {
|
||||
[isLossless ? 1 : 0, sampleRate ?? 0, bitDepth ?? 0, bitrateKbps ?? 0]
|
||||
}
|
||||
|
||||
func isBetter(than other: AudioFormat?) -> Bool {
|
||||
guard let other else { return true }
|
||||
return other.rank.lexicographicallyPrecedes(rank)
|
||||
}
|
||||
}
|
||||
|
||||
extension AudioFormat.Codec {
|
||||
init?(suffix: String?) {
|
||||
switch suffix?.lowercased() {
|
||||
case "flac": self = .flac
|
||||
case "alac": self = .alac
|
||||
case "wav": self = .wav
|
||||
case "mp3": self = .mp3
|
||||
case "aac", "m4a": self = .aac
|
||||
case "opus": self = .opus
|
||||
case "ogg": self = .vorbis
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SubsonicMapper: Sendable {
|
||||
let source: SourceID
|
||||
|
||||
func id(_ remote: String) -> EntityID {
|
||||
EntityID(source: source, remote: remote)
|
||||
}
|
||||
|
||||
func artwork(_ remote: String?) -> EntityID? {
|
||||
guard let remote, !remote.isEmpty else { return nil }
|
||||
return id(remote)
|
||||
}
|
||||
|
||||
func artist(_ json: ArtistJSON) -> Artist {
|
||||
Artist(id: id(json.id), name: json.name, sortName: json.sortName, artwork: artwork(json.coverArt), albumCount: json.albumCount ?? 0)
|
||||
}
|
||||
|
||||
func track(_ json: SongJSON) -> Track {
|
||||
Track(
|
||||
id: id(json.id),
|
||||
title: json.title,
|
||||
albumID: json.albumId.map(id),
|
||||
albumTitle: json.album ?? "",
|
||||
artistID: json.artistId.map(id),
|
||||
artistName: json.artist ?? "",
|
||||
discNumber: json.discNumber ?? 1,
|
||||
trackNumber: json.track ?? 0,
|
||||
duration: json.duration ?? 0,
|
||||
year: json.year,
|
||||
genre: json.genre,
|
||||
artwork: artwork(json.coverArt),
|
||||
isExplicit: json.explicitStatus == "explicit",
|
||||
formats: AudioFormat(song: json).map { [$0] } ?? []
|
||||
)
|
||||
}
|
||||
|
||||
func album(_ json: AlbumJSON, bestFormat: AudioFormat?) -> Album {
|
||||
Album(
|
||||
id: id(json.id),
|
||||
title: json.name,
|
||||
artistID: json.artistId.map(id),
|
||||
artistName: json.artist ?? "",
|
||||
year: json.year,
|
||||
genre: json.genre,
|
||||
artwork: artwork(json.coverArt),
|
||||
trackCount: json.songCount ?? 0,
|
||||
duration: json.duration ?? 0,
|
||||
addedAt: SubsonicDate.parse(json.created) ?? .distantPast,
|
||||
bestFormat: bestFormat
|
||||
)
|
||||
}
|
||||
|
||||
func playlist(_ json: PlaylistJSON) -> (Playlist, [PlaylistEntry]) {
|
||||
let playlist = Playlist(
|
||||
id: id(json.id),
|
||||
name: json.name,
|
||||
summary: json.comment ?? "",
|
||||
artwork: artwork(json.coverArt),
|
||||
trackCount: json.songCount ?? json.entry?.count ?? 0,
|
||||
duration: json.duration ?? 0,
|
||||
updatedAt: SubsonicDate.parse(json.changed) ?? .distantPast
|
||||
)
|
||||
let entries = (json.entry ?? []).enumerated().map { index, song in
|
||||
PlaylistEntry(id: "\(json.id):\(index)", track: id(song.id))
|
||||
}
|
||||
return (playlist, entries)
|
||||
}
|
||||
|
||||
func station(_ json: StationJSON) -> RadioStation? {
|
||||
guard let url = URL(string: json.streamUrl), url.scheme != nil else { return nil }
|
||||
return RadioStation(
|
||||
id: id(json.id),
|
||||
name: json.name,
|
||||
tagline: json.homePageUrl ?? "",
|
||||
streamURL: url,
|
||||
artwork: artwork(json.coverArt)
|
||||
)
|
||||
}
|
||||
|
||||
func annotation(_ song: SongJSON) -> Annotation? {
|
||||
let favoritedAt = SubsonicDate.parse(song.starred)
|
||||
let playCount = song.playCount ?? 0
|
||||
guard favoritedAt != nil || playCount > 0 else { return nil }
|
||||
return Annotation(favoritedAt: favoritedAt, playCount: playCount, lastPlayedAt: SubsonicDate.parse(song.played))
|
||||
}
|
||||
|
||||
func lyrics(_ list: [StructuredLyricsJSON]) -> Lyrics? {
|
||||
guard let entry = list.first(where: \.synced) ?? list.first else { return nil }
|
||||
let offset = entry.offset ?? 0
|
||||
let lines = (entry.line ?? []).map { line in
|
||||
LyricLine(time: entry.synced ? Double((line.start ?? 0) + offset) / 1000 : 0, text: line.value)
|
||||
}
|
||||
guard !lines.isEmpty else { return nil }
|
||||
return Lyrics(lines: lines, isSynced: entry.synced)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import Foundation
|
||||
|
||||
struct ArtistJSON: Decodable {
|
||||
let id: String
|
||||
let name: String
|
||||
let sortName: String?
|
||||
let coverArt: String?
|
||||
let albumCount: Int?
|
||||
let starred: String?
|
||||
}
|
||||
|
||||
struct AlbumJSON: Decodable {
|
||||
let id: String
|
||||
let song: [SongJSON]?
|
||||
let name: String
|
||||
let artist: String?
|
||||
let artistId: String?
|
||||
let coverArt: String?
|
||||
let songCount: Int?
|
||||
let duration: Double?
|
||||
let created: String?
|
||||
let year: Int?
|
||||
let genre: String?
|
||||
let starred: String?
|
||||
}
|
||||
|
||||
struct SongJSON: Decodable {
|
||||
let id: String
|
||||
let title: String
|
||||
let album: String?
|
||||
let albumId: String?
|
||||
let artist: String?
|
||||
let artistId: String?
|
||||
let track: Int?
|
||||
let discNumber: Int?
|
||||
let year: Int?
|
||||
let genre: String?
|
||||
let coverArt: String?
|
||||
let suffix: String?
|
||||
let bitRate: Int?
|
||||
let samplingRate: Int?
|
||||
let bitDepth: Int?
|
||||
let channelCount: Int?
|
||||
let duration: Double?
|
||||
let playCount: Int?
|
||||
let played: String?
|
||||
let starred: String?
|
||||
let explicitStatus: String?
|
||||
}
|
||||
|
||||
struct PlaylistJSON: Decodable {
|
||||
let id: String
|
||||
let name: String
|
||||
let comment: String?
|
||||
let coverArt: String?
|
||||
let songCount: Int?
|
||||
let duration: Double?
|
||||
let changed: String?
|
||||
let entry: [SongJSON]?
|
||||
}
|
||||
|
||||
struct StationJSON: Decodable {
|
||||
let id: String
|
||||
let name: String
|
||||
let streamUrl: String
|
||||
let homePageUrl: String?
|
||||
let coverArt: String?
|
||||
}
|
||||
|
||||
struct StructuredLyricsJSON: Decodable {
|
||||
struct Line: Decodable {
|
||||
let start: Int?
|
||||
let value: String
|
||||
}
|
||||
|
||||
let lang: String?
|
||||
let synced: Bool
|
||||
let offset: Int?
|
||||
let line: [Line]?
|
||||
}
|
||||
|
||||
struct ArtistsPayload: Decodable {
|
||||
struct Index: Decodable {
|
||||
let artist: [ArtistJSON]?
|
||||
}
|
||||
|
||||
struct Artists: Decodable {
|
||||
let index: [Index]?
|
||||
}
|
||||
|
||||
let artists: Artists
|
||||
}
|
||||
|
||||
struct AlbumListPayload: Decodable {
|
||||
struct List: Decodable {
|
||||
let album: [AlbumJSON]?
|
||||
}
|
||||
|
||||
let albumList2: List
|
||||
}
|
||||
|
||||
struct AlbumPayload: Decodable {
|
||||
let album: AlbumJSON
|
||||
}
|
||||
|
||||
struct SearchPayload: Decodable {
|
||||
struct Result: Decodable {
|
||||
let song: [SongJSON]?
|
||||
}
|
||||
|
||||
let searchResult3: Result
|
||||
}
|
||||
|
||||
struct PlaylistsPayload: Decodable {
|
||||
struct List: Decodable {
|
||||
let playlist: [PlaylistJSON]?
|
||||
}
|
||||
|
||||
let playlists: List
|
||||
}
|
||||
|
||||
struct PlaylistPayload: Decodable {
|
||||
let playlist: PlaylistJSON
|
||||
}
|
||||
|
||||
struct StationsPayload: Decodable {
|
||||
struct List: Decodable {
|
||||
let internetRadioStation: [StationJSON]?
|
||||
}
|
||||
|
||||
let internetRadioStations: List
|
||||
}
|
||||
|
||||
struct StarredPayload: Decodable {
|
||||
struct Starred: Decodable {
|
||||
let artist: [ArtistJSON]?
|
||||
let album: [AlbumJSON]?
|
||||
let song: [SongJSON]?
|
||||
}
|
||||
|
||||
let starred2: Starred
|
||||
}
|
||||
|
||||
struct LyricsPayload: Decodable {
|
||||
struct List: Decodable {
|
||||
let structuredLyrics: [StructuredLyricsJSON]?
|
||||
}
|
||||
|
||||
let lyricsList: List
|
||||
}
|
||||
@@ -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