From a4c870251c96ee20054afbda0df309a07e11f39a Mon Sep 17 00:00:00 2001 From: root Date: Wed, 28 Oct 2026 15:49:09 -0700 Subject: [PATCH] iOS: stem playlist refresh and covers, download queue page, reorderable Library, whole-library radio Co-Authored-By: Claude Sonnet 5.5 --- .env.example | 0 CHANGELOG.md | 13 +++ TODO.md | 2 + ios/Stemify/Cache/MediaCache.swift | 2 +- ios/Stemify/CarPlay/CarLibrary.swift | 38 ++++++- ios/Stemify/Core/Settings.swift | 5 +- ios/Stemify/Playback/StemPlaylists.swift | 30 ++++- ios/Stemify/Plex/PlexClient.swift | 62 +++++++++++ ios/Stemify/Plex/PlexModels.swift | 36 ++++++ ios/Stemify/Plex/PlexServer.swift | 40 +++++++ ios/Stemify/Stems/StemServer.swift | 69 ++++++++++++ ios/Stemify/UI/Components/MediaMenu.swift | 20 +++- ios/Stemify/UI/Components/Rows.swift | 9 +- ios/Stemify/UI/Nav.swift | 4 +- ios/Stemify/UI/RootView.swift | 2 + ios/Stemify/UI/Screens/LibraryScreens.swift | 110 +++++++++++++++++-- ios/Stemify/UI/Screens/SettingsScreens.swift | 102 +++++++++++++++-- server/{docker-compose.yml => compose.yaml} | 0 18 files changed, 514 insertions(+), 30 deletions(-) create mode 100644 .env.example create mode 100644 CHANGELOG.md create mode 100644 TODO.md rename server/{docker-compose.yml => compose.yaml} (100%) diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e69de29 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..3253878 --- /dev/null +++ b/CHANGELOG.md @@ -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. diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..b6dbda7 --- /dev/null +++ b/TODO.md @@ -0,0 +1,2 @@ +# TODO + diff --git a/ios/Stemify/Cache/MediaCache.swift b/ios/Stemify/Cache/MediaCache.swift index 9e5e3ea..8090200 100644 --- a/ios/Stemify/Cache/MediaCache.swift +++ b/ios/Stemify/Cache/MediaCache.swift @@ -498,7 +498,7 @@ final class CachedResource: @unchecked Sendable { block = 0 lastBlock = MediaCache.blocksFor(Int64(body.count)) - 1 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() let length = self.length diff --git a/ios/Stemify/CarPlay/CarLibrary.swift b/ios/Stemify/CarPlay/CarLibrary.swift index 3e0ef9b..c98c776 100644 --- a/ios/Stemify/CarPlay/CarLibrary.swift +++ b/ios/Stemify/CarPlay/CarLibrary.swift @@ -69,13 +69,14 @@ final class CarLibrary: @unchecked Sendable { func children(_ id: String) async throws -> [CarItem]? { let away = await away() 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.albums: return try await albumsTab(away) case CarIds.artists: return try await artistsTab(away) default: guard let (kind, key) = CarIds.split(id) else { return nil } switch kind { + case "dl": return try await downloadsView(key) 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 "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() 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) } } else { 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 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") @@ -263,7 +291,7 @@ final class CarLibrary: @unchecked Sendable { private func songItem(_ container: String, _ t: Track, _ showArtist: Bool, _ group: String?) -> CarItem { // Stems are the norm, so only the exceptions are marked. 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, art: t.thumb.map { .thumb($0) } ?? .letter(CarTree.letterOf(t.album)), playable: true, group: group, downloaded: pm.playableOffline(t), trackKey: t.ratingKey) diff --git a/ios/Stemify/Core/Settings.swift b/ios/Stemify/Core/Settings.swift index f85549e..4bafab4 100644 --- a/ios/Stemify/Core/Settings.swift +++ b/ios/Stemify/Core/Settings.swift @@ -107,7 +107,7 @@ final class Settings: @unchecked Sendable { private static let keys = [ "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", "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 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. 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 downloadsWifiOnly: Bool { get { read(\.downloadsWifiOnly, "downloadsWifiOnly", true) } set { write(\.downloadsWifiOnly, "downloadsWifiOnly", newValue, true) } } /// 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 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. diff --git a/ios/Stemify/Playback/StemPlaylists.swift b/ios/Stemify/Playback/StemPlaylists.swift index dad58ca..ecd48bc 100644 --- a/ios/Stemify/Playback/StemPlaylists.swift +++ b/ios/Stemify/Playback/StemPlaylists.swift @@ -15,10 +15,12 @@ struct StemPlaylist: Codable, Hashable, Identifiable { var items: [StemPlaylistItem] = [] var created = Date() var updated = Date() + /// Cover picked by the user; nil follows the first song. + var artThumb: String? var tracks: [Track] { items.map(\.track) } 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 } } } @@ -51,6 +53,32 @@ final class StemPlaylists { 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 remove(_ id: String, item: String) { edit(id) { $0.items.removeAll { $0.uid == item } } } func setMix(_ id: String, item: String, _ mix: [String: StemSetting]) { diff --git a/ios/Stemify/Plex/PlexClient.swift b/ios/Stemify/Plex/PlexClient.swift index db096ea..fc191c8 100644 --- a/ios/Stemify/Plex/PlexClient.swift +++ b/ios/Stemify/Plex/PlexClient.swift @@ -141,6 +141,18 @@ final class PlexClient: @unchecked Sendable { 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] { 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)) @@ -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. 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) } } diff --git a/ios/Stemify/Plex/PlexModels.swift b/ios/Stemify/Plex/PlexModels.swift index 36d26c6..c29391d 100644 --- a/ios/Stemify/Plex/PlexModels.swift +++ b/ios/Stemify/Plex/PlexModels.swift @@ -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].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 { var username: String? var title: String? diff --git a/ios/Stemify/Plex/PlexServer.swift b/ios/Stemify/Plex/PlexServer.swift index fae9aec..0466fdc 100644 --- a/ios/Stemify/Plex/PlexServer.swift +++ b/ios/Stemify/Plex/PlexServer.swift @@ -325,6 +325,46 @@ final class PlexServer: MediaSource, @unchecked Sendable { 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].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 { 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)) diff --git a/ios/Stemify/Stems/StemServer.swift b/ios/Stemify/Stems/StemServer.swift index 70a3311..04c299e 100644 --- a/ios/Stemify/Stems/StemServer.swift +++ b/ios/Stemify/Stems/StemServer.swift @@ -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. @Observable final class StemServer: @unchecked Sendable { @@ -35,6 +52,13 @@ final class StemServer: @unchecked Sendable { 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? + /// 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 = [] + private(set) var failed: Set = [] + @ObservationIgnored private var lastDone: Int? + + var busy: Bool { current != nil || !pending.isEmpty } init(http: Http, settings: Settings, dir: URL) { self.http = http @@ -53,6 +77,49 @@ final class StemServer: @unchecked Sendable { 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: "/")) } @@ -164,6 +231,8 @@ final class StemServer: @unchecked Sendable { 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)") } + await MainActor.run { paths.forEach { self.pending.insert($0) } } + Task.detached { [self] in await pollQueue() } return paths.count } diff --git a/ios/Stemify/UI/Components/MediaMenu.swift b/ios/Stemify/UI/Components/MediaMenu.swift index 8c4e248..eb84af7 100644 --- a/ios/Stemify/UI/Components/MediaMenu.swift +++ b/ios/Stemify/UI/Components/MediaMenu.swift @@ -15,7 +15,14 @@ struct MediaMenu { 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]) }) - 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 { 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))) @@ -68,6 +75,17 @@ struct MediaMenu { { pl.tracks }, confirm: false)) if pl.items.isEmpty { a = [] } let lists = g.stemPlaylists + if !pl.items.isEmpty { + a.append(SheetAction("Change Cover…", icon: "photo") { + var seen = Set() + 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) { overlays.confirm("Delete “\(pl.title)”?", "Stem playlists are only on this device.", "Delete") { lists.delete(pl.id); if onPage { ui.pop() } } }) diff --git a/ios/Stemify/UI/Components/Rows.swift b/ios/Stemify/UI/Components/Rows.swift index 67ba693..7c1ebbf 100644 --- a/ios/Stemify/UI/Components/Rows.swift +++ b/ios/Stemify/UI/Components/Rows.swift @@ -56,7 +56,14 @@ struct TrackRow: View { } .padding(.vertical, 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) } else if downloaded { DownloadedMark().padding(.trailing, 4) } if let menu { MoreMenu(items: menu).foregroundStyle(p.label) } diff --git a/ios/Stemify/UI/Nav.swift b/ios/Stemify/UI/Nav.swift index 2981ca5..92ec2d6 100644 --- a/ios/Stemify/UI/Nav.swift +++ b/ios/Stemify/UI/Nav.swift @@ -36,9 +36,9 @@ enum Tab: String, CaseIterable, 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 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) } diff --git a/ios/Stemify/UI/RootView.swift b/ios/Stemify/UI/RootView.swift index fb9435c..4da699e 100644 --- a/ios/Stemify/UI/RootView.swift +++ b/ios/Stemify/UI/RootView.swift @@ -61,6 +61,7 @@ struct RootView: View { case .Playlists: PlaylistsScreen() case .Genres: GenresScreen() case .Downloads: DownloadsScreen() + case .DownloadQueue: DownloadQueueScreen() case .TabBar: TabBarScreen() case .WebRadio: WebRadioScreen() case .GenrePage(let g): GenreScreen(genre: g) @@ -68,6 +69,7 @@ struct RootView: View { case .EqFilters(let id): EqFiltersScreen(profileId: id) case .EqFilterPage(let id, let f): EqFilterPage(profileId: id, filterId: f) case .Servers: ServersScreen() + case .ServerList: ServerListScreen() case .Server(let id): ServerScreen(id: id) case .StemServer: StemServerScreen() case .Quality: QualityScreen() diff --git a/ios/Stemify/UI/Screens/LibraryScreens.swift b/ios/Stemify/UI/Screens/LibraryScreens.swift index 3651281..af910f4 100644 --- a/ios/Stemify/UI/Screens/LibraryScreens.swift +++ b/ios/Stemify/UI/Screens/LibraryScreens.swift @@ -9,8 +9,25 @@ struct LibraryHome: View { @State private var played: [Album] = [] @State private var added: [Track] = [] @State private var reload = 0 + @State private var editing = false 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(_ 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 { let s = g.settings let ready = s.libraryReady @@ -63,15 +80,18 @@ struct LibraryHome: View { if ready { SectionTitle(title: "Stations") 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", { + // Whole albums in random order, across the entire library. 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 }), ] - ForEach(stations, id: \.1) { st in - LibraryRow(icon: st.0, title: st.1, last: st.1 == stations.last?.1) { play(st.2) } + let shown = ordered(stations) { $0.1 } + 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). @@ -81,8 +101,17 @@ struct LibraryHome: View { ("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), ] - ForEach(rows, id: \.1) { r in - LibraryRow(icon: r.0, title: r.1, last: r.1 == rows.last?.1) { ui.push(r.2) } + let shownRows = ordered(rows) { $0.1 } + 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)") { @@ -92,6 +121,7 @@ struct LibraryHome: View { await LoadState.load($albums) { try await g.plex.albums() } played = Array((await rp ?? []).prefix(24)) 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 title: String let last: Bool + var editing = false + var move: (Int) -> Void = { _ in } let action: () -> Void @Environment(\.palette) private var p var body: some View { - Button(action: action) { + Button(action: { if !editing { action() } }) { HStack(spacing: 14) { Image(systemName: icon).font(.system(size: 22)).foregroundStyle(p.accent).frame(width: 26) Text(title).font(.title3).foregroundStyle(p.label) 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) .contentShape(Rectangle()) @@ -472,7 +512,7 @@ struct SongsScreen: View { /// Shuffles the whole library, not just the pages loaded so far. private func shuffleAll() { 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)") } } } @@ -700,7 +740,7 @@ struct DownloadsScreen: View { 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) } + } + } +} diff --git a/ios/Stemify/UI/Screens/SettingsScreens.swift b/ios/Stemify/UI/Screens/SettingsScreens.swift index b4a4f12..7a2e5fd 100644 --- a/ios/Stemify/UI/Screens/SettingsScreens.swift +++ b/ios/Stemify/UI/Screens/SettingsScreens.swift @@ -403,9 +403,8 @@ struct BackupCells: View { /// 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." -struct ServersScreen: View { +struct ServerListScreen: View { @Environment(\.palette) private var p - @State private var libs: [String: [MusicSection]] = [:] private var g: AppGraph { .shared } 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"), 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? + 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.") } ForEach(list.filter(\.signedIn)) { s in @@ -433,20 +474,64 @@ struct ServersScreen: View { } } 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) .themedList() - .navigationTitle("Music Servers") + .navigationTitle("Plex Account") .navigationBarTitleDisplayMode(.inline) .task { for s in list where s.signedIn && libs[s.id] == nil { 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. @@ -698,6 +783,7 @@ struct StemServerScreen: View { case .unknown: "—" } }()).themedRow() + ValueCell(title: "stemd", value: g.stems.liveLine ?? (g.stems.failed.isEmpty ? "Idle" : "\(g.stems.failed.count) failed")).themedRow() ValueCell(title: "Opus Transcoding", value: { if case .online(_, let t) = status { return t ? "Available" : "No ffmpeg" } return "—" diff --git a/server/docker-compose.yml b/server/compose.yaml similarity index 100% rename from server/docker-compose.yml rename to server/compose.yaml