Plex: repair a rejected server token or moved address instead of signing the person out
build-container / build (push) Successful in 2m24s

The saved Plex server token or address can stop working (token rotated, server moved). That
looked like "signed out" and sent the person back to the sign-in screen. The proxy now asks plex.tv
(using the saved account sign-in) for a fresh token and address, retries once, and saves them.
Sign-ins saved before the server id was recorded are matched by server name. A revoked account
sign-in signs the person out cleanly; repairs are one at a time and pause 30s after a failure.
The server logs [plex] lines for sign-in, connect, repair and failure.

tools/plex-test.js (31 checks, fake plex.tv + fake Plex) runs in the Gitea workflow.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-20 16:45:05 -04:00
1 parent f18d9dd7a9
commit 2d17ee9939
3 files changed
+212 -44

No files matched your search

+94 -44
View File
@@ -169,11 +169,81 @@ const plexFile = () => require('path').join(users.dir(), 'plex.json');
const plexLoad = () => { try { return vault.unseal(JSON.parse(require('fs').readFileSync(plexFile(), 'utf8')).data) || {}; } catch { return {}; } };
const plexSave = o => require('fs').writeFileSync(plexFile(), JSON.stringify({ data: vault.seal(o) }), { mode: 0o600 });
const plexWipe = () => { try { require('fs').unlinkSync(plexFile()); } catch {} };
const PLEX_TV = process.env.PLEX_TV_URL || 'https://plex.tv'; // (only the tests change this)
const plexTv = async (path, token, opts = {}) => {
const r = await fetch('https://plex.tv' + path, { ...opts, headers: { ...PLEX_HEADERS, ...(token ? { 'X-Plex-Token': token } : {}) } });
if (!r.ok) throw new Error('plex.tv ' + r.status);
const r = await fetch(PLEX_TV + path, { ...opts, headers: { ...PLEX_HEADERS, ...(token ? { 'X-Plex-Token': token } : {}) } });
if (!r.ok) { const e = new Error('plex.tv ' + r.status); e.status = r.status; throw e; }
return r.json();
};
const RESOURCES = '/api/v2/resources?includeHttps=1&includeRelay=1';
/** Find a working address for one Plex server. Candidates, best first: its LAN address over plain http (no DNS, works from a
* container on the same network), then its plex.direct names, then the public address, then Plex's relay.
* Returns { ok, uri, label } or { ok: false, summary }. */
async function plexConnect(r, manual) {
const id = r.clientIdentifier;
let cands = [];
if (manual) cands = [{ uri: manual.origin, label: 'manual ' + manual.host, t: 6000 }];
else {
for (const c of r.connections || []) {
if (c.local && c.address && c.port) cands.push({ uri: `http://${c.address}:${c.port}`, label: 'lan ' + c.address, rank: 0, t: 3000 });
cands.push({ uri: c.uri, label: (c.local ? 'local ' : c.relay ? 'relay ' : 'remote ') + c.address, rank: c.local ? 1 : c.relay ? 3 : 2, t: 6000 });
}
cands = cands.sort((a, b) => a.rank - b.rank).filter((c, i, arr) => arr.findIndex(x => x.uri === c.uri) === i);
}
const tried = [];
for (const c of cands) {
try {
// /identity needs no token, so the token is never sent to a host before we know it is this server.
const t = await fetch(c.uri + '/identity', { headers: PLEX_HEADERS, signal: AbortSignal.timeout(c.t) });
if (!t.ok) { tried.push(`${c.label}: HTTP ${t.status}`); continue; }
// Plex answers /identity as JSON or XML depending on the server; accept both.
const body = await t.text();
let mid; try { mid = JSON.parse(body).MediaContainer?.machineIdentifier; } catch { mid = /machineIdentifier="([^"]+)"/.exec(body)?.[1]; }
if (!mid) { tried.push(`${c.label}: answered, but not like a Plex server`); continue; }
if (String(mid).toLowerCase() !== String(id).toLowerCase()) { tried.push(`${c.label}: a different server answered (id ${String(mid).slice(0, 6)}…, wanted ${String(id).slice(0, 6)}…)`); continue; }
return { ok: true, uri: c.uri, label: c.label };
} catch (e) { tried.push(`${c.label}: ${e.cause?.code || e.name || e.message}`); }
}
// Group identical outcomes so a long list of LAN addresses cannot hide the public/relay results.
const groups = new Map();
for (const t of tried) { const i = t.indexOf(': '), label = t.slice(0, i), why = t.slice(i + 2).replace(/\s*\(id [^)]*\)/, '');
const seen = (t.match(/\(id ([^…]+)…/) || [])[1]; const key = why + (seen ? ` (id ${seen}…)` : '');
groups.set(key, [...(groups.get(key) || []), label]); }
return { ok: false, summary: `Could not reach ${r.name} (id ${String(id).slice(0, 6)}…). ` + [...groups].map(([why, labels]) => `${why}: ${labels.join(', ')}`).join(' | ') };
}
// Self-repair: if the saved server token or address stops working, ask plex.tv (using the saved account sign-in) for fresh
// ones and carry on, so a rotated server token never looks like "signed out". One repair at a time per person, and a short
// pause after a failed one so a dead server does not hammer plex.tv.
const healing = new Map(), healFailedAt = new Map();
async function plexHeal() {
const uid = users.current().id;
if (healing.has(uid)) return healing.get(uid);
if (Date.now() - (healFailedAt.get(uid) || 0) < 30e3) return null;
const job = (async () => {
const st = plexLoad();
if (!st.token || (!st.sid && !st.name)) { console.warn('[plex] cannot repair: the saved sign-in does not say which server it was for (choose the server again once)'); return null; }
let rs;
try { rs = await plexTv(RESOURCES, st.token); }
catch (e) {
if (e.status === 401 || e.status === 403) { console.warn('[plex] plex.tv rejected the saved account sign-in (' + e.status + '); signing out so you can sign in again'); plexWipe(); }
else console.warn('[plex] could not reach plex.tv to repair the sign-in:', e.message);
return null;
}
// Sign-ins saved before the server id was recorded are matched by the server's name instead.
const r = rs.find(x => st.sid ? x.clientIdentifier === st.sid : x.name === st.name);
if (!r) { console.warn('[plex] the saved server is no longer on this Plex account'); return null; }
const c = await plexConnect(r);
if (!c.ok) { console.warn('[plex] repair failed: ' + c.summary); return null; }
const fresh = { ...st, srv: c.uri, stk: r.accessToken, name: r.name, sid: r.clientIdentifier };
plexSave(fresh);
console.log(`[plex] repaired the saved server sign-in for ${r.name} (${c.label}${r.accessToken !== st.stk ? ', new server token' : ''})`);
return fresh;
})().then(r => { if (!r) healFailedAt.set(uid, Date.now()); return r; }).finally(() => healing.delete(uid));
healing.set(uid, job);
return job;
}
async function plexApi(req, res) {
const u = new URL(req.url, 'http://x'), st = plexLoad(), token = st.token;
@@ -189,55 +259,28 @@ async function plexApi(req, res) {
if (u.pathname === '/api/plex/pin' && req.method === 'GET') {
const pin = await plexTv(`/api/v2/pins/${encodeURIComponent(u.searchParams.get('id'))}?code=${encodeURIComponent(u.searchParams.get('code'))}`);
if (!pin.authToken) return json(res, 200, { ok: false });
plexSave({ token: pin.authToken }); // a fresh sign-in; the server is chosen again
if (token !== pin.authToken) { plexSave({ token: pin.authToken }); console.log('[plex] signed in; the server will be chosen next'); } // a new sign-in; polling again later must not wipe the chosen server
return json(res, 200, { ok: true });
}
if (!token) return json(res, 401, { error: 'Not signed in to Plex' });
if (u.pathname === '/api/plex/servers') {
const rs = await plexTv('/api/v2/resources?includeHttps=1&includeRelay=1', token);
const rs = await plexTv(RESOURCES, token);
return json(res, 200, rs.filter(r => (r.provides || '').includes('server')).map(r => ({ id: r.clientIdentifier, name: r.name, owned: r.owned })));
}
if (u.pathname === '/api/plex/server' && req.method === 'POST') {
const { id, uri: manual } = JSON.parse(await readBody(req));
const r = (await plexTv('/api/v2/resources?includeHttps=1&includeRelay=1', token)).find(x => x.clientIdentifier === id);
const r = (await plexTv(RESOURCES, token)).find(x => x.clientIdentifier === id);
if (!r) return json(res, 404, { error: 'Server not found' });
// Candidates, best first: the server's LAN address over plain http (no DNS, works from a container on the
// same network), then its plex.direct names, then the public address, then Plex's relay.
let cands = [];
let m;
if (manual) {
let m; try { m = new URL(manual); } catch { return json(res, 400, { error: 'That is not a valid address (example: http://192.168.1.10:32400)' }); }
try { m = new URL(manual); } catch { return json(res, 400, { error: 'That is not a valid address (example: http://192.168.1.10:32400)' }); }
if (!/^https?:$/.test(m.protocol)) return json(res, 400, { error: 'Address must start with http:// or https://' });
cands = [{ uri: m.origin, label: 'manual ' + m.host, t: 6000 }];
} else {
for (const c of r.connections) {
if (c.local && c.address && c.port) cands.push({ uri: `http://${c.address}:${c.port}`, label: 'lan ' + c.address, rank: 0, t: 3000 });
cands.push({ uri: c.uri, label: (c.local ? 'local ' : c.relay ? 'relay ' : 'remote ') + c.address, rank: c.local ? 1 : c.relay ? 3 : 2, t: 6000 });
}
cands = cands.sort((a, b) => a.rank - b.rank).filter((c, i, arr) => arr.findIndex(x => x.uri === c.uri) === i);
}
const tried = [];
for (const c of cands) {
try {
// /identity needs no token, so the token is never sent to a host before we know it is this server.
const t = await fetch(c.uri + '/identity', { headers: PLEX_HEADERS, signal: AbortSignal.timeout(c.t) });
if (!t.ok) { tried.push(`${c.label}: HTTP ${t.status}`); continue; }
// Plex answers /identity as JSON or XML depending on the server; accept both.
const body = await t.text();
let mid; try { mid = JSON.parse(body).MediaContainer?.machineIdentifier; } catch { mid = /machineIdentifier="([^"]+)"/.exec(body)?.[1]; }
if (!mid) { tried.push(`${c.label}: answered, but not like a Plex server`); continue; }
if (String(mid).toLowerCase() !== String(id).toLowerCase()) { tried.push(`${c.label}: a different server answered (id ${String(mid).slice(0, 6)}…, wanted ${String(id).slice(0, 6)}…)`); continue; }
plexSave({ ...st, srv: c.uri, stk: r.accessToken, name: r.name });
return json(res, 200, { ok: true, name: r.name, via: c.label });
} catch (e) { tried.push(`${c.label}: ${e.cause?.code || e.name || e.message}`); }
}
console.error('Plex connect failed for', r.name, tried);
// Group identical outcomes so a long list of LAN addresses cannot hide the public/relay results.
const groups = new Map();
for (const t of tried) { const i = t.indexOf(': '), label = t.slice(0, i), why = t.slice(i + 2).replace(/\s*\(id [^)]*\)/, '');
const seen = (t.match(/\(id ([^…]+)…/) || [])[1]; const key = why + (seen ? ` (id ${seen}…)` : '');
groups.set(key, [...(groups.get(key) || []), label]); }
const summary = [...groups].map(([why, labels]) => `${why}: ${labels.join(', ')}`).join(' | ');
return json(res, 502, { error: `Could not reach ${r.name} (id ${String(id).slice(0, 6)}…). ` + summary });
const c = await plexConnect(r, m);
if (!c.ok) { console.error('[plex] connect failed:', c.summary); return json(res, 502, { error: c.summary }); }
plexSave({ ...st, srv: c.uri, stk: r.accessToken, name: r.name, sid: id }); // sid lets the sign-in repair itself later
console.log(`[plex] connected to ${r.name} (${c.label})`);
return json(res, 200, { ok: true, name: r.name, via: c.label });
}
if (u.pathname === '/api/plex/logout' && req.method === 'POST') {
plexWipe();
@@ -248,14 +291,21 @@ async function plexApi(req, res) {
}
async function plexProxy(req, res) {
const st = plexLoad(), base = st.srv, tok = st.stk;
if (!base || !tok) { res.writeHead(401); return res.end(); }
const headers = { ...PLEX_HEADERS, 'X-Plex-Token': tok, Accept: req.headers.accept || 'application/json', 'Accept-Encoding': 'identity' };
if (req.headers.range) headers.range = req.headers.range;
const st = plexLoad();
if (!st.srv || !st.stk) { res.writeHead(401); return res.end(); }
const ac = new AbortController();
res.on('close', () => ac.abort());
const attempt = s => fetch(s.srv + req.url.slice('/plex'.length), { signal: ac.signal,
headers: { ...PLEX_HEADERS, 'X-Plex-Token': s.stk, Accept: req.headers.accept || 'application/json', 'Accept-Encoding': 'identity', ...(req.headers.range ? { range: req.headers.range } : {}) } });
let up = null;
try { up = await attempt(st); } catch { up = null; }
if (!up || up.status === 401 || up.status === 403) { // the saved token or address stopped working: repair once, then retry
if (up && up.body) { try { await up.body.cancel(); } catch {} }
const fresh = await plexHeal();
if (fresh) { try { up = await attempt(fresh); } catch { up = null; } }
}
try {
const up = await fetch(base + req.url.slice('/plex'.length), { headers, signal: ac.signal });
if (!up) { if (!res.headersSent) { res.writeHead(502); res.end(); } return; }
const out = {};
// fetch() transparently decompresses, so an upstream content-length would no longer match the bytes we send.
const encoded = !!up.headers.get('content-encoding');