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:
1 parent
48faa6682f
commit
7cdfbef160
16 files changed
+2006
-730
No files matched your search
+51
-4
@@ -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()
|
||||
|
||||
+34
-1
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user