Add iOS app with CarPlay and Siri
SwiftUI port of the Android app under ios/, with CarPlay browse and Now Playing, Siri play-media intents, and a build-ipa.sh script that makes an unsigned IPA for sideloading. README gets an iOS section and the vibecoded disclaimer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
45bc0eefe0
commit
fdf9e970b2
45 files changed
+9822
No files matched your search
@@ -0,0 +1,98 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
/// How many of an album's (or artist's) songs have stems.
|
||||
struct Coverage: Equatable {
|
||||
var total: Int
|
||||
var withStems: Int
|
||||
var none: Bool { total > 0 && withStems == 0 }
|
||||
var partial: Bool { withStems >= 1 && withStems < total }
|
||||
}
|
||||
|
||||
/// Which albums and artists have stems. Plex albums carry no file paths, so this keeps a slim
|
||||
/// copy of every track's path (fetched in pages, cached on disk, refreshed every few hours) and
|
||||
/// matches it against the stems index whenever that changes. Until the first full listing
|
||||
/// arrives [ready] is false and nothing is flagged.
|
||||
@Observable
|
||||
final class StemCoverage: @unchecked Sendable {
|
||||
private struct Slim: Codable { var k: String; var al: String?; var ar: String?; var f: String?; var d: Int64 = 0 }
|
||||
private struct Listing: Codable { var section: String; var at: Double; var tracks: [Slim] }
|
||||
|
||||
@ObservationIgnored private let plex: PlexClient
|
||||
@ObservationIgnored private let stems: StemServer
|
||||
@ObservationIgnored private let settings: Settings
|
||||
@ObservationIgnored private let file: URL
|
||||
@ObservationIgnored private let lock = AsyncLock()
|
||||
@ObservationIgnored private var listing: Listing?
|
||||
private static let page = 1000
|
||||
private static let staleSeconds: Double = 6 * 3600
|
||||
|
||||
private(set) var albums: [String: Coverage] = [:]
|
||||
private(set) var artists: [String: Coverage] = [:]
|
||||
/// True once the whole library has been listed and matched against a non-empty stems index.
|
||||
private(set) var ready = false
|
||||
|
||||
init(plex: PlexClient, stems: StemServer, settings: Settings, dir: URL) {
|
||||
self.plex = plex
|
||||
self.stems = stems
|
||||
self.settings = settings
|
||||
file = dir.appendingPathComponent("library_tracks.json")
|
||||
listing = (try? Data(contentsOf: file)).flatMap { try? JSONDecoder().decode(Listing.self, from: $0) }
|
||||
settings.onChange("musicSection") { [weak self] in self?.sectionChanged() }
|
||||
stems.onIndexChange { [weak self] in self?.recompute() }
|
||||
sectionChanged()
|
||||
recompute()
|
||||
}
|
||||
|
||||
private func sectionChanged() {
|
||||
let section = settings.musicSection
|
||||
guard !section.isEmpty else { return }
|
||||
if let l = listing, l.section == section, Date().timeIntervalSince1970 - l.at <= Self.staleSeconds { return }
|
||||
Task { await refresh() }
|
||||
}
|
||||
|
||||
/// Lists every track in the library again (every few hours).
|
||||
func refresh() async {
|
||||
await lock.run {
|
||||
let section = settings.musicSection
|
||||
guard !section.isEmpty, plex.signedIn else { return }
|
||||
var all: [Slim] = []
|
||||
do {
|
||||
var start = 0
|
||||
while true {
|
||||
let (page, total) = try await plex.tracksPage(start: start, size: Self.page)
|
||||
all += page.map { Slim(k: $0.ratingKey, al: $0.albumKey, ar: $0.artistKey, f: $0.partFile, d: $0.durationMs) }
|
||||
start += page.count
|
||||
if page.isEmpty || start >= total { break }
|
||||
}
|
||||
} catch { return }
|
||||
let l = Listing(section: section, at: Date().timeIntervalSince1970, tracks: all)
|
||||
if let data = try? JSONEncoder().encode(l) { try? data.write(to: file, options: .atomic) }
|
||||
await MainActor.run {
|
||||
self.listing = l
|
||||
self.recompute()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func recompute() {
|
||||
guard let l = listing, l.section == settings.musicSection, stems.index.size > 0 else { return }
|
||||
let tracks = l.tracks
|
||||
Task.detached { [self] in
|
||||
var al: [String: (Int, Int)] = [:]
|
||||
var ar: [String: (Int, Int)] = [:]
|
||||
for t in tracks {
|
||||
let has = stems.find(Track(ratingKey: t.k, title: "", artist: "", album: "", durationMs: t.d, partFile: t.f)) != nil
|
||||
if let k = t.al { let c = al[k] ?? (0, 0); al[k] = (c.0 + 1, c.1 + (has ? 1 : 0)) }
|
||||
if let k = t.ar { let c = ar[k] ?? (0, 0); ar[k] = (c.0 + 1, c.1 + (has ? 1 : 0)) }
|
||||
}
|
||||
let albums = al.mapValues { Coverage(total: $0.0, withStems: $0.1) }
|
||||
let artists = ar.mapValues { Coverage(total: $0.0, withStems: $0.1) }
|
||||
await MainActor.run {
|
||||
self.albums = albums
|
||||
self.artists = artists
|
||||
self.ready = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import Foundation
|
||||
|
||||
struct StemFile: Codable, Hashable {
|
||||
var name: String
|
||||
var file: String
|
||||
var size: Int64
|
||||
var mtime: Int64 = 0
|
||||
|
||||
init(name: String, file: String, size: Int64, mtime: Int64 = 0) {
|
||||
self.name = name; self.file = file; self.size = size; self.mtime = mtime
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: AnyKey.self)
|
||||
name = c.str("name") ?? ""
|
||||
file = c.str("file") ?? ""
|
||||
size = c.int64("size") ?? 0
|
||||
mtime = c.int64("mtime") ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
struct StemEntry: Codable, Hashable {
|
||||
var key: String
|
||||
var source: String
|
||||
var duration: Double?
|
||||
var samplerate: Int?
|
||||
var model: String?
|
||||
var finished: Double?
|
||||
var stems: [StemFile] = []
|
||||
|
||||
init(key: String, source: String, duration: Double? = nil, samplerate: Int? = nil, model: String? = nil,
|
||||
finished: Double? = nil, stems: [StemFile] = []) {
|
||||
self.key = key; self.source = source; self.duration = duration; self.samplerate = samplerate
|
||||
self.model = model; self.finished = finished; self.stems = stems
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: AnyKey.self)
|
||||
key = c.str("key") ?? ""
|
||||
source = c.str("source") ?? ""
|
||||
duration = c.double("duration")
|
||||
samplerate = c.int("samplerate")
|
||||
model = c.str("model")
|
||||
finished = c.double("finished")
|
||||
stems = c.list("stems")
|
||||
}
|
||||
|
||||
/// "Artist/Album/01 Song" -> "01 Song" etc., for stem-only browsing.
|
||||
var title: String { key.split(separator: "/", omittingEmptySubsequences: false).last.map(String.init) ?? key }
|
||||
var folder: String {
|
||||
guard let i = key.lastIndex(of: "/") else { return "" }
|
||||
return String(key[..<i])
|
||||
}
|
||||
}
|
||||
|
||||
struct StemIndexFile: Codable {
|
||||
var version = 1
|
||||
var tracks: [StemEntry] = []
|
||||
|
||||
init(version: Int = 1, tracks: [StemEntry] = []) { self.version = version; self.tracks = tracks }
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: AnyKey.self)
|
||||
version = c.int("version") ?? 1
|
||||
tracks = c.list("tracks")
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps Plex file paths to stem folders. The stems folder mirrors the music folder, so the
|
||||
/// source path relative to the music root identifies a track; Plex reports absolute paths
|
||||
/// as its container sees them, so the root is unknown up front.
|
||||
///
|
||||
/// Matching tries every suffix of the Plex path, longest first. A suffix that is only a
|
||||
/// file name, or whose duration disagrees, is accepted only under a root prefix learned
|
||||
/// from an earlier, longer match, so "Intro.flac" in one album can't grab another's stems.
|
||||
final class StemIndex: @unchecked Sendable {
|
||||
let entries: [StemEntry]
|
||||
private var bySource: [String: StemEntry] = [:]
|
||||
private var learned: [String] = []
|
||||
private let lock = NSLock()
|
||||
|
||||
init(_ entries: [StemEntry], roots: [String] = []) {
|
||||
self.entries = entries
|
||||
bySource.reserveCapacity(entries.count * 2)
|
||||
for e in entries {
|
||||
let k = Self.norm(e.source)
|
||||
if bySource[k] == nil { bySource[k] = e }
|
||||
}
|
||||
for r in roots where !learned.contains(r) { learned.append(r) }
|
||||
}
|
||||
|
||||
var size: Int { entries.count }
|
||||
var roots: [String] { lock.lock(); defer { lock.unlock() }; return learned }
|
||||
|
||||
/// Returns the entry for a Plex part path, learning the path's root on a confident match.
|
||||
func find(_ plexPath: String?, durationMs: Int64 = 0) -> StemEntry? {
|
||||
guard let plexPath, !plexPath.isEmpty, !entries.isEmpty else { return nil }
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
let path = Self.norm(plexPath)
|
||||
for root in learned where path.hasPrefix(root) {
|
||||
if let hit = bySource[String(path.dropFirst(root.count))] { return hit }
|
||||
}
|
||||
let parts = path.split(separator: "/", omittingEmptySubsequences: false).map(String.init)
|
||||
guard parts.count > 1 else { return nil }
|
||||
for i in 1..<parts.count {
|
||||
let rel = parts[i...].joined(separator: "/")
|
||||
guard let hit = bySource[rel] else { continue }
|
||||
let deep = parts.count - i >= 2
|
||||
if deep && durationMatches(hit, durationMs) {
|
||||
let root = parts[0..<i].joined(separator: "/") + "/"
|
||||
if !learned.contains(root) { learned.append(root) }
|
||||
return hit
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func durationMatches(_ e: StemEntry, _ durationMs: Int64) -> Bool {
|
||||
guard let d = e.duration, durationMs > 0 else { return true }
|
||||
return abs(d * 1000 - Double(durationMs)) < 3000
|
||||
}
|
||||
|
||||
/// NFC, forward slashes, no extension, lower case.
|
||||
static func norm(_ path: String) -> String {
|
||||
var p = path.precomposedStringWithCanonicalMapping.replacingOccurrences(of: "\\", with: "/")
|
||||
let slash = p.lastIndex(of: "/").map { p.distance(from: p.startIndex, to: $0) } ?? -1
|
||||
if let dotIdx = p.lastIndex(of: ".") {
|
||||
let dot = p.distance(from: p.startIndex, to: dotIdx)
|
||||
if dot > slash + 1 { p = String(p[..<dotIdx]) }
|
||||
}
|
||||
return p.lowercased()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
enum StemServerStatus: Equatable {
|
||||
case unknown
|
||||
case online(tracks: Int, transcode: Bool)
|
||||
case offline(reason: String, cachedTracks: Int)
|
||||
|
||||
var isOnline: Bool { if case .online = self { true } else { false } }
|
||||
}
|
||||
|
||||
private struct ServerInfo: Decodable {
|
||||
var tracks = 0
|
||||
var ffmpeg = false
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: AnyKey.self)
|
||||
tracks = c.int("tracks") ?? 0
|
||||
ffmpeg = c.bool("ffmpeg") ?? false
|
||||
}
|
||||
}
|
||||
|
||||
/// Client for server/stemserve.py: the stems index, stem file URLs and waveforms.
|
||||
@Observable
|
||||
final class StemServer: @unchecked Sendable {
|
||||
@ObservationIgnored private let http: Http
|
||||
@ObservationIgnored private let settings: Settings
|
||||
@ObservationIgnored private let indexFile: URL
|
||||
@ObservationIgnored private let etagFile: URL
|
||||
@ObservationIgnored private let refreshLock = AsyncLock()
|
||||
@ObservationIgnored private var transcode = false
|
||||
@ObservationIgnored private var indexListeners: [() -> Void] = []
|
||||
private static let checkSeconds: UInt64 = 60
|
||||
|
||||
private(set) var index = StemIndex([])
|
||||
private(set) var status = StemServerStatus.unknown
|
||||
/// When the index was last checked against the server, nil before the first check.
|
||||
private(set) var checkedAt: Date?
|
||||
|
||||
init(http: Http, settings: Settings, dir: URL) {
|
||||
self.http = http
|
||||
self.settings = settings
|
||||
indexFile = dir.appendingPathComponent("stem_index.json")
|
||||
etagFile = dir.appendingPathComponent("stem_index.etag")
|
||||
if let data = try? Data(contentsOf: indexFile),
|
||||
let parsed = try? JSONDecoder().decode(StemIndexFile.self, from: data) {
|
||||
index = StemIndex(parsed.tracks, roots: roots())
|
||||
}
|
||||
// stemd on the server separates new music around the clock; look for its new stems
|
||||
// every minute. Unchanged indexes cost one 304.
|
||||
Task.detached { [weak self] in
|
||||
while let self {
|
||||
await self.refresh()
|
||||
try? await Task.sleep(nanoseconds: Self.checkSeconds * 1_000_000_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var base: String { settings.stemUrl.trimmingCharacters(in: CharacterSet(charactersIn: "/")) }
|
||||
private func roots() -> [String] { settings.stemRoots.split(separator: "\n").map(String.init).filter { !$0.isEmpty } }
|
||||
|
||||
/// Main-thread callback after the index is replaced.
|
||||
func onIndexChange(_ f: @escaping () -> Void) { indexListeners.append(f) }
|
||||
|
||||
private func req(_ url: URL, method: String = "GET") -> URLRequest {
|
||||
var r = URLRequest(url: url)
|
||||
r.httpMethod = method
|
||||
let t = settings.stemToken
|
||||
if !t.isEmpty { r.setValue(t, forHTTPHeaderField: "X-Stem-Token") }
|
||||
return r
|
||||
}
|
||||
|
||||
private func url(_ path: String, _ params: [(String, Any?)] = []) throws -> URL {
|
||||
guard let u = URL(string: base + path) else { throw PlainError("Bad stemserve address") }
|
||||
return u.with(params)
|
||||
}
|
||||
|
||||
func refresh(force: Bool = false) async {
|
||||
await refreshLock.run {
|
||||
do {
|
||||
let info = try JSONDecoder().decode(ServerInfo.self, from: try await http.data(req(try url("/api/info"))))
|
||||
transcode = info.ffmpeg
|
||||
let fm = FileManager.default
|
||||
let etag = (!force && fm.fileExists(atPath: indexFile.path)) ? try? String(contentsOf: etagFile, encoding: .utf8) : nil
|
||||
var r = req(try url("/api/index"))
|
||||
if let etag { r.setValue(etag, forHTTPHeaderField: "If-None-Match") }
|
||||
// Straight to the session: a 304 must not be confused with a cached 200.
|
||||
let (body, resp) = try await http.session.data(for: r)
|
||||
let code = (resp as? HTTPURLResponse)?.statusCode ?? 0
|
||||
var newIndex: StemIndex?
|
||||
if code == 304 {
|
||||
} else if (200..<300).contains(code) {
|
||||
let parsed = try JSONDecoder().decode(StemIndexFile.self, from: body)
|
||||
newIndex = StemIndex(parsed.tracks, roots: roots())
|
||||
try? body.write(to: indexFile, options: .atomic)
|
||||
if let tag = (resp as? HTTPURLResponse)?.value(forHTTPHeaderField: "ETag") {
|
||||
try? tag.write(to: etagFile, atomically: true, encoding: .utf8)
|
||||
}
|
||||
} else {
|
||||
throw HttpError(code: code, message: "stemserve answered \(code)")
|
||||
}
|
||||
let fresh = newIndex
|
||||
await MainActor.run {
|
||||
if let newIndex = fresh {
|
||||
self.index = newIndex
|
||||
self.indexListeners.forEach { $0() }
|
||||
}
|
||||
self.status = .online(tracks: self.index.size, transcode: info.ffmpeg)
|
||||
self.checkedAt = Date()
|
||||
}
|
||||
} catch {
|
||||
let reason = error.localizedDescription
|
||||
await MainActor.run { self.status = .offline(reason: reason, cachedTracks: self.index.size) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func find(_ track: Track) -> StemEntry? {
|
||||
let idx = index
|
||||
if let k = track.stemKey { return idx.entries.first { $0.key == k } }
|
||||
let hit = idx.find(track.partFile, durationMs: track.durationMs)
|
||||
let learned = idx.roots.joined(separator: "\n")
|
||||
if learned != settings.stemRoots { settings.stemRoots = learned }
|
||||
return hit
|
||||
}
|
||||
|
||||
/// Format the server will actually send: Opus needs ffmpeg there.
|
||||
func effectiveQuality(_ q: StemQuality) -> StemQuality {
|
||||
q.fmt == "opus" && !transcode && status.isOnline ? .Lossless : q
|
||||
}
|
||||
|
||||
func fileUrl(_ entry: StemEntry, _ stem: StemFile, _ quality: StemQuality) -> URL? {
|
||||
let path = (entry.key.split(separator: "/", omittingEmptySubsequences: false).map(String.init) + [stem.file])
|
||||
.map(urlEncode).joined(separator: "/")
|
||||
guard let u = URL(string: base + "/file/" + path) else { return nil }
|
||||
var params: [(String, Any?)] = []
|
||||
if quality.fmt == "opus" { params += [("fmt", "opus"), ("br", quality.kbps)] }
|
||||
if !settings.stemToken.isEmpty { params.append(("token", settings.stemToken)) }
|
||||
return u.with(params)
|
||||
}
|
||||
|
||||
/// Asks the server to start the Opus transcodes now, so the stems are ready when we need them.
|
||||
func prepare(_ entry: StemEntry, _ quality: StemQuality) {
|
||||
guard quality.fmt == "opus" else { return }
|
||||
Task.detached { [self] in
|
||||
guard let u = try? url("/api/prepare", [("key", entry.key), ("fmt", "opus"), ("br", quality.kbps)]) else { return }
|
||||
var r = req(u, method: "POST")
|
||||
r.httpBody = Data()
|
||||
_ = try? await http.session.data(for: r)
|
||||
}
|
||||
}
|
||||
|
||||
/// "Stem Now": asks stemd (through stemserve) to separate these songs before the rest of the
|
||||
/// library. Returns how many it queued: the ones with no stems yet.
|
||||
func stemNow(_ tracks: [Track]) async throws -> Int {
|
||||
var seen = Set<String>()
|
||||
let paths = tracks.filter { find($0) == nil }.compactMap(\.partFile).filter { seen.insert($0).inserted }
|
||||
if paths.isEmpty { return 0 }
|
||||
var r = req(try url("/api/stem"), method: "POST")
|
||||
r.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
r.httpBody = try JSONSerialization.data(withJSONObject: ["paths": paths])
|
||||
let (body, resp) = try await http.session.data(for: r)
|
||||
let code = (resp as? HTTPURLResponse)?.statusCode ?? 0
|
||||
if !(200..<300).contains(code) {
|
||||
let msg = (try? JSONSerialization.jsonObject(with: body) as? [String: Any])?["error"] as? String
|
||||
throw HttpError(code: code, message: code == 404 ? "stemserve needs updating for Stem Now" : msg ?? "stemserve answered \(code)")
|
||||
}
|
||||
return paths.count
|
||||
}
|
||||
|
||||
/// Per-stem RMS envelopes (0..1 of full scale), [buckets] values each.
|
||||
func peaks(_ entry: StemEntry, buckets: Int) async -> [String: [Float]]? {
|
||||
guard let u = try? url("/api/peaks", [("key", entry.key), ("n", buckets)]),
|
||||
let body = try? await http.data(req(u)),
|
||||
let obj = try? JSONSerialization.jsonObject(with: body) as? [String: Any],
|
||||
let stems = obj["stems"] as? [String: [Any]] else { return nil }
|
||||
return stems.mapValues { arr in arr.map { Float(($0 as? NSNumber)?.intValue ?? 0) / 1000 } }
|
||||
}
|
||||
}
|
||||
|
||||
/// A simple async mutex: one closure at a time, in arrival order.
|
||||
actor AsyncLock {
|
||||
private var busy = false
|
||||
private var waiters: [CheckedContinuation<Void, Never>] = []
|
||||
|
||||
func run<T>(_ body: () async throws -> T) async rethrows -> T {
|
||||
if busy {
|
||||
await withCheckedContinuation { waiters.append($0) }
|
||||
}
|
||||
busy = true
|
||||
defer {
|
||||
if waiters.isEmpty { busy = false } else { waiters.removeFirst().resume() }
|
||||
}
|
||||
return try await body()
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user