diff --git a/README.md b/README.md index ab74a08..d5dd7f2 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/server/README.md b/server/README.md index d96f54c..86f3f38 100644 --- a/server/README.md +++ b/server/README.md @@ -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 diff --git a/server/docker-compose.yml b/server/docker-compose.yml index cf099b3..36dd469 100644 --- a/server/docker-compose.yml +++ b/server/docker-compose.yml @@ -14,3 +14,5 @@ services: environment: # Optional shared secret; set the same token in the app (Settings > Stem Server). - TOKEN= + - PLEX_URL= + - PLEX_TOKEN= diff --git a/server/stemserve.py b/server/stemserve.py index 3e4e0be..d9b444a 100644 --- a/server/stemserve.py +++ b/server/stemserve.py @@ -16,6 +16,7 @@ Endpoints (all GET unless noted; add ?token=... or X-Stem-Token when TOKEN is se 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 /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 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: diff --git a/web/app.js b/web/app.js index 210fcea..3b3f89a 100644 --- a/web/app.js +++ b/web/app.js @@ -782,7 +782,7 @@ const routes = { if (S.plex) secs = ((await px("/library/sections").catch(() => ({}))).Directory || []).filter(d => d.type === "artist"); view.innerHTML = `

Settings

Plex

-

${S.plex ? `Connected to ${esc(S.plex.name || S.plex.url)} at ${esc(S.plex.url)}` : "Not signed in."}

+

${S.plex ? `Plex: ${esc(S.plex.name && S.plex.name !== S.plex.url ? S.plex.name + " (" + S.plex.url + ")" : S.plex.url)}${S.plex.env ? " (set by the server)" : ""}` : "Not signed in."}

${S.plex ? `` : ""}
${secs.length > 1 ? `` : ""}
Enter a server and token by hand
@@ -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);