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

+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);