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
|
||||
}
|
||||
Reference in new issue
Block a user