diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index ad0ce18..7647ddf 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -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 diff --git a/server.js b/server.js index f634107..e2cd0c1 100644 --- a/server.js +++ b/server.js @@ -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'); diff --git a/tools/plex-test.js b/tools/plex-test.js new file mode 100644 index 0000000..bd5ce55 --- /dev/null +++ b/tools/plex-test.js @@ -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); });