feat(*): subsonic client with avfoundation playback and apple music ui
This commit is contained in:
commit
e5bb559d7f
93 files changed
+7517
No files matched your search
@@ -0,0 +1,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)
|
||||
}
|
||||
Reference in new issue
Block a user