Web UI: fix Plex status text, PLEX_URL/PLEX_TOKEN env, local stem server auto-detect, WEB=0

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
cdnutterandClaude Sonnet 5.5 committed 2026-10-20 13:00:34 -07:00
1 parent 2a4e848788
commit 4b53a720c4
5 files changed
+47 -2

No files matched your search

+9
View File
@@ -30,6 +30,12 @@ The stems folder mirrors the music folder. If a song lives at `Kansas/Leftovertu
Plex Media Server :32400 ───────────────────────────────▶ library, artwork, playlists
```
Three parts, and only stemd is required:
- **stemd** is the daemon. It does the actual separating and nothing else.
- **Stemify web UI** is optional, for desktop users. stemserve serves it at `/`. It plays the stems streamed from the server, and picks up a local stem server on `localhost:8765` by itself.
- **iOS and Android apps** are optional and built from this repo too.
- **stemd** works through the library on the GPU, one song at a time, and rescans every 30 minutes for new music.
- **stemserve** serves the finished stems over HTTP with range requests, waveforms and optional Opus transcodes.
- **Plex** handles the library, metadata, artwork and playlists.
@@ -132,6 +138,9 @@ To update, copy the new files, rebuild, `docker rm -f` the container and run it
| `PORT` | `8765` | Listen port |
| `TOKEN` | empty | If set, requests need `X-Stem-Token` or `?token=` |
| `INDEX_TTL` | `30` | Seconds between rescans of the stems folder |
| `PLEX_URL` | empty | Plex address. With `PLEX_TOKEN`, the web UI connects to Plex without signing in |
| `PLEX_TOKEN` | empty | Plex token. Only handed out to clients that pass `TOKEN`, so set `TOKEN` too |
| `WEB` | `1` | Set to `0` to turn off the web UI and serve the API only |
</details>
+3
View File
@@ -43,6 +43,9 @@ curl http://192.168.86.2:8765/api/info
| `TOKEN` | empty | If set, requests need `X-Stem-Token` or `?token=` |
| `INDEX_TTL` | `30` | Seconds between rescans of the stems folder |
| `QUEUE_DIR` | `/queue` | stemd's Stem Now folder (`stemd/state/queue` on unraid) |
| `PLEX_URL` | empty | Plex address, passed to the web UI via `/api/config` |
| `PLEX_TOKEN`| empty | Plex token for the web UI (set `TOKEN` too) |
| `WEB` | `1` | `0` turns off the web UI |
## API
+2
View File
@@ -14,3 +14,5 @@ services:
environment:
# Optional shared secret; set the same token in the app (Settings > Stem Server).
- TOKEN=
- PLEX_URL=
- PLEX_TOKEN=
+9
View File
@@ -16,6 +16,7 @@ Endpoints (all GET unless noted; add ?token=... or X-Stem-Token when TOKEN is se
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
/file/<key>/<file>[?fmt=opus&br=160] the stem itself, with Range support
/api/config Plex URL and token from PLEX_URL / PLEX_TOKEN, if set
/ the web player (web/, or WEB_DIR); no token needed
<key> is the track's folder relative to STEMS_DIR, e.g. "Artist/Album/01 Song".
@@ -44,6 +45,9 @@ PORT = int(os.environ.get("PORT", "8765"))
TOKEN = os.environ.get("TOKEN", "")
INDEX_TTL = float(os.environ.get("INDEX_TTL", "30"))
QUEUE_DIR = os.environ.get("QUEUE_DIR", "/queue") # stemd's request folder, shared with it
PLEX_URL = os.environ.get("PLEX_URL", "").rstrip("/")
PLEX_TOKEN = os.environ.get("PLEX_TOKEN", "")
SERVE_WEB = os.environ.get("WEB", "1") != "0" # WEB=0: API only, no web player
MAX_BODY = 8 << 20
MARKER = ".stemify.json"
VERSION = 1
@@ -273,6 +277,8 @@ class Handler(BaseHTTPRequestHandler):
self._send(status, json.dumps({"error": msg}).encode())
def _web(self, path):
if not SERVE_WEB:
return self._error(404, "not found")
if path == "/":
path = "/index.html"
full = os.path.realpath(os.path.join(WEB_DIR, path.lstrip("/")))
@@ -304,6 +310,9 @@ class Handler(BaseHTTPRequestHandler):
"name": "stemserve", "version": VERSION, "tracks": INDEX.count,
"ffmpeg": bool(FFMPEG), "opus": list(OPUS_RATES) if FFMPEG else [],
"stemNow": os.access(QUEUE_DIR, os.W_OK)}).encode())
if path == "/api/config":
plex = {"url": PLEX_URL, "token": PLEX_TOKEN} if PLEX_URL and PLEX_TOKEN else None
return self._send(200, json.dumps({"plex": plex}).encode(), headers={"Cache-Control": "no-store"})
if path == "/api/index":
body, etag = INDEX.get()
if self.headers.get("If-None-Match") == etag:
+24 -2
View File
@@ -782,7 +782,7 @@ const routes = {
if (S.plex) secs = ((await px("/library/sections").catch(() => ({}))).Directory || []).filter(d => d.type === "artist");
view.innerHTML = `<h1>Settings</h1>
<h2>Plex</h2><div class="form">
<p class="note">${S.plex ? `Connected to <b>${esc(S.plex.name || S.plex.url)}</b> at ${esc(S.plex.url)}` : "Not signed in."}</p>
<p class="note">${S.plex ? `Plex: <b>${esc(S.plex.name && S.plex.name !== S.plex.url ? S.plex.name + " (" + S.plex.url + ")" : S.plex.url)}</b>${S.plex.env ? " (set by the server)" : ""}` : "Not signed in."}</p>
<div class="row"><button class="btn primary" id="signin">Sign in with Plex</button>${S.plex ? `<button class="btn" id="signout">Sign out</button>` : ""}<span class="note" id="sstatus"></span></div>
${secs.length > 1 ? `<label>Music library<select id="sec">${secs.map(d => `<option value="${d.key}" ${d.key == S.section ? "selected" : ""}>${esc(d.title)}</option>`).join("")}</select></label>` : ""}
<details><summary class="note">Enter a server and token by hand</summary><div class="form" style="margin-top:12px">
@@ -835,10 +835,32 @@ $("#search").onsubmit = ev => {
if (q) location.hash = "#/search/" + encodeURIComponent(q);
};
// Plex from the server's PLEX_URL / PLEX_TOKEN when nothing was signed in by hand, and a
// local stemserve when this page's own server has none.
async function autoConnect() {
const ok = await Stems.refresh();
if (!ok && !Stems.ok && !S.stemUrl && !/^(localhost|127\.0\.0\.1)$/.test(location.hostname)) {
for (const u of ["http://localhost:8765", "http://127.0.0.1:8765"]) {
try {
const r = await fetch(u + "/api/info" + stemQuery(), { signal: AbortSignal.timeout(1500) });
if (r.ok) { S.stemUrl = u; save(); await Stems.refresh(); break; }
} catch {}
}
}
if (!S.plex || S.plex.env) {
try {
const c = await fetch(stemBase() + "/api/config" + stemQuery()).then(r => r.ok ? r.json() : null);
if (c?.plex) S.plex = { ...c.plex, name: "", env: true };
else if (S.plex?.env) S.plex = null;
save();
} catch {}
}
}
// ---- start -----------------------------------------------------------------------------
(async () => {
await Promise.race([Stems.refresh(), new Promise(r => setTimeout(r, 4000))]);
await Promise.race([autoConnect(), new Promise(r => setTimeout(r, 4000))]);
const saved = load(KEY + "-queue");
if (saved?.items?.length) {
Q.items = saved.items; Q.orig = saved.orig; Q.i = Math.min(saved.i, saved.items.length - 1);