iOS: stem playlist refresh and covers, download queue page, reorderable Library, whole-library radio

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Sonnet 5.5 committed 2026-10-28 15:49:09 -07:00
1 parent 7c8dfb54ca
commit a4c870251c
18 files changed
+514 -30

No files matched your search

View File
Whitespace-only changes.
+13
View File
@@ -0,0 +1,13 @@
# Changelog
## 2026-10-26
- Project created.
2026-10-28 iOS: stemd queue state (STEMMING/QUEUED/FAILED tags, Stem Now state, CarPlay, stemd line in Stem Server settings). Plex sign-in via plex.tv adds every server and all music libraries.
2026-10-28 iOS: media fetch errors (HTTP 500/404) now include the Plex host, port and path.
2026-10-28 iOS: Plex refresh keeps each server's library selection (new servers still get all libraries). Music Servers list moved to its own page under Plex Account.
2026-10-28 iOS: CarPlay Downloads opens onto Playlists/Albums/Artists/Songs, limited to downloaded music.
2026-10-28 iOS: stem playlists refresh their track metadata from Plex on each library load. Playlist covers can be changed from the playlist menu.
2026-10-28 iOS: Downloads 3-dots menu gets View Queue (waiting, downloading, failed, done).
2026-10-28 iOS: Library screen can be reordered (Reorder button). Library Radio and Random Album Radio shuffle the whole library.
+2
View File
@@ -0,0 +1,2 @@
# TODO
+1 -1
View File
@@ -498,7 +498,7 @@ final class CachedResource: @unchecked Sendable {
block = 0 block = 0
lastBlock = MediaCache.blocksFor(Int64(body.count)) - 1 lastBlock = MediaCache.blocksFor(Int64(body.count)) - 1
default: default:
throw MediaError("HTTP \(resp.statusCode) fetching media") throw MediaError("HTTP \(resp.statusCode) fetching media from \(u.host ?? "?")\(u.port.map { ":\($0)" } ?? "")\(u.path)")
} }
cond.lock() cond.lock()
let length = self.length let length = self.length
+33 -5
View File
@@ -69,13 +69,14 @@ final class CarLibrary: @unchecked Sendable {
func children(_ id: String) async throws -> [CarItem]? { func children(_ id: String) async throws -> [CarItem]? {
let away = await away() let away = await away()
switch id { switch id {
case CarIds.downloads: return await songs(CarIds.downloads, try tracksOf(CarIds.downloads), shuffleRow: true, showArtist: true) case CarIds.downloads: return downloadsMenu()
case CarIds.playlists: return try await playlists(away) case CarIds.playlists: return try await playlists(away)
case CarIds.albums: return try await albumsTab(away) case CarIds.albums: return try await albumsTab(away)
case CarIds.artists: return try await artistsTab(away) case CarIds.artists: return try await artistsTab(away)
default: default:
guard let (kind, key) = CarIds.split(id) else { return nil } guard let (kind, key) = CarIds.split(id) else { return nil }
switch kind { switch kind {
case "dl": return try await downloadsView(key)
case "playlist", "stemplaylist": return await songs(id, try tracksOf(id), shuffleRow: true, showArtist: true) case "playlist", "stemplaylist": return await songs(id, try tracksOf(id), shuffleRow: true, showArtist: true)
case "album": return await songs(id, try tracksOf(id), shuffleRow: false, showArtist: false) case "album": return await songs(id, try tracksOf(id), shuffleRow: false, showArtist: false)
case "artist": return try await artistPage(key, away) case "artist": return try await artistPage(key, away)
@@ -87,15 +88,42 @@ final class CarLibrary: @unchecked Sendable {
} }
} }
private func playlists(_ away: Bool) async throws -> [CarItem] { /// Downloads opens onto the same categories as the other tabs, each limited to what's downloaded.
private func downloadsMenu() -> [CarItem] {
let dl = downloadedTracks()
guard !dl.isEmpty else { return [] }
return [
CarItem(id: "dl:playlists", title: "Playlists", art: .letter("P"), playable: false),
CarItem(id: "dl:albums", title: "Albums", subtitle: CarTree.countLabel(albumsOf(dl).count, "album", "albums"), art: .letter("A"), playable: false),
CarItem(id: "dl:artists", title: "Artists", subtitle: CarTree.countLabel(Set(dl.compactMap(\.artistKey)).count, "artist", "artists"), art: .letter("A"), playable: false),
CarItem(id: "dl:songs", title: "Songs", subtitle: CarTree.countLabel(dl.count, "song", "songs"), art: .letter("S"), playable: false),
]
}
private func downloadsView(_ key: String) async throws -> [CarItem]? {
switch key {
case "playlists": return try await playlists(true, onlyDownloaded: true)
case "albums": return try await albumsFor(true).map { albumItem($0) }
case "artists": return try await artistsFor(true).map { artistItem($0) }
case "songs": return await songs(CarIds.downloads, try tracksOf(CarIds.downloads), shuffleRow: true, showArtist: true)
default: return nil
}
}
private func playlists(_ away: Bool, onlyDownloaded: Bool = false) async throws -> [CarItem] {
let all = try await plex.playlists() let all = try await plex.playlists()
var shown: [Playlist] = [] var shown: [Playlist] = []
if away { if onlyDownloaded {
let have = Set(downloadedTracks().map(\.ratingKey))
for p in all where ((try? await plex.playlistTracks(p.ratingKey))?.contains { have.contains($0.ratingKey) } == true) { shown.append(p) }
} else if away {
for p in all where ((try? await tracksOf(CarIds.playlist(p.ratingKey)))?.isEmpty == false) { shown.append(p) } for p in all where ((try? await tracksOf(CarIds.playlist(p.ratingKey)))?.isEmpty == false) { shown.append(p) }
} else { } else {
shown = all shown = all
} }
let stem = await MainActor.run { AppGraph.shared.stemPlaylists.lists.filter { !$0.items.isEmpty } } let have = Set(downloadedTracks().map(\.ratingKey))
let stem = await MainActor.run { AppGraph.shared.stemPlaylists.lists.filter { l in
onlyDownloaded ? (l.tracks.contains { have.contains($0.ratingKey) }) : !l.items.isEmpty } }
let stemItems = stem.map { l in let stemItems = stem.map { l in
CarItem(id: "stemplaylist:\(l.id)", title: l.title, subtitle: CarTree.countLabel(l.items.count, "song", "songs"), CarItem(id: "stemplaylist:\(l.id)", title: l.title, subtitle: CarTree.countLabel(l.items.count, "song", "songs"),
art: artOr(l.thumb, l.title), playable: false, group: "Stem Playlists") art: artOr(l.thumb, l.title), playable: false, group: "Stem Playlists")
@@ -263,7 +291,7 @@ final class CarLibrary: @unchecked Sendable {
private func songItem(_ container: String, _ t: Track, _ showArtist: Bool, _ group: String?) -> CarItem { private func songItem(_ container: String, _ t: Track, _ showArtist: Bool, _ group: String?) -> CarItem {
// Stems are the norm, so only the exceptions are marked. // Stems are the norm, so only the exceptions are marked.
let noStems = stems.index.size > 0 && !pm.hasStems(t) let noStems = stems.index.size > 0 && !pm.hasStems(t)
let sub = [noStems ? "No stems" : nil, showArtist ? t.artist : nil].compactMap { $0 }.joined(separator: " · ") let sub = [noStems ? (stems.state(t) == .stemming ? "Stemming" : stems.state(t) == .queued ? "Queued for stems" : "No stems") : nil, showArtist ? t.artist : nil].compactMap { $0 }.joined(separator: " · ")
return CarItem(id: CarIds.play(container, t.ratingKey), title: t.title, subtitle: sub.isEmpty ? nil : sub, return CarItem(id: CarIds.play(container, t.ratingKey), title: t.title, subtitle: sub.isEmpty ? nil : sub,
art: t.thumb.map { .thumb($0) } ?? .letter(CarTree.letterOf(t.album)), playable: true, group: group, art: t.thumb.map { .thumb($0) } ?? .letter(CarTree.letterOf(t.album)), playable: true, group: group,
downloaded: pm.playableOffline(t), trackKey: t.ratingKey) downloaded: pm.playableOffline(t), trackKey: t.ratingKey)
+4 -1
View File
@@ -107,7 +107,7 @@ final class Settings: @unchecked Sendable {
private static let keys = [ private static let keys = [
"servers", "plexUrl", "plexToken", "plexUser", "plexMachineId", "musicSection", "musicSectionTitle", "servers", "plexUrl", "plexToken", "plexUser", "plexMachineId", "musicSection", "musicSectionTitle",
"stemUrl", "stemToken", "stemRoots", "theme", "accent", "albumColorNowPlaying", "highRefreshRate", "stemUrl", "stemToken", "plexAccountToken", "stemRoots", "theme", "accent", "albumColorNowPlaying", "highRefreshRate",
"stemQuality", "cacheLimitGb", "downloadsWifiOnly", "downloadSort", "keepStemMix", "masterVolume", "tabLayout", "stemQuality", "cacheLimitGb", "downloadsWifiOnly", "downloadSort", "keepStemMix", "masterVolume", "tabLayout",
"karaokeVocals", "karaokeSoftDb", "playbackSpeed", "stemsOnly", "noStems", "precacheCount", "hiddenNowPlaying", "presets", "presetOrder", "hiddenPresets", "lockedPreset", "webStations", "lockScreenMix", "acapella", "karaokeVocals", "karaokeSoftDb", "playbackSpeed", "stemsOnly", "noStems", "precacheCount", "hiddenNowPlaying", "presets", "presetOrder", "hiddenPresets", "lockedPreset", "webStations", "lockScreenMix", "acapella",
] ]
@@ -207,6 +207,7 @@ final class Settings: @unchecked Sendable {
var stemUrl: String { get { read(\.stemUrl, "stemUrl", "http://192.168.86.2:8765") } set { write(\.stemUrl, "stemUrl", newValue, "http://192.168.86.2:8765") } } var stemUrl: String { get { read(\.stemUrl, "stemUrl", "http://192.168.86.2:8765") } set { write(\.stemUrl, "stemUrl", newValue, "http://192.168.86.2:8765") } }
var stemToken: String { get { read(\.stemToken, "stemToken", "") } set { write(\.stemToken, "stemToken", newValue, "") } } var stemToken: String { get { read(\.stemToken, "stemToken", "") } set { write(\.stemToken, "stemToken", newValue, "") } }
var plexAccountToken: String { get { read(\.plexAccountToken, "plexAccountToken", "") } set { write(\.plexAccountToken, "plexAccountToken", newValue, "") } }
/// Plex path prefixes that matched the stems index, newline-separated. /// Plex path prefixes that matched the stems index, newline-separated.
var stemRoots: String { get { read(\.stemRoots, "stemRoots", "") } set { write(\.stemRoots, "stemRoots", newValue, "") } } var stemRoots: String { get { read(\.stemRoots, "stemRoots", "") } set { write(\.stemRoots, "stemRoots", newValue, "") } }
@@ -223,6 +224,8 @@ final class Settings: @unchecked Sendable {
var cacheLimitGb: Int { get { read(\.cacheLimitGb, "cacheLimitGb", 10) } set { write(\.cacheLimitGb, "cacheLimitGb", newValue, 10) } } var cacheLimitGb: Int { get { read(\.cacheLimitGb, "cacheLimitGb", 10) } set { write(\.cacheLimitGb, "cacheLimitGb", newValue, 10) } }
var downloadsWifiOnly: Bool { get { read(\.downloadsWifiOnly, "downloadsWifiOnly", true) } set { write(\.downloadsWifiOnly, "downloadsWifiOnly", newValue, true) } } var downloadsWifiOnly: Bool { get { read(\.downloadsWifiOnly, "downloadsWifiOnly", true) } set { write(\.downloadsWifiOnly, "downloadsWifiOnly", newValue, true) } }
/// Downloads page order: 0 download order, 1 artist, 2 release date. /// Downloads page order: 0 download order, 1 artist, 2 release date.
/// Library screen row order, titles joined by "|". Unlisted rows keep their default place after the listed ones.
var libraryOrder: String { get { read(\.libraryOrder, "libraryOrder", "") } set { write(\.libraryOrder, "libraryOrder", newValue, "") } }
var downloadSort: Int { get { read(\.downloadSort, "downloadSort", 0) } set { write(\.downloadSort, "downloadSort", newValue, 0) } } var downloadSort: Int { get { read(\.downloadSort, "downloadSort", 0) } set { write(\.downloadSort, "downloadSort", newValue, 0) } }
var karaokeVocals: Int { get { read(\.karaokeVocals, "karaokeVocals", KaraokeVocals.Off.rawValue) } set { write(\.karaokeVocals, "karaokeVocals", newValue, KaraokeVocals.Off.rawValue) } } var karaokeVocals: Int { get { read(\.karaokeVocals, "karaokeVocals", KaraokeVocals.Off.rawValue) } set { write(\.karaokeVocals, "karaokeVocals", newValue, KaraokeVocals.Off.rawValue) } }
/// How far down Soft karaoke puts the vocals, in dB. /// How far down Soft karaoke puts the vocals, in dB.
+29 -1
View File
@@ -15,10 +15,12 @@ struct StemPlaylist: Codable, Hashable, Identifiable {
var items: [StemPlaylistItem] = [] var items: [StemPlaylistItem] = []
var created = Date() var created = Date()
var updated = Date() var updated = Date()
/// Cover picked by the user; nil follows the first song.
var artThumb: String?
var tracks: [Track] { items.map(\.track) } var tracks: [Track] { items.map(\.track) }
var mixes: [[String: StemSetting]] { items.map(\.mix) } var mixes: [[String: StemSetting]] { items.map(\.mix) }
var thumb: String? { items.lazy.compactMap(\.track.thumb).first } var thumb: String? { artThumb ?? items.lazy.compactMap(\.track.thumb).first }
var durationMs: Int64 { items.reduce(0) { $0 + $1.track.durationMs } } var durationMs: Int64 { items.reduce(0) { $0 + $1.track.durationMs } }
} }
@@ -51,6 +53,32 @@ final class StemPlaylists {
edit(id) { $0.items += tracks.map { StemPlaylistItem(track: $0, mix: MixPresets.normalized(mix)) } } edit(id) { $0.items += tracks.map { StemPlaylistItem(track: $0, mix: MixPresets.normalized(mix)) } }
} }
func setArt(_ id: String, _ thumb: String?) { edit(id) { $0.artThumb = thumb } }
/// Re-reads every song from the library so renamed, retagged or re-added songs match again.
/// Keeps each item's uid and mix, and leaves songs the library can't find as they are.
/// Returns true when anything changed.
@discardableResult
func refresh(albumTracks: (String) async throws -> [Track]) async -> Bool {
var fresh: [String: [Track]] = [:]
for key in Set(lists.flatMap { $0.items.compactMap(\.track.albumKey) }) {
if let t = try? await albumTracks(key) { fresh[key] = t }
}
var changed = false
for li in lists.indices {
for ii in lists[li].items.indices {
let old = lists[li].items[ii].track
guard let key = old.albumKey, let pool = fresh[key] else { continue }
guard var new = pool.first(where: { $0.ratingKey == old.ratingKey })
?? pool.first(where: { $0.title == old.title && $0.artist == old.artist }) else { continue }
if new.stemKey == nil { new.stemKey = old.stemKey }
if new != old { lists[li].items[ii].track = new; changed = true }
}
}
if changed { save() }
return changed
}
func setItems(_ id: String, _ items: [StemPlaylistItem]) { edit(id) { $0.items = items } } func setItems(_ id: String, _ items: [StemPlaylistItem]) { edit(id) { $0.items = items } }
func remove(_ id: String, item: String) { edit(id) { $0.items.removeAll { $0.uid == item } } } func remove(_ id: String, item: String) { edit(id) { $0.items.removeAll { $0.uid == item } } }
func setMix(_ id: String, item: String, _ mix: [String: StemSetting]) { func setMix(_ id: String, item: String, _ mix: [String: StemSetting]) {
+62
View File
@@ -141,6 +141,18 @@ final class PlexClient: @unchecked Sendable {
return (out, total) return (out, total)
} }
/// Every track in the library, in pages, shuffled.
func allTracksShuffled() async throws -> [Track] {
let size = 2000
var (out, total) = try await tracksPage(start: 0, size: size)
while out.count < total {
let (page, _) = try await tracksPage(start: out.count, size: size)
if page.isEmpty { break }
out += page
}
return out.shuffled()
}
func randomTracks(_ count: Int) async throws -> [Track] { func randomTracks(_ count: Int) async throws -> [Track] {
let each = count / max(sources.count, 1) + 1 let each = count / max(sources.count, 1) + 1
return Array(try await pool { s in try await s.randomTracks(each).map { $0.tagged(s.server.id) } }.shuffled().prefix(count)) return Array(try await pool { s in try await s.randomTracks(each).map { $0.tagged(s.server.id) } }.shuffled().prefix(count))
@@ -269,5 +281,55 @@ final class PlexClient: @unchecked Sendable {
/// A Plex client for plex.tv calls and for a server that isn't saved yet. /// A Plex client for plex.tv calls and for a server that isn't saved yet.
func plexTv(_ url: String = "") -> PlexServer { PlexServer(http: http, settings: settings, server: MediaServer(url: url)) } func plexTv(_ url: String = "") -> PlexServer { PlexServer(http: http, settings: settings, server: MediaServer(url: url)) }
/// Signs in with a plex.tv token: finds every server on the account, saves each one that
/// answers (matched by machine id, so it never duplicates), and switches on all its music
/// libraries. Libraries already picked on a known server stay as they were.
/// Returns (servers saved, servers on the account that didn't answer).
@discardableResult
func syncAccount(token: String) async throws -> (saved: Int, unreachable: Int) {
let tv = plexTv()
let user = await {
guard let u = try? await tv.user(token: token) else { return "Plex user" }
return u.title ?? u.username ?? "Plex user"
}()
let found = try await tv.resources(token: token)
struct Hit { var res: PlexResource; var url: String; var secs: [MusicSection] }
let hits: [Hit] = await withTaskGroup(of: Hit?.self) { group in
for r in found {
group.addTask { [self] in
guard let url = await tv.reach(r, userToken: token) else { return nil }
let tok = r.accessToken.isEmpty ? token : r.accessToken
let probe = MediaServer(name: r.name, url: url, token: tok, machineId: r.clientIdentifier)
let secs = (try? await make(probe).musicSections()) ?? []
return Hit(res: r, url: url, secs: secs)
}
}
var out: [Hit] = []
for await h in group { if let h { out.append(h) } }
return out
}
let order = Dictionary(uniqueKeysWithValues: found.enumerated().map { ($1.clientIdentifier, $0) })
for h in hits.sorted(by: { (order[$0.res.clientIdentifier] ?? 0) < (order[$1.res.clientIdentifier] ?? 0) }) {
let known = settings.servers.first { $0.kind == .plex && $0.machineId == h.res.clientIdentifier && !$0.machineId.isEmpty }
?? settings.servers.first { $0.kind == .plex && $0.machineId.isEmpty && $0.base == h.url.trimmingCharacters(in: CharacterSet(charactersIn: "/")) }
var srv = known ?? MediaServer(id: settings.nextServerId(), kind: .plex)
srv.url = h.url
srv.token = h.res.accessToken.isEmpty ? token : h.res.accessToken
srv.machineId = h.res.clientIdentifier
srv.user = user
if srv.name.isEmpty || known == nil { srv.name = h.res.name }
if known != nil {
if !h.secs.isEmpty { srv.sections = srv.sections.filter { k in h.secs.contains { $0.key == k } } }
} else {
srv.sections = h.secs.map(\.key)
}
if !h.secs.isEmpty {
srv.sectionTitles = srv.sections.compactMap { k in h.secs.first { $0.key == k }?.title }.joined(separator: " + ")
}
settings.save(srv)
}
return (hits.count, found.count - hits.count)
}
func jellyfin(_ s: MediaServer) -> JellyfinClient { JellyfinClient(http: http, settings: settings, server: s) } func jellyfin(_ s: MediaServer) -> JellyfinClient { JellyfinClient(http: http, settings: settings, server: s) }
} }
+36
View File
@@ -263,6 +263,42 @@ struct PlexPin: Decodable {
} }
} }
/// One server from plex.tv/api/v2/resources, with every address it can be reached at.
struct PlexResource: Decodable {
struct Connection {
var uri = ""
var local = false
var relay = false
}
var name = ""
var clientIdentifier = ""
var accessToken = ""
var provides = ""
var connections: [Connection] = []
var isServer: Bool { provides.split(separator: ",").contains("server") }
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: AnyKey.self)
name = c.str("name") ?? ""
clientIdentifier = c.str("clientIdentifier") ?? ""
accessToken = c.str("accessToken") ?? ""
provides = c.str("provides") ?? ""
connections = (try? c.decodeIfPresent([Lenient<PlexConn>].self, forKey: AnyKey("connections")))?
.compactMap(\.value).map { Connection(uri: $0.uri, local: $0.local, relay: $0.relay) } ?? []
}
}
private struct PlexConn: Decodable {
var uri = ""
var local = false
var relay = false
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: AnyKey.self)
uri = c.str("uri") ?? ""
local = c.bool("local") ?? false
relay = c.bool("relay") ?? false
}
}
struct PlexUser: Decodable { struct PlexUser: Decodable {
var username: String? var username: String?
var title: String? var title: String?
+40
View File
@@ -325,6 +325,46 @@ final class PlexServer: MediaSource, @unchecked Sendable {
return try JSONDecoder().decode(PlexPin.self, from: try await http.data(req)) return try JSONDecoder().decode(PlexPin.self, from: try await http.data(req))
} }
/// A strong PIN, completed in the browser at [authUrl] (same flow as the web UI).
func createStrongPin() async throws -> PlexPin {
var req = request(URL(string: "https://plex.tv/api/v2/pins?strong=true")!, withToken: false, method: "POST")
req.httpBody = Data()
return try JSONDecoder().decode(PlexPin.self, from: try await http.data(req))
}
func authUrl(code: String) -> URL? {
var c = URLComponents(string: "https://app.plex.tv/auth")
c?.percentEncodedFragment = "?clientID=\(urlEncode(settings.clientId))&code=\(urlEncode(code))&context%5Bdevice%5D%5Bproduct%5D=\(urlEncode(Self.product))"
return c?.url
}
/// Every server this plex.tv account can reach, owned or shared.
func resources(token: String) async throws -> [PlexResource] {
var req = request(URL(string: "https://plex.tv/api/v2/resources?includeHttps=1&includeRelay=1")!, withToken: false)
req.setValue(token, forHTTPHeaderField: "X-Plex-Token")
let list = try JSONDecoder().decode([Lenient<PlexResource>].self, from: try await http.data(req))
return list.compactMap(\.value).filter(\.isServer)
}
/// The first address that answers /identity as the expected server, local ones first, relay last.
func reach(_ r: PlexResource, userToken: String) async -> String? {
let tok = r.accessToken.isEmpty ? userToken : r.accessToken
let ordered = r.connections.filter { !$0.uri.isEmpty }.sorted { rank($0) < rank($1) }
for c in ordered {
let base = c.uri.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
guard let u = URL(string: base + "/identity") else { continue }
var req = request(u, withToken: false)
req.setValue(tok, forHTTPHeaderField: "X-Plex-Token")
req.timeoutInterval = 3.5
guard let body = try? await http.data(req),
let id = try? decode(body).machineIdentifier, id == r.clientIdentifier || r.clientIdentifier.isEmpty else { continue }
return base
}
return nil
}
private func rank(_ c: PlexResource.Connection) -> Int { c.relay ? 2 : (c.local ? 0 : 1) }
func checkPin(_ id: Int64) async throws -> PlexPin { func checkPin(_ id: Int64) async throws -> PlexPin {
let req = request(URL(string: "https://plex.tv/api/v2/pins/\(id)")!, withToken: false) let req = request(URL(string: "https://plex.tv/api/v2/pins/\(id)")!, withToken: false)
return try JSONDecoder().decode(PlexPin.self, from: try await http.data(req)) return try JSONDecoder().decode(PlexPin.self, from: try await http.data(req))
+69
View File
@@ -19,6 +19,23 @@ private struct ServerInfo: Decodable {
} }
} }
/// What stemd is doing, from stemserve's /api/queue.
enum StemState: Equatable { case stemming, queued, failed }
private struct QueueInfo: Decodable {
var pending: [String] = []
var current: String?
var done = 0
var failed: [String] = []
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: AnyKey.self)
pending = c.list("pending")
current = c.str("current")
done = c.int("done") ?? 0
failed = c.list("failed")
}
}
/// Client for server/stemserve.py: the stems index, stem file URLs and waveforms. /// Client for server/stemserve.py: the stems index, stem file URLs and waveforms.
@Observable @Observable
final class StemServer: @unchecked Sendable { final class StemServer: @unchecked Sendable {
@@ -35,6 +52,13 @@ final class StemServer: @unchecked Sendable {
private(set) var status = StemServerStatus.unknown private(set) var status = StemServerStatus.unknown
/// When the index was last checked against the server, nil before the first check. /// When the index was last checked against the server, nil before the first check.
private(set) var checkedAt: Date? private(set) var checkedAt: Date?
/// stemd's live state: the song being separated, the ones waiting, the ones that failed.
private(set) var current: String?
private(set) var pending: Set<String> = []
private(set) var failed: Set<String> = []
@ObservationIgnored private var lastDone: Int?
var busy: Bool { current != nil || !pending.isEmpty }
init(http: Http, settings: Settings, dir: URL) { init(http: Http, settings: Settings, dir: URL) {
self.http = http self.http = http
@@ -53,6 +77,49 @@ final class StemServer: @unchecked Sendable {
try? await Task.sleep(nanoseconds: Self.checkSeconds * 1_000_000_000) try? await Task.sleep(nanoseconds: Self.checkSeconds * 1_000_000_000)
} }
} }
// Watch stemd's queue: every few seconds while it has work, slowly when idle.
Task.detached { [weak self] in
while let self {
await self.pollQueue()
try? await Task.sleep(nanoseconds: (self.busy ? 4 : 30) * 1_000_000_000)
}
}
}
/// Where a track stands with stemd, nil when it has stems or isn't known to it.
func state(_ track: Track) -> StemState? {
guard let f = track.partFile, !f.isEmpty else { return nil }
if find(track) != nil { return nil }
if let c = current, f == c || f.hasSuffix("/" + c) { return .stemming }
if pending.contains(f) { return .queued }
if failed.contains(f) { return .failed }
return nil
}
/// "Stemming “X” · N queued", nil when stemd is idle.
var liveLine: String? {
let n = pending.count
if let c = current {
let name = ((c as NSString).lastPathComponent as NSString).deletingPathExtension
return "Stemming “\(name)”" + (n > 0 ? " · \(n) queued" : "")
}
return n > 0 ? "\(n) queued for stems" : nil
}
/// Reads /api/queue. A change in the finished count pulls the new index straight away.
func pollQueue() async {
guard !base.isEmpty,
let u = try? url("/api/queue"),
let data = try? await http.data(req(u)),
let q = try? JSONDecoder().decode(QueueInfo.self, from: data) else { return }
let changed = lastDone != nil && lastDone != q.done
lastDone = q.done
await MainActor.run {
self.current = q.current
self.pending = Set(q.pending)
self.failed = Set(q.failed)
}
if changed { await refresh() }
} }
private var base: String { settings.stemUrl.trimmingCharacters(in: CharacterSet(charactersIn: "/")) } private var base: String { settings.stemUrl.trimmingCharacters(in: CharacterSet(charactersIn: "/")) }
@@ -164,6 +231,8 @@ final class StemServer: @unchecked Sendable {
let msg = (try? JSONSerialization.jsonObject(with: body) as? [String: Any])?["error"] as? String 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)") throw HttpError(code: code, message: code == 404 ? "stemserve needs updating for Stem Now" : msg ?? "stemserve answered \(code)")
} }
await MainActor.run { paths.forEach { self.pending.insert($0) } }
Task.detached { [self] in await pollQueue() }
return paths.count return paths.count
} }
+19 -1
View File
@@ -15,7 +15,14 @@ struct MediaMenu {
a.append(SheetAction("Remove Download", icon: "trash", destructive: true) { g.downloads.remove([t]) }) a.append(SheetAction("Remove Download", icon: "trash", destructive: true) { g.downloads.remove([t]) })
} }
a.append(SheetAction("Add to Playlist…", icon: "text.badge.plus") { addToPlaylist([t]) }) a.append(SheetAction("Add to Playlist…", icon: "text.badge.plus") { addToPlaylist([t]) })
if t.partFile != nil, g.stems.index.size > 0, g.stems.find(t) == nil { a.append(stemNow { [t] }) } if t.partFile != nil, g.stems.index.size > 0, g.stems.find(t) == nil {
let s = g.stems.state(t)
if s == .stemming || s == .queued {
a.append(SheetAction(s == .stemming ? "Stemming…" : "Queued for Stems", icon: "waveform") {})
} else {
a.append(stemNow { [t] })
}
}
if allowGoTo, let k = t.albumKey { if allowGoTo, let k = t.albumKey {
a.append(SheetAction("Go to Album", icon: "square.stack") { a.append(SheetAction("Go to Album", icon: "square.stack") {
ui.showInLibrary(.AlbumPage(Album(ratingKey: k, title: t.album, artist: t.artist, artistKey: t.artistKey, year: t.year, thumb: t.thumb))) ui.showInLibrary(.AlbumPage(Album(ratingKey: k, title: t.album, artist: t.artist, artistKey: t.artistKey, year: t.year, thumb: t.thumb)))
@@ -68,6 +75,17 @@ struct MediaMenu {
{ pl.tracks }, confirm: false)) { pl.tracks }, confirm: false))
if pl.items.isEmpty { a = [] } if pl.items.isEmpty { a = [] }
let lists = g.stemPlaylists let lists = g.stemPlaylists
if !pl.items.isEmpty {
a.append(SheetAction("Change Cover…", icon: "photo") {
var seen = Set<String>()
var acts: [SheetAction] = [SheetAction("Automatic", icon: "wand.and.stars") { lists.setArt(pl.id, nil) }]
for t in pl.tracks {
guard let th = t.thumb, seen.insert(th).inserted, seen.count <= 20 else { continue }
acts.append(SheetAction(t.album, icon: th == pl.artThumb ? "checkmark" : "photo") { lists.setArt(pl.id, th) })
}
overlays.actions("Choose a Cover", acts)
})
}
a.append(SheetAction("Delete Stem Playlist", icon: "trash", destructive: true) { a.append(SheetAction("Delete Stem Playlist", icon: "trash", destructive: true) {
overlays.confirm("Delete “\(pl.title)”?", "Stem playlists are only on this device.", "Delete") { lists.delete(pl.id); if onPage { ui.pop() } } overlays.confirm("Delete “\(pl.title)”?", "Stem playlists are only on this device.", "Delete") { lists.delete(pl.id); if onPage { ui.pop() } }
}) })
+8 -1
View File
@@ -56,7 +56,14 @@ struct TrackRow<Leading: View>: View {
} }
.padding(.vertical, 6) .padding(.vertical, 6)
Spacer(minLength: 6) Spacer(minLength: 6)
if noStems { Tag(text: "NO STEMS").padding(.trailing, 6) } if noStems {
switch g.stems.state(track) {
case .stemming: Tag(text: "STEMMING").padding(.trailing, 6)
case .queued: Tag(text: "QUEUED").padding(.trailing, 6)
case .failed: Tag(text: "FAILED").padding(.trailing, 6)
case nil: Tag(text: "NO STEMS").padding(.trailing, 6)
}
}
if let progress { ProgressRing(progress: progress, size: 16).padding(.trailing, 4) } if let progress { ProgressRing(progress: progress, size: 16).padding(.trailing, 4) }
else if downloaded { DownloadedMark().padding(.trailing, 4) } else if downloaded { DownloadedMark().padding(.trailing, 4) }
if let menu { MoreMenu(items: menu).foregroundStyle(p.label) } if let menu { MoreMenu(items: menu).foregroundStyle(p.label) }
+2 -2
View File
@@ -36,9 +36,9 @@ enum Tab: String, CaseIterable, Hashable {
} }
enum Route: Hashable { enum Route: Hashable {
case Artists, Albums, Songs, Playlists, Genres, Downloads, TabBar, WebRadio case Artists, Albums, Songs, Playlists, Genres, Downloads, DownloadQueue, TabBar, WebRadio
case Eq, EqFilters(profileId: String), EqFilterPage(profileId: String, filterId: String) case Eq, EqFilters(profileId: String), EqFilterPage(profileId: String, filterId: String)
case Servers, Server(String), StemServer, Quality, Storage, Appearance, NowPlayingOptions, StemEditor, About case Servers, ServerList, Server(String), StemServer, Quality, Storage, Appearance, NowPlayingOptions, StemEditor, About
case GenrePage(String), ArtistPage(Artist), AlbumPage(Album), PlaylistPage(Playlist), StemPlaylistPage(String) case GenrePage(String), ArtistPage(Artist), AlbumPage(Album), PlaylistPage(Playlist), StemPlaylistPage(String)
} }
+2
View File
@@ -61,6 +61,7 @@ struct RootView: View {
case .Playlists: PlaylistsScreen() case .Playlists: PlaylistsScreen()
case .Genres: GenresScreen() case .Genres: GenresScreen()
case .Downloads: DownloadsScreen() case .Downloads: DownloadsScreen()
case .DownloadQueue: DownloadQueueScreen()
case .TabBar: TabBarScreen() case .TabBar: TabBarScreen()
case .WebRadio: WebRadioScreen() case .WebRadio: WebRadioScreen()
case .GenrePage(let g): GenreScreen(genre: g) case .GenrePage(let g): GenreScreen(genre: g)
@@ -68,6 +69,7 @@ struct RootView: View {
case .EqFilters(let id): EqFiltersScreen(profileId: id) case .EqFilters(let id): EqFiltersScreen(profileId: id)
case .EqFilterPage(let id, let f): EqFilterPage(profileId: id, filterId: f) case .EqFilterPage(let id, let f): EqFilterPage(profileId: id, filterId: f)
case .Servers: ServersScreen() case .Servers: ServersScreen()
case .ServerList: ServerListScreen()
case .Server(let id): ServerScreen(id: id) case .Server(let id): ServerScreen(id: id)
case .StemServer: StemServerScreen() case .StemServer: StemServerScreen()
case .Quality: QualityScreen() case .Quality: QualityScreen()
+100 -10
View File
@@ -9,8 +9,25 @@ struct LibraryHome: View {
@State private var played: [Album] = [] @State private var played: [Album] = []
@State private var added: [Track] = [] @State private var added: [Track] = []
@State private var reload = 0 @State private var reload = 0
@State private var editing = false
private var g: AppGraph { .shared } private var g: AppGraph { .shared }
/// Rows in the saved order. Titles not saved yet keep their default order after the saved ones.
private func ordered<T>(_ items: [T], _ title: (T) -> String) -> [T] {
let saved = g.settings.libraryOrder.split(separator: "|").map(String.init)
func rank(_ t: String) -> Int { saved.firstIndex(of: t) ?? saved.count }
return items.enumerated().sorted { (rank(title($0.element)), $0.offset) < (rank(title($1.element)), $1.offset) }.map(\.element)
}
/// Moves a row one place within its section and saves the whole order.
private func move(_ title: String, _ by: Int, in titles: [String]) {
guard let i = titles.firstIndex(of: title), titles.indices.contains(i + by) else { return }
var t = titles
t.swapAt(i, i + by)
let saved = g.settings.libraryOrder.split(separator: "|").map(String.init)
g.settings.libraryOrder = (t + saved.filter { !t.contains($0) }).joined(separator: "|")
}
var body: some View { var body: some View {
let s = g.settings let s = g.settings
let ready = s.libraryReady let ready = s.libraryReady
@@ -63,15 +80,18 @@ struct LibraryHome: View {
if ready { if ready {
SectionTitle(title: "Stations") SectionTitle(title: "Stations")
let stations: [(String, String, () async throws -> [Track])] = [ let stations: [(String, String, () async throws -> [Track])] = [
("dot.radiowaves.left.and.right", "Library Radio", { try await g.plex.randomTracks(200) }), ("dot.radiowaves.left.and.right", "Library Radio", { try await g.plex.allTracksShuffled() }),
("square.stack", "Random Album Radio", { ("square.stack", "Random Album Radio", {
// Whole albums in random order, across the entire library.
var out: [Track] = [] var out: [Track] = []
for a in try await g.plex.randomAlbums(10) { out += try await g.plex.albumTracks(a.ratingKey) } for a in try await g.plex.albums().shuffled() { out += try await g.plex.albumTracks(a.ratingKey) }
return out return out
}), }),
] ]
ForEach(stations, id: \.1) { st in let shown = ordered(stations) { $0.1 }
LibraryRow(icon: st.0, title: st.1, last: st.1 == stations.last?.1) { play(st.2) } ForEach(shown, id: \.1) { st in
LibraryRow(icon: st.0, title: st.1, last: st.1 == shown.last?.1, editing: editing,
move: { move(st.1, $0, in: shown.map(\.1)) }) { play(st.2) }
} }
} }
// Every tab except Settings (and this one). // Every tab except Settings (and this one).
@@ -81,8 +101,17 @@ struct LibraryHome: View {
("square.stack", "Albums", .Albums), ("music.note", "Songs", .Songs), ("guitars.fill", "Genres", .Genres), ("square.stack", "Albums", .Albums), ("music.note", "Songs", .Songs), ("guitars.fill", "Genres", .Genres),
("arrow.down.circle", "Downloads", .Downloads), ("antenna.radiowaves.left.and.right", "Web Radio", .WebRadio), ("arrow.down.circle", "Downloads", .Downloads), ("antenna.radiowaves.left.and.right", "Web Radio", .WebRadio),
] ]
ForEach(rows, id: \.1) { r in let shownRows = ordered(rows) { $0.1 }
LibraryRow(icon: r.0, title: r.1, last: r.1 == rows.last?.1) { ui.push(r.2) } ForEach(shownRows, id: \.1) { r in
LibraryRow(icon: r.0, title: r.1, last: r.1 == shownRows.last?.1, editing: editing,
move: { move(r.1, $0, in: shownRows.map(\.1)) }) { ui.push(r.2) }
}
}
.toolbar {
if ready {
ToolbarItem(placement: .topBarTrailing) {
Button(editing ? "Done" : "Reorder") { withAnimation { editing.toggle() } }
}
} }
} }
.task(id: "\(s.libraryKey)|\(reload)") { .task(id: "\(s.libraryKey)|\(reload)") {
@@ -92,6 +121,7 @@ struct LibraryHome: View {
await LoadState.load($albums) { try await g.plex.albums() } await LoadState.load($albums) { try await g.plex.albums() }
played = Array((await rp ?? []).prefix(24)) played = Array((await rp ?? []).prefix(24))
added = Array((await ra ?? []).prefix(10)) added = Array((await ra ?? []).prefix(10))
if await g.stemPlaylists.refresh(albumTracks: { try await g.plex.albumTracks($0) }) { await g.downloads.refresh() }
} }
} }
@@ -166,15 +196,25 @@ private struct LibraryRow: View {
let icon: String let icon: String
let title: String let title: String
let last: Bool let last: Bool
var editing = false
var move: (Int) -> Void = { _ in }
let action: () -> Void let action: () -> Void
@Environment(\.palette) private var p @Environment(\.palette) private var p
var body: some View { var body: some View {
Button(action: action) { Button(action: { if !editing { action() } }) {
HStack(spacing: 14) { HStack(spacing: 14) {
Image(systemName: icon).font(.system(size: 22)).foregroundStyle(p.accent).frame(width: 26) Image(systemName: icon).font(.system(size: 22)).foregroundStyle(p.accent).frame(width: 26)
Text(title).font(.title3).foregroundStyle(p.label) Text(title).font(.title3).foregroundStyle(p.label)
Spacer() Spacer()
Image(systemName: "chevron.right").font(.system(size: 14, weight: .semibold)).foregroundStyle(p.label3) if editing {
HStack(spacing: 18) {
Image(systemName: "chevron.up").onTapGesture { move(-1) }
Image(systemName: "chevron.down").onTapGesture { move(1) }
}
.font(.system(size: 17, weight: .semibold)).foregroundStyle(p.accent)
} else {
Image(systemName: "chevron.right").font(.system(size: 14, weight: .semibold)).foregroundStyle(p.label3)
}
} }
.frame(height: 52) .frame(height: 52)
.contentShape(Rectangle()) .contentShape(Rectangle())
@@ -472,7 +512,7 @@ struct SongsScreen: View {
/// Shuffles the whole library, not just the pages loaded so far. /// Shuffles the whole library, not just the pages loaded so far.
private func shuffleAll() { private func shuffleAll() {
Task { Task {
do { g.playback.playTracks(try await g.plex.randomTracks(1000), shuffle: true) } do { g.playback.playTracks(try await g.plex.allTracksShuffled(), shuffle: true) }
catch { g.playback.report("Couldn't load songs: \(error.localizedDescription)") } catch { g.playback.report("Couldn't load songs: \(error.localizedDescription)") }
} }
} }
@@ -700,7 +740,7 @@ struct DownloadsScreen: View {
SheetAction(i == sort ? "✓ \(label)" : label) { g.settings.downloadSort = i } SheetAction(i == sort ? "✓ \(label)" : label) { g.settings.downloadSort = i }
}) })
} }
MoreMenu(items: moreItems(all, pinned)) MoreMenu(items: moreItems(all, pinned) + [SheetAction("View Queue", icon: "list.bullet") { ui.push(.DownloadQueue) }])
} }
} }
} }
@@ -813,3 +853,53 @@ struct GenreScreen: View {
} }
} }
} }
struct DownloadQueueScreen: View {
private var g: AppGraph { .shared }
var body: some View {
let recs = g.downloads.records.values
let active = recs.filter { !$0.done && $0.error == nil }.sorted { $0.added < $1.added }
let failed = recs.filter { $0.error != nil }.sorted { $0.added < $1.added }
let done = recs.filter(\.done).sorted { $0.added > $1.added }
List {
if active.isEmpty && failed.isEmpty && done.isEmpty {
Text("Nothing in the queue").foregroundStyle(.secondary)
}
if !active.isEmpty {
Section("In Progress · \(active.count)") {
ForEach(active, id: \.track.ratingKey) { r in
let p = g.downloads.progress[r.track.ratingKey] ?? 0
row(r, status: p > 0 ? "Downloading \(Int(p * 100))%" : "Waiting", progress: p > 0 ? p : nil)
}
}
}
if !failed.isEmpty {
Section("Failed · \(failed.count)") {
ForEach(failed, id: \.track.ratingKey) { r in row(r, status: r.error ?? "Failed", progress: nil) }
}
}
if !done.isEmpty {
Section("Completed · \(done.count)") {
ForEach(done, id: \.track.ratingKey) { r in row(r, status: r.stems ? "Done · stems" : "Done", progress: nil) }
}
}
}
.navigationTitle("Download Queue")
.toolbar {
if !failed.isEmpty {
Button("Retry Failed") { g.downloads.retryFailed() }
}
}
.task { await g.downloads.refresh() }
}
private func row(_ r: DownloadRecord, status: String, progress: Float?) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(r.track.title).lineLimit(1)
Text("\(r.track.artist) · \(status)").font(.caption).foregroundStyle(.secondary).lineLimit(1)
if let progress { ProgressView(value: progress) }
}
}
}
+94 -8
View File
@@ -403,9 +403,8 @@ struct BackupCells: View {
/// Said on both server screens: stems are found by file path, so libraries have to line up. /// Said on both server screens: stems are found by file path, so libraries have to line up.
private let matchNote = "For stems to work, every Plex or Jellyfin music library here has to match a stem library one-to-one: the same folders with the same files, as your stem server sees them. Stems are found by each song's file path, so a library with no matching stem folder plays without stems, and songs that are only on one side don't get stems at all." private let matchNote = "For stems to work, every Plex or Jellyfin music library here has to match a stem library one-to-one: the same folders with the same files, as your stem server sees them. Stems are found by each song's file path, so a library with no matching stem folder plays without stems, and songs that are only on one side don't get stems at all."
struct ServersScreen: View { struct ServerListScreen: View {
@Environment(\.palette) private var p @Environment(\.palette) private var p
@State private var libs: [String: [MusicSection]] = [:]
private var g: AppGraph { .shared } private var g: AppGraph { .shared }
var body: some View { var body: some View {
@@ -417,7 +416,49 @@ struct ServersScreen: View {
NavCell(title: s.label, value: s.ready ? s.sectionTitles : (s.signedIn ? "No Libraries" : "Not Signed In"), NavCell(title: s.label, value: s.ready ? s.sectionTitles : (s.signedIn ? "No Libraries" : "Not Signed In"),
icon: "server.rack", iconBg: s.kind == .plex ? 0xE5A00D : 0xAA5CC3, route: .Server(s.id)) icon: "server.rack", iconBg: s.kind == .plex ? 0xE5A00D : 0xAA5CC3, route: .Server(s.id))
} }
} header: { Header(text: "Music Servers") } footer: { } header: { Header(text: "Servers") } footer: {
Footer(text: "Every server's picked libraries are pooled into one library. Playlists stay on the server they were made on.")
}
Section {
NavCell(title: "Add Plex Server", icon: "plus", iconBg: 0xE5A00D, route: .Server("new-plex"))
NavCell(title: "Add Jellyfin Server", icon: "plus", iconBg: 0xAA5CC3, route: .Server("new-jellyfin"))
} header: { Header(text: "Add") } footer: { Footer(text: matchNote) }
}
.listStyle(.insetGrouped)
.themedList()
.navigationTitle("Music Servers")
.navigationBarTitleDisplayMode(.inline)
}
}
struct ServersScreen: View {
@Environment(\.palette) private var p
@Environment(\.openURL) private var openURL
@State private var libs: [String: [MusicSection]] = [:]
@State private var busy = false
@State private var status: String?
@State private var signInTask: Task<Void, Never>?
private var g: AppGraph { .shared }
var body: some View {
let list = g.settings.servers
List {
Section {
if busy {
HStack { ProgressView(); Text(status ?? "Working…").foregroundStyle(p.label2) }.themedRow()
} else {
ActionCell(title: "Sign in with Plex") { signIn() }
if !g.settings.plexAccountToken.isEmpty {
ActionCell(title: "Refresh Servers") { refresh(g.settings.plexAccountToken) }
}
if let status { Text(status).font(.footnote).foregroundStyle(p.label2).themedRow() }
}
} header: { Header(text: "Plex Account") } footer: {
Footer(text: "Sign in at plex.tv to add every server on your account and all of their music libraries at once.")
}
Section {
NavCell(title: "Music Servers", value: list.isEmpty ? "None" : "\(list.count)", icon: "server.rack", iconBg: 0xE5A00D, route: .ServerList)
} footer: {
Footer(text: "Every server's picked libraries are pooled into one library. Playlists stay on the server they were made on.") Footer(text: "Every server's picked libraries are pooled into one library. Playlists stay on the server they were made on.")
} }
ForEach(list.filter(\.signedIn)) { s in ForEach(list.filter(\.signedIn)) { s in
@@ -433,20 +474,64 @@ struct ServersScreen: View {
} }
} header: { Header(text: s.label + " Libraries") } } header: { Header(text: s.label + " Libraries") }
} }
Section {
NavCell(title: "Add Plex Server", icon: "plus", iconBg: 0xE5A00D, route: .Server("new-plex"))
NavCell(title: "Add Jellyfin Server", icon: "plus", iconBg: 0xAA5CC3, route: .Server("new-jellyfin"))
} header: { Header(text: "Add") } footer: { Footer(text: matchNote) }
} }
.listStyle(.insetGrouped) .listStyle(.insetGrouped)
.themedList() .themedList()
.navigationTitle("Music Servers") .navigationTitle("Plex Account")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.task { .task {
for s in list where s.signedIn && libs[s.id] == nil { for s in list where s.signedIn && libs[s.id] == nil {
libs[s.id] = (try? await g.plex.make(s).musicSections()) ?? [] libs[s.id] = (try? await g.plex.make(s).musicSections()) ?? []
} }
} }
.onDisappear { signInTask?.cancel() }
}
/// plex.tv sign-in in the browser, the same flow as the web app.
private func signIn() {
signInTask?.cancel()
busy = true; status = "Waiting for plex.tv…"
signInTask = Task {
let tv = g.plex.plexTv()
do {
let pin = try await tv.createStrongPin()
guard let url = tv.authUrl(code: pin.code) else { throw URLError(.badURL) }
openURL(url)
for _ in 0..<150 {
try await Task.sleep(for: .seconds(2))
if let t = try? await tv.checkPin(pin.id).authToken, !t.isEmpty {
await sync(t); return
}
}
status = "Sign-in timed out."
} catch is CancellationError {
} catch {
status = "Sign-in failed: \(error.localizedDescription)"
}
busy = false
}
}
private func refresh(_ token: String) {
busy = true; status = "Finding servers…"
signInTask = Task { await sync(token) }
}
private func sync(_ token: String) async {
status = "Finding servers…"
do {
let r = try await g.plex.syncAccount(token: token)
g.settings.plexAccountToken = token
libs = [:]
status = r.saved == 0 && r.unreachable == 0 ? "No Plex servers on this account."
: "\(r.saved) server\(r.saved == 1 ? "" : "s") added or updated" + (r.unreachable > 0 ? ", \(r.unreachable) unreachable." : ".")
for s in g.settings.servers where s.signedIn {
libs[s.id] = (try? await g.plex.make(s).musicSections()) ?? []
}
} catch {
status = "Sync failed: \(error.localizedDescription)"
}
busy = false
} }
/// Turns one library of a server on or off. All off keeps the sign-in. /// Turns one library of a server on or off. All off keeps the sign-in.
@@ -698,6 +783,7 @@ struct StemServerScreen: View {
case .unknown: "—" case .unknown: "—"
} }
}()).themedRow() }()).themedRow()
ValueCell(title: "stemd", value: g.stems.liveLine ?? (g.stems.failed.isEmpty ? "Idle" : "\(g.stems.failed.count) failed")).themedRow()
ValueCell(title: "Opus Transcoding", value: { ValueCell(title: "Opus Transcoding", value: {
if case .online(_, let t) = status { return t ? "Available" : "No ffmpeg" } if case .online(_, let t) = status { return t ? "Available" : "No ffmpeg" }
return "—" return "—"
File renamed without changes.