Plex: repair a rejected server token or moved address instead of signing the person out
build-container / build (push) Successful in 2m24s
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:
1 parent
f18d9dd7a9
commit
2d17ee9939
3 files changed
+212
-44
No files matched your search
@@ -19,6 +19,7 @@ jobs:
|
||||
- run: npm ci
|
||||
- run: node tools/smoke-test.js
|
||||
- run: node tools/users-test.js # accounts: private data, tab limits, invites, migration
|
||||
- run: node tools/plex-test.js # Plex sign-in stays signed in and repairs itself
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env node
|
||||
// Plex sign-in: does it stay signed in, and does it repair itself? Uses a fake plex.tv and a fake Plex server.
|
||||
// Run: node tools/plex-test.js
|
||||
const { spawn } = require('child_process');
|
||||
const http = require('http');
|
||||
const fs = require('fs'), os = require('os'), path = require('path'), crypto = require('crypto');
|
||||
|
||||
const root = path.join(__dirname, '..');
|
||||
let failed = 0;
|
||||
const ok = (n, c, x) => { console.log((c ? 'PASS ' : 'FAIL ') + n + (c ? '' : ' -> ' + JSON.stringify(x))); if (!c) failed++; };
|
||||
const wait = ms => new Promise(r => setTimeout(r, ms));
|
||||
const SECRET = 'plex-test-secret-'.repeat(3), UID = 'uplex01';
|
||||
|
||||
const listen = h => new Promise(res => { const s = http.createServer(h); s.listen(0, '127.0.0.1', () => res(s)); });
|
||||
const jsonOut = (rs, code, o) => { rs.writeHead(code, { 'content-type': 'application/json' }); rs.end(JSON.stringify(o)); };
|
||||
|
||||
(async () => {
|
||||
// ---- fake plex.tv
|
||||
const tv = { accountToken: 'ACCOUNT', serverToken: 'SRV1', plexPort: 0, pinPolls: 0, resourceCalls: 0 };
|
||||
const plexTv = await listen((rq, rs) => {
|
||||
const u = new URL(rq.url, 'http://x');
|
||||
if (rq.method === 'POST' && u.pathname === '/api/v2/pins') return jsonOut(rs, 201, { id: 42, code: 'ABCD' });
|
||||
if (u.pathname === '/api/v2/pins/42') return jsonOut(rs, 200, { authToken: ++tv.pinPolls >= 2 ? tv.accountToken : null });
|
||||
if (u.pathname === '/api/v2/resources') {
|
||||
tv.resourceCalls++;
|
||||
if (rq.headers['x-plex-token'] !== tv.accountToken) return jsonOut(rs, 401, { error: 'bad token' });
|
||||
return jsonOut(rs, 200, [{ name: 'E-va', clientIdentifier: 'sid1', provides: 'server', owned: true, accessToken: tv.serverToken,
|
||||
connections: [{ uri: `http://127.0.0.1:${tv.plexPort}`, local: false, relay: false, address: '127.0.0.1', port: tv.plexPort }] }]);
|
||||
}
|
||||
rs.writeHead(404); rs.end();
|
||||
});
|
||||
// ---- fake Plex server: /identity is open; everything else needs the server token
|
||||
const seen = [];
|
||||
const makePlex = () => listen((rq, rs) => {
|
||||
if (rq.url === '/identity') return jsonOut(rs, 200, { MediaContainer: { machineIdentifier: 'sid1' } });
|
||||
seen.push(rq.headers['x-plex-token']);
|
||||
if (rq.headers['x-plex-token'] !== tv.serverToken) { rs.writeHead(401); return rs.end(); }
|
||||
jsonOut(rs, 200, { MediaContainer: { Directory: [{ key: '1', type: 'artist', title: 'Music' }] } });
|
||||
});
|
||||
let plex = await makePlex(); tv.plexPort = plex.address().port;
|
||||
|
||||
// ---- the hub, with one admin account already in place
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-plex-')), port = 20000 + Math.floor(Math.random() * 20000), base = `http://localhost:${port}`;
|
||||
fs.writeFileSync(path.join(dir, 'users.json'), JSON.stringify({ users: [{ id: UID, name: 'Admin', role: 'admin', tabs: [], handle: 'aGFuZGxl', epoch: 1, disabled: false, created: Date.now() }] }));
|
||||
const exp = Date.now() + 864e5, cookie = `hub_session=${UID}.1.${exp}.${crypto.createHmac('sha256', SECRET).update(`s${UID}.1.${exp}`).digest('base64url')}`;
|
||||
const startHub = () => { const c = spawn(process.execPath, ['server.js'], { cwd: root, env: { ...process.env, PORT: String(port), DATA_DIR: dir, COOKIE_SECRET: SECRET, PLEX_TV_URL: `http://127.0.0.1:${plexTv.address().port}` } });
|
||||
const h = { child: c, log: '' }; c.stdout.on('data', d => h.log += d); c.stderr.on('data', d => h.log += d); return h; };
|
||||
const ready = async () => { for (let i = 0; i < 60; i++) { try { await fetch(base + '/login'); return; } catch { await wait(100); } } throw new Error('hub did not start'); };
|
||||
const call = async (p, body, method) => { const r = await fetch(base + p, { method: method || (body ? 'POST' : 'GET'), headers: { cookie, 'content-type': 'application/json' }, body: body ? JSON.stringify(body) : undefined }); let j = {}; try { j = JSON.parse(await r.text()); } catch {} return { status: r.status, json: j }; };
|
||||
// the saved file is encrypted; decrypt it the same way the server does so the test can look inside
|
||||
const savedPlex = () => { try { const b = Buffer.from(JSON.parse(fs.readFileSync(path.join(dir, 'users', UID, 'plex.json'), 'utf8')).data, 'base64'), d = crypto.createDecipheriv('aes-256-gcm', crypto.scryptSync(SECRET, 'nehub-plex', 32), b.subarray(0, 12)); d.setAuthTag(b.subarray(12, 28)); return JSON.parse(Buffer.concat([d.update(b.subarray(28)), d.final()]).toString()); } catch { return null; } };
|
||||
|
||||
let hub = startHub(); await ready();
|
||||
|
||||
// ================= signing in, and staying signed in =================
|
||||
let r = await call('/api/plex/status'); ok('before signing in: not signed in', r.json.signedIn === false, r.json);
|
||||
r = await call('/api/plex/pin', {}); ok('starting a sign-in gives a code and a plex.tv link', r.json.id === 42 && /app\.plex\.tv\/auth/.test(r.json.authUrl), r.json);
|
||||
r = await call('/api/plex/pin?id=42&code=ABCD'); ok('while the person has not approved yet: not ready', r.json.ok === false, r.json);
|
||||
r = await call('/api/plex/pin?id=42&code=ABCD'); ok('after they approve: signed in', r.json.ok === true, r.json);
|
||||
r = await call('/api/plex/status'); ok('status: signed in, no server chosen yet', r.json.signedIn === true && r.json.server === null, r.json);
|
||||
r = await call('/api/plex/servers'); ok('their servers are listed', r.json.length === 1 && r.json[0].name === 'E-va' && r.json[0].id === 'sid1', r.json);
|
||||
r = await call('/api/plex/server', { id: 'sid1' }); ok('choosing the server connects', r.status === 200 && r.json.ok, r);
|
||||
let s = savedPlex(); ok('everything needed to stay signed in is saved (account sign-in, server, its token, its id)', s && s.token === 'ACCOUNT' && s.stk === 'SRV1' && s.sid === 'sid1' && s.name === 'E-va' && s.srv.startsWith('http://127.0.0.1:'), s);
|
||||
ok('and it is encrypted on disk', !fs.readFileSync(path.join(dir, 'users', UID, 'plex.json'), 'utf8').includes('ACCOUNT'));
|
||||
r = await call('/api/plex/pin?id=42&code=ABCD'); r = await call('/api/plex/status'); ok('polling the sign-in again later does not wipe the chosen server', r.json.signedIn && r.json.server === 'E-va', r.json);
|
||||
const tvBefore = tv.resourceCalls;
|
||||
r = await call('/plex/library/sections'); ok('Plex pages load through the hub', r.status === 200 && r.json.MediaContainer.Directory[0].title === 'Music', r);
|
||||
ok('a healthy sign-in makes no extra calls to plex.tv', tv.resourceCalls === tvBefore, [tv.resourceCalls, tvBefore]);
|
||||
hub.child.kill(); await wait(400); hub = startHub(); await ready();
|
||||
r = await call('/api/plex/status'); ok('AFTER A RESTART: still signed in, same server', r.json.signedIn === true && r.json.server === 'E-va', r.json);
|
||||
r = await call('/plex/library/sections'); ok('AFTER A RESTART: Plex pages still load', r.status === 200, r.status);
|
||||
|
||||
// ================= repairing itself =================
|
||||
tv.serverToken = 'SRV2'; const calls1 = tv.resourceCalls; // Plex rotates the server token
|
||||
r = await call('/plex/library/sections'); ok('a changed server token is repaired automatically (the page still loads)', r.status === 200, r.status);
|
||||
s = savedPlex(); ok('and the new token is saved', s.stk === 'SRV2' && s.token === 'ACCOUNT', s);
|
||||
ok('it asked plex.tv exactly once', tv.resourceCalls === calls1 + 1, [tv.resourceCalls, calls1]);
|
||||
r = await call('/plex/library/sections'); ok('the next request needs no repair', r.status === 200 && tv.resourceCalls === calls1 + 1, [r.status, tv.resourceCalls]);
|
||||
const parallel = await Promise.all([1, 2, 3, 4].map(() => call('/plex/library/sections'))); ok('several requests at once all work', parallel.every(x => x.status === 200), parallel.map(x => x.status));
|
||||
|
||||
const oldPlex = plex; plex = await makePlex(); tv.plexPort = plex.address().port; oldPlex.close(); await wait(100); // the server moves to a new address
|
||||
r = await call('/plex/library/sections'); ok('a server that moved to a new address is found again', r.status === 200, r.status);
|
||||
s = savedPlex(); ok('and the new address is saved', s.srv === `http://127.0.0.1:${tv.plexPort}`, s.srv);
|
||||
|
||||
hub.child.kill(); await wait(400); hub = startHub(); await ready();
|
||||
r = await call('/plex/library/sections'); ok('after another restart, still working on the repaired address', r.status === 200, r.status);
|
||||
|
||||
// ================= when it genuinely cannot continue =================
|
||||
tv.accountToken = 'CHANGED'; tv.serverToken = 'SRV3'; // Plex has revoked this account sign-in
|
||||
const calls2 = tv.resourceCalls;
|
||||
r = await call('/plex/library/sections'); ok('if plex.tv rejects the account sign-in, the request fails cleanly (401)', r.status === 401, r.status);
|
||||
r = await call('/api/plex/status'); ok('and the person is shown as signed out, so they are asked to sign in again', r.json.signedIn === false, r.json);
|
||||
ok('only one attempt was made', tv.resourceCalls === calls2 + 1, [tv.resourceCalls, calls2]);
|
||||
|
||||
// a dead server: do not hammer plex.tv
|
||||
tv.accountToken = 'ACCOUNT'; tv.serverToken = 'SRV1'; await call('/api/plex/pin?id=42&code=ABCD'); r = await call('/api/plex/server', { id: 'sid1' });
|
||||
ok('signing in again and choosing the server works', r.status === 200, r);
|
||||
plex.close(); await wait(100); const calls3 = tv.resourceCalls;
|
||||
r = await call('/plex/library/sections'); const afterFirst = tv.resourceCalls; r = await call('/plex/library/sections'); r = await call('/plex/library/sections');
|
||||
ok('with the server down it gives up quickly and does not hammer plex.tv (at most one lookup for three requests)', r.status >= 400 && tv.resourceCalls - calls3 <= 1 && tv.resourceCalls === afterFirst, [r.status, calls3, afterFirst, tv.resourceCalls]);
|
||||
ok('the saved sign-in is kept while the server is simply offline', (savedPlex() || {}).token === 'ACCOUNT', savedPlex());
|
||||
plex = await makePlex(); tv.plexPort = plex.address().port;
|
||||
r = await call('/api/plex/logout', {}); r = await call('/api/plex/status'); ok('signing out removes everything', r.json.signedIn === false && !fs.existsSync(path.join(dir, 'users', UID, 'plex.json')), r.json);
|
||||
|
||||
// a sign-in saved by an older version has no server id: it is matched by the server's name and the id is remembered
|
||||
const seal = o => { const iv = crypto.randomBytes(12), c = crypto.createCipheriv('aes-256-gcm', crypto.scryptSync(SECRET, 'nehub-plex', 32), iv); const d = Buffer.concat([c.update(JSON.stringify(o), 'utf8'), c.final()]); return Buffer.concat([iv, c.getAuthTag(), d]).toString('base64'); };
|
||||
fs.mkdirSync(path.join(dir, 'users', UID), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'users', UID, 'plex.json'), JSON.stringify({ data: seal({ token: 'ACCOUNT', srv: 'http://127.0.0.1:1', stk: 'STALE', name: 'E-va' }) })); // no sid, dead address, stale token
|
||||
await wait(31000 * 0 + 1); hub.child.kill(); await wait(400); hub = startHub(); await ready(); // a fresh process: no repair pause
|
||||
r = await call('/plex/library/sections'); s = savedPlex();
|
||||
ok('a sign-in saved by an older version (no server id) repairs itself by server name', r.status === 200 && s.sid === 'sid1' && s.stk === 'SRV1', [r.status, s]);
|
||||
|
||||
ok('the hub logged what it did (for support)', /\[plex\] (signed in|connected|repaired)/.test(hub.log), hub.log.split('\n').filter(l => /plex/.test(l)).slice(0, 4));
|
||||
hub.child.kill(); plex.close(); plexTv.close(); fs.rmSync(dir, { recursive: true, force: true });
|
||||
console.log(failed ? `\n${failed} check(s) failed` : '\nAll Plex checks passed');
|
||||
process.exit(failed ? 1 : 0);
|
||||
})().catch(e => { console.error('TEST CRASHED:', e); process.exit(1); });
|
||||
Reference in new issue
Block a user