#!/usr/bin/env python3 """stemserve: serves Stemify library stems to the Android app over HTTP. Stdlib only; ffmpeg is optional and enables Opus transcodes and waveforms. The stems folder is what stemify.library writes: /Artist/Album/01 Song/.stemify.json /Artist/Album/01 Song/01 Song - vocals.flac, ... Endpoints (all GET unless noted; add ?token=... or X-Stem-Token when TOKEN is set): /api/info server facts /api/index every finished track (ETag / If-None-Match) /api/peaks?key=&n= per-stem RMS envelopes, 0..1000 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 /plex/... proxy to PLEX_URL (needs TOKEN or the Plex token) /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". """ import email.utils import hashlib import json import operator import os import shutil import signal import subprocess import sys import threading import time import urllib.error import urllib.parse import urllib.request from array import array from concurrent.futures import ThreadPoolExecutor from http import HTTPStatus from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer STEMS_DIR = os.path.realpath(os.environ.get("STEMS_DIR", "/stems")) CACHE_DIR = os.environ.get("CACHE_DIR", "/cache") 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 FFMPEG = shutil.which("ffmpeg") OPUS_RATES = (64, 96, 128, 160, 192, 256) PEAK_RATE = 2000 # Hz the envelope is computed at; plenty for a few hundred bars CHUNK = 1 << 20 # The web player. Served without the token; it asks for one in its settings when TOKEN is set. WEB_DIR = os.path.realpath(os.environ.get("WEB_DIR", os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "web"))) WEB_TYPES = {".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".css": "text/css; charset=utf-8", ".svg": "image/svg+xml", ".png": "image/png", ".ico": "image/x-icon", ".json": "application/json"} _pool = ThreadPoolExecutor(max_workers=max(2, (os.cpu_count() or 2) // 2)) _locks: dict[str, threading.Lock] = {} _locks_guard = threading.Lock() def log(*args): print(time.strftime("%H:%M:%S"), *args, file=sys.stderr, flush=True) def _lock_for(path): with _locks_guard: return _locks.setdefault(path, threading.Lock()) # ---- index ------------------------------------------------------------------ class Index: def __init__(self): self._lock = threading.Lock() self._scanning = threading.Lock() self._at = 0.0 self._body = b'{"tracks":[]}' self._etag = '"empty"' self._count = 0 self._by_key: dict[str, dict] = {} def get(self): if time.monotonic() - self._at > INDEX_TTL: self.refresh() with self._lock: return self._body, self._etag def entry(self, key): if time.monotonic() - self._at > INDEX_TTL: self.refresh() with self._lock: return self._by_key.get(key) @property def count(self): return self._count def refresh(self): # One scan at a time; callers that lose the race use the previous result. if not self._scanning.acquire(blocking=self._at == 0): return try: tracks = [] for root, dirs, files in os.walk(STEMS_DIR): dirs[:] = sorted(d for d in dirs if not d.startswith(".")) if MARKER not in files: continue entry = _read_marker(root, set(files)) if entry: tracks.append(entry) tracks.sort(key=lambda t: t["key"].casefold()) body = json.dumps({"version": VERSION, "tracks": tracks}, separators=(",", ":")).encode() with self._lock: self._body = body self._etag = '"%s"' % hashlib.sha1(body).hexdigest()[:20] self._count = len(tracks) self._by_key = {t["key"]: t for t in tracks} self._at = time.monotonic() finally: self._scanning.release() def _read_marker(folder, files): try: with open(os.path.join(folder, MARKER), encoding="utf-8") as f: meta = json.load(f) except (OSError, ValueError): return None key = os.path.relpath(folder, STEMS_DIR).replace(os.sep, "/") stems = [] for s in meta.get("stems", []): name, fname = s.get("name"), s.get("file") if not name or not fname or fname not in files: continue try: st = os.stat(os.path.join(folder, fname)) except OSError: continue stems.append({"name": name, "file": fname, "size": st.st_size, "mtime": int(st.st_mtime)}) if not stems: return None # The stems folder mirrors the music folder, so the source's path relative to the # music root is this folder's parent plus the source's file name. src_name = os.path.basename(meta.get("source") or "") or os.path.basename(folder) parent = os.path.dirname(key) return { "key": key, "source": f"{parent}/{src_name}" if parent else src_name, "duration": meta.get("duration"), "samplerate": meta.get("samplerate"), "model": meta.get("model"), "finished": meta.get("finished"), "stems": stems, } INDEX = Index() # ---- transcodes and peaks --------------------------------------------------- def _resolve(key, fname): """Absolute path of a stem file, or None if it escapes STEMS_DIR or is missing.""" path = os.path.realpath(os.path.join(STEMS_DIR, key, fname)) if not path.startswith(STEMS_DIR + os.sep) or not path.lower().endswith(".flac"): return None return path if os.path.isfile(path) else None def _cache_name(src, tag, ext): st = os.stat(src) h = hashlib.sha1(f"{src}|{st.st_size}|{st.st_mtime_ns}|{tag}".encode()).hexdigest() return os.path.join(CACHE_DIR, tag.split(":")[0], h[:2], h + ext) def transcode(src, br): """Path of an Ogg Opus copy of src at br kbps, transcoding it first if needed.""" out = _cache_name(src, f"opus:{br}", ".opus") if os.path.exists(out): os.utime(out) # for LRU-ish cleanup by mtime return out with _lock_for(out): if os.path.exists(out): return out os.makedirs(os.path.dirname(out), exist_ok=True) tmp = out + ".part" t0 = time.monotonic() subprocess.run([FFMPEG, "-nostdin", "-v", "error", "-y", "-i", src, "-map", "0:a:0", "-c:a", "libopus", "-b:a", f"{br}k", "-vbr", "on", "-application", "audio", "-map_metadata", "-1", "-f", "ogg", tmp], check=True, timeout=600) os.replace(tmp, out) log(f"opus {br}k {os.path.basename(src)} in {time.monotonic() - t0:.1f}s") return out def _envelope(src, n, duration): """RMS of src in n equal buckets across `duration` seconds, as 0..1 of full scale.""" raw = subprocess.run([FFMPEG, "-nostdin", "-v", "error", "-i", src, "-map", "0:a:0", "-ac", "1", "-ar", str(PEAK_RATE), "-f", "s16le", "-"], check=True, capture_output=True, timeout=300).stdout pcm = array("h") pcm.frombytes(raw[:len(raw) // 2 * 2]) if sys.byteorder == "big": pcm.byteswap() total = max(1, int(round((duration or len(pcm) / PEAK_RATE) * PEAK_RATE))) out = [] for i in range(n): a, b = i * total // n, (i + 1) * total // n chunk = pcm[a:max(b, a + 1)] if not chunk: out.append(0) continue ms = sum(map(operator.mul, chunk, chunk)) / len(chunk) out.append(min(1000, int(round((ms ** 0.5) / 32768 * 1000)))) return out def peaks(entry, n): folder = os.path.join(STEMS_DIR, entry["key"]) first = _resolve(entry["key"], entry["stems"][0]["file"]) sig = "|".join(f"{s['file']}:{s['size']}:{s['mtime']}" for s in entry["stems"]) out = _cache_name(first, f"peaks:{n}:{hashlib.sha1(sig.encode()).hexdigest()}", ".json") if os.path.exists(out): with open(out, "rb") as f: return f.read() with _lock_for(out): if os.path.exists(out): with open(out, "rb") as f: return f.read() t0 = time.monotonic() jobs = {s["name"]: _pool.submit(_envelope, os.path.join(folder, s["file"]), n, entry.get("duration")) for s in entry["stems"]} body = json.dumps({"key": entry["key"], "n": n, "duration": entry.get("duration"), "stems": {name: job.result() for name, job in jobs.items()}}, separators=(",", ":")).encode() os.makedirs(os.path.dirname(out), exist_ok=True) with open(out + ".part", "wb") as f: f.write(body) os.replace(out + ".part", out) log(f"peaks {entry['key']} in {time.monotonic() - t0:.1f}s") return body # ---- HTTP ------------------------------------------------------------------- class Handler(BaseHTTPRequestHandler): protocol_version = "HTTP/1.1" server_version = f"stemserve/{VERSION}" def log_message(self, fmt, *args): if os.environ.get("VERBOSE"): log(self.address_string(), fmt % args) def _authorized(self, query): if not TOKEN: return True return TOKEN in (self.headers.get("X-Stem-Token"), query.get("token", [None])[0]) def _plex_proxy(self, path, rawquery, query): """Forward to PLEX_URL so an HTTPS page can reach an HTTP Plex. Needs the stem or Plex token.""" given = self.headers.get("X-Plex-Token") or query.get("X-Plex-Token", [None])[0] if not (self._authorized(query) or (PLEX_TOKEN and given == PLEX_TOKEN)): return self._error(HTTPStatus.UNAUTHORIZED, "bad token") pairs = [(k, v) for k, v in urllib.parse.parse_qsl(rawquery, keep_blank_values=True) if k not in ("token", "X-Plex-Token")] req = urllib.request.Request(PLEX_URL + path + ("?" + urllib.parse.urlencode(pairs) if pairs else "")) req.add_header("X-Plex-Token", PLEX_TOKEN or given or "") for h in ("Range", "Accept", "X-Plex-Client-Identifier", "X-Plex-Product", "If-None-Match"): if self.headers.get(h): req.add_header(h, self.headers[h]) try: r = urllib.request.urlopen(req, timeout=30) except urllib.error.HTTPError as e: r = e except OSError as e: return self._error(502, f"plex unreachable: {e}") self.send_response(r.status if hasattr(r, "status") else r.code) for h in ("Content-Type", "Content-Length", "Content-Range", "Accept-Ranges", "ETag", "Cache-Control"): if r.headers.get(h): self.send_header(h, r.headers[h]) self.end_headers() while chunk := r.read(65536): self.wfile.write(chunk) def _send(self, status, body=b"", ctype="application/json", headers=None): self.send_response(status) self.send_header("Content-Type", ctype) self.send_header("Content-Length", str(len(body))) for k, v in (headers or {}).items(): self.send_header(k, v) self.end_headers() if self.command != "HEAD": self.wfile.write(body) def _error(self, status, msg): 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("/"))) if not full.startswith(WEB_DIR + os.sep) or not os.path.isfile(full): return self._error(404, "not found") with open(full, "rb") as f: body = f.read() ctype = WEB_TYPES.get(os.path.splitext(full)[1], "application/octet-stream") self._send(200, body, ctype, {"Cache-Control": "no-cache"}) def do_HEAD(self): self.do_GET() def do_POST(self): self.do_GET() def do_GET(self): url = urllib.parse.urlsplit(self.path) query = urllib.parse.parse_qs(url.query) path = urllib.parse.unquote(url.path) if path.startswith("/plex/") and PLEX_URL: return self._plex_proxy(path[len("/plex"):], url.query, query) if not path.startswith(("/api/", "/file/")): return self._web(path) if not self._authorized(query): return self._error(HTTPStatus.UNAUTHORIZED, "bad token") try: if path == "/api/info": INDEX.get() return self._send(200, json.dumps({ "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: return self._send(304, headers={"ETag": etag}) return self._send(200, body, headers={"ETag": etag, "Cache-Control": "no-cache"}) if path == "/api/peaks": return self._peaks(query) if path == "/api/prepare": return self._prepare(query) if path == "/api/stem": return self._stem_now() if path.startswith("/file/"): return self._file(path[len("/file/"):], query) return self._error(404, "not found") except (BrokenPipeError, ConnectionResetError): pass except subprocess.CalledProcessError as e: self._error(500, f"ffmpeg failed ({e.returncode})") except Exception as e: # keep serving; report what broke log("error", self.path, repr(e)) try: self._error(500, str(e) or e.__class__.__name__) except OSError: pass def _entry(self, query): key = (query.get("key") or [""])[0] entry = INDEX.entry(key) if entry is None: self._error(404, "unknown key") return entry def _peaks(self, query): if not FFMPEG: return self._error(501, "ffmpeg not installed") entry = self._entry(query) if entry is None: return n = max(16, min(4096, int((query.get("n") or ["512"])[0]))) self._send(200, peaks(entry, n), headers={"Cache-Control": "max-age=86400"}) def _prepare(self, query): entry = self._entry(query) if entry is None: return fmt, br = _format(query) if fmt == "opus": for s in entry["stems"]: src = _resolve(entry["key"], s["file"]) if src: _pool.submit(transcode, src, br) self._send(202, b'{"ok":true}') def _stem_now(self): """Hands a list of Plex file paths to stemd, which separates them before the rest.""" if self.command != "POST": return self._error(405, "POST a JSON body: {\"paths\": [...]}") n = int(self.headers.get("Content-Length") or 0) if n > MAX_BODY: self.close_connection = True return self._error(413, "too many paths") try: paths = json.loads(self.rfile.read(n) or b"{}").get("paths") except (ValueError, AttributeError): paths = None if not isinstance(paths, list) or not all(isinstance(p, str) for p in paths): return self._error(400, "expected {\"paths\": [...]}") if not os.access(QUEUE_DIR, os.W_OK): return self._error(503, f"stemd's queue folder isn't mounted at {QUEUE_DIR}") if paths: # stemd reads requests in name order; the dot keeps a half-written one out of its glob. name = f"{time.time_ns()}-{os.getpid()}-{threading.get_ident()}.json" tmp = os.path.join(QUEUE_DIR, "." + name) with open(tmp, "w", encoding="utf-8") as f: json.dump({"paths": paths, "at": time.time()}, f) os.replace(tmp, os.path.join(QUEUE_DIR, name)) log(f"stem now: {len(paths)} track{'' if len(paths) == 1 else 's'} queued") self._send(202, json.dumps({"queued": len(paths)}).encode()) def _file(self, rel, query): key, _, fname = rel.rpartition("/") src = _resolve(key, fname) if not src: return self._error(404, "no such stem") fmt, br = _format(query) path, ctype = (transcode(src, br), "audio/ogg") if fmt == "opus" else (src, "audio/flac") self._serve_file(path, ctype) def _serve_file(self, path, ctype): st = os.stat(path) size = st.st_size etag = '"%x-%x"' % (st.st_size, st.st_mtime_ns) start, end = 0, size - 1 status = 200 rng = self.headers.get("Range") if rng and rng.startswith("bytes=") and "," not in rng and self.headers.get("If-Range", etag) == etag: a, _, b = rng[6:].strip().partition("-") try: if a: start = int(a) end = min(int(b), size - 1) if b else size - 1 else: # suffix range: last N bytes start = max(0, size - int(b)) except ValueError: return self._error(400, "bad range") if start >= size or start > end: self.send_response(416) self.send_header("Content-Range", f"bytes */{size}") self.send_header("Content-Length", "0") self.end_headers() return status = 206 length = end - start + 1 self.send_response(status) self.send_header("Content-Type", ctype) self.send_header("Content-Length", str(length)) self.send_header("Accept-Ranges", "bytes") self.send_header("ETag", etag) self.send_header("Last-Modified", email.utils.formatdate(st.st_mtime, usegmt=True)) self.send_header("Cache-Control", "max-age=31536000, immutable") if status == 206: self.send_header("Content-Range", f"bytes {start}-{end}/{size}") self.end_headers() if self.command == "HEAD": return with open(path, "rb") as f: f.seek(start) left = length while left > 0: buf = f.read(min(CHUNK, left)) if not buf: break self.wfile.write(buf) left -= len(buf) def _format(query): fmt = (query.get("fmt") or ["flac"])[0] if fmt != "opus" or not FFMPEG: return "flac", 0 try: br = int((query.get("br") or ["160"])[0]) except ValueError: br = 160 return "opus", min(OPUS_RATES, key=lambda r: abs(r - br)) def main(): if not os.path.isdir(STEMS_DIR): sys.exit(f"STEMS_DIR not found: {STEMS_DIR}") os.makedirs(CACHE_DIR, exist_ok=True) INDEX.refresh() log(f"stemserve on :{PORT}, {INDEX.count} tracks in {STEMS_DIR}, ffmpeg={'yes' if FFMPEG else 'no'}, " f"token={'on' if TOKEN else 'off'}") server = ThreadingHTTPServer(("0.0.0.0", PORT), Handler) server.daemon_threads = True # As PID 1 in a container, SIGTERM is ignored unless handled; docker stop would hang. signal.signal(signal.SIGTERM, lambda *_: sys.exit(0)) try: server.serve_forever() except KeyboardInterrupt: pass if __name__ == "__main__": main()