diff --git a/ios/Stemify/Core/Settings.swift b/ios/Stemify/Core/Settings.swift index 8c2d91a..5d56f21 100644 --- a/ios/Stemify/Core/Settings.swift +++ b/ios/Stemify/Core/Settings.swift @@ -103,6 +103,7 @@ final class Settings: @unchecked Sendable { "plexUrl", "plexToken", "plexUser", "plexMachineId", "musicSection", "musicSectionTitle", "stemUrl", "stemToken", "stemRoots", "theme", "accent", "albumColorNowPlaying", "highRefreshRate", "stemQuality", "cacheLimitGb", "downloadsWifiOnly", "downloadSort", "keepStemMix", "masterVolume", "tabLayout", + "karaokeVocals", "karaokeSoftDb", "playbackSpeed", "stemsOnly", ] private func read(_ kp: KeyPath, _ key: String, _ def: T) -> T { @@ -189,6 +190,8 @@ final class Settings: @unchecked Sendable { 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. var karaokeSoftDb: Int { get { read(\.karaokeSoftDb, "karaokeSoftDb", -12) } set { write(\.karaokeSoftDb, "karaokeSoftDb", newValue, -12) } } + /// Library lists show only what has stems. + var stemsOnly: Bool { get { read(\.stemsOnly, "stemsOnly", false) } set { write(\.stemsOnly, "stemsOnly", newValue, false) } } var keepStemMix: Bool { get { read(\.keepStemMix, "keepStemMix", true) } set { write(\.keepStemMix, "keepStemMix", newValue, true) } } var playbackSpeed: Double { get { access(keyPath: \.playbackSpeed); lock.lock(); defer { lock.unlock() }; return (store["playbackSpeed"] as? NSNumber)?.doubleValue ?? 1 } diff --git a/ios/Stemify/Plex/PlexClient.swift b/ios/Stemify/Plex/PlexClient.swift index d002152..b4ddccb 100644 --- a/ios/Stemify/Plex/PlexClient.swift +++ b/ios/Stemify/Plex/PlexClient.swift @@ -36,7 +36,8 @@ final class PlexClient: @unchecked Sendable { var signedIn: Bool { !settings.plexToken.isEmpty } private var base: String { settings.plexUrl.trimmingCharacters(in: CharacterSet(charactersIn: "/")) } private var token: String { settings.plexToken } - private var section: String { settings.musicSection } + /// The chosen music libraries; listings pool them, in the order they were picked. + private var sections: [String] { settings.musicSection.split(separator: ",").map(String.init) } private func request(_ url: URL, withToken: Bool = true, method: String = "GET") -> URLRequest { var r = URLRequest(url: url) @@ -97,6 +98,33 @@ final class PlexClient: @unchecked Sendable { return out } + /// Every section's listing, one after another. + private func pooled(_ params: [(String, Any?)]) async throws -> [PlexItem] { + var out: [PlexItem] = [] + for sec in sections { out += try await all("/library/sections/\(sec)/all", params) } + return out + } + + /// A page of a listing that runs on through each section in turn. + private func pooledPage(_ params: [(String, Any?)], start: Int, size: Int) async throws -> ([PlexItem], Int) { + if sections.count == 1 { + let c = try await get("/library/sections/\(sections[0])/all", params, start: start, size: size) + return (c.metadata, c.totalSize ?? c.size) + } + var out: [PlexItem] = [] + var offset = 0, total = 0 + for sec in sections { + let n = try await get("/library/sections/\(sec)/all", params, start: 0, size: 0) + let count = n.totalSize ?? n.size + let from = max(start + out.count - offset, 0) + if out.count < size, from < count { + out += try await get("/library/sections/\(sec)/all", params, start: from, size: size - out.count).metadata + } + offset += count; total += count + } + return (out, total) + } + // ---- server ------------------------------------------------------------------- func machineId() async throws -> String { @@ -114,27 +142,36 @@ final class PlexClient: @unchecked Sendable { // ---- library ------------------------------------------------------------------ func artists(sort: String = "titleSort") async throws -> [Artist] { - try await all("/library/sections/\(section)/all", [("type", 8), ("sort", sort)]).map { $0.toArtist() } + try await pooled([("type", 8), ("sort", sort)]).map { $0.toArtist() } } func albums(sort: String = "titleSort") async throws -> [Album] { - try await all("/library/sections/\(section)/all", [("type", 9), ("sort", sort)]).map { $0.toAlbum() } + try await pooled([("type", 9), ("sort", sort)]).map { $0.toAlbum() } } func recentlyAdded(count: Int = 20) async throws -> [Album] { - try await get("/library/sections/\(section)/all", [("type", 9), ("sort", "addedAt:desc")], start: 0, size: count) - .metadata.map { $0.toAlbum() } + var out: [Album] = [] + for sec in sections { + out += try await get("/library/sections/\(sec)/all", [("type", 9), ("sort", "addedAt:desc")], start: 0, size: count) + .metadata.map { $0.toAlbum() } + } + return out } /// One page of every track. Returns the page and the library's track count. func tracksPage(start: Int, size: Int, sort: String = "titleSort") async throws -> ([Track], Int) { - let c = try await get("/library/sections/\(section)/all", [("type", 10), ("sort", sort)], start: start, size: size) - return (c.metadata.map { $0.toTrack() }, c.totalSize ?? c.size) + let (page, total) = try await pooledPage([("type", 10), ("sort", sort)], start: start, size: size) + return (page.map { $0.toTrack() }, total) } /// Random tracks from the whole library, for shuffling it without loading every page. func randomTracks(_ count: Int) async throws -> [Track] { - try await get("/library/sections/\(section)/all", [("type", 10), ("sort", "random")], start: 0, size: count).metadata.map { $0.toTrack() } + var out: [Track] = [] + for sec in sections { + out += try await get("/library/sections/\(sec)/all", [("type", 10), ("sort", "random")], start: 0, size: count / max(sections.count, 1) + 1) + .metadata.map { $0.toTrack() } + } + return Array(out.shuffled().prefix(count)) } func album(_ key: String) async throws -> Album? { @@ -148,7 +185,7 @@ final class PlexClient: @unchecked Sendable { // Not /library/metadata//children: that leaves out compilations, singles and live // albums (an artist with only "The Best of Kansas" came back empty). func artistAlbums(_ key: String) async throws -> [Album] { - try await all("/library/sections/\(section)/all", [("type", 9), ("artist.id", key)]).map { $0.toAlbum() } + try await pooled([("type", 9), ("artist.id", key)]).map { $0.toAlbum() } } func artistTracks(_ key: String) async throws -> [Track] { @@ -161,7 +198,9 @@ final class PlexClient: @unchecked Sendable { /// Title search inside the music section; used to give stem-only entries Plex metadata. func searchTracks(_ title: String) async throws -> [Track] { - try await get("/library/sections/\(section)/search", [("type", 10), ("query", title)]).metadata.map { $0.toTrack() } + var out: [Track] = [] + for sec in sections { out += try await get("/library/sections/\(sec)/search", [("type", 10), ("query", title)]).metadata.map { $0.toTrack() } } + return out } /// Plex's own loudness analysis (what Plexamp draws its waveform from), in dB. @@ -178,7 +217,7 @@ final class PlexClient: @unchecked Sendable { } func search(_ query: String) async throws -> SearchResults { - let hubs = try await get("/hubs/search", [("query", query), ("limit", 12), ("sectionId", section)]).hubs + let hubs = try await get("/hubs/search", [("query", query), ("limit", 12), ("sectionId", sections.count == 1 ? sections[0] : nil)]).hubs func hub(_ type: String) -> [PlexItem] { hubs.first { $0.type == type }?.metadata ?? [] } return SearchResults( artists: hub("artist").map { $0.toArtist() }, diff --git a/ios/Stemify/UI/Screens/LibraryScreens.swift b/ios/Stemify/UI/Screens/LibraryScreens.swift index 9f8630f..5c7b3a2 100644 --- a/ios/Stemify/UI/Screens/LibraryScreens.swift +++ b/ios/Stemify/UI/Screens/LibraryScreens.swift @@ -121,9 +121,12 @@ struct SortOption { var up = "Ascending" var down = "Descending" + /// Random has no direction; picking it again reshuffles. + var random: Bool { key == "random" } + /// Plex reverses a compound key by its first field. func param(_ reversed: Bool) -> String { - guard desc != reversed else { return key } + guard !random, desc != reversed else { return key } var fields = key.split(separator: ",").map(String.init) fields[0] += ":desc" return fields.joined(separator: ",") @@ -133,6 +136,7 @@ struct SortOption { private let AZ = (up: "A to Z", down: "Z to A") private let NEW = (up: "Oldest First", down: "Newest First") private let MOST = (up: "Fewest First", down: "Most First") +private let RANDOM = SortOption(title: "Random", key: "random", up: "Shuffled", down: "Shuffled") let ARTIST_SORTS = [ SortOption(title: "Name", key: "titleSort", up: AZ.up, down: AZ.down), @@ -140,6 +144,7 @@ let ARTIST_SORTS = [ SortOption(title: "Last Played", key: "lastViewedAt", desc: true, up: NEW.up, down: NEW.down), SortOption(title: "Plays", key: "viewCount", desc: true, up: MOST.up, down: MOST.down), SortOption(title: "Rating", key: "userRating", desc: true, up: "Lowest First", down: "Highest First"), + RANDOM, ] let ALBUM_SORTS = [ @@ -151,6 +156,7 @@ let ALBUM_SORTS = [ SortOption(title: "Last Played", key: "lastViewedAt", desc: true, up: NEW.up, down: NEW.down), SortOption(title: "Plays", key: "viewCount", desc: true, up: MOST.up, down: MOST.down), SortOption(title: "Rating", key: "userRating", desc: true, up: "Lowest First", down: "Highest First"), + RANDOM, ] let SONG_SORTS = [ @@ -162,6 +168,7 @@ let SONG_SORTS = [ SortOption(title: "Plays", key: "viewCount", desc: true, up: MOST.up, down: MOST.down), SortOption(title: "Rating", key: "userRating", desc: true, up: "Lowest First", down: "Highest First"), SortOption(title: "Duration", key: "duration", desc: true, up: "Shortest First", down: "Longest First"), + RANDOM, ] let PLAYLIST_SORTS = [ @@ -171,6 +178,7 @@ let PLAYLIST_SORTS = [ SortOption(title: "Last Played", key: "lastViewedAt", desc: true, up: NEW.up, down: NEW.down), SortOption(title: "Plays", key: "viewCount", desc: true, up: MOST.up, down: MOST.down), SortOption(title: "Duration", key: "duration", desc: true, up: "Shortest First", down: "Longest First"), + RANDOM, ] /// A screen's sort choice, kept across launches under `sort.`. @@ -188,6 +196,8 @@ struct SortState: DynamicProperty { var current: Int { options.indices.contains(index) ? index : 0 } var option: SortOption { options[current] } var param: String { option.param(reversed) } + /// Changes whenever the list should reload, including a reshuffle of Random. + var token: String { "\(param)|\(reversed)" } /// The default order (first option, its own direction), where a letter index makes sense. var isDefault: Bool { current == 0 && !reversed } @@ -216,13 +226,16 @@ struct SortMenu: View { } } } - Section { + if !sort.option.random { Section { Button { sort.setDescending(false) } label: { if sort.descending { Text(sort.option.up) } else { Label(sort.option.up, systemImage: "checkmark") } } Button { sort.setDescending(true) } label: { if sort.descending { Label(sort.option.down, systemImage: "checkmark") } else { Text(sort.option.down) } } + } } + Section { + Toggle("Stems Only", systemImage: "waveform", isOn: Bindable(AppGraph.shared.settings).stemsOnly) } } label: { Image(systemName: "arrow.up.arrow.down") @@ -230,6 +243,21 @@ struct SortMenu: View { } } +/// The Stems Only filter. It only applies once there's a stems index (and, for albums and +/// artists, once the library has been matched against it), so nothing vanishes while loading. +@MainActor +enum StemsOnly { + private static var g: AppGraph { .shared } + static var on: Bool { g.settings.stemsOnly && g.stems.index.size > 0 } + static func tracks(_ list: [Track]) -> [Track] { on ? list.filter { g.stems.find($0) != nil } : list } + static func albums(_ list: [Album]) -> [Album] { + on && g.coverage.ready ? list.filter { (g.coverage.albums[$0.ratingKey]?.withStems ?? 0) > 0 } : list + } + static func artists(_ list: [Artist]) -> [Artist] { + on && g.coverage.ready ? list.filter { (g.coverage.artists[$0.ratingKey]?.withStems ?? 0) > 0 } : list + } +} + // ---- artists --------------------------------------------------------------------------------- struct ArtistsScreen: View { @@ -243,7 +271,8 @@ struct ArtistsScreen: View { ScrollViewReader { proxy in ScrollView { LazyVStack(alignment: .leading, spacing: 0) { - Loaded(state: artists, retry: { reload += 1 }) { list in + Loaded(state: artists, retry: { reload += 1 }) { all in + let list = StemsOnly.artists(all) ForEach(list) { a in Button { ui.push(.ArtistPage(a)) } label: { ArtistRow(artist: a).contentShape(Rectangle()) } .buttonStyle(.plain) @@ -257,14 +286,14 @@ struct ArtistsScreen: View { } .background(p.bg) .overlay(alignment: .trailing) { - if sort.isDefault, let list = artists.value, list.count > 30 { + if sort.isDefault, let list = artists.value.map({ StemsOnly.artists($0) }), list.count > 30 { SectionIndex(titles: list.map(\.sortTitle)) { i in proxy.scrollTo(list[i].ratingKey, anchor: .top) } } } } .navigationTitle("Artists") .toolbar { ToolbarItem(placement: .topBarTrailing) { SortMenu(sort: sort) } } - .task(id: "\(AppGraph.shared.settings.musicSection)|\(sort.param)|\(reload)") { + .task(id: "\(AppGraph.shared.settings.musicSection)|\(sort.token)|\(reload)") { await LoadState.load($artists) { try await AppGraph.shared.plex.artists(sort: sort.param) } } } @@ -327,13 +356,13 @@ struct AlbumsScreen: View { var body: some View { Page(title: "Albums") { Loaded(state: albums, retry: { reload += 1 }) { list in - AlbumGrid(albums: list, menu: { ui.menu.album($0) }, open: { ui.push(.AlbumPage($0)) }).padding(.top, 8) + AlbumGrid(albums: StemsOnly.albums(list), menu: { ui.menu.album($0) }, open: { ui.push(.AlbumPage($0)) }).padding(.top, 8) } } .toolbar { ToolbarItem(placement: .topBarTrailing) { SortMenu(sort: sort) } } - .task(id: "\(AppGraph.shared.settings.musicSection)|\(sort.param)|\(reload)") { + .task(id: "\(AppGraph.shared.settings.musicSection)|\(sort.token)|\(reload)") { await LoadState.load($albums) { try await AppGraph.shared.plex.albums(sort: sort.param) } } } @@ -350,6 +379,8 @@ struct SongsScreen: View { @State private var error: String? /// Bumped when the sort changes, so a page still loading for the old order is dropped. @State private var gen = 0 + /// How far into the unfiltered listing we've read; Stems Only drops rows as they arrive. + @State private var offset = 0 private let sort = SortState("songs", SONG_SORTS) private var g: AppGraph { .shared } @@ -364,7 +395,7 @@ struct SongsScreen: View { var body: some View { Page(title: "Songs") { if !tracks.isEmpty { - if total > 0 { + if total > 0, !StemsOnly.on, !sort.option.random { Text(songs(total)).font(.subheadline).foregroundStyle(p.label2).padding(.leading, MARGIN).padding(.bottom, 12) } PlayShuffle(play: { g.playback.playTracks(Array(tracks.prefix(1000))) }, @@ -384,21 +415,31 @@ struct SongsScreen: View { } .toolbar { ToolbarItem(placement: .topBarTrailing) { SortMenu(sort: sort) } } .task { if tracks.isEmpty { loadMore() } } - .onChange(of: sort.param) { - gen += 1; tracks = []; total = -1; error = nil; loading = false + .onChange(of: "\(sort.token)|\(StemsOnly.on)|\(g.settings.musicSection)") { + gen += 1; tracks = []; total = -1; offset = 0; error = nil; loading = false loadMore() } } private func loadMore() { - if loading || (total >= 0 && tracks.count >= total) { return } + if loading || (total >= 0 && offset >= total) { return } loading = true - let mine = gen, order = sort.param + let mine = gen, order = sort.param, random = sort.option.random Task { do { - let (page, n) = try await g.plex.tracksPage(start: tracks.count, size: 200, sort: order) + // Random pages would repeat songs, so it's one shuffled batch. + let page: [Track], n: Int + if random { + page = try await g.plex.randomTracks(1000); n = page.count + } else { + let r = try await g.plex.tracksPage(start: offset, size: 200, sort: order) + page = r.0; n = r.1 + } guard mine == gen else { return } - tracks += page; total = n; error = nil + let kept = StemsOnly.tracks(page) + tracks += kept; offset += page.count; total = n; error = nil + // A page that the filter mostly emptied leaves nothing on screen to trigger the next one. + if kept.count < 40, !page.isEmpty, offset < n { loading = false; loadMore(); return } } catch { guard mine == gen else { return } self.error = error.localizedDescription @@ -687,7 +728,7 @@ struct GenreScreen: View { var body: some View { Page(title: genre) { Loaded(state: albums, retry: { reload += 1 }) { list in - AlbumGrid(albums: list.filter { $0.genre == genre }, menu: { ui.menu.album($0) }, open: { ui.push(.AlbumPage($0)) }).padding(.top, 8) + AlbumGrid(albums: StemsOnly.albums(list.filter { $0.genre == genre }), menu: { ui.menu.album($0) }, open: { ui.push(.AlbumPage($0)) }).padding(.top, 8) } } .task(id: "\(AppGraph.shared.settings.musicSection)|\(reload)") { diff --git a/ios/Stemify/UI/Screens/PlaylistScreens.swift b/ios/Stemify/UI/Screens/PlaylistScreens.swift index 9fee23d..686f21b 100644 --- a/ios/Stemify/UI/Screens/PlaylistScreens.swift +++ b/ios/Stemify/UI/Screens/PlaylistScreens.swift @@ -47,8 +47,10 @@ struct PlaylistsScreen: View { Button { kind == .Plex ? newPlaylist() : newStemPlaylist() } label: { Image(systemName: "plus") } } } - .task(id: "\(g.settings.musicSection)|\(sort.param)|\(reload)") { - await LoadState.load($lists) { try await g.plex.playlists(sort: sort.param) } + .task(id: "\(g.settings.musicSection)|\(sort.token)|\(reload)") { + // /playlists may not take sort=random, so Random shuffles here. + let random = sort.option.random + await LoadState.load($lists) { try await random ? g.plex.playlists().shuffled() : g.plex.playlists(sort: sort.param) } } } diff --git a/ios/Stemify/UI/Screens/SettingsScreens.swift b/ios/Stemify/UI/Screens/SettingsScreens.swift index 60a9113..b65a8ee 100644 --- a/ios/Stemify/UI/Screens/SettingsScreens.swift +++ b/ios/Stemify/UI/Screens/SettingsScreens.swift @@ -160,6 +160,7 @@ struct SettingsScreen: View { } Section { NavCell(title: "Appearance", value: s.theme.label, icon: "paintbrush.fill", iconBg: 0x007AFF, route: .Appearance) + ToggleCell(title: "Only Show Songs With Stems", icon: "waveform", iconBg: 0xAF52DE, isOn: $s.stemsOnly) } header: { Header(text: "Display") } footer: { Footer(text: "Stemify \(Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.1") · stems by stemd on your server · \(UIScreen.main.maximumFramesPerSecond) Hz") } @@ -270,8 +271,11 @@ struct PlexAccountScreen: View { if list.isEmpty { Text("No music libraries found").foregroundStyle(p.label).themedRow() } ForEach(list) { sec in Button { - s.musicSection = sec.key - s.musicSectionTitle = sec.title + // Tapping adds or drops a library; the picked ones are pooled. One always stays. + var keys = s.musicSection.split(separator: ",").map(String.init) + if let i = keys.firstIndex(of: sec.key) { if keys.count > 1 { keys.remove(at: i) } } else { keys.append(sec.key) } + s.musicSection = keys.joined(separator: ",") + s.musicSectionTitle = keys.compactMap { k in list.first { $0.key == k }?.title }.joined(separator: " + ") } label: { HStack { VStack(alignment: .leading, spacing: 2) { @@ -281,7 +285,7 @@ struct PlexAccountScreen: View { } } Spacer() - if sec.key == s.musicSection { Image(systemName: "checkmark").font(.body.weight(.semibold)).foregroundStyle(p.accent) } + if s.musicSection.split(separator: ",").contains(Substring(sec.key)) { Image(systemName: "checkmark").font(.body.weight(.semibold)).foregroundStyle(p.accent) } } .contentShape(Rectangle()) } @@ -291,7 +295,7 @@ struct PlexAccountScreen: View { } else { ProgressView().frame(maxWidth: .infinity).themedRow() } - } header: { Header(text: "Music Library") } + } header: { Header(text: "Music Libraries") } footer: { Footer(text: "Pick more than one to pool them into one library.") } Section { ActionCell(title: "Sign Out", color: p.red, centered: true) { ui.overlays.confirm("Sign Out of Plex?", "Downloads stay on this phone.", "Sign Out") { diff --git a/server/stemd.py b/server/stemd.py index 0d5164f..2bb2b96 100644 --- a/server/stemd.py +++ b/server/stemd.py @@ -235,6 +235,43 @@ def resolve(plex_path): return path if os.path.splitext(path)[1].lower() in SOURCE_RANK else None +class Status: + """What stemd is up to, for stemserve's /api/queue: the track in progress (the Plex path for + a Stem Now, else the path under MUSIC_DIR), how many tracks were finished since start, and the + last Stem Now paths that couldn't be done. Kept in QUEUE_DIR/.status.json, which the "*.json" + request glob skips because it starts with a dot.""" + + def __init__(self): + self.path = os.path.join(QUEUE_DIR, ".status.json") + self.current, self.done, self.failed = None, 0, [] + self.write() + + def start(self, current): + self.current = current + self.write() + + def finish(self, ok, failed_path=None): + self.current = None + if ok: + self.done += 1 + elif failed_path: + self.failed = [p for p in self.failed if p != failed_path][-49:] + [failed_path] + self.write() + + def write(self): + tmp = self.path + ".tmp" + try: + with open(tmp, "w") as f: + json.dump({"current": self.current, "done": self.done, "failed": self.failed, + "at": time.time()}, f) + os.replace(tmp, self.path) + except OSError: + pass + + +status = None # set in main() + + def serve_queue(sep, failed): """Separate every Stem Now request, oldest first. Returns whether any track was attempted. @@ -257,6 +294,8 @@ def serve_queue(sep, failed): src = resolve(p) if isinstance(p, str) else None if src is None: log(f"stem now: not in {MUSIC_DIR}: {p}") + if isinstance(p, str): + status.finish(False, p) continue dest = os.path.join(STEMS_DIR, os.path.splitext(os.path.relpath(src, MUSIC_DIR))[0]) if is_done(dest): @@ -267,17 +306,20 @@ def serve_queue(sep, failed): continue if failed.skip(src, sig): log(f"stem now: skip {os.path.relpath(src, MUSIC_DIR)}: failed before ({failed.data[src]['msg']})") + status.finish(False, p) continue - todo.append((src, dest, sig)) + todo.append((p, src, dest, sig)) if todo: log(f"stem now: {len(todo)} track{'' if len(todo) == 1 else 's'} ahead of the rest") - for i, (src, dest, sig) in enumerate(todo, 1): + for i, (p, src, dest, sig) in enumerate(todo, 1): if stop.is_set(): return used if is_done(dest): continue used = True - separate(sep, failed, src, dest, sig, f"[now {i}/{len(todo)}]") + status.start(p) + ok = separate(sep, failed, src, dest, sig, f"[now {i}/{len(todo)}]") + status.finish(ok, None if ok else p) if stop.is_set(): break try: @@ -316,7 +358,10 @@ def run_pass(sep, failed): if is_done(dest): # a full pass takes days; the desktop or a Stem Now may have got there first continue used = True - if separate(sep, failed, path, dest, sig, f"[{i}/{len(todo)}]"): + status.start(os.path.relpath(path, MUSIC_DIR)) + ok = separate(sep, failed, path, dest, sig, f"[{i}/{len(todo)}]") + status.finish(ok) + if ok: done += 1 return done, used @@ -347,6 +392,8 @@ def main(): os.makedirs(QUEUE_DIR, exist_ok=True) except OSError as e: log(f"WARNING: can't create {QUEUE_DIR} ({e}); Stem Now won't work") + global status + status = Status() log(f"stemd: {MUSIC_DIR} -> {STEMS_DIR}") migrate_markers() sep = Separator() diff --git a/server/stemserve.py b/server/stemserve.py index 95bade4..fde3ea1 100644 --- a/server/stemserve.py +++ b/server/stemserve.py @@ -15,6 +15,8 @@ Endpoints (all GET unless noted; add ?token=... or X-Stem-Token when TOKEN is se /api/peaks?key=&n= per-stem RMS envelopes, 0..1000 POST /api/prepare?key=&fmt=opus&br=160 start transcodes in the background POST /api/stem {"paths": [...]} "Stem Now": stemd separates these Plex files next + /api/queue Stem Now progress: pending paths, the track in progress, + tracks done since stemd started, recent failures /file//[?fmt=opus&br=160] the stem itself, with Range support /plex/... proxy to PLEX_URL (needs TOKEN or the Plex token) /api/config Plex URL and token from PLEX_URL / PLEX_TOKEN, if set @@ -24,6 +26,7 @@ Endpoints (all GET unless noted; add ?token=... or X-Stem-Token when TOKEN is se """ import email.utils +import glob import hashlib import json import operator @@ -106,9 +109,10 @@ class Index: return self._count def refresh(self): + """Rescans STEMS_DIR. Returns False if another scan was already running.""" # One scan at a time; callers that lose the race use the previous result. if not self._scanning.acquire(blocking=self._at == 0): - return + return False try: tracks = [] for root, dirs, files in os.walk(STEMS_DIR): @@ -128,6 +132,7 @@ class Index: self._at = time.monotonic() finally: self._scanning.release() + return True def _read_marker(folder, files): @@ -354,6 +359,8 @@ class Handler(BaseHTTPRequestHandler): return self._prepare(query) if path == "/api/stem": return self._stem_now() + if path == "/api/queue": + return self._queue() if path.startswith("/file/"): return self._file(path[len("/file/"):], query) return self._error(404, "not found") @@ -422,6 +429,32 @@ class Handler(BaseHTTPRequestHandler): log(f"stem now: {len(paths)} track{'' if len(paths) == 1 else 's'} queued") self._send(202, json.dumps({"queued": len(paths)}).encode()) + _seen_done = None + + def _queue(self): + """What stemd has left to do, from its request files and the status file it keeps.""" + pending = [] + for req in sorted(glob.glob(os.path.join(QUEUE_DIR, "*.json"))): + try: + with open(req, encoding="utf-8") as f: + paths = json.load(f).get("paths") + except (OSError, ValueError, AttributeError): + continue + if isinstance(paths, list): + pending += [p for p in paths if isinstance(p, str)] + try: + with open(os.path.join(QUEUE_DIR, ".status.json"), encoding="utf-8") as f: + st = json.load(f) + except (OSError, ValueError): + st = {} + done = st.get("done", 0) + # A track just finished: rescan now so the index the client fetches next includes it. + if done != Handler._seen_done and INDEX.refresh(): + Handler._seen_done = done + self._send(200, json.dumps({"pending": pending, "current": st.get("current"), "done": done, + "failed": st.get("failed", []), "at": st.get("at")}).encode(), + headers={"Cache-Control": "no-store"}) + def _file(self, rel, query): key, _, fname = rel.rpartition("/") src = _resolve(key, fname) diff --git a/web/app.js b/web/app.js index d0ff95c..f5a2fac 100644 --- a/web/app.js +++ b/web/app.js @@ -1,449 +1,102 @@ -// Stemify web player. Browses Plex, plays stems from stemserve, no build step. -// Each stem is its own