commit e5bb559d7fc5dc094cc2c5bc3a8939ca9a5ccf02 Author: h Date: Tue Sep 22 06:44:44 2026 +0200 feat(*): subsonic client with avfoundation playback and apple music ui diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..af1c77f --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# macOS +.DS_Store + +# Editors +.idea/ +.vscode/ +*.swp + +# Xcode +build/ +DerivedData/ +xcuserdata/ +*.xcuserstate +*.xcscmblueprint +*.xcresult +*.ipa +*.dSYM +*.dSYM.zip + +# Swift Package Manager +.build/ +.swiftpm/ + +# Signing: copy Config/Signing.example.xcconfig +Config/Signing.xcconfig diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..e58c03a --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,13 @@ +repos: + - repo: local + hooks: + - id: fmt + name: fmt + entry: make fmt + language: system + pass_filenames: false + - id: check + name: check + entry: make check + language: system + pass_filenames: false diff --git a/.swiftformat b/.swiftformat new file mode 100644 index 0000000..0b9a6df --- /dev/null +++ b/.swiftformat @@ -0,0 +1,8 @@ +--swiftversion 6.0 +--exclude build,**/.build +--maxwidth 140 +--wraparguments before-first +--wrapcollections before-first +--self remove +--commas always +--disable wrapMultilineStatementBraces diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..7c5ecd8 --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,28 @@ +included: + - Amphlify + - Packages +excluded: + - Packages/AmphlifyKit/.build +line_length: + warning: 140 + ignores_comments: true +identifier_name: + excluded: [x, y, id, dx, dy, to, on] +type_body_length: + warning: 320 +file_length: + warning: 450 +nesting: + type_level: 2 +opt_in_rules: + - empty_count + - first_where + - sorted_first_last + - toggle_bool + - redundant_nil_coalescing +disabled_rules: + - trailing_comma + - opening_brace + - todo +cyclomatic_complexity: + ignores_case_statements: true diff --git a/Amphlify.xcodeproj/project.pbxproj b/Amphlify.xcodeproj/project.pbxproj new file mode 100644 index 0000000..fec31cd --- /dev/null +++ b/Amphlify.xcodeproj/project.pbxproj @@ -0,0 +1,311 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + A10000000000000000000100 /* Domain in Frameworks */ = {isa = PBXBuildFile; productRef = A10000000000000000000101 /* Domain */; }; + A10000000000000000000110 /* Store in Frameworks */ = {isa = PBXBuildFile; productRef = A10000000000000000000111 /* Store */; }; + A10000000000000000000120 /* Sync in Frameworks */ = {isa = PBXBuildFile; productRef = A10000000000000000000121 /* Sync */; }; + A10000000000000000000130 /* Playback in Frameworks */ = {isa = PBXBuildFile; productRef = A10000000000000000000131 /* Playback */; }; + A10000000000000000000140 /* SourceFake in Frameworks */ = {isa = PBXBuildFile; productRef = A10000000000000000000141 /* SourceFake */; }; + A10000000000000000000150 /* SourceSubsonic in Frameworks */ = {isa = PBXBuildFile; productRef = A10000000000000000000151 /* SourceSubsonic */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + A10000000000000000000001 /* Amphlify.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Amphlify.app; sourceTree = BUILT_PRODUCTS_DIR; }; + A10000000000000000000030 /* Base.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Base.xcconfig; path = Config/Base.xcconfig; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + A10000000000000000000002 /* Amphlify */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = Amphlify; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + A10000000000000000000003 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + A10000000000000000000100 /* Domain in Frameworks */, + A10000000000000000000110 /* Store in Frameworks */, + A10000000000000000000120 /* Sync in Frameworks */, + A10000000000000000000130 /* Playback in Frameworks */, + A10000000000000000000140 /* SourceFake in Frameworks */, + A10000000000000000000150 /* SourceSubsonic in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + A10000000000000000000004 = { + isa = PBXGroup; + children = ( + A10000000000000000000030 /* Base.xcconfig */, + A10000000000000000000002 /* Amphlify */, + A10000000000000000000005 /* Products */, + ); + sourceTree = ""; + }; + A10000000000000000000005 /* Products */ = { + isa = PBXGroup; + children = ( + A10000000000000000000001 /* Amphlify.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + A10000000000000000000006 /* Amphlify */ = { + isa = PBXNativeTarget; + buildConfigurationList = A1000000000000000000000B /* Build configuration list for PBXNativeTarget "Amphlify" */; + buildPhases = ( + A10000000000000000000007 /* Sources */, + A10000000000000000000003 /* Frameworks */, + A10000000000000000000008 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + A10000000000000000000002 /* Amphlify */, + ); + name = Amphlify; + packageProductDependencies = ( + A10000000000000000000101 /* Domain */, + A10000000000000000000111 /* Store */, + A10000000000000000000121 /* Sync */, + A10000000000000000000131 /* Playback */, + A10000000000000000000141 /* SourceFake */, + A10000000000000000000151 /* SourceSubsonic */, + ); + productName = Amphlify; + productReference = A10000000000000000000001 /* Amphlify.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + A10000000000000000000009 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2700; + LastUpgradeCheck = 2700; + TargetAttributes = { + A10000000000000000000006 = { + CreatedOnToolsVersion = 27.0; + }; + }; + }; + buildConfigurationList = A1000000000000000000000A /* Build configuration list for PBXProject "Amphlify" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + ru, + Base, + ); + mainGroup = A10000000000000000000004; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + A10000000000000000000200 /* XCLocalSwiftPackageReference "Packages/AmphlifyKit" */, + ); + preferredProjectObjectVersion = 77; + productRefGroup = A10000000000000000000005 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + A10000000000000000000006 /* Amphlify */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + A10000000000000000000008 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + A10000000000000000000007 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + A1000000000000000000000C /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = A10000000000000000000030 /* Base.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_OPTIMIZATION_LEVEL = 0; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + A1000000000000000000000D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = A10000000000000000000030 /* Base.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + A1000000000000000000000E /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Config/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = Amphlify; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 0.1; + PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).amphlify"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Debug; + }; + A1000000000000000000000F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Config/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = Amphlify; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 0.1; + PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).amphlify"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + A1000000000000000000000A /* Build configuration list for PBXProject "Amphlify" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A1000000000000000000000C /* Debug */, + A1000000000000000000000D /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + A1000000000000000000000B /* Build configuration list for PBXNativeTarget "Amphlify" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A1000000000000000000000E /* Debug */, + A1000000000000000000000F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + A10000000000000000000200 /* XCLocalSwiftPackageReference "Packages/AmphlifyKit" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Packages/AmphlifyKit; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + A10000000000000000000101 /* Domain */ = { + isa = XCSwiftPackageProductDependency; + productName = Domain; + }; + A10000000000000000000111 /* Store */ = { + isa = XCSwiftPackageProductDependency; + productName = Store; + }; + A10000000000000000000121 /* Sync */ = { + isa = XCSwiftPackageProductDependency; + productName = Sync; + }; + A10000000000000000000131 /* Playback */ = { + isa = XCSwiftPackageProductDependency; + productName = Playback; + }; + A10000000000000000000141 /* SourceFake */ = { + isa = XCSwiftPackageProductDependency; + productName = SourceFake; + }; + A10000000000000000000151 /* SourceSubsonic */ = { + isa = XCSwiftPackageProductDependency; + productName = SourceSubsonic; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = A10000000000000000000009 /* Project object */; +} diff --git a/Amphlify.xcodeproj/xcshareddata/xcschemes/Amphlify.xcscheme b/Amphlify.xcodeproj/xcshareddata/xcschemes/Amphlify.xcscheme new file mode 100644 index 0000000..eefdab8 --- /dev/null +++ b/Amphlify.xcodeproj/xcshareddata/xcschemes/Amphlify.xcscheme @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Amphlify/App/Accounts.swift b/Amphlify/App/Accounts.swift new file mode 100644 index 0000000..f5b5c21 --- /dev/null +++ b/Amphlify/App/Accounts.swift @@ -0,0 +1,110 @@ +import Foundation +import Observation +import Security + +struct ServerAccount: Identifiable, Hashable, Codable { + var id = UUID() + var name: String + var baseURL: URL + var username: String + + static func normalize(_ text: String) -> URL? { + var trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + while trimmed.hasSuffix("/") { + trimmed.removeLast() + } + guard !trimmed.isEmpty else { return nil } + if !trimmed.contains("://") { + trimmed = "https://" + trimmed + } + guard let url = URL(string: trimmed), url.host() != nil else { return nil } + return url + } +} + +@Observable +final class AccountStore { + private(set) var accounts: [ServerAccount] = [] + private let file: URL + + init() { + let support = try? FileManager.default.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + let folder = (support ?? URL.temporaryDirectory).appending(path: "Amphlify", directoryHint: .isDirectory) + try? FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + file = folder.appending(path: "accounts.json") + accounts = (try? Data(contentsOf: file)).flatMap { try? JSONDecoder().decode([ServerAccount].self, from: $0) } ?? [] + } + + func account(for id: UUID) -> ServerAccount? { + accounts.first { $0.id == id } + } + + func password(for id: UUID) -> String? { + Keychain.read(id.uuidString) + } + + func save(_ account: ServerAccount, password: String) throws { + try Keychain.write(password, for: account.id.uuidString) + if let index = accounts.firstIndex(where: { $0.id == account.id }) { + accounts[index] = account + } else { + accounts.append(account) + } + try persist() + } + + func remove(_ id: UUID) { + Keychain.delete(id.uuidString) + accounts.removeAll { $0.id == id } + try? persist() + } + + private func persist() throws { + try JSONEncoder().encode(accounts).write(to: file, options: .atomic) + } +} + +private enum Keychain { + static let service = "com.soundoz.amphlify" + + static func read(_ account: String) -> String? { + var query = base(account) + query[kSecReturnData] = true + query[kSecMatchLimit] = kSecMatchLimitOne + var result: CFTypeRef? + guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, let data = result as? Data else { return nil } + return String(data: data, encoding: .utf8) + } + + static func write(_ value: String, for account: String) throws { + let data = Data(value.utf8) + let update = SecItemUpdate(base(account) as CFDictionary, [kSecValueData: data] as CFDictionary) + if update == errSecItemNotFound { + var query = base(account) + query[kSecValueData] = data + query[kSecAttrAccessible] = kSecAttrAccessibleAfterFirstUnlock + try check(SecItemAdd(query as CFDictionary, nil)) + } else { + try check(update) + } + } + + static func delete(_ account: String) { + SecItemDelete(base(account) as CFDictionary) + } + + private static func base(_ account: String) -> [CFString: Any] { + [kSecClass: kSecClassGenericPassword, kSecAttrService: service, kSecAttrAccount: account] + } + + private static func check(_ status: OSStatus) throws { + guard status == errSecSuccess else { + throw NSError(domain: NSOSStatusErrorDomain, code: Int(status)) + } + } +} diff --git a/Amphlify/App/AmphlifyApp.swift b/Amphlify/App/AmphlifyApp.swift new file mode 100644 index 0000000..c099714 --- /dev/null +++ b/Amphlify/App/AmphlifyApp.swift @@ -0,0 +1,31 @@ +import SwiftUI + +@main +struct AmphlifyApp: App { + @State private var app = Composition.makeApp() + @Environment(\.scenePhase) private var scenePhase + + var body: some Scene { + WindowGroup { + RootView() + .environment(app) + .environment(app.player) + .environment(app.router) + .task { await app.start() } + .task(id: scenePhase) { + guard scenePhase == .active else { return } + while !Task.isCancelled { + await app.resync() + try? await Task.sleep(for: .seconds(300)) + } + } + #if DEBUG + .task(id: app.revision == 0) { + if app.revision > 0 { + await DemoScene.requested?.apply(to: app) + } + } + #endif + } + } +} diff --git a/Amphlify/App/AppModel.swift b/Amphlify/App/AppModel.swift new file mode 100644 index 0000000..99d432c --- /dev/null +++ b/Amphlify/App/AppModel.swift @@ -0,0 +1,130 @@ +import Domain +import Foundation +import Observation +import Playback +import Store +import Sync + +enum ConnectError: Error { + case unreachable, unauthorized +} + +typealias Connector = (ServerAccount, String) async throws -> any LibrarySource + +@Observable +final class AppModel { + let library: any Library + let player: PlayerModel + let artwork: ArtworkLoader + let router = Router() + private(set) var sources: [any LibrarySource] + private(set) var revision = 0 + private(set) var favorites: Set = [] + private let registry: SourceRegistry + private let accounts: AccountStore + private let connect: Connector + private let sync: SyncEngine + private let remote = RemoteCommands() + private var isSyncing = false + + init( + library: any Library, + registry: SourceRegistry, + accounts: AccountStore, + session: PlaybackSession, + connect: @escaping Connector + ) { + self.library = library + self.registry = registry + self.accounts = accounts + self.connect = connect + sources = registry.sources + sync = SyncEngine(library: library) + artwork = ArtworkLoader(registry: registry) + player = PlayerModel(session: session, library: library, registry: registry) + } + + var hasSources: Bool { + !sources.isEmpty + } + + func account(for source: SourceID) -> ServerAccount? { + accounts.account(for: source.raw) + } + + func start() async { + isSyncing = true + player.start() + remote.start(player: player, artwork: artwork) + let updates = await library.updates() + await syncAll() + #if DEBUG + if UserDefaults.standard.bool(forKey: "autoplay") { + await player.play(Array(library.tracks().prefix(5))) + router.showsNowPlaying = true + } + #endif + for await _ in updates { + await refresh() + } + } + + func resync() async { + guard !isSyncing else { return } + isSyncing = true + await syncAll() + } + + private func syncAll() async { + for source in sources { + try? await sync.sync(source) + } + await refresh() + isSyncing = false + } + + private func refresh() async { + favorites = await library.favorites() + revision += 1 + } + + func add(account: ServerAccount, password: String) async throws { + let source = try await connect(account, password) + try accounts.save(account, password: password) + registry.append(source) + sources.append(source) + try? await sync.sync(source) + await refresh() + } + + func signOut(_ account: ServerAccount) { + accounts.remove(account.id) + registry.remove(SourceID(account.id)) + sources.removeAll { $0.id.raw == account.id } + player.send(.setQueue(tracks: [], startIndex: 0, shuffled: false)) + revision += 1 + } + + func lyrics(for track: Track) async -> Lyrics? { + guard let source = registry.source(for: track.id.source) as? any LyricsSource else { return nil } + return try? await source.lyrics(for: track.id) + } + + func canFavorite(_ id: EntityID) -> Bool { + registry.source(for: id.source) is any Starring + } + + func toggleFavorite(_ id: EntityID, kind: EntityKind) { + let isFavorite = !favorites.contains(id) + if isFavorite { + favorites.insert(id) + } else { + favorites.remove(id) + } + Task { + await library.setFavorite(isFavorite, for: id) + let source = registry.source(for: id.source) as? any Starring + try? await source?.setFavorite(isFavorite, for: id, kind: kind) + } + } +} diff --git a/Amphlify/App/ArtworkLoader.swift b/Amphlify/App/ArtworkLoader.swift new file mode 100644 index 0000000..f80c925 --- /dev/null +++ b/Amphlify/App/ArtworkLoader.swift @@ -0,0 +1,45 @@ +import Domain +import UIKit + +final class ArtworkLoader { + private let cache = NSCache() + private let registry: SourceRegistry + + init(registry: SourceRegistry) { + self.registry = registry + cache.countLimit = 400 + } + + func cached(_ id: EntityID, pixelSize: Int) -> UIImage? { + cache.object(forKey: key(id, pixelSize)) + } + + func image(_ id: EntityID, pixelSize: Int) async -> UIImage? { + if let hit = cached(id, pixelSize: pixelSize) { + return hit + } + guard let source = registry.source(for: id.source), + let request = try? await source.artwork(id, pixelSize: pixelSize), + let image = await Self.load(request) + else { return nil } + cache.setObject(image, forKey: key(id, pixelSize)) + return image + } + + private func key(_ id: EntityID, _ pixelSize: Int) -> NSString { + "\(id.source.raw)/\(id.remote)/\(pixelSize)" as NSString + } + + @concurrent + private nonisolated static func load(_ request: ResourceRequest) async -> UIImage? { + let data: Data? + if request.url.isFileURL { + data = try? Data(contentsOf: request.url) + } else { + var urlRequest = URLRequest(url: request.url) + request.headers.forEach { urlRequest.setValue($1, forHTTPHeaderField: $0) } + data = try? await URLSession.shared.data(for: urlRequest).0 + } + return data.flatMap(UIImage.init(data:))?.preparingForDisplay() + } +} diff --git a/Amphlify/App/Composition.swift b/Amphlify/App/Composition.swift new file mode 100644 index 0000000..b3a6146 --- /dev/null +++ b/Amphlify/App/Composition.swift @@ -0,0 +1,86 @@ +import Domain +import Foundation +import Playback +import SourceFake +import SourceSubsonic +import Store + +enum Composition { + static func makeApp() -> AppModel { + #if DEBUG + if DemoScene.requested != nil { + return makeDemo() + } + #endif + let accounts = AccountStore() + #if DEBUG + LaunchAccount.apply(to: accounts) + #endif + let registry = SourceRegistry() + let engine = AVFoundationEngine { track in + guard let source = registry.source(for: track.id.source) else { throw SourceError.notFound } + return try await source.stream(for: track.id) + } + registry.replace(accounts.accounts.compactMap { account in + accounts.password(for: account.id).map { source(for: account, password: $0) } + }) + return AppModel( + library: MemoryLibrary(), + registry: registry, + accounts: accounts, + session: PlaybackSession(engine: engine), + connect: connect + ) + } + + private static func source(for account: ServerAccount, password: String) -> SubsonicSource { + SubsonicSource( + id: SourceID(account.id), + account: SubsonicAccount(baseURL: account.baseURL, username: account.username), + password: password, + name: account.name + ) + } + + private static func connect(_ account: ServerAccount, password: String) async throws -> any LibrarySource { + let source = source(for: account, password: password) + do { + try await source.verify() + } catch is URLError { + throw ConnectError.unreachable + } catch SubsonicError.server(code: 40, message: _), SubsonicError.server(code: 41, message: _), SubsonicError.http(401) { + throw ConnectError.unauthorized + } + return source + } + + #if DEBUG + private static func makeDemo() -> AppModel { + let source = FakeSource() + ReferenceTrack.install(into: source) + let registry = SourceRegistry() + registry.replace([source]) + return AppModel( + library: MemoryLibrary(), + registry: registry, + accounts: AccountStore(), + session: PlaybackSession(engine: SilentEngine()), + connect: connect + ) + } + + private enum LaunchAccount { + static func apply(to store: AccountStore) { + let defaults = UserDefaults.standard + guard let server = defaults.string(forKey: "server"), + let url = ServerAccount.normalize(server), + let username = defaults.string(forKey: "user"), + let password = defaults.string(forKey: "password") + else { return } + let existing = store.accounts.first { $0.baseURL == url && $0.username == username } + let account = existing ?? ServerAccount(name: url.host() ?? server, baseURL: url, username: username) + try? store.save(account, password: password) + } + } + #endif +} diff --git a/Amphlify/App/DemoScene.swift b/Amphlify/App/DemoScene.swift new file mode 100644 index 0000000..cf4b6ba --- /dev/null +++ b/Amphlify/App/DemoScene.swift @@ -0,0 +1,96 @@ +#if DEBUG + import Domain + import Foundation + + enum DemoScene: String { + case library, search, albums, artists, playlists, radio, album, artist, playlist, playing, queue, toggle, reference, mix, kassa, + lyrics + + static var requested: DemoScene? { + UserDefaults.standard.string(forKey: "demo").flatMap(DemoScene.init) + } + + func apply(to app: AppModel) async { + let albums = await app.library.albums(sortedBy: .artist) + guard let album = albums.dropFirst(11).first else { return } + let router = app.router + switch self { + case .library: router.tab = .library + case .search: router.tab = .search + case .albums: show(.section(.albums), in: router) + case .artists: show(.section(.artists), in: router) + case .playlists: show(.section(.playlists), in: router) + case .radio: router.tab = .radio + case .album: show(.album(album.id), in: router) + case .artist: album.artistID.map { show(.artist($0), in: router) } + case .playlist: await app.library.playlists().first.map { show(.playlist($0.id), in: router) } + case .mix: + guard let playlist = await app.library.playlists().first(where: { $0.name == "Reference" }) else { return } + await app.player.play(app.library.tracks(inPlaylist: playlist.id)) + try? await Task.sleep(for: .seconds(1)) + router.showsNowPlaying = true + case .kassa, .lyrics: + guard let track = await app.library.tracks().first(where: { $0.id.remote == ReferenceTrack.remote }) else { return } + app.player.play([track]) + try? await Task.sleep(for: .seconds(1)) + if self == .kassa { + app.player.send(.pause) + } + router.showsNowPlaying = true + router.showsLyrics = self == .lyrics + case .reference: + guard let track = await app.library.tracks().first(where: { $0.id.remote == ReferenceTrack.remote }) else { return } + let others = await app.library.tracks(inGenre: "Hip-Hop").filter { $0.id != track.id }.prefix(6) + app.player.play([track] + others) + try? await Task.sleep(for: .seconds(1)) + router.showsNowPlaying = true + app.player.send(.seek(to: 90)) + while true { + try? await Task.sleep(for: .seconds(1.5)) + router.showsQueue.toggle() + } + case .playing, .queue, .toggle: await playAlbum(album, in: app) + } + } + + private func playAlbum(_ album: Album, in app: AppModel) async { + let router = app.router + show(.album(album.id), in: router) + await app.player.play(app.library.tracks(inAlbum: album.id), startingAt: 2) + try? await Task.sleep(for: .seconds(1)) + router.showsNowPlaying = true + app.player.send(.seek(to: 47)) + if self == .queue { + router.showsQueue = true + } + while self == .toggle { + try? await Task.sleep(for: .seconds(1.5)) + router.showsQueue.toggle() + } + } + + private func playReference(in app: AppModel) async { + guard let track = await app.library.tracks().first(where: { $0.id.remote == ReferenceTrack.remote }) else { return } + let router = app.router + let others = await app.library.tracks(inGenre: "Hip-Hop").filter { $0.id != track.id }.prefix(6) + app.player.play(self == .reference ? [track] + others : [track]) + try? await Task.sleep(for: .seconds(1)) + if self == .kassa { + app.player.send(.pause) + } + router.showsNowPlaying = true + router.showsLyrics = self == .lyrics + guard self == .reference else { return } + app.player.send(.seek(to: 90)) + while true { + try? await Task.sleep(for: .seconds(1.5)) + router.showsQueue.toggle() + } + } + + private func show(_ route: Route, in router: Router) { + router.tab = .library + router.paths[.library] = [route] + } + } +#endif diff --git a/Amphlify/App/PlayerModel.swift b/Amphlify/App/PlayerModel.swift new file mode 100644 index 0000000..2f02d22 --- /dev/null +++ b/Amphlify/App/PlayerModel.swift @@ -0,0 +1,81 @@ +import Domain +import Foundation +import Observation +import Playback +import Store + +@Observable +final class PlayerModel { + private(set) var state = PlaybackState() + private let session: PlaybackSession + private let library: any Library + private let registry: SourceRegistry + private let scrobbleThreshold: TimeInterval = 240 + + init(session: PlaybackSession, library: any Library, registry: SourceRegistry) { + self.session = session + self.library = library + self.registry = registry + } + + func start() { + Task { + for await next in await session.states() { + let previous = state + state = next + guard next.current?.id != previous.current?.id else { continue } + if let finished = previous.current { + submit(finished, listened: previous.anchor.position(at: .now)) + } + if let started = next.current { + announce(started.track) + } + } + } + } + + var current: Track? { + state.current?.track + } + + var isPlaying: Bool { + state.status == .playing + } + + func position(at instant: ContinuousClock.Instant = .now) -> TimeInterval { + min(max(state.anchor.position(at: instant), 0), current?.duration ?? 0) + } + + func send(_ command: PlaybackCommand) { + Task { await session.send(command) } + } + + func play(_ tracks: [Track], startingAt index: Int = 0) { + send(.setQueue(tracks: tracks, startIndex: index, shuffled: false)) + } + + func shuffle(_ tracks: [Track]) { + send(.setQueue(tracks: tracks, startIndex: 0, shuffled: true)) + } + + func isCurrent(_ track: Track) -> Bool { + current?.id == track.id + } + + private func submit(_ item: QueueItem, listened: TimeInterval) { + let duration = item.track.duration + let played = min(max(listened, 0), duration) + guard played > 0, played >= min(duration / 2, scrobbleThreshold) else { return } + scrobble(item.track.id, submission: true) + } + + private func announce(_ track: Track) { + Task { await library.recordPlay(of: track, at: Date()) } + scrobble(track.id, submission: false) + } + + private func scrobble(_ id: EntityID, submission: Bool) { + guard let source = registry.source(for: id.source) as? any Scrobbling else { return } + Task { try? await source.scrobble(id, at: Date(), submission: submission) } + } +} diff --git a/Amphlify/App/ReferenceTrack.swift b/Amphlify/App/ReferenceTrack.swift new file mode 100644 index 0000000..c39a43f --- /dev/null +++ b/Amphlify/App/ReferenceTrack.swift @@ -0,0 +1,70 @@ +#if DEBUG + import Domain + import Foundation + import SourceFake + + enum ReferenceTrack { + static let remote = "reference-track" + + static func install(into source: FakeSource) { + guard let cover = Bundle.main.url(forResource: "kassa", withExtension: "png"), + let lyrics = Bundle.main.url(forResource: "kassa", withExtension: "lrc") + else { return } + let artwork = EntityID(source: source.id, remote: "reference-art") + let artist = Artist( + id: EntityID(source: source.id, remote: "reference-artist"), + name: "рай не сегодня & 17 SEVENTEEN", + artwork: artwork + ) + let album = Album( + id: EntityID(source: source.id, remote: "reference-album"), + title: "Касса - Single", + artistID: artist.id, + artistName: artist.name, + year: 2021, + genre: "Hip-Hop", + artwork: artwork, + trackCount: 1, + duration: 121, + addedAt: Date() + ) + let track = Track( + id: EntityID(source: source.id, remote: remote), + title: "Касса", + albumID: album.id, + albumTitle: album.title, + artistID: artist.id, + artistName: artist.name, + trackNumber: 1, + duration: 121, + year: 2021, + genre: "Hip-Hop", + artwork: artwork, + isExplicit: true, + formats: [AudioFormat(codec: .mp3, bitrateKbps: 128, sampleRate: 44100)] + ) + let filler = Array(source.sampleTracks.prefix(24)) + source.add(changes(artist, album, track, filler: filler), artworkFiles: ["reference-art": cover], lyricsFiles: [remote: lyrics]) + } + + private static func changes(_ artist: Artist, _ album: Album, _ track: Track, filler: [Track]) -> [Change] { + let playlist = Playlist( + id: EntityID(source: track.id.source, remote: "reference-playlist"), + name: "Reference", + artwork: track.artwork, + trackCount: filler.count + 1, + duration: track.duration + filler.reduce(0) { $0 + $1.duration }, + updatedAt: Date() + ) + return [ + .upsert(.artist(artist)), + .upsert(.album(album)), + .upsert(.track(track)), + .upsert(.playlist( + playlist, + entries: ([track] + filler).enumerated().map { PlaylistEntry(id: "reference-\($0.offset)", track: $0.element.id) } + )), + ] + } + } +#endif diff --git a/Amphlify/App/RemoteCommands.swift b/Amphlify/App/RemoteCommands.swift new file mode 100644 index 0000000..e246f0e --- /dev/null +++ b/Amphlify/App/RemoteCommands.swift @@ -0,0 +1,71 @@ +import Domain +import MediaPlayer +import Playback + +final class RemoteCommands { + private let center = MPRemoteCommandCenter.shared() + private let info = MPNowPlayingInfoCenter.default() + private var artworkID: EntityID? + private var artwork: MPMediaItemArtwork? + private var artworkTask: Task? + + func start(player: PlayerModel, artwork loader: ArtworkLoader) { + bind(center.playCommand, to: .play, on: player) + bind(center.pauseCommand, to: .pause, on: player) + bind(center.togglePlayPauseCommand, to: .togglePlayPause, on: player) + bind(center.nextTrackCommand, to: .next, on: player) + bind(center.previousTrackCommand, to: .previous, on: player) + center.changePlaybackPositionCommand.addTarget { event in + guard let event = event as? MPChangePlaybackPositionCommandEvent else { return .commandFailed } + player.send(.seek(to: event.positionTime)) + return .success + } + Task { + for await state in Observations({ player.state }) { + update(state, loader) + } + } + } + + private func bind(_ command: MPRemoteCommand, to playbackCommand: PlaybackCommand, on player: PlayerModel) { + command.addTarget { _ in + player.send(playbackCommand) + return .success + } + } + + private func update(_ state: PlaybackState, _ loader: ArtworkLoader) { + guard let track = state.current?.track else { + info.nowPlayingInfo = nil + info.playbackState = .stopped + return + } + loadArtwork(track.artwork, loader) + let position = min(max(state.anchor.position(at: .now), 0), track.duration) + var dict: [String: Any] = [ + MPMediaItemPropertyTitle: track.title, + MPMediaItemPropertyArtist: track.artistName, + MPMediaItemPropertyAlbumTitle: track.albumTitle, + MPMediaItemPropertyPlaybackDuration: track.duration, + MPNowPlayingInfoPropertyElapsedPlaybackTime: position, + MPNowPlayingInfoPropertyPlaybackRate: state.anchor.rate, + MPNowPlayingInfoPropertyMediaType: MPNowPlayingInfoMediaType.audio.rawValue, + ] + dict[MPMediaItemPropertyArtwork] = artwork + info.nowPlayingInfo = dict + info.playbackState = state.status == .playing ? .playing : .paused + } + + private func loadArtwork(_ id: EntityID?, _ loader: ArtworkLoader) { + guard id != artworkID else { return } + artworkID = id + artwork = nil + artworkTask?.cancel() + guard let id else { return } + artworkTask = Task { + guard let image = await loader.image(id, pixelSize: 600), !Task.isCancelled else { return } + artwork = MPMediaItemArtwork(boundsSize: image.size) { @Sendable _ in image } + info.nowPlayingInfo?[MPMediaItemPropertyArtwork] = artwork + } + } +} diff --git a/Amphlify/App/Router.swift b/Amphlify/App/Router.swift new file mode 100644 index 0000000..0438ab4 --- /dev/null +++ b/Amphlify/App/Router.swift @@ -0,0 +1,50 @@ +import Domain +import Observation + +enum AppTab: Hashable { + case home, radio, library, search +} + +enum LibrarySection: Hashable, CaseIterable { + case playlists, artists, albums, songs, genres +} + +enum Route: Hashable { + case album(EntityID) + case artist(EntityID) + case playlist(EntityID) + case genre(String) + case favorites + case section(LibrarySection) +} + +@Observable +final class Router { + var tab: AppTab = .home + var paths: [AppTab: [Route]] = [:] + var showsNowPlaying = false + var showsQueue = false { + didSet { + if showsQueue { + showsLyrics = false + } + } + } + + var showsLyrics = false { + didSet { + if showsLyrics { + showsQueue = false + } + } + } + + func path(for tab: AppTab) -> [Route] { + paths[tab] ?? [] + } + + func open(_ route: Route) { + showsNowPlaying = false + paths[tab, default: []].append(route) + } +} diff --git a/Amphlify/App/SourceRegistry.swift b/Amphlify/App/SourceRegistry.swift new file mode 100644 index 0000000..809c87d --- /dev/null +++ b/Amphlify/App/SourceRegistry.swift @@ -0,0 +1,26 @@ +import Domain +import Synchronization + +final nonisolated class SourceRegistry: Sendable { + private let storage = Mutex<[any LibrarySource]>([]) + + var sources: [any LibrarySource] { + storage.withLock { $0 } + } + + func replace(_ sources: [any LibrarySource]) { + storage.withLock { $0 = sources } + } + + func append(_ source: any LibrarySource) { + storage.withLock { $0.append(source) } + } + + func remove(_ id: SourceID) { + storage.withLock { list in list.removeAll { $0.id == id } } + } + + func source(for id: SourceID) -> (any LibrarySource)? { + sources.first { $0.id == id } + } +} diff --git a/Amphlify/Resources/Assets.xcassets/AccentColor.colorset/Contents.json b/Amphlify/Resources/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..3d8d7a6 --- /dev/null +++ b/Amphlify/Resources/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,7 @@ +{ + "colors" : [ + { "idiom" : "universal", "color" : { "color-space" : "srgb", "components" : { "red" : "0.980", "green" : "0.137", "blue" : "0.231", "alpha" : "1.000" } } }, + { "idiom" : "universal", "appearances" : [ { "appearance" : "luminosity", "value" : "dark" } ], "color" : { "color-space" : "srgb", "components" : { "red" : "0.980", "green" : "0.176", "blue" : "0.282", "alpha" : "1.000" } } } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/Amphlify/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/Amphlify/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..31b61ae --- /dev/null +++ b/Amphlify/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1 @@ +{ "images" : [ { "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" } ], "info" : { "author" : "xcode", "version" : 1 } } diff --git a/Amphlify/Resources/Assets.xcassets/Contents.json b/Amphlify/Resources/Assets.xcassets/Contents.json new file mode 100644 index 0000000..46cba7d --- /dev/null +++ b/Amphlify/Resources/Assets.xcassets/Contents.json @@ -0,0 +1 @@ +{ "info" : { "author" : "xcode", "version" : 1 } } diff --git a/Amphlify/Resources/Localizable.xcstrings b/Amphlify/Resources/Localizable.xcstrings new file mode 100644 index 0000000..b67e57c --- /dev/null +++ b/Amphlify/Resources/Localizable.xcstrings @@ -0,0 +1,658 @@ +{ + "sourceLanguage": "en", + "strings": { + "%lld songs, %@": { + "localizations": { + "en": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "%lld song, %@" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld songs, %@" + } + } + } + } + }, + "ru": { + "variations": { + "plural": { + "few": { + "stringUnit": { + "state": "translated", + "value": "%lld песни, %@" + } + }, + "many": { + "stringUnit": { + "state": "translated", + "value": "%lld песен, %@" + } + }, + "one": { + "stringUnit": { + "state": "translated", + "value": "%lld песня, %@" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld песни, %@" + } + } + } + } + } + } + }, + "Account": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Учётная запись" + } + } + } + }, + "Album · %@": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Альбом · %@" + } + } + } + }, + "Albums": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Альбомы" + } + } + } + }, + "Artist": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Артист" + } + } + } + }, + "Artist Stations": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Станции артистов" + } + } + } + }, + "Artists": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Артисты" + } + } + } + }, + "Artists, Songs, Albums and More": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Артисты, песни, альбомы и др." + } + } + } + }, + "Browse Categories": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Обзор категорий" + } + } + } + }, + "Can't connect to the server": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удается подключиться к серверу" + } + } + } + }, + "Can't sign in. Try again later.": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удается войти. Повторите попытку позже." + } + } + } + }, + "Done": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Готово" + } + } + } + }, + "Favorite": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "В избранное" + } + } + } + }, + "Favorite Songs": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Избранные песни" + } + } + } + }, + "Genres": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Жанры" + } + } + } + }, + "Go to Album": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Перейти к альбому" + } + } + } + }, + "Go to Artist": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Перейти к артисту" + } + } + } + }, + "Home": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Главная" + } + } + } + }, + "Library": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Медиатека" + } + } + } + }, + "Lyrics": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Текст песни" + } + } + } + }, + "More": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ещё" + } + } + } + }, + "More by %@": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ещё от: %@" + } + } + } + }, + "Most Played": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Часто слушаете" + } + } + } + }, + "Next": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Вперёд" + } + } + } + }, + "Not Playing": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не исполняется" + } + } + } + }, + "Nothing up next": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Очередь пуста" + } + } + } + }, + "Now Playing": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сейчас играет" + } + } + } + }, + "Password": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Пароль" + } + } + } + }, + "Pause": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Пауза" + } + } + } + }, + "Play": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Слушать" + } + } + } + }, + "Play Last": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Воспроизвести последней" + } + } + } + }, + "Play Next": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Воспроизвести далее" + } + } + } + }, + "Playing Next": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Далее" + } + } + } + }, + "Playlist": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Плейлист" + } + } + } + }, + "Playlists": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Плейлисты" + } + } + } + }, + "Previous": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Назад" + } + } + } + }, + "Queue": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Очередь" + } + } + } + }, + "Radio": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Радио" + } + } + } + }, + "Recently Added": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Недавно добавленные" + } + } + } + }, + "Recently Played": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Недавно прослушанные" + } + } + } + }, + "Repeat": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Повтор" + } + } + } + }, + "Search": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Поиск" + } + } + } + }, + "Server": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сервер" + } + } + } + }, + "Shuffle": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Перемешать" + } + } + } + }, + "Sign In": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Войти" + } + } + } + }, + "Sign Out": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Выйти" + } + } + } + }, + "Songs": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Песни" + } + } + } + }, + "Sort": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сортировка" + } + } + } + }, + "Sources": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Источники" + } + } + } + }, + "Station": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Станция" + } + } + } + }, + "Stations by Genre": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Станции по жанрам" + } + } + } + }, + "Title": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Название" + } + } + } + }, + "Top Picks for You": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Подборки для вас" + } + } + } + }, + "Top Songs": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Топ-песни" + } + } + } + }, + "Track position": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Позиция трека" + } + } + } + }, + "Undo Favorite": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Убрать из избранного" + } + } + } + }, + "Username": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Имя пользователя" + } + } + } + }, + "Volume": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Громкость" + } + } + } + }, + "Wrong username or password": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Неверное имя пользователя или пароль" + } + } + } + }, + "Year": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Год" + } + } + } + }, + "Your Playlists": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ваши плейлисты" + } + } + } + } + }, + "version": "1.0" +} diff --git a/Amphlify/UI/Components/ArtworkView.swift b/Amphlify/UI/Components/ArtworkView.swift new file mode 100644 index 0000000..d626282 --- /dev/null +++ b/Amphlify/UI/Components/ArtworkView.swift @@ -0,0 +1,48 @@ +import Domain +import SwiftUI + +struct ArtworkView: View { + enum Shape { + case rounded(CGFloat) + case circle + } + + let artwork: EntityID? + var shape: Shape = .rounded(8) + var pixelSize = 320 + + @Environment(AppModel.self) private var app + @State private var image: UIImage? + + var body: some View { + Color(.secondarySystemFill) + .aspectRatio(1, contentMode: .fit) + .overlay { + if let image = image ?? artwork.flatMap({ app.artwork.cached($0, pixelSize: pixelSize) }) { + Image(uiImage: image).resizable().scaledToFill() + } else { + GeometryReader { proxy in + Image(systemName: "music.note") + .font(.system(size: proxy.size.width * 0.4)) + .foregroundStyle(.tertiary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + } + .clipShape(clip) + .overlay { clip.strokeBorder(.primary.opacity(0.1), lineWidth: 0.5) } + .task(id: artwork) { + image = nil + guard let artwork else { return } + image = await app.artwork.image(artwork, pixelSize: pixelSize) + } + .accessibilityHidden(true) + } + + private var clip: RoundedRectangle { + switch shape { + case let .rounded(radius): RoundedRectangle(cornerRadius: radius, style: .continuous) + case .circle: RoundedRectangle(cornerRadius: 10000, style: .circular) + } + } +} diff --git a/Amphlify/UI/Components/Chrome.swift b/Amphlify/UI/Components/Chrome.swift new file mode 100644 index 0000000..13df034 --- /dev/null +++ b/Amphlify/UI/Components/Chrome.swift @@ -0,0 +1,98 @@ +import SwiftUI + +enum Metrics { + static let margin: CGFloat = UIScreen.main.bounds.width >= 414 ? 20 : 16 + static let tileSpacing: CGFloat = 12 +} + +extension View { + func shelfTile(fraction: CGFloat = 1 / 2.15) -> some View { + containerRelativeFrame(.horizontal) { width, _ in + (width - Metrics.margin * 2 - Metrics.tileSpacing) * fraction + } + } +} + +struct AccountToolbar: ViewModifier { + @State private var showsAccount = false + @Environment(AppModel.self) private var app + + func body(content: Content) -> some View { + content + .toolbarTitleDisplayMode(.inlineLarge) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { showsAccount = true } label: { + Text(app.sources.first?.name.prefix(1).uppercased() ?? "") + .font(.headline) + .foregroundStyle(.white) + .frame(width: 36, height: 36) + .background(Color(.systemIndigo).mix(with: .black, by: 0.45).gradient, in: .circle) + } + .accessibilityLabel("Account") + } + .sharedBackgroundVisibility(.hidden) + } + .sheet(isPresented: $showsAccount) { AccountView() } + } +} + +struct Marquee: View { + let text: String + let font: Font + var restart = true + + @State private var textWidth: CGFloat = 0 + @State private var boxWidth: CGFloat = 0 + @State private var startedAt = Date() + @Environment(\.accessibilityReduceMotion) private var reduceMotion + private let gap: CGFloat = 48 + private let pause: TimeInterval = 2 + private let speed: CGFloat = 30 + + var body: some View { + let overflows = textWidth > boxWidth + 1 && !reduceMotion + Text(text) + .font(font) + .lineLimit(1) + .hidden() + .onGeometryChange(for: CGFloat.self) { $0.size.width } action: { boxWidth = $0 } + .overlay(alignment: .leading) { + TimelineView(.animation(paused: !overflows)) { context in + HStack(spacing: gap) { + label.onGeometryChange(for: CGFloat.self) { $0.size.width } action: { textWidth = $0 } + if overflows { + label + } + } + .offset(x: overflows ? -travel(at: context.date) : 0) + } + } + .mask { + LinearGradient( + stops: [.init(color: .black, location: overflows ? 0.92 : 1), .init(color: overflows ? .clear : .black, location: 1)], + startPoint: .leading, + endPoint: .trailing + ) + } + .clipped() + .onChange(of: "\(text)\(restart)", initial: true) { _, _ in + if restart { + startedAt = Date() + } + } + .accessibilityLabel(text) + } + + private func travel(at date: Date) -> CGFloat { + let loop = textWidth + gap + let elapsed = date.timeIntervalSince(startedAt) + let cycle = pause + Double(loop / speed) + let phase = max(elapsed.truncatingRemainder(dividingBy: cycle) - pause, 0) + return CGFloat(phase) * speed + } + + private var label: some View { + Text(text).font(font).lineLimit(1).fixedSize() + } +} diff --git a/Amphlify/UI/Components/CollectionHeader.swift b/Amphlify/UI/Components/CollectionHeader.swift new file mode 100644 index 0000000..a2b33df --- /dev/null +++ b/Amphlify/UI/Components/CollectionHeader.swift @@ -0,0 +1,87 @@ +import Domain +import SwiftUI + +struct CollectionHeader: View { + let artwork: EntityID? + let title: String + var subtitle: String? + var subtitleRoute: Route? + var meta: String? + let onPlay: () -> Void + let onShuffle: () -> Void + + var body: some View { + VStack(spacing: 0) { + ArtworkView(artwork: artwork, shape: .rounded(10), pixelSize: 1024) + .frame(maxWidth: 270) + .shadow(color: .black.opacity(0.22), radius: 22, y: 12) + .padding(.horizontal, 60) + .padding(.bottom, 18) + Text(title) + .font(.title3.weight(.semibold)) + .multilineTextAlignment(.center) + if let subtitle { + if let subtitleRoute { + NavigationLink(value: subtitleRoute) { Text(subtitle).font(.title3) } + } else { + Text(subtitle).font(.title3).foregroundStyle(.secondary) + } + } + if let meta { + Text(meta) + .font(.caption.weight(.medium)) + .foregroundStyle(.secondary) + .padding(.top, 4) + } + PlayShuffleButtons(onPlay: onPlay, onShuffle: onShuffle) + .padding(.top, 16) + } + .padding(.horizontal, Metrics.margin) + .padding(.bottom, 12) + } +} + +struct PlayShuffleButtons: View { + let onPlay: () -> Void + let onShuffle: () -> Void + + var body: some View { + HStack(spacing: 14) { + Button(action: onPlay) { Label("Play", systemImage: "play.fill").modifier(PillLabel()) } + Button(action: onShuffle) { Label("Shuffle", systemImage: "shuffle").modifier(PillLabel()) } + } + .buttonStyle(.plain) + } + + private struct PillLabel: ViewModifier { + func body(content: Content) -> some View { + content + .font(.body.weight(.semibold)) + .lineLimit(1) + .minimumScaleFactor(0.7) + .foregroundStyle(.tint) + .frame(maxWidth: .infinity, minHeight: 50) + .background(Color(.secondarySystemFill), in: .capsule) + .contentShape(.capsule) + } + } +} + +struct ScrollRevealedTitle: ViewModifier { + let title: String + var threshold: CGFloat = 300 + @State private var isRevealed = false + + func body(content: Content) -> some View { + content + .onScrollGeometryChange(for: Bool.self) { $0.contentOffset.y + $0.contentInsets.top > threshold } action: { _, passed in + withAnimation(.easeOut(duration: 0.2)) { isRevealed = passed } + } + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .principal) { + Text(title).font(.headline).lineLimit(1).opacity(isRevealed ? 1 : 0) + } + } + } +} diff --git a/Amphlify/UI/Components/Formatting.swift b/Amphlify/UI/Components/Formatting.swift new file mode 100644 index 0000000..123cd8d --- /dev/null +++ b/Amphlify/UI/Components/Formatting.swift @@ -0,0 +1,36 @@ +import Domain +import Foundation + +extension TimeInterval { + var clock: String { + let total = Int(rounded(.down)) + return total >= 3600 + ? String(format: "%d:%02d:%02d", total / 3600, total / 60 % 60, total % 60) + : String(format: "%d:%02d", total / 60, total % 60) + } + + var spoken: String { + Duration.seconds(self < 60 ? self : (self / 60).rounded() * 60) + .formatted(.units(allowed: [.hours, .minutes, .seconds], width: .wide, maximumUnitCount: 2)) + } +} + +extension AudioFormat { + var badge: String { + if isHiRes { + return "Hi-Res Lossless" + } + if isLossless { + return "Lossless" + } + let name = codec.rawValue.uppercased() + return bitrateKbps.map { "\(name) \($0)" } ?? name + } + + var detail: String? { + guard isLossless, let bitDepth, let sampleRate else { return nil } + let rate = Measurement(value: Double(sampleRate) / 1000, unit: UnitFrequency.kilohertz) + .formatted(.measurement(width: .abbreviated, numberFormatStyle: .number.precision(.fractionLength(0 ... 1)))) + return "\(bitDepth)-bit/\(rate) \(codec.rawValue.uppercased())" + } +} diff --git a/Amphlify/UI/Components/Shelf.swift b/Amphlify/UI/Components/Shelf.swift new file mode 100644 index 0000000..7008f02 --- /dev/null +++ b/Amphlify/UI/Components/Shelf.swift @@ -0,0 +1,46 @@ +import SwiftUI + +struct Shelf: View { + let title: LocalizedStringKey + var route: Route? + @ViewBuilder let content: Content + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + SectionHeader(title: title, route: route) + ScrollView(.horizontal) { + LazyHStack(alignment: .top, spacing: Metrics.tileSpacing) { content } + .scrollTargetLayout() + } + .scrollTargetBehavior(.viewAligned) + .scrollIndicators(.hidden) + .contentMargins(.horizontal, Metrics.margin, for: .scrollContent) + } + } +} + +struct SectionHeader: View { + let title: LocalizedStringKey + var route: Route? + + var body: some View { + Group { + if let route { + NavigationLink(value: route) { + HStack(spacing: 4) { + Text(title) + Image(systemName: "chevron.right") + .font(.headline) + .foregroundStyle(.secondary) + } + } + .buttonStyle(.plain) + } else { + Text(title) + } + } + .font(.title2.bold()) + .padding(.horizontal, Metrics.margin) + .accessibilityAddTraits(.isHeader) + } +} diff --git a/Amphlify/UI/Components/Tiles.swift b/Amphlify/UI/Components/Tiles.swift new file mode 100644 index 0000000..8ed4402 --- /dev/null +++ b/Amphlify/UI/Components/Tiles.swift @@ -0,0 +1,68 @@ +import Domain +import SwiftUI + +struct AlbumTile: View { + let album: Album + var showsArtist = true + + @Environment(AppModel.self) private var app + @Environment(PlayerModel.self) private var player + + var body: some View { + NavigationLink(value: Route.album(album.id)) { + VStack(alignment: .leading, spacing: 6) { + ArtworkView(artwork: album.artwork) + VStack(alignment: .leading, spacing: 1) { + Text(album.title).foregroundStyle(.primary) + Text(showsArtist ? album.artistName : album.year.map(String.init) ?? " ").foregroundStyle(.secondary) + } + .font(.subheadline) + .lineLimit(1) + } + } + .buttonStyle(.plain) + .contextMenu { + Button("Play", systemImage: "play") { load { player.play($0) } } + Button("Shuffle", systemImage: "shuffle") { load { player.shuffle($0) } } + Button("Play Next", systemImage: "text.line.first.and.arrowtriangle.forward") { load { player.send(.insertNext($0)) } } + Button("Play Last", systemImage: "text.line.last.and.arrowtriangle.forward") { load { player.send(.insertLast($0)) } } + if let artist = album.artistID { + Divider() + Button("Go to Artist", systemImage: "music.microphone") { app.router.open(.artist(artist)) } + } + } + } + + private func load(_ action: @escaping ([Track]) -> Void) { + Task { await action(app.library.tracks(inAlbum: album.id)) } + } +} + +struct PlaylistTile: View { + let playlist: Playlist + + var body: some View { + NavigationLink(value: Route.playlist(playlist.id)) { + VStack(alignment: .leading, spacing: 6) { + ArtworkView(artwork: playlist.artwork) + Text(playlist.name) + .font(.subheadline) + .lineLimit(2, reservesSpace: true) + .multilineTextAlignment(.leading) + } + } + .buttonStyle(.plain) + } +} + +struct AlbumGrid: View { + let albums: [Album] + var showsArtist = true + + var body: some View { + LazyVGrid(columns: [GridItem(.adaptive(minimum: 150), spacing: Metrics.tileSpacing, alignment: .top)], spacing: 24) { + ForEach(albums) { AlbumTile(album: $0, showsArtist: showsArtist) } + } + .padding(.horizontal, Metrics.margin) + } +} diff --git a/Amphlify/UI/Components/TrackRow.swift b/Amphlify/UI/Components/TrackRow.swift new file mode 100644 index 0000000..a4848a1 --- /dev/null +++ b/Amphlify/UI/Components/TrackRow.swift @@ -0,0 +1,151 @@ +import Domain +import SwiftUI + +struct TrackRow: View { + enum Subtitle { + case none, artist, album + } + + enum Leading { + case number + case artwork + } + + let track: Track + var leading: Leading = .artwork + var subtitle: Subtitle = .artist + let action: () -> Void + + @Environment(AppModel.self) private var app + @Environment(PlayerModel.self) private var player + + var body: some View { + HStack(spacing: 12) { + Button(action: action) { + HStack(spacing: 12) { + lead + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 4) { + Text(track.title).lineLimit(1) + if track.isExplicit { + Image(systemName: "e.square.fill").font(.caption).foregroundStyle(.secondary) + } + } + if subtitle != .none { + Text(subtitle == .artist ? track.artistName : [track.albumTitle, track.year.map(String.init)].compactMap(\.self) + .joined(separator: " · ")) + .font(.subheadline).foregroundStyle(.secondary).lineLimit(1) + } + } + Spacer(minLength: 0) + } + .contentShape(.rect) + } + .buttonStyle(.plain) + Menu { TrackMenu(track: track) } label: { + Image(systemName: "ellipsis") + .font(.body.weight(.semibold)) + .frame(width: 44, height: 44) + .contentShape(.rect) + } + .tint(.primary) + .accessibilityLabel("More") + } + .padding(.leading, Metrics.margin) + .padding(.trailing, 8) + .frame(minHeight: leading == .artwork ? 60 : 48) + .contextMenu { TrackMenu(track: track) } + } + + @ViewBuilder private var lead: some View { + let isCurrent = player.isCurrent(track) + switch leading { + case .number: + ZStack { + if isCurrent { + PlayingIndicator() + } else { + Text(track.trackNumber, format: .number).foregroundStyle(.secondary).monospacedDigit() + } + } + .frame(width: 28) + .overlay(alignment: .leading) { + if app.favorites.contains(track.id) { + Image(systemName: "star.fill").font(.system(size: 8)).foregroundStyle(.tint).offset(x: -12) + } + } + case .artwork: + ArtworkView(artwork: track.artwork, shape: .rounded(5)) + .frame(width: 48, height: 48) + .overlay { + if isCurrent { + RoundedRectangle(cornerRadius: 5, style: .continuous).fill(.black.opacity(0.45)) + PlayingIndicator().foregroundStyle(.white) + } + } + } + } +} + +struct PlayingIndicator: View { + @Environment(PlayerModel.self) private var player + + var body: some View { + Image(systemName: "waveform") + .font(.subheadline.weight(.bold)) + .foregroundStyle(.tint) + .symbolEffect(.variableColor.iterative.dimInactiveLayers, options: .repeating, isActive: player.isPlaying) + .accessibilityLabel("Now Playing") + } +} + +struct TrackMenu: View { + let track: Track + + @Environment(AppModel.self) private var app + @Environment(PlayerModel.self) private var player + + var body: some View { + if app.canFavorite(track.id) { + let isFavorite = app.favorites.contains(track.id) + Button(isFavorite ? "Undo Favorite" as LocalizedStringKey : "Favorite", systemImage: isFavorite ? "star.slash" : "star") { + app.toggleFavorite(track.id, kind: .track) + } + } + Section { + Button("Play Next", systemImage: "text.line.first.and.arrowtriangle.forward") { player.send(.insertNext([track])) } + Button("Play Last", systemImage: "text.line.last.and.arrowtriangle.forward") { player.send(.insertLast([track])) } + } + Section { + if let album = track.albumID { + Button { app.router.open(.album(album)) } label: { + Label("Go to Album", systemImage: "square.stack") + Text(track.albumTitle) + } + } + if let artist = track.artistID { + Button { app.router.open(.artist(artist)) } label: { + Label("Go to Artist", systemImage: "music.microphone") + Text(track.artistName) + } + } + } + } +} + +struct TrackList: View { + let tracks: [Track] + var leading: TrackRow.Leading = .artwork + var subtitle: TrackRow.Subtitle = .artist + + @Environment(PlayerModel.self) private var player + + var body: some View { + LazyVStack(spacing: 0) { + ForEach(Array(tracks.enumerated()), id: \.offset) { index, track in + TrackRow(track: track, leading: leading, subtitle: subtitle) { player.play(tracks, startingAt: index) } + Divider().padding(.leading, Metrics.margin + (leading == .artwork ? 60 : 40)) + } + } + } +} diff --git a/Amphlify/UI/Detail/AlbumDetailView.swift b/Amphlify/UI/Detail/AlbumDetailView.swift new file mode 100644 index 0000000..d1539ce --- /dev/null +++ b/Amphlify/UI/Detail/AlbumDetailView.swift @@ -0,0 +1,121 @@ +import Domain +import SwiftUI + +struct AlbumDetailView: View { + let id: EntityID + + @Environment(AppModel.self) private var app + @Environment(PlayerModel.self) private var player + @State private var album: Album? + @State private var tracks: [Track] = [] + @State private var more: [Album] = [] + + var body: some View { + ScrollView { + if let album { + VStack(spacing: 0) { + CollectionHeader( + artwork: album.artwork, + title: album.title, + subtitle: album.artistName, + subtitleRoute: album.artistID.map(Route.artist), + meta: meta(album) + ) { player.play(tracks) } onShuffle: { player.shuffle(tracks) } + Divider().padding(.leading, Metrics.margin) + TrackList(tracks: tracks, leading: .number, subtitle: .none) + footer(album) + if !more.isEmpty { + Shelf(title: "More by \(album.artistName)", route: album.artistID.map(Route.artist)) { + ForEach(more) { AlbumTile(album: $0, showsArtist: false).shelfTile() } + } + .padding(.vertical, 24) + .background(Color(.secondarySystemBackground)) + } + } + } + } + .modifier(ScrollRevealedTitle(title: album?.title ?? "")) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Menu("More", systemImage: "ellipsis") { + Button("Play Next", systemImage: "text.line.first.and.arrowtriangle.forward") { player.send(.insertNext(tracks)) } + Button("Play Last", systemImage: "text.line.last.and.arrowtriangle.forward") { player.send(.insertLast(tracks)) } + } + } + } + .task(id: app.revision) { + album = await app.library.album(id) + tracks = await app.library.tracks(inAlbum: id) + if let artist = album?.artistID { + more = await app.library.albums(byArtist: artist).filter { $0.id != id } + } + } + } + + private func meta(_ album: Album) -> String { + [album.genre, album.year.map(String.init), album.bestFormat.flatMap { $0.isLossless ? $0.badge : nil }] + .compactMap(\.self) + .joined(separator: " · ") + } + + private func footer(_ album: Album) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(album.addedAt, format: .dateTime.day().month(.wide).year()) + Text("\(tracks.count) songs, \(album.duration.spoken)") + if let detail = album.bestFormat?.detail { + Text(detail) + } + } + .font(.footnote) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(Metrics.margin) + } +} + +struct PlaylistDetailView: View { + enum Source: Hashable { + case playlist(EntityID) + case favorites + } + + let source: Source + + @Environment(AppModel.self) private var app + @Environment(PlayerModel.self) private var player + @State private var playlist: Playlist? + @State private var tracks: [Track] = [] + + var body: some View { + ScrollView { + VStack(spacing: 0) { + CollectionHeader( + artwork: playlist?.artwork ?? (source == .favorites ? nil : tracks.first?.artwork), + title: title, + subtitle: playlist?.summary.isEmpty == false ? playlist?.summary : nil + ) { player.play(tracks) } onShuffle: { player.shuffle(tracks) } + Divider().padding(.leading, Metrics.margin) + TrackList(tracks: tracks) + Text("\(tracks.count) songs, \(tracks.reduce(0) { $0 + $1.duration }.spoken)") + .font(.footnote) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(Metrics.margin) + } + } + .modifier(ScrollRevealedTitle(title: title)) + .task(id: app.revision) { + switch source { + case let .playlist(id): + playlist = await app.library.playlists().first { $0.id == id } + tracks = await app.library.tracks(inPlaylist: id) + case .favorites: + tracks = await app.library.favoriteTracks() + } + } + } + + private var title: String { + source == .favorites ? String(localized: "Favorite Songs") : playlist?.name ?? "" + } +} diff --git a/Amphlify/UI/Detail/ArtistDetailView.swift b/Amphlify/UI/Detail/ArtistDetailView.swift new file mode 100644 index 0000000..3674e52 --- /dev/null +++ b/Amphlify/UI/Detail/ArtistDetailView.swift @@ -0,0 +1,96 @@ +import Domain +import SwiftUI + +struct ArtistDetailView: View { + let id: EntityID + + @Environment(AppModel.self) private var app + @Environment(PlayerModel.self) private var player + @State private var artist: Artist? + @State private var albums: [Album] = [] + @State private var topTracks: [Track] = [] + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + hero + if !topTracks.isEmpty { + VStack(alignment: .leading, spacing: 8) { + SectionHeader(title: "Top Songs") + TrackList(tracks: topTracks, subtitle: .album) + } + } + if !albums.isEmpty { + VStack(alignment: .leading, spacing: 12) { + SectionHeader(title: "Albums") + AlbumGrid(albums: albums, showsArtist: false) + } + } + } + .padding(.bottom, 24) + } + .ignoresSafeArea(edges: .top) + .modifier(ScrollRevealedTitle(title: artist?.name ?? "", threshold: 270)) + .scrollEdgeEffectHidden(true, for: .top) + .toolbar { + if app.canFavorite(id) { + ToolbarItem(placement: .topBarTrailing) { + let isFavorite = app.favorites.contains(id) + Button( + isFavorite ? "Undo Favorite" as LocalizedStringKey : "Favorite", + systemImage: isFavorite ? "star.fill" : "star" + ) { + app.toggleFavorite(id, kind: .artist) + } + } + } + } + .task(id: app.revision) { + artist = await app.library.artist(id) + albums = await app.library.albums(byArtist: id) + topTracks = await Array(app.library.tracks(byArtist: id).prefix(5)) + } + } + + private var hero: some View { + ArtworkView(artwork: artist?.artwork, shape: .rounded(0), pixelSize: 1024) + .containerRelativeFrame(.horizontal) + .visualEffect { content, proxy in + let pull = max(proxy.frame(in: .scrollView).minY, 0) + return content.scaleEffect(1 + pull / max(proxy.size.height, 1), anchor: .bottom) + } + .overlay(alignment: .bottom) { + LinearGradient( + stops: [ + .init(color: .clear, location: 0), + .init(color: Color(.systemBackground).opacity(0.55), location: 0.55), + .init(color: Color(.systemBackground), location: 1), + ], + startPoint: .top, + endPoint: .bottom + ) + .frame(height: 200) + } + .overlay(alignment: .bottom) { + HStack(alignment: .bottom) { + Text(artist?.name ?? "") + .font(.largeTitle.bold()) + .lineLimit(2) + .minimumScaleFactor(0.7) + Spacer() + Button { + Task { await player.shuffle(app.library.tracks(byArtist: id)) } + } label: { + Image(systemName: "play.fill") + .font(.title3) + .foregroundStyle(.white) + .frame(width: 48, height: 48) + .background(.tint, in: .circle) + } + .accessibilityLabel("Play") + } + .padding(.horizontal, Metrics.margin) + .padding(.bottom, 12) + } + } +} diff --git a/Amphlify/UI/Home/HomeView.swift b/Amphlify/UI/Home/HomeView.swift new file mode 100644 index 0000000..f585321 --- /dev/null +++ b/Amphlify/UI/Home/HomeView.swift @@ -0,0 +1,141 @@ +import Domain +import SwiftUI + +struct HomeView: View { + @Environment(AppModel.self) private var app + @State private var picks: [Album] = [] + @State private var recentlyPlayed: [Album] = [] + @State private var recentlyAdded: [Album] = [] + @State private var mostPlayed: [Album] = [] + @State private var playlists: [Playlist] = [] + + var body: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 32) { + if !picks.isEmpty { + Shelf(title: "Top Picks for You") { + ForEach(picks) { PickCard(album: $0).shelfTile(fraction: 0.71) } + } + } + albumShelf("Recently Played", recentlyPlayed) + albumShelf("Recently Added", recentlyAdded, route: .section(.albums)) + if !playlists.isEmpty { + Shelf(title: "Your Playlists", route: .section(.playlists)) { + ForEach(playlists) { PlaylistTile(playlist: $0).shelfTile() } + } + } + albumShelf("Most Played", mostPlayed) + } + .padding(.vertical, 12) + } + .navigationTitle("Home") + .modifier(AccountToolbar()) + .task(id: app.revision) { await load() } + } + + @ViewBuilder + private func albumShelf(_ title: LocalizedStringKey, _ albums: [Album], route: Route? = nil) -> some View { + if !albums.isEmpty { + Shelf(title: title, route: route) { + ForEach(albums) { AlbumTile(album: $0).shelfTile() } + } + } + } + + private func load() async { + let library = app.library + recentlyPlayed = await library.recentlyPlayed(limit: 12) + recentlyAdded = await Array(library.albums(sortedBy: .recentlyAdded).prefix(12)) + mostPlayed = await library.mostPlayed(limit: 12) + playlists = await library.playlists() + if picks.isEmpty { + picks = await Array(library.albums(sortedBy: .year).shuffled().prefix(6)) + } + } +} + +struct PickCard: View { + let album: Album + + var body: some View { + NavigationLink(value: Route.album(album.id)) { + VStack(alignment: .leading, spacing: 0) { + ArtworkView(artwork: album.artwork, shape: .rounded(0), pixelSize: 1024) + .mask { LinearGradient(colors: [.black, .black, .clear], startPoint: .center, endPoint: .bottom) } + VStack(alignment: .leading, spacing: 2) { + Text(album.genre ?? " ").opacity(0.75) + Text(album.title).fontWeight(.semibold) + Text(album.artistName).opacity(0.75) + } + .font(.subheadline) + .lineLimit(1) + .foregroundStyle(.white) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + .padding(.top, 4) + .padding(.bottom, 18) + } + .background { + ArtworkView(artwork: album.artwork, shape: .rounded(0)) + .scaleEffect(x: 1, y: -1) + .blur(radius: 30) + .scaleEffect(1.4) + .overlay(.black.opacity(0.35)) + .frame(maxHeight: .infinity, alignment: .bottom) + } + .clipShape(.rect(cornerRadius: 16, style: .continuous)) + } + .buttonStyle(.plain) + } +} + +struct AccountView: View { + @Environment(AppModel.self) private var app + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + List { + Section("Sources") { + ForEach(app.sources, id: \.id) { source in + ServerRow(name: source.name, account: app.account(for: source.id)) + } + } + } + .navigationTitle("Account") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done", systemImage: "checkmark") { dismiss() } + } + } + } + .presentationDetents([.medium, .large]) + } +} + +private struct ServerRow: View { + let name: String + let account: ServerAccount? + @Environment(AppModel.self) private var app + + var body: some View { + HStack { + Label { + VStack(alignment: .leading) { + Text(name) + if let account { + Text(account.username).font(.footnote).foregroundStyle(.secondary) + } + } + } icon: { + Image(systemName: "externaldrive.connected.to.line.below") + } + Spacer() + if let account { + Button("Sign Out", role: .destructive) { app.signOut(account) } + .buttonStyle(.borderless) + } + } + } +} diff --git a/Amphlify/UI/Library/LibraryLists.swift b/Amphlify/UI/Library/LibraryLists.swift new file mode 100644 index 0000000..c8b0992 --- /dev/null +++ b/Amphlify/UI/Library/LibraryLists.swift @@ -0,0 +1,129 @@ +import Domain +import Store +import SwiftUI + +struct AlbumsView: View { + @Environment(AppModel.self) private var app + @State private var albums: [Album] = [] + @State private var sort: AlbumSort = .artist + + var body: some View { + ScrollView { + AlbumGrid(albums: albums).padding(.vertical, 12) + } + .navigationTitle("Albums") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Menu("Sort", systemImage: "arrow.up.arrow.down") { + Picker("Sort", selection: $sort) { + Text("Artist").tag(AlbumSort.artist) + Text("Title").tag(AlbumSort.title) + Text("Year").tag(AlbumSort.year) + Text("Recently Added").tag(AlbumSort.recentlyAdded) + } + } + } + } + .task(id: [app.revision, sort.hashValue]) { albums = await app.library.albums(sortedBy: sort) } + } +} + +struct ArtistsView: View { + @Environment(AppModel.self) private var app + @State private var artists: [Artist] = [] + + var body: some View { + List(artists) { artist in + NavigationLink(value: Route.artist(artist.id)) { + HStack(spacing: 14) { + ArtworkView(artwork: artist.artwork, shape: .circle).frame(width: 52, height: 52) + Text(artist.name).font(.title3) + } + } + .alignmentGuide(.listRowSeparatorLeading) { $0[.leading] + 66 } + } + .listStyle(.plain) + .navigationTitle("Artists") + .task(id: app.revision) { artists = await app.library.artists() } + } +} + +struct SongsView: View { + @Environment(AppModel.self) private var app + @Environment(PlayerModel.self) private var player + @State private var tracks: [Track] = [] + + var body: some View { + ScrollView { + PlayShuffleButtons { player.play(tracks) } onShuffle: { player.shuffle(tracks) } + .padding(.horizontal, Metrics.margin) + .padding(.vertical, 12) + TrackList(tracks: tracks) + } + .navigationTitle("Songs") + .task(id: app.revision) { tracks = await app.library.tracks() } + } +} + +struct PlaylistsView: View { + @Environment(AppModel.self) private var app + @State private var playlists: [Playlist] = [] + + var body: some View { + List { + NavigationLink(value: Route.favorites) { + row(Text("Favorite Songs")) { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(Color.accentColor.gradient) + .overlay { Image(systemName: "star.fill").font(.title).foregroundStyle(.white) } + } + } + .listRowSeparator(.hidden, edges: .top) + .alignmentGuide(.listRowSeparatorLeading) { $0[.leading] + 86 } + ForEach(playlists) { playlist in + NavigationLink(value: Route.playlist(playlist.id)) { + row(Text(playlist.name)) { ArtworkView(artwork: playlist.artwork, shape: .rounded(6)) } + } + } + .alignmentGuide(.listRowSeparatorLeading) { $0[.leading] + 86 } + } + .listStyle(.plain) + .navigationTitle("Playlists") + .task(id: app.revision) { playlists = await app.library.playlists() } + } + + private func row(_ name: Text, @ViewBuilder artwork: () -> some View) -> some View { + HStack(spacing: 14) { + artwork().frame(width: 72, height: 72) + name.lineLimit(2) + } + } +} + +struct GenresView: View { + @Environment(AppModel.self) private var app + @State private var genres: [Genre] = [] + + var body: some View { + List(genres) { genre in + NavigationLink(value: Route.genre(genre.name)) { Text(genre.name).font(.title3) } + } + .listStyle(.plain) + .navigationTitle("Genres") + .task(id: app.revision) { genres = await app.library.genres() } + } +} + +struct GenreDetailView: View { + let genre: String + @Environment(AppModel.self) private var app + @State private var albums: [Album] = [] + + var body: some View { + ScrollView { + AlbumGrid(albums: albums).padding(.vertical, 12) + } + .navigationTitle(genre) + .task(id: app.revision) { albums = await app.library.albums(inGenre: genre) } + } +} diff --git a/Amphlify/UI/Library/LibraryView.swift b/Amphlify/UI/Library/LibraryView.swift new file mode 100644 index 0000000..25716dd --- /dev/null +++ b/Amphlify/UI/Library/LibraryView.swift @@ -0,0 +1,64 @@ +import Domain +import SwiftUI + +extension LibrarySection { + var title: LocalizedStringKey { + switch self { + case .playlists: "Playlists" + case .artists: "Artists" + case .albums: "Albums" + case .songs: "Songs" + case .genres: "Genres" + } + } + + var systemImage: String { + switch self { + case .playlists: "music.note.list" + case .artists: "music.microphone" + case .albums: "square.stack" + case .songs: "music.note" + case .genres: "guitars" + } + } +} + +struct LibraryView: View { + @Environment(AppModel.self) private var app + @State private var recentlyAdded: [Album] = [] + + var body: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(LibrarySection.allCases, id: \.self) { section in + NavigationLink(value: Route.section(section)) { + HStack(spacing: 14) { + Image(systemName: section.systemImage) + .font(.title3) + .foregroundStyle(.tint) + .frame(width: 30) + Text(section.title).font(.title3) + Spacer() + Image(systemName: "chevron.right").font(.footnote.weight(.semibold)).foregroundStyle(.tertiary) + } + .frame(minHeight: 52) + .contentShape(.rect) + } + .buttonStyle(.plain) + .padding(.horizontal, Metrics.margin) + Divider().padding(.leading, Metrics.margin + 44).padding(.trailing, Metrics.margin) + } + SectionHeader(title: "Recently Added") + .padding(.top, 28) + .padding(.bottom, 14) + AlbumGrid(albums: recentlyAdded) + } + .padding(.bottom, 20) + } + .navigationTitle("Library") + .modifier(AccountToolbar()) + .task(id: app.revision) { + recentlyAdded = await Array(app.library.albums(sortedBy: .recentlyAdded).prefix(30)) + } + } +} diff --git a/Amphlify/UI/Player/Backdrop.swift b/Amphlify/UI/Player/Backdrop.swift new file mode 100644 index 0000000..d510f41 --- /dev/null +++ b/Amphlify/UI/Player/Backdrop.swift @@ -0,0 +1,137 @@ +import Domain +import SwiftUI + +struct Backdrop: View { + let artwork: EntityID? + @Environment(AppModel.self) private var app + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var layers: [Layer] = [] + @State private var drifts = false + + private struct Layer: Identifiable { + let id = UUID() + let dark: Color + let light: Color + } + + var body: some View { + ZStack { + Color(white: 0.1) + ForEach(layers) { layer in + MeshGradient( + width: 3, height: 3, + points: [ + [0, 0], [0.5, 0], [1, 0], + [0, 0.5], drifts ? [0.35, 0.6] : [0.65, 0.4], [1, 0.5], + [0, 1], [0.5, 1], [1, 1], + ], + colors: [ + layer.dark, layer.light, layer.light, + layer.dark, drifts ? layer.dark : layer.light, layer.light, + layer.dark, layer.dark, layer.dark, + ] + ) + .transition(.asymmetric(insertion: .opacity, removal: .identity)) + } + LinearGradient(colors: [.clear, .black.opacity(0.15)], startPoint: .init(x: 0.5, y: 0.6), endPoint: .bottom) + } + .animation(.easeOut(duration: 0.35), value: layers.map(\.id)) + .ignoresSafeArea() + .task(id: artwork) { + guard let artwork, let image = await app.artwork.image(artwork, pixelSize: 128), + let (dark, light) = Palette.pair(of: image) else { return } + let layer = Layer(dark: dark, light: light) + layers.append(layer) + try? await Task.sleep(for: .seconds(0.5)) + layers.removeAll { $0.id != layer.id } + } + .onAppear { + guard !reduceMotion else { return } + withAnimation(.easeInOut(duration: 30).repeatForever(autoreverses: true)) { drifts = true } + } + } +} + +private enum Palette { + private static let side = 32 + + static func pair(of image: UIImage) -> (Color, Color)? { + guard let pixels = samples(of: image) else { return nil } + let mean = pixels.reduce(SIMD3()) { $0 + $1 } / Double(pixels.count) + let target = 0.42 * luminance(mean) + 22 + var clusters = kmeans(pixels, count: 5) + .filter { luminance($0.center) > 25 && luminance($0.center) < 200 && saturation($0.center) > 0.15 } + .sorted { $0.weight > $1.weight } + .prefix(2) + .map(\.center) + if clusters.isEmpty { + clusters = [mean] + } + if clusters.count == 1 { + clusters.append(clusters[0] * 0.75) + } + let scale = target / max(luminance((clusters[0] + clusters[1]) / 2), 4) + let ordered = clusters.map { $0 * scale }.sorted { luminance($0) < luminance($1) } + return (color(ordered[0]), color(ordered[1])) + } + + private struct Cluster { + var center: SIMD3 + var weight: Double + } + + private static func kmeans(_ pixels: [SIMD3], count: Int) -> [Cluster] { + var centers = (0 ..< count).map { pixels[$0 * pixels.count / count] } + var labels = [Int](repeating: 0, count: pixels.count) + for _ in 0 ..< 12 { + for (index, pixel) in pixels.enumerated() { + labels[index] = centers.indices.min { distance(pixel, centers[$0]) < distance(pixel, centers[$1]) } ?? 0 + } + for cluster in centers.indices { + let members = pixels.indices.filter { labels[$0] == cluster } + guard !members.isEmpty else { continue } + centers[cluster] = members.reduce(SIMD3()) { $0 + pixels[$1] } / Double(members.count) + } + } + return centers.indices.map { cluster in + Cluster(center: centers[cluster], weight: Double(labels.filter { $0 == cluster }.count) / Double(pixels.count)) + } + } + + private static func samples(of image: UIImage) -> [SIMD3]? { + guard let bitmap = image.cgImage, let srgb = CGColorSpace(name: CGColorSpace.sRGB), + let canvas = CGContext( + data: nil, + width: side, + height: side, + bitsPerComponent: 8, + bytesPerRow: side * 4, + space: srgb, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) + else { return nil } + canvas.interpolationQuality = .high + canvas.draw(bitmap, in: CGRect(x: 0, y: 0, width: side, height: side)) + guard let data = canvas.data else { return nil } + let bytes = data.bindMemory(to: UInt8.self, capacity: side * side * 4) + return stride(from: 0, to: side * side * 4, by: 4).map { + SIMD3(Double(bytes[$0]), Double(bytes[$0 + 1]), Double(bytes[$0 + 2])) + } + } + + private static func luminance(_ rgb: SIMD3) -> Double { + 0.299 * rgb.x + 0.587 * rgb.y + 0.114 * rgb.z + } + + private static func saturation(_ rgb: SIMD3) -> Double { + (rgb.max() - rgb.min()) / max(rgb.max(), 1) + } + + private static func distance(_ lhs: SIMD3, _ rhs: SIMD3) -> Double { + ((lhs - rhs) * (lhs - rhs)).sum() + } + + private static func color(_ rgb: SIMD3) -> Color { + Color(red: rgb.x / 255, green: rgb.y / 255, blue: rgb.z / 255) + } +} diff --git a/Amphlify/UI/Player/LyricsScrollSurface.swift b/Amphlify/UI/Player/LyricsScrollSurface.swift new file mode 100644 index 0000000..5e5cd3d --- /dev/null +++ b/Amphlify/UI/Player/LyricsScrollSurface.swift @@ -0,0 +1,42 @@ +import SwiftUI + +struct LyricsScrollSurface: View { + enum Phase { + case began, ended + } + + let contentHeight: CGFloat + let indicatorInset: CGFloat + let offset: CGFloat + let userDriven: Bool + let onPhase: (Phase, CGFloat) -> Void + let onScroll: (CGFloat) -> Void + let onTap: (CGPoint) -> Void + @State private var position = ScrollPosition() + + var body: some View { + ScrollView { + Color.clear + .frame(height: contentHeight) + .contentShape(.rect) + .onTapGesture(coordinateSpace: .named("surface")) { onTap($0) } + } + .coordinateSpace(.named("surface")) + .scrollPosition($position) + .scrollIndicators(.visible) + .contentMargins(.bottom, indicatorInset, for: .scrollIndicators) + .onScrollGeometryChange(for: CGFloat.self) { $0.contentOffset.y } action: { _, y in onScroll(y) } + .onScrollPhaseChange { old, new, context in + let y = context.geometry.contentOffset.y + if new == .tracking || (new == .interacting && old == .idle) { + onPhase(.began, y) + } else if new == .idle, old != .animating { + onPhase(.ended, y) + } + } + .onChange(of: offset, initial: true) { + guard !userDriven else { return } + position.scrollTo(y: offset) + } + } +} diff --git a/Amphlify/UI/Player/LyricsView.swift b/Amphlify/UI/Player/LyricsView.swift new file mode 100644 index 0000000..ec3a147 --- /dev/null +++ b/Amphlify/UI/Player/LyricsView.swift @@ -0,0 +1,389 @@ +import Domain +import SwiftUI + +struct LyricsView: View { + enum Intent { + case reveal, hide + } + + let bottomInset: CGFloat + let controls: (Intent) -> Void + let seeks: Bool + @Environment(AppModel.self) private var app + @Environment(PlayerModel.self) private var player + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var lyrics: Lyrics? + @State private var heights: [Int: CGFloat] = [:] + @State private var estimates: [CGFloat] = [] + @State private var size: CGSize = .zero + @State private var scroll: Scroll? + @State private var pressed: Press? + @State private var seeked: Int? + + private let anchor: CGFloat = 74 + private let blockSpacing: CGFloat = 27 + private let idleReturn: TimeInterval = 3.5 + + private struct Scroll { + struct Return { + let from: CGFloat + let began: Date + let response: Double + } + + var offset: CGFloat + var dragStart: CGFloat + var travel: CGFloat = 0 + var dragging = true + var idleSince: Date? + var back: Return? + + func progress(at date: Date) -> Double { + guard let back else { return 0 } + return LyricsView.spring(date.timeIntervalSince(back.began), response: back.response) + } + } + + private struct Press { + let index: Int + let began: Date + } + + private struct Frame { + let current: Int + let elapsed: TimeInterval + let position: TimeInterval + let date: Date + let tops: [CGFloat] + let follow: CGFloat + let contentOffset: CGFloat + let progress: Double + let uniform: Bool + } + + var body: some View { + Group { + if let lyrics { + synced(lyrics) + } else { + Text("No lyrics") + .font(.headline) + .foregroundStyle(.white.opacity(0.5)) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .onGeometryChange(for: CGSize.self) { $0.size } action: { size = $0 } + .onChange(of: size.width) { remeasure() } + .task(id: player.current?.id) { + lyrics = nil + heights = [:] + scroll = nil + pressed = nil + seeked = nil + guard let track = player.current else { return } + lyrics = await app.lyrics(for: track) + remeasure() + } + } + + private func synced(_ lyrics: Lyrics) -> some View { + let entries = entries(of: lyrics) + return TimelineView(.animation) { context in + let frame = frame(entries, date: context.date) + let idle = scroll?.idleSince.map { context.date.timeIntervalSince($0) > idleReturn } ?? false + ZStack(alignment: .topLeading) { + Color.clear + ForEach(visible(entries, frame: frame), id: \.self) { index in + entry(entries[index], index: index, frame: frame) + } + } + .overlay { + LyricsScrollSurface( + contentHeight: frame.tops[entries.count - 1] + size.height, + indicatorInset: bottomInset, + offset: frame.contentOffset, + userDriven: scroll?.back == nil && scroll != nil, + onPhase: { phase, offset in scrolled(phase, offset: offset) }, + onScroll: { offset in scrolled(to: offset) }, + onTap: { point in tapped(at: point, entries: entries) } + ) + .padding(.horizontal, -32) + } + .onChange(of: idle) { _, idle in + if idle { + startReturn(response: 0.45) + } + } + .onChange(of: frame.progress >= 0.999) { _, done in + if done { + scroll = nil + } + } + } + .mask { + VStack(spacing: 0) { + LinearGradient(colors: [.clear, .black], startPoint: .top, endPoint: .bottom).frame(height: 36) + Color.black + LinearGradient(colors: [.black, .clear], startPoint: .top, endPoint: .bottom).frame(height: 64) + Color.clear.frame(height: bottomInset) + } + .animation(.easeInOut(duration: 0.1), value: bottomInset) + } + } + + private func frame(_ entries: [Entry], date: Date) -> Frame { + let position = player.position(at: .now) + let current = entries.lastIndex { $0.time <= position + 0.03 } ?? 0 + let elapsed = position - entries[current].time + var tops: [CGFloat] = [] + var top: CGFloat = 0 + for index in entries.indices { + tops.append(top) + top += height(index, entries: entries) + blockSpacing + } + let follow = followOffset(tops: tops, current: current, elapsed: elapsed) + let progress = scroll?.progress(at: date) ?? 1 + let contentOffset: CGFloat = if let scroll { + scroll.back.map { $0.from + (follow - $0.from) * CGFloat(progress) } ?? scroll.offset + } else { + follow + } + return Frame( + current: current, + elapsed: elapsed, + position: position, + date: date, + tops: tops, + follow: follow, + contentOffset: contentOffset, + progress: progress, + uniform: scroll != nil && scroll?.back == nil + ) + } + + private func wave(tops: [CGFloat], current: Int, elapsed: TimeInterval) -> (start: CGFloat, delta: CGFloat)? { + guard current > 0, !reduceMotion, elapsed < 1.5, seeked != current else { return nil } + let start = tops[current - 1] + return (start, tops[current] - start) + } + + private func followOffset(tops: [CGFloat], current: Int, elapsed: TimeInterval) -> CGFloat { + guard let wave = wave(tops: tops, current: current, elapsed: elapsed) else { return tops[current] } + return wave.start + wave.delta * CGFloat(Self.spring(elapsed - 0.03, response: 0.65)) + } + + private func residual(of index: Int, frame: Frame) -> CGFloat { + guard index > frame.current, let wave = wave(tops: frame.tops, current: frame.current, elapsed: frame.elapsed) else { return 0 } + let base = Self.spring(frame.elapsed - 0.03, response: 0.65) + let delayed = Self.spring(frame.elapsed - 0.03 - 0.033 * Double(index - frame.current), response: 0.65) + return wave.delta * CGFloat((base - delayed) * frame.progress) + } + + private func displayed(_ index: Int, frame: Frame) -> CGFloat { + anchor + frame.tops[index] - frame.contentOffset + residual(of: index, frame: frame) + } + + private func visible(_ entries: [Entry], frame: Frame) -> [Int] { + entries.indices.filter { index in + let top = displayed(index, frame: frame) + return top + height(index, entries: entries) > -160 && top < size.height + 160 + } + } + + private func scrolled(_ phase: LyricsScrollSurface.Phase, offset: CGFloat) { + switch phase { + case .began: + scroll = Scroll(offset: offset, dragStart: offset) + pressed = nil + case .ended: + scroll?.dragging = false + scroll?.idleSince = .now + } + } + + private func scrolled(to offset: CGFloat) { + guard var live = scroll, live.back == nil else { return } + let delta = offset - live.offset + live.travel = delta * live.travel >= 0 ? live.travel + delta : delta + live.offset = offset + scroll = live + if live.travel > 12 { + controls(.hide) + } else if live.travel < -12 { + controls(.reveal) + } + } + + private func startReturn(response: Double) { + guard let live = scroll, live.back == nil else { return } + scroll?.back = Scroll.Return(from: live.offset, began: .now, response: response) + } + + private func tapped(at point: CGPoint, entries: [Entry]) { + controls(.reveal) + guard seeks else { return } + let frame = frame(entries, date: .now) + for index in entries.indices { + let top = displayed(index, frame: frame) + guard case let .line(line) = entries[index].kind, + (top - 8 ... top + height(index, entries: entries) + 8).contains(point.y) else { continue } + pressed = Press(index: index, began: .now) + seeked = index + var held = Scroll(offset: frame.contentOffset, dragStart: frame.contentOffset, dragging: false) + held.back = Scroll.Return(from: frame.contentOffset, began: .now + 0.12, response: 0.65) + scroll = held + Task { + try? await Task.sleep(for: .milliseconds(120)) + player.send(.seek(to: line.time)) + player.send(.play) + } + return + } + } + + static func spring(_ elapsed: TimeInterval, response: Double) -> Double { + guard elapsed > 0 else { return 0 } + let omega = 2 * Double.pi / response + return 1 - (1 + omega * elapsed) * exp(-omega * elapsed) + } + + private func entry(_ entry: Entry, index: Int, frame: Frame) -> some View { + let state = LyricLineView.State(distance: index - frame.current, uniform: frame.uniform) + let press = pressAmount(index: index, date: frame.date) + return Group { + switch entry.kind { + case let .line(line): + LyricLineView(text: line.text, state: state, highlight: press.highlight) + case let .intro(until): + IntroDots(progress: min(max(frame.position / until, 0), 1), state: state) + } + } + .onGeometryChange(for: CGFloat.self) { $0.size.height } action: { heights[index] = $0 } + .offset(y: displayed(index, frame: frame) + press.shift) + .allowsHitTesting(false) + } + + private func pressAmount(index: Int, date: Date) -> (highlight: Double, shift: CGFloat) { + guard let pressed else { return (0, 0) } + let elapsed = date.timeIntervalSince(pressed.began) + guard elapsed < 0.55 else { return (0, 0) } + let highlight = elapsed < 0.05 ? elapsed / 0.05 : max(0, 1 - (elapsed - 0.15) / 0.4) + let push = elapsed < 0.12 ? elapsed / 0.12 : max(0, 1 - (elapsed - 0.12) / 0.3) + let eased = 1 - pow(1 - push, 2) + if index == pressed.index { + return (min(highlight, 1), CGFloat(eased) * 3) + } + return (0, CGFloat(eased) * (index < pressed.index ? -8 : 8)) + } + + private func height(_ index: Int, entries: [Entry]) -> CGFloat { + if let measured = heights[index] { + return measured + } + return estimates.indices.contains(index) ? estimates[index] : estimate(entries[index]) + } + + private func remeasure() { + guard let lyrics, size.width > 0 else { return } + estimates = entries(of: lyrics).map(estimate) + } + + private func estimate(_ entry: Entry) -> CGFloat { + switch entry.kind { + case .intro: + return 39 + case let .line(line): + let bounds = (line.text as NSString).boundingRect( + with: CGSize(width: max(size.width - 36, 1), height: .greatestFiniteMagnitude), + options: [.usesLineFragmentOrigin, .usesFontLeading], + attributes: [.font: LyricLineView.font], + context: nil + ) + return ceil(bounds.height) + } + } + + private struct Entry { + enum Kind { + case line(LyricLine) + case intro(TimeInterval) + } + + let time: TimeInterval + let kind: Kind + } + + private func entries(of lyrics: Lyrics) -> [Entry] { + var out: [Entry] = [] + if let first = lyrics.lines.first, first.time > 3 { + out.append(Entry(time: 0, kind: .intro(first.time))) + } + out += lyrics.lines.filter { !$0.text.isEmpty }.map { Entry(time: $0.time, kind: .line($0)) } + return out + } +} + +private struct LyricLineView: View { + static let font = UIFont.systemFont(ofSize: 33, weight: .bold) + + struct State: Equatable { + let distance: Int + let uniform: Bool + + var opacity: Double { + if distance == 0 { + return 1 + } + if uniform { + return 0.5 + } + return distance < 0 ? 0.15 : max(0.38 - 0.04 * Double(distance - 1), 0.18) + } + + var blur: CGFloat { + if distance == 0 || uniform { + return 0 + } + return distance < 0 ? 3 : min(2 + CGFloat(distance - 1) * 0.8, 5) + } + } + + let text: String + let state: State + let highlight: Double + + var body: some View { + Text(text) + .font(.system(size: 33, weight: .bold)) + .foregroundStyle(.white.opacity(state.opacity)) + .blur(radius: state.blur) + .animation(.easeInOut(duration: state.uniform ? 0.12 : 0.25), value: state) + .padding(.leading, 2) + .padding(.trailing, 34) + .frame(maxWidth: .infinity, alignment: .leading) + .background { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(.white.opacity(0.13 * highlight)) + .padding(.vertical, -14) + } + } +} + +private struct IntroDots: View { + let progress: Double + let state: LyricLineView.State + + var body: some View { + HStack(spacing: 10) { + ForEach(0 ..< 3) { dot in + Circle() + .fill(.white.opacity(progress * 3 > Double(dot) + 0.5 ? 1 : 0.4)) + .frame(width: 15, height: 15) + } + } + .padding(.vertical, 12) + .opacity(state.uniform ? 0.5 : state.distance == 0 ? 1 : 0.3) + .blur(radius: state.blur) + .animation(.easeInOut(duration: 0.3), value: progress * 3 > 1.5) + } +} diff --git a/Amphlify/UI/Player/MiniPlayer.swift b/Amphlify/UI/Player/MiniPlayer.swift new file mode 100644 index 0000000..d319d12 --- /dev/null +++ b/Amphlify/UI/Player/MiniPlayer.swift @@ -0,0 +1,48 @@ +import SwiftUI + +struct MiniPlayer: View { + @Environment(PlayerModel.self) private var player + @Environment(Router.self) private var router + @Environment(\.tabViewBottomAccessoryPlacement) private var placement + + var body: some View { + HStack(spacing: 10) { + Button { + router.showsNowPlaying = true + } label: { + HStack(spacing: 10) { + ArtworkView(artwork: player.current?.artwork, shape: .rounded(7)).frame(width: 36, height: 36) + VStack(alignment: .leading, spacing: 0) { + Text(player.current?.title ?? String(localized: "Not Playing")).font(.subheadline.weight(.semibold)) + if let artist = player.current?.artistName, placement != .inline { + Text(artist).font(.footnote).foregroundStyle(.secondary) + } + } + .lineLimit(1) + Spacer(minLength: 0) + } + .contentShape(.rect) + } + .buttonStyle(.plain) + Group { + Button( + player.isPlaying ? "Pause" as LocalizedStringKey : "Play", + systemImage: player.isPlaying ? "pause.fill" : "play.fill" + ) { + player.send(.togglePlayPause) + } + .contentTransition(.symbolEffect(.replace)) + if placement != .inline { + Button("Next", systemImage: "forward.fill") { player.send(.next) } + } + } + .labelStyle(.iconOnly) + .font(.title3) + .frame(width: 40, height: 44) + .tint(.primary) + .disabled(player.current == nil) + } + .padding(.leading, 12) + .padding(.trailing, 10) + } +} diff --git a/Amphlify/UI/Player/NowPlayingView.swift b/Amphlify/UI/Player/NowPlayingView.swift new file mode 100644 index 0000000..8479621 --- /dev/null +++ b/Amphlify/UI/Player/NowPlayingView.swift @@ -0,0 +1,384 @@ +import Domain +import Playback +import SwiftUI + +struct NowPlayingView: View { + @Environment(AppModel.self) private var app + @Environment(PlayerModel.self) private var player + @Environment(Router.self) private var router + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var volume = 0.7 + @State private var controlsHeight: CGFloat = 0 + @State private var controlsRevealedAt = Date() + @State private var controlsForcedHidden = false + @State private var controlsHidden = false + + private var hidesControls: Bool { + showsLyrics && (controlsForcedHidden || (player.isPlaying && Date().timeIntervalSince(controlsRevealedAt) > 4)) + } + + private func setControls(_ intent: LyricsView.Intent) { + controlsForcedHidden = intent == .hide + if intent == .reveal { + controlsRevealedAt = Date() + } + controlsHidden = hidesControls + } + + private var showsQueue: Bool { + router.showsQueue + } + + private var showsLyrics: Bool { + router.showsLyrics + } + + private var isCompact: Bool { + showsQueue || showsLyrics + } + + var body: some View { + ZStack(alignment: .bottom) { + VStack(spacing: 0) { + Capsule().fill(.white.opacity(0.4)).frame(width: 36, height: 5).padding(.top, 8).padding(.bottom, 14) + GeometryReader { proxy in + let size = CGSize(width: proxy.size.width, height: proxy.size.height - controlsHeight) + stage(Stage(size: size, isCompact: isCompact, overflow: showsLyrics ? controlsHeight : 0)) + } + } + controls(hidden: controlsHidden) + .onGeometryChange(for: CGFloat.self) { $0.size.height } action: { controlsHeight = $0 } + .allowsHitTesting(!controlsHidden) + } + .task(id: showsLyrics) { + while showsLyrics, !Task.isCancelled { + controlsHidden = hidesControls + try? await Task.sleep(for: .milliseconds(250)) + } + controlsHidden = false + } + .padding(.horizontal, 32) + .frame(maxWidth: 560) + .frame(maxWidth: .infinity) + .background { Backdrop(artwork: player.current?.artwork) } + .environment(\.colorScheme, .dark) + } + + private struct Stage { + let size: CGSize + let isCompact: Bool + let overflow: CGFloat + let titleHeight: CGFloat = 70 + let compactSide: CGFloat = 72 + let compactTop: CGFloat = 7 + + var fullSide: CGFloat { + max(min(size.width + 16, size.height - titleHeight - 16), compactSide) + } + + var side: CGFloat { + isCompact ? compactSide : fullSide + } + + var artworkX: CGFloat { + isCompact ? 0 : (size.width - fullSide) / 2 + } + + var artworkY: CGFloat { + isCompact ? compactTop : (size.height - titleHeight - fullSide) / 2 + } + + var titleY: CGFloat { + let full = size.height - titleHeight + guard isCompact else { return full } + let fullBottom = (size.height - titleHeight - fullSide) / 2 + fullSide + return full - 1.17 * (fullBottom - compactTop - compactSide) + } + + var queueY: CGFloat { + artworkY + side + 12 + } + + var queueHeight: CGFloat { + max(size.height - compactTop - compactSide - 12, 0) + } + + var lyricsHeight: CGFloat { + queueHeight + overflow + } + } + + private var move: Animation { + reduceMotion ? .easeInOut(duration: 0.2) : .spring(response: 0.305, dampingFraction: 1) + } + + private func stage(_ stage: Stage) -> some View { + let isCompact = stage.isCompact + let isShrunk = !isCompact && !player.isPlaying && !reduceMotion + return ZStack(alignment: .topLeading) { + Color.clear + .overlay(alignment: .topLeading) { + Group { + if showsLyrics { + LyricsView(bottomInset: controlsHidden ? 0 : stage.overflow, controls: setControls, seeks: !controlsHidden) + } else { + QueueView() + } + } + .frame(width: stage.size.width, height: showsLyrics ? stage.lyricsHeight : stage.queueHeight) + .offset(y: stage.queueY) + .animation(move, value: isCompact) + } + .clipped() + .opacity(isCompact ? 1 : 0) + .animation(isCompact ? .linear(duration: 0.16).delay(0.18) : .linear(duration: 0.115).delay(0.02), value: isCompact) + .allowsHitTesting(isCompact) + titleRow(isCompact: false) + .frame(width: stage.size.width) + .offset(y: stage.titleY) + .animation(move, value: isCompact) + .opacity(isCompact ? 0 : 1) + .animation(isCompact ? .linear(duration: 0.1).delay(0.04) : .easeInOut(duration: 0.16).delay(0.155), value: isCompact) + .allowsHitTesting(!isCompact) + titleRow(isCompact: true) + .frame(width: stage.size.width - stage.compactSide - 12, height: stage.compactSide) + .offset(x: stage.compactSide + 12, y: stage.compactTop) + .offset(y: isCompact ? 0 : 70) + .animation(isCompact ? move.delay(0.15) : nil, value: isCompact) + .opacity(isCompact ? 1 : 0) + .animation(isCompact ? .easeInOut(duration: 0.15).delay(0.215) : nil, value: isCompact) + .allowsHitTesting(isCompact) + ArtworkView(artwork: player.current?.artwork, shape: .rounded(isCompact ? 8 : 12), pixelSize: 1024) + .shadow(color: .black.opacity(player.isPlaying ? 0.3 : 0.15), radius: 20, y: 10) + .scaleEffect(isShrunk ? 0.73 : 1) + .animation(.spring(duration: 0.5, bounce: 0.3), value: player.isPlaying) + .frame(width: stage.side, height: stage.side) + .offset(x: stage.artworkX, y: stage.artworkY) + .animation(move, value: isCompact) + .allowsHitTesting(false) + } + .frame(width: stage.size.width, height: stage.size.height + stage.overflow, alignment: .topLeading) + } + + private func titles(isCompact: Bool) -> some View { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 4) { + Marquee( + text: player.current?.title ?? String(localized: "Not Playing"), + font: isCompact ? .headline : .title3.bold(), + restart: self.isCompact == isCompact + ) + if player.current?.isExplicit == true { + Image(systemName: "e.square.fill") + .font(isCompact ? .caption : .footnote) + .foregroundStyle(.white.opacity(0.55)) + } + } + .foregroundStyle(.white) + Marquee(text: player.current?.artistName ?? " ", font: isCompact ? .subheadline : .title3, restart: self.isCompact == isCompact) + .foregroundStyle(.white.opacity(0.65)) + .modifier(InvisibleMenu { + if let track = player.current { + TrackMenu(track: track) + } + }) + } + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func titleRow(isCompact: Bool) -> some View { + HStack(spacing: 10) { + titles(isCompact: isCompact) + actions + } + } + + private var actions: some View { + HStack(spacing: 10) { + if let track = player.current { + if app.canFavorite(track.id) { + let isFavorite = app.favorites.contains(track.id) + Button( + isFavorite ? "Undo Favorite" as LocalizedStringKey : "Favorite", + systemImage: isFavorite ? "star.fill" : "star" + ) { + app.toggleFavorite(track.id, kind: .track) + } + .modifier(RoundGlyph()) + .symbolEffect(.bounce, value: isFavorite) + } + Label("More", systemImage: "ellipsis") + .modifier(RoundGlyph()) + .modifier(InvisibleMenu { TrackMenu(track: track) }) + } + } + } + + private func controls(hidden: Bool) -> some View { + VStack(spacing: 0) { + Timeline() + .modifier(Fold(hidden: hidden, order: 0)) + HStack(spacing: 34) { + Button("Previous", systemImage: "backward.fill") { player.send(.previous) }.font(.system(size: 32)) + Button( + player.isPlaying ? "Pause" as LocalizedStringKey : "Play", + systemImage: player.isPlaying ? "pause.fill" : "play.fill" + ) { + player.send(.togglePlayPause) + } + .font(.system(size: 46)) + .contentTransition(.symbolEffect(.replace)) + Button("Next", systemImage: "forward.fill") { player.send(.next) }.font(.system(size: 32)) + } + .buttonStyle(TransportButtonStyle()) + .disabled(player.current == nil) + .padding(.top, 31) + .padding(.bottom, 47) + .modifier(Fold(hidden: hidden, order: 1)) + HStack(spacing: 10) { + Image(systemName: "speaker.fill") + ScrubBar(value: volume, range: 0 ... 1) { volume = $0 } onCommit: { volume = $0 } + Image(systemName: "speaker.wave.3.fill") + } + .font(.footnote) + .foregroundStyle(.white.opacity(0.65)) + .accessibilityElement() + .accessibilityLabel("Volume") + .accessibilityValue(Text(volume, format: .percent.precision(.fractionLength(0)))) + .modifier(Fold(hidden: hidden, order: 2)) + HStack(spacing: 70) { + Button("Lyrics", systemImage: "quote.bubble") { router.showsLyrics.toggle() } + .modifier(ActiveGlyph(isActive: showsLyrics)) + RoutePicker().frame(width: 44, height: 44) + Button("Queue", systemImage: "list.bullet") { router.showsQueue.toggle() } + .modifier(ActiveGlyph(isActive: showsQueue)) + } + .labelStyle(.iconOnly) + .font(.title3) + .buttonStyle(GlyphButtonStyle()) + .foregroundStyle(.white.opacity(0.7)) + .padding(.top, 14) + .padding(.bottom, 11) + .modifier(Fold(hidden: hidden, order: 3)) + } + } +} + +private struct Fold: ViewModifier { + let hidden: Bool + let order: Int + + func body(content: Content) -> some View { + content + .opacity(hidden ? 0 : 1) + .offset(y: hidden ? 110 : 0) + .animation( + hidden + ? .easeIn(duration: 0.12).delay(0.06 * Double(order)) + : .easeOut(duration: 0.12).delay(0.2 + 0.04 * Double(3 - order)), + value: hidden + ) + } +} + +private struct Timeline: View { + @Environment(PlayerModel.self) private var player + @State private var scrubbed: Double? + + var body: some View { + let duration = player.current?.duration ?? 0 + TimelineView(.animation(minimumInterval: 0.2, paused: !player.isPlaying)) { _ in + let position = player.position() + VStack(spacing: -4) { + ScrubBar(value: position, range: 0 ... max(duration, 1)) { scrubbed = $0 } onCommit: { + scrubbed = nil + player.send(.seek(to: $0)) + } + HStack { + Text((scrubbed ?? position).clock) + Spacer() + if let format = player.current?.formats.first { + Label(format.badge, systemImage: "waveform") + .labelIconToTitleSpacing(4) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(.white.opacity(0.12), in: .rect(cornerRadius: 6, style: .continuous)) + } + Spacer() + Text("-" + max(duration - (scrubbed ?? position), 0).clock) + } + .font(.caption.weight(.semibold).monospacedDigit()) + .foregroundStyle(.white.opacity(0.6)) + } + } + .accessibilityElement() + .accessibilityLabel("Track position") + .accessibilityValue(Text(player.position().clock)) + .accessibilityAdjustableAction { direction in + player.send(.seek(to: player.position() + (direction == .increment ? 15 : -15))) + } + } +} + +private struct InvisibleMenu: ViewModifier { + @ViewBuilder let items: Items + + func body(content: Content) -> some View { + content.overlay { + Menu { items } label: { + Color.clear.contentShape(.rect) + } + .buttonStyle(.plain) + } + } +} + +private struct ActiveGlyph: ViewModifier { + let isActive: Bool + + func body(content: Content) -> some View { + content + .foregroundStyle(isActive ? Color.black.opacity(0.75) : .white.opacity(0.7)) + .background { + if isActive { + Circle().fill(.white.opacity(0.6)).frame(width: 38, height: 38) + } + } + } +} + +private struct RoundGlyph: ViewModifier { + func body(content: Content) -> some View { + content + .labelStyle(.iconOnly) + .font(.title2) + .foregroundStyle(.white) + .frame(width: 44, height: 44) + .contentShape(.rect) + } +} + +private struct TransportButtonStyle: ButtonStyle { + @Environment(\.isEnabled) private var isEnabled + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .labelStyle(.iconOnly) + .foregroundStyle(.white.opacity(isEnabled ? 1 : 0.35)) + .frame(width: 72, height: 72) + .background(.white.opacity(configuration.isPressed ? 0.16 : 0), in: .circle) + .scaleEffect(configuration.isPressed ? 0.86 : 1) + .animation(.spring(duration: 0.25, bounce: 0.4), value: configuration.isPressed) + } +} + +private struct GlyphButtonStyle: ButtonStyle { + @Environment(\.isEnabled) private var isEnabled + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .frame(width: 44, height: 44) + .opacity(isEnabled ? (configuration.isPressed ? 0.5 : 1) : 0.35) + } +} diff --git a/Amphlify/UI/Player/QueueView.swift b/Amphlify/UI/Player/QueueView.swift new file mode 100644 index 0000000..e387808 --- /dev/null +++ b/Amphlify/UI/Player/QueueView.swift @@ -0,0 +1,90 @@ +import Playback +import SwiftUI + +struct QueueView: View { + @Environment(PlayerModel.self) private var player + + var body: some View { + VStack(spacing: 0) { + HStack(spacing: 12) { + toggle("Shuffle", "shuffle", isOn: player.state.isShuffled) { player.send(.setShuffle(!player.state.isShuffled)) } + toggle("Repeat", player.state.repeatMode == .one ? "repeat.1" : "repeat", isOn: player.state.repeatMode != .off) { + let modes = RepeatMode.allCases + let index = modes.firstIndex(of: player.state.repeatMode) ?? 0 + player.send(.setRepeat(modes[(index + 1) % modes.count])) + } + } + Text("Playing Next") + .font(.headline) + .foregroundStyle(.white) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 18) + .padding(.bottom, 6) + List { + ForEach(player.state.upcoming) { item in + Button { + player.send(.jump(to: item.id)) + } label: { + HStack(spacing: 12) { + ArtworkView(artwork: item.track.artwork, shape: .rounded(5)).frame(width: 48, height: 48) + VStack(alignment: .leading, spacing: 2) { + Text(item.track.title).foregroundStyle(.white) + Text(item.track.artistName).font(.subheadline).foregroundStyle(.white.opacity(0.6)) + } + .lineLimit(1) + Spacer(minLength: 0) + Image(systemName: "line.3.horizontal").foregroundStyle(.white.opacity(0.3)) + } + .contentShape(.rect) + } + .buttonStyle(.plain) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 4, leading: 0, bottom: 4, trailing: 0)) + } + .onMove(perform: move) + .onDelete { offsets in + offsets.map { player.state.upcoming[$0].id }.forEach { player.send(.remove($0)) } + } + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .scrollIndicators(.hidden) + .overlay { + if player.state.upcoming.isEmpty { + Text("Nothing up next").foregroundStyle(.white.opacity(0.5)) + } + } + .mask { + LinearGradient( + stops: [.init(color: .black, location: 0.9), .init(color: .clear, location: 1)], + startPoint: .top, + endPoint: .bottom + ) + } + } + } + + private func move(from offsets: IndexSet, to destination: Int) { + let upcoming = player.state.upcoming + guard let from = offsets.first else { return } + var reordered = upcoming + reordered.move(fromOffsets: offsets, toOffset: destination) + guard let landed = reordered.firstIndex(where: { $0.id == upcoming[from].id }) else { return } + player.send(.move(item: upcoming[from].id, after: landed > 0 ? reordered[landed - 1].id : nil)) + } + + private func toggle(_ title: LocalizedStringKey, _ systemImage: String, isOn: Bool, action: @escaping () -> Void) -> some View { + Button(action: action) { + Label(title, systemImage: systemImage) + .labelStyle(.iconOnly) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(isOn ? Color.black.opacity(0.8) : .white) + .frame(maxWidth: .infinity, minHeight: 38) + .background(.white.opacity(isOn ? 0.55 : 0.12), in: .capsule) + .contentShape(.rect) + } + .buttonStyle(.plain) + .accessibilityAddTraits(isOn ? .isSelected : []) + } +} diff --git a/Amphlify/UI/Player/RoutePicker.swift b/Amphlify/UI/Player/RoutePicker.swift new file mode 100644 index 0000000..2b77f5c --- /dev/null +++ b/Amphlify/UI/Player/RoutePicker.swift @@ -0,0 +1,14 @@ +import AVKit +import SwiftUI + +struct RoutePicker: UIViewRepresentable { + func makeUIView(context _: Context) -> AVRoutePickerView { + let view = AVRoutePickerView() + view.tintColor = .white.withAlphaComponent(0.7) + view.activeTintColor = .white + view.prioritizesVideoDevices = false + return view + } + + func updateUIView(_: AVRoutePickerView, context _: Context) {} +} diff --git a/Amphlify/UI/Player/ScrubBar.swift b/Amphlify/UI/Player/ScrubBar.swift new file mode 100644 index 0000000..677a144 --- /dev/null +++ b/Amphlify/UI/Player/ScrubBar.swift @@ -0,0 +1,52 @@ +import SwiftUI + +struct ScrubBar: View { + let value: Double + let range: ClosedRange + var onScrub: (Double) -> Void = { _ in } + let onCommit: (Double) -> Void + + @State private var dragStart: Double? + @State private var dragValue: Double? + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + var isScrubbing: Bool { + dragValue != nil + } + + var body: some View { + GeometryReader { proxy in + let span = max(range.upperBound - range.lowerBound, .leastNonzeroMagnitude) + let fraction = ((dragValue ?? value) - range.lowerBound) / span + Capsule() + .fill(.white.opacity(0.25)) + .overlay(alignment: .leading) { + Rectangle().fill(.white.opacity(isScrubbing ? 1 : 0.65)).frame(width: proxy.size.width * min(max(fraction, 0), 1)) + } + .clipShape(.capsule) + .frame(height: isScrubbing ? 14 : 7) + .frame(maxHeight: .infinity) + .contentShape(.rect) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { drag in + let start = dragStart ?? value + dragStart = start + let next = start + drag.translation.width / proxy.size.width * span + dragValue = min(max(next, range.lowerBound), range.upperBound) + onScrub(dragValue ?? value) + } + .onEnded { _ in + if let dragValue { + onCommit(dragValue) + } + dragStart = nil + dragValue = nil + } + ) + } + .frame(height: 30) + .scaleEffect(x: isScrubbing && !reduceMotion ? 1.03 : 1, y: 1) + .animation(.spring(duration: 0.3), value: isScrubbing) + } +} diff --git a/Amphlify/UI/Radio/RadioView.swift b/Amphlify/UI/Radio/RadioView.swift new file mode 100644 index 0000000..bb2177f --- /dev/null +++ b/Amphlify/UI/Radio/RadioView.swift @@ -0,0 +1,101 @@ +import Domain +import SwiftUI + +struct RadioView: View { + @Environment(AppModel.self) private var app + @Environment(PlayerModel.self) private var player + @State private var artists: [Artist] = [] + @State private var genres: [Genre] = [] + + var body: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 32) { + if !artists.isEmpty { + Shelf(title: "Artist Stations") { + ForEach(artists) { artist in + StationTile(artwork: artist.artwork, title: artist.name, isCircle: true) { + await app.library.tracks(byArtist: artist.id) + } + } + } + } + if !genres.isEmpty { + VStack(alignment: .leading, spacing: 12) { + SectionHeader(title: "Stations by Genre") + LazyVGrid(columns: [GridItem(.adaptive(minimum: 150), spacing: 12)], spacing: 12) { + ForEach(Array(genres.enumerated()), id: \.element) { index, genre in + Button { + Task { await player.shuffle(app.library.tracks(inGenre: genre.name)) } + } label: { + GenreCard(name: genre.name, index: index) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, Metrics.margin) + } + } + } + .padding(.vertical, 12) + } + .navigationTitle("Radio") + .modifier(AccountToolbar()) + .task(id: app.revision) { + artists = await app.library.artists() + genres = await app.library.genres() + } + } +} + +private struct StationTile: View { + let artwork: EntityID? + let title: String + let isCircle: Bool + let tracks: () async -> [Track] + + @Environment(PlayerModel.self) private var player + + var body: some View { + Button { + Task { await player.shuffle(tracks()) } + } label: { + VStack(spacing: 8) { + ArtworkView(artwork: artwork, shape: isCircle ? .circle : .rounded(8)) + VStack(spacing: 1) { + Text(title).lineLimit(1) + Text("Station").foregroundStyle(.secondary) + } + .font(.subheadline) + } + .shelfTile(fraction: 1 / 2.6) + } + .buttonStyle(.plain) + } +} + +struct GenreCard: View { + let name: String + let index: Int + + var body: some View { + let hue = (Double(index) * 0.618 + 0.95).truncatingRemainder(dividingBy: 1) + Text(name) + .font(.headline) + .foregroundStyle(.white) + .multilineTextAlignment(.leading) + .padding(12) + .frame(maxWidth: .infinity, minHeight: 104, alignment: .bottomLeading) + .background { + LinearGradient( + colors: [ + Color(hue: hue, saturation: 0.72, brightness: 0.82), + Color(hue: hue + 0.06, saturation: 0.85, brightness: 0.55), + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + } + .clipShape(.rect(cornerRadius: 12, style: .continuous)) + .contentShape(.rect) + } +} diff --git a/Amphlify/UI/RootView.swift b/Amphlify/UI/RootView.swift new file mode 100644 index 0000000..3123545 --- /dev/null +++ b/Amphlify/UI/RootView.swift @@ -0,0 +1,67 @@ +import SwiftUI + +struct RootView: View { + @Environment(AppModel.self) private var app + @Environment(Router.self) private var router + @Namespace private var playerSpace + + var body: some View { + if app.hasSources { + tabs + } else { + SignInView() + } + } + + private var tabs: some View { + @Bindable var router = router + return TabView(selection: $router.tab) { + Tab("Home", systemImage: "house.fill", value: AppTab.home) { + TabStack(tab: .home) { HomeView() } + } + Tab("Radio", systemImage: "dot.radiowaves.left.and.right", value: AppTab.radio) { + TabStack(tab: .radio) { RadioView() } + } + Tab("Library", systemImage: "music.note.square.stack.fill", value: AppTab.library) { + TabStack(tab: .library) { LibraryView() } + } + Tab("Search", systemImage: "magnifyingglass", value: AppTab.search) { + TabStack(tab: .search) { SearchView() } + } + } + .tabBarMinimizeBehavior(.onScrollDown) + .tabViewBottomAccessory { + MiniPlayer() + .matchedTransitionSource(id: "player", in: playerSpace) + } + .fullScreenCover(isPresented: $router.showsNowPlaying) { + NowPlayingView() + .navigationTransition(.zoom(sourceID: "player", in: playerSpace)) + } + } +} + +struct TabStack: View { + let tab: AppTab + @ViewBuilder let content: Content + @Environment(Router.self) private var router + + var body: some View { + NavigationStack(path: Binding { router.path(for: tab) } set: { router.paths[tab] = $0 }) { + content.navigationDestination(for: Route.self) { route in + switch route { + case let .album(id): AlbumDetailView(id: id) + case let .artist(id): ArtistDetailView(id: id) + case let .playlist(id): PlaylistDetailView(source: .playlist(id)) + case .favorites: PlaylistDetailView(source: .favorites) + case let .genre(name): GenreDetailView(genre: name) + case .section(.playlists): PlaylistsView() + case .section(.artists): ArtistsView() + case .section(.albums): AlbumsView() + case .section(.songs): SongsView() + case .section(.genres): GenresView() + } + } + } + } +} diff --git a/Amphlify/UI/Search/SearchView.swift b/Amphlify/UI/Search/SearchView.swift new file mode 100644 index 0000000..64d3f0d --- /dev/null +++ b/Amphlify/UI/Search/SearchView.swift @@ -0,0 +1,82 @@ +import Domain +import Store +import SwiftUI + +struct SearchView: View { + @Environment(AppModel.self) private var app + @Environment(PlayerModel.self) private var player + @State private var query = "" + @State private var results = SearchResults() + @State private var genres: [Genre] = [] + + var body: some View { + ScrollView { + if query.trimmingCharacters(in: .whitespaces).isEmpty { + browse + } else if results.isEmpty { + ContentUnavailableView.search(text: query).padding(.top, 80) + } else { + found + } + } + .navigationTitle("Search") + .modifier(AccountToolbar()) + .searchable(text: $query, placement: .navigationBarDrawer(displayMode: .always), prompt: "Artists, Songs, Albums and More") + .task(id: query) { + results = await app.library.search(query) + } + .task(id: app.revision) { genres = await app.library.genres() } + } + + private var browse: some View { + VStack(alignment: .leading, spacing: 12) { + SectionHeader(title: "Browse Categories") + LazyVGrid(columns: [GridItem(.adaptive(minimum: 150), spacing: 12)], spacing: 12) { + ForEach(Array(genres.enumerated()), id: \.element) { index, genre in + NavigationLink(value: Route.genre(genre.name)) { GenreCard(name: genre.name, index: index) }.buttonStyle(.plain) + } + } + .padding(.horizontal, Metrics.margin) + } + .padding(.vertical, 12) + } + + private var found: some View { + LazyVStack(spacing: 0) { + ForEach(results.artists) { artist in + link(.artist(artist.id), artwork: artist.artwork, isCircle: true, title: artist.name, subtitle: Text("Artist")) + } + ForEach(results.albums) { album in + link(.album(album.id), artwork: album.artwork, title: album.title, subtitle: Text("Album · \(album.artistName)")) + } + ForEach(results.playlists) { playlist in + link(.playlist(playlist.id), artwork: playlist.artwork, title: playlist.name, subtitle: Text("Playlist")) + } + ForEach(Array(results.tracks.enumerated()), id: \.offset) { index, track in + TrackRow(track: track) { player.play(results.tracks, startingAt: index) } + Divider().padding(.leading, Metrics.margin + 60) + } + } + } + + private func link(_ route: Route, artwork: EntityID?, isCircle: Bool = false, title: String, subtitle: Text) -> some View { + VStack(spacing: 0) { + NavigationLink(value: route) { + HStack(spacing: 12) { + ArtworkView(artwork: artwork, shape: isCircle ? .circle : .rounded(5)).frame(width: 48, height: 48) + VStack(alignment: .leading, spacing: 2) { + Text(title).lineLimit(1) + subtitle.font(.subheadline).foregroundStyle(.secondary).lineLimit(1) + } + Spacer() + Image(systemName: "chevron.right").font(.footnote.weight(.semibold)).foregroundStyle(.tertiary) + } + .padding(.horizontal, Metrics.margin) + .frame(minHeight: 60) + .contentShape(.rect) + } + .buttonStyle(.plain) + Divider().padding(.leading, Metrics.margin + 60) + } + } +} diff --git a/Amphlify/UI/SignInView.swift b/Amphlify/UI/SignInView.swift new file mode 100644 index 0000000..2b83f30 --- /dev/null +++ b/Amphlify/UI/SignInView.swift @@ -0,0 +1,87 @@ +import SwiftUI + +struct SignInView: View { + private enum Field { + case server, username, password + } + + @Environment(AppModel.self) private var app + @State private var server = "" + @State private var username = "" + @State private var password = "" + @State private var isSigningIn = false + @State private var failure: LocalizedStringKey? + @FocusState private var focus: Field? + + private var canSubmit: Bool { + !server.isEmpty && !username.isEmpty && !password.isEmpty && !isSigningIn + } + + var body: some View { + NavigationStack { + Form { + Section { + TextField("Server", text: $server) + .textContentType(.URL) + .keyboardType(.URL) + .focused($focus, equals: .server) + .submitLabel(.next) + .onSubmit { focus = .username } + TextField("Username", text: $username) + .textContentType(.username) + .focused($focus, equals: .username) + .submitLabel(.next) + .onSubmit { focus = .password } + SecureField("Password", text: $password) + .textContentType(.password) + .focused($focus, equals: .password) + .submitLabel(.go) + .onSubmit(signIn) + } footer: { + if let failure { + Text(failure).foregroundStyle(.red) + } + } + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + Section { + Button(action: signIn) { + ZStack { + Text("Sign In").fontWeight(.semibold).opacity(isSigningIn ? 0 : 1) + if isSigningIn { + ProgressView() + } + } + .frame(maxWidth: .infinity) + } + .disabled(!canSubmit) + } + } + .navigationTitle("Sign In") + } + } + + private func signIn() { + guard canSubmit else { return } + guard let url = ServerAccount.normalize(server) else { + failure = "Can't connect to the server" + return + } + let name = username.trimmingCharacters(in: .whitespaces) + let account = ServerAccount(name: url.host() ?? server, baseURL: url, username: name) + isSigningIn = true + failure = nil + Task { + do { + try await app.add(account: account, password: password) + } catch ConnectError.unreachable { + failure = "Can't connect to the server" + } catch ConnectError.unauthorized { + failure = "Wrong username or password" + } catch { + failure = "Can't sign in. Try again later." + } + isSigningIn = false + } + } +} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9333590 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,16 @@ +## Code Principles +- **Simplicity**: Write simple, straightforward code +- **Readability**: Make code easy to understand +- **Performance**: Consider performance without sacrificing readability +- **Maintainability**: Write code that's easy to update +- **Reusability**: Create reusable components and functions +- **Less Code = Less Debt**: Minimize code footprint +- **NEVER write comments** - code should be self-documenting +- **Docstrings ONLY if explicitly asked** + +## Checking commands +After writing code, always run: +```shell +make fmt +make check +``` diff --git a/Config/Base.xcconfig b/Config/Base.xcconfig new file mode 100644 index 0000000..441bf58 --- /dev/null +++ b/Config/Base.xcconfig @@ -0,0 +1,2 @@ +BUNDLE_ID_PREFIX = com.example +#include? "Signing.xcconfig" diff --git a/Config/Info.plist b/Config/Info.plist new file mode 100644 index 0000000..435eb45 --- /dev/null +++ b/Config/Info.plist @@ -0,0 +1,12 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + UIBackgroundModes + + audio + + + diff --git a/Config/Signing.example.xcconfig b/Config/Signing.example.xcconfig new file mode 100644 index 0000000..8bde387 --- /dev/null +++ b/Config/Signing.example.xcconfig @@ -0,0 +1,2 @@ +DEVELOPMENT_TEAM = ABCDE12345 +BUNDLE_ID_PREFIX = com.yourname diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..580c313 --- /dev/null +++ b/Makefile @@ -0,0 +1,22 @@ +SCHEME := Amphlify +DESTINATION ?= platform=iOS Simulator,name=iPhone 18 Pro +XCODEBUILD := xcodebuild -project Amphlify.xcodeproj -scheme $(SCHEME) -derivedDataPath build -quiet +KIT := Packages/AmphlifyKit + +.PHONY: fmt check test run + +fmt: + swiftformat . + +check: + swiftformat --lint . + swiftlint --strict --quiet + $(XCODEBUILD) -destination 'generic/platform=iOS Simulator' build + +test: + swift test --package-path $(KIT) --quiet + +run: + $(XCODEBUILD) -destination '$(DESTINATION)' build + xcrun simctl install booted build/Build/Products/Debug-iphonesimulator/Amphlify.app + xcrun simctl launch booted com.soundoz.amphlify diff --git a/Packages/AmphlifyKit/Package.swift b/Packages/AmphlifyKit/Package.swift new file mode 100644 index 0000000..c579249 --- /dev/null +++ b/Packages/AmphlifyKit/Package.swift @@ -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] +) diff --git a/Packages/AmphlifyKit/Sources/Domain/AudioFormat.swift b/Packages/AmphlifyKit/Sources/Domain/AudioFormat.swift new file mode 100644 index 0000000..e17521a --- /dev/null +++ b/Packages/AmphlifyKit/Sources/Domain/AudioFormat.swift @@ -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) + } +} diff --git a/Packages/AmphlifyKit/Sources/Domain/Catalog.swift b/Packages/AmphlifyKit/Sources/Domain/Catalog.swift new file mode 100644 index 0000000..f65ecb2 --- /dev/null +++ b/Packages/AmphlifyKit/Sources/Domain/Catalog.swift @@ -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 + } +} diff --git a/Packages/AmphlifyKit/Sources/Domain/Identity.swift b/Packages/AmphlifyKit/Sources/Domain/Identity.swift new file mode 100644 index 0000000..205692e --- /dev/null +++ b/Packages/AmphlifyKit/Sources/Domain/Identity.swift @@ -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 +} diff --git a/Packages/AmphlifyKit/Sources/Domain/Lyrics.swift b/Packages/AmphlifyKit/Sources/Domain/Lyrics.swift new file mode 100644 index 0000000..27d331a --- /dev/null +++ b/Packages/AmphlifyKit/Sources/Domain/Lyrics.swift @@ -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? +} diff --git a/Packages/AmphlifyKit/Sources/Domain/Source.swift b/Packages/AmphlifyKit/Sources/Domain/Source.swift new file mode 100644 index 0000000..5e697c4 --- /dev/null +++ b/Packages/AmphlifyKit/Sources/Domain/Source.swift @@ -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 + 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 +} diff --git a/Packages/AmphlifyKit/Sources/Playback/AVFoundationEngine.swift b/Packages/AmphlifyKit/Sources/Playback/AVFoundationEngine.swift new file mode 100644 index 0000000..0e43850 --- /dev/null +++ b/Packages/AmphlifyKit/Sources/Playback/AVFoundationEngine.swift @@ -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 + private let continuation: AsyncStream.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.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 diff --git a/Packages/AmphlifyKit/Sources/Playback/AudioSession.swift b/Packages/AmphlifyKit/Sources/Playback/AudioSession.swift new file mode 100644 index 0000000..32981fc --- /dev/null +++ b/Packages/AmphlifyKit/Sources/Playback/AudioSession.swift @@ -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 diff --git a/Packages/AmphlifyKit/Sources/Playback/PlaybackModel.swift b/Packages/AmphlifyKit/Sources/Playback/PlaybackModel.swift new file mode 100644 index 0000000..707ae14 --- /dev/null +++ b/Packages/AmphlifyKit/Sources/Playback/PlaybackModel.swift @@ -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 { 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 {} +} diff --git a/Packages/AmphlifyKit/Sources/Playback/PlaybackSession.swift b/Packages/AmphlifyKit/Sources/Playback/PlaybackSession.swift new file mode 100644 index 0000000..cbc4190 --- /dev/null +++ b/Packages/AmphlifyKit/Sources/Playback/PlaybackSession.swift @@ -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.Continuation] = [:] + private var eventTask: Task? + 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 { + listenToEngine() + let key = UUID() + let (stream, continuation) = AsyncStream.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[.. + private let continuation: AsyncStream.Continuation + private var item: QueueItem? + private var position: TimeInterval = 0 + private var startedAt: ContinuousClock.Instant? + private var timer: Task? + + public init() { + (events, continuation) = AsyncStream.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 + } +} diff --git a/Packages/AmphlifyKit/Sources/SourceFake/FakeArtwork.swift b/Packages/AmphlifyKit/Sources/SourceFake/FakeArtwork.swift new file mode 100644 index 0000000..8b77e7d --- /dev/null +++ b/Packages/AmphlifyKit/Sources/SourceFake/FakeArtwork.swift @@ -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]) + } +} diff --git a/Packages/AmphlifyKit/Sources/SourceFake/FakeCatalog.swift b/Packages/AmphlifyKit/Sources/SourceFake/FakeCatalog.swift new file mode 100644 index 0000000..d21a854 --- /dev/null +++ b/Packages/AmphlifyKit/Sources/SourceFake/FakeCatalog.swift @@ -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)") + )))) + } + } +} diff --git a/Packages/AmphlifyKit/Sources/SourceFake/FakeSource.swift b/Packages/AmphlifyKit/Sources/SourceFake/FakeSource.swift new file mode 100644 index 0000000..27f17cf --- /dev/null +++ b/Packages/AmphlifyKit/Sources/SourceFake/FakeSource.swift @@ -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 { + 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 {} +} diff --git a/Packages/AmphlifyKit/Sources/SourceSubsonic/SubsonicClient.swift b/Packages/AmphlifyKit/Sources/SourceSubsonic/SubsonicClient.swift new file mode 100644 index 0000000..173074f --- /dev/null +++ b/Packages/AmphlifyKit/Sources/SourceSubsonic/SubsonicClient.swift @@ -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(_ 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(_ data: Data, as _: Payload.Type) throws -> Payload { + let decoder = JSONDecoder() + let status: Wrapper + do { + status = try decoder.decode(Wrapper.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.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: 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 {} diff --git a/Packages/AmphlifyKit/Sources/SourceSubsonic/SubsonicCrawl.swift b/Packages/AmphlifyKit/Sources/SourceSubsonic/SubsonicCrawl.swift new file mode 100644 index 0000000..6d085da --- /dev/null +++ b/Packages/AmphlifyKit/Sources/SourceSubsonic/SubsonicCrawl.swift @@ -0,0 +1,137 @@ +import Domain +import Foundation + +struct SubsonicMemory: Sendable { + var seen: [EntityKind: Set] = [:] + 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]) 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]) { + for (kind, remotes) in previous { + for remote in remotes.subtracting(memory.seen[kind] ?? []).sorted() { + emit(.remove(kind, mapper.id(remote))) + } + } + } +} diff --git a/Packages/AmphlifyKit/Sources/SourceSubsonic/SubsonicMapping.swift b/Packages/AmphlifyKit/Sources/SourceSubsonic/SubsonicMapping.swift new file mode 100644 index 0000000..487613f --- /dev/null +++ b/Packages/AmphlifyKit/Sources/SourceSubsonic/SubsonicMapping.swift @@ -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[.. 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) + } +} diff --git a/Packages/AmphlifyKit/Sources/SourceSubsonic/SubsonicModels.swift b/Packages/AmphlifyKit/Sources/SourceSubsonic/SubsonicModels.swift new file mode 100644 index 0000000..e715e75 --- /dev/null +++ b/Packages/AmphlifyKit/Sources/SourceSubsonic/SubsonicModels.swift @@ -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 +} diff --git a/Packages/AmphlifyKit/Sources/SourceSubsonic/SubsonicSource.swift b/Packages/AmphlifyKit/Sources/SourceSubsonic/SubsonicSource.swift new file mode 100644 index 0000000..6f63404 --- /dev/null +++ b/Packages/AmphlifyKit/Sources/SourceSubsonic/SubsonicSource.swift @@ -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 { + 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"]) + } +} diff --git a/Packages/AmphlifyKit/Sources/Store/Library.swift b/Packages/AmphlifyKit/Sources/Store/Library.swift new file mode 100644 index 0000000..6c4216b --- /dev/null +++ b/Packages/AmphlifyKit/Sources/Store/Library.swift @@ -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 + + 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 + func setFavorite(_ isFavorite: Bool, for id: EntityID) async + func recordPlay(of track: Track, at date: Date) async + + func search(_ query: String) async -> SearchResults +} diff --git a/Packages/AmphlifyKit/Sources/Store/MemoryLibrary.swift b/Packages/AmphlifyKit/Sources/Store/MemoryLibrary.swift new file mode 100644 index 0000000..f602b0a --- /dev/null +++ b/Packages/AmphlifyKit/Sources/Store/MemoryLibrary.swift @@ -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.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 { + let key = UUID() + let (stream, continuation) = AsyncStream.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 { + 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 + } +} diff --git a/Packages/AmphlifyKit/Sources/Sync/SyncEngine.swift b/Packages/AmphlifyKit/Sources/Sync/SyncEngine.swift new file mode 100644 index 0000000..2d60b0b --- /dev/null +++ b/Packages/AmphlifyKit/Sources/Sync/SyncEngine.swift @@ -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) + } + } +} diff --git a/Packages/AmphlifyKit/Tests/DomainTests/LyricsTests.swift b/Packages/AmphlifyKit/Tests/DomainTests/LyricsTests.swift new file mode 100644 index 0000000..a6a81d4 --- /dev/null +++ b/Packages/AmphlifyKit/Tests/DomainTests/LyricsTests.swift @@ -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) + } +} diff --git a/Packages/AmphlifyKit/Tests/PlaybackTests/PlaybackSessionTests.swift b/Packages/AmphlifyKit/Tests/PlaybackTests/PlaybackSessionTests.swift new file mode 100644 index 0000000..a233615 --- /dev/null +++ b/Packages/AmphlifyKit/Tests/PlaybackTests/PlaybackSessionTests.swift @@ -0,0 +1,185 @@ +import Domain +import Foundation +@testable import Playback +import Testing + +actor RecordingEngine: OutputEngine { + nonisolated let events: AsyncStream + nonisolated let continuation: AsyncStream.Continuation + private(set) var loaded: [QueueItem.ID] = [] + private(set) var prepared: [QueueItem.ID?] = [] + + init() { + (events, continuation) = AsyncStream.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) +} diff --git a/Packages/AmphlifyKit/Tests/SourceSubsonicTests/FixtureTransport.swift b/Packages/AmphlifyKit/Tests/SourceSubsonicTests/FixtureTransport.swift new file mode 100644 index 0000000..8b08a18 --- /dev/null +++ b/Packages/AmphlifyKit/Tests/SourceSubsonicTests/FixtureTransport.swift @@ -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(_ value: T?) throws -> T { + guard let value else { throw FixtureError.missing } + return value +} + +private enum FixtureError: Error { + case missing +} diff --git a/Packages/AmphlifyKit/Tests/SourceSubsonicTests/Fixtures/album.json b/Packages/AmphlifyKit/Tests/SourceSubsonicTests/Fixtures/album.json new file mode 100644 index 0000000..b73fe67 --- /dev/null +++ b/Packages/AmphlifyKit/Tests/SourceSubsonicTests/Fixtures/album.json @@ -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-10T21:16:06.7708678Z","albumId":"5sPoyWVCZcD9yiA97o0FbY","artistId":"2FXyezR7e5yT2ZwTDFF5ps","type":"music","played":"2026-09-11T19:16:48.845Z","bpm":0,"comment":"","sortName":"office song","mediaType":"song","musicBrainzId":"","isrc":["FRX762654568"],"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":"composer","artist":{"id":"2aKpbUZ0l9FuDeYo57E7Jk","name":"Dmitri Itskov"}},{"role":"performer","artist":{"id":"2FXyezR7e5yT2ZwTDFF5ps","name":"Kai Angel"}}],"displayComposer":"Dmitri Itskov","explicitStatus":"","groupings":[],"works":[],"movements":[]},{"id":"kkosXzMVKl8vT0YNsNkwaz","parent":"5sPoyWVCZcD9yiA97o0FbY","isDir":false,"title":"david bowie","album":"Shh!","artist":"Kai Angel","track":5,"year":2026,"genre":"Alternative Rap","coverArt":"mf-kkosXzMVKl8vT0YNsNkwaz_6aa31dbd","size":13428040,"contentType":"audio/flac","suffix":"flac","duration":93,"bitRate":1143,"path":"Kai Angel/Shh!/01-05 - david bowie.flac","playCount":2,"discNumber":1,"created":"2026-09-10T21:16:06.778714151Z","albumId":"5sPoyWVCZcD9yiA97o0FbY","artistId":"2FXyezR7e5yT2ZwTDFF5ps","type":"music","played":"2026-09-11T19:30:40.233Z","bpm":0,"comment":"","sortName":"david bowie","mediaType":"song","musicBrainzId":"","isrc":["FRX762654569"],"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":"composer","artist":{"id":"2aKpbUZ0l9FuDeYo57E7Jk","name":"Dmitri Itskov"}},{"role":"performer","artist":{"id":"2FXyezR7e5yT2ZwTDFF5ps","name":"Kai Angel"}}],"displayComposer":"Dmitri Itskov","explicitStatus":"","groupings":[],"works":[],"movements":[]},{"id":"cca0G5SsdhyMWJbVqsxXTa","parent":"5sPoyWVCZcD9yiA97o0FbY","isDir":false,"title":"having fun","album":"Shh!","artist":"Kai Angel","track":6,"year":2026,"genre":"Alternative Rap","coverArt":"mf-cca0G5SsdhyMWJbVqsxXTa_6aa31dbd","size":18845317,"contentType":"audio/flac","suffix":"flac","duration":135,"bitRate":1110,"path":"Kai Angel/Shh!/01-06 - having fun.flac","playCount":2,"discNumber":1,"created":"2026-09-10T21:16:06.787321775Z","albumId":"5sPoyWVCZcD9yiA97o0FbY","artistId":"2FXyezR7e5yT2ZwTDFF5ps","type":"music","played":"2026-09-11T19:26:57.592Z","bpm":0,"comment":"","sortName":"having fun","mediaType":"song","musicBrainzId":"","isrc":["FRX762654570"],"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":"composer","artist":{"id":"2aKpbUZ0l9FuDeYo57E7Jk","name":"Dmitri Itskov"}},{"role":"performer","artist":{"id":"2FXyezR7e5yT2ZwTDFF5ps","name":"Kai Angel"}}],"displayComposer":"Dmitri Itskov","explicitStatus":"","groupings":[],"works":[],"movements":[]},{"id":"QdV8d7gtWICDB5HpEpLz3V","parent":"5sPoyWVCZcD9yiA97o0FbY","isDir":false,"title":"Leopard Red","album":"Shh!","artist":"Kai Angel","track":7,"year":2026,"genre":"Alternative Rap","coverArt":"mf-QdV8d7gtWICDB5HpEpLz3V_6aa31dbd","size":15617356,"contentType":"audio/flac","suffix":"flac","duration":114,"bitRate":1081,"path":"Kai Angel/Shh!/01-07 - Leopard Red.flac","playCount":4,"discNumber":1,"created":"2026-09-10T21:16:06.78218843Z","albumId":"5sPoyWVCZcD9yiA97o0FbY","artistId":"2FXyezR7e5yT2ZwTDFF5ps","type":"music","played":"2026-09-20T20:21:27.738621024Z","bpm":0,"comment":"","sortName":"leopard red","mediaType":"song","musicBrainzId":"","isrc":["FRX762654571"],"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":"qkek2NyoYBGzQxkdrHcOFa","parent":"5sPoyWVCZcD9yiA97o0FbY","isDir":false,"title":"Sting","album":"Shh!","artist":"Kai Angel","track":8,"year":2026,"genre":"Alternative Rap","coverArt":"mf-qkek2NyoYBGzQxkdrHcOFa_6aa31dbd","size":17352666,"contentType":"audio/flac","suffix":"flac","duration":132,"bitRate":1043,"path":"Kai Angel/Shh!/01-08 - Sting.flac","playCount":3,"discNumber":1,"created":"2026-09-10T21:16:06.777073139Z","albumId":"5sPoyWVCZcD9yiA97o0FbY","artistId":"2FXyezR7e5yT2ZwTDFF5ps","type":"music","played":"2026-09-18T17:30:04.236Z","bpm":0,"comment":"","sortName":"sting","mediaType":"song","musicBrainzId":"","isrc":["FRX762654572"],"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":"composer","artist":{"id":"2aKpbUZ0l9FuDeYo57E7Jk","name":"Dmitri Itskov"}},{"role":"performer","artist":{"id":"2FXyezR7e5yT2ZwTDFF5ps","name":"Kai Angel"}}],"displayComposer":"Dmitri Itskov","explicitStatus":"","groupings":[],"works":[],"movements":[]},{"id":"uZway8WEk8KrYeFT2xRSMQ","parent":"5sPoyWVCZcD9yiA97o0FbY","isDir":false,"title":"ew","album":"Shh!","artist":"Kai Angel","track":9,"year":2026,"genre":"Alternative Rap","coverArt":"mf-uZway8WEk8KrYeFT2xRSMQ_6aa31dbd","size":15270322,"contentType":"audio/flac","suffix":"flac","duration":105,"bitRate":1144,"path":"Kai Angel/Shh!/01-09 - ew.flac","playCount":2,"discNumber":1,"created":"2026-09-10T21:16:06.773931522Z","albumId":"5sPoyWVCZcD9yiA97o0FbY","artistId":"2FXyezR7e5yT2ZwTDFF5ps","type":"music","played":"2026-09-11T19:18:33.768Z","bpm":0,"comment":"","sortName":"ew","mediaType":"song","musicBrainzId":"","isrc":["FRX762654573"],"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":"composer","artist":{"id":"2aKpbUZ0l9FuDeYo57E7Jk","name":"Dmitri Itskov"}},{"role":"performer","artist":{"id":"2FXyezR7e5yT2ZwTDFF5ps","name":"Kai Angel"}}],"displayComposer":"Dmitri Itskov","explicitStatus":"","groupings":[],"works":[],"movements":[]},{"id":"fL1joD6hherflyofV2n7MN","parent":"5sPoyWVCZcD9yiA97o0FbY","isDir":false,"title":"STFU!","album":"Shh!","artist":"Kai Angel","track":10,"year":2026,"genre":"Alternative Rap","coverArt":"mf-fL1joD6hherflyofV2n7MN_6aa31dbd","size":25033779,"contentType":"audio/flac","suffix":"flac","duration":174,"bitRate":1142,"path":"Kai Angel/Shh!/01-10 - STFU!.flac","playCount":2,"discNumber":1,"created":"2026-09-10T21:16:06.791563411Z","albumId":"5sPoyWVCZcD9yiA97o0FbY","artistId":"2FXyezR7e5yT2ZwTDFF5ps","type":"music","played":"2026-09-11T19:22:37.202Z","bpm":0,"comment":"","sortName":"stfu!","mediaType":"song","musicBrainzId":"","isrc":["FRX762654574"],"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":"composer","artist":{"id":"2aKpbUZ0l9FuDeYo57E7Jk","name":"Dmitri Itskov"}},{"role":"performer","artist":{"id":"2FXyezR7e5yT2ZwTDFF5ps","name":"Kai Angel"}}],"displayComposer":"Dmitri Itskov","explicitStatus":"","groupings":[],"works":[],"movements":[]},{"id":"kW3psXGtdWCAq3P9T2OuH3","parent":"5sPoyWVCZcD9yiA97o0FbY","isDir":false,"title":"1971 Rockstar","album":"Shh!","artist":"Kai Angel","track":11,"year":2026,"genre":"Alternative Rap","coverArt":"mf-kW3psXGtdWCAq3P9T2OuH3_6aa31dbd","size":13602577,"contentType":"audio/flac","suffix":"flac","duration":104,"bitRate":1037,"path":"Kai Angel/Shh!/01-11 - 1971 Rockstar.flac","playCount":2,"discNumber":1,"created":"2026-09-10T21:16:06.780389634Z","albumId":"5sPoyWVCZcD9yiA97o0FbY","artistId":"2FXyezR7e5yT2ZwTDFF5ps","type":"music","played":"2026-09-11T19:13:47.5Z","bpm":0,"comment":"","sortName":"1971 rockstar","mediaType":"song","musicBrainzId":"","isrc":["FRX762654575"],"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":"composer","artist":{"id":"2aKpbUZ0l9FuDeYo57E7Jk","name":"Dmitri Itskov"}},{"role":"performer","artist":{"id":"2FXyezR7e5yT2ZwTDFF5ps","name":"Kai Angel"}}],"displayComposer":"Dmitri Itskov","explicitStatus":"","groupings":[],"works":[],"movements":[]},{"id":"hxPdm4qIy5xLlhH88mxngC","parent":"5sPoyWVCZcD9yiA97o0FbY","isDir":false,"title":"does it hurt?","album":"Shh!","artist":"Kai Angel","track":12,"year":2026,"genre":"Alternative Rap","coverArt":"mf-hxPdm4qIy5xLlhH88mxngC_6aa31dbd","size":12964824,"contentType":"audio/flac","suffix":"flac","duration":103,"bitRate":996,"path":"Kai Angel/Shh!/01-12 - does it hurt?.flac","playCount":2,"discNumber":1,"created":"2026-09-10T21:16:06.77249783Z","albumId":"5sPoyWVCZcD9yiA97o0FbY","artistId":"2FXyezR7e5yT2ZwTDFF5ps","type":"music","played":"2026-09-11T19:20:18.841Z","bpm":0,"comment":"","sortName":"does it hurt?","mediaType":"song","musicBrainzId":"","isrc":["FRX762654576"],"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":"composer","artist":{"id":"2aKpbUZ0l9FuDeYo57E7Jk","name":"Dmitri Itskov"}},{"role":"performer","artist":{"id":"2FXyezR7e5yT2ZwTDFF5ps","name":"Kai Angel"}}],"displayComposer":"Dmitri Itskov","explicitStatus":"","groupings":[],"works":[],"movements":[]},{"id":"YLaeSCCWvxDY2MS4JOJ5he","parent":"5sPoyWVCZcD9yiA97o0FbY","isDir":false,"title":"Madam 2","album":"Shh!","artist":"Kai Angel","track":13,"year":2026,"genre":"Alternative Rap","coverArt":"mf-YLaeSCCWvxDY2MS4JOJ5he_6aa31dbd","size":13786508,"contentType":"audio/flac","suffix":"flac","duration":109,"bitRate":999,"path":"Kai Angel/Shh!/01-13 - Madam 2.flac","playCount":2,"discNumber":1,"created":"2026-09-10T21:16:06.78959972Z","albumId":"5sPoyWVCZcD9yiA97o0FbY","artistId":"2FXyezR7e5yT2ZwTDFF5ps","type":"music","played":"2026-09-11T19:28:59.403Z","bpm":0,"comment":"","sortName":"madam 2","mediaType":"song","musicBrainzId":"","isrc":["FRX762654578"],"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":"composer","artist":{"id":"2aKpbUZ0l9FuDeYo57E7Jk","name":"Dmitri Itskov"}},{"role":"performer","artist":{"id":"2FXyezR7e5yT2ZwTDFF5ps","name":"Kai Angel"}}],"displayComposer":"Dmitri Itskov","explicitStatus":"","groupings":[],"works":[],"movements":[]},{"id":"L5crnBFIsmh4bdgur5ZybD","parent":"5sPoyWVCZcD9yiA97o0FbY","isDir":false,"title":"chrome hearts","album":"Shh!","artist":"Kai Angel","track":14,"year":2026,"genre":"Alternative Rap","coverArt":"mf-L5crnBFIsmh4bdgur5ZybD_6aa31dbd","size":18792654,"contentType":"audio/flac","suffix":"flac","duration":135,"bitRate":1103,"path":"Kai Angel/Shh!/01-14 - chrome hearts.flac","playCount":3,"discNumber":1,"created":"2026-09-10T21:16:06.775394549Z","albumId":"5sPoyWVCZcD9yiA97o0FbY","artistId":"2FXyezR7e5yT2ZwTDFF5ps","type":"music","played":"2026-09-11T19:09:44.259Z","bpm":0,"comment":"","sortName":"chrome hearts","mediaType":"song","musicBrainzId":"","isrc":["FRX762654579"],"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":"composer","artist":{"id":"2aKpbUZ0l9FuDeYo57E7Jk","name":"Dmitri Itskov"}},{"role":"performer","artist":{"id":"2FXyezR7e5yT2ZwTDFF5ps","name":"Kai Angel"}}],"displayComposer":"Dmitri Itskov","explicitStatus":"","groupings":[],"works":[],"movements":[]}]}}} \ No newline at end of file diff --git a/Packages/AmphlifyKit/Tests/SourceSubsonicTests/Fixtures/albumList2.json b/Packages/AmphlifyKit/Tests/SourceSubsonicTests/Fixtures/albumList2.json new file mode 100644 index 0000000..b73fdb8 --- /dev/null +++ b/Packages/AmphlifyKit/Tests/SourceSubsonicTests/Fixtures/albumList2.json @@ -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":""}]}}} \ No newline at end of file diff --git a/Packages/AmphlifyKit/Tests/SourceSubsonicTests/Fixtures/albumList2All.json b/Packages/AmphlifyKit/Tests/SourceSubsonicTests/Fixtures/albumList2All.json new file mode 100644 index 0000000..a15a340 --- /dev/null +++ b/Packages/AmphlifyKit/Tests/SourceSubsonicTests/Fixtures/albumList2All.json @@ -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":"","isCompilation":false,"sortName":"8 миля","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"7Dy79oui9xLw7S5an2x0nk","name":"madk1d"}],"displayArtist":"madk1d","explicitStatus":"","version":""},{"id":"7dFUyjTR5ogaNB2aBh21bD","name":"8 способов как бросить","artist":"Poshlaya Molly","artistId":"4Ks5djIxGF54hZy8r6X1fc","coverArt":"al-7dFUyjTR5ogaNB2aBh21bD_6a876a44","songCount":7,"duration":1407,"playCount":2,"created":"2026-08-20T05:12:32.079858766Z","played":"2026-08-29T19:48:41.504Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"8 способов как бросить","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"4Ks5djIxGF54hZy8r6X1fc","name":"Poshlaya Molly"}],"displayArtist":"Poshlaya Molly","explicitStatus":"","version":""},{"id":"34LRT9wUuP3dliQR83YnIo","name":"808s \u0026 Heartbreak","artist":"Kanye West","artistId":"02DiEqiNBn3idZvY8XP1lr","coverArt":"al-34LRT9wUuP3dliQR83YnIo_6a87684c","songCount":1,"duration":241,"created":"2026-08-20T05:12:32.079858766Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"808s \u0026 heartbreak","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"02DiEqiNBn3idZvY8XP1lr","name":"Kanye West"}],"displayArtist":"Kanye West","explicitStatus":"","version":""},{"id":"0o4flcMY36TWOtDU7CPvlS","name":"agent provocateur","artist":"Slayer Diesel","artistId":"7DvjlX7vKw5rY7XvRTwaVn","coverArt":"al-0o4flcMY36TWOtDU7CPvlS_6a876847","songCount":1,"duration":95,"created":"2026-08-20T05:12:32.079858766Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"agent provocateur","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"7DvjlX7vKw5rY7XvRTwaVn","name":"Slayer Diesel"}],"displayArtist":"Slayer Diesel","explicitStatus":"","version":""},{"id":"3w3tg8dXaq3AX7HfMmWZHL","name":"ANGEL MAY CRY","artist":"Kai Angel","artistId":"2FXyezR7e5yT2ZwTDFF5ps","coverArt":"al-3w3tg8dXaq3AX7HfMmWZHL_6a8778e0","songCount":7,"duration":915,"playCount":1,"created":"2026-08-20T05:12:32.079858766Z","year":2023,"genre":"Hip Hop","played":"2026-08-20T15:42:38.294280154Z","userRating":0,"genres":[{"name":"Hip Hop"},{"name":"Rap"}],"musicBrainzId":"","isCompilation":false,"sortName":"angel may cry","discTitles":[],"originalReleaseDate":{},"releaseDate":{"year":2023},"releaseTypes":["Album"],"recordLabels":[{"name":"Koala Music"}],"moods":[],"artists":[{"id":"2FXyezR7e5yT2ZwTDFF5ps","name":"Kai Angel"}],"displayArtist":"Kai Angel","explicitStatus":"","version":""},{"id":"0mHWbFnslaClvw4ONYcgKF","name":"ANGEL MAY CRY 2","artist":"Kai Angel","artistId":"2FXyezR7e5yT2ZwTDFF5ps","coverArt":"al-0mHWbFnslaClvw4ONYcgKF_6a877993","songCount":8,"duration":948,"playCount":5,"created":"2026-08-20T05:12:32.079858766Z","year":2025,"genre":"Rap","played":"2026-09-20T20:32:40.406359565Z","userRating":0,"genres":[{"name":"Rap"},{"name":"Hip-Hop"},{"name":"Hip Hop"}],"musicBrainzId":"","isCompilation":false,"sortName":"angel may cry 2","discTitles":[],"originalReleaseDate":{},"releaseDate":{"year":2025},"releaseTypes":["Album"],"recordLabels":[{"name":"Koala Music"}],"moods":[],"artists":[{"id":"2FXyezR7e5yT2ZwTDFF5ps","name":"Kai Angel"}],"displayArtist":"Kai Angel","explicitStatus":"","version":""},{"id":"6bDHsI8JO4EcV6mADyN9RQ","name":"BEACH ROCK HOTEL","artist":"SALUKI","artistId":"4fKYKrsm8mmpys7Fp5oRyN","coverArt":"al-6bDHsI8JO4EcV6mADyN9RQ_6ab1ec2b","songCount":12,"duration":1594,"playCount":4,"created":"2026-08-20T05:12:32.079858766Z","year":2024,"played":"2026-08-29T18:55:40.315Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"beach rock hotel","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"4fKYKrsm8mmpys7Fp5oRyN","name":"SALUKI"}],"displayArtist":"SALUKI","explicitStatus":"","version":""},{"id":"1I3LpNOuc2HYMbk5iUhgGa","name":"The Boy who cried Terrified ","artist":"fakemink","artistId":"5TF4NqQ1jdxztyH9lXtlDc","coverArt":"al-1I3LpNOuc2HYMbk5iUhgGa_6a876790","songCount":1,"duration":160,"playCount":1,"created":"2026-08-20T20:09:34.174546902Z","played":"2026-08-29T18:18:28.268Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"boy who cried terrified","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"5TF4NqQ1jdxztyH9lXtlDc","name":"fakemink"}],"displayArtist":"fakemink","explicitStatus":"","version":""},{"id":"2yq3uEPMJz0yQbVlTiytgz","name":"BULLY","artist":"Ye","artistId":"3rNgp5ZP4ZJ1KyKufSvbDO","coverArt":"al-2yq3uEPMJz0yQbVlTiytgz_6a87677b","songCount":4,"duration":616,"playCount":1,"created":"2026-08-20T05:12:32.079858766Z","played":"2026-09-09T14:57:12.45Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"bully","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"3rNgp5ZP4ZJ1KyKufSvbDO","name":"Ye"}],"displayArtist":"Ye","explicitStatus":"","version":""},{"id":"0ODYKLFJLsPRIidAmykTtc","name":"Bye Bye","artist":"woee33","artistId":"0y7OcittSJlSLz3U183RBX","coverArt":"al-0ODYKLFJLsPRIidAmykTtc_6a876798","songCount":1,"duration":131,"created":"2026-08-20T05:12:32.079858766Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"bye bye","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"0y7OcittSJlSLz3U183RBX","name":"woee33"}],"displayArtist":"woee33","explicitStatus":"","version":""},{"id":"4es7x83HmysujMiaatC4N5","name":"caramel","artist":"Рай не сегодня","artistId":"22qO7WPYmgKqChrt72BFOf","coverArt":"al-4es7x83HmysujMiaatC4N5_6a87695b","songCount":1,"duration":109,"created":"2026-08-20T05:12:32.079858766Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"caramel","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"22qO7WPYmgKqChrt72BFOf","name":"Рай не сегодня"}],"displayArtist":"Рай не сегодня","explicitStatus":"","version":""},{"id":"05sJxPjQFaO1UOc3SwUgBm","name":"CORTISOL","artist":"SALUKI","artistId":"4fKYKrsm8mmpys7Fp5oRyN","coverArt":"al-05sJxPjQFaO1UOc3SwUgBm_6a8767d6","songCount":1,"duration":151,"created":"2026-08-20T05:12:32.079858766Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"cortisol","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"4fKYKrsm8mmpys7Fp5oRyN","name":"SALUKI"}],"displayArtist":"SALUKI","explicitStatus":"","version":""},{"id":"5Gd2kOmaJL9WZLnUUky3xj","name":"damage","artist":"Kai Angel","artistId":"2FXyezR7e5yT2ZwTDFF5ps","coverArt":"al-5Gd2kOmaJL9WZLnUUky3xj_6ab1f20a","songCount":13,"duration":1519,"playCount":2,"created":"2026-08-20T05:12:32.079858766Z","year":2025,"played":"2026-09-20T20:15:54.065365719Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"damage","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"2FXyezR7e5yT2ZwTDFF5ps","name":"Kai Angel"}],"displayArtist":"Kai Angel","explicitStatus":"","version":""},{"id":"18MhT8HydfLknLRdbelZdq","name":"damage","artist":"Kai Angel","artistId":"2FXyezR7e5yT2ZwTDFF5ps","coverArt":"al-18MhT8HydfLknLRdbelZdq_6a87699c","songCount":14,"duration":2088,"playCount":11,"created":"2026-08-20T05:12:32.079858766Z","year":2025,"played":"2026-09-20T20:42:26.068933467Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"damage","discTitles":[],"originalReleaseDate":{},"releaseDate":{"year":2025},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"2FXyezR7e5yT2ZwTDFF5ps","name":"Kai Angel"}],"displayArtist":"Kai Angel","explicitStatus":"","version":""},{"id":"16ZRVKUaQvLZ1KXeynaMzN","name":"damage","artist":"Kai Angel","artistId":"2FXyezR7e5yT2ZwTDFF5ps","coverArt":"al-16ZRVKUaQvLZ1KXeynaMzN_6a877b65","songCount":1,"duration":145,"created":"2026-08-20T22:14:57.857827746Z","year":2025,"genre":"Hip-Hop","userRating":0,"genres":[{"name":"Hip-Hop"},{"name":"Rap"}],"musicBrainzId":"18571ca4-a2ea-4030-9e9c-ee6fa8ccb540","isCompilation":false,"sortName":"damage","discTitles":[],"originalReleaseDate":{"year":2025,"month":6,"day":5},"releaseDate":{"year":2025},"releaseTypes":["album"],"recordLabels":[{"name":"Koala Music"}],"moods":[],"artists":[{"id":"2FXyezR7e5yT2ZwTDFF5ps","name":"Kai Angel"}],"displayArtist":"Kai Angel","explicitStatus":"","version":""},{"id":"2vXmjndmMU8E7oetbOUw6c","name":"Deadhead","artist":"2kizz","artistId":"5yZjZwoBql4MIx8H6x5F1o","coverArt":"al-2vXmjndmMU8E7oetbOUw6c_6a9d405f","songCount":1,"duration":127,"created":"2026-09-06T10:28:52.9661036Z","year":2022,"userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"deadhead","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"5yZjZwoBql4MIx8H6x5F1o","name":"2kizz"}],"displayArtist":"2kizz","explicitStatus":"","version":""},{"id":"7BD7NzMVvGaEX25lg6OTEA","name":"DESIGNER","artist":"Sabu","artistId":"3IiXOaGUHA6ZHaW3IsWkm6","coverArt":"al-7BD7NzMVvGaEX25lg6OTEA_6a876795","songCount":1,"duration":99,"playCount":1,"created":"2026-08-20T05:12:32.079858766Z","played":"2026-08-29T18:05:52.852Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"designer","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"3IiXOaGUHA6ZHaW3IsWkm6","name":"Sabu"}],"displayArtist":"Sabu","explicitStatus":"","version":""},{"id":"67Lx1avwF5WkMIRwQCN2on","name":"DIOR SAUVAGE","artist":"дмитрий уткин","artistId":"7v0nmjPcxsKDPInhOlBnNs","coverArt":"al-67Lx1avwF5WkMIRwQCN2on_6ab13232","songCount":1,"duration":123,"created":"2026-09-21T13:33:43.30709994Z","year":2025,"userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"dior sauvage","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"7v0nmjPcxsKDPInhOlBnNs","name":"дмитрий уткин"}],"displayArtist":"дмитрий уткин","explicitStatus":"","version":""},{"id":"2Fk1fmJ95AavjttFLja08k","name":"Donda","artist":"Kanye West","artistId":"02DiEqiNBn3idZvY8XP1lr","coverArt":"al-2Fk1fmJ95AavjttFLja08k_6a87684a","songCount":1,"duration":538,"created":"2026-08-20T05:12:32.079858766Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"donda","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"02DiEqiNBn3idZvY8XP1lr","name":"Kanye West"}],"displayArtist":"Kanye West","explicitStatus":"","version":""},{"id":"53HdlbtXs9GLQu1b5tO1HD","name":"DONDA 2","artist":"DONDA","artistId":"66RsGWTEo8JPL8Nn4qbwJQ","coverArt":"al-53HdlbtXs9GLQu1b5tO1HD_6a87626d","songCount":7,"duration":956,"playCount":3,"created":"2026-08-20T05:12:32.079858766Z","played":"2026-09-09T11:50:29.157Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"donda 2","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"66RsGWTEo8JPL8Nn4qbwJQ","name":"DONDA"}],"displayArtist":"DONDA","explicitStatus":"","version":""},{"id":"33pPzfVcAIkROoLsCu1R3u","name":"doom","artist":"zavet","artistId":"2lzWXQ0Wd7RzG6RmnWUaTy","coverArt":"al-33pPzfVcAIkROoLsCu1R3u_6a87690a","songCount":3,"duration":409,"created":"2026-08-20T05:12:32.079858766Z","year":2022,"genre":"rusrap","userRating":0,"genres":[{"name":"rusrap"}],"musicBrainzId":"","isCompilation":false,"sortName":"doom","discTitles":[],"originalReleaseDate":{},"releaseDate":{"year":2022,"month":2,"day":18},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"2lzWXQ0Wd7RzG6RmnWUaTy","name":"zavet"}],"displayArtist":"zavet","explicitStatus":"","version":""},{"id":"3YTWcmOMJqEAwvMvAAiZLI","name":"Down Under (feat. Colin Hay)","artist":"Luude","artistId":"57W6fk5PWwj28VJSELDx94","coverArt":"al-3YTWcmOMJqEAwvMvAAiZLI_6a87697a","songCount":1,"duration":158,"playCount":1,"created":"2026-08-20T05:12:32.079858766Z","played":"2026-09-20T18:42:46.168920285Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"down under (feat. colin hay)","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"57W6fk5PWwj28VJSELDx94","name":"Luude"}],"displayArtist":"Luude","explicitStatus":"","version":""},{"id":"37teNqNtjqMJn7xycuDqOF","name":"Eureka","artist":"Kinoko Teikoku","artistId":"38OzEcIhEKGADUaQsERiuH","coverArt":"al-37teNqNtjqMJn7xycuDqOF_6a9b198f","songCount":9,"duration":2866,"created":"2026-09-04T19:23:07.21364707Z","year":2013,"genre":"Rock","userRating":0,"genres":[{"name":"Rock"}],"musicBrainzId":"","isCompilation":false,"sortName":"eureka","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"38OzEcIhEKGADUaQsERiuH","name":"Kinoko Teikoku"}],"displayArtist":"Kinoko Teikoku","explicitStatus":"","version":""},{"id":"0OH7ZqpaERshtiZ1kq8htk","name":"evil season","artist":"Рай не сегодня","artistId":"22qO7WPYmgKqChrt72BFOf","coverArt":"al-0OH7ZqpaERshtiZ1kq8htk_6a879eaf","songCount":6,"duration":778,"playCount":2,"created":"2026-08-20T05:12:32.079858766Z","year":2023,"played":"2026-09-09T14:39:36.295Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"evil season","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"22qO7WPYmgKqChrt72BFOf","name":"Рай не сегодня"}],"displayArtist":"Рай не сегодня","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":"0ByHUpsw9rkrVE7q1jOe0f","name":"Fever","artist":"Buckshot","artistId":"18kcqt8NLNywKHmIbv06pd","coverArt":"al-0ByHUpsw9rkrVE7q1jOe0f_6a876887","songCount":1,"duration":144,"created":"2026-08-20T05:12:32.079858766Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"fever","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"18kcqt8NLNywKHmIbv06pd","name":"Buckshot"}],"displayArtist":"Buckshot","explicitStatus":"","version":""},{"id":"1bwCBXcfP7vMFhXumxHLsG","name":"Finally We Are No One","artist":"múm","artistId":"3cByLCspfJAVaa8uIzhenz","coverArt":"al-1bwCBXcfP7vMFhXumxHLsG_6a9b19f2","songCount":11,"duration":3372,"created":"2026-09-04T19:23:07.21210133Z","year":2002,"genre":"Electronic","userRating":0,"genres":[{"name":"Electronic"}],"musicBrainzId":"","isCompilation":false,"sortName":"finally we are no one","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"3cByLCspfJAVaa8uIzhenz","name":"múm"}],"displayArtist":"múm","explicitStatus":"","version":""},{"id":"33eUMwzyNsVJZJ0uiAGK4G","name":"Folklore","artist":"AOKI,hayato \u0026 haruka nakamura","artistId":"1XA4JP1bkoWdQ3ou23UE6I","coverArt":"al-33eUMwzyNsVJZJ0uiAGK4G_6a9b1bf0","songCount":14,"duration":3635,"created":"2026-09-04T19:33:08.615204051Z","year":2015,"genre":"New Age","userRating":0,"genres":[{"name":"New Age"}],"musicBrainzId":"","isCompilation":false,"sortName":"folklore","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"1XA4JP1bkoWdQ3ou23UE6I","name":"AOKI,hayato \u0026 haruka nakamura"}],"displayArtist":"AOKI,hayato \u0026 haruka nakamura","explicitStatus":"","version":""},{"id":"4gAgobTSNRiA1zHzuXqqdG","name":"FunHouse Deluxe","artist":"prettifun","artistId":"6zHcoSPaK36t0UqjWUZRBG","coverArt":"al-4gAgobTSNRiA1zHzuXqqdG_6a9b1c66","songCount":15,"duration":2275,"created":"2026-09-04T19:33:08.603372289Z","year":2025,"genre":"Hip-Hop","userRating":0,"genres":[{"name":"Hip-Hop"},{"name":"Rap"}],"musicBrainzId":"","isCompilation":false,"sortName":"funhouse deluxe","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"6zHcoSPaK36t0UqjWUZRBG","name":"prettifun"}],"displayArtist":"prettifun","explicitStatus":"","version":""},{"id":"71UNypu8O3pmBNQ5ENvKOO","name":"GOD SYSTEM","artist":"Kai Angel","artistId":"2FXyezR7e5yT2ZwTDFF5ps","coverArt":"al-71UNypu8O3pmBNQ5ENvKOO_6ab1f0dd","songCount":8,"duration":1451,"created":"2026-08-20T05:12:32.079858766Z","year":2024,"genre":"Hip Hop","userRating":0,"genres":[{"name":"Hip Hop"},{"name":"Rap"}],"musicBrainzId":"","isCompilation":false,"sortName":"god system","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[{"name":"Koala Music"}],"moods":[],"artists":[{"id":"2FXyezR7e5yT2ZwTDFF5ps","name":"Kai Angel"}],"displayArtist":"Kai Angel","explicitStatus":"","version":""},{"id":"2CXX2cOzLpJU8g1SCvCIih","name":"GOD SYSTEM","artist":"Kai Angel","artistId":"2FXyezR7e5yT2ZwTDFF5ps","coverArt":"al-2CXX2cOzLpJU8g1SCvCIih_6a8769d7","songCount":16,"duration":1801,"playCount":9,"created":"2026-08-20T05:12:32.079858766Z","year":2024,"played":"2026-09-20T20:29:47.185742579Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"god system","discTitles":[],"originalReleaseDate":{},"releaseDate":{"year":2024},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"2FXyezR7e5yT2ZwTDFF5ps","name":"Kai Angel"}],"displayArtist":"Kai Angel","explicitStatus":"","version":""},{"id":"5HvuHkrMiz56Y4kDbY5G4f","name":"gonka","artist":"zavet","artistId":"2lzWXQ0Wd7RzG6RmnWUaTy","coverArt":"al-5HvuHkrMiz56Y4kDbY5G4f_624b3f26","songCount":1,"duration":172,"created":"2026-08-20T05:12:32.079858766Z","year":2020,"genre":"Hip-Hop","userRating":0,"genres":[{"name":"Hip-Hop"}],"musicBrainzId":"","isCompilation":false,"sortName":"gonka","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"2lzWXQ0Wd7RzG6RmnWUaTy","name":"zavet"}],"displayArtist":"zavet","explicitStatus":"","version":""},{"id":"6slPqfr6STcDdon0R2kNLq","name":"Goth Tv","artist":"zavet","artistId":"2lzWXQ0Wd7RzG6RmnWUaTy","coverArt":"al-6slPqfr6STcDdon0R2kNLq_6a8768bf","songCount":1,"duration":154,"created":"2026-08-20T20:53:22.418518582Z","year":2021,"genre":"Rap \u0026 Hip-Hop","userRating":0,"genres":[{"name":"Rap \u0026 Hip-Hop"}],"musicBrainzId":"","isCompilation":false,"sortName":"goth tv","discTitles":[],"originalReleaseDate":{},"releaseDate":{"year":2021},"releaseTypes":[],"recordLabels":[{"name":"Gentlepunks Inc."}],"moods":[],"artists":[{"id":"2lzWXQ0Wd7RzG6RmnWUaTy","name":"zavet"}],"displayArtist":"zavet","explicitStatus":"","version":""},{"id":"6zPv7YXLHLCumFhUfhlzcA","name":"gotica","artist":"zavet","artistId":"2lzWXQ0Wd7RzG6RmnWUaTy","coverArt":"al-6zPv7YXLHLCumFhUfhlzcA_6a876935","songCount":2,"duration":291,"created":"2026-08-20T05:12:32.079858766Z","year":2021,"genre":"zavet","userRating":0,"genres":[{"name":"zavet"}],"musicBrainzId":"","isCompilation":false,"sortName":"gotica","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"2lzWXQ0Wd7RzG6RmnWUaTy","name":"zavet"}],"displayArtist":"zavet","explicitStatus":"","version":""},{"id":"5Dd0CUcozlgLow3YP9hA5G","name":"gotika","artist":"zavet","artistId":"2lzWXQ0Wd7RzG6RmnWUaTy","coverArt":"al-5Dd0CUcozlgLow3YP9hA5G_6ab1f20a","songCount":24,"duration":3492,"playCount":3,"created":"2026-08-20T21:59:51.656001366Z","year":2021,"played":"2026-08-29T18:10:50.725Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"gotika","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"2lzWXQ0Wd7RzG6RmnWUaTy","name":"zavet"}],"displayArtist":"zavet","explicitStatus":"","version":""},{"id":"0GqBiOBTw8dhakC9BdSciz","name":"grace","artist":"haruka nakamura","artistId":"2Ks0H5m0Cn1LXTYEkfsPMD","coverArt":"al-0GqBiOBTw8dhakC9BdSciz_6a9b1b96","songCount":13,"duration":2829,"created":"2026-09-04T19:28:07.18815792Z","year":2008,"genre":"Electronic","userRating":0,"genres":[{"name":"Electronic"}],"musicBrainzId":"","isCompilation":false,"sortName":"grace","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"2Ks0H5m0Cn1LXTYEkfsPMD","name":"haruka nakamura"}],"displayArtist":"haruka nakamura","explicitStatus":"","version":""},{"id":"1UjMMlctWv78td4vD8SnzG","name":"Graduation","artist":"Kanye West","artistId":"02DiEqiNBn3idZvY8XP1lr","coverArt":"al-1UjMMlctWv78td4vD8SnzG_6a87686a","songCount":8,"duration":1757,"playCount":1,"created":"2026-08-20T05:12:32.079858766Z","played":"2026-08-29T18:40:38.136Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"graduation","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"02DiEqiNBn3idZvY8XP1lr","name":"Kanye West"}],"displayArtist":"Kanye West","explicitStatus":"","version":""},{"id":"5n2VvT8JJrDQHXE0azJ9lU","name":"HEAVY METAL","artist":"Kai Angel","artistId":"2FXyezR7e5yT2ZwTDFF5ps","coverArt":"al-5n2VvT8JJrDQHXE0azJ9lU_6a876a2c","songCount":2,"duration":243,"playCount":1,"created":"2026-08-20T05:12:32.079858766Z","year":2023,"played":"2026-08-29T18:38:44.603Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"heavy metal","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"2FXyezR7e5yT2ZwTDFF5ps","name":"Kai Angel"}],"displayArtist":"Kai Angel","explicitStatus":"","version":""},{"id":"4rwXDr40tGXadmkRmxw0rs","name":"HEAVY METAL","artist":"Kai Angel","artistId":"2FXyezR7e5yT2ZwTDFF5ps","coverArt":"al-4rwXDr40tGXadmkRmxw0rs_6a876a2f","songCount":6,"duration":821,"playCount":5,"created":"2026-08-20T05:12:32.079858766Z","year":2023,"played":"2026-09-20T20:48:40.643940095Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"heavy metal","discTitles":[],"originalReleaseDate":{},"releaseDate":{"year":2023},"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":"39GtiPI5sCFlyQCEYTZ14F","name":"HYPERPOP ATTACK","artist":"Midix","artistId":"6czjE6pPpWVuxiDRbLPGWi","coverArt":"al-39GtiPI5sCFlyQCEYTZ14F_6a876772","songCount":1,"duration":111,"playCount":1,"created":"2026-08-20T05:12:32.079858766Z","played":"2026-08-20T15:53:20.359747212Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"hyperpop attack","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"6czjE6pPpWVuxiDRbLPGWi","name":"Midix"}],"displayArtist":"Midix","explicitStatus":"","version":""},{"id":"2nAxeNQgr6m8fffWm0BtvD","name":"I Love Cats","artist":"never die","artistId":"1RlholhxfO8YebLSmsyb4o","coverArt":"al-2nAxeNQgr6m8fffWm0BtvD_6a8767b6","songCount":1,"duration":123,"created":"2026-08-20T05:12:32.079858766Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"i love cats","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"1RlholhxfO8YebLSmsyb4o","name":"never die"}],"displayArtist":"never die","explicitStatus":"","version":""},{"id":"3Fhvi1gsAXGwodRgAWRTXk","name":"ILD","artist":"Clonnex","artistId":"6nz4rI0viVYELtYOOEnXyE","coverArt":"al-3Fhvi1gsAXGwodRgAWRTXk_6a87694b","songCount":1,"duration":116,"created":"2026-08-20T05:12:32.079858766Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"ild","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"6nz4rI0viVYELtYOOEnXyE","name":"Clonnex"}],"displayArtist":"Clonnex","explicitStatus":"","version":""},{"id":"7rJgIxZlL7f9ZcoK3zoV3W","name":"in da club","artist":"дмитрий уткин","artistId":"7v0nmjPcxsKDPInhOlBnNs","coverArt":"al-7rJgIxZlL7f9ZcoK3zoV3W_6a8767de","songCount":1,"duration":114,"created":"2026-08-20T05:12:32.079858766Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"in da club","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"7v0nmjPcxsKDPInhOlBnNs","name":"дмитрий уткин"}],"displayArtist":"дмитрий уткин","explicitStatus":"","version":""},{"id":"1bxHCauxCjifZBClXd0Pwl","name":"Inspiration is DEAD","artist":"Ling tosite sigure","artistId":"02kw7dvTBVLsOPfd2Cm8oG","coverArt":"al-1bxHCauxCjifZBClXd0Pwl_6a9b19ca","songCount":9,"duration":2551,"playCount":2,"created":"2026-09-04T19:23:07.221015331Z","year":2007,"genre":"Rock","played":"2026-09-09T14:53:50.984Z","userRating":0,"genres":[{"name":"Rock"}],"musicBrainzId":"","isCompilation":false,"sortName":"inspiration is dead","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"02kw7dvTBVLsOPfd2Cm8oG","name":"Ling tosite sigure"}],"displayArtist":"Ling tosite sigure","explicitStatus":"","version":""},{"id":"2CZWHVgc91wZOwiSCM5GvF","name":"J is for Joon","artist":"Ms Gloom","artistId":"5dpDaE9Jn46Zsot7XgbuIQ","coverArt":"al-2CZWHVgc91wZOwiSCM5GvF_6a8767fc","songCount":1,"duration":143,"created":"2026-08-20T05:12:32.079858766Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"j is for joon","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"5dpDaE9Jn46Zsot7XgbuIQ","name":"Ms Gloom"}],"displayArtist":"Ms Gloom","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":"1LqsbVelF8547OhRNxDUqM","name":"JESUS IS KING","artist":"Kanye West","artistId":"02DiEqiNBn3idZvY8XP1lr","coverArt":"al-1LqsbVelF8547OhRNxDUqM_6a876850","songCount":1,"duration":49,"playCount":1,"created":"2026-08-20T05:12:32.079858766Z","played":"2026-08-20T15:14:14.30480148Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"jesus is king","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"02DiEqiNBn3idZvY8XP1lr","name":"Kanye West"}],"displayArtist":"Kanye West","explicitStatus":"","version":""},{"id":"0G6rfOLnjXpIsBxkkE6Qag","name":"kAiCoRe","artist":"Kai Angel","artistId":"2FXyezR7e5yT2ZwTDFF5ps","coverArt":"al-0G6rfOLnjXpIsBxkkE6Qag_6a8769e2","songCount":6,"duration":761,"playCount":3,"created":"2026-08-20T05:12:32.079858766Z","year":2022,"played":"2026-09-20T20:34:43.996262003Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"kaicore","discTitles":[],"originalReleaseDate":{},"releaseDate":{"year":2022},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"2FXyezR7e5yT2ZwTDFF5ps","name":"Kai Angel"}],"displayArtist":"Kai Angel","explicitStatus":"","version":""},{"id":"1IWVqdo1l5XO1b2Nd3EfB5","name":"Kaidan","artist":"zavet","artistId":"2lzWXQ0Wd7RzG6RmnWUaTy","coverArt":"al-1IWVqdo1l5XO1b2Nd3EfB5_6a8768d4","songCount":4,"duration":461,"playCount":11,"created":"2026-08-20T05:12:32.079858766Z","year":2019,"genre":"Hip-Hop","played":"2026-08-20T14:37:47.511990468Z","userRating":0,"genres":[{"name":"Hip-Hop"},{"name":"rusrap"}],"musicBrainzId":"","isCompilation":false,"sortName":"kaidan","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"2lzWXQ0Wd7RzG6RmnWUaTy","name":"zavet"}],"displayArtist":"zavet","explicitStatus":"","version":""},{"id":"0h8AKzwod7yMIk4hqvJHdu","name":"Kaidan","artist":"zavet","artistId":"2lzWXQ0Wd7RzG6RmnWUaTy","coverArt":"al-0h8AKzwod7yMIk4hqvJHdu_6a8768d2","songCount":15,"duration":2389,"playCount":2,"created":"2026-08-20T05:12:32.079858766Z","year":2019,"genre":"rusrap","played":"2026-09-09T15:03:51.291Z","userRating":0,"genres":[{"name":"rusrap"},{"name":"Hip Hop"},{"name":"Rap"}],"musicBrainzId":"","isCompilation":false,"sortName":"kaidan","discTitles":[],"originalReleaseDate":{},"releaseDate":{"year":2019,"month":5,"day":7},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"2lzWXQ0Wd7RzG6RmnWUaTy","name":"zavet"}],"displayArtist":"zavet","explicitStatus":"","version":""},{"id":"4tLsz2rB1AJfdP41v9rCre","name":"Life of a emorapstar","artist":"Sqwore","artistId":"2YnvHCogxcvJyosR2BCOvL","coverArt":"al-4tLsz2rB1AJfdP41v9rCre_6a876882","songCount":1,"duration":66,"created":"2026-08-20T05:12:32.079858766Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"life of a emorapstar","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"2YnvHCogxcvJyosR2BCOvL","name":"Sqwore"}],"displayArtist":"Sqwore","explicitStatus":"","version":""},{"id":"7eOkOfjh9EZY9ySwFfK2mJ","name":"The Lighthouse Project - EP","artist":"Amiina","artistId":"1X634Dwyeuc9aCScrj5S3x","coverArt":"al-7eOkOfjh9EZY9ySwFfK2mJ_6a9b19a3","songCount":6,"duration":1319,"created":"2026-09-04T19:23:07.209137691Z","year":2013,"genre":"Electronic","userRating":0,"genres":[{"name":"Electronic"}],"musicBrainzId":"","isCompilation":false,"sortName":"lighthouse project - ep","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"1X634Dwyeuc9aCScrj5S3x","name":"Amiina"}],"displayArtist":"Amiina","explicitStatus":"","version":""},{"id":"7ja23M5CVoEYhEV9q1LhL7","name":"Loserpop","artist":"New Sylveon","artistId":"1O3tH1OWBPZmewMIvjBKT0","coverArt":"al-7ja23M5CVoEYhEV9q1LhL7_6a876831","songCount":3,"duration":493,"playCount":1,"created":"2026-08-20T05:12:32.079858766Z","played":"2026-09-09T11:54:02.562Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"loserpop","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"1O3tH1OWBPZmewMIvjBKT0","name":"New Sylveon"}],"displayArtist":"New Sylveon","explicitStatus":"","version":""},{"id":"1Om8wpRcJB0uA0fy273cDQ","name":"Losing","artist":"never die","artistId":"1RlholhxfO8YebLSmsyb4o","coverArt":"al-1Om8wpRcJB0uA0fy273cDQ_6a8767b8","songCount":1,"duration":114,"created":"2026-08-20T05:12:32.079858766Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"losing","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"1RlholhxfO8YebLSmsyb4o","name":"never die"}],"displayArtist":"never die","explicitStatus":"","version":""},{"id":"53KTc6iUdPIrB79QvTwiyf","name":"LOST SOULS","artist":"dirty punk","artistId":"4923fQPHihzzaiU5iYnXJO","coverArt":"al-53KTc6iUdPIrB79QvTwiyf_6a87677f","songCount":1,"duration":118,"created":"2026-08-20T05:12:32.079858766Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"lost souls","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"4923fQPHihzzaiU5iYnXJO","name":"dirty punk"}],"displayArtist":"dirty punk","explicitStatus":"","version":""},{"id":"4TOguEDP18zmh0VrY74Nad","name":"LOV3 EXPLOR3R","artist":"MARSI","artistId":"4l5J0sD4TCoVAadVgxSyBP","coverArt":"al-4TOguEDP18zmh0VrY74Nad_6a876974","songCount":1,"duration":129,"playCount":3,"created":"2026-08-20T05:12:32.079858766Z","played":"2026-09-19T16:12:58.109103634Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"lov3 explor3r","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"4l5J0sD4TCoVAadVgxSyBP","name":"MARSI"}],"displayArtist":"MARSI","explicitStatus":"","version":""},{"id":"5UsHwogVLfZ9lG6Ez6YgMc","name":"Love is a Dog from Hell ","artist":"fakemink","artistId":"5TF4NqQ1jdxztyH9lXtlDc","coverArt":"al-5UsHwogVLfZ9lG6Ez6YgMc_6a876792","songCount":1,"duration":144,"created":"2026-08-20T20:09:34.172724896Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"love is a dog from hell","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"5TF4NqQ1jdxztyH9lXtlDc","name":"fakemink"}],"displayArtist":"fakemink","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":"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":"71WFq5Yx2OWJaokfgOOUqr","name":"Make Love","artist":"amekudeku","artistId":"5b63hpimhe6HfsDUjFYMN1","coverArt":"al-71WFq5Yx2OWJaokfgOOUqr_6a876978","songCount":1,"duration":142,"created":"2026-08-20T05:12:32.079858766Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"make love","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"5b63hpimhe6HfsDUjFYMN1","name":"amekudeku"}],"displayArtist":"amekudeku","explicitStatus":"","version":""},{"id":"6maGFUWCKXGpXDTL5aLPMi","name":"MARILYN MANSON","artist":"Kai Angel","artistId":"2FXyezR7e5yT2ZwTDFF5ps","coverArt":"al-6maGFUWCKXGpXDTL5aLPMi_6a876a20","songCount":11,"duration":1418,"playCount":11,"created":"2026-08-20T05:12:32.079858766Z","year":2023,"played":"2026-09-20T20:19:31.737411751Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"marilyn manson","discTitles":[],"originalReleaseDate":{},"releaseDate":{"year":2023},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"2FXyezR7e5yT2ZwTDFF5ps","name":"Kai Angel"}],"displayArtist":"Kai Angel","explicitStatus":"","version":""},{"id":"74XU7uj8aYdNVURXa6lqAi","name":"MEDIV Cult of Bayraktar","artist":"Clonnex","artistId":"6nz4rI0viVYELtYOOEnXyE","coverArt":"al-74XU7uj8aYdNVURXa6lqAi_6a87694c","songCount":1,"duration":120,"created":"2026-08-20T05:12:32.079858766Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"mediv cult of bayraktar","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"6nz4rI0viVYELtYOOEnXyE","name":"Clonnex"}],"displayArtist":"Clonnex","explicitStatus":"","version":""},{"id":"2HszDS9TcgJ7Y8aMmUzR9j","name":"Memories","artist":"LLLL","artistId":"53hR5FbRRAhVDnstybxtQP","coverArt":"al-2HszDS9TcgJ7Y8aMmUzR9j_6a876976","songCount":1,"duration":330,"created":"2026-08-20T05:12:32.079858766Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"memories","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"53hR5FbRRAhVDnstybxtQP","name":"LLLL"}],"displayArtist":"LLLL","explicitStatus":"","version":""},{"id":"3ipXOgLl9SuujGdudoyU89","name":"MOONCHILD","artist":"drowsyy","artistId":"2o9RENrzMtJnTO5701rfVU","coverArt":"al-3ipXOgLl9SuujGdudoyU89_6a8767fd","songCount":1,"duration":154,"playCount":1,"created":"2026-08-20T05:12:32.079858766Z","played":"2026-08-20T14:48:01.292563097Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"moonchild","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"2o9RENrzMtJnTO5701rfVU","name":"drowsyy"}],"displayArtist":"drowsyy","explicitStatus":"","version":""},{"id":"4769HW4vf62DAOCqgJdLIG","name":"Moth Avenue","artist":"New Sylveon","artistId":"1O3tH1OWBPZmewMIvjBKT0","coverArt":"al-4769HW4vf62DAOCqgJdLIG_6aa1a0c9","songCount":12,"duration":2072,"playCount":4,"created":"2026-08-20T05:12:32.079858766Z","year":2019,"played":"2026-08-29T19:57:55.839Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"moth avenue","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"1O3tH1OWBPZmewMIvjBKT0","name":"New Sylveon"}],"displayArtist":"New Sylveon","explicitStatus":"","version":""},{"id":"4uuJx6pn8DYxKmECy7hLR3","name":"My Beautiful Dark Twisted Fantasy","artist":"Kanye West","artistId":"02DiEqiNBn3idZvY8XP1lr","coverArt":"al-4uuJx6pn8DYxKmECy7hLR3_6a87687e","songCount":6,"duration":2208,"created":"2026-08-20T05:12:32.079858766Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"my beautiful dark twisted fantasy","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"02DiEqiNBn3idZvY8XP1lr","name":"Kanye West"}],"displayArtist":"Kanye West","explicitStatus":"","version":""},{"id":"1Hv53bcW5S6juKqxQHmFGd","name":"My Sweet Lullaby","artist":"zavet","artistId":"2lzWXQ0Wd7RzG6RmnWUaTy","coverArt":"al-1Hv53bcW5S6juKqxQHmFGd_6a876937","songCount":1,"duration":133,"playCount":1,"created":"2026-08-20T20:53:22.434014252Z","year":2021,"genre":"Rap \u0026 Hip-Hop","played":"2026-09-20T19:02:40.114352027Z","userRating":0,"genres":[{"name":"Rap \u0026 Hip-Hop"}],"musicBrainzId":"","isCompilation":false,"sortName":"my sweet lullaby","discTitles":[],"originalReleaseDate":{},"releaseDate":{"year":2021},"releaseTypes":[],"recordLabels":[{"name":"DNK Music"}],"moods":[],"artists":[{"id":"2lzWXQ0Wd7RzG6RmnWUaTy","name":"zavet"}],"displayArtist":"zavet","explicitStatus":"","version":""},{"id":"6h2zGoQk1hKXOlMVdAKCVp","name":"nights before gotika","artist":"zavet","artistId":"2lzWXQ0Wd7RzG6RmnWUaTy","coverArt":"al-6h2zGoQk1hKXOlMVdAKCVp_6a876933","songCount":5,"duration":664,"playCount":1,"created":"2026-08-20T05:12:32.079858766Z","year":2021,"genre":"Hip Hop","played":"2026-08-20T15:56:24.81922217Z","userRating":0,"genres":[{"name":"Hip Hop"},{"name":"Rap"}],"musicBrainzId":"","isCompilation":false,"sortName":"nights before gotika","discTitles":[],"originalReleaseDate":{},"releaseDate":{"year":2021},"releaseTypes":[],"recordLabels":[{"name":"DNK Music"}],"moods":[],"artists":[{"id":"2lzWXQ0Wd7RzG6RmnWUaTy","name":"zavet"}],"displayArtist":"zavet","explicitStatus":"explicit","version":""},{"id":"2j9BfBKLvSlIMYfTqTvs3X","name":"Nrg Drinq. Skool Soulja","artist":"New Sylveon","artistId":"1O3tH1OWBPZmewMIvjBKT0","coverArt":"al-2j9BfBKLvSlIMYfTqTvs3X_6a876833","songCount":2,"duration":283,"created":"2026-08-20T05:12:32.079858766Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"nrg drinq. skool soulja","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"1O3tH1OWBPZmewMIvjBKT0","name":"New Sylveon"}],"displayArtist":"New Sylveon","explicitStatus":"","version":""},{"id":"2lPsgAtiH4kNpDcdv72gbf","name":"Onlyfans Paradise","artist":"Валентин Дядька","artistId":"1ldo7ND3Xt2U5y9RwcNIOG","coverArt":"al-2lPsgAtiH4kNpDcdv72gbf_6a87688a","songCount":1,"duration":148,"created":"2026-08-20T05:12:32.079858766Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"onlyfans paradise","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"1ldo7ND3Xt2U5y9RwcNIOG","name":"Валентин Дядька"}],"displayArtist":"Валентин Дядька","explicitStatus":"","version":""},{"id":"3p8Pwh46Rdmh7UFqw2Jcbx","name":"Paranoia","artist":"zavet","artistId":"2lzWXQ0Wd7RzG6RmnWUaTy","coverArt":"al-3p8Pwh46Rdmh7UFqw2Jcbx_6a8768bf","songCount":1,"duration":152,"created":"2026-08-20T20:53:22.446132365Z","year":2020,"genre":"Rap \u0026 Hip-Hop","userRating":0,"genres":[{"name":"Rap \u0026 Hip-Hop"}],"musicBrainzId":"","isCompilation":false,"sortName":"paranoia","discTitles":[],"originalReleaseDate":{},"releaseDate":{"year":2020},"releaseTypes":[],"recordLabels":[{"name":"DNK Music"}],"moods":[],"artists":[{"id":"2lzWXQ0Wd7RzG6RmnWUaTy","name":"zavet"}],"displayArtist":"zavet","explicitStatus":"","version":""},{"id":"7xYr3b4HSLxAsuoMtESEo4","name":"Pretti Loves U 2","artist":"prettifun","artistId":"6zHcoSPaK36t0UqjWUZRBG","coverArt":"al-7xYr3b4HSLxAsuoMtESEo4_6a9b1c8f","songCount":12,"duration":2084,"playCount":1,"created":"2026-09-04T19:33:08.604065876Z","year":2026,"genre":"Hip-Hop","played":"2026-09-05T19:49:24.424Z","userRating":0,"genres":[{"name":"Hip-Hop"},{"name":"Rap"}],"musicBrainzId":"","isCompilation":false,"sortName":"pretti loves u 2","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"6zHcoSPaK36t0UqjWUZRBG","name":"prettifun"}],"displayArtist":"prettifun","explicitStatus":"","version":""},{"id":"51OmBhIYVMEQ2PBD79V11T","name":"Prince","artist":"44neverluv","artistId":"0xMsw5NccH5Syedj08drV9","coverArt":"al-51OmBhIYVMEQ2PBD79V11T_6a87694e","songCount":1,"duration":96,"created":"2026-08-20T05:12:32.079858766Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"prince","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"0xMsw5NccH5Syedj08drV9","name":"44neverluv"}],"displayArtist":"44neverluv","explicitStatus":"","version":""},{"id":"2b1Q6suDvamPL2w64Joy6S","name":"puga","artist":"zavet feat. Мак Сима Мгла","artistId":"2lzWXQ0Wd7RzG6RmnWUaTy","coverArt":"al-2b1Q6suDvamPL2w64Joy6S_6a8768bf","songCount":1,"duration":152,"created":"2026-08-20T20:53:22.893035882Z","year":2019,"genre":"Hip Hop","userRating":0,"genres":[{"name":"Hip Hop"},{"name":"Rap"}],"musicBrainzId":"","isCompilation":false,"sortName":"puga","discTitles":[],"originalReleaseDate":{},"releaseDate":{"year":2019},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"2lzWXQ0Wd7RzG6RmnWUaTy","name":"zavet"},{"id":"479vtVFOv5EM4T2i8TNURF","name":"Мак Сима Мгла"}],"displayArtist":"zavet feat. Мак Сима Мгла","explicitStatus":"","version":""},{"id":"2aw2X3EwqZbDtsQOWilcXz","name":"puga","artist":"Мак Сима Мгла,zavet","artistId":"6YYriejN9jz0ThCkxVXJNC","coverArt":"al-2aw2X3EwqZbDtsQOWilcXz_6a8768bf","songCount":2,"duration":305,"created":"2026-08-20T05:12:32.079858766Z","year":2019,"genre":"Hip-Hop","userRating":0,"genres":[{"name":"Hip-Hop"},{"name":"rusrap"}],"musicBrainzId":"","isCompilation":false,"sortName":"puga","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"6YYriejN9jz0ThCkxVXJNC","name":"Мак Сима Мгла,zavet"}],"displayArtist":"Мак Сима Мгла,zavet","explicitStatus":"","version":""},{"id":"35GVfLF0ityuyeaif6xymd","name":"punkthed","artist":"rizza","artistId":"5PjcH8aQAlFrfWqvOnqua5","coverArt":"al-35GVfLF0ityuyeaif6xymd_6a876842","songCount":1,"duration":122,"created":"2026-08-20T05:12:32.079858766Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"punkthed","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"5PjcH8aQAlFrfWqvOnqua5","name":"rizza"}],"displayArtist":"rizza","explicitStatus":"","version":""},{"id":"18qdHf0P0f6ZPEgYXlw6Ro","name":"Pushin Ї","artist":"Clonnex","artistId":"6nz4rI0viVYELtYOOEnXyE","coverArt":"al-18qdHf0P0f6ZPEgYXlw6Ro_6a876946","songCount":1,"duration":128,"created":"2026-08-20T05:12:32.079858766Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"pushin ї","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"6nz4rI0viVYELtYOOEnXyE","name":"Clonnex"}],"displayArtist":"Clonnex","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":"0OwrYJ26gc7glVBvn2Mk7y","name":"RATT#WHORE","artist":"Poshlaya Molly","artistId":"4Ks5djIxGF54hZy8r6X1fc","coverArt":"al-0OwrYJ26gc7glVBvn2Mk7y_6a876a47","songCount":2,"duration":354,"created":"2026-08-20T05:12:32.079858766Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"ratt#whore","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"4Ks5djIxGF54hZy8r6X1fc","name":"Poshlaya Molly"}],"displayArtist":"Poshlaya Molly","explicitStatus":"","version":""},{"id":"0gtnrBD9efouc2JW502mE9","name":"Resonance Mirage Expariment","artist":"zavet","artistId":"2lzWXQ0Wd7RzG6RmnWUaTy","coverArt":"al-0gtnrBD9efouc2JW502mE9_624b3f28","songCount":1,"duration":99,"created":"2026-08-20T05:12:32.079858766Z","year":2021,"genre":"Hip-Hop","userRating":0,"genres":[{"name":"Hip-Hop"}],"musicBrainzId":"","isCompilation":false,"sortName":"resonance mirage expariment","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"2lzWXQ0Wd7RzG6RmnWUaTy","name":"zavet"}],"displayArtist":"zavet","explicitStatus":"","version":""},{"id":"2YzTvKn3whO7WPo57g2leQ","name":"senseless","artist":"evan wheel","artistId":"0GEuFay3P1f4WhApEF2uOE","coverArt":"al-2YzTvKn3whO7WPo57g2leQ_6a876794","songCount":1,"duration":144,"created":"2026-08-20T05:12:32.079858766Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"senseless","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"0GEuFay3P1f4WhApEF2uOE","name":"evan wheel"}],"displayArtist":"evan wheel","explicitStatus":"","version":""},{"id":"0PJdY4KSq7HhCmjMPPeMjL","name":"Separate Century","artist":"AokigaharaRN","artistId":"7byFxTas9oTmNJEpZpkady","coverArt":"al-0PJdY4KSq7HhCmjMPPeMjL_6a872218","songCount":1,"duration":132,"created":"2026-08-20T05:12:32.079858766Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"separate century","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"7byFxTas9oTmNJEpZpkady","name":"AokigaharaRN"}],"displayArtist":"AokigaharaRN","explicitStatus":"","version":""},{"id":"6VaeyE9cIFMCImpXNGPIZ5","name":"sexyswag","artist":"madk1d","artistId":"7Dy79oui9xLw7S5an2x0nk","coverArt":"al-6VaeyE9cIFMCImpXNGPIZ5_6a876891","songCount":2,"duration":228,"playCount":2,"created":"2026-08-20T05:12:32.079858766Z","played":"2026-08-29T18:29:35.951Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"sexyswag","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"7Dy79oui9xLw7S5an2x0nk","name":"madk1d"}],"displayArtist":"madk1d","explicitStatus":"","version":""},{"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":""},{"id":"6kyJrHqHmnC3LxWtldP4tZ","name":"Shh...","artist":"Kai Angel","artistId":"2FXyezR7e5yT2ZwTDFF5ps","coverArt":"al-6kyJrHqHmnC3LxWtldP4tZ_6aa46d8e","songCount":14,"duration":2421,"playCount":67,"created":"2026-09-03T21:09:56.10902198Z","year":2026,"genre":"Hip-Hop","played":"2026-09-20T21:18:16.949825983Z","userRating":0,"genres":[{"name":"Hip-Hop"},{"name":"Rap"}],"musicBrainzId":"","isCompilation":false,"sortName":"shh...","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[{"name":"Angel"}],"moods":[],"artists":[{"id":"2FXyezR7e5yT2ZwTDFF5ps","name":"Kai Angel"}],"displayArtist":"Kai Angel","explicitStatus":"","version":""},{"id":"5rSF3jmsJxqnVsaowkcPkG","name":"Skitzofrenia Simulation","artist":"Sewerslvt","artistId":"5piJT3LK4juV6RpcUzq6rG","coverArt":"al-5rSF3jmsJxqnVsaowkcPkG_6a9b1969","songCount":14,"duration":4127,"created":"2026-09-04T19:23:07.50355141Z","year":2021,"genre":"Drum'n'bass","userRating":0,"genres":[{"name":"Drum'n'bass"},{"name":"Jungle"}],"musicBrainzId":"","isCompilation":false,"sortName":"skitzofrenia simulation","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"5piJT3LK4juV6RpcUzq6rG","name":"Sewerslvt"}],"displayArtist":"Sewerslvt","explicitStatus":"","version":""},{"id":"3uWboQieNC9cziOAHIp8aj","name":"snuff video","artist":"never die","artistId":"1RlholhxfO8YebLSmsyb4o","coverArt":"al-3uWboQieNC9cziOAHIp8aj_6a8767b1","songCount":8,"duration":830,"playCount":3,"created":"2026-08-20T05:12:32.079858766Z","played":"2026-08-29T20:42:26.974Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"snuff video","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"1RlholhxfO8YebLSmsyb4o","name":"never die"}],"displayArtist":"never die","explicitStatus":"","version":""},{"id":"600akydhU2UyeeQ8HSnSjM","name":"ssdasfdsdfhjgkdsfd","artist":"zavet","artistId":"2lzWXQ0Wd7RzG6RmnWUaTy","coverArt":"al-600akydhU2UyeeQ8HSnSjM_6a871917","songCount":1,"duration":140,"playCount":1,"created":"2026-08-20T05:12:32.079858766Z","year":2021,"genre":"Hip-Hop","played":"2026-08-20T15:26:22.694036212Z","userRating":0,"genres":[{"name":"Hip-Hop"}],"musicBrainzId":"","isCompilation":false,"sortName":"ssdasfdsdfhjgkdsfd","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"2lzWXQ0Wd7RzG6RmnWUaTy","name":"zavet"}],"displayArtist":"zavet","explicitStatus":"","version":""},{"id":"1aDxSTqieY4mKtiLXuxV5K","name":"Summer Make Good","artist":"múm","artistId":"3cByLCspfJAVaa8uIzhenz","coverArt":"al-1aDxSTqieY4mKtiLXuxV5K_6a9b1c43","songCount":12,"duration":2814,"playCount":1,"created":"2026-09-04T19:33:08.601874736Z","year":2004,"genre":"Electronic","played":"2026-09-05T19:51:16.131Z","userRating":0,"genres":[{"name":"Electronic"}],"musicBrainzId":"","isCompilation":false,"sortName":"summer make good","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"3cByLCspfJAVaa8uIzhenz","name":"múm"}],"displayArtist":"múm","explicitStatus":"","version":""},{"id":"5yUCrKalgOcMsrs5l7ZOG7","name":"tears under california highway","artist":"zavet","artistId":"2lzWXQ0Wd7RzG6RmnWUaTy","coverArt":"al-5yUCrKalgOcMsrs5l7ZOG7_6a8761ef","songCount":1,"duration":100,"created":"2026-08-20T05:12:32.079858766Z","year":2020,"genre":"Hip-Hop","userRating":0,"genres":[{"name":"Hip-Hop"}],"musicBrainzId":"","isCompilation":false,"sortName":"tears under california highway","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"2lzWXQ0Wd7RzG6RmnWUaTy","name":"zavet"}],"displayArtist":"zavet","explicitStatus":"","version":""},{"id":"6WHDjhyQVel3r1GqtVeBYE","name":"tears under seoul highway","artist":"zavet","artistId":"2lzWXQ0Wd7RzG6RmnWUaTy","coverArt":"al-6WHDjhyQVel3r1GqtVeBYE_6a8761f0","songCount":1,"duration":204,"created":"2026-08-20T05:12:32.079858766Z","year":2020,"genre":"Hip-Hop","userRating":0,"genres":[{"name":"Hip-Hop"}],"musicBrainzId":"","isCompilation":false,"sortName":"tears under seoul highway","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"2lzWXQ0Wd7RzG6RmnWUaTy","name":"zavet"}],"displayArtist":"zavet","explicitStatus":"","version":""},{"id":"1h46DoevHRJP4Or8LlOMB8","name":"Toreshi","artist":"woee33","artistId":"0y7OcittSJlSLz3U183RBX","coverArt":"al-1h46DoevHRJP4Or8LlOMB8_6a876797","songCount":1,"duration":146,"playCount":1,"created":"2026-08-20T05:12:32.079858766Z","played":"2026-09-09T14:47:49.753Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"toreshi","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"0y7OcittSJlSLz3U183RBX","name":"woee33"}],"displayArtist":"woee33","explicitStatus":"","version":""},{"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":"65yYjo20QATKvh9qKGw5Rm","name":"True Love","artist":"Kanye West","artistId":"02DiEqiNBn3idZvY8XP1lr","coverArt":"al-65yYjo20QATKvh9qKGw5Rm_6a876853","songCount":1,"duration":148,"created":"2026-08-20T05:12:32.079858766Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"true love","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"02DiEqiNBn3idZvY8XP1lr","name":"Kanye West"}],"displayArtist":"Kanye West","explicitStatus":"","version":""},{"id":"3otC7fKAAsay3Ii5eQoKhG","name":"Turn On the Bright Lights","artist":"Interpol","artistId":"2LN5WRdGr6YhvwBw5LIPz9","coverArt":"al-3otC7fKAAsay3Ii5eQoKhG_6a9b1a9e","songCount":11,"duration":2939,"playCount":1,"created":"2026-09-04T19:28:07.185873586Z","year":2002,"genre":"Alternative","played":"2026-09-05T19:43:27.563Z","userRating":0,"genres":[{"name":"Alternative"}],"musicBrainzId":"","isCompilation":false,"sortName":"turn on the bright lights","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"2LN5WRdGr6YhvwBw5LIPz9","name":"Interpol"}],"displayArtist":"Interpol","explicitStatus":"","version":""},{"id":"3jxXu6ohDgn7p8GUaxn9bD","name":"Tweaker","artist":"рай не сегодня","artistId":"22qO7WPYmgKqChrt72BFOf","coverArt":"al-3jxXu6ohDgn7p8GUaxn9bD_6ab1ee8b","songCount":12,"duration":1637,"playCount":27,"created":"2026-08-21T01:01:41.955581177Z","year":2021,"genre":"Pop","played":"2026-09-18T20:11:13.047Z","userRating":0,"genres":[{"name":"Pop"}],"musicBrainzId":"","isCompilation":false,"sortName":"tweaker","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"22qO7WPYmgKqChrt72BFOf","name":"Рай не сегодня"}],"displayArtist":"рай не сегодня","explicitStatus":"","version":""},{"id":"1aFCALy4AiEAFVrh1fp2VB","name":"Twilight","artist":"haruka nakamura","artistId":"2Ks0H5m0Cn1LXTYEkfsPMD","coverArt":"al-1aFCALy4AiEAFVrh1fp2VB_6a9b1bc0","songCount":13,"duration":3134,"created":"2026-09-04T19:28:07.185066673Z","year":2010,"genre":"Ambient","userRating":0,"genres":[{"name":"Ambient"}],"musicBrainzId":"","isCompilation":false,"sortName":"twilight","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"2Ks0H5m0Cn1LXTYEkfsPMD","name":"haruka nakamura"}],"displayArtist":"haruka nakamura","explicitStatus":"","version":""},{"id":"5miMAkUxceByxH1MbxYPI3","name":"twilight (Deluxe Edition)","artist":"haruka nakamura","artistId":"2Ks0H5m0Cn1LXTYEkfsPMD","coverArt":"al-5miMAkUxceByxH1MbxYPI3_6a9b1ccf","songCount":15,"duration":3125,"created":"2026-09-04T19:33:08.671399677Z","year":2010,"genre":"Ambient","userRating":0,"genres":[{"name":"Ambient"}],"musicBrainzId":"","isCompilation":false,"sortName":"twilight (deluxe edition)","discTitles":[{"disc":1,"title":"","coverArt":"dc-5miMAkUxceByxH1MbxYPI3:1_6a9b1ccf"},{"disc":2,"title":"","coverArt":"dc-5miMAkUxceByxH1MbxYPI3:2_6a9b1ccf"}],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"2Ks0H5m0Cn1LXTYEkfsPMD","name":"haruka nakamura"}],"displayArtist":"haruka nakamura","explicitStatus":"","version":""},{"id":"3fH290Q3trEOiVZZKF7Y6W","name":"UGLY STYLEZZ","artist":"VELIAL SQUAD","artistId":"00VCVR5fmexJwulmPR74Nu","coverArt":"al-3fH290Q3trEOiVZZKF7Y6W_6a87694f","songCount":1,"duration":140,"playCount":1,"created":"2026-08-20T05:12:32.079858766Z","year":2021,"genre":"Hip Hop","played":"2026-08-29T20:46:32.271Z","userRating":0,"genres":[{"name":"Hip Hop"},{"name":"Rap"}],"musicBrainzId":"","isCompilation":false,"sortName":"ugly stylezz","discTitles":[],"originalReleaseDate":{},"releaseDate":{"year":2021,"month":10,"day":15},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"00VCVR5fmexJwulmPR74Nu","name":"VELIAL SQUAD"}],"displayArtist":"VELIAL SQUAD","explicitStatus":"","version":""},{"id":"15HKL8DeO5o6fh2tqJXUvC","name":"ULTRA NEW ERA MUTANT DIVING","artist":"New Sylveon","artistId":"1O3tH1OWBPZmewMIvjBKT0","coverArt":"al-15HKL8DeO5o6fh2tqJXUvC_6a876826","songCount":7,"duration":1159,"playCount":1,"created":"2026-08-20T05:12:32.079858766Z","played":"2026-08-25T16:36:22.953Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"ultra new era mutant diving","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"1O3tH1OWBPZmewMIvjBKT0","name":"New Sylveon"}],"displayArtist":"New Sylveon","explicitStatus":"","version":""},{"id":"0MEHAbwLYvF4XOy6EAclZi","name":"Unmusique","artist":"Lucy Bedroque","artistId":"3w3c8gSvt9jfHJymkUCjNV","coverArt":"al-0MEHAbwLYvF4XOy6EAclZi_6a9b1a4d","songCount":17,"duration":2403,"playCount":1,"created":"2026-09-04T19:23:07.285608926Z","year":2025,"genre":"Hip-Hop","played":"2026-09-09T15:01:24.237Z","userRating":0,"genres":[{"name":"Hip-Hop"},{"name":"Rap"}],"musicBrainzId":"","isCompilation":false,"sortName":"unmusique","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"3w3c8gSvt9jfHJymkUCjNV","name":"Lucy Bedroque"}],"displayArtist":"Lucy Bedroque","explicitStatus":"","version":""},{"id":"5neEYJzJWTFcerR7aOQAe9","name":"Untrue","artist":"Burial","artistId":"0f6qFzlx5zzeRbsdreHE90","coverArt":"al-5neEYJzJWTFcerR7aOQAe9_6a9b1a74","songCount":13,"duration":3054,"playCount":1,"created":"2026-09-04T19:23:07.216263161Z","year":2007,"genre":"Electronic","played":"2026-09-09T14:59:18.374Z","userRating":0,"genres":[{"name":"Electronic"}],"musicBrainzId":"","isCompilation":false,"sortName":"untrue","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"0f6qFzlx5zzeRbsdreHE90","name":"Burial"}],"displayArtist":"Burial","explicitStatus":"","version":""},{"id":"5RNTuHRxhPDyuQ0cNSbci8","name":"velvet heaven","artist":"zavet","artistId":"2lzWXQ0Wd7RzG6RmnWUaTy","coverArt":"al-5RNTuHRxhPDyuQ0cNSbci8_6ab1ee8b","songCount":5,"duration":735,"playCount":10,"created":"2026-08-20T05:12:32.079858766Z","year":2020,"genre":"rusrap","played":"2026-08-20T14:04:27.117Z","userRating":0,"genres":[{"name":"rusrap"}],"musicBrainzId":"","isCompilation":false,"sortName":"velvet heaven","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"2lzWXQ0Wd7RzG6RmnWUaTy","name":"zavet"}],"displayArtist":"zavet","explicitStatus":"","version":""},{"id":"4nVuime1UXpOtoUU6BZBg2","name":"velvet heaven","artist":"zavet","artistId":"2lzWXQ0Wd7RzG6RmnWUaTy","coverArt":"al-4nVuime1UXpOtoUU6BZBg2_6a87692a","songCount":2,"duration":296,"playCount":10,"created":"2026-08-20T05:12:32.079858766Z","year":2020,"genre":"Hip Hop","played":"2026-08-20T14:04:27.117Z","userRating":0,"genres":[{"name":"Hip Hop"},{"name":"Rap"}],"musicBrainzId":"","isCompilation":false,"sortName":"velvet heaven","discTitles":[],"originalReleaseDate":{},"releaseDate":{"year":2020},"releaseTypes":[],"recordLabels":[{"name":"DNK Music"}],"moods":[],"artists":[{"id":"2lzWXQ0Wd7RzG6RmnWUaTy","name":"zavet"}],"displayArtist":"zavet","explicitStatus":"","version":""},{"id":"1KSxgpZEFsweiT1PMR03zG","name":"Velvet Heaven","artist":"zavet","artistId":"2lzWXQ0Wd7RzG6RmnWUaTy","coverArt":"al-1KSxgpZEFsweiT1PMR03zG_6a877b45","songCount":9,"duration":1333,"playCount":5,"created":"2026-08-20T22:09:56.015208721Z","year":2020,"genre":"Hip Hop","played":"2026-09-20T19:00:15.294238608Z","userRating":0,"genres":[{"name":"Hip Hop"},{"name":"Rap"}],"musicBrainzId":"9b8b9f32-32cf-4073-947c-0011e49df06a","isCompilation":false,"sortName":"velvet heaven","discTitles":[],"originalReleaseDate":{"year":2020,"month":7,"day":3},"releaseDate":{"year":2020},"releaseTypes":["album"],"recordLabels":[{"name":"DNK Music"}],"moods":[],"artists":[{"id":"2lzWXQ0Wd7RzG6RmnWUaTy","name":"zavet"}],"displayArtist":"zavet","explicitStatus":"explicit","version":""},{"id":"5BxVkgnRBrP4OhuPqAGTNU","name":"VULTURES 1","artist":"¥$","artistId":"5JkMqHkP41lzJw8CoWNGxr","coverArt":"al-5BxVkgnRBrP4OhuPqAGTNU_6a871db9","songCount":7,"duration":1450,"playCount":1,"created":"2026-08-20T05:12:32.079858766Z","played":"2026-08-29T18:45:28.17Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"vultures 1","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"5JkMqHkP41lzJw8CoWNGxr","name":"¥$"}],"displayArtist":"¥$","explicitStatus":"","version":""},{"id":"16R67sTsgakEr8CLdrlrJx","name":"VULTURES 2","artist":"¥$","artistId":"5JkMqHkP41lzJw8CoWNGxr","coverArt":"al-16R67sTsgakEr8CLdrlrJx_6a871dbe","songCount":9,"duration":1729,"playCount":2,"created":"2026-08-20T05:12:32.079858766Z","played":"2026-09-09T19:12:57.571Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"vultures 2","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"5JkMqHkP41lzJw8CoWNGxr","name":"¥$"}],"displayArtist":"¥$","explicitStatus":"","version":""},{"id":"5mNROofQRwHffgs92Ln4R7","name":"witchpop","artist":"siouxxie sixxsta","artistId":"3mWJRO3PGE7pw8BQmNEhun","coverArt":"al-5mNROofQRwHffgs92Ln4R7_6a87688c","songCount":1,"duration":125,"created":"2026-08-20T05:12:32.079858766Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"witchpop","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"3mWJRO3PGE7pw8BQmNEhun","name":"siouxxie sixxsta"}],"displayArtist":"siouxxie sixxsta","explicitStatus":"","version":""},{"id":"6cjWYtQW4KZA5iQYMatUv5","name":"Y’all","artist":"glaive","artistId":"6Atjc7fhemv7XpDcN1Eg2R","coverArt":"al-6cjWYtQW4KZA5iQYMatUv5_6a8767f4","songCount":12,"duration":1657,"playCount":2,"created":"2026-08-20T05:12:32.079858766Z","played":"2026-08-20T15:23:59.356461226Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"y'all","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"6Atjc7fhemv7XpDcN1Eg2R","name":"glaive"}],"displayArtist":"glaive","explicitStatus":"","version":""},{"id":"5UyFsOYWVBPvgbgNFH3Yta","name":"yjdsqqfkm,jv123","artist":"New Sylveon","artistId":"1O3tH1OWBPZmewMIvjBKT0","coverArt":"al-5UyFsOYWVBPvgbgNFH3Yta_6a87682a","songCount":3,"duration":442,"playCount":1,"created":"2026-08-20T05:12:32.079858766Z","played":"2026-08-20T15:21:25.422739899Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"yjdsqqfkm,jv123","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"1O3tH1OWBPZmewMIvjBKT0","name":"New Sylveon"}],"displayArtist":"New Sylveon","explicitStatus":"","version":""},{"id":"1mY02Tj7edugHyNRpl28uV","name":"ZVER","artist":"New Sylveon","artistId":"1O3tH1OWBPZmewMIvjBKT0","coverArt":"al-1mY02Tj7edugHyNRpl28uV_6aa259b9","songCount":7,"duration":1053,"playCount":1,"created":"2026-08-20T05:12:32.079858766Z","year":2021,"played":"2026-09-09T11:47:36.896Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"zver","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"1O3tH1OWBPZmewMIvjBKT0","name":"New Sylveon"}],"displayArtist":"New Sylveon","explicitStatus":"","version":""},{"id":"6Of9o0Fh12lxKSj9JYNFS6","name":"ZVER","artist":"New Sylveon","artistId":"1O3tH1OWBPZmewMIvjBKT0","coverArt":"al-6Of9o0Fh12lxKSj9JYNFS6_6a87683e","songCount":1,"duration":140,"playCount":13,"created":"2026-08-20T05:12:32.079858766Z","year":2021,"genre":"Rap \u0026 Hip-Hop","played":"2026-08-20T14:37:47.511990468Z","userRating":0,"genres":[{"name":"Rap \u0026 Hip-Hop"}],"musicBrainzId":"","isCompilation":false,"sortName":"zver","discTitles":[],"originalReleaseDate":{},"releaseDate":{"year":2021},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"1O3tH1OWBPZmewMIvjBKT0","name":"New Sylveon"}],"displayArtist":"New Sylveon","explicitStatus":"","version":""},{"id":"0A0r5xk1xDAEDDqF2YJjEM","name":"zz p2","artist":"zavet","artistId":"2lzWXQ0Wd7RzG6RmnWUaTy","coverArt":"al-0A0r5xk1xDAEDDqF2YJjEM_6a8761f2","songCount":1,"duration":133,"playCount":1,"created":"2026-08-20T05:12:32.079858766Z","year":2019,"genre":"Hip-Hop","played":"2026-09-20T19:21:46.070370578Z","userRating":0,"genres":[{"name":"Hip-Hop"}],"musicBrainzId":"","isCompilation":false,"sortName":"zz p2","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"2lzWXQ0Wd7RzG6RmnWUaTy","name":"zavet"}],"displayArtist":"zavet","explicitStatus":"","version":""},{"id":"74kdpRYrB0GcjQbuV16gYn","name":"АЙАН","artist":"IAN HOPELESS","artistId":"2yeddrgIGN77Xyj2rWXWs3","coverArt":"al-74kdpRYrB0GcjQbuV16gYn_6a876781","songCount":1,"duration":185,"playCount":13,"created":"2026-08-20T05:12:32.079858766Z","year":2020,"genre":"rusrap","played":"2026-08-20T14:37:47.511990468Z","userRating":0,"genres":[{"name":"rusrap"}],"musicBrainzId":"","isCompilation":false,"sortName":"айан","discTitles":[],"originalReleaseDate":{},"releaseDate":{"year":2020,"month":10,"day":30},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"2yeddrgIGN77Xyj2rWXWs3","name":"IAN HOPELESS"}],"displayArtist":"IAN HOPELESS","explicitStatus":"","version":""},{"id":"2BUwWVdBGIcr6uVyZ0nKZP","name":"АЙАН","artist":"IAN HOPELESS","artistId":"2yeddrgIGN77Xyj2rWXWs3","coverArt":"al-2BUwWVdBGIcr6uVyZ0nKZP_6a87651c","songCount":1,"duration":185,"created":"2026-08-20T05:12:32.079858766Z","year":2020,"genre":"Hip-Hop","userRating":0,"genres":[{"name":"Hip-Hop"}],"musicBrainzId":"","isCompilation":false,"sortName":"айан","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"2yeddrgIGN77Xyj2rWXWs3","name":"IAN HOPELESS"}],"displayArtist":"IAN HOPELESS","explicitStatus":"","version":""},{"id":"3dsGFVhgROauM0TeafJgM8","name":"айпад кид","artist":"never die","artistId":"1RlholhxfO8YebLSmsyb4o","coverArt":"al-3dsGFVhgROauM0TeafJgM8_6a8767bc","songCount":1,"duration":115,"created":"2026-08-20T05:12:32.079858766Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"айпад кид","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"1RlholhxfO8YebLSmsyb4o","name":"never die"}],"displayArtist":"never die","explicitStatus":"","version":""},{"id":"3OZC1BxjHaPgxM8jTJnEuz","name":"Ангельское True","artist":"Slava KPSS","artistId":"3pBe8GsWJqYRUeKVzVAXc7","coverArt":"al-3OZC1BxjHaPgxM8jTJnEuz_6a87678d","songCount":8,"duration":1617,"created":"2026-08-20T05:12:32.079858766Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"ангельское true","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"3pBe8GsWJqYRUeKVzVAXc7","name":"Slava KPSS"}],"displayArtist":"Slava KPSS","explicitStatus":"","version":""},{"id":"1dkqmVbXnIZUOQr0E9Lqlj","name":"Аномальная Персона","artist":"New Sylveon","artistId":"1O3tH1OWBPZmewMIvjBKT0","coverArt":"al-1dkqmVbXnIZUOQr0E9Lqlj_6aa19ff4","songCount":1,"duration":74,"created":"2026-09-09T18:05:45.358954103Z","year":2020,"userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"аномальная персона","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"1O3tH1OWBPZmewMIvjBKT0","name":"New Sylveon"}],"displayArtist":"New Sylveon","explicitStatus":"","version":""},{"id":"4Tvn9XklIjDAXjJLfPbkAL","name":"Атана","artist":"zavet","artistId":"2lzWXQ0Wd7RzG6RmnWUaTy","coverArt":"al-4Tvn9XklIjDAXjJLfPbkAL_6a87690b","songCount":1,"duration":28,"playCount":12,"created":"2026-08-20T05:12:32.079858766Z","year":2019,"genre":"alternative","played":"2026-08-20T14:04:27.117Z","userRating":0,"genres":[{"name":"alternative"}],"musicBrainzId":"","isCompilation":false,"sortName":"атана","discTitles":[],"originalReleaseDate":{},"releaseDate":{"year":2024,"month":3,"day":25},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"2lzWXQ0Wd7RzG6RmnWUaTy","name":"zavet"}],"displayArtist":"zavet","explicitStatus":"","version":""},{"id":"18jXBF5TwumfzTGqRIpuQ8","name":"Атана","artist":"zavet","artistId":"2lzWXQ0Wd7RzG6RmnWUaTy","coverArt":"al-18jXBF5TwumfzTGqRIpuQ8_6a876911","songCount":2,"duration":261,"playCount":1,"created":"2026-08-20T05:12:32.079858766Z","year":2019,"genre":"Hip-Hop","played":"2026-09-20T19:19:01.864342992Z","userRating":0,"genres":[{"name":"Hip-Hop"},{"name":"alternative"}],"musicBrainzId":"","isCompilation":false,"sortName":"атана","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"2lzWXQ0Wd7RzG6RmnWUaTy","name":"zavet"}],"displayArtist":"zavet","explicitStatus":"","version":""},{"id":"3rU4l89oMqIVWOg8IisA5W","name":"Атана","artist":"zavet","artistId":"2lzWXQ0Wd7RzG6RmnWUaTy","coverArt":"al-3rU4l89oMqIVWOg8IisA5W_6a876919","songCount":8,"duration":1025,"playCount":15,"created":"2026-08-20T05:12:32.079858766Z","year":2019,"genre":"Hip Hop","played":"2026-08-29T18:07:04.094Z","userRating":0,"genres":[{"name":"Hip Hop"},{"name":"Rap"}],"musicBrainzId":"","isCompilation":false,"sortName":"атана","discTitles":[],"originalReleaseDate":{},"releaseDate":{"year":2024,"month":3,"day":24},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"2lzWXQ0Wd7RzG6RmnWUaTy","name":"zavet"}],"displayArtist":"zavet","explicitStatus":"","version":""},{"id":"79MqKixsBADbx1JmDjJryR","name":"Болгарка","artist":"Denya8","artistId":"5jOJTyt2uMZAliz3IOudoz","coverArt":"al-79MqKixsBADbx1JmDjJryR_6a876952","songCount":1,"duration":129,"created":"2026-08-20T05:12:32.079858766Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"болгарка","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"5jOJTyt2uMZAliz3IOudoz","name":"Denya8"}],"displayArtist":"Denya8","explicitStatus":"","version":""},{"id":"59kwtjRmhNm3VjlX7vwPKi","name":"Будь только моей","artist":"amekudeku","artistId":"5b63hpimhe6HfsDUjFYMN1","coverArt":"al-59kwtjRmhNm3VjlX7vwPKi_6a876977","songCount":1,"duration":124,"playCount":1,"created":"2026-08-20T05:12:32.079858766Z","played":"2026-08-26T20:25:48.302Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"будь только моей","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"5b63hpimhe6HfsDUjFYMN1","name":"amekudeku"}],"displayArtist":"amekudeku","explicitStatus":"","version":""},{"id":"6qrSDp1hgwUfDX9O9q2FTD","name":"вот она","artist":"zavet","artistId":"2lzWXQ0Wd7RzG6RmnWUaTy","coverArt":"al-6qrSDp1hgwUfDX9O9q2FTD_6a87693b","songCount":1,"duration":121,"created":"2026-08-20T20:53:22.714002665Z","year":2021,"genre":"Hip Hop","userRating":0,"genres":[{"name":"Hip Hop"},{"name":"Rap"}],"musicBrainzId":"","isCompilation":false,"sortName":"вот она","discTitles":[],"originalReleaseDate":{},"releaseDate":{"year":2021},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"2lzWXQ0Wd7RzG6RmnWUaTy","name":"zavet"}],"displayArtist":"zavet","explicitStatus":"","version":""},{"id":"2rw673uetckpkpeICAFuTe","name":"Всю ночь","artist":"lyralei","artistId":"3JpQ2ekgf8swJrUf4I9HCx","coverArt":"al-2rw673uetckpkpeICAFuTe_6a8767c3","songCount":1,"duration":140,"playCount":1,"created":"2026-08-20T05:12:32.079858766Z","played":"2026-08-25T16:48:41.689Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"всю ночь","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"3JpQ2ekgf8swJrUf4I9HCx","name":"lyralei"}],"displayArtist":"lyralei","explicitStatus":"","version":""},{"id":"759DHMOcXZjEH1PuoOSp3l","name":"Выключили свет","artist":"zavet","artistId":"2lzWXQ0Wd7RzG6RmnWUaTy","coverArt":"al-759DHMOcXZjEH1PuoOSp3l_6a876938","songCount":1,"duration":141,"created":"2026-08-20T20:53:22.582795056Z","year":2020,"genre":"rusrap","userRating":0,"genres":[{"name":"rusrap"}],"musicBrainzId":"","isCompilation":false,"sortName":"выключили свет","discTitles":[],"originalReleaseDate":{},"releaseDate":{"year":2020,"month":2,"day":27},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"2lzWXQ0Wd7RzG6RmnWUaTy","name":"zavet"}],"displayArtist":"zavet","explicitStatus":"","version":""},{"id":"4JsgXDbzVrXqx3mUACMjLa","name":"выключили свет (ver. 57)","artist":"QT","artistId":"09rULau0M6sC9mRS3Mr3DZ","coverArt":"al-4JsgXDbzVrXqx3mUACMjLa_6aa1a12c","songCount":1,"duration":182,"created":"2026-09-09T18:10:57.046814102Z","year":2026,"userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"выключили свет (ver. 57)","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"09rULau0M6sC9mRS3Mr3DZ","name":"QT"}],"displayArtist":"QT","explicitStatus":"","version":""},{"id":"6cVk7rnxrgOKg8jfnvOWKJ","name":"Высокая облачность","artist":"Рай не сегодня","artistId":"22qO7WPYmgKqChrt72BFOf","coverArt":"al-6cVk7rnxrgOKg8jfnvOWKJ_6a876955","songCount":2,"duration":256,"created":"2026-08-20T05:12:32.079858766Z","year":2020,"userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"высокая облачность","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"22qO7WPYmgKqChrt72BFOf","name":"Рай не сегодня"}],"displayArtist":"Рай не сегодня","explicitStatus":"","version":""},{"id":"0nV8Q8xFjjCm7Eykb8Z3xV","name":"гламурный подонок","artist":"дмитрий уткин","artistId":"7v0nmjPcxsKDPInhOlBnNs","coverArt":"al-0nV8Q8xFjjCm7Eykb8Z3xV_6a8767df","songCount":1,"duration":115,"created":"2026-08-20T05:12:32.079858766Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"гламурный подонок","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"7v0nmjPcxsKDPInhOlBnNs","name":"дмитрий уткин"}],"displayArtist":"дмитрий уткин","explicitStatus":"","version":""},{"id":"2RjlNl9MNuAz8H23VsIylo","name":"Две тысячи семнадцатый","artist":"17 SEVENTEEN","artistId":"62qr8uCyh7AmYFTqGroesm","coverArt":"al-2RjlNl9MNuAz8H23VsIylo_6a876895","songCount":2,"duration":268,"playCount":3,"created":"2026-08-20T05:12:32.079858766Z","played":"2026-09-09T19:23:35.65Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"две тысячи семнадцатый","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"62qr8uCyh7AmYFTqGroesm","name":"17 SEVENTEEN"}],"displayArtist":"17 SEVENTEEN","explicitStatus":"","version":""},{"id":"6Ny4b6eMsv44kmwsV41bPp","name":"Доверять","artist":"kyNZai","artistId":"68bJfG7ShRByVvC44edWjB","coverArt":"al-6Ny4b6eMsv44kmwsV41bPp_6a8767f6","songCount":1,"duration":142,"created":"2026-08-20T05:12:32.079858766Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"доверять","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"68bJfG7ShRByVvC44edWjB","name":"kyNZai"}],"displayArtist":"kyNZai","explicitStatus":"","version":""},{"id":"6nyTmZaXMKrBOfC3YR2Yma","name":"догвилль","artist":"тёмный принц","artistId":"3r67QtGcmRhNEJhtqdNM1K","coverArt":"al-6nyTmZaXMKrBOfC3YR2Yma_6a876a4c","songCount":1,"duration":121,"created":"2026-08-20T05:12:32.079858766Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"догвилль","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"3r67QtGcmRhNEJhtqdNM1K","name":"тёмный принц"}],"displayArtist":"тёмный принц","explicitStatus":"","version":""},{"id":"45BKgzS9X2KyedLphSKSiM","name":"Доставай!","artist":"Pure","artistId":"1VNJ7bUDSsv6efQnIwtnxK","coverArt":"al-45BKgzS9X2KyedLphSKSiM_6a876972","songCount":1,"duration":140,"playCount":1,"created":"2026-08-20T05:12:32.079858766Z","played":"2026-08-20T15:33:17.07975838Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"доставай!","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"1VNJ7bUDSsv6efQnIwtnxK","name":"Pure"}],"displayArtist":"Pure","explicitStatus":"","version":""},{"id":"5xGRYiif1h7Ka8o5yhyND4","name":"ешь, молись, танцуй","artist":"never die","artistId":"1RlholhxfO8YebLSmsyb4o","coverArt":"al-5xGRYiif1h7Ka8o5yhyND4_6a8767bb","songCount":3,"duration":309,"playCount":4,"created":"2026-08-20T05:12:32.079858766Z","played":"2026-08-26T17:46:20.073Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"ешь, молись, танцуй","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"1RlholhxfO8YebLSmsyb4o","name":"never die"}],"displayArtist":"never die","explicitStatus":"","version":""},{"id":"4H81iL0GZJrhavwUdBDVe7","name":"Жилы","artist":"zavet","artistId":"2lzWXQ0Wd7RzG6RmnWUaTy","coverArt":"al-4H81iL0GZJrhavwUdBDVe7_6a876904","songCount":25,"duration":4298,"playCount":30,"created":"2026-08-20T05:12:32.079858766Z","year":2020,"genre":"rusrap","played":"2026-09-11T19:04:05.458Z","userRating":0,"genres":[{"name":"rusrap"}],"musicBrainzId":"","isCompilation":false,"sortName":"жилы","discTitles":[{"disc":1,"title":"","coverArt":"dc-4H81iL0GZJrhavwUdBDVe7:1_6a876904"},{"disc":2,"title":"","coverArt":"dc-4H81iL0GZJrhavwUdBDVe7:2_6a876904"},{"disc":3,"title":"","coverArt":"dc-4H81iL0GZJrhavwUdBDVe7:3_6a876904"}],"originalReleaseDate":{},"releaseDate":{"year":2020,"month":4,"day":10},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"2lzWXQ0Wd7RzG6RmnWUaTy","name":"zavet"}],"displayArtist":"zavet","explicitStatus":"","version":""},{"id":"7wmLAUtwjJVbeAmsCoOQPO","name":"Жилы","artist":"zavet","artistId":"2lzWXQ0Wd7RzG6RmnWUaTy","coverArt":"al-7wmLAUtwjJVbeAmsCoOQPO_6a8768fe","songCount":10,"duration":1903,"playCount":2,"created":"2026-08-20T05:12:32.079858766Z","year":2020,"genre":"rusrap","played":"2026-08-29T19:54:38.627Z","userRating":0,"genres":[{"name":"rusrap"},{"name":"Gospel"}],"musicBrainzId":"","isCompilation":false,"sortName":"жилы","discTitles":[{"disc":1,"title":"","coverArt":"dc-7wmLAUtwjJVbeAmsCoOQPO:1_6a8768fe"},{"disc":2,"title":"","coverArt":"dc-7wmLAUtwjJVbeAmsCoOQPO:2_6a8768fe"},{"disc":3,"title":"","coverArt":"dc-7wmLAUtwjJVbeAmsCoOQPO:3_6a8768fe"}],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"2lzWXQ0Wd7RzG6RmnWUaTy","name":"zavet"}],"displayArtist":"zavet","explicitStatus":"","version":""},{"id":"6eRx7rbdCuDQnFjY3xRUZO","name":"Застольные Психоделические Песни","artist":"Валентин Дядька","artistId":"1ldo7ND3Xt2U5y9RwcNIOG","coverArt":"al-6eRx7rbdCuDQnFjY3xRUZO_6a876888","songCount":1,"duration":194,"created":"2026-08-20T05:12:32.079858766Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"застольные психоделические песни","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"1ldo7ND3Xt2U5y9RwcNIOG","name":"Валентин Дядька"}],"displayArtist":"Валентин Дядька","explicitStatus":"","version":""},{"id":"5yAH2YdEkKwZHlomKl887T","name":"Змеем","artist":"Сюзанна feat. zavet","artistId":"6CWFWrWeNIMzjn6U1Ye7fg","coverArt":"al-5yAH2YdEkKwZHlomKl887T_6a8768bf","songCount":1,"duration":194,"created":"2026-08-20T20:53:22.948060086Z","year":2022,"genre":"Hip Hop","userRating":0,"genres":[{"name":"Hip Hop"},{"name":"Rap"}],"musicBrainzId":"","isCompilation":false,"sortName":"змеем","discTitles":[],"originalReleaseDate":{},"releaseDate":{"year":2022},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"6CWFWrWeNIMzjn6U1Ye7fg","name":"Сюзанна"},{"id":"2lzWXQ0Wd7RzG6RmnWUaTy","name":"zavet"}],"displayArtist":"Сюзанна feat. zavet","explicitStatus":"","version":""},{"id":"1mIU2AhZU0vQKxPYnOfrrO","name":"Змеем","artist":"Сюзанна,zavet","artistId":"4dFyANqGvMN1ncDH8uQIXS","coverArt":"al-1mIU2AhZU0vQKxPYnOfrrO_6a8768bf","songCount":2,"duration":388,"created":"2026-08-20T05:12:32.079858766Z","year":2022,"genre":"Hip-Hop","userRating":0,"genres":[{"name":"Hip-Hop"},{"name":"ruspop"}],"musicBrainzId":"","isCompilation":false,"sortName":"змеем","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"4dFyANqGvMN1ncDH8uQIXS","name":"Сюзанна,zavet"}],"displayArtist":"Сюзанна,zavet","explicitStatus":"","version":""},{"id":"1qbBY1f22kpNqUGclsAQw9","name":"золотые купола","artist":"zavet","artistId":"2lzWXQ0Wd7RzG6RmnWUaTy","coverArt":"al-1qbBY1f22kpNqUGclsAQw9_6a8761f3","songCount":1,"duration":190,"created":"2026-08-20T05:12:32.079858766Z","year":2019,"genre":"Hip-Hop","userRating":0,"genres":[{"name":"Hip-Hop"}],"musicBrainzId":"","isCompilation":false,"sortName":"золотые купола","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"2lzWXQ0Wd7RzG6RmnWUaTy","name":"zavet"}],"displayArtist":"zavet","explicitStatus":"","version":""},{"id":"1ttX3mf0A8onPwvT8Imo4U","name":"изучаю мир","artist":"Рай не сегодня","artistId":"22qO7WPYmgKqChrt72BFOf","coverArt":"al-1ttX3mf0A8onPwvT8Imo4U_6a876968","songCount":7,"duration":1092,"playCount":1,"created":"2026-08-20T05:12:32.079858766Z","played":"2026-08-20T14:32:57.59952785Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"изучаю мир","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"22qO7WPYmgKqChrt72BFOf","name":"Рай не сегодня"}],"displayArtist":"Рай не сегодня","explicitStatus":"","version":""},{"id":"3dbpvAEDJmLzyCIT7wged0","name":"Картина","artist":"FADE031","artistId":"724HMvJCNhak92oE4Mw3If","coverArt":"al-3dbpvAEDJmLzyCIT7wged0_6a87678e","songCount":1,"duration":105,"playCount":1,"created":"2026-08-20T05:12:32.079858766Z","played":"2026-09-09T14:42:55.975Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"картина","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"724HMvJCNhak92oE4Mw3If","name":"FADE031"}],"displayArtist":"FADE031","explicitStatus":"","version":""},{"id":"51HRrpbk7QEjMvB1iM8Jtk","name":"Касса","artist":"Рай не сегодня","artistId":"22qO7WPYmgKqChrt72BFOf","coverArt":"al-51HRrpbk7QEjMvB1iM8Jtk_6a871fa9","songCount":1,"duration":120,"created":"2026-08-20T05:12:32.079858766Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"касса","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"22qO7WPYmgKqChrt72BFOf","name":"Рай не сегодня"}],"displayArtist":"Рай не сегодня","explicitStatus":"","version":""},{"id":"0yqzb6wUSCSvUwMwbfO6CY","name":"кем я хотел стать","artist":"KVESTAR","artistId":"5p11l3dOsKjMlcd8HBxwIQ","coverArt":"al-0yqzb6wUSCSvUwMwbfO6CY_6a876881","songCount":1,"duration":126,"created":"2026-08-20T05:12:32.079858766Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"кем я хотел стать","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"5p11l3dOsKjMlcd8HBxwIQ","name":"KVESTAR"}],"displayArtist":"KVESTAR","explicitStatus":"","version":""},{"id":"2nuBP4AdLeCqVJlPBSYTyj","name":"Кипит кровь","artist":"3TERNITY","artistId":"6s95evG6nwNSd0mnfjsbCz","coverArt":"al-2nuBP4AdLeCqVJlPBSYTyj_6a876886","songCount":1,"duration":183,"created":"2026-08-20T05:12:32.079858766Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"кипит кровь","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"6s95evG6nwNSd0mnfjsbCz","name":"3TERNITY"}],"displayArtist":"3TERNITY","explicitStatus":"","version":""},{"id":"4ptmuTmb1g7Ri0WxOaqxGL","name":"КЛУБНЯК","artist":"zwonkybeats","artistId":"5k4pDGC8q3NeCd5pYr221I","coverArt":"al-4ptmuTmb1g7Ri0WxOaqxGL_6a87677d","songCount":1,"duration":150,"created":"2026-08-20T05:12:32.079858766Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"клубняк","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"5k4pDGC8q3NeCd5pYr221I","name":"zwonkybeats"}],"displayArtist":"zwonkybeats","explicitStatus":"","version":""},{"id":"4dKIrormOIFAKpxldNAZXy","name":"крыша","artist":"never die","artistId":"1RlholhxfO8YebLSmsyb4o","coverArt":"al-4dKIrormOIFAKpxldNAZXy_6a8767b9","songCount":1,"duration":112,"playCount":1,"created":"2026-08-20T05:12:32.079858766Z","played":"2026-08-29T20:44:15.873Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"крыша","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"1RlholhxfO8YebLSmsyb4o","name":"never die"}],"displayArtist":"never die","explicitStatus":"","version":""},{"id":"7hVa203lZYsEN9Wv2FqQcb","name":"Кукла","artist":"TOSHCHIII","artistId":"3iADjBvDrFTdoQZov2MxJt","coverArt":"al-7hVa203lZYsEN9Wv2FqQcb_6a876782","songCount":1,"duration":90,"created":"2026-08-20T05:12:32.079858766Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"кукла","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"3iADjBvDrFTdoQZov2MxJt","name":"TOSHCHIII"}],"displayArtist":"TOSHCHIII","explicitStatus":"","version":""},{"id":"6QE7dktbM3h4nITq9caMSV","name":"Летаю","artist":"Рай не сегодня","artistId":"22qO7WPYmgKqChrt72BFOf","coverArt":"al-6QE7dktbM3h4nITq9caMSV_6a87695d","songCount":1,"duration":84,"created":"2026-08-20T05:12:32.079858766Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"летаю","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"22qO7WPYmgKqChrt72BFOf","name":"Рай не сегодня"}],"displayArtist":"Рай не сегодня","explicitStatus":"","version":""},{"id":"13GHhDDHaSM9PssKq0GNxs","name":"Лучший друг","artist":"rizza","artistId":"5PjcH8aQAlFrfWqvOnqua5","coverArt":"al-13GHhDDHaSM9PssKq0GNxs_6a876840","songCount":1,"duration":79,"created":"2026-08-20T05:12:32.079858766Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"лучший друг","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"5PjcH8aQAlFrfWqvOnqua5","name":"rizza"}],"displayArtist":"rizza","explicitStatus":"","version":""},{"id":"280UBv1FCiVJ6HaJYgBL81","name":"МАМА, ПАПА, ПРОСТИТЕ МЕНЯ","artist":"QT","artistId":"09rULau0M6sC9mRS3Mr3DZ","coverArt":"al-280UBv1FCiVJ6HaJYgBL81_6a87a2a0","songCount":1,"duration":357,"created":"2026-08-21T00:58:14.011104488Z","year":2024,"userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"мама, папа, простите меня","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"09rULau0M6sC9mRS3Mr3DZ","name":"QT"}],"displayArtist":"QT","explicitStatus":"","version":""},{"id":"2MV8VrflzcVcnJd9FySaLo","name":"МАМА, ПАПА, ПРОСТИТЕ МЕНЯ","artist":"QT \u0026 PUNKSHOW","artistId":"74qITFok5LWugUcixZizjS","coverArt":"al-2MV8VrflzcVcnJd9FySaLo_6a872219","songCount":1,"duration":357,"created":"2026-08-20T05:12:32.079858766Z","year":2024,"genre":"rusrock","userRating":0,"genres":[{"name":"rusrock"}],"musicBrainzId":"","isCompilation":false,"sortName":"мама, папа, простите меня","discTitles":[],"originalReleaseDate":{},"releaseDate":{"year":2024,"month":7,"day":26},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"74qITFok5LWugUcixZizjS","name":"QT \u0026 PUNKSHOW"}],"displayArtist":"QT \u0026 PUNKSHOW","explicitStatus":"","version":""},{"id":"4p6BLr7R7sRWB6mhOiPvXN","name":"МАМА, ПАПА, ПРОСТИТЕ МЕНЯ","artist":"QT \u0026 PUNKSHOW","artistId":"74qITFok5LWugUcixZizjS","coverArt":"al-4p6BLr7R7sRWB6mhOiPvXN_6a8768bf","songCount":1,"duration":357,"created":"2026-08-20T20:53:23.340841543Z","year":2024,"userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"мама, папа, простите меня","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"74qITFok5LWugUcixZizjS","name":"QT \u0026 PUNKSHOW"}],"displayArtist":"QT \u0026 PUNKSHOW","explicitStatus":"","version":""},{"id":"6NDxteyBBRQ2tt7825ZhE5","name":"Мечта Vol.2","artist":"kirra","artistId":"2o8aTjfmh7xHPQT9KtoqY7","coverArt":"al-6NDxteyBBRQ2tt7825ZhE5_6aa19db0","songCount":1,"duration":188,"created":"2026-09-09T17:56:10.973938011Z","year":2022,"userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"мечта vol.2","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"2o8aTjfmh7xHPQT9KtoqY7","name":"kirra"}],"displayArtist":"kirra","explicitStatus":"","version":""},{"id":"2XIlG6AGhbYTkNdSpjRymf","name":"мрачные треды","artist":"тёмный принц","artistId":"3r67QtGcmRhNEJhtqdNM1K","coverArt":"al-2XIlG6AGhbYTkNdSpjRymf_6a876a56","songCount":7,"duration":635,"playCount":3,"created":"2026-08-20T05:12:32.079858766Z","played":"2026-08-26T20:21:57.458Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"мрачные треды","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"3r67QtGcmRhNEJhtqdNM1K","name":"тёмный принц"}],"displayArtist":"тёмный принц","explicitStatus":"","version":""},{"id":"3J6nXEPOmELLzfiddV7cke","name":"навсегда","artist":"Рай не сегодня","artistId":"22qO7WPYmgKqChrt72BFOf","coverArt":"al-3J6nXEPOmELLzfiddV7cke_6a87221b","songCount":1,"duration":161,"playCount":1,"created":"2026-08-20T05:12:32.079858766Z","played":"2026-08-20T14:27:11.12139062Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"навсегда","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"22qO7WPYmgKqChrt72BFOf","name":"Рай не сегодня"}],"displayArtist":"Рай не сегодня","explicitStatus":"","version":""},{"id":"4yFd67I2bKsVNLqgazvneM","name":"НАЙКРАЩИЙ УКРАЇНСЬКИЙ РЕПЕР","artist":"Clonnex","artistId":"6nz4rI0viVYELtYOOEnXyE","coverArt":"al-4yFd67I2bKsVNLqgazvneM_6a876947","songCount":1,"duration":116,"created":"2026-08-20T05:12:32.079858766Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"найкращий український репер","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"6nz4rI0viVYELtYOOEnXyE","name":"Clonnex"}],"displayArtist":"Clonnex","explicitStatus":"","version":""},{"id":"2F2CwUvt0swOcq24eE0u01","name":"нарратифффчики","artist":"New Sylveon","artistId":"1O3tH1OWBPZmewMIvjBKT0","coverArt":"al-2F2CwUvt0swOcq24eE0u01_6a87682d","songCount":2,"duration":319,"playCount":2,"created":"2026-08-20T05:12:32.079858766Z","played":"2026-09-05T19:39:19.812Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"нарратифффчики","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"1O3tH1OWBPZmewMIvjBKT0","name":"New Sylveon"}],"displayArtist":"New Sylveon","explicitStatus":"","version":""},{"id":"0dUar9R7G1X8aJgUL2nGxH","name":"не виноват","artist":"never die","artistId":"1RlholhxfO8YebLSmsyb4o","coverArt":"al-0dUar9R7G1X8aJgUL2nGxH_6a87679c","songCount":1,"duration":124,"created":"2026-08-20T05:12:32.079858766Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"не виноват","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"1RlholhxfO8YebLSmsyb4o","name":"never die"}],"displayArtist":"never die","explicitStatus":"","version":""},{"id":"0l0hsVukhR85GxlBLXfFor","name":"не вспоминаю","artist":"Рай не сегодня","artistId":"22qO7WPYmgKqChrt72BFOf","coverArt":"al-0l0hsVukhR85GxlBLXfFor_6a87221b","songCount":1,"duration":119,"playCount":1,"created":"2026-08-20T05:12:32.079858766Z","played":"2026-08-29T18:51:45.443Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"не вспоминаю","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"22qO7WPYmgKqChrt72BFOf","name":"Рай не сегодня"}],"displayArtist":"Рай не сегодня","explicitStatus":"","version":""},{"id":"2KXOYAIItG2udVba6CmkM9","name":"Не зря","artist":"kyNZai","artistId":"68bJfG7ShRByVvC44edWjB","coverArt":"al-2KXOYAIItG2udVba6CmkM9_6a8767f5","songCount":1,"duration":172,"playCount":1,"created":"2026-08-20T05:12:32.079858766Z","played":"2026-09-19T16:08:38.00209251Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"не зря","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"68bJfG7ShRByVvC44edWjB","name":"kyNZai"}],"displayArtist":"kyNZai","explicitStatus":"","version":""},{"id":"4HrIcQjbHPrkRscVhnFUXv","name":"Не плачь","artist":"Hikariii","artistId":"6q6Q7MmEiWdjlqT8TAFZUL","coverArt":"al-4HrIcQjbHPrkRscVhnFUXv_6a87696c","songCount":1,"duration":123,"created":"2026-08-20T05:12:32.079858766Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"не плачь","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"6q6Q7MmEiWdjlqT8TAFZUL","name":"Hikariii"}],"displayArtist":"Hikariii","explicitStatus":"","version":""},{"id":"4GyZAyd7jo3OFg8viIR4fG","name":"не тая","artist":"Рай не сегодня","artistId":"22qO7WPYmgKqChrt72BFOf","coverArt":"al-4GyZAyd7jo3OFg8viIR4fG_6a885879","songCount":1,"duration":222,"playCount":2,"created":"2026-08-21T13:54:06.905230161Z","year":2021,"played":"2026-09-09T19:21:59.03Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"не тая","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"22qO7WPYmgKqChrt72BFOf","name":"Рай не сегодня"}],"displayArtist":"Рай не сегодня","explicitStatus":"","version":""},{"id":"4cBkzAKSQxU3EWSNzA5D95","name":"Никаких чувств","artist":"ICELOWSIDE","artistId":"5n6zaJC3sO9QwX6eUVwm4l","coverArt":"al-4cBkzAKSQxU3EWSNzA5D95_6a876776","songCount":1,"duration":133,"created":"2026-08-20T05:12:32.079858766Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"никаких чувств","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"5n6zaJC3sO9QwX6eUVwm4l","name":"ICELOWSIDE"}],"displayArtist":"ICELOWSIDE","explicitStatus":"","version":""},{"id":"5vXbYas8VvvGXIaYmyiMyE","name":"Обновление","artist":"НТР","artistId":"6a7GXnrHWsEoqwFqmzr9Cg","coverArt":"al-5vXbYas8VvvGXIaYmyiMyE_6a871e20","songCount":1,"duration":113,"created":"2026-08-20T05:12:32.079858766Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"обновление","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"6a7GXnrHWsEoqwFqmzr9Cg","name":"НТР"}],"displayArtist":"НТР","explicitStatus":"","version":""},{"id":"5ErIr1S3Ojs5MIfhj6Arkg","name":"Огней","artist":"SALUKI","artistId":"4fKYKrsm8mmpys7Fp5oRyN","coverArt":"al-5ErIr1S3Ojs5MIfhj6Arkg_6a8767db","songCount":1,"duration":180,"created":"2026-08-20T05:12:32.079858766Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"огней","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"4fKYKrsm8mmpys7Fp5oRyN","name":"SALUKI"}],"displayArtist":"SALUKI","explicitStatus":"","version":""},{"id":"5sOzLpWmN8racCO0KVIdFz","name":"она думает","artist":"baadee","artistId":"6HKZ3zGU5N4fxJhM3WEP8B","coverArt":"al-5sOzLpWmN8racCO0KVIdFz_6a87696d","songCount":1,"duration":104,"created":"2026-08-20T05:12:32.079858766Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"она думает","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"6HKZ3zGU5N4fxJhM3WEP8B","name":"baadee"}],"displayArtist":"baadee","explicitStatus":"","version":""},{"id":"4ORjMac6rdslxiaOM9uVeO","name":"Ополчение","artist":"zavet","artistId":"2lzWXQ0Wd7RzG6RmnWUaTy","coverArt":"al-4ORjMac6rdslxiaOM9uVeO_6a876906","songCount":1,"duration":117,"created":"2026-08-20T20:53:22.618282182Z","year":2019,"genre":"rusrap","userRating":0,"genres":[{"name":"rusrap"}],"musicBrainzId":"","isCompilation":false,"sortName":"ополчение","discTitles":[],"originalReleaseDate":{},"releaseDate":{"year":2019,"month":12,"day":20},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"2lzWXQ0Wd7RzG6RmnWUaTy","name":"zavet"}],"displayArtist":"zavet","explicitStatus":"","version":""},{"id":"5FyaI7MFIGCoiSletmSChT","name":"Особо сохраняемый","artist":"New Sylveon","artistId":"1O3tH1OWBPZmewMIvjBKT0","coverArt":"al-5FyaI7MFIGCoiSletmSChT_6ab13189","songCount":11,"duration":1758,"playCount":1,"created":"2026-08-20T05:12:32.079858766Z","year":2018,"played":"2026-08-26T20:16:14.829Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"особо сохраняемый","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"1O3tH1OWBPZmewMIvjBKT0","name":"New Sylveon"}],"displayArtist":"New Sylveon","explicitStatus":"","version":""},{"id":"5aN4no088b78SKi1zNfLO6","name":"оставь живым","artist":"Рай не сегодня","artistId":"22qO7WPYmgKqChrt72BFOf","coverArt":"al-5aN4no088b78SKi1zNfLO6_6a87696b","songCount":1,"duration":108,"created":"2026-08-20T05:12:32.079858766Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"оставь живым","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"22qO7WPYmgKqChrt72BFOf","name":"Рай не сегодня"}],"displayArtist":"Рай не сегодня","explicitStatus":"","version":""},{"id":"5qjqTrTO9pk7zFQ26vFQhn","name":"Отражение - Single","artist":"Ваня Дмитриенко, Мальбэк \u0026 Сюзанна","artistId":"1fbZVBFq1QzaxaS0oZKW7H","coverArt":"al-5qjqTrTO9pk7zFQ26vFQhn_6a884c22","songCount":1,"duration":152,"created":"2026-08-21T13:02:25.433898719Z","year":2026,"genre":"Pop","userRating":0,"genres":[{"name":"Pop"}],"musicBrainzId":"","isCompilation":false,"sortName":"отражение - single","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"1fbZVBFq1QzaxaS0oZKW7H","name":"Ваня Дмитриенко, Мальбэк \u0026 Сюзанна"}],"displayArtist":"Ваня Дмитриенко, Мальбэк \u0026 Сюзанна","explicitStatus":"","version":""},{"id":"7KFZZ0rK8luIaIjIxZyBMp","name":"ОТТЕНКИ БАРДА 2","artist":"Slava KPSS","artistId":"3pBe8GsWJqYRUeKVzVAXc7","coverArt":"al-7KFZZ0rK8luIaIjIxZyBMp_6aad8f9f","songCount":1,"duration":145,"created":"2026-09-18T19:23:16.765561139Z","year":2025,"userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"оттенки барда 2","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"3pBe8GsWJqYRUeKVzVAXc7","name":"Slava KPSS"}],"displayArtist":"Slava KPSS","explicitStatus":"","version":""},{"id":"3KxekOxjEaOV3PcW58jiLz","name":"ПАПА","artist":"тёмный принц","artistId":"3r67QtGcmRhNEJhtqdNM1K","coverArt":"al-3KxekOxjEaOV3PcW58jiLz_6a876a59","songCount":1,"duration":102,"created":"2026-08-20T05:12:32.079858766Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"папа","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"3r67QtGcmRhNEJhtqdNM1K","name":"тёмный принц"}],"displayArtist":"тёмный принц","explicitStatus":"","version":""},{"id":"6NDe8CdrJPTBE1kPwjMbQD","name":"Переворот","artist":"zavet","artistId":"2lzWXQ0Wd7RzG6RmnWUaTy","coverArt":"al-6NDe8CdrJPTBE1kPwjMbQD_6a87693d","songCount":1,"duration":185,"created":"2026-08-20T20:53:22.621400835Z","year":2019,"genre":"Hip Hop","userRating":0,"genres":[{"name":"Hip Hop"},{"name":"Rap"}],"musicBrainzId":"","isCompilation":false,"sortName":"переворот","discTitles":[],"originalReleaseDate":{},"releaseDate":{"year":2019,"month":10,"day":11},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"2lzWXQ0Wd7RzG6RmnWUaTy","name":"zavet"}],"displayArtist":"zavet","explicitStatus":"","version":""},{"id":"7KdAFn7Ao6U9MzBBws8eSI","name":"Пися попа","artist":"never die","artistId":"1RlholhxfO8YebLSmsyb4o","coverArt":"al-7KdAFn7Ao6U9MzBBws8eSI_6a87679b","songCount":3,"duration":321,"created":"2026-08-20T05:12:32.079858766Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"пися попа","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"1RlholhxfO8YebLSmsyb4o","name":"never die"}],"displayArtist":"never die","explicitStatus":"","version":""},{"id":"3KCzL5LsRrtx7EwQ5rCDlb","name":"Пися Попа 2","artist":"never die","artistId":"1RlholhxfO8YebLSmsyb4o","coverArt":"al-3KCzL5LsRrtx7EwQ5rCDlb_6a8767b2","songCount":1,"duration":125,"playCount":1,"created":"2026-08-20T05:12:32.079858766Z","played":"2026-08-29T18:08:15.325Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"пися попа 2","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"1RlholhxfO8YebLSmsyb4o","name":"never die"}],"displayArtist":"never die","explicitStatus":"","version":""},{"id":"73mBWGDJEDskLRvZAZtF04","name":"Пластик бэг","artist":"Ranger Over","artistId":"5a6B9Aoi4p9MsZWLQz4om9","coverArt":"al-73mBWGDJEDskLRvZAZtF04_6a876a3b","songCount":1,"duration":206,"created":"2026-08-20T05:12:32.079858766Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"пластик бэг","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"5a6B9Aoi4p9MsZWLQz4om9","name":"Ranger Over"}],"displayArtist":"Ranger Over","explicitStatus":"","version":""},{"id":"20mFgjI0ozOAPbtADmfdFQ","name":"повод","artist":"Рай не сегодня","artistId":"22qO7WPYmgKqChrt72BFOf","coverArt":"al-20mFgjI0ozOAPbtADmfdFQ_6a87221b","songCount":1,"duration":115,"created":"2026-08-20T05:12:32.079858766Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"повод","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"22qO7WPYmgKqChrt72BFOf","name":"Рай не сегодня"}],"displayArtist":"Рай не сегодня","explicitStatus":"","version":""},{"id":"2FxSQae2keHNtBGZ5NeAgg","name":"последние песни про телок","artist":"рэйчи","artistId":"4AEooAaGF5g70Dz6HEVR9t","coverArt":"al-2FxSQae2keHNtBGZ5NeAgg_6a876970","songCount":2,"duration":283,"playCount":1,"created":"2026-08-20T05:12:32.079858766Z","played":"2026-09-09T19:29:15.81Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"последние песни про телок","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"4AEooAaGF5g70Dz6HEVR9t","name":"рэйчи"}],"displayArtist":"рэйчи","explicitStatus":"","version":""},{"id":"0dh79tg2OlySa6Uu9np3Z8","name":"Ресентимент","artist":"НТР","artistId":"6a7GXnrHWsEoqwFqmzr9Cg","coverArt":"al-0dh79tg2OlySa6Uu9np3Z8_6a87221b","songCount":8,"duration":1144,"playCount":2,"created":"2026-08-20T05:12:32.079858766Z","played":"2026-08-29T20:12:34.582Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"ресентимент","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"6a7GXnrHWsEoqwFqmzr9Cg","name":"НТР"}],"displayArtist":"НТР","explicitStatus":"","version":""},{"id":"5mc2etwjKQEelK5UpCVHlq","name":"Родина","artist":"zavet","artistId":"2lzWXQ0Wd7RzG6RmnWUaTy","coverArt":"al-5mc2etwjKQEelK5UpCVHlq_6a8761ee","songCount":1,"duration":165,"playCount":2,"created":"2026-08-20T05:12:32.079858766Z","year":2029,"genre":"Hip-Hop","played":"2026-08-20T13:53:10.826Z","userRating":0,"genres":[{"name":"Hip-Hop"}],"musicBrainzId":"","isCompilation":false,"sortName":"родина","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"2lzWXQ0Wd7RzG6RmnWUaTy","name":"zavet"}],"displayArtist":"zavet","explicitStatus":"","version":""},{"id":"47rmmmEIEj52kMuD70BFqq","name":"САМЫЙ ЛУЧШИЙ РУССКИЙ РЕПЕР","artist":"Clonnex","artistId":"6nz4rI0viVYELtYOOEnXyE","coverArt":"al-47rmmmEIEj52kMuD70BFqq_6a876944","songCount":1,"duration":116,"playCount":1,"created":"2026-08-20T05:12:32.079858766Z","played":"2026-09-19T16:06:13.597296508Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"самый лучший русский репер","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"6nz4rI0viVYELtYOOEnXyE","name":"Clonnex"}],"displayArtist":"Clonnex","explicitStatus":"","version":""},{"id":"6g8B30kmLNMNmKMCaVnP5y","name":"Спальня","artist":"Merlin White","artistId":"0eM2cEfwBxjCBZALmNWNJu","coverArt":"al-6g8B30kmLNMNmKMCaVnP5y_6a876a49","songCount":1,"duration":151,"created":"2026-08-20T05:12:32.079858766Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"спальня","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"0eM2cEfwBxjCBZALmNWNJu","name":"Merlin White"}],"displayArtist":"Merlin White","explicitStatus":"","version":""},{"id":"4t1HfwbapW1iBn19OFhFg8","name":"Спать с тобой","artist":"itzice","artistId":"6XaghgoxtyVolkxBeRKvFJ","coverArt":"al-4t1HfwbapW1iBn19OFhFg8_6a8711c6","songCount":1,"duration":129,"created":"2026-08-20T05:12:32.079858766Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"спать с тобой","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"6XaghgoxtyVolkxBeRKvFJ","name":"itzice"}],"displayArtist":"itzice","explicitStatus":"","version":""},{"id":"4iyL0qIZurpodlZ1X5grpm","name":"Спущусь потрогать траву","artist":"New Sylveon","artistId":"1O3tH1OWBPZmewMIvjBKT0","coverArt":"al-4iyL0qIZurpodlZ1X5grpm_6a872219","songCount":1,"duration":211,"playCount":1,"created":"2026-08-20T05:12:32.079858766Z","played":"2026-08-20T15:16:08.960894876Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"спущусь потрогать траву","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"1O3tH1OWBPZmewMIvjBKT0","name":"New Sylveon"}],"displayArtist":"New Sylveon","explicitStatus":"","version":""},{"id":"030WfvY0omzElFrvNjmdSI","name":"стук в дверь","artist":"zavet","artistId":"2lzWXQ0Wd7RzG6RmnWUaTy","coverArt":"al-030WfvY0omzElFrvNjmdSI_6a8761f5","songCount":1,"duration":108,"playCount":2,"created":"2026-08-20T05:12:32.079858766Z","year":2019,"genre":"Hip-Hop","played":"2026-08-20T13:53:10.826Z","userRating":0,"genres":[{"name":"Hip-Hop"}],"musicBrainzId":"","isCompilation":false,"sortName":"стук в дверь","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"2lzWXQ0Wd7RzG6RmnWUaTy","name":"zavet"}],"displayArtist":"zavet","explicitStatus":"","version":""},{"id":"4yxBqftjoUwTc26j9rW1cp","name":"суета","artist":"zavet","artistId":"2lzWXQ0Wd7RzG6RmnWUaTy","coverArt":"al-4yxBqftjoUwTc26j9rW1cp_6a87693a","songCount":1,"duration":104,"created":"2026-08-20T20:53:22.736874777Z","year":2020,"genre":"Hip Hop","userRating":0,"genres":[{"name":"Hip Hop"},{"name":"Rap"}],"musicBrainzId":"","isCompilation":false,"sortName":"суета","discTitles":[],"originalReleaseDate":{},"releaseDate":{"year":2020},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"2lzWXQ0Wd7RzG6RmnWUaTy","name":"zavet"}],"displayArtist":"zavet","explicitStatus":"","version":""},{"id":"5U0Vts62zlPkOrcoo15Ikx","name":"тайны","artist":"silver gloria","artistId":"1vguCdNkDOcUh7vmaC4o99","coverArt":"al-5U0Vts62zlPkOrcoo15Ikx_6a87696f","songCount":1,"duration":121,"playCount":1,"created":"2026-08-20T05:12:32.079858766Z","played":"2026-09-09T19:25:37.415Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"тайны","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"1vguCdNkDOcUh7vmaC4o99","name":"silver gloria"}],"displayArtist":"silver gloria","explicitStatus":"","version":""},{"id":"2VfGZN1rfewjD2MSZEujZ5","name":"Тизер","artist":"zavet","artistId":"2lzWXQ0Wd7RzG6RmnWUaTy","coverArt":"al-2VfGZN1rfewjD2MSZEujZ5_6a8768bf","songCount":1,"duration":117,"playCount":1,"created":"2026-08-20T05:12:32.079858766Z","year":2018,"genre":"alternative","played":"2026-09-20T19:16:40.25971494Z","userRating":0,"genres":[{"name":"alternative"}],"musicBrainzId":"","isCompilation":false,"sortName":"тизер","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"2lzWXQ0Wd7RzG6RmnWUaTy","name":"zavet"}],"displayArtist":"zavet","explicitStatus":"","version":""},{"id":"1mPXTAB23VxGwigP8GSg9o","name":"треки","artist":"never die","artistId":"1RlholhxfO8YebLSmsyb4o","coverArt":"al-1mPXTAB23VxGwigP8GSg9o_6a8767c0","songCount":1,"duration":111,"created":"2026-08-20T05:12:32.079858766Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"треки","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"1RlholhxfO8YebLSmsyb4o","name":"never die"}],"displayArtist":"never die","explicitStatus":"","version":""},{"id":"2S21BQ9zHSWgVc78g3IY3j","name":"тусить","artist":"Denya8","artistId":"5jOJTyt2uMZAliz3IOudoz","coverArt":"al-2S21BQ9zHSWgVc78g3IY3j_6a876950","songCount":1,"duration":124,"created":"2026-08-20T05:12:32.079858766Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"тусить","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"5jOJTyt2uMZAliz3IOudoz","name":"Denya8"}],"displayArtist":"Denya8","explicitStatus":"","version":""},{"id":"2rJ76gBIWTXPWyOy3RkM9M","name":"ты любишь этот звук","artist":"never die","artistId":"1RlholhxfO8YebLSmsyb4o","coverArt":"al-2rJ76gBIWTXPWyOy3RkM9M_6a8767b4","songCount":1,"duration":131,"playCount":3,"created":"2026-08-20T05:12:32.079858766Z","played":"2026-09-09T11:43:12.199Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"ты любишь этот звук","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"1RlholhxfO8YebLSmsyb4o","name":"never die"}],"displayArtist":"never die","explicitStatus":"","version":""},{"id":"5sLeTxs1psK27E2yYPNoFD","name":"умираю","artist":"kantaa","artistId":"2tRU5FNknyFpmpMbkVt3cq","coverArt":"al-5sLeTxs1psK27E2yYPNoFD_6a876843","songCount":1,"duration":112,"created":"2026-08-20T05:12:32.079858766Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"умираю","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"2tRU5FNknyFpmpMbkVt3cq","name":"kantaa"}],"displayArtist":"kantaa","explicitStatus":"","version":""},{"id":"0JDW8q99a9AyUvZlZLtg00","name":"Фанат","artist":"2kizz","artistId":"5yZjZwoBql4MIx8H6x5F1o","coverArt":"al-0JDW8q99a9AyUvZlZLtg00_6a87697c","songCount":1,"duration":102,"created":"2026-08-20T05:12:32.079858766Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"фанат","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"5yZjZwoBql4MIx8H6x5F1o","name":"2kizz"}],"displayArtist":"2kizz","explicitStatus":"","version":""},{"id":"3lacdPzhnFObN1K9IkKq1V","name":"Хватит врать","artist":"Рай не сегодня","artistId":"22qO7WPYmgKqChrt72BFOf","coverArt":"al-3lacdPzhnFObN1K9IkKq1V_6a87695a","songCount":1,"duration":126,"playCount":1,"created":"2026-08-20T05:12:32.079858766Z","played":"2026-08-20T15:38:55.879609044Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"хватит врать","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"22qO7WPYmgKqChrt72BFOf","name":"Рай не сегодня"}],"displayArtist":"Рай не сегодня","explicitStatus":"","version":""},{"id":"3uctIVYB5Jk3SFcU64ol8I","name":"Я пустой, буквально","artist":"never die","artistId":"1RlholhxfO8YebLSmsyb4o","coverArt":"al-3uctIVYB5Jk3SFcU64ol8I_6a871305","songCount":2,"duration":284,"playCount":1,"created":"2026-08-20T05:12:32.079858766Z","played":"2026-08-26T20:23:50.634Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"я пустой, буквально","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"1RlholhxfO8YebLSmsyb4o","name":"never die"}],"displayArtist":"never die","explicitStatus":"","version":""},{"id":"0nOEyAM2wNNtJUsuEQAtn1","name":"Яд","artist":"zavet feat. VACÍO","artistId":"2lzWXQ0Wd7RzG6RmnWUaTy","coverArt":"al-0nOEyAM2wNNtJUsuEQAtn1_6a8768bf","songCount":1,"duration":145,"created":"2026-08-20T20:53:23.104073087Z","year":2020,"genre":"Rap \u0026 Hip-Hop","userRating":0,"genres":[{"name":"Rap \u0026 Hip-Hop"}],"musicBrainzId":"","isCompilation":false,"sortName":"яд","discTitles":[],"originalReleaseDate":{},"releaseDate":{"year":2020,"month":2,"day":4},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"2lzWXQ0Wd7RzG6RmnWUaTy","name":"zavet"},{"id":"0LDxWEsCdXB1kzWolG2bb0","name":"VACÍO"}],"displayArtist":"zavet feat. VACÍO","explicitStatus":"","version":""},{"id":"0tscbYwGYTUhBoH5QfMQvx","name":"Яд","artist":"zavet,VACÍO","artistId":"7LMbjIoejtpv73X0I16NJZ","coverArt":"al-0tscbYwGYTUhBoH5QfMQvx_6a8768bf","songCount":2,"duration":290,"created":"2026-08-20T05:12:32.079858766Z","year":2020,"genre":"Hip-Hop","userRating":0,"genres":[{"name":"Hip-Hop"},{"name":"rusrap"}],"musicBrainzId":"","isCompilation":false,"sortName":"яд","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"7LMbjIoejtpv73X0I16NJZ","name":"zavet,VACÍO"}],"displayArtist":"zavet,VACÍO","explicitStatus":"","version":""}]}}} \ No newline at end of file diff --git a/Packages/AmphlifyKit/Tests/SourceSubsonicTests/Fixtures/artist.json b/Packages/AmphlifyKit/Tests/SourceSubsonicTests/Fixtures/artist.json new file mode 100644 index 0000000..3ca67ee --- /dev/null +++ b/Packages/AmphlifyKit/Tests/SourceSubsonicTests/Fixtures/artist.json @@ -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":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"2FXyezR7e5yT2ZwTDFF5ps","name":"Kai Angel"}],"displayArtist":"Kai Angel","explicitStatus":"","version":""},{"id":"0G6rfOLnjXpIsBxkkE6Qag","name":"kAiCoRe","artist":"Kai Angel","artistId":"2FXyezR7e5yT2ZwTDFF5ps","coverArt":"al-0G6rfOLnjXpIsBxkkE6Qag_6a8769e2","songCount":6,"duration":761,"playCount":3,"created":"2026-08-20T05:12:32.079858766Z","year":2022,"played":"2026-09-20T20:34:43.996262003Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"kaicore","discTitles":[],"originalReleaseDate":{},"releaseDate":{"year":2022},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"2FXyezR7e5yT2ZwTDFF5ps","name":"Kai Angel"}],"displayArtist":"Kai Angel","explicitStatus":"","version":""},{"id":"5n2VvT8JJrDQHXE0azJ9lU","name":"HEAVY METAL","artist":"Kai Angel","artistId":"2FXyezR7e5yT2ZwTDFF5ps","coverArt":"al-5n2VvT8JJrDQHXE0azJ9lU_6a876a2c","songCount":2,"duration":243,"playCount":1,"created":"2026-08-20T05:12:32.079858766Z","year":2023,"played":"2026-08-29T18:38:44.603Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"heavy metal","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"2FXyezR7e5yT2ZwTDFF5ps","name":"Kai Angel"}],"displayArtist":"Kai Angel","explicitStatus":"","version":""},{"id":"3w3tg8dXaq3AX7HfMmWZHL","name":"ANGEL MAY CRY","artist":"Kai Angel","artistId":"2FXyezR7e5yT2ZwTDFF5ps","coverArt":"al-3w3tg8dXaq3AX7HfMmWZHL_6a8778e0","songCount":7,"duration":915,"playCount":1,"created":"2026-08-20T05:12:32.079858766Z","year":2023,"genre":"Hip Hop","played":"2026-08-20T15:42:38.294280154Z","userRating":0,"genres":[{"name":"Hip Hop"},{"name":"Rap"}],"musicBrainzId":"","isCompilation":false,"sortName":"angel may cry","discTitles":[],"originalReleaseDate":{},"releaseDate":{"year":2023},"releaseTypes":["Album"],"recordLabels":[{"name":"Koala Music"}],"moods":[],"artists":[{"id":"2FXyezR7e5yT2ZwTDFF5ps","name":"Kai Angel"}],"displayArtist":"Kai Angel","explicitStatus":"","version":""},{"id":"4rwXDr40tGXadmkRmxw0rs","name":"HEAVY METAL","artist":"Kai Angel","artistId":"2FXyezR7e5yT2ZwTDFF5ps","coverArt":"al-4rwXDr40tGXadmkRmxw0rs_6a876a2f","songCount":6,"duration":821,"playCount":5,"created":"2026-08-20T05:12:32.079858766Z","year":2023,"played":"2026-09-20T20:48:40.643940095Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"heavy metal","discTitles":[],"originalReleaseDate":{},"releaseDate":{"year":2023},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"2FXyezR7e5yT2ZwTDFF5ps","name":"Kai Angel"}],"displayArtist":"Kai Angel","explicitStatus":"","version":""},{"id":"6maGFUWCKXGpXDTL5aLPMi","name":"MARILYN MANSON","artist":"Kai Angel","artistId":"2FXyezR7e5yT2ZwTDFF5ps","coverArt":"al-6maGFUWCKXGpXDTL5aLPMi_6a876a20","songCount":11,"duration":1418,"playCount":11,"created":"2026-08-20T05:12:32.079858766Z","year":2023,"played":"2026-09-20T20:19:31.737411751Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"marilyn manson","discTitles":[],"originalReleaseDate":{},"releaseDate":{"year":2023},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"2FXyezR7e5yT2ZwTDFF5ps","name":"Kai Angel"}],"displayArtist":"Kai Angel","explicitStatus":"","version":""},{"id":"71UNypu8O3pmBNQ5ENvKOO","name":"GOD SYSTEM","artist":"Kai Angel","artistId":"2FXyezR7e5yT2ZwTDFF5ps","coverArt":"al-71UNypu8O3pmBNQ5ENvKOO_6ab1f0dd","songCount":8,"duration":1451,"created":"2026-08-20T05:12:32.079858766Z","year":2024,"genre":"Hip Hop","userRating":0,"genres":[{"name":"Hip Hop"},{"name":"Rap"}],"musicBrainzId":"","isCompilation":false,"sortName":"god system","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[{"name":"Koala Music"}],"moods":[],"artists":[{"id":"2FXyezR7e5yT2ZwTDFF5ps","name":"Kai Angel"}],"displayArtist":"Kai Angel","explicitStatus":"","version":""},{"id":"2CXX2cOzLpJU8g1SCvCIih","name":"GOD SYSTEM","artist":"Kai Angel","artistId":"2FXyezR7e5yT2ZwTDFF5ps","coverArt":"al-2CXX2cOzLpJU8g1SCvCIih_6a8769d7","songCount":16,"duration":1801,"playCount":9,"created":"2026-08-20T05:12:32.079858766Z","year":2024,"played":"2026-09-20T20:29:47.185742579Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"god system","discTitles":[],"originalReleaseDate":{},"releaseDate":{"year":2024},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"2FXyezR7e5yT2ZwTDFF5ps","name":"Kai Angel"}],"displayArtist":"Kai Angel","explicitStatus":"","version":""},{"id":"5Gd2kOmaJL9WZLnUUky3xj","name":"damage","artist":"Kai Angel","artistId":"2FXyezR7e5yT2ZwTDFF5ps","coverArt":"al-5Gd2kOmaJL9WZLnUUky3xj_6ab1f20a","songCount":13,"duration":1519,"playCount":2,"created":"2026-08-20T05:12:32.079858766Z","year":2025,"played":"2026-09-20T20:15:54.065365719Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"damage","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"2FXyezR7e5yT2ZwTDFF5ps","name":"Kai Angel"}],"displayArtist":"Kai Angel","explicitStatus":"","version":""},{"id":"0mHWbFnslaClvw4ONYcgKF","name":"ANGEL MAY CRY 2","artist":"Kai Angel","artistId":"2FXyezR7e5yT2ZwTDFF5ps","coverArt":"al-0mHWbFnslaClvw4ONYcgKF_6a877993","songCount":8,"duration":948,"playCount":5,"created":"2026-08-20T05:12:32.079858766Z","year":2025,"genre":"Rap","played":"2026-09-20T20:32:40.406359565Z","userRating":0,"genres":[{"name":"Rap"},{"name":"Hip-Hop"},{"name":"Hip Hop"}],"musicBrainzId":"","isCompilation":false,"sortName":"angel may cry 2","discTitles":[],"originalReleaseDate":{},"releaseDate":{"year":2025},"releaseTypes":["Album"],"recordLabels":[{"name":"Koala Music"}],"moods":[],"artists":[{"id":"2FXyezR7e5yT2ZwTDFF5ps","name":"Kai Angel"}],"displayArtist":"Kai Angel","explicitStatus":"","version":""},{"id":"18MhT8HydfLknLRdbelZdq","name":"damage","artist":"Kai Angel","artistId":"2FXyezR7e5yT2ZwTDFF5ps","coverArt":"al-18MhT8HydfLknLRdbelZdq_6a87699c","songCount":14,"duration":2088,"playCount":11,"created":"2026-08-20T05:12:32.079858766Z","year":2025,"played":"2026-09-20T20:42:26.068933467Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"damage","discTitles":[],"originalReleaseDate":{},"releaseDate":{"year":2025},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"2FXyezR7e5yT2ZwTDFF5ps","name":"Kai Angel"}],"displayArtist":"Kai Angel","explicitStatus":"","version":""},{"id":"16ZRVKUaQvLZ1KXeynaMzN","name":"damage","artist":"Kai Angel","artistId":"2FXyezR7e5yT2ZwTDFF5ps","coverArt":"al-16ZRVKUaQvLZ1KXeynaMzN_6a877b65","songCount":1,"duration":145,"created":"2026-08-20T22:14:57.857827746Z","year":2025,"genre":"Hip-Hop","userRating":0,"genres":[{"name":"Hip-Hop"},{"name":"Rap"}],"musicBrainzId":"18571ca4-a2ea-4030-9e9c-ee6fa8ccb540","isCompilation":false,"sortName":"damage","discTitles":[],"originalReleaseDate":{"year":2025,"month":6,"day":5},"releaseDate":{"year":2025},"releaseTypes":["album"],"recordLabels":[{"name":"Koala Music"}],"moods":[],"artists":[{"id":"2FXyezR7e5yT2ZwTDFF5ps","name":"Kai Angel"}],"displayArtist":"Kai Angel","explicitStatus":"","version":""},{"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":""},{"id":"6kyJrHqHmnC3LxWtldP4tZ","name":"Shh...","artist":"Kai Angel","artistId":"2FXyezR7e5yT2ZwTDFF5ps","coverArt":"al-6kyJrHqHmnC3LxWtldP4tZ_6aa46d8e","songCount":14,"duration":2421,"playCount":67,"created":"2026-09-03T21:09:56.10902198Z","year":2026,"genre":"Hip-Hop","played":"2026-09-20T21:18:16.949825983Z","userRating":0,"genres":[{"name":"Hip-Hop"},{"name":"Rap"}],"musicBrainzId":"","isCompilation":false,"sortName":"shh...","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[{"name":"Angel"}],"moods":[],"artists":[{"id":"2FXyezR7e5yT2ZwTDFF5ps","name":"Kai Angel"}],"displayArtist":"Kai Angel","explicitStatus":"","version":""}]}}} \ No newline at end of file diff --git a/Packages/AmphlifyKit/Tests/SourceSubsonicTests/Fixtures/artists.json b/Packages/AmphlifyKit/Tests/SourceSubsonicTests/Fixtures/artists.json new file mode 100644 index 0000000..bed0bd9 --- /dev/null +++ b/Packages/AmphlifyKit/Tests/SourceSubsonicTests/Fixtures/artists.json @@ -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-22qO7WPYmgKqChrt72BFOf_0","albumCount":13,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTIycU83V1BZbWdLcUNocnQ3MkJGT2ZfMCIsImlzcyI6Ik5EIn0.Y-Np3eV4ohjOc3kVSxASWy7qAMG_YxHohHY5fsNbB2A?size=600","musicBrainzId":"","sortName":"рай не сегодня","roles":["composer","performer","maincredit","albumartist","artist"]},{"id":"4AEooAaGF5g70Dz6HEVR9t","name":"рэйчи","coverArt":"ar-4AEooAaGF5g70Dz6HEVR9t_0","albumCount":1,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTRBRW9vQWFHRjVnNzBEejZIRVZSOXRfMCIsImlzcyI6Ik5EIn0.jJ3tzdh6o_VYcDorBwsTfe5jSkfFcyUiIi5n1Jw8NR4?size=600","musicBrainzId":"","sortName":"рэйчи","roles":["artist","maincredit","albumartist"]},{"id":"6CWFWrWeNIMzjn6U1Ye7fg","name":"Сюзанна","coverArt":"ar-6CWFWrWeNIMzjn6U1Ye7fg_0","albumCount":1,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTZDV0ZXcldlTklNempuNlUxWWU3ZmdfMCIsImlzcyI6Ik5EIn0.fl_0u53N6M_6WYPIk5vgTdxqTjlr9Rg9OZY891ESiDA?size=600","musicBrainzId":"","sortName":"сюзанна","roles":["artist","maincredit","albumartist"]},{"id":"4dFyANqGvMN1ncDH8uQIXS","name":"Сюзанна,zavet","coverArt":"ar-4dFyANqGvMN1ncDH8uQIXS_0","albumCount":1,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTRkRnlBTnFHdk1OMW5jREg4dVFJWFNfMCIsImlzcyI6Ik5EIn0.8_tcqangoml_oCl2dDDgyCcw9V74cSCujlRMZuFge80?size=600","musicBrainzId":"","sortName":"сюзанна,zavet","roles":["albumartist","maincredit"]},{"id":"1EKMdgwWJQWKKwMurtJ1up","name":"темныи принц","coverArt":"ar-1EKMdgwWJQWKKwMurtJ1up_0","albumCount":1,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTFFS01kZ3dXSlFXS0t3TXVydEoxdXBfMCIsImlzcyI6Ik5EIn0.lKQvzRE5TLXkcUa0WiXn6NCe7JPtFLdy2CoVlbytpgs?size=600","musicBrainzId":"","sortName":"темныи принц","roles":["artist","maincredit","albumartist"]},{"id":"3r67QtGcmRhNEJhtqdNM1K","name":"тёмный принц","coverArt":"ar-3r67QtGcmRhNEJhtqdNM1K_0","albumCount":3,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTNyNjdRdEdjbVJoTkVKaHRxZE5NMUtfMCIsImlzcyI6Ik5EIn0.lsaJEeUXtaWM8ZTo27F4CjE4VwlVy2spnEa61akZ6zE?size=600","musicBrainzId":"","sortName":"тёмный принц","roles":["artist","maincredit","albumartist"]}]},{"name":"A","artist":[{"id":"5b63hpimhe6HfsDUjFYMN1","name":"amekudeku","coverArt":"ar-5b63hpimhe6HfsDUjFYMN1_0","albumCount":2,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTViNjNocGltaGU2SGZzRFVqRllNTjFfMCIsImlzcyI6Ik5EIn0.4UTQ_JIKpZ4uq8g0fdUjCc0qNGViv6qO-G8aqCb6C2E?size=600","musicBrainzId":"","sortName":"amekudeku","roles":["maincredit","albumartist","artist","composer"]},{"id":"1X634Dwyeuc9aCScrj5S3x","name":"Amiina","coverArt":"ar-1X634Dwyeuc9aCScrj5S3x_0","albumCount":1,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTFYNjM0RHd5ZXVjOWFDU2NyajVTM3hfMCIsImlzcyI6Ik5EIn0.8LBKebqRSNn5Kd4V45dLUJyG8NMgg6jQFxsJE0EhJzM?size=600","musicBrainzId":"","sortName":"amiina","roles":["artist","performer","maincredit","albumartist"]},{"id":"1XA4JP1bkoWdQ3ou23UE6I","name":"AOKI,hayato \u0026 haruka nakamura","coverArt":"ar-1XA4JP1bkoWdQ3ou23UE6I_0","albumCount":1,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTFYQTRKUDFia29XZFEzb3UyM1VFNklfMCIsImlzcyI6Ik5EIn0.W8d9L3a6_de-Xkd_T87SfiDayloLXFhIBbXDQZqGmSE?size=600","musicBrainzId":"","sortName":"aoki,hayato \u0026 haruka nakamura","roles":["artist","composer","performer","maincredit","albumartist"]},{"id":"7byFxTas9oTmNJEpZpkady","name":"AokigaharaRN","coverArt":"ar-7byFxTas9oTmNJEpZpkady_0","albumCount":1,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTdieUZ4VGFzOW9UbU5KRXBacGthZHlfMCIsImlzcyI6Ik5EIn0.wcwT90vk7nQtIxhvHlk_rn-cLD7Donnzmzf3akimTtA?size=600","musicBrainzId":"","sortName":"aokigahararn","roles":["maincredit","albumartist","artist"]}]},{"name":"B","artist":[{"id":"6HKZ3zGU5N4fxJhM3WEP8B","name":"baadee","coverArt":"ar-6HKZ3zGU5N4fxJhM3WEP8B_0","albumCount":1,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTZIS1ozekdVNU40ZnhKaE0zV0VQOEJfMCIsImlzcyI6Ik5EIn0.SCzmWjLSK83AOYUrnprHRaN8_e1pjqZx5xKqt4HdZog?size=600","musicBrainzId":"","sortName":"baadee","roles":["artist","composer","maincredit","albumartist"]},{"id":"18kcqt8NLNywKHmIbv06pd","name":"Buckshot","coverArt":"ar-18kcqt8NLNywKHmIbv06pd_0","albumCount":1,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTE4a2NxdDhOTE55d0tIbUlidjA2cGRfMCIsImlzcyI6Ik5EIn0.SxZhuX2YIg0IcLLVrDkhDn71igCD4Q8gemHCkgDGZl0?size=600","musicBrainzId":"","sortName":"buckshot","roles":["albumartist","artist","maincredit"]},{"id":"0f6qFzlx5zzeRbsdreHE90","name":"Burial","coverArt":"ar-0f6qFzlx5zzeRbsdreHE90_0","albumCount":1,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTBmNnFGemx4NXp6ZVJic2RyZUhFOTBfMCIsImlzcyI6Ik5EIn0.M6NDDPRIkoULL4NsukQGY_vWJX195Lwx9dTSZUUVEfU?size=600","musicBrainzId":"","sortName":"burial","roles":["composer","performer","producer","maincredit","albumartist","artist"]}]},{"name":"C","artist":[{"id":"6nz4rI0viVYELtYOOEnXyE","name":"Clonnex","coverArt":"ar-6nz4rI0viVYELtYOOEnXyE_0","albumCount":5,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTZuejRySTB2aVZZRUx0WU9PRW5YeUVfMCIsImlzcyI6Ik5EIn0.2SzNNN9YuVhqmK35PLT4_Hw-EzHerOo1Bm2W1gaeQ2g?size=600","musicBrainzId":"","sortName":"clonnex","roles":["maincredit","albumartist","artist","composer","producer"]}]},{"name":"D","artist":[{"id":"5jOJTyt2uMZAliz3IOudoz","name":"Denya8","coverArt":"ar-5jOJTyt2uMZAliz3IOudoz_0","albumCount":2,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTVqT0pUeXQydU1aQWxpejNJT3Vkb3pfMCIsImlzcyI6Ik5EIn0.raTobFAi3iKMhmu-MFBZ8jagSgrJ7pUPyrTUm0PtE2o?size=600","musicBrainzId":"","sortName":"denya8","roles":["artist","composer","maincredit","albumartist"]},{"id":"4923fQPHihzzaiU5iYnXJO","name":"dirty punk","coverArt":"ar-4923fQPHihzzaiU5iYnXJO_0","albumCount":1,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTQ5MjNmUVBIaWh6emFpVTVpWW5YSk9fMCIsImlzcyI6Ik5EIn0.LxIKTfZNLhAlLilytg4cTuE2Qcd2M3oFCSPcVhJNsOU?size=600","musicBrainzId":"","sortName":"dirty punk","roles":["albumartist","artist","composer","maincredit"]},{"id":"66RsGWTEo8JPL8Nn4qbwJQ","name":"DONDA","coverArt":"ar-66RsGWTEo8JPL8Nn4qbwJQ_0","albumCount":1,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTY2UnNHV1RFbzhKUEw4Tm40cWJ3SlFfMCIsImlzcyI6Ik5EIn0.G57z5Anix97_ao2n3-nL7bQZOTcqpPk7RaEsmDw9J9g?size=600","musicBrainzId":"","sortName":"donda","roles":["albumartist","artist","maincredit"]},{"id":"2o9RENrzMtJnTO5701rfVU","name":"drowsyy","coverArt":"ar-2o9RENrzMtJnTO5701rfVU_0","albumCount":1,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTJvOVJFTnJ6TXRKblRPNTcwMXJmVlVfMCIsImlzcyI6Ik5EIn0.zjJuqXc1Q7uz7CYCLMpHSywN1r_1gX8UqUZ76XItXJ0?size=600","musicBrainzId":"","sortName":"drowsyy","roles":["albumartist","artist","composer","producer","maincredit"]}]},{"name":"E","artist":[{"id":"0GEuFay3P1f4WhApEF2uOE","name":"evan wheel","coverArt":"ar-0GEuFay3P1f4WhApEF2uOE_0","albumCount":1,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTBHRXVGYXkzUDFmNFdoQXBFRjJ1T0VfMCIsImlzcyI6Ik5EIn0.nSGHpXG5V1hIM9ULd8qhpHbpfJRB_Y0QVBLUSZn-4BQ?size=600","musicBrainzId":"","sortName":"evan wheel","roles":["composer","maincredit","albumartist","artist"]}]},{"name":"F","artist":[{"id":"724HMvJCNhak92oE4Mw3If","name":"FADE031","coverArt":"ar-724HMvJCNhak92oE4Mw3If_0","albumCount":1,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTcyNEhNdkpDTmhhazkyb0U0TXczSWZfMCIsImlzcyI6Ik5EIn0.5Urp-iO5rtPwRfvn65TcgWkynV6WzIoTU8Bh85R4zLs?size=600","musicBrainzId":"","sortName":"fade031","roles":["albumartist","artist","composer","maincredit"]},{"id":"5TF4NqQ1jdxztyH9lXtlDc","name":"fakemink","coverArt":"ar-5TF4NqQ1jdxztyH9lXtlDc_0","albumCount":3,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTVURjROcVExamR4enR5SDlsWHRsRGNfMCIsImlzcyI6Ik5EIn0.yP7RADnuIriCLnqmhLrBJNEqrdLAk2Hxx9R1X67_IGs?size=600","musicBrainzId":"","sortName":"fakemink","roles":["artist","composer","maincredit","albumartist"]}]},{"name":"G","artist":[{"id":"6Atjc7fhemv7XpDcN1Eg2R","name":"glaive","coverArt":"ar-6Atjc7fhemv7XpDcN1Eg2R_0","albumCount":1,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTZBdGpjN2ZoZW12N1hwRGNOMUVnMlJfMCIsImlzcyI6Ik5EIn0.InTadi6ZPDkUgs-BXi9ZzOzbnA1Fh8BHWCVrn0iAn_s?size=600","musicBrainzId":"","sortName":"glaive","roles":["artist","composer","producer","maincredit","albumartist"]}]},{"name":"H","artist":[{"id":"2Ks0H5m0Cn1LXTYEkfsPMD","name":"haruka nakamura","coverArt":"ar-2Ks0H5m0Cn1LXTYEkfsPMD_0","albumCount":3,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTJLczBINW0wQ24xTFhUWUVrZnNQTURfMCIsImlzcyI6Ik5EIn0.qyT7_ZLgFlIaP7-r9Ic6NAj9-UUdRaLKelcTzFYTO-A?size=600","musicBrainzId":"","sortName":"haruka nakamura","roles":["composer","performer","maincredit","albumartist","artist"]},{"id":"6q6Q7MmEiWdjlqT8TAFZUL","name":"Hikariii","coverArt":"ar-6q6Q7MmEiWdjlqT8TAFZUL_0","albumCount":1,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTZxNlE3TW1FaVdkamxxVDhUQUZaVUxfMCIsImlzcyI6Ik5EIn0.hBmiMdl9C5_NIRf6KZG0LMn-N1ijzUF7HWgswItpkKw?size=600","musicBrainzId":"","sortName":"hikariii","roles":["composer","maincredit","albumartist","artist"]},{"id":"5nixPYpmgfAPMYSqsAEk6K","name":"Hikerii","coverArt":"ar-5nixPYpmgfAPMYSqsAEk6K_0","albumCount":1,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTVuaXhQWXBtZ2ZBUE1ZU3FzQUVrNktfMCIsImlzcyI6Ik5EIn0.fEEGSjAb3oopJu-gT0PgGpP6HqWdjmGOhxLlbSW1kK8?size=600","musicBrainzId":"","sortName":"hikerii","roles":["composer","maincredit","albumartist","artist"]}]},{"name":"I","artist":[{"id":"2yeddrgIGN77Xyj2rWXWs3","name":"IAN HOPELESS","coverArt":"ar-2yeddrgIGN77Xyj2rWXWs3_0","albumCount":2,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTJ5ZWRkcmdJR043N1h5ajJyV1hXczNfMCIsImlzcyI6Ik5EIn0.wfm0M_faeANsMrxpFQ1DxBIEoNWHix4-Tu9MQpuTv90?size=600","musicBrainzId":"","sortName":"ian hopeless","roles":["maincredit","albumartist","artist","composer"]},{"id":"5n6zaJC3sO9QwX6eUVwm4l","name":"ICELOWSIDE","coverArt":"ar-5n6zaJC3sO9QwX6eUVwm4l_0","albumCount":1,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTVuNnphSkMzc085UXdYNmVVVndtNGxfMCIsImlzcyI6Ik5EIn0.A2OvGrf4SFP6Wmnr1__XoxzxRaK7k4-tOEqBlhz3wTQ?size=600","musicBrainzId":"","sortName":"icelowside","roles":["artist","composer","maincredit","albumartist"]},{"id":"2LN5WRdGr6YhvwBw5LIPz9","name":"Interpol","coverArt":"ar-2LN5WRdGr6YhvwBw5LIPz9_0","albumCount":1,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTJMTjVXUmRHcjZZaHZ3Qnc1TElQejlfMCIsImlzcyI6Ik5EIn0.WvqgC6ldC-SCbMELotAlkMaZ_dybuM7sJRigPOiG93o?size=600","musicBrainzId":"","sortName":"interpol","roles":["artist","performer","maincredit","albumartist"]},{"id":"6XaghgoxtyVolkxBeRKvFJ","name":"itzice","coverArt":"ar-6XaghgoxtyVolkxBeRKvFJ_0","albumCount":1,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTZYYWdoZ294dHlWb2xreEJlUkt2RkpfMCIsImlzcyI6Ik5EIn0.83HQ_iDkec5HGQkwpqR7hJT-PtWQWW7TR6MdK4Pde6Q?size=600","musicBrainzId":"","sortName":"itzice","roles":["albumartist","artist","maincredit"]}]},{"name":"K","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":["composer","performer","producer","maincredit","albumartist","artist"]},{"id":"2tRU5FNknyFpmpMbkVt3cq","name":"kantaa","coverArt":"ar-2tRU5FNknyFpmpMbkVt3cq_0","albumCount":1,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTJ0UlU1Rk5rbnlGcG1wTWJrVnQzY3FfMCIsImlzcyI6Ik5EIn0.vN1k9fQon2sb5GS9YD6dYBJmIfh5vA96aud0HKB2KBo?size=600","musicBrainzId":"","sortName":"kantaa","roles":["artist","composer","producer","maincredit","albumartist"]},{"id":"02DiEqiNBn3idZvY8XP1lr","name":"Kanye West","coverArt":"ar-02DiEqiNBn3idZvY8XP1lr_0","albumCount":6,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTAyRGlFcWlOQm4zaWRadlk4WFAxbHJfMCIsImlzcyI6Ik5EIn0.3Sz5mtAiQanbsWEPI8VnmRFxwaolTJec1GQYhgr2Few?size=600","musicBrainzId":"","sortName":"kanye west","roles":["producer","maincredit","albumartist","artist","composer"]},{"id":"38OzEcIhEKGADUaQsERiuH","name":"Kinoko Teikoku","coverArt":"ar-38OzEcIhEKGADUaQsERiuH_0","albumCount":1,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTM4T3pFY0loRUtHQURVYVFzRVJpdUhfMCIsImlzcyI6Ik5EIn0.7c-3qFJCa-0-eI1PmfvR84T9ZjYhMnDGB3i8Fdz1hAI?size=600","musicBrainzId":"","sortName":"kinoko teikoku","roles":["artist","performer","maincredit","albumartist"]},{"id":"2o8aTjfmh7xHPQT9KtoqY7","name":"kirra","coverArt":"ar-2o8aTjfmh7xHPQT9KtoqY7_0","albumCount":1,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTJvOGFUamZtaDd4SFBRVDlLdG9xWTdfMCIsImlzcyI6Ik5EIn0.2UGzZoNWnK6JN3O9UChzzdlByJt7CQ-SZvGGpVOEZ1s?size=600","musicBrainzId":"","sortName":"kirra","roles":["albumartist","maincredit"]},{"id":"5p11l3dOsKjMlcd8HBxwIQ","name":"KVESTAR","coverArt":"ar-5p11l3dOsKjMlcd8HBxwIQ_0","albumCount":2,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTVwMTFsM2RPc0tqTWxjZDhIQnh3SVFfMCIsImlzcyI6Ik5EIn0.7cOwp0gXnpbrBdMK6tjC15m3yheusYIHs79z-OSjN_g?size=600","musicBrainzId":"","sortName":"kvestar","roles":["artist","composer","maincredit","albumartist"]},{"id":"68bJfG7ShRByVvC44edWjB","name":"kyNZai","coverArt":"ar-68bJfG7ShRByVvC44edWjB_0","albumCount":2,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTY4YkpmRzdTaFJCeVZ2QzQ0ZWRXakJfMCIsImlzcyI6Ik5EIn0.gXaYs_Vj29o5bNaFTW46DAiOtqNeYNVEeEtndBx3gOk?size=600","musicBrainzId":"","sortName":"kynzai","roles":["artist","composer","producer","maincredit","albumartist"]}]},{"name":"L","artist":[{"id":"02kw7dvTBVLsOPfd2Cm8oG","name":"Ling tosite sigure","coverArt":"ar-02kw7dvTBVLsOPfd2Cm8oG_0","albumCount":2,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTAya3c3ZHZUQlZMc09QZmQyQ204b0dfMCIsImlzcyI6Ik5EIn0.WjpbZqfXMFrApmr1VZQ8KJph_U2KVrFg8W4jDGDbHMU?size=600","musicBrainzId":"","sortName":"ling tosite sigure","roles":["maincredit","albumartist","artist","performer"]},{"id":"53hR5FbRRAhVDnstybxtQP","name":"LLLL","coverArt":"ar-53hR5FbRRAhVDnstybxtQP_0","albumCount":1,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTUzaFI1RmJSUkFoVkRuc3R5Ynh0UVBfMCIsImlzcyI6Ik5EIn0.cOAIx6_Lqn2PDcOlgY-upTvPabDq0upub3VBEZk6pDM?size=600","musicBrainzId":"","sortName":"llll","roles":["producer","maincredit","albumartist","artist"]},{"id":"3w3c8gSvt9jfHJymkUCjNV","name":"Lucy Bedroque","coverArt":"ar-3w3c8gSvt9jfHJymkUCjNV_0","albumCount":1,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTN3M2M4Z1N2dDlqZkhKeW1rVUNqTlZfMCIsImlzcyI6Ik5EIn0.jXJlL67Pj9f9lm3IeFw8RHf-Q6VOE5B6Koj1zsPNJps?size=600","musicBrainzId":"","sortName":"lucy bedroque","roles":["albumartist","artist","composer","performer","maincredit"]},{"id":"57W6fk5PWwj28VJSELDx94","name":"Luude","coverArt":"ar-57W6fk5PWwj28VJSELDx94_0","albumCount":1,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTU3VzZmazVQV3dqMjhWSlNFTER4OTRfMCIsImlzcyI6Ik5EIn0.NJKW2q9vnb7O1Xuf7Th-PlkPEBRR-OBZwNkQLOhBry8?size=600","musicBrainzId":"","sortName":"luude","roles":["artist","producer","maincredit","albumartist"]},{"id":"3JpQ2ekgf8swJrUf4I9HCx","name":"lyralei","coverArt":"ar-3JpQ2ekgf8swJrUf4I9HCx_0","albumCount":1,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTNKcFEyZWtnZjhzd0pyVWY0STlIQ3hfMCIsImlzcyI6Ik5EIn0.H7A8Hp0SXuZE3e4ZFRSwPlp3hl9yDRyTGtbo6u9AIAg?size=600","musicBrainzId":"","sortName":"lyralei","roles":["composer","maincredit","albumartist","artist"]}]},{"name":"M","artist":[{"id":"7Dy79oui9xLw7S5an2x0nk","name":"madk1d","coverArt":"ar-7Dy79oui9xLw7S5an2x0nk_0","albumCount":2,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTdEeTc5b3VpOXhMdzdTNWFuMngwbmtfMCIsImlzcyI6Ik5EIn0.M2bWgb4aAYPsipNM3cauGonrSa8gz93Oi1G-KcuHgbA?size=600","musicBrainzId":"","sortName":"madk1d","roles":["albumartist","artist","composer","maincredit"]},{"id":"4l5J0sD4TCoVAadVgxSyBP","name":"MARSI","coverArt":"ar-4l5J0sD4TCoVAadVgxSyBP_0","albumCount":1,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTRsNUowc0Q0VENvVkFhZFZneFN5QlBfMCIsImlzcyI6Ik5EIn0.Rpq2zUd-KCKm_4VkiWBJxMZ-drq7WAijE0GlGZUv6So?size=600","musicBrainzId":"","sortName":"marsi","roles":["artist","composer","maincredit","albumartist"]},{"id":"0eM2cEfwBxjCBZALmNWNJu","name":"Merlin White","coverArt":"ar-0eM2cEfwBxjCBZALmNWNJu_0","albumCount":1,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTBlTTJjRWZ3QnhqQ0JaQUxtTldOSnVfMCIsImlzcyI6Ik5EIn0.q5dvGkeIJut2eaAjpFReQ299SkQZLciB-yi8tdBOQ90?size=600","musicBrainzId":"","sortName":"merlin white","roles":["maincredit","albumartist","artist","composer"]},{"id":"6czjE6pPpWVuxiDRbLPGWi","name":"Midix","coverArt":"ar-6czjE6pPpWVuxiDRbLPGWi_0","albumCount":2,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTZjempFNnBQcFdWdXhpRFJiTFBHV2lfMCIsImlzcyI6Ik5EIn0.jZ2QsrjtbCZKYSfblvyRtwIkzZaVB4Acas0bM8C27HI?size=600","musicBrainzId":"","sortName":"midix","roles":["albumartist","artist","composer","producer","maincredit"]},{"id":"5dpDaE9Jn46Zsot7XgbuIQ","name":"Ms Gloom","coverArt":"ar-5dpDaE9Jn46Zsot7XgbuIQ_0","albumCount":1,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTVkcERhRTlKbjQ2WnNvdDdYZ2J1SVFfMCIsImlzcyI6Ik5EIn0.HQQwma4aCudGjfCGI2CiiqLnbe-_5xyUuLPE8j14r_4?size=600","musicBrainzId":"","sortName":"ms gloom","roles":["albumartist","artist","maincredit"]},{"id":"3cByLCspfJAVaa8uIzhenz","name":"múm","coverArt":"ar-3cByLCspfJAVaa8uIzhenz_0","albumCount":2,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTNjQnlMQ3NwZkpBVmFhOHVJemhlbnpfMCIsImlzcyI6Ik5EIn0.o_4g8oqMUmfoC1fac9VPoCFVsMQGYqQ8oR_RI90GinY?size=600","musicBrainzId":"","sortName":"mum","roles":["artist","composer","performer","maincredit","albumartist"]}]},{"name":"N","artist":[{"id":"1RlholhxfO8YebLSmsyb4o","name":"never die","coverArt":"ar-1RlholhxfO8YebLSmsyb4o_0","albumCount":13,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTFSbGhvbGh4Zk84WWViTFNtc3liNG9fMCIsImlzcyI6Ik5EIn0.ac9tXR5mlKI-vEW_XZYEQjt-hf0qzykJvw9z9-6btvw?size=600","musicBrainzId":"","sortName":"never die","roles":["composer","producer","maincredit","albumartist","artist"]},{"id":"1O3tH1OWBPZmewMIvjBKT0","name":"New Sylveon","coverArt":"ar-1O3tH1OWBPZmewMIvjBKT0_0","albumCount":11,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTFPM3RIMU9XQlBabWV3TUl2akJLVDBfMCIsImlzcyI6Ik5EIn0.ud3yw2cGYlQOsF5NIUrGLzqaDDmX1EKIHo71GnY866Q?size=600","musicBrainzId":"","sortName":"new sylveon","roles":["producer","maincredit","albumartist","artist","composer"]}]},{"name":"P","artist":[{"id":"4Ks5djIxGF54hZy8r6X1fc","name":"Poshlaya Molly","coverArt":"ar-4Ks5djIxGF54hZy8r6X1fc_0","albumCount":2,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTRLczVkakl4R0Y1NGhaeThyNlgxZmNfMCIsImlzcyI6Ik5EIn0.BYfDBNL3dhBudy2qoyIyVjfuvrCtGJ0F0FcppHfSbws?size=600","musicBrainzId":"","sortName":"poshlaya molly","roles":["albumartist","artist","maincredit"]},{"id":"6zHcoSPaK36t0UqjWUZRBG","name":"prettifun","coverArt":"ar-6zHcoSPaK36t0UqjWUZRBG_0","albumCount":2,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTZ6SGNvU1BhSzM2dDBVcWpXVVpSQkdfMCIsImlzcyI6Ik5EIn0.BZOouyfZpzA44g_-qzIzjZG0L3Dh-GkXhimxL3PVioM?size=600","musicBrainzId":"","sortName":"prettifun","roles":["albumartist","artist","performer","maincredit"]},{"id":"1VNJ7bUDSsv6efQnIwtnxK","name":"Pure","coverArt":"ar-1VNJ7bUDSsv6efQnIwtnxK_0","albumCount":1,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTFWTko3YlVEU3N2NmVmUW5Jd3RueEtfMCIsImlzcyI6Ik5EIn0.Wy53Sva0yQ_ZDHKEudr1LB1cyo5lFJe_e9Lv9LKCMm8?size=600","musicBrainzId":"","sortName":"pure","roles":["albumartist","artist","composer","maincredit"]}]},{"name":"Q","artist":[{"id":"09rULau0M6sC9mRS3Mr3DZ","name":"QT","coverArt":"ar-09rULau0M6sC9mRS3Mr3DZ_0","albumCount":2,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTA5clVMYXUwTTZzQzltUlMzTXIzRFpfMCIsImlzcyI6Ik5EIn0.yNxaKCGDxDjLd55_qR3DNlkQncXFnilQ5C7gSCRS6Jo?size=600","musicBrainzId":"","sortName":"qt","roles":["artist","composer","producer","maincredit","albumartist"]},{"id":"74qITFok5LWugUcixZizjS","name":"QT \u0026 PUNKSHOW","coverArt":"ar-74qITFok5LWugUcixZizjS_0","albumCount":2,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTc0cUlURm9rNUxXdWdVY2l4Wml6alNfMCIsImlzcyI6Ik5EIn0.kfuH5ChiU3uxeRxMhV-dhUAAJjAAtL-NiSCkqUCDcyA?size=600","musicBrainzId":"","sortName":"qt \u0026 punkshow","roles":["maincredit","albumartist"]}]},{"name":"R","artist":[{"id":"5a6B9Aoi4p9MsZWLQz4om9","name":"Ranger Over","coverArt":"ar-5a6B9Aoi4p9MsZWLQz4om9_0","albumCount":1,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTVhNkI5QW9pNHA5TXNaV0xRejRvbTlfMCIsImlzcyI6Ik5EIn0.DwbWgOAFcHgvHUoq1BgUGKJZDoLRsNdtzrNLDhLOWOw?size=600","musicBrainzId":"","sortName":"ranger over","roles":["albumartist","artist","composer","maincredit"]},{"id":"5PjcH8aQAlFrfWqvOnqua5","name":"rizza","coverArt":"ar-5PjcH8aQAlFrfWqvOnqua5_0","albumCount":2,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTVQamNIOGFRQWxGcmZXcXZPbnF1YTVfMCIsImlzcyI6Ik5EIn0.fdABFQxMiD7hf11QLUfIWPxwVTREhkWtvJc4u-rdTOw?size=600","musicBrainzId":"","sortName":"rizza","roles":["maincredit","albumartist","artist","composer","producer"]}]},{"name":"S","artist":[{"id":"3IiXOaGUHA6ZHaW3IsWkm6","name":"SABU","coverArt":"ar-3IiXOaGUHA6ZHaW3IsWkm6_0","albumCount":1,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTNJaVhPYUdVSEE2WkhhVzNJc1drbTZfMCIsImlzcyI6Ik5EIn0.TnYfExQWWy3r7w6EJJIHAEAQshmXl9ll4wta9CstQXU?size=600","musicBrainzId":"","sortName":"sabu","roles":["artist","composer","producer","maincredit","albumartist"]},{"id":"4fKYKrsm8mmpys7Fp5oRyN","name":"SALUKI","coverArt":"ar-4fKYKrsm8mmpys7Fp5oRyN_0","albumCount":3,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTRmS1lLcnNtOG1tcHlzN0ZwNW9SeU5fMCIsImlzcyI6Ik5EIn0.toG1IGimxlYGU3AzI1P0OGBzj-tWpVNEOo5axUZm-Ls?size=600","musicBrainzId":"","sortName":"saluki","roles":["maincredit","albumartist","artist","composer","producer"]},{"id":"5piJT3LK4juV6RpcUzq6rG","name":"Sewerslvt","coverArt":"ar-5piJT3LK4juV6RpcUzq6rG_0","albumCount":1,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTVwaUpUM0xLNGp1VjZScGNVenE2ckdfMCIsImlzcyI6Ik5EIn0.1wq7ftEOnh1-8Lhx9iIcl8dnEaLgR-VcWKHeqz5fSs4?size=600","musicBrainzId":"","sortName":"sewerslvt","roles":["maincredit","albumartist","artist","composer","performer"]},{"id":"1vguCdNkDOcUh7vmaC4o99","name":"silver gloria","coverArt":"ar-1vguCdNkDOcUh7vmaC4o99_0","albumCount":1,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTF2Z3VDZE5rRE9jVWg3dm1hQzRvOTlfMCIsImlzcyI6Ik5EIn0.VSPtzoJP5WA3MRVbhhnBI9sZNnXyvNH9Uahmf5yhDQA?size=600","musicBrainzId":"","sortName":"silver gloria","roles":["artist","composer","maincredit","albumartist"]},{"id":"3mWJRO3PGE7pw8BQmNEhun","name":"siouxxie sixxsta","coverArt":"ar-3mWJRO3PGE7pw8BQmNEhun_0","albumCount":1,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTNtV0pSTzNQR0U3cHc4QlFtTkVodW5fMCIsImlzcyI6Ik5EIn0.dCog31ODTB2-ydR09mfSf9p9heybL4cvucbEcIO24iA?size=600","musicBrainzId":"","sortName":"siouxxie sixxsta","roles":["albumartist","artist","maincredit"]},{"id":"3pBe8GsWJqYRUeKVzVAXc7","name":"Slava KPSS","coverArt":"ar-3pBe8GsWJqYRUeKVzVAXc7_0","albumCount":2,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTNwQmU4R3NXSnFZUlVlS1Z6VkFYYzdfMCIsImlzcyI6Ik5EIn0.aDvLAWzcTx95-vDmTln3v2ou2QPqEK8TH8wkfKCQA2I?size=600","musicBrainzId":"","sortName":"slava kpss","roles":["albumartist","artist","maincredit"]},{"id":"7DvjlX7vKw5rY7XvRTwaVn","name":"Slayer Diesel","coverArt":"ar-7DvjlX7vKw5rY7XvRTwaVn_0","albumCount":1,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTdEdmpsWDd2S3c1clk3WHZSVHdhVm5fMCIsImlzcyI6Ik5EIn0.mXboe8yYjeh9cLLtkNbOafFDG-U2l0o-3cmnGvL1NPo?size=600","musicBrainzId":"","sortName":"slayer diesel","roles":["maincredit","albumartist","artist","composer","producer"]},{"id":"2YnvHCogxcvJyosR2BCOvL","name":"Sqwore","coverArt":"ar-2YnvHCogxcvJyosR2BCOvL_0","albumCount":1,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTJZbnZIQ29neGN2Snlvc1IyQkNPdkxfMCIsImlzcyI6Ik5EIn0.2tTExmuMLBLwD46KSGi0GMrSCCf5tLcbOW5zIZMAM3E?size=600","musicBrainzId":"","sortName":"sqwore","roles":["albumartist","artist","composer","maincredit"]}]},{"name":"T","artist":[{"id":"3iADjBvDrFTdoQZov2MxJt","name":"TOSHCHIII","coverArt":"ar-3iADjBvDrFTdoQZov2MxJt_0","albumCount":1,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTNpQURqQnZEckZUZG9RWm92Mk14SnRfMCIsImlzcyI6Ik5EIn0.RRxUT6Rjo4kl2E9dIhQfclWBhbkQP43SC_NFnNt32t4?size=600","musicBrainzId":"","sortName":"toshchiii","roles":["maincredit","albumartist","artist","composer"]}]},{"name":"V","artist":[{"id":"0LDxWEsCdXB1kzWolG2bb0","name":"VACÍO","coverArt":"ar-0LDxWEsCdXB1kzWolG2bb0_0","albumCount":1,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTBMRHhXRXNDZFhCMWt6V29sRzJiYjBfMCIsImlzcyI6Ik5EIn0.eDEtARw5BPAXs0g7VGclnyXOnpQvENYbeuVPoCdthAw?size=600","musicBrainzId":"","sortName":"vacio","roles":["maincredit","albumartist","artist"]},{"id":"00VCVR5fmexJwulmPR74Nu","name":"VELIAL SQUAD","coverArt":"ar-00VCVR5fmexJwulmPR74Nu_0","albumCount":1,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTAwVkNWUjVmbWV4Snd1bG1QUjc0TnVfMCIsImlzcyI6Ik5EIn0.Y0Qy2t1-7DwBmIuZNbd84CIJepOzoktdeOALMapl4xE?size=600","musicBrainzId":"","sortName":"velial squad","roles":["albumartist","artist","maincredit"]}]},{"name":"W","artist":[{"id":"0y7OcittSJlSLz3U183RBX","name":"woee33","coverArt":"ar-0y7OcittSJlSLz3U183RBX_0","albumCount":2,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTB5N09jaXR0U0psU0x6M1UxODNSQlhfMCIsImlzcyI6Ik5EIn0.YC-93F2KlB4t4d8d_MHCiE9rSuC8sCE0-GAmJRlQb1E?size=600","musicBrainzId":"","sortName":"woee33","roles":["composer","maincredit","albumartist","artist"]}]},{"name":"X-Z","artist":[{"id":"3rNgp5ZP4ZJ1KyKufSvbDO","name":"Ye","coverArt":"ar-3rNgp5ZP4ZJ1KyKufSvbDO_0","albumCount":1,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTNyTmdwNVpQNFpKMUt5S3VmU3ZiRE9fMCIsImlzcyI6Ik5EIn0.wLhqcqrd5WHG5d_D7FS-fbwkQOTJzwiZ_HpADH32vkA?size=600","musicBrainzId":"","sortName":"ye","roles":["albumartist","artist","maincredit"]},{"id":"2lzWXQ0Wd7RzG6RmnWUaTy","name":"zavet","coverArt":"ar-2lzWXQ0Wd7RzG6RmnWUaTy_0","albumCount":41,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTJseldYUTBXZDdSekc2Um1uV1VhVHlfMCIsImlzcyI6Ik5EIn0.cJ65AXcy6QlCumnYOk6ofwzRs6EqHaKH51EGQrT99tg?size=600","musicBrainzId":"bbb1d20c-b3ec-4b48-9a90-206289d92047","sortName":"zavet","roles":["maincredit","albumartist","artist","composer","producer"]},{"id":"7LMbjIoejtpv73X0I16NJZ","name":"zavet,VACÍO","coverArt":"ar-7LMbjIoejtpv73X0I16NJZ_0","albumCount":1,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTdMTWJqSW9lanRwdjczWDBJMTZOSlpfMCIsImlzcyI6Ik5EIn0.ZJRaweeM7GoWHRqqJicallfVE4neTOgMcYVMZkwkRoU?size=600","musicBrainzId":"","sortName":"zavet,vacio","roles":["maincredit","albumartist"]},{"id":"5k4pDGC8q3NeCd5pYr221I","name":"zwonkybeats","coverArt":"ar-5k4pDGC8q3NeCd5pYr221I_0","albumCount":1,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTVrNHBER0M4cTNOZUNkNXBZcjIyMUlfMCIsImlzcyI6Ik5EIn0.JhsaKEif9NG2ZbKPDrUDAwQlxiKVNPEQPwxiqaO7nog?size=600","musicBrainzId":"","sortName":"zwonkybeats","roles":["composer","producer","maincredit","albumartist","artist"]}]}],"lastModified":1790046730000,"ignoredArticles":"The El La Los Las Le Les Os As O A"}}} \ No newline at end of file diff --git a/Packages/AmphlifyKit/Tests/SourceSubsonicTests/Fixtures/extensions.json b/Packages/AmphlifyKit/Tests/SourceSubsonicTests/Fixtures/extensions.json new file mode 100644 index 0000000..49bad84 --- /dev/null +++ b/Packages/AmphlifyKit/Tests/SourceSubsonicTests/Fixtures/extensions.json @@ -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]}]}} \ No newline at end of file diff --git a/Packages/AmphlifyKit/Tests/SourceSubsonicTests/Fixtures/genres.json b/Packages/AmphlifyKit/Tests/SourceSubsonicTests/Fixtures/genres.json new file mode 100644 index 0000000..1c06b38 --- /dev/null +++ b/Packages/AmphlifyKit/Tests/SourceSubsonicTests/Fixtures/genres.json @@ -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}]}}} \ No newline at end of file diff --git a/Packages/AmphlifyKit/Tests/SourceSubsonicTests/Fixtures/lyricsEmpty.json b/Packages/AmphlifyKit/Tests/SourceSubsonicTests/Fixtures/lyricsEmpty.json new file mode 100644 index 0000000..210fe3f --- /dev/null +++ b/Packages/AmphlifyKit/Tests/SourceSubsonicTests/Fixtures/lyricsEmpty.json @@ -0,0 +1 @@ +{"subsonic-response":{"status":"ok","version":"1.16.1","type":"navidrome","serverVersion":"0.63.2 (be10f89c)","openSubsonic":true,"lyricsList":{}}} \ No newline at end of file diff --git a/Packages/AmphlifyKit/Tests/SourceSubsonicTests/Fixtures/ping.json b/Packages/AmphlifyKit/Tests/SourceSubsonicTests/Fixtures/ping.json new file mode 100644 index 0000000..2f963d0 --- /dev/null +++ b/Packages/AmphlifyKit/Tests/SourceSubsonicTests/Fixtures/ping.json @@ -0,0 +1 @@ +{"subsonic-response":{"status":"ok","version":"1.16.1","type":"navidrome","serverVersion":"0.63.2 (be10f89c)","openSubsonic":true}} \ No newline at end of file diff --git a/Packages/AmphlifyKit/Tests/SourceSubsonicTests/Fixtures/pingError.json b/Packages/AmphlifyKit/Tests/SourceSubsonicTests/Fixtures/pingError.json new file mode 100644 index 0000000..2f963d0 --- /dev/null +++ b/Packages/AmphlifyKit/Tests/SourceSubsonicTests/Fixtures/pingError.json @@ -0,0 +1 @@ +{"subsonic-response":{"status":"ok","version":"1.16.1","type":"navidrome","serverVersion":"0.63.2 (be10f89c)","openSubsonic":true}} \ No newline at end of file diff --git a/Packages/AmphlifyKit/Tests/SourceSubsonicTests/Fixtures/playlist.json b/Packages/AmphlifyKit/Tests/SourceSubsonicTests/Fixtures/playlist.json new file mode 100644 index 0000000..9700595 --- /dev/null +++ b/Packages/AmphlifyKit/Tests/SourceSubsonicTests/Fixtures/playlist.json @@ -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":"Рай не сегодня","contributors":[],"displayComposer":"","explicitStatus":"","groupings":[],"works":[],"movements":[]},{"id":"906jMvcy8Seii6e0NKOzZe","parent":"5UyFsOYWVBPvgbgNFH3Yta","isDir":false,"title":"скевоморфизм","album":"yjdsqqfkm,jv123","artist":"New Sylveon","coverArt":"mf-906jMvcy8Seii6e0NKOzZe_6a87682a","size":2726296,"contentType":"audio/mpeg","suffix":"mp3","duration":161,"bitRate":128,"path":"New Sylveon/yjdsqqfkm,jv123/скевоморфизм.mp3","playCount":1,"created":"2026-08-20T04:28:51.671633628Z","albumId":"5UyFsOYWVBPvgbgNFH3Yta","artistId":"1O3tH1OWBPZmewMIvjBKT0","type":"music","played":"2026-08-20T15:21:25.422739899Z","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":"composer","artist":{"id":"1O3tH1OWBPZmewMIvjBKT0","name":"New Sylveon"}},{"role":"producer","artist":{"id":"0ZyhDKBl72UfApUeX2Tu9e","name":"Princess Ketamine"}},{"role":"producer","artist":{"id":"1O3tH1OWBPZmewMIvjBKT0","name":"New Sylveon"}}],"displayComposer":"New Sylveon","explicitStatus":"","groupings":[],"works":[],"movements":[]},{"id":"jcbaBJi82fCYHpPHMWMTLi","parent":"6cjWYtQW4KZA5iQYMatUv5","isDir":false,"title":"i love you and it sounds stupid","album":"Y’all","artist":"glaive","coverArt":"mf-jcbaBJi82fCYHpPHMWMTLi_6a8767f4","size":2635238,"contentType":"audio/mpeg","suffix":"mp3","duration":160,"bitRate":128,"path":"glaive/Y’all/i love you and it sounds stupid.mp3","playCount":1,"created":"2026-08-20T04:43:43.703712623Z","albumId":"6cjWYtQW4KZA5iQYMatUv5","artistId":"6Atjc7fhemv7XpDcN1Eg2R","type":"music","played":"2026-08-20T14:51:59.697856359Z","bpm":0,"comment":"","sortName":"i love you and it sounds stupid","mediaType":"song","musicBrainzId":"","isrc":[],"genres":[],"replayGain":{},"channelCount":2,"samplingRate":44100,"bitDepth":0,"moods":[],"artists":[{"id":"6Atjc7fhemv7XpDcN1Eg2R","name":"glaive"}],"displayArtist":"glaive","albumArtists":[{"id":"6Atjc7fhemv7XpDcN1Eg2R","name":"glaive"}],"displayAlbumArtist":"glaive","contributors":[{"role":"composer","artist":{"id":"6Atjc7fhemv7XpDcN1Eg2R","name":"glaive"}},{"role":"composer","artist":{"id":"1CzweK9mYuvYSzOfsJpSeY","name":"Jeff Hazin"}},{"role":"composer","artist":{"id":"3e735O7r62YVt4LWsrYEPp","name":"Ralph Castelli"}},{"role":"composer","artist":{"id":"4DX98dUCrM0mD2xasDxwYw","name":"John Cunningham"}},{"role":"producer","artist":{"id":"1CzweK9mYuvYSzOfsJpSeY","name":"Jeff Hazin"}},{"role":"producer","artist":{"id":"3e735O7r62YVt4LWsrYEPp","name":"Ralph Castelli"}},{"role":"producer","artist":{"id":"4DX98dUCrM0mD2xasDxwYw","name":"John Cunningham"}},{"role":"producer","artist":{"id":"4V5tkjaLB16EMfRoe8pL9z","name":"Jasper Sheff"}}],"displayComposer":"glaive • Jeff Hazin • Ralph Castelli • John Cunningham","explicitStatus":"","groupings":[],"works":[],"movements":[]},{"id":"dno68nA8ap42FrJHaZXVIw","parent":"6maGFUWCKXGpXDTL5aLPMi","isDir":false,"title":"FUNERAL","album":"MARILYN MANSON","artist":"Kai Angel","track":11,"year":2023,"coverArt":"mf-dno68nA8ap42FrJHaZXVIw_6a876a13","size":18287837,"contentType":"audio/flac","suffix":"flac","duration":159,"bitRate":915,"path":"Kai Angel/MARILYN MANSON/11 - FUNERAL.flac","playCount":2,"created":"2026-08-20T04:33:25.326522276Z","albumId":"6maGFUWCKXGpXDTL5aLPMi","artistId":"2FXyezR7e5yT2ZwTDFF5ps","type":"music","played":"2026-08-26T18:10:29.563Z","bpm":0,"comment":"","sortName":"funeral","mediaType":"song","musicBrainzId":"","isrc":[],"genres":[],"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":"composer","artist":{"id":"2FXyezR7e5yT2ZwTDFF5ps","name":"Kai Angel"}},{"role":"producer","artist":{"id":"2FXyezR7e5yT2ZwTDFF5ps","name":"Kai Angel"}}],"displayComposer":"Kai Angel","explicitStatus":"","groupings":[],"works":[],"movements":[]},{"id":"3YrsUl3MYZtOBPqi6Kwatd","parent":"3ipXOgLl9SuujGdudoyU89","isDir":false,"title":"Ghost Town","album":"MOONCHILD","artist":"drowsyy","coverArt":"mf-3YrsUl3MYZtOBPqi6Kwatd_6a8767fd","size":2680169,"contentType":"audio/mpeg","suffix":"mp3","duration":154,"bitRate":128,"path":"drowsyy/MOONCHILD/Ghost Town.mp3","playCount":1,"created":"2026-08-20T04:41:47.928803639Z","albumId":"3ipXOgLl9SuujGdudoyU89","artistId":"2o9RENrzMtJnTO5701rfVU","type":"music","played":"2026-08-20T14:48:01.292563097Z","bpm":0,"comment":"","sortName":"ghost town","mediaType":"song","musicBrainzId":"","isrc":[],"genres":[],"replayGain":{},"channelCount":2,"samplingRate":44100,"bitDepth":0,"moods":[],"artists":[{"id":"2o9RENrzMtJnTO5701rfVU","name":"drowsyy"}],"displayArtist":"drowsyy","albumArtists":[{"id":"2o9RENrzMtJnTO5701rfVU","name":"drowsyy"}],"displayAlbumArtist":"drowsyy","contributors":[{"role":"composer","artist":{"id":"2o9RENrzMtJnTO5701rfVU","name":"drowsyy"}},{"role":"composer","artist":{"id":"3EBEVuZGnUXX7wjF1OtVnL","name":"LXNER"}},{"role":"producer","artist":{"id":"2o9RENrzMtJnTO5701rfVU","name":"drowsyy"}}],"displayComposer":"drowsyy • LXNER","explicitStatus":"","groupings":[],"works":[],"movements":[]},{"id":"jFbqvXO6VlubPuE4FpmQ6S","parent":"6cjWYtQW4KZA5iQYMatUv5","isDir":false,"title":"We Don't Leave the House","album":"Y’all","artist":"glaive","coverArt":"mf-jFbqvXO6VlubPuE4FpmQ6S_6a8767f1","size":2408961,"contentType":"audio/mpeg","suffix":"mp3","duration":145,"bitRate":128,"path":"glaive/Y’all/We Don't Leave the House.mp3","playCount":1,"created":"2026-08-20T05:12:32.093535187Z","albumId":"6cjWYtQW4KZA5iQYMatUv5","artistId":"6Atjc7fhemv7XpDcN1Eg2R","type":"music","played":"2026-08-20T15:23:59.356461226Z","bpm":0,"comment":"","sortName":"we don't leave the house","mediaType":"song","musicBrainzId":"","isrc":[],"genres":[],"replayGain":{},"channelCount":2,"samplingRate":44100,"bitDepth":0,"moods":[],"artists":[{"id":"6Atjc7fhemv7XpDcN1Eg2R","name":"glaive"}],"displayArtist":"glaive","albumArtists":[{"id":"6Atjc7fhemv7XpDcN1Eg2R","name":"glaive"}],"displayAlbumArtist":"glaive","contributors":[{"role":"composer","artist":{"id":"6Atjc7fhemv7XpDcN1Eg2R","name":"glaive"}},{"role":"composer","artist":{"id":"1CzweK9mYuvYSzOfsJpSeY","name":"Jeff Hazin"}},{"role":"composer","artist":{"id":"3e735O7r62YVt4LWsrYEPp","name":"Ralph Castelli"}},{"role":"composer","artist":{"id":"4DX98dUCrM0mD2xasDxwYw","name":"John Cunningham"}},{"role":"producer","artist":{"id":"1CzweK9mYuvYSzOfsJpSeY","name":"Jeff Hazin"}},{"role":"producer","artist":{"id":"3e735O7r62YVt4LWsrYEPp","name":"Ralph Castelli"}},{"role":"producer","artist":{"id":"4DX98dUCrM0mD2xasDxwYw","name":"John Cunningham"}}],"displayComposer":"glaive • Jeff Hazin • Ralph Castelli • John Cunningham","explicitStatus":"","groupings":[],"works":[],"movements":[]},{"id":"W6BSWmi7r1sdj0ucxgiyAo","parent":"2RjlNl9MNuAz8H23VsIylo","isDir":false,"title":"Сердце","album":"Две тысячи семнадцатый","artist":"17 SEVENTEEN","coverArt":"mf-W6BSWmi7r1sdj0ucxgiyAo_6a876895","size":2494054,"contentType":"audio/mpeg","suffix":"mp3","duration":143,"bitRate":128,"path":"17 SEVENTEEN/Две тысячи семнадцатый/Сердце.mp3","playCount":1,"created":"2026-08-20T05:32:06.759953813Z","albumId":"2RjlNl9MNuAz8H23VsIylo","artistId":"62qr8uCyh7AmYFTqGroesm","type":"music","played":"2026-08-20T14:45:31.95803814Z","bpm":0,"comment":"","sortName":"сердце","mediaType":"song","musicBrainzId":"","isrc":[],"genres":[],"replayGain":{},"channelCount":2,"samplingRate":44100,"bitDepth":0,"moods":[],"artists":[{"id":"62qr8uCyh7AmYFTqGroesm","name":"17 SEVENTEEN"}],"displayArtist":"17 SEVENTEEN","albumArtists":[{"id":"62qr8uCyh7AmYFTqGroesm","name":"17 SEVENTEEN"}],"displayAlbumArtist":"17 SEVENTEEN","contributors":[{"role":"composer","artist":{"id":"62qr8uCyh7AmYFTqGroesm","name":"17 SEVENTEEN"}},{"role":"producer","artist":{"id":"62qr8uCyh7AmYFTqGroesm","name":"17 SEVENTEEN"}}],"displayComposer":"17 SEVENTEEN","explicitStatus":"","groupings":[],"works":[],"movements":[]},{"id":"bKAMco3N9SHhKM82bup9Uf","parent":"0h8AKzwod7yMIk4hqvJHdu","isDir":false,"title":"Катакомба","album":"Kaidan","artist":"zavet","track":2,"year":2019,"genre":"rusrap","coverArt":"mf-bKAMco3N9SHhKM82bup9Uf_6a8768c3","size":15360684,"contentType":"audio/flac","suffix":"flac","duration":142,"bitRate":838,"path":"zavet/Kaidan/01-02 - Катакомба.flac","playCount":1,"discNumber":1,"created":"2026-08-20T04:14:23.445945235Z","albumId":"0h8AKzwod7yMIk4hqvJHdu","artistId":"2lzWXQ0Wd7RzG6RmnWUaTy","type":"music","played":"2026-08-20T14:37:47.511990468Z","bpm":0,"comment":"","sortName":"катакомба","mediaType":"song","musicBrainzId":"","isrc":[],"genres":[{"name":"rusrap"}],"replayGain":{},"channelCount":2,"samplingRate":44100,"bitDepth":16,"moods":[],"artists":[{"id":"2lzWXQ0Wd7RzG6RmnWUaTy","name":"zavet"}],"displayArtist":"zavet","albumArtists":[{"id":"2lzWXQ0Wd7RzG6RmnWUaTy","name":"zavet"}],"displayAlbumArtist":"zavet","contributors":[{"role":"composer","artist":{"id":"2lzWXQ0Wd7RzG6RmnWUaTy","name":"zavet"}},{"role":"producer","artist":{"id":"2lzWXQ0Wd7RzG6RmnWUaTy","name":"zavet"}}],"displayComposer":"zavet","explicitStatus":"","groupings":[],"works":[],"movements":[]},{"id":"PCVv8REtWFDSYLNPLdOedk","parent":"3w3tg8dXaq3AX7HfMmWZHL","isDir":false,"title":"BABY","album":"ANGEL MAY CRY","artist":"Kai Angel","track":4,"year":2023,"genre":"Rap","coverArt":"mf-PCVv8REtWFDSYLNPLdOedk_6a8778d6","size":17443882,"contentType":"audio/flac","suffix":"flac","duration":140,"bitRate":986,"path":"Kai Angel/ANGEL MAY CRY/01-04 - BABY.flac","playCount":1,"discNumber":1,"created":"2026-08-20T04:33:25.467488894Z","albumId":"3w3tg8dXaq3AX7HfMmWZHL","artistId":"2FXyezR7e5yT2ZwTDFF5ps","type":"music","played":"2026-08-20T15:42:38.294280154Z","bpm":0,"comment":"","sortName":"baby","mediaType":"song","musicBrainzId":"","isrc":["DGA062304350"],"genres":[{"name":"Rap"},{"name":"Hip Hop"}],"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":[],"displayComposer":"","explicitStatus":"","groupings":[],"works":[],"movements":[]},{"id":"qQ1bMmcbvNbnIYuhLPyNKI","parent":"600akydhU2UyeeQ8HSnSjM","isDir":false,"title":"ssdasfdsdfhjgkdsfd","album":"ssdasfdsdfhjgkdsfd","artist":"zavet","track":1,"year":2021,"genre":"Hip-Hop","coverArt":"mf-qQ1bMmcbvNbnIYuhLPyNKI_6a871917","size":5647159,"contentType":"audio/mpeg","suffix":"mp3","duration":140,"bitRate":320,"path":"zavet/ssdasfdsdfhjgkdsfd/01-01 - ssdasfdsdfhjgkdsfd.mp3","playCount":1,"discNumber":1,"created":"2026-08-20T04:14:23.496441523Z","albumId":"600akydhU2UyeeQ8HSnSjM","artistId":"2lzWXQ0Wd7RzG6RmnWUaTy","type":"music","played":"2026-08-20T15:26:22.694036212Z","bpm":0,"comment":"был прислан в церковь свидетелей ицкова одноименным аккаунтом","sortName":"ssdasfdsdfhjgkdsfd","mediaType":"song","musicBrainzId":"","isrc":[],"genres":[{"name":"Hip-Hop"}],"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":"3izV7689nBNcVsKE9dJr43","parent":"1ttX3mf0A8onPwvT8Imo4U","isDir":false,"title":"пропасть из виду","album":"изучаю мир","artist":"Рай не сегодня","coverArt":"mf-3izV7689nBNcVsKE9dJr43_6a876968","size":2395239,"contentType":"audio/mpeg","suffix":"mp3","duration":136,"bitRate":128,"path":"Рай не сегодня/изучаю мир/пропасть из виду.mp3","playCount":1,"created":"2026-08-20T05:20:32.969647262Z","albumId":"1ttX3mf0A8onPwvT8Imo4U","artistId":"22qO7WPYmgKqChrt72BFOf","type":"music","played":"2026-08-20T14:32:57.59952785Z","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":"Рай не сегодня","contributors":[{"role":"composer","artist":{"id":"3FvVxsOSNbyByTdPpTgum7","name":"рай не сегодня (rai ne segodnya)"}},{"role":"producer","artist":{"id":"3FvVxsOSNbyByTdPpTgum7","name":"рай не сегодня (rai ne segodnya)"}}],"displayComposer":"рай не сегодня (rai ne segodnya)","explicitStatus":"","groupings":[],"works":[],"movements":[]},{"id":"giTTKVLvRwWiegCUXMSkWz","parent":"6h2zGoQk1hKXOlMVdAKCVp","isDir":false,"title":"zanoza sport xtreme","album":"nights before gotika","artist":"zavet • Дмитрий Ицков","track":1,"year":2021,"genre":"Rap","coverArt":"mf-giTTKVLvRwWiegCUXMSkWz_6a876930","size":16842791,"contentType":"audio/flac","suffix":"flac","duration":135,"bitRate":919,"path":"zavet/nights before gotika/01-01 - zanoza sport xtreme.flac","playCount":1,"discNumber":1,"created":"2026-08-20T04:14:23.286150498Z","albumId":"6h2zGoQk1hKXOlMVdAKCVp","artistId":"2lzWXQ0Wd7RzG6RmnWUaTy","type":"music","played":"2026-08-20T15:56:24.81922217Z","bpm":0,"comment":"","sortName":"zanoza sport xtreme","mediaType":"song","musicBrainzId":"","isrc":["QM4TW2125820"],"genres":[{"name":"Rap"},{"name":"Hip Hop"}],"replayGain":{},"channelCount":2,"samplingRate":44100,"bitDepth":16,"moods":[],"artists":[{"id":"2lzWXQ0Wd7RzG6RmnWUaTy","name":"zavet"},{"id":"6xYkc85V7eam5ok0k1rNUx","name":"Дмитрий Ицков"}],"displayArtist":"zavet • Дмитрий Ицков","albumArtists":[{"id":"2lzWXQ0Wd7RzG6RmnWUaTy","name":"zavet"}],"displayAlbumArtist":"zavet","contributors":[{"role":"composer","artist":{"id":"2lzWXQ0Wd7RzG6RmnWUaTy","name":"zavet"}},{"role":"producer","artist":{"id":"2lzWXQ0Wd7RzG6RmnWUaTy","name":"zavet"}}],"displayComposer":"zavet","explicitStatus":"explicit","groupings":[],"works":[],"movements":[]},{"id":"SZd1Eba1TQhHICYrMNmONZ","parent":"5Dd0CUcozlgLow3YP9hA5G","isDir":false,"title":"SALEM FLOW","album":"gotika","artist":"zavet","track":1,"year":2021,"coverArt":"mf-SZd1Eba1TQhHICYrMNmONZ_6a877888","size":14267257,"contentType":"audio/flac","suffix":"flac","duration":135,"bitRate":834,"path":"zavet/gotika/01-01 - SALEM FLOW.flac","playCount":1,"discNumber":1,"created":"2026-08-20T21:59:51.684536234Z","albumId":"5Dd0CUcozlgLow3YP9hA5G","artistId":"2lzWXQ0Wd7RzG6RmnWUaTy","type":"music","played":"2026-08-20T22:03:21.641464926Z","bpm":0,"comment":"downloaded from lucida.to: http://www.tidal.com/track/286386703","sortName":"salem flow","mediaType":"song","musicBrainzId":"","isrc":["RUUM72100836"],"genres":[],"replayGain":{"trackGain":-5.62,"albumGain":-7.62,"trackPeak":0.9906,"albumPeak":1},"channelCount":2,"samplingRate":44100,"bitDepth":16,"moods":[],"artists":[{"id":"2lzWXQ0Wd7RzG6RmnWUaTy","name":"zavet"}],"displayArtist":"zavet","albumArtists":[{"id":"2lzWXQ0Wd7RzG6RmnWUaTy","name":"zavet"}],"displayAlbumArtist":"zavet","contributors":[],"displayComposer":"","explicitStatus":"","groupings":[],"works":[],"movements":[]},{"id":"fs3E6eN5QpnESTZIQ414KR","parent":"2CXX2cOzLpJU8g1SCvCIih","isDir":false,"title":"1-800-PARADISE","album":"GOD SYSTEM","artist":"Kai Angel","track":1,"year":2024,"coverArt":"mf-fs3E6eN5QpnESTZIQ414KR_6a8769ba","size":15576259,"contentType":"audio/flac","suffix":"flac","duration":130,"bitRate":948,"path":"Kai Angel/GOD SYSTEM/01 - 1-800-PARADISE.flac","playCount":2,"created":"2026-08-20T04:33:25.489243906Z","albumId":"2CXX2cOzLpJU8g1SCvCIih","artistId":"2FXyezR7e5yT2ZwTDFF5ps","type":"music","played":"2026-08-26T17:50:57.454Z","bpm":0,"comment":"","sortName":"1-800-paradise","mediaType":"song","musicBrainzId":"","isrc":[],"genres":[],"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":"composer","artist":{"id":"2FXyezR7e5yT2ZwTDFF5ps","name":"Kai Angel"}},{"role":"composer","artist":{"id":"43IEgzjYAESghfX2VuDlkx","name":"730hahah"}},{"role":"producer","artist":{"id":"43IEgzjYAESghfX2VuDlkx","name":"730hahah"}}],"displayComposer":"Kai Angel • 730hahah","explicitStatus":"","groupings":[],"works":[],"movements":[]},{"id":"ZtRXu2EEIa9a7Gk2iKVdqP","parent":"2CXX2cOzLpJU8g1SCvCIih","isDir":false,"title":"HILLS","album":"GOD SYSTEM","artist":"Kai Angel","track":14,"year":2024,"coverArt":"mf-ZtRXu2EEIa9a7Gk2iKVdqP_6a8769c4","size":16098058,"contentType":"audio/flac","suffix":"flac","duration":126,"bitRate":1007,"path":"Kai Angel/GOD SYSTEM/14 - HILLS.flac","playCount":1,"created":"2026-08-20T04:33:25.479783899Z","albumId":"2CXX2cOzLpJU8g1SCvCIih","artistId":"2FXyezR7e5yT2ZwTDFF5ps","type":"music","played":"2026-08-20T15:48:04.684031735Z","bpm":0,"comment":"","sortName":"hills","mediaType":"song","musicBrainzId":"","isrc":[],"genres":[],"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":"composer","artist":{"id":"2FXyezR7e5yT2ZwTDFF5ps","name":"Kai Angel"}},{"role":"composer","artist":{"id":"0ShnzUlBVIlBfqg2erugBE","name":"delone"}},{"role":"composer","artist":{"id":"0m1OBW94oyhFjXP6TolKiK","name":"touchthwsky"}},{"role":"composer","artist":{"id":"1iqlFdVEm2LGSjJgivs9iq","name":"hvoya"}},{"role":"producer","artist":{"id":"0ShnzUlBVIlBfqg2erugBE","name":"delone"}},{"role":"producer","artist":{"id":"0m1OBW94oyhFjXP6TolKiK","name":"touchthwsky"}},{"role":"producer","artist":{"id":"1iqlFdVEm2LGSjJgivs9iq","name":"hvoya"}}],"displayComposer":"Kai Angel • delone • touchthwsky • hvoya","explicitStatus":"","groupings":[],"works":[],"movements":[]},{"id":"qwRwNiJujv3NEPukRMr8LR","parent":"3lacdPzhnFObN1K9IkKq1V","isDir":false,"title":"Хватит врать","album":"Хватит врать","artist":"Рай не сегодня","coverArt":"mf-qwRwNiJujv3NEPukRMr8LR_6a87695a","size":2153137,"contentType":"audio/mpeg","suffix":"mp3","duration":126,"bitRate":128,"path":"Рай не сегодня/Хватит врать/Хватит врать.mp3","playCount":1,"created":"2026-08-20T04:52:46.122944749Z","albumId":"3lacdPzhnFObN1K9IkKq1V","artistId":"22qO7WPYmgKqChrt72BFOf","type":"music","played":"2026-08-20T15:38:55.879609044Z","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":"Рай не сегодня","contributors":[{"role":"composer","artist":{"id":"5AMNGbowlP332romiL4XHg","name":"eikko"}},{"role":"composer","artist":{"id":"3FvVxsOSNbyByTdPpTgum7","name":"рай не сегодня (rai ne segodnya)"}},{"role":"producer","artist":{"id":"3FvVxsOSNbyByTdPpTgum7","name":"рай не сегодня (rai ne segodnya)"}}],"displayComposer":"eikko • рай не сегодня (rai ne segodnya)","explicitStatus":"","groupings":[],"works":[],"movements":[]},{"id":"m3qW1AUBsWtJVuxP0cEVws","parent":"45BKgzS9X2KyedLphSKSiM","isDir":false,"title":"Доставай!","album":"Доставай!","artist":"Pure","coverArt":"mf-m3qW1AUBsWtJVuxP0cEVws_6a876972","size":2323342,"contentType":"audio/mpeg","suffix":"mp3","duration":140,"bitRate":128,"path":"Pure/Доставай!/Доставай!.mp3","playCount":1,"created":"2026-08-20T05:02:45.224149269Z","albumId":"45BKgzS9X2KyedLphSKSiM","artistId":"1VNJ7bUDSsv6efQnIwtnxK","type":"music","played":"2026-08-20T15:33:17.07975838Z","bpm":0,"comment":"","sortName":"доставай!","mediaType":"song","musicBrainzId":"","isrc":[],"genres":[],"replayGain":{},"channelCount":2,"samplingRate":44100,"bitDepth":0,"moods":[],"artists":[{"id":"1VNJ7bUDSsv6efQnIwtnxK","name":"Pure"}],"displayArtist":"Pure","albumArtists":[{"id":"1VNJ7bUDSsv6efQnIwtnxK","name":"Pure"}],"displayAlbumArtist":"Pure","contributors":[{"role":"composer","artist":{"id":"38SXjN13BFOK9gMaRZ5URc","name":"KEEDFOUR"}},{"role":"composer","artist":{"id":"1VNJ7bUDSsv6efQnIwtnxK","name":"Pure"}},{"role":"producer","artist":{"id":"38SXjN13BFOK9gMaRZ5URc","name":"KEEDFOUR"}}],"displayComposer":"KEEDFOUR • Pure","explicitStatus":"","groupings":[],"works":[],"movements":[]},{"id":"tNmmBEDDKKTvhS6PbdJGYf","parent":"5xGRYiif1h7Ka8o5yhyND4","isDir":false,"title":"застрявшие в 16 (Prod. by never die)","album":"ешь, молись, танцуй","artist":"never die","coverArt":"mf-tNmmBEDDKKTvhS6PbdJGYf_6a8767bb","size":1907372,"contentType":"audio/mpeg","suffix":"mp3","duration":114,"bitRate":128,"path":"never die/ешь, молись, танцуй/застрявшие в 16 (Prod. by never die).mp3","playCount":1,"created":"2026-08-20T04:29:53.356469405Z","albumId":"5xGRYiif1h7Ka8o5yhyND4","artistId":"1RlholhxfO8YebLSmsyb4o","type":"music","played":"2026-08-20T15:35:10.825866766Z","bpm":0,"comment":"","sortName":"застрявшие в 16 (prod. by never die)","mediaType":"song","musicBrainzId":"","isrc":[],"genres":[],"replayGain":{},"channelCount":2,"samplingRate":44100,"bitDepth":0,"moods":[],"artists":[{"id":"1RlholhxfO8YebLSmsyb4o","name":"never die"}],"displayArtist":"never die","albumArtists":[{"id":"1RlholhxfO8YebLSmsyb4o","name":"never die"}],"displayAlbumArtist":"never die","contributors":[{"role":"composer","artist":{"id":"1RlholhxfO8YebLSmsyb4o","name":"never die"}},{"role":"producer","artist":{"id":"1RlholhxfO8YebLSmsyb4o","name":"never die"}}],"displayComposer":"never die","explicitStatus":"","groupings":[],"works":[],"movements":[]},{"id":"gYn1dwUzZHxSgAxerIaRll","parent":"4rwXDr40tGXadmkRmxw0rs","isDir":false,"title":"N1CHEVO","album":"HEAVY METAL","artist":"Kai Angel","track":2,"year":2023,"coverArt":"mf-gYn1dwUzZHxSgAxerIaRll_6a876a2b","size":14200384,"contentType":"audio/flac","suffix":"flac","duration":104,"bitRate":1083,"path":"Kai Angel/HEAVY METAL/02 - N1CHEVO.flac","playCount":1,"created":"2026-08-20T04:33:25.457246689Z","albumId":"4rwXDr40tGXadmkRmxw0rs","artistId":"2FXyezR7e5yT2ZwTDFF5ps","type":"music","played":"2026-08-20T15:36:59.816265567Z","bpm":0,"comment":"","sortName":"n1chevo","mediaType":"song","musicBrainzId":"","isrc":[],"genres":[],"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":"composer","artist":{"id":"2FXyezR7e5yT2ZwTDFF5ps","name":"Kai Angel"}},{"role":"composer","artist":{"id":"1pJzjdRWKmxEvqMGtiTmoy","name":"9mice"}},{"role":"producer","artist":{"id":"2FXyezR7e5yT2ZwTDFF5ps","name":"Kai Angel"}}],"displayComposer":"Kai Angel • 9mice","explicitStatus":"","groupings":[],"works":[],"movements":[]},{"id":"rGORSlEEwCpi1b77Isa4me","parent":"3Lnw1qfghHkbGmUBOCeKQY","isDir":false,"title":"dj","album":"#djcore","artist":"never die","coverArt":"mf-rGORSlEEwCpi1b77Isa4me_6a87679e","size":1588958,"contentType":"audio/mpeg","suffix":"mp3","duration":92,"bitRate":128,"path":"never die/#djcore/dj.mp3","playCount":1,"created":"2026-08-20T04:25:26.173905982Z","albumId":"3Lnw1qfghHkbGmUBOCeKQY","artistId":"1RlholhxfO8YebLSmsyb4o","type":"music","played":"2026-08-20T15:28:23.425686673Z","bpm":0,"comment":"","sortName":"dj","mediaType":"song","musicBrainzId":"","isrc":[],"genres":[],"replayGain":{},"channelCount":2,"samplingRate":44100,"bitDepth":0,"moods":[],"artists":[{"id":"1RlholhxfO8YebLSmsyb4o","name":"never die"}],"displayArtist":"never die","albumArtists":[{"id":"1RlholhxfO8YebLSmsyb4o","name":"never die"}],"displayAlbumArtist":"never die","contributors":[{"role":"composer","artist":{"id":"1RlholhxfO8YebLSmsyb4o","name":"never die"}},{"role":"producer","artist":{"id":"1RlholhxfO8YebLSmsyb4o","name":"never die"}}],"displayComposer":"never die","explicitStatus":"","groupings":[],"works":[],"movements":[]},{"id":"9I09DOEjvVJHzI97TaUdpP","parent":"6VaeyE9cIFMCImpXNGPIZ5","isDir":false,"title":"ты че обиделась","album":"sexyswag","artist":"madk1d","coverArt":"mf-9I09DOEjvVJHzI97TaUdpP_6a876890","size":1527600,"contentType":"audio/mpeg","suffix":"mp3","duration":90,"bitRate":128,"path":"madk1d/sexyswag/ты че обиделась.mp3","playCount":2,"created":"2026-08-20T04:33:25.539845803Z","albumId":"6VaeyE9cIFMCImpXNGPIZ5","artistId":"7Dy79oui9xLw7S5an2x0nk","type":"music","played":"2026-08-29T18:29:35.951Z","bpm":0,"comment":"","sortName":"ты че обиделась","mediaType":"song","musicBrainzId":"","isrc":[],"genres":[],"replayGain":{},"channelCount":2,"samplingRate":44100,"bitDepth":0,"moods":[],"artists":[{"id":"7Dy79oui9xLw7S5an2x0nk","name":"madk1d"}],"displayArtist":"madk1d","albumArtists":[{"id":"7Dy79oui9xLw7S5an2x0nk","name":"madk1d"}],"displayAlbumArtist":"madk1d","contributors":[{"role":"composer","artist":{"id":"7Dy79oui9xLw7S5an2x0nk","name":"madk1d"}},{"role":"composer","artist":{"id":"4wsnYsHYsh68yMLjjIQyk2","name":"тёмный принц (darkprinceee)"}},{"role":"composer","artist":{"id":"0whzp6t5Ld4cygAVNXw0xY","name":"k4neswagga"}},{"role":"composer","artist":{"id":"10pZ3w6fCXsFOR016ikenF","name":"yngyuuuchi"}},{"role":"producer","artist":{"id":"0whzp6t5Ld4cygAVNXw0xY","name":"k4neswagga"}},{"role":"producer","artist":{"id":"10pZ3w6fCXsFOR016ikenF","name":"yngyuuuchi"}}],"displayComposer":"madk1d • тёмный принц (darkprinceee) • k4neswagga • yngyuuuchi","explicitStatus":"","groupings":[],"works":[],"movements":[]},{"id":"D1AmPRnX7A7fwUczVM9Uyl","parent":"3uWboQieNC9cziOAHIp8aj","isDir":false,"title":"snuff video","album":"snuff video","artist":"never die","coverArt":"mf-D1AmPRnX7A7fwUczVM9Uyl_6a8767a9","size":1788817,"contentType":"audio/mpeg","suffix":"mp3","duration":88,"bitRate":128,"path":"never die/snuff video/snuff video.mp3","playCount":1,"created":"2026-08-20T04:33:25.311567109Z","albumId":"3uWboQieNC9cziOAHIp8aj","artistId":"1RlholhxfO8YebLSmsyb4o","type":"music","played":"2026-08-20T15:40:43.354741566Z","bpm":0,"comment":"","sortName":"snuff video","mediaType":"song","musicBrainzId":"","isrc":[],"genres":[],"replayGain":{},"channelCount":2,"samplingRate":44100,"bitDepth":0,"moods":[],"artists":[{"id":"1RlholhxfO8YebLSmsyb4o","name":"never die"}],"displayArtist":"never die","albumArtists":[{"id":"1RlholhxfO8YebLSmsyb4o","name":"never die"}],"displayAlbumArtist":"never die","contributors":[{"role":"composer","artist":{"id":"1RlholhxfO8YebLSmsyb4o","name":"never die"}},{"role":"producer","artist":{"id":"1RlholhxfO8YebLSmsyb4o","name":"never die"}}],"displayComposer":"never die","explicitStatus":"","groupings":[],"works":[],"movements":[]},{"id":"patNRkgwgIELkG2GpLjouE","parent":"2CXX2cOzLpJU8g1SCvCIih","isDir":false,"title":"SHOW OFF","album":"GOD SYSTEM","artist":"Kai Angel","track":12,"year":2024,"coverArt":"mf-patNRkgwgIELkG2GpLjouE_6a8769cc","size":11664332,"contentType":"audio/flac","suffix":"flac","duration":87,"bitRate":1049,"path":"Kai Angel/GOD SYSTEM/12 - SHOW OFF.flac","playCount":2,"created":"2026-08-20T04:33:25.483638808Z","albumId":"2CXX2cOzLpJU8g1SCvCIih","artistId":"2FXyezR7e5yT2ZwTDFF5ps","type":"music","played":"2026-09-09T11:56:04.258Z","bpm":0,"comment":"","sortName":"show off","mediaType":"song","musicBrainzId":"","isrc":[],"genres":[],"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":"composer","artist":{"id":"2FXyezR7e5yT2ZwTDFF5ps","name":"Kai Angel"}},{"role":"composer","artist":{"id":"0AmHOCBpMLdXud7gNtTkxz","name":"molores"}},{"role":"composer","artist":{"id":"4VSpARd7cIN7UhwCzeX1Vy","name":"Towa"}},{"role":"producer","artist":{"id":"0AmHOCBpMLdXud7gNtTkxz","name":"molores"}},{"role":"producer","artist":{"id":"4VSpARd7cIN7UhwCzeX1Vy","name":"Towa"}}],"displayComposer":"Kai Angel • molores • Towa","explicitStatus":"","groupings":[],"works":[],"movements":[]},{"id":"v5yXi7L2rVewdg3gEl2ziP","parent":"39GtiPI5sCFlyQCEYTZ14F","isDir":false,"title":"Позвони мне","album":"HYPERPOP ATTACK","artist":"Midix","coverArt":"mf-v5yXi7L2rVewdg3gEl2ziP_6a876772","size":1984396,"contentType":"audio/mpeg","suffix":"mp3","duration":111,"bitRate":128,"path":"Midix/HYPERPOP ATTACK/Позвони мне.mp3","playCount":1,"created":"2026-08-20T05:23:05.442069223Z","albumId":"39GtiPI5sCFlyQCEYTZ14F","artistId":"6czjE6pPpWVuxiDRbLPGWi","type":"music","played":"2026-08-20T15:53:20.359747212Z","bpm":0,"comment":"","sortName":"позвони мне","mediaType":"song","musicBrainzId":"","isrc":[],"genres":[],"replayGain":{},"channelCount":2,"samplingRate":44100,"bitDepth":0,"moods":[],"artists":[{"id":"6czjE6pPpWVuxiDRbLPGWi","name":"Midix"}],"displayArtist":"Midix","albumArtists":[{"id":"6czjE6pPpWVuxiDRbLPGWi","name":"Midix"}],"displayAlbumArtist":"Midix","contributors":[{"role":"composer","artist":{"id":"5GVYR2caT74aRdnNDzwOy8","name":"Chuyko"}},{"role":"composer","artist":{"id":"3N7WOQhzyHqWFQz7P8EzpN","name":"Lirin"}},{"role":"composer","artist":{"id":"6czjE6pPpWVuxiDRbLPGWi","name":"Midix"}},{"role":"producer","artist":{"id":"6czjE6pPpWVuxiDRbLPGWi","name":"Midix"}}],"displayComposer":"Chuyko • Lirin • Midix","explicitStatus":"","groupings":[],"works":[],"movements":[]},{"id":"7GTleGG3j7lnQTVXnECuKM","parent":"2XIlG6AGhbYTkNdSpjRymf","isDir":false,"title":"свалка","album":"мрачные треды","artist":"тёмный принц","coverArt":"mf-7GTleGG3j7lnQTVXnECuKM_6a876a56","size":1346244,"contentType":"audio/mpeg","suffix":"mp3","duration":81,"bitRate":128,"path":"тёмный принц/мрачные треды/свалка.mp3","playCount":2,"created":"2026-08-20T05:28:32.126889509Z","albumId":"2XIlG6AGhbYTkNdSpjRymf","artistId":"3r67QtGcmRhNEJhtqdNM1K","type":"music","played":"2026-08-25T16:38:24.518Z","bpm":0,"comment":"","sortName":"свалка","mediaType":"song","musicBrainzId":"","isrc":[],"genres":[],"replayGain":{},"channelCount":2,"samplingRate":44100,"bitDepth":0,"moods":[],"artists":[{"id":"3r67QtGcmRhNEJhtqdNM1K","name":"тёмный принц"}],"displayArtist":"тёмный принц","albumArtists":[{"id":"3r67QtGcmRhNEJhtqdNM1K","name":"тёмный принц"}],"displayAlbumArtist":"тёмный принц","contributors":[{"role":"composer","artist":{"id":"4wsnYsHYsh68yMLjjIQyk2","name":"тёмный принц (darkprinceee)"}},{"role":"composer","artist":{"id":"12vONuRyFzp4acvfCcma5D","name":"ashleyrossmith"}},{"role":"producer","artist":{"id":"7pAiH4g1IUSua1joRl5qFC","name":"narxi"}}],"displayComposer":"тёмный принц (darkprinceee) • ashleyrossmith","explicitStatus":"","groupings":[],"works":[],"movements":[]},{"id":"tBcN2srLXtOO8VQAt6PtEP","parent":"1LqsbVelF8547OhRNxDUqM","isDir":false,"title":"Jesus Is Lord","album":"JESUS IS KING","artist":"Kanye West","coverArt":"mf-tBcN2srLXtOO8VQAt6PtEP_6a876850","size":859200,"contentType":"audio/mpeg","suffix":"mp3","duration":49,"bitRate":128,"path":"Kanye West/JESUS IS KING/Jesus Is Lord.mp3","playCount":1,"created":"2026-08-20T04:54:14.020608137Z","albumId":"1LqsbVelF8547OhRNxDUqM","artistId":"02DiEqiNBn3idZvY8XP1lr","type":"music","played":"2026-08-20T15:14:14.30480148Z","bpm":0,"comment":"","sortName":"jesus is lord","mediaType":"song","musicBrainzId":"","isrc":[],"genres":[],"replayGain":{},"channelCount":2,"samplingRate":44100,"bitDepth":0,"moods":[],"artists":[{"id":"02DiEqiNBn3idZvY8XP1lr","name":"Kanye West"}],"displayArtist":"Kanye West","albumArtists":[{"id":"02DiEqiNBn3idZvY8XP1lr","name":"Kanye West"}],"displayAlbumArtist":"Kanye West","contributors":[{"role":"composer","artist":{"id":"5jcfEsueAmmIs7NZwXZPFl","name":"Angel López"}},{"role":"composer","artist":{"id":"5c4SM28NyOL4HsQApZ2Qo5","name":"Brian Miller"}},{"role":"composer","artist":{"id":"0VYIeu8Grn2NZI2SH0cI5l","name":"Claude Léveillée"}},{"role":"composer","artist":{"id":"1tvxJpe6DCvFsDiKLXjMIX","name":"Federico Vindver"}},{"role":"composer","artist":{"id":"02DiEqiNBn3idZvY8XP1lr","name":"Kanye West"}},{"role":"composer","artist":{"id":"6nyxPSL7LrF2yfd5V1oN5z","name":"Timbaland"}},{"role":"producer","artist":{"id":"02DiEqiNBn3idZvY8XP1lr","name":"Kanye West"}},{"role":"producer","artist":{"id":"5c4SM28NyOL4HsQApZ2Qo5","name":"Brian Miller"}},{"role":"producer","artist":{"id":"1tvxJpe6DCvFsDiKLXjMIX","name":"Federico Vindver"}},{"role":"producer","artist":{"id":"5jcfEsueAmmIs7NZwXZPFl","name":"Angel López"}},{"role":"producer","artist":{"id":"6nyxPSL7LrF2yfd5V1oN5z","name":"Timbaland"}}],"displayComposer":"Angel López • Brian Miller • Claude Léveillée • Federico Vindver • Kanye West • Timbaland","explicitStatus":"","groupings":[],"works":[],"movements":[]}]}}} \ No newline at end of file diff --git a/Packages/AmphlifyKit/Tests/SourceSubsonicTests/Fixtures/playlists.json b/Packages/AmphlifyKit/Tests/SourceSubsonicTests/Fixtures/playlists.json new file mode 100644 index 0000000..1d462dc --- /dev/null +++ b/Packages/AmphlifyKit/Tests/SourceSubsonicTests/Fixtures/playlists.json @@ -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}]}}} \ No newline at end of file diff --git a/Packages/AmphlifyKit/Tests/SourceSubsonicTests/Fixtures/radioStations.json b/Packages/AmphlifyKit/Tests/SourceSubsonicTests/Fixtures/radioStations.json new file mode 100644 index 0000000..a85551a --- /dev/null +++ b/Packages/AmphlifyKit/Tests/SourceSubsonicTests/Fixtures/radioStations.json @@ -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":""}]}}} \ No newline at end of file diff --git a/Packages/AmphlifyKit/Tests/SourceSubsonicTests/Fixtures/search3All.json b/Packages/AmphlifyKit/Tests/SourceSubsonicTests/Fixtures/search3All.json new file mode 100644 index 0000000..608e0d3 --- /dev/null +++ b/Packages/AmphlifyKit/Tests/SourceSubsonicTests/Fixtures/search3All.json @@ -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":[]}]}}} \ No newline at end of file diff --git a/Packages/AmphlifyKit/Tests/SourceSubsonicTests/Fixtures/starred2.json b/Packages/AmphlifyKit/Tests/SourceSubsonicTests/Fixtures/starred2.json new file mode 100644 index 0000000..382dbba --- /dev/null +++ b/Packages/AmphlifyKit/Tests/SourceSubsonicTests/Fixtures/starred2.json @@ -0,0 +1 @@ +{"subsonic-response":{"status":"ok","version":"1.16.1","type":"navidrome","serverVersion":"0.63.2 (be10f89c)","openSubsonic":true,"starred2":{}}} \ No newline at end of file diff --git a/Packages/AmphlifyKit/Tests/SourceSubsonicTests/SubsonicSourceTests.swift b/Packages/AmphlifyKit/Tests/SourceSubsonicTests/SubsonicSourceTests.swift new file mode 100644 index 0000000..6e642d2 --- /dev/null +++ b/Packages/AmphlifyKit/Tests/SourceSubsonicTests/SubsonicSourceTests.swift @@ -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) +} diff --git a/Packages/AmphlifyKit/Tests/StoreTests/SyncTests.swift b/Packages/AmphlifyKit/Tests/StoreTests/SyncTests.swift new file mode 100644 index 0000000..ecadbe9 --- /dev/null +++ b/Packages/AmphlifyKit/Tests/StoreTests/SyncTests.swift @@ -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) +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..e356540 --- /dev/null +++ b/README.md @@ -0,0 +1,14 @@ +# Amphlify + +A native music player for iPhone (macOS and watchOS to follow) for your own OpenSubsonic server, such as Navidrome. Part of Soundoz. + +## Build + +Needs Xcode 27+, iOS 26+. + +```sh +cp Config/Signing.example.xcconfig Config/Signing.xcconfig # set your team and bundle prefix +open Amphlify.xcodeproj +``` + +`make fmt`, `make check`, `make test`, `make run`.