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

+34 -1
View File
@@ -15,6 +15,8 @@ Endpoints (all GET unless noted; add ?token=... or X-Stem-Token when TOKEN is se
/api/peaks?key=<key>&n=<buckets> per-stem RMS envelopes, 0..1000
POST /api/prepare?key=<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/<key>/<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)