feat(*): subsonic client with avfoundation playback and apple music ui

This commit is contained in:
hh committed 2026-09-22 06:44:44 +02:00
commit e5bb559d7f
93 files changed
+7517

No files matched your search

+28
View File
@@ -0,0 +1,28 @@
// swift-tools-version: 6.2
import PackageDescription
let package = Package(
name: "AmphlifyKit",
platforms: [.iOS(.v26), .macOS(.v26), .watchOS(.v26)],
products: [
.library(name: "Domain", targets: ["Domain"]),
.library(name: "Store", targets: ["Store"]),
.library(name: "Sync", targets: ["Sync"]),
.library(name: "Playback", targets: ["Playback"]),
.library(name: "SourceFake", targets: ["SourceFake"]),
.library(name: "SourceSubsonic", targets: ["SourceSubsonic"]),
],
targets: [
.target(name: "Domain"),
.target(name: "Store", dependencies: ["Domain"]),
.target(name: "Sync", dependencies: ["Domain", "Store"]),
.target(name: "Playback", dependencies: ["Domain"]),
.target(name: "SourceFake", dependencies: ["Domain"]),
.target(name: "SourceSubsonic", dependencies: ["Domain"]),
.testTarget(name: "DomainTests", dependencies: ["Domain"]),
.testTarget(name: "PlaybackTests", dependencies: ["Playback"]),
.testTarget(name: "StoreTests", dependencies: ["Store", "Sync", "SourceFake"]),
.testTarget(name: "SourceSubsonicTests", dependencies: ["SourceSubsonic"], resources: [.copy("Fixtures")]),
],
swiftLanguageModes: [.v6]
)
@@ -0,0 +1,32 @@
import Foundation
public struct AudioFormat: Hashable, Codable, Sendable {
public enum Codec: String, Codable, Sendable {
case flac, alac, wav, mp3, aac, opus, vorbis, eac3
}
public var codec: Codec
public var bitrateKbps: Int?
public var sampleRate: Int?
public var bitDepth: Int?
public var channels: Int
public init(codec: Codec, bitrateKbps: Int? = nil, sampleRate: Int? = nil, bitDepth: Int? = nil, channels: Int = 2) {
self.codec = codec
self.bitrateKbps = bitrateKbps
self.sampleRate = sampleRate
self.bitDepth = bitDepth
self.channels = channels
}
public var isLossless: Bool {
switch codec {
case .flac, .alac, .wav: true
default: false
}
}
public var isHiRes: Bool {
isLossless && ((sampleRate ?? 0) > 48000 || (bitDepth ?? 0) > 16 && (sampleRate ?? 0) > 44100)
}
}
@@ -0,0 +1,177 @@
import Foundation
public struct Artist: Identifiable, Hashable, Codable, Sendable {
public let id: EntityID
public var name: String
public var sortName: String
public var artwork: EntityID?
public var albumCount: Int
public init(id: EntityID, name: String, sortName: String? = nil, artwork: EntityID? = nil, albumCount: Int = 0) {
self.id = id
self.name = name
self.sortName = sortName ?? name
self.artwork = artwork
self.albumCount = albumCount
}
}
public struct Album: Identifiable, Hashable, Codable, Sendable {
public let id: EntityID
public var title: String
public var artistID: EntityID?
public var artistName: String
public var year: Int?
public var genre: String?
public var artwork: EntityID?
public var trackCount: Int
public var duration: TimeInterval
public var addedAt: Date
public var bestFormat: AudioFormat?
public init(
id: EntityID,
title: String,
artistID: EntityID?,
artistName: String,
year: Int? = nil,
genre: String? = nil,
artwork: EntityID? = nil,
trackCount: Int = 0,
duration: TimeInterval = 0,
addedAt: Date = .distantPast,
bestFormat: AudioFormat? = nil
) {
self.id = id
self.title = title
self.artistID = artistID
self.artistName = artistName
self.year = year
self.genre = genre
self.artwork = artwork
self.trackCount = trackCount
self.duration = duration
self.addedAt = addedAt
self.bestFormat = bestFormat
}
}
public struct Track: Identifiable, Hashable, Codable, Sendable {
public let id: EntityID
public var title: String
public var albumID: EntityID?
public var albumTitle: String
public var artistID: EntityID?
public var artistName: String
public var discNumber: Int
public var trackNumber: Int
public var duration: TimeInterval
public var year: Int?
public var genre: String?
public var artwork: EntityID?
public var isExplicit: Bool
public var contentHash: Data?
public var formats: [AudioFormat]
public init(
id: EntityID,
title: String,
albumID: EntityID?,
albumTitle: String,
artistID: EntityID?,
artistName: String,
discNumber: Int = 1,
trackNumber: Int = 0,
duration: TimeInterval,
year: Int? = nil,
genre: String? = nil,
artwork: EntityID? = nil,
isExplicit: Bool = false,
contentHash: Data? = nil,
formats: [AudioFormat] = []
) {
self.id = id
self.title = title
self.albumID = albumID
self.albumTitle = albumTitle
self.artistID = artistID
self.artistName = artistName
self.discNumber = discNumber
self.trackNumber = trackNumber
self.duration = duration
self.year = year
self.genre = genre
self.artwork = artwork
self.isExplicit = isExplicit
self.contentHash = contentHash
self.formats = formats
}
}
public struct Playlist: Identifiable, Hashable, Codable, Sendable {
public let id: EntityID
public var name: String
public var summary: String
public var artwork: EntityID?
public var trackCount: Int
public var duration: TimeInterval
public var updatedAt: Date
public init(
id: EntityID,
name: String,
summary: String = "",
artwork: EntityID? = nil,
trackCount: Int = 0,
duration: TimeInterval = 0,
updatedAt: Date = .distantPast
) {
self.id = id
self.name = name
self.summary = summary
self.artwork = artwork
self.trackCount = trackCount
self.duration = duration
self.updatedAt = updatedAt
}
}
public struct PlaylistEntry: Identifiable, Hashable, Codable, Sendable {
public let id: String
public let track: EntityID
public init(id: String, track: EntityID) {
self.id = id
self.track = track
}
}
public struct RadioStation: Identifiable, Hashable, Codable, Sendable {
public let id: EntityID
public var name: String
public var tagline: String
public var streamURL: URL
public var artwork: EntityID?
public init(id: EntityID, name: String, tagline: String = "", streamURL: URL, artwork: EntityID? = nil) {
self.id = id
self.name = name
self.tagline = tagline
self.streamURL = streamURL
self.artwork = artwork
}
}
public struct Genre: Identifiable, Hashable, Sendable {
public var id: String {
name
}
public let name: String
public let albumCount: Int
public init(name: String, albumCount: Int) {
self.name = name
self.albumCount = albumCount
}
}
@@ -0,0 +1,23 @@
import Foundation
public struct SourceID: Hashable, Codable, Sendable {
public let raw: UUID
public init(_ raw: UUID = UUID()) {
self.raw = raw
}
}
public struct EntityID: Hashable, Codable, Sendable {
public let source: SourceID
public let remote: String
public init(source: SourceID, remote: String) {
self.source = source
self.remote = remote
}
}
public enum EntityKind: String, Codable, Sendable {
case artist, album, track, playlist, station
}
@@ -0,0 +1,75 @@
import Foundation
public struct LyricLine: Hashable, Sendable, Identifiable {
public let time: TimeInterval
public let text: String
public var id: TimeInterval {
time
}
public init(time: TimeInterval, text: String) {
self.time = time
self.text = text
}
}
public struct Lyrics: Hashable, Sendable {
public let lines: [LyricLine]
public let isSynced: Bool
public init(lines: [LyricLine], isSynced: Bool) {
self.lines = lines
self.isSynced = isSynced
}
public init?(lrc: String) {
var lines: [LyricLine] = []
var plain: [String] = []
for raw in lrc.split(whereSeparator: \.isNewline) {
var rest = Substring(raw)
var stamps: [TimeInterval] = []
while rest.first == "[", let close = rest.firstIndex(of: "]") {
let tag = rest[rest.index(after: rest.startIndex) ..< close]
rest = rest[rest.index(after: close)...]
if let time = Self.time(tag) {
stamps.append(time)
}
}
let text = rest.trimmingCharacters(in: .whitespaces)
if stamps.isEmpty {
if !text.isEmpty {
plain.append(text)
}
} else {
lines += stamps.map { LyricLine(time: $0, text: text) }
}
}
if !lines.isEmpty {
self.init(lines: lines.sorted { $0.time < $1.time }, isSynced: true)
} else if !plain.isEmpty {
self.init(lines: plain.map { LyricLine(time: 0, text: $0) }, isSynced: false)
} else {
return nil
}
}
public func index(at time: TimeInterval) -> Int? {
guard isSynced else { return nil }
var found: Int?
for (index, line) in lines.enumerated() where line.time <= time {
found = index
}
return found
}
private static func time(_ tag: Substring) -> TimeInterval? {
let parts = tag.split(separator: ":")
guard parts.count == 2, let minutes = Double(parts[0]), let seconds = Double(parts[1]) else { return nil }
return minutes * 60 + seconds
}
}
public protocol LyricsSource: LibrarySource {
func lyrics(for track: EntityID) async throws -> Lyrics?
}
@@ -0,0 +1,79 @@
import Foundation
public struct ResourceRequest: Hashable, Sendable {
public var url: URL
public var headers: [String: String]
public var expiresAt: Date?
public init(url: URL, headers: [String: String] = [:], expiresAt: Date? = nil) {
self.url = url
self.headers = headers
self.expiresAt = expiresAt
}
}
public struct StreamRequest: Hashable, Sendable {
public var resource: ResourceRequest
public var format: AudioFormat?
public init(resource: ResourceRequest, format: AudioFormat?) {
self.resource = resource
self.format = format
}
}
public struct Cursor: Hashable, Codable, Sendable {
public let raw: String
public init(_ raw: String) {
self.raw = raw
}
}
public struct Annotation: Hashable, Codable, Sendable {
public var favoritedAt: Date?
public var playCount: Int
public var lastPlayedAt: Date?
public init(favoritedAt: Date? = nil, playCount: Int = 0, lastPlayedAt: Date? = nil) {
self.favoritedAt = favoritedAt
self.playCount = playCount
self.lastPlayedAt = lastPlayedAt
}
}
public enum Entity: Sendable {
case artist(Artist)
case album(Album)
case track(Track)
case playlist(Playlist, entries: [PlaylistEntry])
case station(RadioStation)
}
public enum Change: Sendable {
case upsert(Entity)
case remove(EntityKind, EntityID)
case annotate(EntityID, Annotation)
case checkpoint(Cursor)
}
public enum SourceError: Error {
case cannotCalculateChanges
case notFound
}
public protocol LibrarySource: Sendable {
var id: SourceID { get }
var name: String { get }
func changes(since cursor: Cursor?) -> AsyncThrowingStream<Change, Error>
func stream(for track: EntityID) async throws -> StreamRequest
func artwork(_ id: EntityID, pixelSize: Int) async throws -> ResourceRequest
}
public protocol Starring: LibrarySource {
func setFavorite(_ isFavorite: Bool, for id: EntityID, kind: EntityKind) async throws
}
public protocol Scrobbling: LibrarySource {
func scrobble(_ track: EntityID, at date: Date, submission: Bool) async throws
}
@@ -0,0 +1,213 @@
#if canImport(AVFoundation)
import AVFoundation
import Domain
import Foundation
public actor AVFoundationEngine: OutputEngine {
public typealias Resolve = @Sendable (Track) async throws -> StreamRequest
private struct Slot {
let item: QueueItem
let playerItem: AVPlayerItem
let statusObservation: NSKeyValueObservation
}
public nonisolated let events: AsyncStream<OutputEvent>
private let continuation: AsyncStream<OutputEvent>.Continuation
private let resolve: Resolve
private let player = AVQueuePlayer()
private let bufferAhead: TimeInterval = 60
private let anchorInterval: TimeInterval = 5
private var current: Slot?
private var prepared: Slot?
private var adoptable = false
private var wantsPlaying = false
private var sessionActive = false
private var loadGeneration = 0
private var prepareGeneration = 0
private var observations: [NSKeyValueObservation] = []
private var tokens: [any NSObjectProtocol] = []
private var timeObserver: Any?
public init(resolve: @escaping Resolve) {
self.resolve = resolve
(events, continuation) = AsyncStream<OutputEvent>.makeStream()
player.automaticallyWaitsToMinimizeStalling = true
player.audiovisualBackgroundPlaybackPolicy = .continuesIfPossible
}
isolated deinit {
tokens.forEach { NotificationCenter.default.removeObserver($0) }
if let timeObserver {
player.removeTimeObserver(timeObserver)
}
}
public var outputLatency: TimeInterval {
AudioSession.outputLatency
}
public func load(_ item: QueueItem, at position: TimeInterval) async {
startObserving()
loadGeneration += 1
let generation = loadGeneration
if adoptable, current?.item.id == item.id {
adoptable = false
} else if let prepared, prepared.item.id == item.id {
player.advanceToNextItem()
current = prepared
self.prepared = nil
} else {
guard let slot = await makeSlot(item), generation == loadGeneration else { return }
player.removeAllItems()
prepared = nil
player.insert(slot.playerItem, after: nil)
current = slot
}
adoptable = false
if position > 0 {
await seek(to: position)
}
}
public func prepare(_ item: QueueItem?) async {
startObserving()
guard prepared?.item.id != item?.id else { return }
prepareGeneration += 1
let generation = prepareGeneration
if let prepared {
player.remove(prepared.playerItem)
self.prepared = nil
}
guard let item, current != nil else { return }
guard let slot = await makeSlot(item), generation == prepareGeneration, current != nil else { return }
player.insert(slot.playerItem, after: nil)
prepared = slot
}
public func play() async {
wantsPlaying = true
if !sessionActive {
sessionActive = await (try? AudioSession.activate()) != nil
}
player.play()
}
public func pause() {
wantsPlaying = false
player.pause()
}
public func seek(to position: TimeInterval) async {
let time = CMTime(seconds: position, preferredTimescale: 600)
_ = await player.seek(to: time, toleranceBefore: .zero, toleranceAfter: .zero)
reportAnchor()
}
public func stop() {
wantsPlaying = false
player.pause()
player.removeAllItems()
current = nil
prepared = nil
adoptable = false
}
private func makeSlot(_ item: QueueItem) async -> Slot? {
do {
let request = try await resolve(item.track)
let playerItem = makePlayerItem(request)
let observation = playerItem.observe(\.status, options: [.new]) { @Sendable [weak self] playerItem, _ in
guard playerItem.status == .failed else { return }
let id = ObjectIdentifier(playerItem)
Task { await self?.itemFailed(id) }
}
return Slot(item: item, playerItem: playerItem, statusObservation: observation)
} catch {
continuation.yield(.failed(item.id))
return nil
}
}
private func makePlayerItem(_ request: StreamRequest) -> AVPlayerItem {
var options: [String: any Sendable] = [AVURLAssetPreferPreciseDurationAndTimingKey: true]
if !request.resource.headers.isEmpty {
options["AVURLAssetHTTPHeaderFieldsKey"] = request.resource.headers
}
let asset = AVURLAsset(url: request.resource.url, options: options)
let playerItem = AVPlayerItem(asset: asset)
playerItem.preferredForwardBufferDuration = bufferAhead
return playerItem
}
private func startObserving() {
guard timeObserver == nil else { return }
let interval = CMTime(seconds: anchorInterval, preferredTimescale: 600)
timeObserver = player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] _ in
Task { await self?.reportAnchor() }
}
observations.append(player.observe(\.timeControlStatus, options: [.new]) { @Sendable [weak self] _, _ in
Task { await self?.reportAnchor() }
})
tokens.append(observeItems(.AVPlayerItemDidPlayToEndTime) { [weak self] id in await self?.itemEnded(id) })
tokens.append(observeItems(.AVPlayerItemFailedToPlayToEndTime) { [weak self] id in await self?.itemFailed(id) })
tokens.append(contentsOf: AudioSession.observe(
interrupted: { [weak self] began in Task { await self?.interruption(began: began) } },
routeLost: { [weak self] in Task { await self?.routeLost() } }
))
}
private func observeItems(
_ name: Notification.Name,
handler: @escaping @Sendable (ObjectIdentifier) async -> Void
) -> any NSObjectProtocol {
NotificationCenter.default.addObserver(forName: name, object: nil, queue: nil) { notification in
guard let playerItem = notification.object as? AVPlayerItem else { return }
let id = ObjectIdentifier(playerItem)
Task { await handler(id) }
}
}
private func itemEnded(_ id: ObjectIdentifier) {
guard let current, ObjectIdentifier(current.playerItem) == id else { return }
continuation.yield(.finished(current.item.id))
self.current = prepared
prepared = nil
adoptable = self.current != nil
}
private func itemFailed(_ id: ObjectIdentifier) {
if let current, ObjectIdentifier(current.playerItem) == id {
continuation.yield(.failed(current.item.id))
} else if let prepared, ObjectIdentifier(prepared.playerItem) == id {
player.remove(prepared.playerItem)
self.prepared = nil
}
}
private func reportAnchor() {
guard let playing = player.currentItem,
let slot = [current, prepared].compactMap(\.self).first(where: { $0.playerItem === playing })
else { return }
let seconds = player.currentTime().seconds
guard seconds.isFinite else { return }
let rate = player.timeControlStatus == .playing ? Double(player.rate) : 0
continuation.yield(.anchor(slot.item.id, position: seconds, rate: rate))
}
private func interruption(began: Bool) {
if began {
player.pause()
reportAnchor()
} else if wantsPlaying {
player.play()
}
}
private func routeLost() {
wantsPlaying = false
player.pause()
reportAnchor()
}
}
#endif
@@ -0,0 +1,69 @@
#if canImport(AVFoundation)
import AVFoundation
import Foundation
enum AudioSession {
typealias Token = any NSObjectProtocol
#if os(iOS) || os(watchOS)
static var outputLatency: TimeInterval {
AVAudioSession.sharedInstance().outputLatency
}
static func activate() async throws {
let session = AVAudioSession.sharedInstance()
#if os(watchOS)
try session.setCategory(.playback, mode: .default, policy: .longFormAudio)
_ = try await session.activate()
#else
try session.setCategory(.playback, mode: .default)
try session.setActive(true)
#endif
}
static func observe(
interrupted: @escaping @Sendable (Bool) -> Void,
routeLost: @escaping @Sendable () -> Void
) -> [Token] {
let center = NotificationCenter.default
let session = AVAudioSession.sharedInstance()
let interruption = center.addObserver(forName: AVAudioSession.interruptionNotification, object: session, queue: nil) {
guard let raw = $0.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt,
let type = AVAudioSession.InterruptionType(rawValue: raw)
else { return }
switch type {
case .began:
interrupted(true)
case .ended:
let options = $0.userInfo?[AVAudioSessionInterruptionOptionKey] as? UInt ?? 0
if AVAudioSession.InterruptionOptions(rawValue: options).contains(.shouldResume) {
interrupted(false)
}
@unknown default:
break
}
}
let route = center.addObserver(forName: AVAudioSession.routeChangeNotification, object: session, queue: nil) {
let raw = $0.userInfo?[AVAudioSessionRouteChangeReasonKey] as? UInt
if raw.flatMap(AVAudioSession.RouteChangeReason.init) == .oldDeviceUnavailable {
routeLost()
}
}
return [interruption, route]
}
#else
static var outputLatency: TimeInterval {
0
}
static func activate() async throws {}
static func observe(
interrupted _: @escaping @Sendable (Bool) -> Void,
routeLost _: @escaping @Sendable () -> Void
) -> [Token] {
[]
}
#endif
}
#endif
@@ -0,0 +1,88 @@
import Domain
import Foundation
public struct QueueItem: Identifiable, Hashable, Codable, Sendable {
public let id: UUID
public let track: Track
public init(id: UUID = UUID(), track: Track) {
self.id = id
self.track = track
}
}
public enum RepeatMode: String, Codable, Sendable, CaseIterable {
case off, all, one
}
public enum PlaybackCommand: Codable, Sendable {
case play
case pause
case togglePlayPause
case seek(to: TimeInterval)
case next
case previous
case setQueue(tracks: [Track], startIndex: Int, shuffled: Bool)
case insertNext([Track])
case insertLast([Track])
case jump(to: QueueItem.ID)
case move(item: QueueItem.ID, after: QueueItem.ID?)
case remove(QueueItem.ID)
case clearUpcoming
case setShuffle(Bool)
case setRepeat(RepeatMode)
}
public struct PlaybackAnchor: Hashable, Sendable {
public var position: TimeInterval
public var instant: ContinuousClock.Instant
public var rate: Double
public init(position: TimeInterval, instant: ContinuousClock.Instant, rate: Double) {
self.position = position
self.instant = instant
self.rate = rate
}
public func position(at now: ContinuousClock.Instant) -> TimeInterval {
let elapsed = now - instant
let seconds = Double(elapsed.components.seconds) + Double(elapsed.components.attoseconds) / 1e18
return position + seconds * rate
}
}
public enum PlaybackStatus: String, Sendable {
case stopped, paused, playing
}
public struct PlaybackState: Hashable, Sendable {
public var history: [QueueItem] = []
public var current: QueueItem?
public var upcoming: [QueueItem] = []
public var status: PlaybackStatus = .stopped
public var anchor = PlaybackAnchor(position: 0, instant: .now, rate: 0)
public var isShuffled = false
public var repeatMode: RepeatMode = .off
public init() {}
}
public enum OutputEvent: Sendable {
case finished(QueueItem.ID)
case failed(QueueItem.ID)
case anchor(QueueItem.ID, position: TimeInterval, rate: Double)
}
public protocol OutputEngine: Sendable {
var events: AsyncStream<OutputEvent> { get }
func load(_ item: QueueItem, at position: TimeInterval) async
func prepare(_ item: QueueItem?) async
func play() async
func pause() async
func seek(to position: TimeInterval) async
func stop() async
}
public extension OutputEngine {
func prepare(_: QueueItem?) async {}
}
@@ -0,0 +1,270 @@
import Domain
import Foundation
public actor PlaybackSession {
public typealias Now = @Sendable () -> ContinuousClock.Instant
private let engine: any OutputEngine
private let now: Now
private var state = PlaybackState()
private var originalOrder: [QueueItem.ID] = []
private var observers: [UUID: AsyncStream<PlaybackState>.Continuation] = [:]
private var eventTask: Task<Void, Never>?
private var preparedKey: [QueueItem.ID?] = []
private var failureStreak = 0
private let restartThreshold: TimeInterval = 3
public init(engine: any OutputEngine, now: @escaping Now = { .now }) {
self.engine = engine
self.now = now
}
public func states() -> AsyncStream<PlaybackState> {
listenToEngine()
let key = UUID()
let (stream, continuation) = AsyncStream<PlaybackState>.makeStream(bufferingPolicy: .bufferingNewest(1))
observers[key] = continuation
continuation.onTermination = { [weak self] _ in
Task { await self?.removeObserver(key) }
}
continuation.yield(state)
return stream
}
public var snapshot: PlaybackState {
state
}
public func send(_ command: PlaybackCommand) async {
listenToEngine()
failureStreak = 0
switch command {
case .play: await resume()
case .pause: await pause()
case .togglePlayPause: state.status == .playing ? await pause() : await resume()
case let .seek(position): await seek(to: position)
case .next: await advance(auto: false)
case .previous: await goBack()
case let .setQueue(tracks, startIndex, shuffled): await setQueue(tracks, startIndex: startIndex, shuffled: shuffled)
case let .insertNext(tracks): insert(tracks, atEnd: false)
case let .insertLast(tracks): insert(tracks, atEnd: true)
case let .jump(id): await jump(to: id)
case let .move(item, after): move(item, after: after)
case let .remove(id): state.upcoming.removeAll { $0.id == id }
case .clearUpcoming: state.upcoming.removeAll()
case let .setShuffle(isOn): setShuffle(isOn)
case let .setRepeat(mode): state.repeatMode = mode
}
publish()
await syncPrepared()
}
private func listenToEngine() {
guard eventTask == nil else { return }
let events = engine.events
eventTask = Task { [weak self] in
for await event in events {
await self?.handle(event)
}
}
}
private func handle(_ event: OutputEvent) async {
switch event {
case let .finished(id) where id == state.current?.id:
failureStreak = 0
await finishCurrent()
case let .failed(id) where id == state.current?.id:
await failCurrent()
case let .anchor(id, position, rate) where id == state.current?.id:
state.anchor = PlaybackAnchor(position: position, instant: now(), rate: state.status == .playing ? rate : 0)
default:
return
}
publish()
await syncPrepared()
}
private func finishCurrent() async {
if state.repeatMode == .one {
await start(at: 0, playing: true)
} else {
await advance(auto: true)
}
}
private func failCurrent() async {
failureStreak += 1
guard failureStreak <= state.history.count + state.upcoming.count else {
anchor(0, playing: false)
await engine.stop()
return
}
await advance(auto: true)
}
private var nextToPrepare: QueueItem? {
if let next = state.upcoming.first {
return next
}
switch state.repeatMode {
case .off: return nil
case .one: return state.current
case .all: return state.history.first ?? state.current
}
}
private func syncPrepared() async {
let key = [state.current?.id, nextToPrepare?.id]
guard key != preparedKey else { return }
preparedKey = key
await engine.prepare(state.current == nil ? nil : nextToPrepare)
}
private func removeObserver(_ key: UUID) {
observers[key] = nil
}
private func publish() {
observers.values.forEach { $0.yield(state) }
}
private func anchor(_ position: TimeInterval, playing: Bool) {
state.anchor = PlaybackAnchor(position: position, instant: now(), rate: playing ? 1 : 0)
state.status = playing ? .playing : .paused
}
private var position: TimeInterval {
let raw = state.anchor.position(at: now())
return min(max(raw, 0), state.current?.track.duration ?? 0)
}
private func resume() async {
guard state.current != nil, state.status != .playing || state.anchor.rate == 0 else { return }
anchor(position, playing: true)
await engine.play()
}
private func pause() async {
guard state.status == .playing else { return }
anchor(position, playing: false)
await engine.pause()
}
private func seek(to target: TimeInterval) async {
guard let current = state.current else { return }
let clamped = min(max(target, 0), current.track.duration)
anchor(clamped, playing: state.status == .playing)
await engine.seek(to: clamped)
}
private func start(at position: TimeInterval, playing: Bool) async {
guard let current = state.current else {
state.status = .stopped
state.anchor = PlaybackAnchor(position: 0, instant: now(), rate: 0)
await engine.stop()
return
}
await engine.load(current, at: position)
anchor(position, playing: playing)
if playing {
await engine.play()
}
}
private func setQueue(_ tracks: [Track], startIndex: Int, shuffled: Bool) async {
let items = tracks.map { QueueItem(track: $0) }
originalOrder = items.map(\.id)
state.history = []
state.isShuffled = shuffled
guard !items.isEmpty else {
state.current = nil
state.upcoming = []
await start(at: 0, playing: false)
return
}
if shuffled {
var rest = items
let first = rest.remove(at: Int.random(in: rest.indices))
state.current = first
state.upcoming = rest.shuffled()
} else {
let index = min(max(startIndex, 0), items.count - 1)
state.history = Array(items[..<index])
state.current = items[index]
state.upcoming = Array(items[(index + 1)...])
}
await start(at: 0, playing: true)
}
private func insert(_ tracks: [Track], atEnd: Bool) {
let items = tracks.map { QueueItem(track: $0) }
originalOrder.append(contentsOf: items.map(\.id))
if atEnd {
state.upcoming.append(contentsOf: items)
} else {
state.upcoming.insert(contentsOf: items, at: 0)
}
}
private func advance(auto: Bool) async {
guard let current = state.current else { return }
var keepsPlaying = auto || state.status == .playing
if state.upcoming.isEmpty {
let all = state.history + [current]
state.history = []
state.current = all.first
state.upcoming = Array(all.dropFirst())
keepsPlaying = keepsPlaying && state.repeatMode != .off
} else {
state.history.append(current)
state.current = state.upcoming.removeFirst()
}
await start(at: 0, playing: keepsPlaying)
}
private func goBack() async {
guard let current = state.current else { return }
guard position <= restartThreshold, let previous = state.history.popLast() else {
await seek(to: 0)
return
}
state.upcoming.insert(current, at: 0)
state.current = previous
await start(at: 0, playing: state.status == .playing)
}
private func jump(to id: QueueItem.ID) async {
guard let current = state.current else { return }
if let index = state.upcoming.firstIndex(where: { $0.id == id }) {
state.history.append(current)
state.history.append(contentsOf: state.upcoming[..<index])
state.current = state.upcoming[index]
state.upcoming.removeSubrange(...index)
} else if let index = state.history.firstIndex(where: { $0.id == id }) {
state.upcoming.insert(contentsOf: Array(state.history[(index + 1)...]) + [current], at: 0)
state.current = state.history[index]
state.history.removeSubrange(index...)
} else {
return
}
await start(at: 0, playing: true)
}
private func move(_ id: QueueItem.ID, after target: QueueItem.ID?) {
guard let from = state.upcoming.firstIndex(where: { $0.id == id }) else { return }
let item = state.upcoming.remove(at: from)
let index = target.flatMap { anchorID in state.upcoming.firstIndex { $0.id == anchorID } }.map { $0 + 1 } ?? 0
state.upcoming.insert(item, at: index)
}
private func setShuffle(_ isOn: Bool) {
state.isShuffled = isOn
if isOn {
state.upcoming.shuffle()
} else {
let rank = Dictionary(uniqueKeysWithValues: originalOrder.enumerated().map { ($1, $0) })
state.upcoming.sort { (rank[$0.id] ?? .max) < (rank[$1.id] ?? .max) }
}
}
}
@@ -0,0 +1,56 @@
import Foundation
public actor SilentEngine: OutputEngine {
public nonisolated let events: AsyncStream<OutputEvent>
private let continuation: AsyncStream<OutputEvent>.Continuation
private var item: QueueItem?
private var position: TimeInterval = 0
private var startedAt: ContinuousClock.Instant?
private var timer: Task<Void, Never>?
public init() {
(events, continuation) = AsyncStream<OutputEvent>.makeStream()
}
public func load(_ item: QueueItem, at position: TimeInterval) {
timer?.cancel()
self.item = item
self.position = position
startedAt = nil
}
public func play() {
guard let item, startedAt == nil else { return }
startedAt = .now
let remaining = max(item.track.duration - position, 0)
timer = Task { [continuation] in
try? await Task.sleep(for: .seconds(remaining))
guard !Task.isCancelled else { return }
continuation.yield(.finished(item.id))
}
}
public func pause() {
timer?.cancel()
if let startedAt {
let elapsed = ContinuousClock.now - startedAt
position += Double(elapsed.components.seconds) + Double(elapsed.components.attoseconds) / 1e18
}
startedAt = nil
}
public func seek(to position: TimeInterval) {
let wasPlaying = startedAt != nil
pause()
self.position = position
if wasPlaying {
play()
}
}
public func stop() {
timer?.cancel()
item = nil
startedAt = nil
}
}
@@ -0,0 +1,129 @@
import CoreGraphics
import Foundation
import ImageIO
import UniformTypeIdentifiers
struct FakeArtwork {
private let context: CGContext
private let space = CGColorSpaceCreateDeviceRGB()
private let size: CGFloat
private let hue: Double
private let spread: Double
private var random: SeededGenerator
static func png(seed: String, side: Int) -> Data? {
guard var artwork = FakeArtwork(seed: seed, side: side) else { return nil }
artwork.draw()
guard let image = artwork.context.makeImage() else { return nil }
let data = NSMutableData()
guard let destination = CGImageDestinationCreateWithData(data, UTType.png.identifier as CFString, 1, nil) else { return nil }
CGImageDestinationAddImage(destination, image, nil)
return CGImageDestinationFinalize(destination) ? data as Data : nil
}
private init?(seed: String, side: Int) {
guard let context = CGContext(
data: nil,
width: side,
height: side,
bitsPerComponent: 8,
bytesPerRow: 0,
space: space,
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
) else { return nil }
self.context = context
size = CGFloat(side)
random = SeededGenerator(seed: seed.utf8.reduce(UInt64(14_695_981_039_346_656_037)) { ($0 ^ UInt64($1)) &* 1_099_511_628_211 })
hue = .random(in: 0 ..< 1, using: &random)
spread = .random(in: 0.08 ... 0.45, using: &random)
}
private mutating func draw() {
let isDark = Bool.random(using: &random)
let ground = [color(hue, 0.75, isDark ? 0.35 : 0.95), color(hue + spread, 0.8, isDark ? 0.12 : 0.7)]
linear(ground, from: CGPoint(x: 0, y: size), to: CGPoint(x: size, y: 0))
switch Int.random(in: 0 ..< 4, using: &random) {
case 0: glows()
case 1: rings()
case 2: sun(horizon: ground[1])
default: blocks()
}
}
private mutating func glows() {
for _ in 0 ..< 3 {
let center = CGPoint(x: .random(in: 0 ... size, using: &random), y: .random(in: 0 ... size, using: &random))
let tint = color(hue + .random(in: -0.2 ... 0.5, using: &random), 0.7, 1)
let radius = size * .random(in: 0.4 ... 0.8, using: &random)
guard let clear = tint.copy(alpha: 0),
let gradient = CGGradient(colorsSpace: space, colors: [tint, clear] as CFArray, locations: nil)
else { continue }
context.drawRadialGradient(gradient, startCenter: center, startRadius: 0, endCenter: center, endRadius: radius, options: [])
}
}
private mutating func rings() {
let center = CGPoint(x: size * .random(in: 0.3 ... 0.7, using: &random), y: size * .random(in: 0.3 ... 0.7, using: &random))
for ring in (1 ... 7).reversed() {
context.setFillColor(color(hue + spread * Double(ring) / 5, 0.65, ring.isMultiple(of: 2) ? 0.95 : 0.25))
let radius = size * 0.09 * CGFloat(ring)
context.fillEllipse(in: CGRect(x: center.x - radius, y: center.y - radius, width: radius * 2, height: radius * 2))
}
}
private func sun(horizon: CGColor) {
let radius = size * 0.32
let disc = CGRect(x: size / 2 - radius, y: size * 0.34, width: radius * 2, height: radius * 2)
context.saveGState()
context.addEllipse(in: disc)
context.clip()
linear(
[color(hue + 0.5, 0.5, 1), color(hue + 0.62, 0.85, 1)],
from: CGPoint(x: 0, y: disc.maxY),
to: CGPoint(x: 0, y: disc.minY)
)
context.restoreGState()
context.setFillColor(horizon)
for stripe in 0 ..< 5 {
let height = size * 0.012 * CGFloat(5 - stripe)
context.fill(CGRect(x: 0, y: disc.minY + CGFloat(stripe) * size * 0.06, width: size, height: height))
}
}
private mutating func blocks() {
let cells = Int.random(in: 2 ... 3, using: &random)
let cell = size / CGFloat(cells)
for row in 0 ..< cells {
for column in 0 ..< cells where Bool.random(using: &random) {
let rect = CGRect(x: CGFloat(column) * cell, y: CGFloat(row) * cell, width: cell, height: cell)
context.setFillColor(color(hue + .random(in: 0 ... 0.6, using: &random), 0.7, .random(in: 0.3 ... 1, using: &random)))
if Bool.random(using: &random) {
context.fill(rect)
} else {
context.fillEllipse(in: rect)
}
}
}
}
private func color(_ hue: Double, _ saturation: Double, _ brightness: Double) -> CGColor {
let sector = (hue.truncatingRemainder(dividingBy: 1) + 1).truncatingRemainder(dividingBy: 1) * 6
let chroma = brightness * saturation
let second = chroma * (1 - abs(sector.truncatingRemainder(dividingBy: 2) - 1))
let table = [
[chroma, second, 0],
[second, chroma, 0],
[0, chroma, second],
[0, second, chroma],
[second, 0, chroma],
[chroma, 0, second],
]
let rgb = table[min(Int(sector), 5)].map { $0 + brightness - chroma }
return CGColor(red: rgb[0], green: rgb[1], blue: rgb[2], alpha: 1)
}
private func linear(_ colors: [CGColor], from: CGPoint, to: CGPoint) {
guard let gradient = CGGradient(colorsSpace: space, colors: colors as CFArray, locations: nil) else { return }
context.drawLinearGradient(gradient, start: from, end: to, options: [.drawsBeforeStartLocation, .drawsAfterEndLocation])
}
}
@@ -0,0 +1,253 @@
import Domain
import Foundation
struct SeededGenerator: RandomNumberGenerator {
private var state: UInt64
init(seed: UInt64) {
state = seed &+ 0x9E37_79B9_7F4A_7C15
}
mutating func next() -> UInt64 {
state &+= 0x9E37_79B9_7F4A_7C15
var mixed = state
mixed = (mixed ^ (mixed >> 30)) &* 0xBF58_476D_1CE4_E5B9
mixed = (mixed ^ (mixed >> 27)) &* 0x94D0_49BB_1331_11EB
return mixed ^ (mixed >> 31)
}
}
struct FakeCatalog {
struct AlbumSeed {
let title: String
let year: Int
let tracks: Int
}
struct ArtistSeed {
let name: String
let genre: String
let albums: [AlbumSeed]
}
static let artists: [ArtistSeed] = [
ArtistSeed(name: "Velvet Harbor", genre: "Indie Pop", albums: [
AlbumSeed(title: "Salt in the Signal", year: 2024, tracks: 11),
AlbumSeed(title: "Low Tide Radio", year: 2021, tracks: 10),
AlbumSeed(title: "Postcards From Nowhere", year: 2018, tracks: 12),
]),
ArtistSeed(name: "Нина Орлова", genre: "Alternative", albums: [
AlbumSeed(title: "Тихий час", year: 2025, tracks: 9),
AlbumSeed(title: "Север внутри", year: 2022, tracks: 11),
]),
ArtistSeed(name: "Kaito Mori", genre: "Electronic", albums: [
AlbumSeed(title: "Neon Monsoon", year: 2023, tracks: 8),
AlbumSeed(title: "東京 After Hours", year: 2020, tracks: 10),
AlbumSeed(title: "Glass Circuits", year: 2017, tracks: 9),
AlbumSeed(title: "Soft Machines (Remixes)", year: 2024, tracks: 6),
]),
ArtistSeed(name: "The Paper Lanterns", genre: "Rock", albums: [
AlbumSeed(title: "Everything Burns Brighter at Night and Other Small Disasters", year: 2019, tracks: 13),
AlbumSeed(title: "Static Bloom", year: 2016, tracks: 11),
AlbumSeed(title: "Live at the Old Observatory", year: 2022, tracks: 16),
]),
ArtistSeed(name: "Marisol Vega", genre: "Latin", albums: [
AlbumSeed(title: "Corazón de Verano", year: 2024, tracks: 12),
AlbumSeed(title: "Luna Roja", year: 2021, tracks: 10),
]),
ArtistSeed(name: "Oskar Lindqvist Trio", genre: "Jazz", albums: [
AlbumSeed(title: "Blue Hours", year: 2015, tracks: 7),
AlbumSeed(title: "Winter Standards", year: 2018, tracks: 9),
AlbumSeed(title: "Stockholm Sessions", year: 2023, tracks: 8),
]),
ArtistSeed(name: "DJ Quartz", genre: "Hip-Hop", albums: [
AlbumSeed(title: "Concrete Gardens", year: 2022, tracks: 14),
AlbumSeed(title: "No Signal", year: 2025, tracks: 12),
]),
ArtistSeed(name: "Aurora Fields", genre: "Ambient", albums: [
AlbumSeed(title: "Weightless", year: 2020, tracks: 6),
AlbumSeed(title: "Slow Light", year: 2023, tracks: 7),
AlbumSeed(title: "Music for Empty Airports", year: 2017, tracks: 5),
]),
ArtistSeed(name: "Holloway & Finch", genre: "Folk", albums: [
AlbumSeed(title: "The Orchard Tapes", year: 2019, tracks: 10),
AlbumSeed(title: "Rivers We Crossed", year: 2024, tracks: 11),
]),
ArtistSeed(name: "Сёстры Грома", genre: "Rock", albums: [
AlbumSeed(title: "Электричество", year: 2023, tracks: 10),
AlbumSeed(title: "Город без сна", year: 2020, tracks: 12),
]),
ArtistSeed(name: "Camille Durand", genre: "Classical", albums: [
AlbumSeed(title: "Nocturnes & Preludes", year: 2021, tracks: 14),
AlbumSeed(title: "Satie: Works for Piano", year: 2016, tracks: 12),
]),
ArtistSeed(name: "Midnight Arcade", genre: "Electronic", albums: [
AlbumSeed(title: "Insert Coin", year: 2018, tracks: 10),
AlbumSeed(title: "High Score", year: 2021, tracks: 11),
AlbumSeed(title: "Continue?", year: 2025, tracks: 9),
]),
ArtistSeed(name: "Juno Okafor", genre: "R&B/Soul", albums: [
AlbumSeed(title: "Honey & Smoke", year: 2024, tracks: 12),
AlbumSeed(title: "Afterglow", year: 2022, tracks: 10),
]),
ArtistSeed(name: "Iron Meridian", genre: "Metal", albums: [
AlbumSeed(title: "Cathedral of Ash", year: 2019, tracks: 9),
AlbumSeed(title: "Tidal Lock", year: 2023, tracks: 8),
]),
]
static let openers = [
"Midnight", "Paper", "Golden", "Silent", "Electric", "Hollow", "Crimson", "Distant", "Velvet", "Broken",
"Summer", "Neon", "Quiet", "Wild", "Frozen", "Burning", "Little", "Endless", "Lonely", "Silver",
]
static let closers = [
"Hearts", "Rivers", "Machines", "Letters", "Highways", "Gardens", "Satellites", "Windows", "Echoes", "Tides",
"Dreams", "Wires", "Shadows", "Mornings", "Skylines", "Ghosts", "Oceans", "Rooms", "Signals", "Fires",
]
static let singles = [
"Interlude", "Overture", "Home", "Gravity", "Again", "Stay", "Run", "Waves", "Closer", "Tomorrow",
"Если бы", "Навсегда", "Après la pluie", "Sin Ti", "さよなら",
]
static let suffixes = ["", "", "", "", "", "", " (Reprise)", " (feat. Juno Okafor)", " (Acoustic)", ", Pt. 2"]
static let formats: [AudioFormat] = [
AudioFormat(codec: .flac, bitrateKbps: 1011, sampleRate: 44100, bitDepth: 16),
AudioFormat(codec: .flac, bitrateKbps: 2890, sampleRate: 96000, bitDepth: 24),
AudioFormat(codec: .flac, bitrateKbps: 1411, sampleRate: 48000, bitDepth: 24),
AudioFormat(codec: .mp3, bitrateKbps: 320, sampleRate: 44100),
AudioFormat(codec: .aac, bitrateKbps: 256, sampleRate: 44100),
]
let source: SourceID
private(set) var changes: [Change] = []
init(source: SourceID, now: Date = Date()) {
self.source = source
var random = SeededGenerator(seed: 42)
var allTracks: [Track] = []
var albumIndex = 0
for (artistIndex, seed) in Self.artists.enumerated() {
let artistID = id("artist-\(artistIndex)")
let artist = Artist(
id: artistID,
name: seed.name,
sortName: seed.name.hasPrefix("The ") ? String(seed.name.dropFirst(4)) : seed.name,
artwork: id("art-artist-\(artistIndex)"),
albumCount: seed.albums.count
)
changes.append(.upsert(.artist(artist)))
for albumSeed in seed.albums {
let tracks = makeTracks(albumSeed, albumIndex: albumIndex, artist: artist, genre: seed.genre, random: &random)
let album = Album(
id: id("album-\(albumIndex)"),
title: albumSeed.title,
artistID: artistID,
artistName: seed.name,
year: albumSeed.year,
genre: seed.genre,
artwork: id("art-album-\(albumIndex)"),
trackCount: tracks.count,
duration: tracks.reduce(0) { $0 + $1.duration },
addedAt: now.addingTimeInterval(-Double(Int.random(in: 1 ... 400, using: &random)) * 86400),
bestFormat: tracks.first?.formats.first
)
changes.append(.upsert(.album(album)))
changes.append(contentsOf: tracks.map { .upsert(.track($0)) })
allTracks.append(contentsOf: tracks)
albumIndex += 1
}
}
makePlaylists(from: allTracks, now: now, random: &random)
makeAnnotations(for: allTracks, now: now, random: &random)
makeStations()
}
private func id(_ remote: String) -> EntityID {
EntityID(source: source, remote: remote)
}
private func makeTracks(
_ seed: AlbumSeed,
albumIndex: Int,
artist: Artist,
genre: String,
random: inout SeededGenerator
) -> [Track] {
let format = Self.formats[Int.random(in: 0 ..< Self.formats.count, using: &random)]
return (1 ... seed.tracks).map { number in
let base = Int.random(in: 0 ..< 5, using: &random) == 0
? Self.singles.randomElement(using: &random) ?? ""
: "\(Self.openers.randomElement(using: &random) ?? "") \(Self.closers.randomElement(using: &random) ?? "")"
return Track(
id: id("track-\(albumIndex)-\(number)"),
title: base + (Self.suffixes.randomElement(using: &random) ?? ""),
albumID: id("album-\(albumIndex)"),
albumTitle: seed.title,
artistID: artist.id,
artistName: artist.name,
trackNumber: number,
duration: genre == "Ambient" || genre == "Jazz"
? Double(Int.random(in: 280 ... 620, using: &random))
: Double(Int.random(in: 118 ... 325, using: &random)),
year: seed.year,
genre: genre,
artwork: id("art-album-\(albumIndex)"),
isExplicit: genre == "Hip-Hop" && Int.random(in: 0 ..< 2, using: &random) == 0,
formats: [format]
)
}
}
private mutating func makePlaylists(from tracks: [Track], now: Date, random: inout SeededGenerator) {
let seeds: [(name: String, summary: String)] = [
("Late Night Drive", "Windows down, city lights."),
("Focus", "Instrumental and quiet."),
("Утро", ""),
("Gym", "Loud."),
("Sunday Kitchen", "Cooking with the radio on."),
]
for (index, seed) in seeds.enumerated() {
let picked = Array(tracks.shuffled(using: &random).prefix(12 + index * 5))
let playlist = Playlist(
id: id("playlist-\(index)"),
name: seed.name,
summary: seed.summary,
artwork: id("art-playlist-\(index)"),
trackCount: picked.count,
duration: picked.reduce(0) { $0 + $1.duration },
updatedAt: now.addingTimeInterval(-Double(index) * 86400 * 9)
)
let entries = picked.enumerated().map { PlaylistEntry(id: "entry-\(index)-\($0.offset)", track: $0.element.id) }
changes.append(.upsert(.playlist(playlist, entries: entries)))
}
}
private mutating func makeAnnotations(for tracks: [Track], now: Date, random: inout SeededGenerator) {
for track in tracks.shuffled(using: &random).prefix(140) {
let plays = Int.random(in: 1 ... 60, using: &random)
changes.append(.annotate(track.id, Annotation(
favoritedAt: plays > 42 ? now.addingTimeInterval(-Double(plays) * 3600) : nil,
playCount: plays,
lastPlayedAt: now.addingTimeInterval(-Double(Int.random(in: 1 ... 900, using: &random)) * 3600)
)))
}
}
private mutating func makeStations() {
let seeds = [
("Radio Paradise", "Eclectic, listener supported", "https://stream.radioparadise.com/flacm"),
("SomaFM Groove Salad", "Ambient and downtempo beats", "https://ice1.somafm.com/groovesalad-256-mp3"),
("KEXP 90.3", "Where the music matters", "https://kexp-mp3-128.streamguys1.com/kexp128.mp3"),
("FIP", "Radio France, sans frontières", "https://icecast.radiofrance.fr/fip-hifi.aac"),
]
for (index, seed) in seeds.enumerated() {
guard let url = URL(string: seed.2) else { continue }
changes.append(.upsert(.station(RadioStation(
id: id("station-\(index)"),
name: seed.0,
tagline: seed.1,
streamURL: url,
artwork: id("art-station-\(index)")
))))
}
}
}
@@ -0,0 +1,75 @@
import Domain
import Foundation
import Synchronization
public final class FakeSource: LibrarySource, Starring, LyricsSource {
public let id: SourceID
public let name: String
private let catalog: FakeCatalog
private let artworkDirectory: URL
private struct Extras {
var changes: [Change] = []
var artwork: [String: URL] = [:]
var lyrics: [String: URL] = [:]
}
private let extras = Mutex(Extras())
public init(id: SourceID = SourceID(), name: String = "Demo Library") {
self.id = id
self.name = name
catalog = FakeCatalog(source: id)
artworkDirectory = FileManager.default.temporaryDirectory.appending(path: "FakeSourceArtwork", directoryHint: .isDirectory)
try? FileManager.default.createDirectory(at: artworkDirectory, withIntermediateDirectories: true)
}
public var sampleTracks: [Track] {
catalog.changes.compactMap {
if case let .upsert(.track(track)) = $0 {
return track
}
return nil
}
}
public func add(_ changes: [Change], artworkFiles: [String: URL], lyricsFiles: [String: URL] = [:]) {
extras.withLock {
$0.changes += changes
$0.artwork.merge(artworkFiles) { $1 }
$0.lyrics.merge(lyricsFiles) { $1 }
}
}
public func lyrics(for track: EntityID) async throws -> Lyrics? {
guard let file = extras.withLock({ $0.lyrics[track.remote] }) else { return nil }
return try Lyrics(lrc: String(contentsOf: file, encoding: .utf8))
}
public func changes(since cursor: Cursor?) -> AsyncThrowingStream<Change, Error> {
let changes = cursor == nil ? catalog.changes + extras.withLock(\.changes) : []
return AsyncThrowingStream { continuation in
changes.forEach { continuation.yield($0) }
continuation.yield(.checkpoint(Cursor("1")))
continuation.finish()
}
}
public func stream(for _: EntityID) async throws -> StreamRequest {
throw SourceError.notFound
}
public func artwork(_ id: EntityID, pixelSize: Int) async throws -> ResourceRequest {
if let file = extras.withLock({ $0.artwork[id.remote] }) {
return ResourceRequest(url: file)
}
let side = pixelSize <= 320 ? 320 : 1024
let file = artworkDirectory.appending(path: "\(id.remote)-\(side).png")
if !FileManager.default.fileExists(atPath: file.path) {
guard let data = FakeArtwork.png(seed: id.remote, side: side) else { throw SourceError.notFound }
try data.write(to: file, options: .atomic)
}
return ResourceRequest(url: file)
}
public func setFavorite(_: Bool, for _: EntityID, kind _: EntityKind) async throws {}
}
@@ -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"])
}
}
@@ -0,0 +1,50 @@
import Domain
import Foundation
public enum AlbumSort: String, CaseIterable, Sendable {
case recentlyAdded, title, artist, year
}
public struct SearchResults: Sendable {
public var artists: [Artist] = []
public var albums: [Album] = []
public var tracks: [Track] = []
public var playlists: [Playlist] = []
public init() {}
public var isEmpty: Bool {
artists.isEmpty && albums.isEmpty && tracks.isEmpty && playlists.isEmpty
}
}
public protocol Library: Sendable {
func apply(_ changes: [Change]) async
func cursor(for source: SourceID) async -> Cursor?
func setCursor(_ cursor: Cursor, for source: SourceID) async
func updates() async -> AsyncStream<Void>
func artists() async -> [Artist]
func artist(_ id: EntityID) async -> Artist?
func albums(sortedBy sort: AlbumSort) async -> [Album]
func albums(byArtist id: EntityID) async -> [Album]
func albums(inGenre genre: String) async -> [Album]
func album(_ id: EntityID) async -> Album?
func tracks() async -> [Track]
func tracks(inAlbum id: EntityID) async -> [Track]
func tracks(byArtist id: EntityID) async -> [Track]
func tracks(inGenre genre: String) async -> [Track]
func tracks(inPlaylist id: EntityID) async -> [Track]
func playlists() async -> [Playlist]
func stations() async -> [RadioStation]
func genres() async -> [Genre]
func recentlyPlayed(limit: Int) async -> [Album]
func mostPlayed(limit: Int) async -> [Album]
func favoriteTracks() async -> [Track]
func favorites() async -> Set<EntityID>
func setFavorite(_ isFavorite: Bool, for id: EntityID) async
func recordPlay(of track: Track, at date: Date) async
func search(_ query: String) async -> SearchResults
}
@@ -0,0 +1,194 @@
import Domain
import Foundation
public actor MemoryLibrary: Library {
private var artistsByID: [EntityID: Artist] = [:]
private var albumsByID: [EntityID: Album] = [:]
private var tracksByID: [EntityID: Track] = [:]
private var playlistsByID: [EntityID: Playlist] = [:]
private var entriesByPlaylist: [EntityID: [PlaylistEntry]] = [:]
private var stationsByID: [EntityID: RadioStation] = [:]
private var annotations: [EntityID: Annotation] = [:]
private var cursors: [SourceID: Cursor] = [:]
private var observers: [UUID: AsyncStream<Void>.Continuation] = [:]
public init() {}
public func apply(_ changes: [Change]) {
for change in changes {
switch change {
case let .upsert(.artist(artist)): artistsByID[artist.id] = artist
case let .upsert(.album(album)): albumsByID[album.id] = album
case let .upsert(.track(track)): tracksByID[track.id] = track
case let .upsert(.playlist(playlist, entries)):
playlistsByID[playlist.id] = playlist
entriesByPlaylist[playlist.id] = entries
case let .upsert(.station(station)): stationsByID[station.id] = station
case let .remove(kind, id): remove(kind, id)
case let .annotate(id, annotation): annotations[id] = annotation
case .checkpoint: break
}
}
notify()
}
private func remove(_ kind: EntityKind, _ id: EntityID) {
switch kind {
case .artist: artistsByID[id] = nil
case .album: albumsByID[id] = nil
case .track: tracksByID[id] = nil
case .playlist:
playlistsByID[id] = nil
entriesByPlaylist[id] = nil
case .station: stationsByID[id] = nil
}
annotations[id] = nil
}
public func cursor(for source: SourceID) -> Cursor? {
cursors[source]
}
public func setCursor(_ cursor: Cursor, for source: SourceID) {
cursors[source] = cursor
}
public func updates() -> AsyncStream<Void> {
let key = UUID()
let (stream, continuation) = AsyncStream<Void>.makeStream(bufferingPolicy: .bufferingNewest(1))
observers[key] = continuation
continuation.onTermination = { [weak self] _ in
Task { await self?.removeObserver(key) }
}
return stream
}
private func removeObserver(_ key: UUID) {
observers[key] = nil
}
private func notify() {
observers.values.forEach { $0.yield() }
}
public func artists() -> [Artist] {
artistsByID.values.sorted { $0.sortName.localizedStandardCompare($1.sortName) == .orderedAscending }
}
public func artist(_ id: EntityID) -> Artist? {
artistsByID[id]
}
public func albums(sortedBy sort: AlbumSort) -> [Album] {
let all = Array(albumsByID.values)
switch sort {
case .recentlyAdded: return all.sorted { $0.addedAt > $1.addedAt }
case .title: return all.sorted { $0.title.localizedStandardCompare($1.title) == .orderedAscending }
case .artist:
return all.sorted {
let order = $0.artistName.localizedStandardCompare($1.artistName)
return order == .orderedSame ? ($0.year ?? 0) < ($1.year ?? 0) : order == .orderedAscending
}
case .year: return all.sorted { ($0.year ?? 0) > ($1.year ?? 0) }
}
}
public func albums(byArtist id: EntityID) -> [Album] {
albumsByID.values.filter { $0.artistID == id }.sorted { ($0.year ?? 0) > ($1.year ?? 0) }
}
public func albums(inGenre genre: String) -> [Album] {
albums(sortedBy: .artist).filter { $0.genre == genre }
}
public func album(_ id: EntityID) -> Album? {
albumsByID[id]
}
public func tracks() -> [Track] {
tracksByID.values.sorted { $0.title.localizedStandardCompare($1.title) == .orderedAscending }
}
public func tracks(inAlbum id: EntityID) -> [Track] {
tracksByID.values.filter { $0.albumID == id }.sorted { ($0.discNumber, $0.trackNumber) < ($1.discNumber, $1.trackNumber) }
}
public func tracks(byArtist id: EntityID) -> [Track] {
tracksByID.values.filter { $0.artistID == id }
.sorted { (annotations[$0.id]?.playCount ?? 0, $1.title) > (annotations[$1.id]?.playCount ?? 0, $0.title) }
}
public func tracks(inGenre genre: String) -> [Track] {
tracksByID.values.filter { $0.genre == genre }
}
public func tracks(inPlaylist id: EntityID) -> [Track] {
(entriesByPlaylist[id] ?? []).compactMap { tracksByID[$0.track] }
}
public func playlists() -> [Playlist] {
playlistsByID.values.sorted { $0.updatedAt > $1.updatedAt }
}
public func stations() -> [RadioStation] {
stationsByID.values.sorted { $0.name < $1.name }
}
public func genres() -> [Genre] {
Dictionary(grouping: albumsByID.values.filter { $0.genre != nil }, by: { $0.genre ?? "" })
.map { Genre(name: $0.key, albumCount: $0.value.count) }
.sorted { $0.name < $1.name }
}
private func albumsRanked(limit: Int, by score: (Annotation) -> Double?) -> [Album] {
var best: [EntityID: Double] = [:]
for (id, annotation) in annotations {
guard let value = score(annotation), let album = tracksByID[id]?.albumID else { continue }
best[album] = max(best[album] ?? -.infinity, value)
}
return best.sorted { $0.value > $1.value }.prefix(limit).compactMap { albumsByID[$0.key] }
}
public func recentlyPlayed(limit: Int) -> [Album] {
albumsRanked(limit: limit) { $0.lastPlayedAt?.timeIntervalSince1970 }
}
public func mostPlayed(limit: Int) -> [Album] {
albumsRanked(limit: limit) { $0.playCount > 0 ? Double($0.playCount) : nil }
}
public func favoriteTracks() -> [Track] {
tracksByID.values
.filter { annotations[$0.id]?.favoritedAt != nil }
.sorted { (annotations[$0.id]?.favoritedAt ?? .distantPast) > (annotations[$1.id]?.favoritedAt ?? .distantPast) }
}
public func favorites() -> Set<EntityID> {
Set(annotations.filter { $0.value.favoritedAt != nil }.keys)
}
public func setFavorite(_ isFavorite: Bool, for id: EntityID) {
annotations[id, default: Annotation()].favoritedAt = isFavorite ? Date() : nil
notify()
}
public func recordPlay(of track: Track, at date: Date) {
annotations[track.id, default: Annotation()].playCount += 1
annotations[track.id, default: Annotation()].lastPlayedAt = date
notify()
}
public func search(_ query: String) -> SearchResults {
let needle = query.trimmingCharacters(in: .whitespaces)
guard !needle.isEmpty else { return SearchResults() }
func matches(_ text: String) -> Bool {
text.range(of: needle, options: [.caseInsensitive, .diacriticInsensitive]) != nil
}
var results = SearchResults()
results.artists = artists().filter { matches($0.name) }
results.albums = albums(sortedBy: .title).filter { matches($0.title) }
results.tracks = Array(tracks().filter { matches($0.title) }.prefix(50))
results.playlists = playlists().filter { matches($0.name) }
return results
}
}
@@ -0,0 +1,39 @@
import Domain
import Store
public actor SyncEngine {
private let library: any Library
private let batchSize = 500
public init(library: any Library) {
self.library = library
}
public func sync(_ source: any LibrarySource) async throws {
do {
try await pull(source, since: library.cursor(for: source.id))
} catch SourceError.cannotCalculateChanges {
try await pull(source, since: nil)
}
}
private func pull(_ source: any LibrarySource, since cursor: Cursor?) async throws {
var batch: [Change] = []
for try await change in source.changes(since: cursor) {
if case let .checkpoint(next) = change {
await library.apply(batch)
batch.removeAll(keepingCapacity: true)
await library.setCursor(next, for: source.id)
} else {
batch.append(change)
if batch.count >= batchSize {
await library.apply(batch)
batch.removeAll(keepingCapacity: true)
}
}
}
if !batch.isEmpty {
await library.apply(batch)
}
}
}
@@ -0,0 +1,21 @@
import Domain
import Testing
struct LyricsTests {
@Test func parsesSyncedLines() {
let lyrics = Lyrics(lrc: "[00:11.39]Первая\n[00:13.61][01:20.18]Повтор\n[02:00.75]\n")
#expect(lyrics?.isSynced == true)
#expect(lyrics?.lines.map(\.text) == ["Первая", "Повтор", "Повтор", ""])
#expect(lyrics?.lines[2].time == 80.18)
#expect(lyrics?.index(at: 5) == nil)
#expect(lyrics?.index(at: 12) == 0)
#expect(lyrics?.index(at: 90) == 2)
}
@Test func fallsBackToPlainText() {
let lyrics = Lyrics(lrc: "Просто строка\nЕщё одна")
#expect(lyrics?.isSynced == false)
#expect(lyrics?.lines.count == 2)
#expect(Lyrics(lrc: "") == nil)
}
}
@@ -0,0 +1,185 @@
import Domain
import Foundation
@testable import Playback
import Testing
actor RecordingEngine: OutputEngine {
nonisolated let events: AsyncStream<OutputEvent>
nonisolated let continuation: AsyncStream<OutputEvent>.Continuation
private(set) var loaded: [QueueItem.ID] = []
private(set) var prepared: [QueueItem.ID?] = []
init() {
(events, continuation) = AsyncStream<OutputEvent>.makeStream()
}
func load(_ item: QueueItem, at _: TimeInterval) {
loaded.append(item.id)
}
func prepare(_ item: QueueItem?) {
prepared.append(item?.id)
}
func play() {}
func pause() {}
func seek(to _: TimeInterval) {}
func stop() {}
}
final class FakeClock: @unchecked Sendable {
private let lock = NSLock()
private var instant = ContinuousClock.now
var now: ContinuousClock.Instant {
lock.withLock { instant }
}
func advance(_ seconds: Double) {
lock.withLock { instant += .seconds(seconds) }
}
}
private func tracks(_ count: Int) -> [Track] {
let source = SourceID()
return (0 ..< count).map {
Track(
id: EntityID(source: source, remote: "\($0)"),
title: "Track \($0)",
albumID: nil,
albumTitle: "",
artistID: nil,
artistName: "",
duration: 200
)
}
}
@Test func queueStartsAtIndexAndAnchorsPosition() async {
let clock = FakeClock()
let session = PlaybackSession(engine: RecordingEngine()) { clock.now }
await session.send(.setQueue(tracks: tracks(5), startIndex: 2, shuffled: false))
clock.advance(10)
let state = await session.snapshot
#expect(state.current?.track.title == "Track 2")
#expect(state.history.count == 2 && state.upcoming.count == 2)
#expect(state.status == .playing)
#expect(abs(state.anchor.position(at: clock.now) - 10) < 0.001)
}
@Test func pauseFreezesAnchor() async {
let clock = FakeClock()
let session = PlaybackSession(engine: RecordingEngine()) { clock.now }
await session.send(.setQueue(tracks: tracks(2), startIndex: 0, shuffled: false))
clock.advance(30)
await session.send(.pause)
clock.advance(30)
let state = await session.snapshot
#expect(abs(state.anchor.position(at: clock.now) - 30) < 0.001)
}
@Test func previousRestartsAfterThresholdAndGoesBackBefore() async {
let clock = FakeClock()
let session = PlaybackSession(engine: RecordingEngine()) { clock.now }
await session.send(.setQueue(tracks: tracks(3), startIndex: 1, shuffled: false))
clock.advance(10)
await session.send(.previous)
#expect(await session.snapshot.current?.track.title == "Track 1")
await session.send(.previous)
#expect(await session.snapshot.current?.track.title == "Track 0")
}
@Test func queueEditsAddressItemsByID() async {
let session = PlaybackSession(engine: RecordingEngine())
await session.send(.setQueue(tracks: tracks(4), startIndex: 0, shuffled: false))
let upcoming = await session.snapshot.upcoming
await session.send(.move(item: upcoming[2].id, after: nil))
await session.send(.remove(upcoming[0].id))
#expect(await session.snapshot.upcoming.map(\.track.title) == ["Track 3", "Track 2"])
await session.send(.jump(to: upcoming[1].id))
let state = await session.snapshot
#expect(state.current?.track.title == "Track 2" && state.upcoming.isEmpty)
}
@Test func finishedTrackAdvancesAndStopsAtEndWithoutRepeat() async throws {
let engine = RecordingEngine()
let session = PlaybackSession(engine: engine)
let states = await session.states()
await session.send(.setQueue(tracks: tracks(2), startIndex: 0, shuffled: false))
for expected in [("Track 1", PlaybackStatus.playing), ("Track 0", .paused)] {
let current = try #require(await session.snapshot.current)
engine.continuation.yield(.finished(current.id))
for await state in states where state.current?.id != current.id {
#expect(state.current?.track.title == expected.0 && state.status == expected.1)
break
}
}
}
@Test func unshuffleRestoresOriginalOrder() async {
let session = PlaybackSession(engine: RecordingEngine())
await session.send(.setQueue(tracks: tracks(8), startIndex: 0, shuffled: false))
await session.send(.setShuffle(true))
await session.send(.setShuffle(false))
#expect(await session.snapshot.upcoming.map(\.track.title) == (1 ..< 8).map { "Track \($0)" })
}
@Test func prepareFollowsHeadOfUpcoming() async {
let engine = RecordingEngine()
let session = PlaybackSession(engine: engine)
await session.send(.setQueue(tracks: tracks(3), startIndex: 0, shuffled: false))
var state = await session.snapshot
#expect(await engine.prepared == [state.upcoming[0].id])
await session.send(.next)
state = await session.snapshot
#expect(await engine.prepared.last == state.upcoming[0].id)
await session.send(.next)
#expect(await engine.prepared.last == .some(nil))
}
@Test func anchorEventReanchorsCurrentAndIgnoresStaleID() async throws {
let clock = FakeClock()
let engine = RecordingEngine()
let session = PlaybackSession(engine: engine) { clock.now }
let states = await session.states()
await session.send(.setQueue(tracks: tracks(2), startIndex: 0, shuffled: false))
let current = try #require(await session.snapshot.current)
engine.continuation.yield(.anchor(UUID(), position: 50, rate: 1))
engine.continuation.yield(.anchor(current.id, position: 70, rate: 0))
for await state in states where state.anchor.rate == 0 {
#expect(state.status == .playing && state.current?.id == current.id)
#expect(abs(state.anchor.position(at: clock.now) - 70) < 0.001)
break
}
}
@Test func failedTrackAdvancesToNext() async throws {
let engine = RecordingEngine()
let session = PlaybackSession(engine: engine)
let states = await session.states()
await session.send(.setQueue(tracks: tracks(2), startIndex: 0, shuffled: false))
let current = try #require(await session.snapshot.current)
engine.continuation.yield(.failed(current.id))
for await state in states where state.current?.id != current.id {
#expect(state.current?.track.title == "Track 1" && state.status == .playing)
break
}
}
@Test func everyTrackFailingStopsInsteadOfLooping() async throws {
let engine = RecordingEngine()
let session = PlaybackSession(engine: engine)
let states = await session.states()
await session.send(.setQueue(tracks: tracks(2), startIndex: 0, shuffled: false))
await session.send(.setRepeat(.all))
for _ in 0 ..< 3 {
let current = try #require(await session.snapshot.current)
engine.continuation.yield(.failed(current.id))
for await state in states where state.current?.id != current.id || state.status == .paused {
break
}
}
let state = await session.snapshot
#expect(state.status == .paused && state.anchor.rate == 0)
#expect(await engine.loaded.count == 2)
}
@@ -0,0 +1,87 @@
import Domain
import Foundation
@testable import SourceSubsonic
struct FixtureTransport: Sendable {
var droppedAlbum: String?
static func fixture(_ name: String) throws -> Data {
let url = try unwrap(Bundle.module.url(forResource: name, withExtension: "json", subdirectory: "Fixtures"))
return try Data(contentsOf: url)
}
static func empty(_ key: String) -> Data {
Data(#"{"subsonic-response":{"status":"ok","version":"1.16.1","\#(key)":{}}}"#.utf8)
}
static func method(of request: URLRequest) -> (String, [String: String]) {
let url = request.url ?? URL(filePath: "/")
let method = url.lastPathComponent.replacingOccurrences(of: ".view", with: "")
let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? []
return (method, Dictionary(items.map { ($0.name, $0.value ?? "") }) { $1 })
}
func callAsFunction(_ request: URLRequest) async throws -> (Data, URLResponse) {
let (method, params) = Self.method(of: request)
let response = HTTPURLResponse(url: request.url ?? URL(filePath: "/"), statusCode: 200, httpVersion: nil, headerFields: nil)
return try (data(for: method, params: params), response ?? URLResponse())
}
private func data(for method: String, params: [String: String]) throws -> Data {
switch method {
case "getArtists": try Self.fixture("artists")
case "search3" where params["songOffset"] == "0": try Self.fixture("search3All")
case "search3": Self.empty("searchResult3")
case "getAlbumList2" where params["offset"] == "0": try albums()
case "getAlbumList2": Self.empty("albumList2")
case "getPlaylists": try Self.fixture("playlists")
case "getPlaylist": try Self.fixture("playlist")
case "getInternetRadioStations": try Self.fixture("radioStations")
case "getStarred2": try Self.fixture("starred2")
case "getLyricsBySongId": try Self.fixture("lyricsEmpty")
default: try Self.fixture("ping")
}
}
private func albums() throws -> Data {
let data = try Self.fixture("albumList2All")
guard let droppedAlbum else { return data }
var root = try unwrap(JSONSerialization.jsonObject(with: data) as? [String: Any])
var response = try unwrap(root["subsonic-response"] as? [String: Any])
var list = try unwrap(response["albumList2"] as? [String: Any])
let albums = try unwrap(list["album"] as? [[String: Any]])
list["album"] = albums.filter { $0["id"] as? String != droppedAlbum }
response["albumList2"] = list
root["subsonic-response"] = response
return try JSONSerialization.data(withJSONObject: root)
}
}
extension SubsonicSource {
static func fixture(transport: FixtureTransport = FixtureTransport(), id: SourceID = SourceID()) -> SubsonicSource {
let client = SubsonicClient(
baseURL: URL(string: "https://music.example.test")!,
username: "h",
password: "secret",
transport: { try await transport($0) }
)
return SubsonicSource(id: id, name: "Test", client: client)
}
func collect() async throws -> [Change] {
var collected: [Change] = []
for try await change in changes(since: nil) {
collected.append(change)
}
return collected
}
}
private func unwrap<T>(_ value: T?) throws -> T {
guard let value else { throw FixtureError.missing }
return value
}
private enum FixtureError: Error {
case missing
}
@@ -0,0 +1 @@
{"subsonic-response":{"status":"ok","version":"1.16.1","type":"navidrome","serverVersion":"0.63.2 (be10f89c)","openSubsonic":true,"album":{"id":"5sPoyWVCZcD9yiA97o0FbY","name":"Shh!","artist":"Kai Angel","artistId":"2FXyezR7e5yT2ZwTDFF5ps","coverArt":"al-5sPoyWVCZcD9yiA97o0FbY_6aa31dbd","songCount":14,"duration":1590,"playCount":31,"created":"2026-09-10T21:16:06.756840041Z","year":2026,"genre":"Alternative Rap","played":"2026-09-20T20:21:27.738621024Z","userRating":0,"genres":[{"name":"Alternative Rap"}],"musicBrainzId":"","isCompilation":false,"sortName":"shh!","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"2FXyezR7e5yT2ZwTDFF5ps","name":"Kai Angel"}],"displayArtist":"Kai Angel","explicitStatus":"","version":"","song":[{"id":"2XjfMC2rf5m85Ln844iLxe","parent":"5sPoyWVCZcD9yiA97o0FbY","isDir":false,"title":"Shh!","album":"Shh!","artist":"Kai Angel","track":1,"year":2026,"genre":"Alternative Rap","coverArt":"mf-2XjfMC2rf5m85Ln844iLxe_6aa31dbc","size":1413073,"contentType":"audio/flac","suffix":"flac","duration":60,"bitRate":173,"path":"Kai Angel/Shh!/01-01 - Shh!.flac","playCount":1,"discNumber":1,"created":"2026-09-10T21:16:06.783726686Z","albumId":"5sPoyWVCZcD9yiA97o0FbY","artistId":"2FXyezR7e5yT2ZwTDFF5ps","type":"music","played":"2026-09-10T21:16:40.955Z","bpm":0,"comment":"","sortName":"shh!","mediaType":"song","musicBrainzId":"","isrc":["FRX762654565"],"genres":[{"name":"Alternative Rap"}],"replayGain":{},"channelCount":2,"samplingRate":44100,"bitDepth":16,"moods":[],"artists":[{"id":"2FXyezR7e5yT2ZwTDFF5ps","name":"Kai Angel"}],"displayArtist":"Kai Angel","albumArtists":[{"id":"2FXyezR7e5yT2ZwTDFF5ps","name":"Kai Angel"}],"displayAlbumArtist":"Kai Angel","contributors":[{"role":"performer","artist":{"id":"2FXyezR7e5yT2ZwTDFF5ps","name":"Kai Angel"}},{"role":"composer","artist":{"id":"1iovHlVfkopxgRWmOVFcIq","name":"Kai Angel \u0026 Dmitri Itskov"}}],"displayComposer":"Kai Angel \u0026 Dmitri Itskov","explicitStatus":"","groupings":[],"works":[],"movements":[]},{"id":"PpRAkqAajLOjMdMRIP8pEM","parent":"5sPoyWVCZcD9yiA97o0FbY","isDir":false,"title":"chateau","album":"Shh!","artist":"Kai Angel","track":2,"year":2026,"genre":"Alternative Rap","coverArt":"mf-PpRAkqAajLOjMdMRIP8pEM_6aa31dbc","size":16291360,"contentType":"audio/flac","suffix":"flac","duration":116,"bitRate":1111,"path":"Kai Angel/Shh!/01-02 - chateau.flac","playCount":2,"discNumber":1,"created":"2026-09-10T21:16:06.769095977Z","albumId":"5sPoyWVCZcD9yiA97o0FbY","artistId":"2FXyezR7e5yT2ZwTDFF5ps","type":"music","played":"2026-09-11T19:32:25.09Z","bpm":0,"comment":"","sortName":"chateau","mediaType":"song","musicBrainzId":"","isrc":["FRX762654566"],"genres":[{"name":"Alternative Rap"}],"replayGain":{},"channelCount":2,"samplingRate":44100,"bitDepth":16,"moods":[],"artists":[{"id":"2FXyezR7e5yT2ZwTDFF5ps","name":"Kai Angel"}],"displayArtist":"Kai Angel","albumArtists":[{"id":"2FXyezR7e5yT2ZwTDFF5ps","name":"Kai Angel"}],"displayAlbumArtist":"Kai Angel","contributors":[{"role":"performer","artist":{"id":"2FXyezR7e5yT2ZwTDFF5ps","name":"Kai Angel"}},{"role":"composer","artist":{"id":"2aKpbUZ0l9FuDeYo57E7Jk","name":"Dmitri Itskov"}}],"displayComposer":"Dmitri Itskov","explicitStatus":"","groupings":[],"works":[],"movements":[]},{"id":"1vD0jhSIaCcOOTHp8gCB6o","parent":"5sPoyWVCZcD9yiA97o0FbY","isDir":false,"title":"gladiator","album":"Shh!","artist":"Kai Angel","track":3,"year":2026,"genre":"Alternative Rap","coverArt":"mf-1vD0jhSIaCcOOTHp8gCB6o_6aa31dbc","size":14395244,"contentType":"audio/flac","suffix":"flac","duration":103,"bitRate":1104,"path":"Kai Angel/Shh!/01-03 - gladiator.flac","playCount":2,"discNumber":1,"created":"2026-09-10T21:16:06.785279872Z","albumId":"5sPoyWVCZcD9yiA97o0FbY","artistId":"2FXyezR7e5yT2ZwTDFF5ps","type":"music","played":"2026-09-11T19:24:55.741Z","bpm":0,"comment":"","sortName":"gladiator","mediaType":"song","musicBrainzId":"","isrc":["FRX762654567"],"genres":[{"name":"Alternative Rap"}],"replayGain":{},"channelCount":2,"samplingRate":44100,"bitDepth":16,"moods":[],"artists":[{"id":"2FXyezR7e5yT2ZwTDFF5ps","name":"Kai Angel"}],"displayArtist":"Kai Angel","albumArtists":[{"id":"2FXyezR7e5yT2ZwTDFF5ps","name":"Kai Angel"}],"displayAlbumArtist":"Kai Angel","contributors":[{"role":"performer","artist":{"id":"2FXyezR7e5yT2ZwTDFF5ps","name":"Kai Angel"}},{"role":"composer","artist":{"id":"2aKpbUZ0l9FuDeYo57E7Jk","name":"Dmitri Itskov"}}],"displayComposer":"Dmitri Itskov","explicitStatus":"","groupings":[],"works":[],"movements":[]},{"id":"uLFOBkciZUwJ99SMUB3RTo","parent":"5sPoyWVCZcD9yiA97o0FbY","isDir":false,"title":"OFFICE SONG","album":"Shh!","artist":"Kai Angel","track":4,"year":2026,"genre":"Alternative Rap","coverArt":"mf-uLFOBkciZUwJ99SMUB3RTo_6aa31dbc","size":14442048,"contentType":"audio/flac","suffix":"flac","duration":102,"bitRate":1114,"path":"Kai Angel/Shh!/01-04 - OFFICE SONG.flac","playCount":2,"discNumber":1,"created":"2026-09-10TLine truncated
@@ -0,0 +1 @@
{"subsonic-response":{"status":"ok","version":"1.16.1","type":"navidrome","serverVersion":"0.63.2 (be10f89c)","openSubsonic":true,"albumList2":{"album":[{"id":"0OpO6hd2TejwnaHREMESA6","name":"#4","artist":"Ling tosite sigure","artistId":"02kw7dvTBVLsOPfd2Cm8oG","coverArt":"al-0OpO6hd2TejwnaHREMESA6_6a9b1a1a","songCount":10,"duration":2742,"created":"2026-09-04T19:23:07.475677821Z","year":2005,"genre":"Rock","userRating":0,"genres":[{"name":"Rock"}],"musicBrainzId":"","isCompilation":false,"sortName":"#4","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"02kw7dvTBVLsOPfd2Cm8oG","name":"Ling tosite sigure"}],"displayArtist":"Ling tosite sigure","explicitStatus":"","version":""},{"id":"3Lnw1qfghHkbGmUBOCeKQY","name":"#djcore","artist":"never die","artistId":"1RlholhxfO8YebLSmsyb4o","coverArt":"al-3Lnw1qfghHkbGmUBOCeKQY_6a8767a6","songCount":6,"duration":622,"playCount":1,"created":"2026-08-20T05:12:32.079858766Z","played":"2026-08-20T15:28:23.425686673Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"#djcore","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"1RlholhxfO8YebLSmsyb4o","name":"never die"}],"displayArtist":"never die","explicitStatus":"","version":""},{"id":"1gvF7VcdmJjLnYNQiFqvfm","name":"(((","artist":"темныи принц","artistId":"1EKMdgwWJQWKKwMurtJ1up","coverArt":"al-1gvF7VcdmJjLnYNQiFqvfm_6a87651c","songCount":1,"duration":66,"playCount":2,"created":"2026-08-20T05:12:32.079858766Z","year":2024,"played":"2026-08-20T13:57:31.661Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":true,"sortName":"(((","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"1EKMdgwWJQWKKwMurtJ1up","name":"темныи принц"}],"displayArtist":"темныи принц","explicitStatus":"","version":""}]}}}
@@ -0,0 +1 @@
{"subsonic-response":{"status":"ok","version":"1.16.1","type":"navidrome","serverVersion":"0.63.2 (be10f89c)","openSubsonic":true,"albumList2":{"album":[{"id":"0OpO6hd2TejwnaHREMESA6","name":"#4","artist":"Ling tosite sigure","artistId":"02kw7dvTBVLsOPfd2Cm8oG","coverArt":"al-0OpO6hd2TejwnaHREMESA6_6a9b1a1a","songCount":10,"duration":2742,"created":"2026-09-04T19:23:07.475677821Z","year":2005,"genre":"Rock","userRating":0,"genres":[{"name":"Rock"}],"musicBrainzId":"","isCompilation":false,"sortName":"#4","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"02kw7dvTBVLsOPfd2Cm8oG","name":"Ling tosite sigure"}],"displayArtist":"Ling tosite sigure","explicitStatus":"","version":""},{"id":"3Lnw1qfghHkbGmUBOCeKQY","name":"#djcore","artist":"never die","artistId":"1RlholhxfO8YebLSmsyb4o","coverArt":"al-3Lnw1qfghHkbGmUBOCeKQY_6a8767a6","songCount":6,"duration":622,"playCount":1,"created":"2026-08-20T05:12:32.079858766Z","played":"2026-08-20T15:28:23.425686673Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"#djcore","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"1RlholhxfO8YebLSmsyb4o","name":"never die"}],"displayArtist":"never die","explicitStatus":"","version":""},{"id":"1gvF7VcdmJjLnYNQiFqvfm","name":"(((","artist":"темныи принц","artistId":"1EKMdgwWJQWKKwMurtJ1up","coverArt":"al-1gvF7VcdmJjLnYNQiFqvfm_6a87651c","songCount":1,"duration":66,"playCount":2,"created":"2026-08-20T05:12:32.079858766Z","year":2024,"played":"2026-08-20T13:57:31.661Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":true,"sortName":"(((","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"1EKMdgwWJQWKKwMurtJ1up","name":"темныи принц"}],"displayArtist":"темныи принц","explicitStatus":"","version":""},{"id":"66z308DnU98p9YLSAnSXiz","name":"*** TRIVIAL","artist":"Kai Angel","artistId":"2FXyezR7e5yT2ZwTDFF5ps","coverArt":"al-66z308DnU98p9YLSAnSXiz_6a87a4c9","songCount":1,"duration":161,"created":"2026-08-21T01:07:26.491226036Z","year":2022,"userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"*** trivial","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"2FXyezR7e5yT2ZwTDFF5ps","name":"Kai Angel"}],"displayArtist":"Kai Angel","explicitStatus":"","version":""},{"id":"7iNtEoUDPLI2jHAhvt68Nb","name":"1705","artist":"KVESTAR","artistId":"5p11l3dOsKjMlcd8HBxwIQ","coverArt":"al-7iNtEoUDPLI2jHAhvt68Nb_6a87687f","songCount":1,"duration":99,"created":"2026-08-20T05:12:32.079858766Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"1705","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"5p11l3dOsKjMlcd8HBxwIQ","name":"KVESTAR"}],"displayArtist":"KVESTAR","explicitStatus":"","version":""},{"id":"6PPofaYbL9xveLL59B6jMq","name":"2009","artist":"дмитрий уткин","artistId":"7v0nmjPcxsKDPInhOlBnNs","coverArt":"al-6PPofaYbL9xveLL59B6jMq_6a8767e1","songCount":1,"duration":130,"created":"2026-08-20T05:12:32.079858766Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"2009","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"7v0nmjPcxsKDPInhOlBnNs","name":"дмитрий уткин"}],"displayArtist":"дмитрий уткин","explicitStatus":"","version":""},{"id":"11aFoKRi2asMpeCFuyvQhY","name":"2DWRLD","artist":"Midix","artistId":"6czjE6pPpWVuxiDRbLPGWi","coverArt":"al-11aFoKRi2asMpeCFuyvQhY_6a876774","songCount":1,"duration":155,"created":"2026-08-20T05:12:32.079858766Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"2dwrld","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"6czjE6pPpWVuxiDRbLPGWi","name":"Midix"}],"displayArtist":"Midix","explicitStatus":"","version":""},{"id":"2YxgR9lkWsMguDKAAR2dFN","name":"3","artist":"Hikerii","artistId":"5nixPYpmgfAPMYSqsAEk6K","coverArt":"al-2YxgR9lkWsMguDKAAR2dFN_6a876a4b","songCount":1,"duration":97,"created":"2026-08-20T05:12:32.079858766Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"3","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"5nixPYpmgfAPMYSqsAEk6K","name":"Hikerii"}],"displayArtist":"Hikerii","explicitStatus":"","version":""},{"id":"4QGXYX9mmibCkcbBXDBvdT","name":"8 миля","artist":"madk1d","artistId":"7Dy79oui9xLw7S5an2x0nk","coverArt":"al-4QGXYX9mmibCkcbBXDBvdT_6a87688e","songCount":1,"duration":115,"created":"2026-08-20T05:12:32.079858766Z","userRating":0,"genres":[],"musicBrainzId"Line truncated
@@ -0,0 +1 @@
{"subsonic-response":{"status":"ok","version":"1.16.1","type":"navidrome","serverVersion":"0.63.2 (be10f89c)","openSubsonic":true,"artist":{"id":"2FXyezR7e5yT2ZwTDFF5ps","name":"Kai Angel","coverArt":"ar-2FXyezR7e5yT2ZwTDFF5ps_0","albumCount":30,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTJGWHllelI3ZTV5VDJad1RERkY1cHNfMCIsImlzcyI6Ik5EIn0.gfjo_ScFAJgCVz4wkF41t5QHH25dB1xTduLnE2wG-4E?size=600","musicBrainzId":"a78be557-1019-48bc-a4ed-6da7a50841ef","sortName":"kai angel","roles":["albumartist","artist","composer","performer","producer","maincredit"],"album":[{"id":"2GKWcFB67yeOQDFRDvFvke","name":" TRIVIAL","artist":"Kai Angel","artistId":"2FXyezR7e5yT2ZwTDFF5ps","coverArt":"al-2GKWcFB67yeOQDFRDvFvke_6a876a31","songCount":1,"duration":161,"created":"2026-08-20T05:12:32.079858766Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"trivial","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"2FXyezR7e5yT2ZwTDFF5ps","name":"Kai Angel"}],"displayArtist":"Kai Angel","explicitStatus":"","version":""},{"id":"18BXzMwofe1WsshrIg4fNr","name":"FAKE LOVE","artist":"Kai Angel","artistId":"2FXyezR7e5yT2ZwTDFF5ps","coverArt":"al-18BXzMwofe1WsshrIg4fNr_6a8769e4","songCount":1,"duration":134,"playCount":8,"created":"2026-08-20T05:12:32.079858766Z","played":"2026-08-20T13:53:10.826Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"fake love","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"2FXyezR7e5yT2ZwTDFF5ps","name":"Kai Angel"}],"displayArtist":"Kai Angel","explicitStatus":"","version":""},{"id":"3CIa4vI8ORVCOG8sc3ah4y","name":"HEAVY METAL 2","artist":"Kai Angel","artistId":"2FXyezR7e5yT2ZwTDFF5ps","coverArt":"al-3CIa4vI8ORVCOG8sc3ah4y_6a876a0d","songCount":21,"duration":2324,"playCount":11,"created":"2026-08-20T05:12:32.079858766Z","played":"2026-09-20T20:23:28.955339273Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"heavy metal 2","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"2FXyezR7e5yT2ZwTDFF5ps","name":"Kai Angel"}],"displayArtist":"Kai Angel","explicitStatus":"","version":""},{"id":"2lzu0A9LaO4U2V1JZmoX2l","name":"Madonna","artist":"Kai Angel","artistId":"2FXyezR7e5yT2ZwTDFF5ps","coverArt":"al-2lzu0A9LaO4U2V1JZmoX2l_6a876a0f","songCount":1,"duration":118,"created":"2026-08-20T05:12:32.079858766Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"madonna","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"2FXyezR7e5yT2ZwTDFF5ps","name":"Kai Angel"}],"displayArtist":"Kai Angel","explicitStatus":"","version":""},{"id":"2TX1qY1w1mRsYDTqCeqJRR","name":"jennifer's body","artist":"Kai Angel","artistId":"2FXyezR7e5yT2ZwTDFF5ps","coverArt":"al-2TX1qY1w1mRsYDTqCeqJRR_6a8769e5","songCount":1,"duration":108,"created":"2026-08-20T05:12:32.079858766Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"jennifer's body","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"2FXyezR7e5yT2ZwTDFF5ps","name":"Kai Angel"}],"displayArtist":"Kai Angel","explicitStatus":"","version":""},{"id":"0N6lvnldi2S79gYY1bdqPN","name":"lovesong","artist":"Kai Angel","artistId":"2FXyezR7e5yT2ZwTDFF5ps","coverArt":"al-0N6lvnldi2S79gYY1bdqPN_6a876a21","songCount":1,"duration":107,"created":"2026-08-20T05:12:32.079858766Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"lovesong","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"2FXyezR7e5yT2ZwTDFF5ps","name":"Kai Angel"}],"displayArtist":"Kai Angel","explicitStatus":"","version":""},{"id":"0ZoaVvVl3AWdVhSKWjxYrg","name":"quiet turn up","artist":"Kai Angel","artistId":"2FXyezR7e5yT2ZwTDFF5ps","coverArt":"al-0ZoaVvVl3AWdVhSKWjxYrg_6a8769b9","songCount":1,"duration":110,"playCount":1,"created":"2026-08-20T05:12:32.079858766Z","played":"2026-08-26T18:39:30.156Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"quiet turn up","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"2FXyezR7e5yT2ZwTDFF5ps","name":"Kai Angel"}],"displayArtist":"Kai Angel","explicitStatus":"","version":""},{"id":"66z308DnU98p9YLSAnSXiz","name":"*** TRIVIAL","artist":"Kai Angel","artistId":"2FXyezR7e5yT2ZwTDFF5ps","coverArt":"al-66z308DnU98p9YLSAnSXiz_6a87a4c9","songCount":1,"duration":161,"created":"2026-08-21T01:07:26.491226036Z","year":2022,"userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"*** trivial","discTitles":[],"originalReleasLine truncated
@@ -0,0 +1 @@
{"subsonic-response":{"status":"ok","version":"1.16.1","type":"navidrome","serverVersion":"0.63.2 (be10f89c)","openSubsonic":true,"artists":{"index":[{"name":"#","artist":[{"id":"62qr8uCyh7AmYFTqGroesm","name":"17 SEVENTEEN","coverArt":"ar-62qr8uCyh7AmYFTqGroesm_0","albumCount":1,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTYycXI4dUN5aDdBbVlGVHFHcm9lc21fMCIsImlzcyI6Ik5EIn0._1Bkk7O3dNWpfsAQX6wzBlU-ujZJ64qjTWYcFE3NL08?size=600","musicBrainzId":"","sortName":"17 seventeen","roles":["artist","composer","producer","maincredit","albumartist"]},{"id":"5yZjZwoBql4MIx8H6x5F1o","name":"2kizz","coverArt":"ar-5yZjZwoBql4MIx8H6x5F1o_0","albumCount":2,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTV5Wmpad29CcWw0TUl4OEg2eDVGMW9fMCIsImlzcyI6Ik5EIn0.s1ht6fWan7sk1Du4OwcjcebiXY6b2nZTMivkxkztetg?size=600","musicBrainzId":"","sortName":"2kizz","roles":["artist","composer","producer","maincredit","albumartist"]},{"id":"6s95evG6nwNSd0mnfjsbCz","name":"3TERNITY","coverArt":"ar-6s95evG6nwNSd0mnfjsbCz_0","albumCount":1,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTZzOTVldkc2bndOU2QwbW5manNiQ3pfMCIsImlzcyI6Ik5EIn0.UADf3hxiJ8KiK7e4rGwCJUXqt7eFO-HUCDfIp3Effic?size=600","musicBrainzId":"","sortName":"3ternity","roles":["albumartist","artist","composer","maincredit"]},{"id":"0xMsw5NccH5Syedj08drV9","name":"44neverluv","coverArt":"ar-0xMsw5NccH5Syedj08drV9_0","albumCount":1,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTB4TXN3NU5jY0g1U3llZGowOGRyVjlfMCIsImlzcyI6Ik5EIn0.LGQeZwJf07Oe9DJfiaszOBDEM-arVcUdO-9yUOAHljU?size=600","musicBrainzId":"","sortName":"44neverluv","roles":["producer","maincredit","albumartist","artist","composer"]},{"id":"5JkMqHkP41lzJw8CoWNGxr","name":"¥$","coverArt":"ar-5JkMqHkP41lzJw8CoWNGxr_0","albumCount":2,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTVKa01xSGtQNDFsekp3OENvV05HeHJfMCIsImlzcyI6Ik5EIn0.iLopIwEPwM2n5ypNyV6ObKo8QJNYjeuBrjmoOJ7Mm0w?size=600","musicBrainzId":"","sortName":"¥$","roles":["maincredit","albumartist","artist"]},{"id":"1ldo7ND3Xt2U5y9RwcNIOG","name":"Валентин Дядька","coverArt":"ar-1ldo7ND3Xt2U5y9RwcNIOG_0","albumCount":2,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTFsZG83TkQzWHQyVTV5OVJ3Y05JT0dfMCIsImlzcyI6Ik5EIn0.ZXX2NdBfQAGp5bkzBVy8cKD0Miak6RHFnlIFxukOxp4?size=600","musicBrainzId":"","sortName":"валентин дядька","roles":["maincredit","albumartist","artist"]},{"id":"1fbZVBFq1QzaxaS0oZKW7H","name":"Ваня Дмитриенко, Мальбэк \u0026 Сюзанна","coverArt":"ar-1fbZVBFq1QzaxaS0oZKW7H_0","albumCount":1,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTFmYlpWQkZxMVF6YXhhUzBvWktXN0hfMCIsImlzcyI6Ik5EIn0.8FO6yTekbFrbL5XCcYof-UduhFm83ZThhtb51IlXITs?size=600","musicBrainzId":"","sortName":"ваня дмитриенко, мальбэк \u0026 сюзанна","roles":["performer","maincredit","albumartist","artist"]},{"id":"7v0nmjPcxsKDPInhOlBnNs","name":"дмитрий уткин","coverArt":"ar-7v0nmjPcxsKDPInhOlBnNs_0","albumCount":4,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTd2MG5talBjeHNLRFBJbmhPbEJuTnNfMCIsImlzcyI6Ik5EIn0.vBFBp9z5yF0ig26XbnfpuYoF-dJykhqYF_qrJeu4ykU?size=600","musicBrainzId":"","sortName":"дмитрий уткин","roles":["maincredit","albumartist","artist"]},{"id":"479vtVFOv5EM4T2i8TNURF","name":"Мак Сима Мгла","coverArt":"ar-479vtVFOv5EM4T2i8TNURF_0","albumCount":1,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTQ3OXZ0VkZPdjVFTTRUMmk4VE5VUkZfMCIsImlzcyI6Ik5EIn0.7AyEsIve3EjPD9bHrLIHq0qLX_VRsmVzLJppAeX5gZA?size=600","musicBrainzId":"","sortName":"мак сима мгла","roles":["maincredit","albumartist","artist"]},{"id":"6YYriejN9jz0ThCkxVXJNC","name":"Мак Сима Мгла,zavet","coverArt":"ar-6YYriejN9jz0ThCkxVXJNC_0","albumCount":1,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTZZWXJpZWpOOWp6MFRoQ2t4VlhKTkNfMCIsImlzcyI6Ik5EIn0.GaiBNetZk_P8JKuNjd7vx1ttjUnX5Ai_V4EsYc_FE5U?size=600","musicBrainzId":"","sortName":"мак сима мгла,zavet","roles":["albumartist","maincredit"]},{"id":"6a7GXnrHWsEoqwFqmzr9Cg","name":"НТР","coverArt":"ar-6a7GXnrHWsEoqwFqmzr9Cg_0","albumCount":2,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTZhN0dYbnJIV3NFb3F3RnFtenI5Q2dfMCIsImlzcyI6Ik5EIn0.YUd8IwxmeE4axWLcvHGB5O-PvVxO2BX-O1h06Qvs85Y?size=600","musicBrainzId":"","sortName":"нтр","roles":["maincredit","albumartist","artist"]},{"id":"22qO7WPYmgKqChrt72BFOf","name":"Рай не сегодня","coverArt":"ar-22qLine truncated
@@ -0,0 +1 @@
{"subsonic-response":{"status":"ok","version":"1.16.1","type":"navidrome","serverVersion":"0.63.2 (be10f89c)","openSubsonic":true,"openSubsonicExtensions":[{"name":"transcodeOffset","versions":[1]},{"name":"formPost","versions":[1]},{"name":"songLyrics","versions":[1,2]},{"name":"indexBasedQueue","versions":[1]},{"name":"transcoding","versions":[1]},{"name":"playbackReport","versions":[1]}]}}
@@ -0,0 +1 @@
{"subsonic-response":{"status":"ok","version":"1.16.1","type":"navidrome","serverVersion":"0.63.2 (be10f89c)","openSubsonic":true,"genres":{"genre":[{"value":"Rap","songCount":92,"albumCount":19},{"value":"Hip-Hop","songCount":68,"albumCount":21},{"value":"rusrap","songCount":58,"albumCount":11},{"value":"Electronic","songCount":55,"albumCount":5},{"value":"Hip Hop","songCount":40,"albumCount":14},{"value":"Rock","songCount":28,"albumCount":3},{"value":"Ambient","songCount":28,"albumCount":2},{"value":"alternative","songCount":14,"albumCount":4},{"value":"Alternative Rap","songCount":14,"albumCount":1},{"value":"Drum'n'bass","songCount":14,"albumCount":1},{"value":"Jungle","songCount":14,"albumCount":1},{"value":"New Age","songCount":14,"albumCount":1},{"value":"Pop","songCount":12,"albumCount":2},{"value":"Rap \u0026 Hip-Hop","songCount":5,"albumCount":5},{"value":"Gospel","songCount":2,"albumCount":1},{"value":"zavet","songCount":2,"albumCount":1},{"value":"ruspop","songCount":1,"albumCount":1},{"value":"rusrock","songCount":1,"albumCount":1}]}}}
@@ -0,0 +1 @@
{"subsonic-response":{"status":"ok","version":"1.16.1","type":"navidrome","serverVersion":"0.63.2 (be10f89c)","openSubsonic":true,"lyricsList":{}}}
@@ -0,0 +1 @@
{"subsonic-response":{"status":"ok","version":"1.16.1","type":"navidrome","serverVersion":"0.63.2 (be10f89c)","openSubsonic":true}}
@@ -0,0 +1 @@
{"subsonic-response":{"status":"ok","version":"1.16.1","type":"navidrome","serverVersion":"0.63.2 (be10f89c)","openSubsonic":true}}
@@ -0,0 +1 @@
{"subsonic-response":{"status":"ok","version":"1.16.1","type":"navidrome","serverVersion":"0.63.2 (be10f89c)","openSubsonic":true,"playlist":{"id":"SxiV9RaUyXY7MN8nYsWZxG","name":"Cassette Wrapped 2026","songCount":29,"duration":3846,"owner":"h","created":"2026-09-19T12:53:49.208808878Z","changed":"2026-09-19T15:01:55.279867381Z","coverArt":"pl-SxiV9RaUyXY7MN8nYsWZxG_6aaea3e3","readonly":false,"entry":[{"id":"b5M4eR9OT8HoteegaIdKdz","parent":"4iyL0qIZurpodlZ1X5grpm","isDir":false,"title":"Спущусь потрогать траву","album":"Спущусь потрогать траву","artist":"New Sylveon","coverArt":"mf-b5M4eR9OT8HoteegaIdKdz_6a872219","size":3509700,"contentType":"audio/mpeg","suffix":"mp3","duration":211,"bitRate":128,"path":"New Sylveon/Спущусь потрогать траву/Спущусь потрогать траву.mp3","playCount":1,"created":"2026-08-20T05:31:27.797462023Z","albumId":"4iyL0qIZurpodlZ1X5grpm","artistId":"1O3tH1OWBPZmewMIvjBKT0","type":"music","played":"2026-08-20T15:16:08.960894876Z","bpm":0,"comment":"","sortName":"спущусь потрогать траву","mediaType":"song","musicBrainzId":"","isrc":[],"genres":[],"replayGain":{},"channelCount":2,"samplingRate":44100,"bitDepth":0,"moods":[],"artists":[{"id":"1O3tH1OWBPZmewMIvjBKT0","name":"New Sylveon"}],"displayArtist":"New Sylveon","albumArtists":[{"id":"1O3tH1OWBPZmewMIvjBKT0","name":"New Sylveon"}],"displayAlbumArtist":"New Sylveon","contributors":[],"displayComposer":"","explicitStatus":"","groupings":[],"works":[],"movements":[]},{"id":"OLousaLERPvAp1yZzpY5LE","parent":"4769HW4vf62DAOCqgJdLIG","isDir":false,"title":"Дети давят на глаза","album":"Moth Avenue","artist":"New Sylveon","coverArt":"mf-OLousaLERPvAp1yZzpY5LE_6a876810","size":3289378,"contentType":"audio/mpeg","suffix":"mp3","duration":196,"bitRate":128,"path":"New Sylveon/Moth Avenue/Дети давят на глаза.mp3","playCount":1,"created":"2026-08-20T05:05:03.351733151Z","albumId":"4769HW4vf62DAOCqgJdLIG","artistId":"1O3tH1OWBPZmewMIvjBKT0","type":"music","played":"2026-08-20T14:30:10.684555127Z","bpm":0,"comment":"","sortName":"дети давят на глаза","mediaType":"song","musicBrainzId":"","isrc":[],"genres":[],"replayGain":{},"channelCount":2,"samplingRate":44100,"bitDepth":0,"moods":[],"artists":[{"id":"1O3tH1OWBPZmewMIvjBKT0","name":"New Sylveon"}],"displayArtist":"New Sylveon","albumArtists":[{"id":"1O3tH1OWBPZmewMIvjBKT0","name":"New Sylveon"}],"displayAlbumArtist":"New Sylveon","contributors":[{"role":"producer","artist":{"id":"1O3tH1OWBPZmewMIvjBKT0","name":"New Sylveon"}},{"role":"composer","artist":{"id":"1O3tH1OWBPZmewMIvjBKT0","name":"New Sylveon"}}],"displayComposer":"New Sylveon","explicitStatus":"","groupings":[],"works":[],"movements":[]},{"id":"Lj2pIAQdo5ZYmY5OyBMaGE","parent":"4769HW4vf62DAOCqgJdLIG","isDir":false,"title":"Nosebleed Url","album":"Moth Avenue","artist":"New Sylveon","coverArt":"mf-Lj2pIAQdo5ZYmY5OyBMaGE_6a87680a","size":2969307,"contentType":"audio/mpeg","suffix":"mp3","duration":176,"bitRate":128,"path":"New Sylveon/Moth Avenue/Nosebleed Url.mp3","playCount":1,"created":"2026-08-20T05:04:46.979793295Z","albumId":"4769HW4vf62DAOCqgJdLIG","artistId":"1O3tH1OWBPZmewMIvjBKT0","type":"music","played":"2026-08-20T15:30:38.225764505Z","bpm":0,"comment":"","sortName":"nosebleed url","mediaType":"song","musicBrainzId":"","isrc":[],"genres":[],"replayGain":{},"channelCount":2,"samplingRate":44100,"bitDepth":0,"moods":[],"artists":[{"id":"1O3tH1OWBPZmewMIvjBKT0","name":"New Sylveon"}],"displayArtist":"New Sylveon","albumArtists":[{"id":"1O3tH1OWBPZmewMIvjBKT0","name":"New Sylveon"}],"displayAlbumArtist":"New Sylveon","contributors":[{"role":"composer","artist":{"id":"1O3tH1OWBPZmewMIvjBKT0","name":"New Sylveon"}},{"role":"producer","artist":{"id":"1O3tH1OWBPZmewMIvjBKT0","name":"New Sylveon"}}],"displayComposer":"New Sylveon","explicitStatus":"","groupings":[],"works":[],"movements":[]},{"id":"8zIlzzTOxw8v36PtkElVXu","parent":"3J6nXEPOmELLzfiddV7cke","isDir":false,"title":"навсегда","album":"навсегда","artist":"Рай не сегодня","coverArt":"mf-8zIlzzTOxw8v36PtkElVXu_6a87221b","size":2923885,"contentType":"audio/mpeg","suffix":"mp3","duration":161,"bitRate":128,"path":"Рай не сегодня/навсегда/навсегда.mp3","playCount":1,"created":"2026-08-20T05:21:22.317778934Z","albumId":"3J6nXEPOmELLzfiddV7cke","artistId":"22qO7WPYmgKqChrt72BFOf","type":"music","played":"2026-08-20T14:27:11.12139062Z","bpm":0,"comment":"","sortName":"навсегда","mediaType":"song","musicBrainzId":"","isrc":[],"genres":[],"replayGain":{},"channelCount":2,"samplingRate":44100,"bitDepth":0,"moods":[],"artists":[{"id":"22qO7WPYmgKqChrt72BFOf","name":"Рай не сегодня"}],"displayArtist":"Рай не сегодня","albumArtists":[{"id":"22qO7WPYmgKqChrt72BFOf","name":"Рай не сегодня"}],"displayAlbumArtist":"Рай не сегоLine truncated
@@ -0,0 +1 @@
{"subsonic-response":{"status":"ok","version":"1.16.1","type":"navidrome","serverVersion":"0.63.2 (be10f89c)","openSubsonic":true,"playlists":{"playlist":[{"id":"SxiV9RaUyXY7MN8nYsWZxG","name":"Cassette Wrapped 2026","songCount":29,"duration":3846,"owner":"h","created":"2026-09-19T12:53:49.208808878Z","changed":"2026-09-19T15:01:55.279867381Z","coverArt":"pl-SxiV9RaUyXY7MN8nYsWZxG_6aaea3e3","readonly":false},{"id":"BmM44LH1EjGvaTe1y8mzlp","name":"Cassette · Energetic","songCount":75,"duration":17271,"owner":"h","created":"2026-08-20T13:29:17.025013179Z","changed":"2026-09-19T12:53:49.574024953Z","coverArt":"pl-BmM44LH1EjGvaTe1y8mzlp_6aae85dd","readonly":false},{"id":"9sUF073MB7Uswaq3H6oJbr","name":"Cassette · Focus","songCount":28,"duration":6259,"owner":"h","created":"2026-09-19T12:53:50.02321498Z","changed":"2026-09-19T12:53:50.21339625Z","coverArt":"pl-9sUF073MB7Uswaq3H6oJbr_6aae85de","readonly":false},{"id":"JHUzaQgnhiqKcXcNyZGpnu","name":"Cassette · Night","songCount":28,"duration":6259,"owner":"h","created":"2026-09-19T12:53:49.298670292Z","changed":"2026-09-19T12:53:49.482594642Z","coverArt":"pl-JHUzaQgnhiqKcXcNyZGpnu_6aae85dd","readonly":false},{"id":"WSIEQCLmb0gkcMXXFQxIs7","name":"Cassette · Workout","songCount":40,"duration":5585,"owner":"h","created":"2026-08-20T13:29:17.307805723Z","changed":"2026-09-19T12:53:49.675658939Z","coverArt":"pl-WSIEQCLmb0gkcMXXFQxIs7_6aae85dd","readonly":false}]}}}
@@ -0,0 +1 @@
{"subsonic-response":{"status":"ok","version":"1.16.1","type":"navidrome","serverVersion":"0.63.2 (be10f89c)","openSubsonic":true,"internetRadioStations":{"internetRadioStation":[{"id":"7kDdJP1A1NGRzKcHKhXJ24","name":"Złote Przeboje Poznań","streamUrl":"http://radiostream.pl/tuba8926-1.mp3","homePageUrl":"http://zloteprzeboje.tuba.pl/","coverArt":""}]}}}
@@ -0,0 +1 @@
{"subsonic-response":{"status":"ok","version":"1.16.1","type":"navidrome","serverVersion":"0.63.2 (be10f89c)","openSubsonic":true,"searchResult3":{"song":[{"id":"gldIYgc7CP9Qk2i6C5jYsl","parent":"18jXBF5TwumfzTGqRIpuQ8","isDir":false,"title":"Cобаки кухня ужин","album":"Атана","artist":"zavet","track":5,"year":2019,"genre":"alternative","coverArt":"mf-gldIYgc7CP9Qk2i6C5jYsl_6a8763a6","size":3484925,"contentType":"audio/mpeg","suffix":"mp3","duration":135,"bitRate":192,"path":"zavet/Атана/05 - Cобаки кухня ужин.mp3","playCount":1,"created":"2026-08-20T04:14:23.106069116Z","albumId":"18jXBF5TwumfzTGqRIpuQ8","artistId":"2lzWXQ0Wd7RzG6RmnWUaTy","type":"music","played":"2026-09-20T19:19:01.864342992Z","bpm":0,"comment":"","sortName":"cобаки кухня ужин","mediaType":"song","musicBrainzId":"","isrc":[],"genres":[{"name":"alternative"}],"replayGain":{},"channelCount":2,"samplingRate":44100,"bitDepth":0,"moods":[],"artists":[{"id":"2lzWXQ0Wd7RzG6RmnWUaTy","name":"zavet"}],"displayArtist":"zavet","albumArtists":[{"id":"2lzWXQ0Wd7RzG6RmnWUaTy","name":"zavet"}],"displayAlbumArtist":"zavet","contributors":[],"displayComposer":"","explicitStatus":"","groupings":[],"works":[],"movements":[]},{"id":"fhUcevtPvPy323PUCn6TBe","parent":"1IWVqdo1l5XO1b2Nd3EfB5","isDir":false,"title":"Изящно","album":"Kaidan","artist":"zavet/Мак Сима Мгла","track":9,"year":2019,"genre":"rusrap","coverArt":"mf-fhUcevtPvPy323PUCn6TBe_6a8763a6","size":3998293,"contentType":"audio/mpeg","suffix":"mp3","duration":164,"bitRate":192,"path":"zavet/Kaidan/09 - Изящно.mp3","created":"2026-08-20T04:14:23.126789624Z","albumId":"1IWVqdo1l5XO1b2Nd3EfB5","artistId":"2AvTMErm7wgGUiIBT452v1","type":"music","bpm":0,"comment":"","sortName":"изящно","mediaType":"song","musicBrainzId":"","isrc":[],"genres":[{"name":"rusrap"}],"replayGain":{},"channelCount":2,"samplingRate":44100,"bitDepth":0,"moods":[],"artists":[{"id":"2AvTMErm7wgGUiIBT452v1","name":"zavet/Мак Сима Мгла"}],"displayArtist":"zavet/Мак Сима Мгла","albumArtists":[{"id":"2lzWXQ0Wd7RzG6RmnWUaTy","name":"zavet"}],"displayAlbumArtist":"zavet","contributors":[],"displayComposer":"","explicitStatus":"","groupings":[],"works":[],"movements":[]},{"id":"asPoMDbYDlvcjPHAidJkhr","parent":"1IWVqdo1l5XO1b2Nd3EfB5","isDir":false,"title":"бедная лошадь (вывод)","album":"Kaidan","artist":"zavet","track":14,"year":2019,"genre":"rusrap","coverArt":"mf-asPoMDbYDlvcjPHAidJkhr_6a8763a6","size":1522037,"contentType":"audio/mpeg","suffix":"mp3","duration":62,"bitRate":192,"path":"zavet/Kaidan/14 - бедная лошадь (вывод).mp3","created":"2026-08-20T04:14:23.132295342Z","albumId":"1IWVqdo1l5XO1b2Nd3EfB5","artistId":"2lzWXQ0Wd7RzG6RmnWUaTy","type":"music","bpm":0,"comment":"","sortName":"бедная лошадь (вывод)","mediaType":"song","musicBrainzId":"","isrc":[],"genres":[{"name":"rusrap"}],"replayGain":{},"channelCount":2,"samplingRate":44100,"bitDepth":0,"moods":[],"artists":[{"id":"2lzWXQ0Wd7RzG6RmnWUaTy","name":"zavet"}],"displayArtist":"zavet","albumArtists":[{"id":"2lzWXQ0Wd7RzG6RmnWUaTy","name":"zavet"}],"displayAlbumArtist":"zavet","contributors":[],"displayComposer":"","explicitStatus":"","groupings":[],"works":[],"movements":[]}]}}}
@@ -0,0 +1 @@
{"subsonic-response":{"status":"ok","version":"1.16.1","type":"navidrome","serverVersion":"0.63.2 (be10f89c)","openSubsonic":true,"starred2":{}}}
@@ -0,0 +1,195 @@
import Domain
import Foundation
@testable import SourceSubsonic
import Synchronization
import Testing
private let source = SubsonicSource.fixture()
@Test func requestsCarryTokenAuth() async throws {
let request = try await source.stream(for: EntityID(source: source.id, remote: "abc"))
let (method, params) = FixtureTransport.method(of: URLRequest(url: request.resource.url))
#expect(method == "stream")
#expect(params["id"] == "abc")
#expect(params["format"] == "raw")
#expect(params["u"] == "h")
#expect(params["t"]?.count == 32)
#expect(params["s"]?.count == 12)
#expect(params["v"] == "1.16.1")
#expect(params["c"] == "Amphlify")
#expect(params["f"] == "json")
#expect(params["p"] == nil)
#expect(request.resource.headers.isEmpty)
}
@Test func saltIsFreshPerRequest() {
let first = FixtureTransport.method(of: URLRequest(url: source.client.url("ping"))).1
let second = FixtureTransport.method(of: URLRequest(url: source.client.url("ping"))).1
#expect(first["s"] != second["s"])
#expect(first["t"] != second["t"])
}
@Test func errorEnvelopeThrows() {
let json = Data(
#"{"subsonic-response":{"status":"failed","version":"1.16.1","error":{"code":40,"message":"Wrong username or password"}}}"#
.utf8
)
#expect(throws: SubsonicError.server(code: 40, message: "Wrong username or password")) {
try SubsonicClient.decode(json, as: EmptyPayload.self)
}
}
@Test func httpErrorThrows() async throws {
let client = try SubsonicClient(baseURL: #require(URL(string: "https://x.test")), username: "h", password: "p") { request in
(Data(), HTTPURLResponse(url: request.url!, statusCode: 401, httpVersion: nil, headerFields: nil)!)
}
await #expect(throws: SubsonicError.http(401)) {
try await client.call("ping")
}
}
@Test func verifyPings() async throws {
try await source.verify()
}
@Test func fullCrawlEmitsWholeLibrary() async throws {
let changes = try await SubsonicSource.fixture().collect()
var artists = 0, albums = 0, tracks = 0, playlists = 0, stations = 0, annotations = 0, removals = 0
var entries: [PlaylistEntry] = []
for change in changes {
switch change {
case .upsert(.artist): artists += 1
case .upsert(.album): albums += 1
case .upsert(.track): tracks += 1
case let .upsert(.playlist(_, list)):
playlists += 1
entries = list
case .upsert(.station): stations += 1
case .annotate: annotations += 1
case .remove: removals += 1
case .checkpoint: break
}
}
#expect(artists == 83)
#expect(tracks == 3)
#expect(albums == 207)
#expect(playlists == 5)
#expect(entries.count == 29)
#expect(entries.first?.id == "SxiV9RaUyXY7MN8nYsWZxG:0")
#expect(stations == 1)
#expect(annotations == 1)
#expect(removals == 0)
guard case .checkpoint = changes.last else {
Issue.record("no checkpoint")
return
}
}
@Test func crawlOrderAndAnnotation() async throws {
let changes = try await SubsonicSource.fixture().collect()
guard case .upsert(.artist) = changes[0], case .upsert(.track) = changes[83], case .upsert(.album) = changes[86] else {
Issue.record("unexpected order")
return
}
let annotation = changes.compactMap { change -> Annotation? in
if case let .annotate(id, annotation) = change, id.remote == "gldIYgc7CP9Qk2i6C5jYsl" {
return annotation
}
return nil
}.first
#expect(annotation?.playCount == 1)
#expect(annotation?.favoritedAt == nil)
#expect(annotation?.lastPlayedAt?.timeIntervalSince1970.rounded() == 1_789_931_942)
}
@Test func secondCrawlHasNoRemovals() async throws {
let source = SubsonicSource.fixture()
_ = try await source.collect()
let second = try await source.collect()
#expect(second.filter {
if case .remove = $0 {
true
} else {
false
}
}.isEmpty)
}
@Test func removalAfterAlbumDisappears() async throws {
let base = FixtureTransport()
let dropped = FixtureTransport(droppedAlbum: "0OpO6hd2TejwnaHREMESA6")
let flag = Mutex(false)
let client = try SubsonicClient(baseURL: #require(URL(string: "https://x.test")), username: "h", password: "p") { request in
try await (flag.withLock { $0 } ? dropped : base)(request)
}
let source = SubsonicSource(id: SourceID(), name: "Test", client: client)
_ = try await source.collect()
flag.withLock { $0 = true }
let removals = try await source.collect().compactMap { change -> (EntityKind, EntityID)? in
if case let .remove(kind, id) = change {
return (kind, id)
}
return nil
}
#expect(removals.count == 1)
#expect(removals.first?.0 == .album)
#expect(removals.first?.1.remote == "0OpO6hd2TejwnaHREMESA6")
}
@Test func flacFormatMapping() throws {
let payload = try SubsonicClient.decode(FixtureTransport.fixture("album"), as: AlbumPayload.self)
let song = try #require(payload.album.song?.first)
let format = try #require(AudioFormat(song: song))
#expect(format.codec == .flac)
#expect(format.isLossless)
#expect(format.sampleRate == 44100)
#expect(format.bitDepth == 16)
#expect(format.channels == 2)
#expect(format.bitrateKbps == 173)
#expect(format.isBetter(than: AudioFormat(codec: .mp3, bitrateKbps: 320, sampleRate: 48000)))
}
@Test func lyricsParsing() throws {
let json = Data(#"""
{"subsonic-response":{"status":"ok","version":"1.16.1","lyricsList":{"structuredLyrics":[
{"lang":"xxx","synced":false,"displayArtist":"A","displayTitle":"T","line":[{"value":"plain"}]},
{"lang":"xxx","synced":true,"displayArtist":"A","displayTitle":"T","offset":100,
"line":[{"start":1000,"value":"one"},{"start":2500,"value":"two"}]}
]}}}
"""#.utf8)
let payload = try SubsonicClient.decode(json, as: LyricsPayload.self)
let lyrics = try #require(SubsonicMapper(source: SourceID()).lyrics(payload.lyricsList.structuredLyrics ?? []))
#expect(lyrics.isSynced)
#expect(lyrics.lines.map(\.text) == ["one", "two"])
#expect(lyrics.lines.map(\.time) == [1.1, 2.6])
}
@Test func emptyLyricsIsNil() async throws {
#expect(try await source.lyrics(for: EntityID(source: source.id, remote: "x")) == nil)
}
@Test func artworkRequest() async throws {
let request = try await source.artwork(EntityID(source: source.id, remote: "al-1"), pixelSize: 300)
let (method, params) = FixtureTransport.method(of: URLRequest(url: request.url))
#expect(method == "getCoverArt")
#expect(params["id"] == "al-1")
#expect(params["size"] == "300")
#expect(params["t"] != nil)
}
@Test func streamRemembersFormat() async throws {
let source = SubsonicSource.fixture()
_ = try await source.collect()
let request = try await source.stream(for: EntityID(source: source.id, remote: "gldIYgc7CP9Qk2i6C5jYsl"))
#expect(request.format?.codec == .mp3)
#expect(request.format?.bitrateKbps == 192)
#expect(request.resource.url.absoluteString.contains("format=raw"))
}
@Test func nanosecondDatesParse() {
let date = SubsonicDate.parse("2026-09-21T13:33:43.30709994Z")
#expect(date.map { abs($0.timeIntervalSince1970 - 1_789_997_623.307) < 0.001 } == true)
#expect(SubsonicDate.parse("2026-08-20T13:57:31.661Z") != nil)
#expect(SubsonicDate.parse("2026-08-20T13:57:31Z") != nil)
#expect(SubsonicDate.parse("garbage") == nil)
}
@@ -0,0 +1,28 @@
import Foundation
import SourceFake
import Store
import Sync
import Testing
@Test func fakeSourceSyncsIntoLibrary() async throws {
let library = MemoryLibrary()
let source = FakeSource()
try await SyncEngine(library: library).sync(source)
let albums = await library.albums(sortedBy: .title)
#expect(albums.count == 35)
#expect(await library.cursor(for: source.id) != nil)
let first = try #require(albums.first)
#expect(await library.tracks(inAlbum: first.id).count == first.trackCount)
#expect(await !library.search("harbor").artists.isEmpty)
try await SyncEngine(library: library).sync(source)
#expect(await library.albums(sortedBy: .title).count == 35)
}
@Test func fakeArtworkRenders() async throws {
let source = FakeSource()
let library = MemoryLibrary()
try await SyncEngine(library: library).sync(source)
let artwork = try #require(await library.albums(sortedBy: .title).first?.artwork)
let request = try await source.artwork(artwork, pixelSize: 300)
#expect(try Data(contentsOf: request.url).count > 1000)
}