Web player overhaul; iOS random sort, stems-only filter, pooled libraries

Web: live Stem Now status that tracks every queued song (albums too),
right-click menus with Stem Now, Songs and Genres pages, a Stems Only
filter in place of the sidebar section, Random sort, several pooled
Plex servers and libraries, themes plus custom CSS, a reorganised
settings page that loads without a refresh, saved mixes, karaoke and
synced lyrics, stem playlists and offline downloads.

iOS: Random in every sort menu, a Stems Only toggle (sort menu and
Settings), and several music libraries pooled from the signed-in server.
Settings now persists karaoke and speed across launches.

stemd writes a status file that stemserve serves at /api/queue.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-20 18:29:45 -07:00
1 parent 48faa6682f
commit 7cdfbef160
16 files changed
+2006 -730

No files matched your search

+51 -4
View File
@@ -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()