feat(*): subsonic client with avfoundation playback and apple music ui
This commit is contained in:
commit
e5bb559d7f
93 files changed
+7517
No files matched your search
@@ -0,0 +1,32 @@
|
||||
import Foundation
|
||||
|
||||
public struct AudioFormat: Hashable, Codable, Sendable {
|
||||
public enum Codec: String, Codable, Sendable {
|
||||
case flac, alac, wav, mp3, aac, opus, vorbis, eac3
|
||||
}
|
||||
|
||||
public var codec: Codec
|
||||
public var bitrateKbps: Int?
|
||||
public var sampleRate: Int?
|
||||
public var bitDepth: Int?
|
||||
public var channels: Int
|
||||
|
||||
public init(codec: Codec, bitrateKbps: Int? = nil, sampleRate: Int? = nil, bitDepth: Int? = nil, channels: Int = 2) {
|
||||
self.codec = codec
|
||||
self.bitrateKbps = bitrateKbps
|
||||
self.sampleRate = sampleRate
|
||||
self.bitDepth = bitDepth
|
||||
self.channels = channels
|
||||
}
|
||||
|
||||
public var isLossless: Bool {
|
||||
switch codec {
|
||||
case .flac, .alac, .wav: true
|
||||
default: false
|
||||
}
|
||||
}
|
||||
|
||||
public var isHiRes: Bool {
|
||||
isLossless && ((sampleRate ?? 0) > 48000 || (bitDepth ?? 0) > 16 && (sampleRate ?? 0) > 44100)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import Foundation
|
||||
|
||||
public struct Artist: Identifiable, Hashable, Codable, Sendable {
|
||||
public let id: EntityID
|
||||
public var name: String
|
||||
public var sortName: String
|
||||
public var artwork: EntityID?
|
||||
public var albumCount: Int
|
||||
|
||||
public init(id: EntityID, name: String, sortName: String? = nil, artwork: EntityID? = nil, albumCount: Int = 0) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.sortName = sortName ?? name
|
||||
self.artwork = artwork
|
||||
self.albumCount = albumCount
|
||||
}
|
||||
}
|
||||
|
||||
public struct Album: Identifiable, Hashable, Codable, Sendable {
|
||||
public let id: EntityID
|
||||
public var title: String
|
||||
public var artistID: EntityID?
|
||||
public var artistName: String
|
||||
public var year: Int?
|
||||
public var genre: String?
|
||||
public var artwork: EntityID?
|
||||
public var trackCount: Int
|
||||
public var duration: TimeInterval
|
||||
public var addedAt: Date
|
||||
public var bestFormat: AudioFormat?
|
||||
|
||||
public init(
|
||||
id: EntityID,
|
||||
title: String,
|
||||
artistID: EntityID?,
|
||||
artistName: String,
|
||||
year: Int? = nil,
|
||||
genre: String? = nil,
|
||||
artwork: EntityID? = nil,
|
||||
trackCount: Int = 0,
|
||||
duration: TimeInterval = 0,
|
||||
addedAt: Date = .distantPast,
|
||||
bestFormat: AudioFormat? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.artistID = artistID
|
||||
self.artistName = artistName
|
||||
self.year = year
|
||||
self.genre = genre
|
||||
self.artwork = artwork
|
||||
self.trackCount = trackCount
|
||||
self.duration = duration
|
||||
self.addedAt = addedAt
|
||||
self.bestFormat = bestFormat
|
||||
}
|
||||
}
|
||||
|
||||
public struct Track: Identifiable, Hashable, Codable, Sendable {
|
||||
public let id: EntityID
|
||||
public var title: String
|
||||
public var albumID: EntityID?
|
||||
public var albumTitle: String
|
||||
public var artistID: EntityID?
|
||||
public var artistName: String
|
||||
public var discNumber: Int
|
||||
public var trackNumber: Int
|
||||
public var duration: TimeInterval
|
||||
public var year: Int?
|
||||
public var genre: String?
|
||||
public var artwork: EntityID?
|
||||
public var isExplicit: Bool
|
||||
public var contentHash: Data?
|
||||
public var formats: [AudioFormat]
|
||||
|
||||
public init(
|
||||
id: EntityID,
|
||||
title: String,
|
||||
albumID: EntityID?,
|
||||
albumTitle: String,
|
||||
artistID: EntityID?,
|
||||
artistName: String,
|
||||
discNumber: Int = 1,
|
||||
trackNumber: Int = 0,
|
||||
duration: TimeInterval,
|
||||
year: Int? = nil,
|
||||
genre: String? = nil,
|
||||
artwork: EntityID? = nil,
|
||||
isExplicit: Bool = false,
|
||||
contentHash: Data? = nil,
|
||||
formats: [AudioFormat] = []
|
||||
) {
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.albumID = albumID
|
||||
self.albumTitle = albumTitle
|
||||
self.artistID = artistID
|
||||
self.artistName = artistName
|
||||
self.discNumber = discNumber
|
||||
self.trackNumber = trackNumber
|
||||
self.duration = duration
|
||||
self.year = year
|
||||
self.genre = genre
|
||||
self.artwork = artwork
|
||||
self.isExplicit = isExplicit
|
||||
self.contentHash = contentHash
|
||||
self.formats = formats
|
||||
}
|
||||
}
|
||||
|
||||
public struct Playlist: Identifiable, Hashable, Codable, Sendable {
|
||||
public let id: EntityID
|
||||
public var name: String
|
||||
public var summary: String
|
||||
public var artwork: EntityID?
|
||||
public var trackCount: Int
|
||||
public var duration: TimeInterval
|
||||
public var updatedAt: Date
|
||||
|
||||
public init(
|
||||
id: EntityID,
|
||||
name: String,
|
||||
summary: String = "",
|
||||
artwork: EntityID? = nil,
|
||||
trackCount: Int = 0,
|
||||
duration: TimeInterval = 0,
|
||||
updatedAt: Date = .distantPast
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.summary = summary
|
||||
self.artwork = artwork
|
||||
self.trackCount = trackCount
|
||||
self.duration = duration
|
||||
self.updatedAt = updatedAt
|
||||
}
|
||||
}
|
||||
|
||||
public struct PlaylistEntry: Identifiable, Hashable, Codable, Sendable {
|
||||
public let id: String
|
||||
public let track: EntityID
|
||||
|
||||
public init(id: String, track: EntityID) {
|
||||
self.id = id
|
||||
self.track = track
|
||||
}
|
||||
}
|
||||
|
||||
public struct RadioStation: Identifiable, Hashable, Codable, Sendable {
|
||||
public let id: EntityID
|
||||
public var name: String
|
||||
public var tagline: String
|
||||
public var streamURL: URL
|
||||
public var artwork: EntityID?
|
||||
|
||||
public init(id: EntityID, name: String, tagline: String = "", streamURL: URL, artwork: EntityID? = nil) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.tagline = tagline
|
||||
self.streamURL = streamURL
|
||||
self.artwork = artwork
|
||||
}
|
||||
}
|
||||
|
||||
public struct Genre: Identifiable, Hashable, Sendable {
|
||||
public var id: String {
|
||||
name
|
||||
}
|
||||
|
||||
public let name: String
|
||||
public let albumCount: Int
|
||||
|
||||
public init(name: String, albumCount: Int) {
|
||||
self.name = name
|
||||
self.albumCount = albumCount
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import Foundation
|
||||
|
||||
public struct SourceID: Hashable, Codable, Sendable {
|
||||
public let raw: UUID
|
||||
|
||||
public init(_ raw: UUID = UUID()) {
|
||||
self.raw = raw
|
||||
}
|
||||
}
|
||||
|
||||
public struct EntityID: Hashable, Codable, Sendable {
|
||||
public let source: SourceID
|
||||
public let remote: String
|
||||
|
||||
public init(source: SourceID, remote: String) {
|
||||
self.source = source
|
||||
self.remote = remote
|
||||
}
|
||||
}
|
||||
|
||||
public enum EntityKind: String, Codable, Sendable {
|
||||
case artist, album, track, playlist, station
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import Foundation
|
||||
|
||||
public struct LyricLine: Hashable, Sendable, Identifiable {
|
||||
public let time: TimeInterval
|
||||
public let text: String
|
||||
|
||||
public var id: TimeInterval {
|
||||
time
|
||||
}
|
||||
|
||||
public init(time: TimeInterval, text: String) {
|
||||
self.time = time
|
||||
self.text = text
|
||||
}
|
||||
}
|
||||
|
||||
public struct Lyrics: Hashable, Sendable {
|
||||
public let lines: [LyricLine]
|
||||
public let isSynced: Bool
|
||||
|
||||
public init(lines: [LyricLine], isSynced: Bool) {
|
||||
self.lines = lines
|
||||
self.isSynced = isSynced
|
||||
}
|
||||
|
||||
public init?(lrc: String) {
|
||||
var lines: [LyricLine] = []
|
||||
var plain: [String] = []
|
||||
for raw in lrc.split(whereSeparator: \.isNewline) {
|
||||
var rest = Substring(raw)
|
||||
var stamps: [TimeInterval] = []
|
||||
while rest.first == "[", let close = rest.firstIndex(of: "]") {
|
||||
let tag = rest[rest.index(after: rest.startIndex) ..< close]
|
||||
rest = rest[rest.index(after: close)...]
|
||||
if let time = Self.time(tag) {
|
||||
stamps.append(time)
|
||||
}
|
||||
}
|
||||
let text = rest.trimmingCharacters(in: .whitespaces)
|
||||
if stamps.isEmpty {
|
||||
if !text.isEmpty {
|
||||
plain.append(text)
|
||||
}
|
||||
} else {
|
||||
lines += stamps.map { LyricLine(time: $0, text: text) }
|
||||
}
|
||||
}
|
||||
if !lines.isEmpty {
|
||||
self.init(lines: lines.sorted { $0.time < $1.time }, isSynced: true)
|
||||
} else if !plain.isEmpty {
|
||||
self.init(lines: plain.map { LyricLine(time: 0, text: $0) }, isSynced: false)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
public func index(at time: TimeInterval) -> Int? {
|
||||
guard isSynced else { return nil }
|
||||
var found: Int?
|
||||
for (index, line) in lines.enumerated() where line.time <= time {
|
||||
found = index
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
private static func time(_ tag: Substring) -> TimeInterval? {
|
||||
let parts = tag.split(separator: ":")
|
||||
guard parts.count == 2, let minutes = Double(parts[0]), let seconds = Double(parts[1]) else { return nil }
|
||||
return minutes * 60 + seconds
|
||||
}
|
||||
}
|
||||
|
||||
public protocol LyricsSource: LibrarySource {
|
||||
func lyrics(for track: EntityID) async throws -> Lyrics?
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import Foundation
|
||||
|
||||
public struct ResourceRequest: Hashable, Sendable {
|
||||
public var url: URL
|
||||
public var headers: [String: String]
|
||||
public var expiresAt: Date?
|
||||
|
||||
public init(url: URL, headers: [String: String] = [:], expiresAt: Date? = nil) {
|
||||
self.url = url
|
||||
self.headers = headers
|
||||
self.expiresAt = expiresAt
|
||||
}
|
||||
}
|
||||
|
||||
public struct StreamRequest: Hashable, Sendable {
|
||||
public var resource: ResourceRequest
|
||||
public var format: AudioFormat?
|
||||
|
||||
public init(resource: ResourceRequest, format: AudioFormat?) {
|
||||
self.resource = resource
|
||||
self.format = format
|
||||
}
|
||||
}
|
||||
|
||||
public struct Cursor: Hashable, Codable, Sendable {
|
||||
public let raw: String
|
||||
|
||||
public init(_ raw: String) {
|
||||
self.raw = raw
|
||||
}
|
||||
}
|
||||
|
||||
public struct Annotation: Hashable, Codable, Sendable {
|
||||
public var favoritedAt: Date?
|
||||
public var playCount: Int
|
||||
public var lastPlayedAt: Date?
|
||||
|
||||
public init(favoritedAt: Date? = nil, playCount: Int = 0, lastPlayedAt: Date? = nil) {
|
||||
self.favoritedAt = favoritedAt
|
||||
self.playCount = playCount
|
||||
self.lastPlayedAt = lastPlayedAt
|
||||
}
|
||||
}
|
||||
|
||||
public enum Entity: Sendable {
|
||||
case artist(Artist)
|
||||
case album(Album)
|
||||
case track(Track)
|
||||
case playlist(Playlist, entries: [PlaylistEntry])
|
||||
case station(RadioStation)
|
||||
}
|
||||
|
||||
public enum Change: Sendable {
|
||||
case upsert(Entity)
|
||||
case remove(EntityKind, EntityID)
|
||||
case annotate(EntityID, Annotation)
|
||||
case checkpoint(Cursor)
|
||||
}
|
||||
|
||||
public enum SourceError: Error {
|
||||
case cannotCalculateChanges
|
||||
case notFound
|
||||
}
|
||||
|
||||
public protocol LibrarySource: Sendable {
|
||||
var id: SourceID { get }
|
||||
var name: String { get }
|
||||
func changes(since cursor: Cursor?) -> AsyncThrowingStream<Change, Error>
|
||||
func stream(for track: EntityID) async throws -> StreamRequest
|
||||
func artwork(_ id: EntityID, pixelSize: Int) async throws -> ResourceRequest
|
||||
}
|
||||
|
||||
public protocol Starring: LibrarySource {
|
||||
func setFavorite(_ isFavorite: Bool, for id: EntityID, kind: EntityKind) async throws
|
||||
}
|
||||
|
||||
public protocol Scrobbling: LibrarySource {
|
||||
func scrobble(_ track: EntityID, at date: Date, submission: Bool) async throws
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
#if canImport(AVFoundation)
|
||||
import AVFoundation
|
||||
import Domain
|
||||
import Foundation
|
||||
|
||||
public actor AVFoundationEngine: OutputEngine {
|
||||
public typealias Resolve = @Sendable (Track) async throws -> StreamRequest
|
||||
|
||||
private struct Slot {
|
||||
let item: QueueItem
|
||||
let playerItem: AVPlayerItem
|
||||
let statusObservation: NSKeyValueObservation
|
||||
}
|
||||
|
||||
public nonisolated let events: AsyncStream<OutputEvent>
|
||||
private let continuation: AsyncStream<OutputEvent>.Continuation
|
||||
private let resolve: Resolve
|
||||
private let player = AVQueuePlayer()
|
||||
private let bufferAhead: TimeInterval = 60
|
||||
private let anchorInterval: TimeInterval = 5
|
||||
private var current: Slot?
|
||||
private var prepared: Slot?
|
||||
private var adoptable = false
|
||||
private var wantsPlaying = false
|
||||
private var sessionActive = false
|
||||
private var loadGeneration = 0
|
||||
private var prepareGeneration = 0
|
||||
private var observations: [NSKeyValueObservation] = []
|
||||
private var tokens: [any NSObjectProtocol] = []
|
||||
private var timeObserver: Any?
|
||||
|
||||
public init(resolve: @escaping Resolve) {
|
||||
self.resolve = resolve
|
||||
(events, continuation) = AsyncStream<OutputEvent>.makeStream()
|
||||
player.automaticallyWaitsToMinimizeStalling = true
|
||||
player.audiovisualBackgroundPlaybackPolicy = .continuesIfPossible
|
||||
}
|
||||
|
||||
isolated deinit {
|
||||
tokens.forEach { NotificationCenter.default.removeObserver($0) }
|
||||
if let timeObserver {
|
||||
player.removeTimeObserver(timeObserver)
|
||||
}
|
||||
}
|
||||
|
||||
public var outputLatency: TimeInterval {
|
||||
AudioSession.outputLatency
|
||||
}
|
||||
|
||||
public func load(_ item: QueueItem, at position: TimeInterval) async {
|
||||
startObserving()
|
||||
loadGeneration += 1
|
||||
let generation = loadGeneration
|
||||
if adoptable, current?.item.id == item.id {
|
||||
adoptable = false
|
||||
} else if let prepared, prepared.item.id == item.id {
|
||||
player.advanceToNextItem()
|
||||
current = prepared
|
||||
self.prepared = nil
|
||||
} else {
|
||||
guard let slot = await makeSlot(item), generation == loadGeneration else { return }
|
||||
player.removeAllItems()
|
||||
prepared = nil
|
||||
player.insert(slot.playerItem, after: nil)
|
||||
current = slot
|
||||
}
|
||||
adoptable = false
|
||||
if position > 0 {
|
||||
await seek(to: position)
|
||||
}
|
||||
}
|
||||
|
||||
public func prepare(_ item: QueueItem?) async {
|
||||
startObserving()
|
||||
guard prepared?.item.id != item?.id else { return }
|
||||
prepareGeneration += 1
|
||||
let generation = prepareGeneration
|
||||
if let prepared {
|
||||
player.remove(prepared.playerItem)
|
||||
self.prepared = nil
|
||||
}
|
||||
guard let item, current != nil else { return }
|
||||
guard let slot = await makeSlot(item), generation == prepareGeneration, current != nil else { return }
|
||||
player.insert(slot.playerItem, after: nil)
|
||||
prepared = slot
|
||||
}
|
||||
|
||||
public func play() async {
|
||||
wantsPlaying = true
|
||||
if !sessionActive {
|
||||
sessionActive = await (try? AudioSession.activate()) != nil
|
||||
}
|
||||
player.play()
|
||||
}
|
||||
|
||||
public func pause() {
|
||||
wantsPlaying = false
|
||||
player.pause()
|
||||
}
|
||||
|
||||
public func seek(to position: TimeInterval) async {
|
||||
let time = CMTime(seconds: position, preferredTimescale: 600)
|
||||
_ = await player.seek(to: time, toleranceBefore: .zero, toleranceAfter: .zero)
|
||||
reportAnchor()
|
||||
}
|
||||
|
||||
public func stop() {
|
||||
wantsPlaying = false
|
||||
player.pause()
|
||||
player.removeAllItems()
|
||||
current = nil
|
||||
prepared = nil
|
||||
adoptable = false
|
||||
}
|
||||
|
||||
private func makeSlot(_ item: QueueItem) async -> Slot? {
|
||||
do {
|
||||
let request = try await resolve(item.track)
|
||||
let playerItem = makePlayerItem(request)
|
||||
let observation = playerItem.observe(\.status, options: [.new]) { @Sendable [weak self] playerItem, _ in
|
||||
guard playerItem.status == .failed else { return }
|
||||
let id = ObjectIdentifier(playerItem)
|
||||
Task { await self?.itemFailed(id) }
|
||||
}
|
||||
return Slot(item: item, playerItem: playerItem, statusObservation: observation)
|
||||
} catch {
|
||||
continuation.yield(.failed(item.id))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func makePlayerItem(_ request: StreamRequest) -> AVPlayerItem {
|
||||
var options: [String: any Sendable] = [AVURLAssetPreferPreciseDurationAndTimingKey: true]
|
||||
if !request.resource.headers.isEmpty {
|
||||
options["AVURLAssetHTTPHeaderFieldsKey"] = request.resource.headers
|
||||
}
|
||||
let asset = AVURLAsset(url: request.resource.url, options: options)
|
||||
let playerItem = AVPlayerItem(asset: asset)
|
||||
playerItem.preferredForwardBufferDuration = bufferAhead
|
||||
return playerItem
|
||||
}
|
||||
|
||||
private func startObserving() {
|
||||
guard timeObserver == nil else { return }
|
||||
let interval = CMTime(seconds: anchorInterval, preferredTimescale: 600)
|
||||
timeObserver = player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] _ in
|
||||
Task { await self?.reportAnchor() }
|
||||
}
|
||||
observations.append(player.observe(\.timeControlStatus, options: [.new]) { @Sendable [weak self] _, _ in
|
||||
Task { await self?.reportAnchor() }
|
||||
})
|
||||
tokens.append(observeItems(.AVPlayerItemDidPlayToEndTime) { [weak self] id in await self?.itemEnded(id) })
|
||||
tokens.append(observeItems(.AVPlayerItemFailedToPlayToEndTime) { [weak self] id in await self?.itemFailed(id) })
|
||||
tokens.append(contentsOf: AudioSession.observe(
|
||||
interrupted: { [weak self] began in Task { await self?.interruption(began: began) } },
|
||||
routeLost: { [weak self] in Task { await self?.routeLost() } }
|
||||
))
|
||||
}
|
||||
|
||||
private func observeItems(
|
||||
_ name: Notification.Name,
|
||||
handler: @escaping @Sendable (ObjectIdentifier) async -> Void
|
||||
) -> any NSObjectProtocol {
|
||||
NotificationCenter.default.addObserver(forName: name, object: nil, queue: nil) { notification in
|
||||
guard let playerItem = notification.object as? AVPlayerItem else { return }
|
||||
let id = ObjectIdentifier(playerItem)
|
||||
Task { await handler(id) }
|
||||
}
|
||||
}
|
||||
|
||||
private func itemEnded(_ id: ObjectIdentifier) {
|
||||
guard let current, ObjectIdentifier(current.playerItem) == id else { return }
|
||||
continuation.yield(.finished(current.item.id))
|
||||
self.current = prepared
|
||||
prepared = nil
|
||||
adoptable = self.current != nil
|
||||
}
|
||||
|
||||
private func itemFailed(_ id: ObjectIdentifier) {
|
||||
if let current, ObjectIdentifier(current.playerItem) == id {
|
||||
continuation.yield(.failed(current.item.id))
|
||||
} else if let prepared, ObjectIdentifier(prepared.playerItem) == id {
|
||||
player.remove(prepared.playerItem)
|
||||
self.prepared = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func reportAnchor() {
|
||||
guard let playing = player.currentItem,
|
||||
let slot = [current, prepared].compactMap(\.self).first(where: { $0.playerItem === playing })
|
||||
else { return }
|
||||
let seconds = player.currentTime().seconds
|
||||
guard seconds.isFinite else { return }
|
||||
let rate = player.timeControlStatus == .playing ? Double(player.rate) : 0
|
||||
continuation.yield(.anchor(slot.item.id, position: seconds, rate: rate))
|
||||
}
|
||||
|
||||
private func interruption(began: Bool) {
|
||||
if began {
|
||||
player.pause()
|
||||
reportAnchor()
|
||||
} else if wantsPlaying {
|
||||
player.play()
|
||||
}
|
||||
}
|
||||
|
||||
private func routeLost() {
|
||||
wantsPlaying = false
|
||||
player.pause()
|
||||
reportAnchor()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,69 @@
|
||||
#if canImport(AVFoundation)
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
|
||||
enum AudioSession {
|
||||
typealias Token = any NSObjectProtocol
|
||||
|
||||
#if os(iOS) || os(watchOS)
|
||||
static var outputLatency: TimeInterval {
|
||||
AVAudioSession.sharedInstance().outputLatency
|
||||
}
|
||||
|
||||
static func activate() async throws {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
#if os(watchOS)
|
||||
try session.setCategory(.playback, mode: .default, policy: .longFormAudio)
|
||||
_ = try await session.activate()
|
||||
#else
|
||||
try session.setCategory(.playback, mode: .default)
|
||||
try session.setActive(true)
|
||||
#endif
|
||||
}
|
||||
|
||||
static func observe(
|
||||
interrupted: @escaping @Sendable (Bool) -> Void,
|
||||
routeLost: @escaping @Sendable () -> Void
|
||||
) -> [Token] {
|
||||
let center = NotificationCenter.default
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
let interruption = center.addObserver(forName: AVAudioSession.interruptionNotification, object: session, queue: nil) {
|
||||
guard let raw = $0.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt,
|
||||
let type = AVAudioSession.InterruptionType(rawValue: raw)
|
||||
else { return }
|
||||
switch type {
|
||||
case .began:
|
||||
interrupted(true)
|
||||
case .ended:
|
||||
let options = $0.userInfo?[AVAudioSessionInterruptionOptionKey] as? UInt ?? 0
|
||||
if AVAudioSession.InterruptionOptions(rawValue: options).contains(.shouldResume) {
|
||||
interrupted(false)
|
||||
}
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
}
|
||||
let route = center.addObserver(forName: AVAudioSession.routeChangeNotification, object: session, queue: nil) {
|
||||
let raw = $0.userInfo?[AVAudioSessionRouteChangeReasonKey] as? UInt
|
||||
if raw.flatMap(AVAudioSession.RouteChangeReason.init) == .oldDeviceUnavailable {
|
||||
routeLost()
|
||||
}
|
||||
}
|
||||
return [interruption, route]
|
||||
}
|
||||
#else
|
||||
static var outputLatency: TimeInterval {
|
||||
0
|
||||
}
|
||||
|
||||
static func activate() async throws {}
|
||||
|
||||
static func observe(
|
||||
interrupted _: @escaping @Sendable (Bool) -> Void,
|
||||
routeLost _: @escaping @Sendable () -> Void
|
||||
) -> [Token] {
|
||||
[]
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,88 @@
|
||||
import Domain
|
||||
import Foundation
|
||||
|
||||
public struct QueueItem: Identifiable, Hashable, Codable, Sendable {
|
||||
public let id: UUID
|
||||
public let track: Track
|
||||
|
||||
public init(id: UUID = UUID(), track: Track) {
|
||||
self.id = id
|
||||
self.track = track
|
||||
}
|
||||
}
|
||||
|
||||
public enum RepeatMode: String, Codable, Sendable, CaseIterable {
|
||||
case off, all, one
|
||||
}
|
||||
|
||||
public enum PlaybackCommand: Codable, Sendable {
|
||||
case play
|
||||
case pause
|
||||
case togglePlayPause
|
||||
case seek(to: TimeInterval)
|
||||
case next
|
||||
case previous
|
||||
case setQueue(tracks: [Track], startIndex: Int, shuffled: Bool)
|
||||
case insertNext([Track])
|
||||
case insertLast([Track])
|
||||
case jump(to: QueueItem.ID)
|
||||
case move(item: QueueItem.ID, after: QueueItem.ID?)
|
||||
case remove(QueueItem.ID)
|
||||
case clearUpcoming
|
||||
case setShuffle(Bool)
|
||||
case setRepeat(RepeatMode)
|
||||
}
|
||||
|
||||
public struct PlaybackAnchor: Hashable, Sendable {
|
||||
public var position: TimeInterval
|
||||
public var instant: ContinuousClock.Instant
|
||||
public var rate: Double
|
||||
|
||||
public init(position: TimeInterval, instant: ContinuousClock.Instant, rate: Double) {
|
||||
self.position = position
|
||||
self.instant = instant
|
||||
self.rate = rate
|
||||
}
|
||||
|
||||
public func position(at now: ContinuousClock.Instant) -> TimeInterval {
|
||||
let elapsed = now - instant
|
||||
let seconds = Double(elapsed.components.seconds) + Double(elapsed.components.attoseconds) / 1e18
|
||||
return position + seconds * rate
|
||||
}
|
||||
}
|
||||
|
||||
public enum PlaybackStatus: String, Sendable {
|
||||
case stopped, paused, playing
|
||||
}
|
||||
|
||||
public struct PlaybackState: Hashable, Sendable {
|
||||
public var history: [QueueItem] = []
|
||||
public var current: QueueItem?
|
||||
public var upcoming: [QueueItem] = []
|
||||
public var status: PlaybackStatus = .stopped
|
||||
public var anchor = PlaybackAnchor(position: 0, instant: .now, rate: 0)
|
||||
public var isShuffled = false
|
||||
public var repeatMode: RepeatMode = .off
|
||||
|
||||
public init() {}
|
||||
}
|
||||
|
||||
public enum OutputEvent: Sendable {
|
||||
case finished(QueueItem.ID)
|
||||
case failed(QueueItem.ID)
|
||||
case anchor(QueueItem.ID, position: TimeInterval, rate: Double)
|
||||
}
|
||||
|
||||
public protocol OutputEngine: Sendable {
|
||||
var events: AsyncStream<OutputEvent> { get }
|
||||
func load(_ item: QueueItem, at position: TimeInterval) async
|
||||
func prepare(_ item: QueueItem?) async
|
||||
func play() async
|
||||
func pause() async
|
||||
func seek(to position: TimeInterval) async
|
||||
func stop() async
|
||||
}
|
||||
|
||||
public extension OutputEngine {
|
||||
func prepare(_: QueueItem?) async {}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
import Domain
|
||||
import Foundation
|
||||
|
||||
public actor PlaybackSession {
|
||||
public typealias Now = @Sendable () -> ContinuousClock.Instant
|
||||
|
||||
private let engine: any OutputEngine
|
||||
private let now: Now
|
||||
private var state = PlaybackState()
|
||||
private var originalOrder: [QueueItem.ID] = []
|
||||
private var observers: [UUID: AsyncStream<PlaybackState>.Continuation] = [:]
|
||||
private var eventTask: Task<Void, Never>?
|
||||
private var preparedKey: [QueueItem.ID?] = []
|
||||
private var failureStreak = 0
|
||||
private let restartThreshold: TimeInterval = 3
|
||||
|
||||
public init(engine: any OutputEngine, now: @escaping Now = { .now }) {
|
||||
self.engine = engine
|
||||
self.now = now
|
||||
}
|
||||
|
||||
public func states() -> AsyncStream<PlaybackState> {
|
||||
listenToEngine()
|
||||
let key = UUID()
|
||||
let (stream, continuation) = AsyncStream<PlaybackState>.makeStream(bufferingPolicy: .bufferingNewest(1))
|
||||
observers[key] = continuation
|
||||
continuation.onTermination = { [weak self] _ in
|
||||
Task { await self?.removeObserver(key) }
|
||||
}
|
||||
continuation.yield(state)
|
||||
return stream
|
||||
}
|
||||
|
||||
public var snapshot: PlaybackState {
|
||||
state
|
||||
}
|
||||
|
||||
public func send(_ command: PlaybackCommand) async {
|
||||
listenToEngine()
|
||||
failureStreak = 0
|
||||
switch command {
|
||||
case .play: await resume()
|
||||
case .pause: await pause()
|
||||
case .togglePlayPause: state.status == .playing ? await pause() : await resume()
|
||||
case let .seek(position): await seek(to: position)
|
||||
case .next: await advance(auto: false)
|
||||
case .previous: await goBack()
|
||||
case let .setQueue(tracks, startIndex, shuffled): await setQueue(tracks, startIndex: startIndex, shuffled: shuffled)
|
||||
case let .insertNext(tracks): insert(tracks, atEnd: false)
|
||||
case let .insertLast(tracks): insert(tracks, atEnd: true)
|
||||
case let .jump(id): await jump(to: id)
|
||||
case let .move(item, after): move(item, after: after)
|
||||
case let .remove(id): state.upcoming.removeAll { $0.id == id }
|
||||
case .clearUpcoming: state.upcoming.removeAll()
|
||||
case let .setShuffle(isOn): setShuffle(isOn)
|
||||
case let .setRepeat(mode): state.repeatMode = mode
|
||||
}
|
||||
publish()
|
||||
await syncPrepared()
|
||||
}
|
||||
|
||||
private func listenToEngine() {
|
||||
guard eventTask == nil else { return }
|
||||
let events = engine.events
|
||||
eventTask = Task { [weak self] in
|
||||
for await event in events {
|
||||
await self?.handle(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handle(_ event: OutputEvent) async {
|
||||
switch event {
|
||||
case let .finished(id) where id == state.current?.id:
|
||||
failureStreak = 0
|
||||
await finishCurrent()
|
||||
case let .failed(id) where id == state.current?.id:
|
||||
await failCurrent()
|
||||
case let .anchor(id, position, rate) where id == state.current?.id:
|
||||
state.anchor = PlaybackAnchor(position: position, instant: now(), rate: state.status == .playing ? rate : 0)
|
||||
default:
|
||||
return
|
||||
}
|
||||
publish()
|
||||
await syncPrepared()
|
||||
}
|
||||
|
||||
private func finishCurrent() async {
|
||||
if state.repeatMode == .one {
|
||||
await start(at: 0, playing: true)
|
||||
} else {
|
||||
await advance(auto: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func failCurrent() async {
|
||||
failureStreak += 1
|
||||
guard failureStreak <= state.history.count + state.upcoming.count else {
|
||||
anchor(0, playing: false)
|
||||
await engine.stop()
|
||||
return
|
||||
}
|
||||
await advance(auto: true)
|
||||
}
|
||||
|
||||
private var nextToPrepare: QueueItem? {
|
||||
if let next = state.upcoming.first {
|
||||
return next
|
||||
}
|
||||
switch state.repeatMode {
|
||||
case .off: return nil
|
||||
case .one: return state.current
|
||||
case .all: return state.history.first ?? state.current
|
||||
}
|
||||
}
|
||||
|
||||
private func syncPrepared() async {
|
||||
let key = [state.current?.id, nextToPrepare?.id]
|
||||
guard key != preparedKey else { return }
|
||||
preparedKey = key
|
||||
await engine.prepare(state.current == nil ? nil : nextToPrepare)
|
||||
}
|
||||
|
||||
private func removeObserver(_ key: UUID) {
|
||||
observers[key] = nil
|
||||
}
|
||||
|
||||
private func publish() {
|
||||
observers.values.forEach { $0.yield(state) }
|
||||
}
|
||||
|
||||
private func anchor(_ position: TimeInterval, playing: Bool) {
|
||||
state.anchor = PlaybackAnchor(position: position, instant: now(), rate: playing ? 1 : 0)
|
||||
state.status = playing ? .playing : .paused
|
||||
}
|
||||
|
||||
private var position: TimeInterval {
|
||||
let raw = state.anchor.position(at: now())
|
||||
return min(max(raw, 0), state.current?.track.duration ?? 0)
|
||||
}
|
||||
|
||||
private func resume() async {
|
||||
guard state.current != nil, state.status != .playing || state.anchor.rate == 0 else { return }
|
||||
anchor(position, playing: true)
|
||||
await engine.play()
|
||||
}
|
||||
|
||||
private func pause() async {
|
||||
guard state.status == .playing else { return }
|
||||
anchor(position, playing: false)
|
||||
await engine.pause()
|
||||
}
|
||||
|
||||
private func seek(to target: TimeInterval) async {
|
||||
guard let current = state.current else { return }
|
||||
let clamped = min(max(target, 0), current.track.duration)
|
||||
anchor(clamped, playing: state.status == .playing)
|
||||
await engine.seek(to: clamped)
|
||||
}
|
||||
|
||||
private func start(at position: TimeInterval, playing: Bool) async {
|
||||
guard let current = state.current else {
|
||||
state.status = .stopped
|
||||
state.anchor = PlaybackAnchor(position: 0, instant: now(), rate: 0)
|
||||
await engine.stop()
|
||||
return
|
||||
}
|
||||
await engine.load(current, at: position)
|
||||
anchor(position, playing: playing)
|
||||
if playing {
|
||||
await engine.play()
|
||||
}
|
||||
}
|
||||
|
||||
private func setQueue(_ tracks: [Track], startIndex: Int, shuffled: Bool) async {
|
||||
let items = tracks.map { QueueItem(track: $0) }
|
||||
originalOrder = items.map(\.id)
|
||||
state.history = []
|
||||
state.isShuffled = shuffled
|
||||
guard !items.isEmpty else {
|
||||
state.current = nil
|
||||
state.upcoming = []
|
||||
await start(at: 0, playing: false)
|
||||
return
|
||||
}
|
||||
if shuffled {
|
||||
var rest = items
|
||||
let first = rest.remove(at: Int.random(in: rest.indices))
|
||||
state.current = first
|
||||
state.upcoming = rest.shuffled()
|
||||
} else {
|
||||
let index = min(max(startIndex, 0), items.count - 1)
|
||||
state.history = Array(items[..<index])
|
||||
state.current = items[index]
|
||||
state.upcoming = Array(items[(index + 1)...])
|
||||
}
|
||||
await start(at: 0, playing: true)
|
||||
}
|
||||
|
||||
private func insert(_ tracks: [Track], atEnd: Bool) {
|
||||
let items = tracks.map { QueueItem(track: $0) }
|
||||
originalOrder.append(contentsOf: items.map(\.id))
|
||||
if atEnd {
|
||||
state.upcoming.append(contentsOf: items)
|
||||
} else {
|
||||
state.upcoming.insert(contentsOf: items, at: 0)
|
||||
}
|
||||
}
|
||||
|
||||
private func advance(auto: Bool) async {
|
||||
guard let current = state.current else { return }
|
||||
var keepsPlaying = auto || state.status == .playing
|
||||
if state.upcoming.isEmpty {
|
||||
let all = state.history + [current]
|
||||
state.history = []
|
||||
state.current = all.first
|
||||
state.upcoming = Array(all.dropFirst())
|
||||
keepsPlaying = keepsPlaying && state.repeatMode != .off
|
||||
} else {
|
||||
state.history.append(current)
|
||||
state.current = state.upcoming.removeFirst()
|
||||
}
|
||||
await start(at: 0, playing: keepsPlaying)
|
||||
}
|
||||
|
||||
private func goBack() async {
|
||||
guard let current = state.current else { return }
|
||||
guard position <= restartThreshold, let previous = state.history.popLast() else {
|
||||
await seek(to: 0)
|
||||
return
|
||||
}
|
||||
state.upcoming.insert(current, at: 0)
|
||||
state.current = previous
|
||||
await start(at: 0, playing: state.status == .playing)
|
||||
}
|
||||
|
||||
private func jump(to id: QueueItem.ID) async {
|
||||
guard let current = state.current else { return }
|
||||
if let index = state.upcoming.firstIndex(where: { $0.id == id }) {
|
||||
state.history.append(current)
|
||||
state.history.append(contentsOf: state.upcoming[..<index])
|
||||
state.current = state.upcoming[index]
|
||||
state.upcoming.removeSubrange(...index)
|
||||
} else if let index = state.history.firstIndex(where: { $0.id == id }) {
|
||||
state.upcoming.insert(contentsOf: Array(state.history[(index + 1)...]) + [current], at: 0)
|
||||
state.current = state.history[index]
|
||||
state.history.removeSubrange(index...)
|
||||
} else {
|
||||
return
|
||||
}
|
||||
await start(at: 0, playing: true)
|
||||
}
|
||||
|
||||
private func move(_ id: QueueItem.ID, after target: QueueItem.ID?) {
|
||||
guard let from = state.upcoming.firstIndex(where: { $0.id == id }) else { return }
|
||||
let item = state.upcoming.remove(at: from)
|
||||
let index = target.flatMap { anchorID in state.upcoming.firstIndex { $0.id == anchorID } }.map { $0 + 1 } ?? 0
|
||||
state.upcoming.insert(item, at: index)
|
||||
}
|
||||
|
||||
private func setShuffle(_ isOn: Bool) {
|
||||
state.isShuffled = isOn
|
||||
if isOn {
|
||||
state.upcoming.shuffle()
|
||||
} else {
|
||||
let rank = Dictionary(uniqueKeysWithValues: originalOrder.enumerated().map { ($1, $0) })
|
||||
state.upcoming.sort { (rank[$0.id] ?? .max) < (rank[$1.id] ?? .max) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import Foundation
|
||||
|
||||
public actor SilentEngine: OutputEngine {
|
||||
public nonisolated let events: AsyncStream<OutputEvent>
|
||||
private let continuation: AsyncStream<OutputEvent>.Continuation
|
||||
private var item: QueueItem?
|
||||
private var position: TimeInterval = 0
|
||||
private var startedAt: ContinuousClock.Instant?
|
||||
private var timer: Task<Void, Never>?
|
||||
|
||||
public init() {
|
||||
(events, continuation) = AsyncStream<OutputEvent>.makeStream()
|
||||
}
|
||||
|
||||
public func load(_ item: QueueItem, at position: TimeInterval) {
|
||||
timer?.cancel()
|
||||
self.item = item
|
||||
self.position = position
|
||||
startedAt = nil
|
||||
}
|
||||
|
||||
public func play() {
|
||||
guard let item, startedAt == nil else { return }
|
||||
startedAt = .now
|
||||
let remaining = max(item.track.duration - position, 0)
|
||||
timer = Task { [continuation] in
|
||||
try? await Task.sleep(for: .seconds(remaining))
|
||||
guard !Task.isCancelled else { return }
|
||||
continuation.yield(.finished(item.id))
|
||||
}
|
||||
}
|
||||
|
||||
public func pause() {
|
||||
timer?.cancel()
|
||||
if let startedAt {
|
||||
let elapsed = ContinuousClock.now - startedAt
|
||||
position += Double(elapsed.components.seconds) + Double(elapsed.components.attoseconds) / 1e18
|
||||
}
|
||||
startedAt = nil
|
||||
}
|
||||
|
||||
public func seek(to position: TimeInterval) {
|
||||
let wasPlaying = startedAt != nil
|
||||
pause()
|
||||
self.position = position
|
||||
if wasPlaying {
|
||||
play()
|
||||
}
|
||||
}
|
||||
|
||||
public func stop() {
|
||||
timer?.cancel()
|
||||
item = nil
|
||||
startedAt = nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
import ImageIO
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
struct FakeArtwork {
|
||||
private let context: CGContext
|
||||
private let space = CGColorSpaceCreateDeviceRGB()
|
||||
private let size: CGFloat
|
||||
private let hue: Double
|
||||
private let spread: Double
|
||||
private var random: SeededGenerator
|
||||
|
||||
static func png(seed: String, side: Int) -> Data? {
|
||||
guard var artwork = FakeArtwork(seed: seed, side: side) else { return nil }
|
||||
artwork.draw()
|
||||
guard let image = artwork.context.makeImage() else { return nil }
|
||||
let data = NSMutableData()
|
||||
guard let destination = CGImageDestinationCreateWithData(data, UTType.png.identifier as CFString, 1, nil) else { return nil }
|
||||
CGImageDestinationAddImage(destination, image, nil)
|
||||
return CGImageDestinationFinalize(destination) ? data as Data : nil
|
||||
}
|
||||
|
||||
private init?(seed: String, side: Int) {
|
||||
guard let context = CGContext(
|
||||
data: nil,
|
||||
width: side,
|
||||
height: side,
|
||||
bitsPerComponent: 8,
|
||||
bytesPerRow: 0,
|
||||
space: space,
|
||||
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
|
||||
) else { return nil }
|
||||
self.context = context
|
||||
size = CGFloat(side)
|
||||
random = SeededGenerator(seed: seed.utf8.reduce(UInt64(14_695_981_039_346_656_037)) { ($0 ^ UInt64($1)) &* 1_099_511_628_211 })
|
||||
hue = .random(in: 0 ..< 1, using: &random)
|
||||
spread = .random(in: 0.08 ... 0.45, using: &random)
|
||||
}
|
||||
|
||||
private mutating func draw() {
|
||||
let isDark = Bool.random(using: &random)
|
||||
let ground = [color(hue, 0.75, isDark ? 0.35 : 0.95), color(hue + spread, 0.8, isDark ? 0.12 : 0.7)]
|
||||
linear(ground, from: CGPoint(x: 0, y: size), to: CGPoint(x: size, y: 0))
|
||||
switch Int.random(in: 0 ..< 4, using: &random) {
|
||||
case 0: glows()
|
||||
case 1: rings()
|
||||
case 2: sun(horizon: ground[1])
|
||||
default: blocks()
|
||||
}
|
||||
}
|
||||
|
||||
private mutating func glows() {
|
||||
for _ in 0 ..< 3 {
|
||||
let center = CGPoint(x: .random(in: 0 ... size, using: &random), y: .random(in: 0 ... size, using: &random))
|
||||
let tint = color(hue + .random(in: -0.2 ... 0.5, using: &random), 0.7, 1)
|
||||
let radius = size * .random(in: 0.4 ... 0.8, using: &random)
|
||||
guard let clear = tint.copy(alpha: 0),
|
||||
let gradient = CGGradient(colorsSpace: space, colors: [tint, clear] as CFArray, locations: nil)
|
||||
else { continue }
|
||||
context.drawRadialGradient(gradient, startCenter: center, startRadius: 0, endCenter: center, endRadius: radius, options: [])
|
||||
}
|
||||
}
|
||||
|
||||
private mutating func rings() {
|
||||
let center = CGPoint(x: size * .random(in: 0.3 ... 0.7, using: &random), y: size * .random(in: 0.3 ... 0.7, using: &random))
|
||||
for ring in (1 ... 7).reversed() {
|
||||
context.setFillColor(color(hue + spread * Double(ring) / 5, 0.65, ring.isMultiple(of: 2) ? 0.95 : 0.25))
|
||||
let radius = size * 0.09 * CGFloat(ring)
|
||||
context.fillEllipse(in: CGRect(x: center.x - radius, y: center.y - radius, width: radius * 2, height: radius * 2))
|
||||
}
|
||||
}
|
||||
|
||||
private func sun(horizon: CGColor) {
|
||||
let radius = size * 0.32
|
||||
let disc = CGRect(x: size / 2 - radius, y: size * 0.34, width: radius * 2, height: radius * 2)
|
||||
context.saveGState()
|
||||
context.addEllipse(in: disc)
|
||||
context.clip()
|
||||
linear(
|
||||
[color(hue + 0.5, 0.5, 1), color(hue + 0.62, 0.85, 1)],
|
||||
from: CGPoint(x: 0, y: disc.maxY),
|
||||
to: CGPoint(x: 0, y: disc.minY)
|
||||
)
|
||||
context.restoreGState()
|
||||
context.setFillColor(horizon)
|
||||
for stripe in 0 ..< 5 {
|
||||
let height = size * 0.012 * CGFloat(5 - stripe)
|
||||
context.fill(CGRect(x: 0, y: disc.minY + CGFloat(stripe) * size * 0.06, width: size, height: height))
|
||||
}
|
||||
}
|
||||
|
||||
private mutating func blocks() {
|
||||
let cells = Int.random(in: 2 ... 3, using: &random)
|
||||
let cell = size / CGFloat(cells)
|
||||
for row in 0 ..< cells {
|
||||
for column in 0 ..< cells where Bool.random(using: &random) {
|
||||
let rect = CGRect(x: CGFloat(column) * cell, y: CGFloat(row) * cell, width: cell, height: cell)
|
||||
context.setFillColor(color(hue + .random(in: 0 ... 0.6, using: &random), 0.7, .random(in: 0.3 ... 1, using: &random)))
|
||||
if Bool.random(using: &random) {
|
||||
context.fill(rect)
|
||||
} else {
|
||||
context.fillEllipse(in: rect)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func color(_ hue: Double, _ saturation: Double, _ brightness: Double) -> CGColor {
|
||||
let sector = (hue.truncatingRemainder(dividingBy: 1) + 1).truncatingRemainder(dividingBy: 1) * 6
|
||||
let chroma = brightness * saturation
|
||||
let second = chroma * (1 - abs(sector.truncatingRemainder(dividingBy: 2) - 1))
|
||||
let table = [
|
||||
[chroma, second, 0],
|
||||
[second, chroma, 0],
|
||||
[0, chroma, second],
|
||||
[0, second, chroma],
|
||||
[second, 0, chroma],
|
||||
[chroma, 0, second],
|
||||
]
|
||||
let rgb = table[min(Int(sector), 5)].map { $0 + brightness - chroma }
|
||||
return CGColor(red: rgb[0], green: rgb[1], blue: rgb[2], alpha: 1)
|
||||
}
|
||||
|
||||
private func linear(_ colors: [CGColor], from: CGPoint, to: CGPoint) {
|
||||
guard let gradient = CGGradient(colorsSpace: space, colors: colors as CFArray, locations: nil) else { return }
|
||||
context.drawLinearGradient(gradient, start: from, end: to, options: [.drawsBeforeStartLocation, .drawsAfterEndLocation])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import Domain
|
||||
import Foundation
|
||||
|
||||
struct SeededGenerator: RandomNumberGenerator {
|
||||
private var state: UInt64
|
||||
|
||||
init(seed: UInt64) {
|
||||
state = seed &+ 0x9E37_79B9_7F4A_7C15
|
||||
}
|
||||
|
||||
mutating func next() -> UInt64 {
|
||||
state &+= 0x9E37_79B9_7F4A_7C15
|
||||
var mixed = state
|
||||
mixed = (mixed ^ (mixed >> 30)) &* 0xBF58_476D_1CE4_E5B9
|
||||
mixed = (mixed ^ (mixed >> 27)) &* 0x94D0_49BB_1331_11EB
|
||||
return mixed ^ (mixed >> 31)
|
||||
}
|
||||
}
|
||||
|
||||
struct FakeCatalog {
|
||||
struct AlbumSeed {
|
||||
let title: String
|
||||
let year: Int
|
||||
let tracks: Int
|
||||
}
|
||||
|
||||
struct ArtistSeed {
|
||||
let name: String
|
||||
let genre: String
|
||||
let albums: [AlbumSeed]
|
||||
}
|
||||
|
||||
static let artists: [ArtistSeed] = [
|
||||
ArtistSeed(name: "Velvet Harbor", genre: "Indie Pop", albums: [
|
||||
AlbumSeed(title: "Salt in the Signal", year: 2024, tracks: 11),
|
||||
AlbumSeed(title: "Low Tide Radio", year: 2021, tracks: 10),
|
||||
AlbumSeed(title: "Postcards From Nowhere", year: 2018, tracks: 12),
|
||||
]),
|
||||
ArtistSeed(name: "Нина Орлова", genre: "Alternative", albums: [
|
||||
AlbumSeed(title: "Тихий час", year: 2025, tracks: 9),
|
||||
AlbumSeed(title: "Север внутри", year: 2022, tracks: 11),
|
||||
]),
|
||||
ArtistSeed(name: "Kaito Mori", genre: "Electronic", albums: [
|
||||
AlbumSeed(title: "Neon Monsoon", year: 2023, tracks: 8),
|
||||
AlbumSeed(title: "東京 After Hours", year: 2020, tracks: 10),
|
||||
AlbumSeed(title: "Glass Circuits", year: 2017, tracks: 9),
|
||||
AlbumSeed(title: "Soft Machines (Remixes)", year: 2024, tracks: 6),
|
||||
]),
|
||||
ArtistSeed(name: "The Paper Lanterns", genre: "Rock", albums: [
|
||||
AlbumSeed(title: "Everything Burns Brighter at Night and Other Small Disasters", year: 2019, tracks: 13),
|
||||
AlbumSeed(title: "Static Bloom", year: 2016, tracks: 11),
|
||||
AlbumSeed(title: "Live at the Old Observatory", year: 2022, tracks: 16),
|
||||
]),
|
||||
ArtistSeed(name: "Marisol Vega", genre: "Latin", albums: [
|
||||
AlbumSeed(title: "Corazón de Verano", year: 2024, tracks: 12),
|
||||
AlbumSeed(title: "Luna Roja", year: 2021, tracks: 10),
|
||||
]),
|
||||
ArtistSeed(name: "Oskar Lindqvist Trio", genre: "Jazz", albums: [
|
||||
AlbumSeed(title: "Blue Hours", year: 2015, tracks: 7),
|
||||
AlbumSeed(title: "Winter Standards", year: 2018, tracks: 9),
|
||||
AlbumSeed(title: "Stockholm Sessions", year: 2023, tracks: 8),
|
||||
]),
|
||||
ArtistSeed(name: "DJ Quartz", genre: "Hip-Hop", albums: [
|
||||
AlbumSeed(title: "Concrete Gardens", year: 2022, tracks: 14),
|
||||
AlbumSeed(title: "No Signal", year: 2025, tracks: 12),
|
||||
]),
|
||||
ArtistSeed(name: "Aurora Fields", genre: "Ambient", albums: [
|
||||
AlbumSeed(title: "Weightless", year: 2020, tracks: 6),
|
||||
AlbumSeed(title: "Slow Light", year: 2023, tracks: 7),
|
||||
AlbumSeed(title: "Music for Empty Airports", year: 2017, tracks: 5),
|
||||
]),
|
||||
ArtistSeed(name: "Holloway & Finch", genre: "Folk", albums: [
|
||||
AlbumSeed(title: "The Orchard Tapes", year: 2019, tracks: 10),
|
||||
AlbumSeed(title: "Rivers We Crossed", year: 2024, tracks: 11),
|
||||
]),
|
||||
ArtistSeed(name: "Сёстры Грома", genre: "Rock", albums: [
|
||||
AlbumSeed(title: "Электричество", year: 2023, tracks: 10),
|
||||
AlbumSeed(title: "Город без сна", year: 2020, tracks: 12),
|
||||
]),
|
||||
ArtistSeed(name: "Camille Durand", genre: "Classical", albums: [
|
||||
AlbumSeed(title: "Nocturnes & Preludes", year: 2021, tracks: 14),
|
||||
AlbumSeed(title: "Satie: Works for Piano", year: 2016, tracks: 12),
|
||||
]),
|
||||
ArtistSeed(name: "Midnight Arcade", genre: "Electronic", albums: [
|
||||
AlbumSeed(title: "Insert Coin", year: 2018, tracks: 10),
|
||||
AlbumSeed(title: "High Score", year: 2021, tracks: 11),
|
||||
AlbumSeed(title: "Continue?", year: 2025, tracks: 9),
|
||||
]),
|
||||
ArtistSeed(name: "Juno Okafor", genre: "R&B/Soul", albums: [
|
||||
AlbumSeed(title: "Honey & Smoke", year: 2024, tracks: 12),
|
||||
AlbumSeed(title: "Afterglow", year: 2022, tracks: 10),
|
||||
]),
|
||||
ArtistSeed(name: "Iron Meridian", genre: "Metal", albums: [
|
||||
AlbumSeed(title: "Cathedral of Ash", year: 2019, tracks: 9),
|
||||
AlbumSeed(title: "Tidal Lock", year: 2023, tracks: 8),
|
||||
]),
|
||||
]
|
||||
|
||||
static let openers = [
|
||||
"Midnight", "Paper", "Golden", "Silent", "Electric", "Hollow", "Crimson", "Distant", "Velvet", "Broken",
|
||||
"Summer", "Neon", "Quiet", "Wild", "Frozen", "Burning", "Little", "Endless", "Lonely", "Silver",
|
||||
]
|
||||
static let closers = [
|
||||
"Hearts", "Rivers", "Machines", "Letters", "Highways", "Gardens", "Satellites", "Windows", "Echoes", "Tides",
|
||||
"Dreams", "Wires", "Shadows", "Mornings", "Skylines", "Ghosts", "Oceans", "Rooms", "Signals", "Fires",
|
||||
]
|
||||
static let singles = [
|
||||
"Interlude", "Overture", "Home", "Gravity", "Again", "Stay", "Run", "Waves", "Closer", "Tomorrow",
|
||||
"Если бы", "Навсегда", "Après la pluie", "Sin Ti", "さよなら",
|
||||
]
|
||||
static let suffixes = ["", "", "", "", "", "", " (Reprise)", " (feat. Juno Okafor)", " (Acoustic)", ", Pt. 2"]
|
||||
static let formats: [AudioFormat] = [
|
||||
AudioFormat(codec: .flac, bitrateKbps: 1011, sampleRate: 44100, bitDepth: 16),
|
||||
AudioFormat(codec: .flac, bitrateKbps: 2890, sampleRate: 96000, bitDepth: 24),
|
||||
AudioFormat(codec: .flac, bitrateKbps: 1411, sampleRate: 48000, bitDepth: 24),
|
||||
AudioFormat(codec: .mp3, bitrateKbps: 320, sampleRate: 44100),
|
||||
AudioFormat(codec: .aac, bitrateKbps: 256, sampleRate: 44100),
|
||||
]
|
||||
|
||||
let source: SourceID
|
||||
private(set) var changes: [Change] = []
|
||||
|
||||
init(source: SourceID, now: Date = Date()) {
|
||||
self.source = source
|
||||
var random = SeededGenerator(seed: 42)
|
||||
var allTracks: [Track] = []
|
||||
var albumIndex = 0
|
||||
for (artistIndex, seed) in Self.artists.enumerated() {
|
||||
let artistID = id("artist-\(artistIndex)")
|
||||
let artist = Artist(
|
||||
id: artistID,
|
||||
name: seed.name,
|
||||
sortName: seed.name.hasPrefix("The ") ? String(seed.name.dropFirst(4)) : seed.name,
|
||||
artwork: id("art-artist-\(artistIndex)"),
|
||||
albumCount: seed.albums.count
|
||||
)
|
||||
changes.append(.upsert(.artist(artist)))
|
||||
for albumSeed in seed.albums {
|
||||
let tracks = makeTracks(albumSeed, albumIndex: albumIndex, artist: artist, genre: seed.genre, random: &random)
|
||||
let album = Album(
|
||||
id: id("album-\(albumIndex)"),
|
||||
title: albumSeed.title,
|
||||
artistID: artistID,
|
||||
artistName: seed.name,
|
||||
year: albumSeed.year,
|
||||
genre: seed.genre,
|
||||
artwork: id("art-album-\(albumIndex)"),
|
||||
trackCount: tracks.count,
|
||||
duration: tracks.reduce(0) { $0 + $1.duration },
|
||||
addedAt: now.addingTimeInterval(-Double(Int.random(in: 1 ... 400, using: &random)) * 86400),
|
||||
bestFormat: tracks.first?.formats.first
|
||||
)
|
||||
changes.append(.upsert(.album(album)))
|
||||
changes.append(contentsOf: tracks.map { .upsert(.track($0)) })
|
||||
allTracks.append(contentsOf: tracks)
|
||||
albumIndex += 1
|
||||
}
|
||||
}
|
||||
makePlaylists(from: allTracks, now: now, random: &random)
|
||||
makeAnnotations(for: allTracks, now: now, random: &random)
|
||||
makeStations()
|
||||
}
|
||||
|
||||
private func id(_ remote: String) -> EntityID {
|
||||
EntityID(source: source, remote: remote)
|
||||
}
|
||||
|
||||
private func makeTracks(
|
||||
_ seed: AlbumSeed,
|
||||
albumIndex: Int,
|
||||
artist: Artist,
|
||||
genre: String,
|
||||
random: inout SeededGenerator
|
||||
) -> [Track] {
|
||||
let format = Self.formats[Int.random(in: 0 ..< Self.formats.count, using: &random)]
|
||||
return (1 ... seed.tracks).map { number in
|
||||
let base = Int.random(in: 0 ..< 5, using: &random) == 0
|
||||
? Self.singles.randomElement(using: &random) ?? ""
|
||||
: "\(Self.openers.randomElement(using: &random) ?? "") \(Self.closers.randomElement(using: &random) ?? "")"
|
||||
return Track(
|
||||
id: id("track-\(albumIndex)-\(number)"),
|
||||
title: base + (Self.suffixes.randomElement(using: &random) ?? ""),
|
||||
albumID: id("album-\(albumIndex)"),
|
||||
albumTitle: seed.title,
|
||||
artistID: artist.id,
|
||||
artistName: artist.name,
|
||||
trackNumber: number,
|
||||
duration: genre == "Ambient" || genre == "Jazz"
|
||||
? Double(Int.random(in: 280 ... 620, using: &random))
|
||||
: Double(Int.random(in: 118 ... 325, using: &random)),
|
||||
year: seed.year,
|
||||
genre: genre,
|
||||
artwork: id("art-album-\(albumIndex)"),
|
||||
isExplicit: genre == "Hip-Hop" && Int.random(in: 0 ..< 2, using: &random) == 0,
|
||||
formats: [format]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private mutating func makePlaylists(from tracks: [Track], now: Date, random: inout SeededGenerator) {
|
||||
let seeds: [(name: String, summary: String)] = [
|
||||
("Late Night Drive", "Windows down, city lights."),
|
||||
("Focus", "Instrumental and quiet."),
|
||||
("Утро", ""),
|
||||
("Gym", "Loud."),
|
||||
("Sunday Kitchen", "Cooking with the radio on."),
|
||||
]
|
||||
for (index, seed) in seeds.enumerated() {
|
||||
let picked = Array(tracks.shuffled(using: &random).prefix(12 + index * 5))
|
||||
let playlist = Playlist(
|
||||
id: id("playlist-\(index)"),
|
||||
name: seed.name,
|
||||
summary: seed.summary,
|
||||
artwork: id("art-playlist-\(index)"),
|
||||
trackCount: picked.count,
|
||||
duration: picked.reduce(0) { $0 + $1.duration },
|
||||
updatedAt: now.addingTimeInterval(-Double(index) * 86400 * 9)
|
||||
)
|
||||
let entries = picked.enumerated().map { PlaylistEntry(id: "entry-\(index)-\($0.offset)", track: $0.element.id) }
|
||||
changes.append(.upsert(.playlist(playlist, entries: entries)))
|
||||
}
|
||||
}
|
||||
|
||||
private mutating func makeAnnotations(for tracks: [Track], now: Date, random: inout SeededGenerator) {
|
||||
for track in tracks.shuffled(using: &random).prefix(140) {
|
||||
let plays = Int.random(in: 1 ... 60, using: &random)
|
||||
changes.append(.annotate(track.id, Annotation(
|
||||
favoritedAt: plays > 42 ? now.addingTimeInterval(-Double(plays) * 3600) : nil,
|
||||
playCount: plays,
|
||||
lastPlayedAt: now.addingTimeInterval(-Double(Int.random(in: 1 ... 900, using: &random)) * 3600)
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
private mutating func makeStations() {
|
||||
let seeds = [
|
||||
("Radio Paradise", "Eclectic, listener supported", "https://stream.radioparadise.com/flacm"),
|
||||
("SomaFM Groove Salad", "Ambient and downtempo beats", "https://ice1.somafm.com/groovesalad-256-mp3"),
|
||||
("KEXP 90.3", "Where the music matters", "https://kexp-mp3-128.streamguys1.com/kexp128.mp3"),
|
||||
("FIP", "Radio France, sans frontières", "https://icecast.radiofrance.fr/fip-hifi.aac"),
|
||||
]
|
||||
for (index, seed) in seeds.enumerated() {
|
||||
guard let url = URL(string: seed.2) else { continue }
|
||||
changes.append(.upsert(.station(RadioStation(
|
||||
id: id("station-\(index)"),
|
||||
name: seed.0,
|
||||
tagline: seed.1,
|
||||
streamURL: url,
|
||||
artwork: id("art-station-\(index)")
|
||||
))))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import Domain
|
||||
import Foundation
|
||||
import Synchronization
|
||||
|
||||
public final class FakeSource: LibrarySource, Starring, LyricsSource {
|
||||
public let id: SourceID
|
||||
public let name: String
|
||||
private let catalog: FakeCatalog
|
||||
private let artworkDirectory: URL
|
||||
private struct Extras {
|
||||
var changes: [Change] = []
|
||||
var artwork: [String: URL] = [:]
|
||||
var lyrics: [String: URL] = [:]
|
||||
}
|
||||
|
||||
private let extras = Mutex(Extras())
|
||||
|
||||
public init(id: SourceID = SourceID(), name: String = "Demo Library") {
|
||||
self.id = id
|
||||
self.name = name
|
||||
catalog = FakeCatalog(source: id)
|
||||
artworkDirectory = FileManager.default.temporaryDirectory.appending(path: "FakeSourceArtwork", directoryHint: .isDirectory)
|
||||
try? FileManager.default.createDirectory(at: artworkDirectory, withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
public var sampleTracks: [Track] {
|
||||
catalog.changes.compactMap {
|
||||
if case let .upsert(.track(track)) = $0 {
|
||||
return track
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
public func add(_ changes: [Change], artworkFiles: [String: URL], lyricsFiles: [String: URL] = [:]) {
|
||||
extras.withLock {
|
||||
$0.changes += changes
|
||||
$0.artwork.merge(artworkFiles) { $1 }
|
||||
$0.lyrics.merge(lyricsFiles) { $1 }
|
||||
}
|
||||
}
|
||||
|
||||
public func lyrics(for track: EntityID) async throws -> Lyrics? {
|
||||
guard let file = extras.withLock({ $0.lyrics[track.remote] }) else { return nil }
|
||||
return try Lyrics(lrc: String(contentsOf: file, encoding: .utf8))
|
||||
}
|
||||
|
||||
public func changes(since cursor: Cursor?) -> AsyncThrowingStream<Change, Error> {
|
||||
let changes = cursor == nil ? catalog.changes + extras.withLock(\.changes) : []
|
||||
return AsyncThrowingStream { continuation in
|
||||
changes.forEach { continuation.yield($0) }
|
||||
continuation.yield(.checkpoint(Cursor("1")))
|
||||
continuation.finish()
|
||||
}
|
||||
}
|
||||
|
||||
public func stream(for _: EntityID) async throws -> StreamRequest {
|
||||
throw SourceError.notFound
|
||||
}
|
||||
|
||||
public func artwork(_ id: EntityID, pixelSize: Int) async throws -> ResourceRequest {
|
||||
if let file = extras.withLock({ $0.artwork[id.remote] }) {
|
||||
return ResourceRequest(url: file)
|
||||
}
|
||||
let side = pixelSize <= 320 ? 320 : 1024
|
||||
let file = artworkDirectory.appending(path: "\(id.remote)-\(side).png")
|
||||
if !FileManager.default.fileExists(atPath: file.path) {
|
||||
guard let data = FakeArtwork.png(seed: id.remote, side: side) else { throw SourceError.notFound }
|
||||
try data.write(to: file, options: .atomic)
|
||||
}
|
||||
return ResourceRequest(url: file)
|
||||
}
|
||||
|
||||
public func setFavorite(_: Bool, for _: EntityID, kind _: EntityKind) async throws {}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
public enum SubsonicError: Error, Equatable, Sendable {
|
||||
case server(code: Int, message: String)
|
||||
case http(Int)
|
||||
case malformed
|
||||
}
|
||||
|
||||
typealias SubsonicTransport = @Sendable (URLRequest) async throws -> (Data, URLResponse)
|
||||
|
||||
struct SubsonicClient: Sendable {
|
||||
let baseURL: URL
|
||||
let username: String
|
||||
let password: String
|
||||
let transport: SubsonicTransport
|
||||
|
||||
init(baseURL: URL, username: String, password: String, transport: @escaping SubsonicTransport) {
|
||||
self.baseURL = baseURL
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.transport = transport
|
||||
}
|
||||
|
||||
init(baseURL: URL, username: String, password: String, session: URLSession) {
|
||||
self.init(baseURL: baseURL, username: username, password: password) { request in
|
||||
try await session.data(for: request)
|
||||
}
|
||||
}
|
||||
|
||||
func url(_ method: String, _ params: [String: String] = [:]) -> URL {
|
||||
let endpoint = baseURL.appending(path: "rest/\(method).view")
|
||||
var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false) ?? URLComponents()
|
||||
components.queryItems = (params.sorted { $0.key < $1.key } + authParams()).map { URLQueryItem(name: $0.key, value: $0.value) }
|
||||
return components.url ?? endpoint
|
||||
}
|
||||
|
||||
func call<Payload: Decodable>(_ method: String, _ params: [String: String] = [:], as _: Payload.Type) async throws -> Payload {
|
||||
let request = URLRequest(url: url(method, params))
|
||||
let (data, response) = try await transport(request)
|
||||
if let http = response as? HTTPURLResponse, http.statusCode >= 400 {
|
||||
throw SubsonicError.http(http.statusCode)
|
||||
}
|
||||
return try Self.decode(data, as: Payload.self)
|
||||
}
|
||||
|
||||
func call(_ method: String, _ params: [String: String] = [:]) async throws {
|
||||
_ = try await call(method, params, as: EmptyPayload.self)
|
||||
}
|
||||
|
||||
static func decode<Payload: Decodable>(_ data: Data, as _: Payload.Type) throws -> Payload {
|
||||
let decoder = JSONDecoder()
|
||||
let status: Wrapper<EnvelopeStatus>
|
||||
do {
|
||||
status = try decoder.decode(Wrapper<EnvelopeStatus>.self, from: data)
|
||||
} catch {
|
||||
throw SubsonicError.malformed
|
||||
}
|
||||
if status.response.status != "ok" {
|
||||
let error = status.response.error
|
||||
throw SubsonicError.server(code: error?.code ?? 0, message: error?.message ?? "")
|
||||
}
|
||||
do {
|
||||
return try decoder.decode(Wrapper<Payload>.self, from: data).response
|
||||
} catch {
|
||||
throw SubsonicError.malformed
|
||||
}
|
||||
}
|
||||
|
||||
private func authParams() -> [(key: String, value: String)] {
|
||||
let salt = Self.salt()
|
||||
let token = Insecure.MD5.hash(data: Data((password + salt).utf8)).map { String(format: "%02x", $0) }.joined()
|
||||
return [("u", username), ("t", token), ("s", salt), ("v", "1.16.1"), ("c", "Amphlify"), ("f", "json")]
|
||||
}
|
||||
|
||||
private static func salt() -> String {
|
||||
let alphabet = Array("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
|
||||
return String((0 ..< 12).compactMap { _ in alphabet.randomElement() })
|
||||
}
|
||||
}
|
||||
|
||||
private struct Wrapper<Payload: Decodable>: Decodable {
|
||||
let response: Payload
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case response = "subsonic-response"
|
||||
}
|
||||
}
|
||||
|
||||
private struct EnvelopeStatus: Decodable {
|
||||
struct Failure: Decodable {
|
||||
let code: Int
|
||||
let message: String?
|
||||
}
|
||||
|
||||
let status: String
|
||||
let error: Failure?
|
||||
}
|
||||
|
||||
struct EmptyPayload: Decodable {}
|
||||
@@ -0,0 +1,137 @@
|
||||
import Domain
|
||||
import Foundation
|
||||
|
||||
struct SubsonicMemory: Sendable {
|
||||
var seen: [EntityKind: Set<String>] = [:]
|
||||
var formats: [String: AudioFormat] = [:]
|
||||
}
|
||||
|
||||
struct SubsonicCrawl {
|
||||
static let pageSize = 500
|
||||
|
||||
let client: SubsonicClient
|
||||
let mapper: SubsonicMapper
|
||||
let emit: (Change) -> Void
|
||||
private var memory = SubsonicMemory()
|
||||
private var bestByAlbum: [String: AudioFormat] = [:]
|
||||
private var annotations: [Change] = []
|
||||
|
||||
init(client: SubsonicClient, mapper: SubsonicMapper, emit: @escaping (Change) -> Void) {
|
||||
self.client = client
|
||||
self.mapper = mapper
|
||||
self.emit = emit
|
||||
}
|
||||
|
||||
mutating func run(previous: [EntityKind: Set<String>]) async throws -> SubsonicMemory {
|
||||
try await crawlArtists()
|
||||
try await crawlTracks()
|
||||
try await crawlAlbums()
|
||||
try await crawlPlaylists()
|
||||
try await crawlStations()
|
||||
try await crawlStarred()
|
||||
annotations.forEach(emit)
|
||||
emitRemovals(previous: previous)
|
||||
return memory
|
||||
}
|
||||
|
||||
private mutating func upsert(_ entity: Entity, kind: EntityKind, remote: String) {
|
||||
memory.seen[kind, default: []].insert(remote)
|
||||
emit(.upsert(entity))
|
||||
}
|
||||
|
||||
private mutating func crawlArtists() async throws {
|
||||
let payload = try await client.call("getArtists", as: ArtistsPayload.self)
|
||||
for index in payload.artists.index ?? [] {
|
||||
for artist in index.artist ?? [] {
|
||||
upsert(.artist(mapper.artist(artist)), kind: .artist, remote: artist.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private mutating func crawlTracks() async throws {
|
||||
var offset = 0
|
||||
while true {
|
||||
try Task.checkCancellation()
|
||||
let params = [
|
||||
"query": "\"\"",
|
||||
"songCount": String(Self.pageSize),
|
||||
"songOffset": String(offset),
|
||||
"artistCount": "0",
|
||||
"albumCount": "0",
|
||||
]
|
||||
let songs = try await client.call("search3", params, as: SearchPayload.self).searchResult3.song ?? []
|
||||
songs.forEach { consume($0) }
|
||||
offset += songs.count
|
||||
if songs.count < Self.pageSize {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private mutating func consume(_ song: SongJSON) {
|
||||
let track = mapper.track(song)
|
||||
upsert(.track(track), kind: .track, remote: song.id)
|
||||
if let format = track.formats.first {
|
||||
memory.formats[song.id] = format
|
||||
if let albumId = song.albumId, format.isBetter(than: bestByAlbum[albumId]) {
|
||||
bestByAlbum[albumId] = format
|
||||
}
|
||||
}
|
||||
if let annotation = mapper.annotation(song) {
|
||||
annotations.append(.annotate(track.id, annotation))
|
||||
}
|
||||
}
|
||||
|
||||
private mutating func crawlAlbums() async throws {
|
||||
var offset = 0
|
||||
while true {
|
||||
try Task.checkCancellation()
|
||||
let params = ["type": "alphabeticalByName", "size": String(Self.pageSize), "offset": String(offset)]
|
||||
let albums = try await client.call("getAlbumList2", params, as: AlbumListPayload.self).albumList2.album ?? []
|
||||
for album in albums {
|
||||
upsert(.album(mapper.album(album, bestFormat: bestByAlbum[album.id])), kind: .album, remote: album.id)
|
||||
}
|
||||
offset += albums.count
|
||||
if albums.count < Self.pageSize {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private mutating func crawlPlaylists() async throws {
|
||||
let playlists = try await client.call("getPlaylists", as: PlaylistsPayload.self).playlists.playlist ?? []
|
||||
for summary in playlists {
|
||||
try Task.checkCancellation()
|
||||
let detail = try await client.call("getPlaylist", ["id": summary.id], as: PlaylistPayload.self).playlist
|
||||
let (playlist, entries) = mapper.playlist(detail)
|
||||
upsert(.playlist(playlist, entries: entries), kind: .playlist, remote: detail.id)
|
||||
}
|
||||
}
|
||||
|
||||
private mutating func crawlStations() async throws {
|
||||
let stations = try await client.call("getInternetRadioStations", as: StationsPayload.self).internetRadioStations
|
||||
for json in stations.internetRadioStation ?? [] {
|
||||
if let station = mapper.station(json) {
|
||||
upsert(.station(station), kind: .station, remote: json.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private mutating func crawlStarred() async throws {
|
||||
let starred = try await client.call("getStarred2", as: StarredPayload.self).starred2
|
||||
for artist in starred.artist ?? [] {
|
||||
annotations.append(.annotate(mapper.id(artist.id), Annotation(favoritedAt: SubsonicDate.parse(artist.starred) ?? .now)))
|
||||
}
|
||||
for album in starred.album ?? [] {
|
||||
annotations.append(.annotate(mapper.id(album.id), Annotation(favoritedAt: SubsonicDate.parse(album.starred) ?? .now)))
|
||||
}
|
||||
}
|
||||
|
||||
private func emitRemovals(previous: [EntityKind: Set<String>]) {
|
||||
for (kind, remotes) in previous {
|
||||
for remote in remotes.subtracting(memory.seen[kind] ?? []).sorted() {
|
||||
emit(.remove(kind, mapper.id(remote)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import Domain
|
||||
import Foundation
|
||||
|
||||
enum SubsonicDate {
|
||||
static func parse(_ raw: String?) -> Date? {
|
||||
guard let raw else { return nil }
|
||||
guard let dot = raw.firstIndex(of: ".") else {
|
||||
return try? Date(raw, strategy: .iso8601)
|
||||
}
|
||||
let digits = raw[raw.index(after: dot)...].prefix { $0.isNumber }
|
||||
let rest = raw[raw.index(dot, offsetBy: 1 + digits.count)...]
|
||||
let fraction = String(digits.prefix(3)).padding(toLength: 3, withPad: "0", startingAt: 0)
|
||||
return try? Date(raw[..<dot] + "." + fraction + rest, strategy: Date.ISO8601FormatStyle(includingFractionalSeconds: true))
|
||||
}
|
||||
}
|
||||
|
||||
extension AudioFormat {
|
||||
init?(song: SongJSON) {
|
||||
guard let codec = Codec(suffix: song.suffix) else { return nil }
|
||||
self.init(
|
||||
codec: codec,
|
||||
bitrateKbps: song.bitRate,
|
||||
sampleRate: song.samplingRate,
|
||||
bitDepth: song.bitDepth.flatMap { $0 > 0 ? $0 : nil },
|
||||
channels: song.channelCount ?? 2
|
||||
)
|
||||
}
|
||||
|
||||
private var rank: [Int] {
|
||||
[isLossless ? 1 : 0, sampleRate ?? 0, bitDepth ?? 0, bitrateKbps ?? 0]
|
||||
}
|
||||
|
||||
func isBetter(than other: AudioFormat?) -> Bool {
|
||||
guard let other else { return true }
|
||||
return other.rank.lexicographicallyPrecedes(rank)
|
||||
}
|
||||
}
|
||||
|
||||
extension AudioFormat.Codec {
|
||||
init?(suffix: String?) {
|
||||
switch suffix?.lowercased() {
|
||||
case "flac": self = .flac
|
||||
case "alac": self = .alac
|
||||
case "wav": self = .wav
|
||||
case "mp3": self = .mp3
|
||||
case "aac", "m4a": self = .aac
|
||||
case "opus": self = .opus
|
||||
case "ogg": self = .vorbis
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SubsonicMapper: Sendable {
|
||||
let source: SourceID
|
||||
|
||||
func id(_ remote: String) -> EntityID {
|
||||
EntityID(source: source, remote: remote)
|
||||
}
|
||||
|
||||
func artwork(_ remote: String?) -> EntityID? {
|
||||
guard let remote, !remote.isEmpty else { return nil }
|
||||
return id(remote)
|
||||
}
|
||||
|
||||
func artist(_ json: ArtistJSON) -> Artist {
|
||||
Artist(id: id(json.id), name: json.name, sortName: json.sortName, artwork: artwork(json.coverArt), albumCount: json.albumCount ?? 0)
|
||||
}
|
||||
|
||||
func track(_ json: SongJSON) -> Track {
|
||||
Track(
|
||||
id: id(json.id),
|
||||
title: json.title,
|
||||
albumID: json.albumId.map(id),
|
||||
albumTitle: json.album ?? "",
|
||||
artistID: json.artistId.map(id),
|
||||
artistName: json.artist ?? "",
|
||||
discNumber: json.discNumber ?? 1,
|
||||
trackNumber: json.track ?? 0,
|
||||
duration: json.duration ?? 0,
|
||||
year: json.year,
|
||||
genre: json.genre,
|
||||
artwork: artwork(json.coverArt),
|
||||
isExplicit: json.explicitStatus == "explicit",
|
||||
formats: AudioFormat(song: json).map { [$0] } ?? []
|
||||
)
|
||||
}
|
||||
|
||||
func album(_ json: AlbumJSON, bestFormat: AudioFormat?) -> Album {
|
||||
Album(
|
||||
id: id(json.id),
|
||||
title: json.name,
|
||||
artistID: json.artistId.map(id),
|
||||
artistName: json.artist ?? "",
|
||||
year: json.year,
|
||||
genre: json.genre,
|
||||
artwork: artwork(json.coverArt),
|
||||
trackCount: json.songCount ?? 0,
|
||||
duration: json.duration ?? 0,
|
||||
addedAt: SubsonicDate.parse(json.created) ?? .distantPast,
|
||||
bestFormat: bestFormat
|
||||
)
|
||||
}
|
||||
|
||||
func playlist(_ json: PlaylistJSON) -> (Playlist, [PlaylistEntry]) {
|
||||
let playlist = Playlist(
|
||||
id: id(json.id),
|
||||
name: json.name,
|
||||
summary: json.comment ?? "",
|
||||
artwork: artwork(json.coverArt),
|
||||
trackCount: json.songCount ?? json.entry?.count ?? 0,
|
||||
duration: json.duration ?? 0,
|
||||
updatedAt: SubsonicDate.parse(json.changed) ?? .distantPast
|
||||
)
|
||||
let entries = (json.entry ?? []).enumerated().map { index, song in
|
||||
PlaylistEntry(id: "\(json.id):\(index)", track: id(song.id))
|
||||
}
|
||||
return (playlist, entries)
|
||||
}
|
||||
|
||||
func station(_ json: StationJSON) -> RadioStation? {
|
||||
guard let url = URL(string: json.streamUrl), url.scheme != nil else { return nil }
|
||||
return RadioStation(
|
||||
id: id(json.id),
|
||||
name: json.name,
|
||||
tagline: json.homePageUrl ?? "",
|
||||
streamURL: url,
|
||||
artwork: artwork(json.coverArt)
|
||||
)
|
||||
}
|
||||
|
||||
func annotation(_ song: SongJSON) -> Annotation? {
|
||||
let favoritedAt = SubsonicDate.parse(song.starred)
|
||||
let playCount = song.playCount ?? 0
|
||||
guard favoritedAt != nil || playCount > 0 else { return nil }
|
||||
return Annotation(favoritedAt: favoritedAt, playCount: playCount, lastPlayedAt: SubsonicDate.parse(song.played))
|
||||
}
|
||||
|
||||
func lyrics(_ list: [StructuredLyricsJSON]) -> Lyrics? {
|
||||
guard let entry = list.first(where: \.synced) ?? list.first else { return nil }
|
||||
let offset = entry.offset ?? 0
|
||||
let lines = (entry.line ?? []).map { line in
|
||||
LyricLine(time: entry.synced ? Double((line.start ?? 0) + offset) / 1000 : 0, text: line.value)
|
||||
}
|
||||
guard !lines.isEmpty else { return nil }
|
||||
return Lyrics(lines: lines, isSynced: entry.synced)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import Foundation
|
||||
|
||||
struct ArtistJSON: Decodable {
|
||||
let id: String
|
||||
let name: String
|
||||
let sortName: String?
|
||||
let coverArt: String?
|
||||
let albumCount: Int?
|
||||
let starred: String?
|
||||
}
|
||||
|
||||
struct AlbumJSON: Decodable {
|
||||
let id: String
|
||||
let song: [SongJSON]?
|
||||
let name: String
|
||||
let artist: String?
|
||||
let artistId: String?
|
||||
let coverArt: String?
|
||||
let songCount: Int?
|
||||
let duration: Double?
|
||||
let created: String?
|
||||
let year: Int?
|
||||
let genre: String?
|
||||
let starred: String?
|
||||
}
|
||||
|
||||
struct SongJSON: Decodable {
|
||||
let id: String
|
||||
let title: String
|
||||
let album: String?
|
||||
let albumId: String?
|
||||
let artist: String?
|
||||
let artistId: String?
|
||||
let track: Int?
|
||||
let discNumber: Int?
|
||||
let year: Int?
|
||||
let genre: String?
|
||||
let coverArt: String?
|
||||
let suffix: String?
|
||||
let bitRate: Int?
|
||||
let samplingRate: Int?
|
||||
let bitDepth: Int?
|
||||
let channelCount: Int?
|
||||
let duration: Double?
|
||||
let playCount: Int?
|
||||
let played: String?
|
||||
let starred: String?
|
||||
let explicitStatus: String?
|
||||
}
|
||||
|
||||
struct PlaylistJSON: Decodable {
|
||||
let id: String
|
||||
let name: String
|
||||
let comment: String?
|
||||
let coverArt: String?
|
||||
let songCount: Int?
|
||||
let duration: Double?
|
||||
let changed: String?
|
||||
let entry: [SongJSON]?
|
||||
}
|
||||
|
||||
struct StationJSON: Decodable {
|
||||
let id: String
|
||||
let name: String
|
||||
let streamUrl: String
|
||||
let homePageUrl: String?
|
||||
let coverArt: String?
|
||||
}
|
||||
|
||||
struct StructuredLyricsJSON: Decodable {
|
||||
struct Line: Decodable {
|
||||
let start: Int?
|
||||
let value: String
|
||||
}
|
||||
|
||||
let lang: String?
|
||||
let synced: Bool
|
||||
let offset: Int?
|
||||
let line: [Line]?
|
||||
}
|
||||
|
||||
struct ArtistsPayload: Decodable {
|
||||
struct Index: Decodable {
|
||||
let artist: [ArtistJSON]?
|
||||
}
|
||||
|
||||
struct Artists: Decodable {
|
||||
let index: [Index]?
|
||||
}
|
||||
|
||||
let artists: Artists
|
||||
}
|
||||
|
||||
struct AlbumListPayload: Decodable {
|
||||
struct List: Decodable {
|
||||
let album: [AlbumJSON]?
|
||||
}
|
||||
|
||||
let albumList2: List
|
||||
}
|
||||
|
||||
struct AlbumPayload: Decodable {
|
||||
let album: AlbumJSON
|
||||
}
|
||||
|
||||
struct SearchPayload: Decodable {
|
||||
struct Result: Decodable {
|
||||
let song: [SongJSON]?
|
||||
}
|
||||
|
||||
let searchResult3: Result
|
||||
}
|
||||
|
||||
struct PlaylistsPayload: Decodable {
|
||||
struct List: Decodable {
|
||||
let playlist: [PlaylistJSON]?
|
||||
}
|
||||
|
||||
let playlists: List
|
||||
}
|
||||
|
||||
struct PlaylistPayload: Decodable {
|
||||
let playlist: PlaylistJSON
|
||||
}
|
||||
|
||||
struct StationsPayload: Decodable {
|
||||
struct List: Decodable {
|
||||
let internetRadioStation: [StationJSON]?
|
||||
}
|
||||
|
||||
let internetRadioStations: List
|
||||
}
|
||||
|
||||
struct StarredPayload: Decodable {
|
||||
struct Starred: Decodable {
|
||||
let artist: [ArtistJSON]?
|
||||
let album: [AlbumJSON]?
|
||||
let song: [SongJSON]?
|
||||
}
|
||||
|
||||
let starred2: Starred
|
||||
}
|
||||
|
||||
struct LyricsPayload: Decodable {
|
||||
struct List: Decodable {
|
||||
let structuredLyrics: [StructuredLyricsJSON]?
|
||||
}
|
||||
|
||||
let lyricsList: List
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import Domain
|
||||
import Foundation
|
||||
import Synchronization
|
||||
|
||||
public struct SubsonicAccount: Codable, Hashable, Sendable {
|
||||
public var baseURL: URL
|
||||
public var username: String
|
||||
|
||||
public init(baseURL: URL, username: String) {
|
||||
self.baseURL = baseURL
|
||||
self.username = username
|
||||
}
|
||||
}
|
||||
|
||||
public final class SubsonicSource: LibrarySource, Starring, LyricsSource, Scrobbling {
|
||||
public let id: SourceID
|
||||
public let name: String
|
||||
let client: SubsonicClient
|
||||
private let mapper: SubsonicMapper
|
||||
private let memory = Mutex(SubsonicMemory())
|
||||
|
||||
public convenience init(id: SourceID, account: SubsonicAccount, password: String, name: String, session: URLSession = .shared) {
|
||||
let client = SubsonicClient(baseURL: account.baseURL, username: account.username, password: password, session: session)
|
||||
self.init(id: id, name: name, client: client)
|
||||
}
|
||||
|
||||
init(id: SourceID, name: String, client: SubsonicClient) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.client = client
|
||||
mapper = SubsonicMapper(source: id)
|
||||
}
|
||||
|
||||
public func verify() async throws {
|
||||
try await client.call("ping")
|
||||
}
|
||||
|
||||
public func changes(since _: Cursor?) -> AsyncThrowingStream<Change, Error> {
|
||||
AsyncThrowingStream { continuation in
|
||||
let task = Task {
|
||||
do {
|
||||
let start = Date.now
|
||||
var crawl = SubsonicCrawl(client: client, mapper: mapper) { continuation.yield($0) }
|
||||
let result = try await crawl.run(previous: memory.withLock(\.seen))
|
||||
memory.withLock { $0 = result }
|
||||
continuation.yield(.checkpoint(Cursor(start.ISO8601Format())))
|
||||
continuation.finish()
|
||||
} catch {
|
||||
continuation.finish(throwing: error)
|
||||
}
|
||||
}
|
||||
continuation.onTermination = { _ in task.cancel() }
|
||||
}
|
||||
}
|
||||
|
||||
public func stream(for track: EntityID) async throws -> StreamRequest {
|
||||
let url = client.url("stream", ["id": track.remote, "format": "raw"])
|
||||
return StreamRequest(resource: ResourceRequest(url: url), format: memory.withLock { $0.formats[track.remote] })
|
||||
}
|
||||
|
||||
public func artwork(_ id: EntityID, pixelSize: Int) async throws -> ResourceRequest {
|
||||
ResourceRequest(url: client.url("getCoverArt", ["id": id.remote, "size": String(pixelSize)]))
|
||||
}
|
||||
|
||||
public func setFavorite(_ isFavorite: Bool, for id: EntityID, kind: EntityKind) async throws {
|
||||
let param = switch kind {
|
||||
case .album: "albumId"
|
||||
case .artist: "artistId"
|
||||
default: "id"
|
||||
}
|
||||
try await client.call(isFavorite ? "star" : "unstar", [param: id.remote])
|
||||
}
|
||||
|
||||
public func lyrics(for track: EntityID) async throws -> Lyrics? {
|
||||
let payload = try await client.call("getLyricsBySongId", ["id": track.remote], as: LyricsPayload.self)
|
||||
return mapper.lyrics(payload.lyricsList.structuredLyrics ?? [])
|
||||
}
|
||||
|
||||
public func scrobble(_ track: EntityID, at date: Date, submission: Bool) async throws {
|
||||
let time = String(Int(date.timeIntervalSince1970 * 1000))
|
||||
try await client.call("scrobble", ["id": track.remote, "time": time, "submission": submission ? "true" : "false"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import Domain
|
||||
import Foundation
|
||||
|
||||
public enum AlbumSort: String, CaseIterable, Sendable {
|
||||
case recentlyAdded, title, artist, year
|
||||
}
|
||||
|
||||
public struct SearchResults: Sendable {
|
||||
public var artists: [Artist] = []
|
||||
public var albums: [Album] = []
|
||||
public var tracks: [Track] = []
|
||||
public var playlists: [Playlist] = []
|
||||
|
||||
public init() {}
|
||||
|
||||
public var isEmpty: Bool {
|
||||
artists.isEmpty && albums.isEmpty && tracks.isEmpty && playlists.isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
public protocol Library: Sendable {
|
||||
func apply(_ changes: [Change]) async
|
||||
func cursor(for source: SourceID) async -> Cursor?
|
||||
func setCursor(_ cursor: Cursor, for source: SourceID) async
|
||||
func updates() async -> AsyncStream<Void>
|
||||
|
||||
func artists() async -> [Artist]
|
||||
func artist(_ id: EntityID) async -> Artist?
|
||||
func albums(sortedBy sort: AlbumSort) async -> [Album]
|
||||
func albums(byArtist id: EntityID) async -> [Album]
|
||||
func albums(inGenre genre: String) async -> [Album]
|
||||
func album(_ id: EntityID) async -> Album?
|
||||
func tracks() async -> [Track]
|
||||
func tracks(inAlbum id: EntityID) async -> [Track]
|
||||
func tracks(byArtist id: EntityID) async -> [Track]
|
||||
func tracks(inGenre genre: String) async -> [Track]
|
||||
func tracks(inPlaylist id: EntityID) async -> [Track]
|
||||
func playlists() async -> [Playlist]
|
||||
func stations() async -> [RadioStation]
|
||||
func genres() async -> [Genre]
|
||||
|
||||
func recentlyPlayed(limit: Int) async -> [Album]
|
||||
func mostPlayed(limit: Int) async -> [Album]
|
||||
func favoriteTracks() async -> [Track]
|
||||
func favorites() async -> Set<EntityID>
|
||||
func setFavorite(_ isFavorite: Bool, for id: EntityID) async
|
||||
func recordPlay(of track: Track, at date: Date) async
|
||||
|
||||
func search(_ query: String) async -> SearchResults
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import Domain
|
||||
import Foundation
|
||||
|
||||
public actor MemoryLibrary: Library {
|
||||
private var artistsByID: [EntityID: Artist] = [:]
|
||||
private var albumsByID: [EntityID: Album] = [:]
|
||||
private var tracksByID: [EntityID: Track] = [:]
|
||||
private var playlistsByID: [EntityID: Playlist] = [:]
|
||||
private var entriesByPlaylist: [EntityID: [PlaylistEntry]] = [:]
|
||||
private var stationsByID: [EntityID: RadioStation] = [:]
|
||||
private var annotations: [EntityID: Annotation] = [:]
|
||||
private var cursors: [SourceID: Cursor] = [:]
|
||||
private var observers: [UUID: AsyncStream<Void>.Continuation] = [:]
|
||||
|
||||
public init() {}
|
||||
|
||||
public func apply(_ changes: [Change]) {
|
||||
for change in changes {
|
||||
switch change {
|
||||
case let .upsert(.artist(artist)): artistsByID[artist.id] = artist
|
||||
case let .upsert(.album(album)): albumsByID[album.id] = album
|
||||
case let .upsert(.track(track)): tracksByID[track.id] = track
|
||||
case let .upsert(.playlist(playlist, entries)):
|
||||
playlistsByID[playlist.id] = playlist
|
||||
entriesByPlaylist[playlist.id] = entries
|
||||
case let .upsert(.station(station)): stationsByID[station.id] = station
|
||||
case let .remove(kind, id): remove(kind, id)
|
||||
case let .annotate(id, annotation): annotations[id] = annotation
|
||||
case .checkpoint: break
|
||||
}
|
||||
}
|
||||
notify()
|
||||
}
|
||||
|
||||
private func remove(_ kind: EntityKind, _ id: EntityID) {
|
||||
switch kind {
|
||||
case .artist: artistsByID[id] = nil
|
||||
case .album: albumsByID[id] = nil
|
||||
case .track: tracksByID[id] = nil
|
||||
case .playlist:
|
||||
playlistsByID[id] = nil
|
||||
entriesByPlaylist[id] = nil
|
||||
case .station: stationsByID[id] = nil
|
||||
}
|
||||
annotations[id] = nil
|
||||
}
|
||||
|
||||
public func cursor(for source: SourceID) -> Cursor? {
|
||||
cursors[source]
|
||||
}
|
||||
|
||||
public func setCursor(_ cursor: Cursor, for source: SourceID) {
|
||||
cursors[source] = cursor
|
||||
}
|
||||
|
||||
public func updates() -> AsyncStream<Void> {
|
||||
let key = UUID()
|
||||
let (stream, continuation) = AsyncStream<Void>.makeStream(bufferingPolicy: .bufferingNewest(1))
|
||||
observers[key] = continuation
|
||||
continuation.onTermination = { [weak self] _ in
|
||||
Task { await self?.removeObserver(key) }
|
||||
}
|
||||
return stream
|
||||
}
|
||||
|
||||
private func removeObserver(_ key: UUID) {
|
||||
observers[key] = nil
|
||||
}
|
||||
|
||||
private func notify() {
|
||||
observers.values.forEach { $0.yield() }
|
||||
}
|
||||
|
||||
public func artists() -> [Artist] {
|
||||
artistsByID.values.sorted { $0.sortName.localizedStandardCompare($1.sortName) == .orderedAscending }
|
||||
}
|
||||
|
||||
public func artist(_ id: EntityID) -> Artist? {
|
||||
artistsByID[id]
|
||||
}
|
||||
|
||||
public func albums(sortedBy sort: AlbumSort) -> [Album] {
|
||||
let all = Array(albumsByID.values)
|
||||
switch sort {
|
||||
case .recentlyAdded: return all.sorted { $0.addedAt > $1.addedAt }
|
||||
case .title: return all.sorted { $0.title.localizedStandardCompare($1.title) == .orderedAscending }
|
||||
case .artist:
|
||||
return all.sorted {
|
||||
let order = $0.artistName.localizedStandardCompare($1.artistName)
|
||||
return order == .orderedSame ? ($0.year ?? 0) < ($1.year ?? 0) : order == .orderedAscending
|
||||
}
|
||||
case .year: return all.sorted { ($0.year ?? 0) > ($1.year ?? 0) }
|
||||
}
|
||||
}
|
||||
|
||||
public func albums(byArtist id: EntityID) -> [Album] {
|
||||
albumsByID.values.filter { $0.artistID == id }.sorted { ($0.year ?? 0) > ($1.year ?? 0) }
|
||||
}
|
||||
|
||||
public func albums(inGenre genre: String) -> [Album] {
|
||||
albums(sortedBy: .artist).filter { $0.genre == genre }
|
||||
}
|
||||
|
||||
public func album(_ id: EntityID) -> Album? {
|
||||
albumsByID[id]
|
||||
}
|
||||
|
||||
public func tracks() -> [Track] {
|
||||
tracksByID.values.sorted { $0.title.localizedStandardCompare($1.title) == .orderedAscending }
|
||||
}
|
||||
|
||||
public func tracks(inAlbum id: EntityID) -> [Track] {
|
||||
tracksByID.values.filter { $0.albumID == id }.sorted { ($0.discNumber, $0.trackNumber) < ($1.discNumber, $1.trackNumber) }
|
||||
}
|
||||
|
||||
public func tracks(byArtist id: EntityID) -> [Track] {
|
||||
tracksByID.values.filter { $0.artistID == id }
|
||||
.sorted { (annotations[$0.id]?.playCount ?? 0, $1.title) > (annotations[$1.id]?.playCount ?? 0, $0.title) }
|
||||
}
|
||||
|
||||
public func tracks(inGenre genre: String) -> [Track] {
|
||||
tracksByID.values.filter { $0.genre == genre }
|
||||
}
|
||||
|
||||
public func tracks(inPlaylist id: EntityID) -> [Track] {
|
||||
(entriesByPlaylist[id] ?? []).compactMap { tracksByID[$0.track] }
|
||||
}
|
||||
|
||||
public func playlists() -> [Playlist] {
|
||||
playlistsByID.values.sorted { $0.updatedAt > $1.updatedAt }
|
||||
}
|
||||
|
||||
public func stations() -> [RadioStation] {
|
||||
stationsByID.values.sorted { $0.name < $1.name }
|
||||
}
|
||||
|
||||
public func genres() -> [Genre] {
|
||||
Dictionary(grouping: albumsByID.values.filter { $0.genre != nil }, by: { $0.genre ?? "" })
|
||||
.map { Genre(name: $0.key, albumCount: $0.value.count) }
|
||||
.sorted { $0.name < $1.name }
|
||||
}
|
||||
|
||||
private func albumsRanked(limit: Int, by score: (Annotation) -> Double?) -> [Album] {
|
||||
var best: [EntityID: Double] = [:]
|
||||
for (id, annotation) in annotations {
|
||||
guard let value = score(annotation), let album = tracksByID[id]?.albumID else { continue }
|
||||
best[album] = max(best[album] ?? -.infinity, value)
|
||||
}
|
||||
return best.sorted { $0.value > $1.value }.prefix(limit).compactMap { albumsByID[$0.key] }
|
||||
}
|
||||
|
||||
public func recentlyPlayed(limit: Int) -> [Album] {
|
||||
albumsRanked(limit: limit) { $0.lastPlayedAt?.timeIntervalSince1970 }
|
||||
}
|
||||
|
||||
public func mostPlayed(limit: Int) -> [Album] {
|
||||
albumsRanked(limit: limit) { $0.playCount > 0 ? Double($0.playCount) : nil }
|
||||
}
|
||||
|
||||
public func favoriteTracks() -> [Track] {
|
||||
tracksByID.values
|
||||
.filter { annotations[$0.id]?.favoritedAt != nil }
|
||||
.sorted { (annotations[$0.id]?.favoritedAt ?? .distantPast) > (annotations[$1.id]?.favoritedAt ?? .distantPast) }
|
||||
}
|
||||
|
||||
public func favorites() -> Set<EntityID> {
|
||||
Set(annotations.filter { $0.value.favoritedAt != nil }.keys)
|
||||
}
|
||||
|
||||
public func setFavorite(_ isFavorite: Bool, for id: EntityID) {
|
||||
annotations[id, default: Annotation()].favoritedAt = isFavorite ? Date() : nil
|
||||
notify()
|
||||
}
|
||||
|
||||
public func recordPlay(of track: Track, at date: Date) {
|
||||
annotations[track.id, default: Annotation()].playCount += 1
|
||||
annotations[track.id, default: Annotation()].lastPlayedAt = date
|
||||
notify()
|
||||
}
|
||||
|
||||
public func search(_ query: String) -> SearchResults {
|
||||
let needle = query.trimmingCharacters(in: .whitespaces)
|
||||
guard !needle.isEmpty else { return SearchResults() }
|
||||
func matches(_ text: String) -> Bool {
|
||||
text.range(of: needle, options: [.caseInsensitive, .diacriticInsensitive]) != nil
|
||||
}
|
||||
var results = SearchResults()
|
||||
results.artists = artists().filter { matches($0.name) }
|
||||
results.albums = albums(sortedBy: .title).filter { matches($0.title) }
|
||||
results.tracks = Array(tracks().filter { matches($0.title) }.prefix(50))
|
||||
results.playlists = playlists().filter { matches($0.name) }
|
||||
return results
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import Domain
|
||||
import Store
|
||||
|
||||
public actor SyncEngine {
|
||||
private let library: any Library
|
||||
private let batchSize = 500
|
||||
|
||||
public init(library: any Library) {
|
||||
self.library = library
|
||||
}
|
||||
|
||||
public func sync(_ source: any LibrarySource) async throws {
|
||||
do {
|
||||
try await pull(source, since: library.cursor(for: source.id))
|
||||
} catch SourceError.cannotCalculateChanges {
|
||||
try await pull(source, since: nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func pull(_ source: any LibrarySource, since cursor: Cursor?) async throws {
|
||||
var batch: [Change] = []
|
||||
for try await change in source.changes(since: cursor) {
|
||||
if case let .checkpoint(next) = change {
|
||||
await library.apply(batch)
|
||||
batch.removeAll(keepingCapacity: true)
|
||||
await library.setCursor(next, for: source.id)
|
||||
} else {
|
||||
batch.append(change)
|
||||
if batch.count >= batchSize {
|
||||
await library.apply(batch)
|
||||
batch.removeAll(keepingCapacity: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !batch.isEmpty {
|
||||
await library.apply(batch)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user