Music: use Plex as another person in your Plex Home (list, PIN, switch back), shown as Playing as <name>
build-container / build (push) Successful in 19m30s

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-22 09:31:47 -04:00
1 parent cedd6845ce
commit dae19176cd
6 files changed
+132 -10

No files matched your search

+1 -1
View File
@@ -46,7 +46,7 @@ e.g. hub.necloud.us. Settings: `SEAFILE_URL`, `ONLYOFFICE_URL` env vars in docke
for iOS 13+, so for most accounts only NE Hub lists are available.
- **Settings sync**: your preferences (mail addresses, audiobooks sign-in (encrypted), Plex library, Office save location, calendar/task views, recent files) are saved on the server in `/data/settings.json` and shared by every device. The Seafile sign-in token is saved there too (encrypted), so Settings shows Seafile as signed in on every device. Only the music queue stays in the browser.
- **Search**: libraries, file names (scans libraries if Seafile has no search index) and Plex artists/albums/tracks.
- **Music**: Plex player. Sign in goes through plex.tv (no password handled), the server proxies your Plex server and adds the token itself, and a mini player keeps playing as you move between tabs; the current and next two tracks are downloaded in full so changing songs is instant and a slow or lost connection does not stop playback; the Home dashboard and the Music tab show a live Now Playing card (follows the song, seek bar, shuffle/repeat, previous/next, lyrics, queue) above the rest; the queue (play next / add to queue) and position are remembered. The player reports what is playing to Plex (play, pause, stop, position every 10 s), so it appears under Now Playing / Activity in the Plex dashboard as "NE Hub", under the Plex account you signed in with. **Lyrics and karaoke**: the Lyrics button (on the Now Playing card, in the full player, and in the wide mini player) opens a full-screen lyrics view that follows the song, tap a line to jump to it, A−/A+ change the size, and **Karaoke** shows just the current line big with the colour sweeping across it as it is sung (word by word when the source has word timing, otherwise across the line). Lyrics come from Plex first if the song has them (embedded, a `.lrc` file next to it, or Plex Pass), otherwise from the bundled **Lyrica** service (`lyrica` container; sources LRCLIB, Musixmatch via Lrcmux, NetEase, Megalobiz; YouTube auto-captions are never used, and an answer for a recording of a very different length is refused). Lookups send the artist and title to those public sources and are kept in `/data/lyrics` so a song is looked up once. If the timing is off, the − / + buttons shift it half a second at a time (remembered per song); "Wrong lyrics?" lets you search with a corrected artist or title. Remove the `lyrica` service and the hub's `LYRICS_API_URL` line to skip the online lookup (Plex lyrics still work). No vocal removal.
- **Music**: Plex player. Sign in goes through plex.tv (no password handled), the server proxies your Plex server and adds the token itself, and a mini player keeps playing as you move between tabs; the current and next two tracks are downloaded in full so changing songs is instant and a slow or lost connection does not stop playback; the Home dashboard and the Music tab show a live Now Playing card (follows the song, seek bar, shuffle/repeat, previous/next, lyrics, queue) above the rest; the queue (play next / add to queue) and position are remembered. The player reports what is playing to Plex (play, pause, stop, position every 10 s), so it appears under Now Playing / Activity in the Plex dashboard as "NE Hub", under the Plex account you signed in with. **Plex Home users**: if your Plex account has a Plex Home (managed users, kids, guests), the Music tab shows "Playing as <name>" with a switch link; pick another person (and enter their PIN if they have one) and Music, playlists, ratings and the Plex dashboard follow that person. The account that signed in is kept so switching back needs no PIN. It is per hub person, remembered across restarts, and has only been tried against a fake plex.tv. **Lyrics and karaoke**: the Lyrics button (on the Now Playing card, in the full player, and in the wide mini player) opens a full-screen lyrics view that follows the song, tap a line to jump to it, A−/A+ change the size, and **Karaoke** shows just the current line big with the colour sweeping across it as it is sung (word by word when the source has word timing, otherwise across the line). Lyrics come from Plex first if the song has them (embedded, a `.lrc` file next to it, or Plex Pass), otherwise from the bundled **Lyrica** service (`lyrica` container; sources LRCLIB, Musixmatch via Lrcmux, NetEase, Megalobiz; YouTube auto-captions are never used, and an answer for a recording of a very different length is refused). Lookups send the artist and title to those public sources and are kept in `/data/lyrics` so a song is looked up once. If the timing is off, the − / + buttons shift it half a second at a time (remembered per song); "Wrong lyrics?" lets you search with a corrected artist or title. Remove the `lyrica` service and the hub's `LYRICS_API_URL` line to skip the online lookup (Plex lyrics still work). No vocal removal.
- **Audiobooks**: embeds audiobooks.necloud.us in a frame that keeps playing while you use other tabs, with reload, full screen and open-in-new-tab buttons.
Back-door sign-in: set `AUDIOBOOKS_LOGIN_LINK` to the site's secret link (the key is part of it). The frame loads `/api/audiobooks/enter`, which checks
the person may use the Audiobooks tab and redirects to that link, so the key stays in the server's environment and never appears in the app's page,
+39
View File
@@ -62,3 +62,42 @@ function sleepSheet() {
if (sleepT.end || sleepT.eot) opts.push({ label: 'Turn off the timer', icon: 'x', run: () => sleepSet(0) });
actionSheet('Sleep timer', sleepLabel() === 'Sleep' ? 'Fades the music out and pauses' : sleepLabel(), opts);
}
// ---- Plex Home: use Music as someone else in your Plex Home (their PIN if they have one)
const plexHome = { t: 0, data: null };
async function plexHomeUsers() {
if (plexHome.data && Date.now() - plexHome.t < 5 * 60e3) return plexHome.data;
plexHome.data = await jfetch('/api/plex/users'); plexHome.t = Date.now(); return plexHome.data;
}
/** After the Music header is drawn: "Playing as Nate" with a switch link, but only when the Plex Home has more than one person. */
async function plexHomeBadge(st) {
try {
const d = await plexHomeUsers(), el = document.getElementById('phu');
if (!el || !d.users || d.users.length < 2) return;
const me = d.users.find(x => x.current) || d.users.find(x => x.admin) || d.users[0];
el.innerHTML = ` · <a href="#" id="pswitch" style="color:var(--accent)">Playing as ${esc(me.title)} ▾</a>`;
document.getElementById('pswitch').onclick = e => { e.preventDefault(); plexSwitchSheet(d.users); };
} catch { /* no Plex Home: nothing to switch */ }
}
function plexSwitchSheet(users) {
const el = openSheet('Who is listening?', 'People in your Plex Home. Playlists, ratings and what you have played follow the person you pick.',
`<div class="list">${users.map((u, i) => `<button class="row" data-u="${i}"><span class="ic" style="background:var(--panel2);overflow:hidden;color:var(--text)">${u.thumb ? `<img src="${esc(u.thumb)}" alt="" referrerpolicy="no-referrer" style="width:100%;height:100%;object-fit:cover" onerror="this.remove()">` : esc([...u.title][0].toUpperCase())}</span>
<span class="grow"><b>${esc(u.title)}${u.current ? ' ✓' : ''}</b><small>${[u.admin ? 'Admin' : '', u.restricted ? 'Restricted' : '', u.protected && !u.admin ? 'PIN' : ''].filter(Boolean).join(' · ') || ' '}</small></span>${u.current ? '' : icon('chev', 'chev')}</button>`).join('')}</div>
<div class="bar"><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Close</button></div>`);
el.querySelector('#sx').onclick = closeSheet;
el.querySelectorAll('[data-u]').forEach(b => b.onclick = () => { const u = users[+b.dataset.u]; if (u.current) return closeSheet(); if (u.protected && !u.admin) plexPinSheet(u); else plexSwitchTo(u, ''); });
}
function plexPinSheet(u) {
const el = openSheet('PIN for ' + u.title, '', `<form id="ppf"><label for="ppi">PIN</label><input type="password" id="ppi" inputmode="numeric" autocomplete="off" maxlength="8" required>
<div class="bar"><button class="btn ghost" type="button" id="sx" style="flex:1;justify-content:center">Cancel</button><button class="btn" id="ppb" style="flex:1;justify-content:center">Switch</button></div><div class="err" id="ppe" role="alert"></div></form>`);
el.querySelector('#sx').onclick = closeSheet;
el.querySelector('#ppf').onsubmit = e => { e.preventDefault(); plexSwitchTo(u, el.querySelector('#ppi').value, msg => { el.querySelector('#ppe').textContent = msg; el.querySelector('#ppi').select(); }); };
setTimeout(() => el.querySelector('#ppi').focus(), 60);
}
async function plexSwitchTo(u, pin, onError) {
try {
await jfetch('/api/plex/switch', { method: 'POST', body: JSON.stringify({ id: u.id, pin }) });
closeSheet(); plexHome.data = null; px.secs = null;
toast('Now using Plex as ' + u.title); route();
} catch (e) { if (onError) onError(e.message); else toast(e.message); }
}
+2 -1
View File
@@ -108,8 +108,9 @@ async function musicRoute([kind = 'albums', key], st) {
const card = (href, thumb, title, sub, round) => `<a class="acard ${round ? 'round' : ''}" href="${href}" data-q="${esc((title + ' ' + sub).toLowerCase())}">
<img class="art" loading="lazy" alt="" src="${px.art(thumb)}"><b>${esc(title)}</b><small>${esc(sub)}</small></a>`;
const listing = (items, html) => {
v.innerHTML = `<div class="wrap"><h1>Music</h1><p class="sub">${esc(st.server)}${secs.length > 1 ? ` · <a href="#" id="plib" style="color:var(--accent)">${esc(cur.title)} ▾</a>` : ''}</p>${seg}<input type="search" id="mq" placeholder="Filter" autocomplete="off">
v.innerHTML = `<div class="wrap"><h1>Music</h1><p class="sub">${esc(st.server)}${secs.length > 1 ? ` · <a href="#" id="plib" style="color:var(--accent)">${esc(cur.title)} ▾</a>` : ''}<span id="phu"></span></p>${seg}<input type="search" id="mq" placeholder="Filter" autocomplete="off">
<div class="agrid" style="margin-top:14px">${html || '<p class="empty">Nothing here</p>'}</div></div>`;
plexHomeBadge(st);
const pl2 = $('#plib');
if (pl2) pl2.onclick = e => { e.preventDefault(); actionSheet('Music library', st.server, secs.map(x => ({ label: x.title + (x.key === cur.key ? ' ✓' : ''), icon: 'music',
run: () => { const m = store.get('plex_lib', {}); m[st.server] = x.key; store.set('plex_lib', m); location.hash = '#/music/albums'; route(); } }))); };
+1 -1
View File
@@ -118,7 +118,7 @@ function viewSettings() {
.catch(e => { const el = $('#sfst'); if (el) el.textContent = cfg.seafile + ' · ' + (sf.token() ? 'Signed in (could not check: ' + e.message + ')' : 'Signed out'); });
if (allowed('music')) jfetch('/api/plex/status').then(st => {
const el = $('#plexst'); if (!el) return;
el.textContent = st.signedIn ? (st.server ? 'Signed in · ' + st.server : 'Signed in · no server chosen') : 'Signed out';
el.textContent = st.signedIn ? (st.server ? 'Signed in · ' + st.server + (st.user ? ' · as ' + st.user.title : '') : 'Signed in · no server chosen') : 'Signed out';
if (st.signedIn) { const b = $('#plexout'); b.hidden = false; b.onclick = async () => { await jfetch('/api/plex/logout', { method: 'POST' }); pl.a.pause(); pl.q = []; pl.i = -1; renderMini(); toast('Signed out of Plex'); viewSettings(); }; }
}).catch(() => { const el = $('#plexst'); if (el) el.textContent = 'Unavailable'; });
if (isAdmin) { loadPeople(); $('#addperson').onclick = () => addPersonSheet(); }
+49 -1
View File
@@ -223,6 +223,21 @@ const plexTv = async (path, token, opts = {}) => {
return r.json();
};
const RESOURCES = '/api/v2/resources?includeHttps=1&includeRelay=1';
/** plex.tv's Home endpoints answer XML or JSON depending on the call; read either into plain objects. */
const attrs = tag => Object.fromEntries([...tag.matchAll(/([\w:-]+)="([^"]*)"/g)].map(m => [m[1], m[2].replace(/&amp;/g, '&').replace(/&quot;/g, '"').replace(/&lt;/g, '<').replace(/&gt;/g, '>')]));
async function plexTvAny(path, token, opts = {}) {
const r = await fetch(PLEX_TV + path, { ...opts, headers: { ...PLEX_HEADERS, ...(token ? { 'X-Plex-Token': token } : {}) } });
const text = await r.text();
if (!r.ok) { const e = new Error('plex.tv ' + r.status); e.status = r.status; e.body = text; throw e; }
try { return { json: JSON.parse(text), text }; } catch { return { json: null, text }; }
}
const truthy = v => v === true || v === 1 || v === '1' || v === 'true';
/** The people in this Plex Home: [{ id, title, thumb, admin, protected, restricted, guest }]. */
async function plexHomeUsers(token) {
const { json, text } = await plexTvAny('/api/home/users', token);
const raw = json ? (json.users || json.MediaContainer?.User || json.User || []) : [...text.matchAll(/<User\b[^>]*>/g)].map(m => attrs(m[0]));
return raw.map(u => ({ id: String(u.id), title: u.title || u.username || u.friendlyName || 'User', thumb: u.thumb || '', admin: truthy(u.admin), protected: truthy(u.protected) || truthy(u.hasPassword), restricted: truthy(u.restricted), guest: truthy(u.guest) }));
}
/** 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.
@@ -296,7 +311,7 @@ async function plexApi(req, res) {
const u = new URL(req.url, 'http://x'), st = plexLoad(), token = st.token;
try {
if (u.pathname === '/api/plex/status') {
return json(res, 200, { signedIn: !!token, server: st.srv ? st.name || 'Plex' : null });
return json(res, 200, { signedIn: !!token, server: st.srv ? st.name || 'Plex' : null, user: st.user ? { id: st.user.id, title: st.user.title, thumb: st.user.thumb, admin: st.user.admin } : null });
}
if (u.pathname === '/api/plex/pin' && req.method === 'POST') {
const pin = await plexTv('/api/v2/pins?strong=true', null, { method: 'POST' });
@@ -329,6 +344,39 @@ async function plexApi(req, res) {
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/users' && req.method === 'GET') { // who is in this Plex Home, and who is using NE Hub right now
let users; try { users = await plexHomeUsers(st.mainToken || token); } catch (e) { return json(res, 200, { users: [], current: st.user || null, note: e.status === 404 ? 'This Plex account has no Plex Home.' : 'Could not read the Plex Home: ' + e.message }); }
const cur = st.user ? st.user.id : (users.find(x => x.admin) || {}).id;
return json(res, 200, { users: users.map(x => ({ ...x, current: x.id === cur })), current: st.user || null });
}
if (u.pathname === '/api/plex/switch' && req.method === 'POST') { // use Plex as another person in the Home (their PIN if they have one)
if (!st.sid) return json(res, 400, { error: 'Choose a Plex server first' });
const { id, pin } = JSON.parse(await readBody(req) || '{}');
const owner = st.mainToken || token;
const target = (await plexHomeUsers(owner)).find(x => x.id === String(id));
if (!target) return json(res, 404, { error: 'That person is not in your Plex Home' });
if (target.admin && !st.mainToken) { plexSave({ ...st, user: { id: target.id, title: target.title, thumb: target.thumb, admin: true } }); return json(res, 200, { ok: true, user: { id: target.id, title: target.title, thumb: target.thumb, admin: true } }); } // already the account that signed in
let userToken;
if (target.admin && st.mainToken) userToken = st.mainToken; // back to the account that signed in: no PIN needed
else {
try {
const q = pin ? '?' + new URLSearchParams({ pin: String(pin).slice(0, 8) }) : '';
const { json: j, text } = await plexTvAny(`/api/home/users/${encodeURIComponent(target.id)}/switch${q}`, owner, { method: 'POST' });
userToken = (j && (j.authToken || j.authenticationToken || (j.user && (j.user.authToken || j.user.authenticationToken)))) || (text.match(/authenticationToken="([^"]+)"/) || [])[1];
} catch (e) {
if (e.status === 401 || e.status === 403) return json(res, 403, { error: target.protected || pin ? 'That PIN is not right' : 'Plex would not switch to that person' });
throw e;
}
if (!userToken) return json(res, 502, { error: 'Plex did not give a sign-in for that person' });
}
const rs = await plexTv(RESOURCES, userToken), r = rs.find(x => x.clientIdentifier === st.sid);
if (!r) return json(res, 403, { error: target.title + ' cannot use this Plex server' });
const c = await plexConnect(r);
if (!c.ok) return json(res, 502, { error: c.summary });
plexSave({ ...st, mainToken: st.mainToken || (target.admin ? undefined : token), token: userToken, srv: c.uri, stk: r.accessToken, user: { id: target.id, title: target.title, thumb: target.thumb, admin: target.admin } });
console.log(`[plex] now using Plex as ${target.title}`);
return json(res, 200, { ok: true, user: { id: target.id, title: target.title, thumb: target.thumb, admin: target.admin } });
}
if (u.pathname === '/api/plex/logout' && req.method === 'POST') {
plexWipe();
return json(res, 200, { ok: true });
+40 -6
View File
@@ -16,15 +16,26 @@ const jsonOut = (rs, code, o) => { rs.writeHead(code, { 'content-type': 'applica
(async () => {
// ---- fake plex.tv
const tv = { accountToken: 'ACCOUNT', serverToken: 'SRV1', plexPort: 0, pinPolls: 0, resourceCalls: 0 };
const tv = { switchCalls: [], homeJson: false, accountToken: 'ACCOUNT', serverToken: 'SRV1', plexPort: 0, pinPolls: 0, resourceCalls: 0 };
const plexTv = await listen((rq, rs) => {
const u = new URL(rq.url, 'http://x');
const u = new URL(rq.url, 'http://x'); let m;
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/home/users' && rq.method === 'GET') {
if (!['ACCOUNT'].includes(rq.headers['x-plex-token'])) { rs.writeHead(401); return rs.end(); }
if (tv.homeJson) return jsonOut(rs, 200, { users: [{ id: 1, title: 'Nate', admin: true, protected: false, thumb: 'https://plex.example/a.png' }, { id: 2, title: 'Kid', admin: false, protected: true, restricted: true }, { id: 3, title: 'Guest', admin: false, protected: false }] });
rs.writeHead(200, { 'content-type': 'application/xml' }); return rs.end('<?xml version="1.0"?><MediaContainer size="3"><User id="1" title="Nate" admin="1" protected="0" thumb="https://plex.example/a.png"/><User id="2" title="Kid" admin="0" protected="1" restricted="1"/><User id="3" title="Guest" admin="0" protected="0"/></MediaContainer>');
}
if ((m = /^\/api\/home\/users\/(\d+)\/switch$/.exec(u.pathname)) && rq.method === 'POST') {
tv.switchCalls.push(m[1] + ':' + (u.searchParams.get('pin') || '') + ':' + rq.headers['x-plex-token']);
if (m[1] === '2' && u.searchParams.get('pin') !== '1234') { rs.writeHead(403); return rs.end('<errors><error>bad pin</error></errors>'); }
rs.writeHead(200, { 'content-type': 'application/xml' }); return rs.end(`<user id="${m[1]}" authenticationToken="${m[1] === '2' ? 'KIDTOKEN' : 'GUESTTOKEN'}"/>`);
}
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,
const who = rq.headers['x-plex-token'], other = { KIDTOKEN: 'KIDSRV', GUESTTOKEN: 'GUESTSRV' }[who];
if (who !== tv.accountToken && !other) return jsonOut(rs, 401, { error: 'bad token' });
return jsonOut(rs, 200, [{ name: 'E-va', clientIdentifier: 'sid1', provides: 'server', owned: true, accessToken: other || 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();
@@ -35,7 +46,7 @@ const jsonOut = (rs, code, o) => { rs.writeHead(code, { 'content-type': 'applica
if (rq.url === '/identity') return jsonOut(rs, 200, { MediaContainer: { machineIdentifier: 'sid1' } });
plexReqs.push(rq.method + ' ' + rq.url);
seen.push(rq.headers['x-plex-token']);
if (rq.headers['x-plex-token'] !== tv.serverToken) { rs.writeHead(401); return rs.end(); }
if (![tv.serverToken, 'KIDSRV', 'GUESTSRV'].includes(rq.headers['x-plex-token'])) { 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;
@@ -54,7 +65,7 @@ const jsonOut = (rs, code, o) => { rs.writeHead(code, { 'content-type': 'applica
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);
let r, r2; 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);
@@ -82,6 +93,29 @@ const jsonOut = (rs, code, o) => { rs.writeHead(code, { 'content-type': 'applica
const refused = []; for (const [m, p] of bad) { const x = await call(p, undefined, m); refused.push(x.status); }
ok('but nothing else on the Plex server can be changed through the hub', refused.every(x => x >= 400) && !plexReqs.length, [refused, plexReqs]);
// ================= Plex Home: using Plex as someone else in the Home =================
r = await call('/api/plex/users'); ok('the Plex Home is listed (read from plex.tv\'s XML), with who is using NE Hub now', r.status === 200 && r.json.users.length === 3 && r.json.users.find(x => x.id === '1').current && r.json.users.find(x => x.id === '2').protected && r.json.users.find(x => x.id === '2').restricted && r.json.users.find(x => x.id === '1').admin, r.json);
tv.homeJson = true; r = await call('/api/plex/users'); ok('and the same when plex.tv answers JSON', r.json.users.length === 3 && r.json.users[1].title === 'Kid', r.json); tv.homeJson = false;
r = await call('/api/plex/switch', { id: '2' }); ok('a protected person needs their PIN', r.status === 403 && /PIN/.test(r.json.error), r);
r = await call('/api/plex/switch', { id: '2', pin: '0000' }); ok('a wrong PIN is refused', r.status === 403, r);
r = await call('/api/plex/status'); ok('and nothing changed after the failures', r.json.user === null || r.json.user.title === 'Nate', r.json);
r = await call('/api/plex/switch', { id: '99' }); ok('someone who is not in the Home is a 404', r.status === 404, r);
seen.length = 0; r = await call('/api/plex/switch', { id: '2', pin: '1234' }); ok('with the right PIN the hub switches to that person', r.status === 200 && r.json.user.title === 'Kid', r);
r = await call('/api/plex/status'); ok('status says who is using Plex now', r.json.user && r.json.user.title === 'Kid' && r.json.signedIn && r.json.server === 'E-va', r.json);
r = await call('/plex/library/sections'); ok('Plex requests now use that person\'s own server token', r.status === 200 && seen[seen.length - 1] === 'KIDSRV', [r.status, seen]);
s = savedPlex(); ok('the owner\'s sign-in is kept (to switch back, and to list the Home) and the new one is saved', s && s.mainToken === 'ACCOUNT' && s.token === 'KIDTOKEN' && s.stk === 'KIDSRV' && s.user.title === 'Kid', s);
r = await call('/api/plex/users'); ok('the list still works as the owner and marks the new current person', r.json.users.find(x => x.id === '2').current && !r.json.users.find(x => x.id === '1').current, r.json);
r = await call('/api/plex/switch', { id: '3' }); ok('switching straight to another person works (no PIN needed for them)', r.status === 200 && r.json.user.title === 'Guest', r);
ok('that switch was asked with the owner\'s token, not the first person\'s', tv.switchCalls[tv.switchCalls.length - 1] === '3::ACCOUNT', tv.switchCalls);
tv.switchCalls.length = 0; r = await call('/api/plex/switch', { id: '1' }); r = await call('/api/plex/status'); r2 = await call('/plex/library/sections');
ok('switching back to the owner needs no PIN and no call to plex.tv', r.json.user.title === 'Nate' && tv.switchCalls.length === 0 && r2.status === 200 && seen[seen.length - 1] === 'SRV1', [r.json, tv.switchCalls, seen.slice(-2)]);
s = savedPlex(); ok('and uses the owner\'s own tokens again', s.token === 'ACCOUNT' && s.stk === 'SRV1', s);
hub.child.kill(); await wait(400); hub = startHub(); await ready();
await call('/api/plex/switch', { id: '2', pin: '1234' }); hub.child.kill(); await wait(400); hub = startHub(); await ready();
r = await call('/api/plex/status'); ok('AFTER A RESTART: still using Plex as the chosen person', r.json.user && r.json.user.title === 'Kid', r.json);
r = await call('/plex/library/sections'); ok('AFTER A RESTART: their Plex pages load', r.status === 200, r.status);
await call('/api/plex/switch', { id: '1' });
// ================= 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);