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

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

No files matched your search

@@ -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
}