From 7ca9d0374d81972e9ff3c6e2502f5ea09fc40069 Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 20 Oct 2026 13:11:18 -0700 Subject: [PATCH] Proxy Plex through stemserve (/plex/) so HTTPS pages avoid mixed-content blocks Co-Authored-By: Claude Sonnet 5.5 --- server/stemserve.py | 30 ++++++++++++++++++++++++++++++ web/app.js | 6 +++--- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/server/stemserve.py b/server/stemserve.py index d9b444a..e325dc5 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 + /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 @@ -33,7 +34,9 @@ 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 @@ -263,6 +266,31 @@ class Handler(BaseHTTPRequestHandler): 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) @@ -299,6 +327,8 @@ class Handler(BaseHTTPRequestHandler): 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): diff --git a/web/app.js b/web/app.js index 3b3f89a..fa66a10 100644 --- a/web/app.js +++ b/web/app.js @@ -60,7 +60,7 @@ const plexHeaders = token => ({ async function px(path, params = {}) { if (!S.plex) throw new Error("Not signed in to Plex"); - const u = new URL(S.plex.url + path); + const u = new URL(S.plex.url + path, location.href); for (const [k, v] of Object.entries(params)) u.searchParams.set(k, v); const r = await fetch(u, { headers: plexHeaders(S.plex.token) }); if (!r.ok) throw new Error(`Plex ${r.status}`); @@ -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 ? `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 ? `Plex: ${esc(S.plex.env ? "via this server" : 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
@@ -850,7 +850,7 @@ async function autoConnect() { 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 }; + if (c?.plex) S.plex = { token: c.plex.token, url: stemBase() + "/plex", name: "", env: true }; else if (S.plex?.env) S.plex = null; save(); } catch {}