Proxy Plex through stemserve (/plex/) so HTTPS pages avoid mixed-content blocks

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
cdnutterandClaude Sonnet 5.5 committed 2026-10-20 13:11:18 -07:00
1 parent 4b53a720c4
commit 7ca9d0374d
2 files changed
+33 -3

No files matched your search

+30
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/prepare?key=<key>&fmt=opus&br=160 start transcodes in the background
POST /api/stem {"paths": [...]} "Stem Now": stemd separates these Plex files next 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 /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 /api/config Plex URL and token from PLEX_URL / PLEX_TOKEN, if set
/ the web player (web/, or WEB_DIR); no token needed / the web player (web/, or WEB_DIR); no token needed
@@ -33,7 +34,9 @@ import subprocess
import sys import sys
import threading import threading
import time import time
import urllib.error
import urllib.parse import urllib.parse
import urllib.request
from array import array from array import array
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from http import HTTPStatus from http import HTTPStatus
@@ -263,6 +266,31 @@ class Handler(BaseHTTPRequestHandler):
return True return True
return TOKEN in (self.headers.get("X-Stem-Token"), query.get("token", [None])[0]) 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): def _send(self, status, body=b"", ctype="application/json", headers=None):
self.send_response(status) self.send_response(status)
self.send_header("Content-Type", ctype) self.send_header("Content-Type", ctype)
@@ -299,6 +327,8 @@ class Handler(BaseHTTPRequestHandler):
url = urllib.parse.urlsplit(self.path) url = urllib.parse.urlsplit(self.path)
query = urllib.parse.parse_qs(url.query) query = urllib.parse.parse_qs(url.query)
path = urllib.parse.unquote(url.path) 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/")): if not path.startswith(("/api/", "/file/")):
return self._web(path) return self._web(path)
if not self._authorized(query): if not self._authorized(query):
+3 -3
View File
@@ -60,7 +60,7 @@ const plexHeaders = token => ({
async function px(path, params = {}) { async function px(path, params = {}) {
if (!S.plex) throw new Error("Not signed in to Plex"); 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); for (const [k, v] of Object.entries(params)) u.searchParams.set(k, v);
const r = await fetch(u, { headers: plexHeaders(S.plex.token) }); const r = await fetch(u, { headers: plexHeaders(S.plex.token) });
if (!r.ok) throw new Error(`Plex ${r.status}`); 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"); if (S.plex) secs = ((await px("/library/sections").catch(() => ({}))).Directory || []).filter(d => d.type === "artist");
view.innerHTML = `<h1>Settings</h1> view.innerHTML = `<h1>Settings</h1>
<h2>Plex</h2><div class="form"> <h2>Plex</h2><div class="form">
<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> <p class="note">${S.plex ? `Plex: <b>${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)}</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> <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>` : ""} ${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"> <details><summary class="note">Enter a server and token by hand</summary><div class="form" style="margin-top:12px">
@@ -850,7 +850,7 @@ async function autoConnect() {
if (!S.plex || S.plex.env) { if (!S.plex || S.plex.env) {
try { try {
const c = await fetch(stemBase() + "/api/config" + stemQuery()).then(r => r.ok ? r.json() : null); 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; else if (S.plex?.env) S.plex = null;
save(); save();
} catch {} } catch {}