Plex: try the server's LAN address first, allow a manual address
build-container / build (push) Successful in 2m6s

Connecting through plex.direct names failed from the container (DNS and
hairpin NAT). Try http://<lan-ip>:<port> first, verify the server's
machine identifier before sending the token, and let the user type an
address when discovery fails.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-20 14:20:39 -04:00
1 parent 4456c03cd0
commit 732b73677b
2 files changed
+37 -12

No files matched your search

+13 -2
View File
@@ -550,11 +550,22 @@ async function plexPickServer() {
if (!servers.length) { v.innerHTML = '<div class="wrap"><h1>Music</h1><p class="err">No Plex servers found on this account.</p></div>'; return; } if (!servers.length) { v.innerHTML = '<div class="wrap"><h1>Music</h1><p class="err">No Plex servers found on this account.</p></div>'; return; }
v.innerHTML = `<div class="wrap"><h1>Music</h1><p class="sub">Choose a server</p><div class="list">${servers.map(s => ` v.innerHTML = `<div class="wrap"><h1>Music</h1><p class="sub">Choose a server</p><div class="list">${servers.map(s => `
<button class="row" data-id="${esc(s.id)}"><span class="ic" style="background:#e5a00d">${icon('music')}</span> <button class="row" data-id="${esc(s.id)}"><span class="ic" style="background:#e5a00d">${icon('music')}</span>
<span class="grow"><b>${esc(s.name)}</b><small>${s.owned ? 'Yours' : 'Shared with you'}</small></span>${icon('chev', 'chev')}</button>`).join('')}</div><div class="err" id="pe"></div></div>`; <span class="grow"><b>${esc(s.name)}</b><small>${s.owned ? 'Yours' : 'Shared with you'}</small></span>${icon('chev', 'chev')}</button>`).join('')}</div><div class="err" id="pe"></div><div id="pm"></div></div>`;
v.querySelectorAll('button.row').forEach(b => b.onclick = async () => { v.querySelectorAll('button.row').forEach(b => b.onclick = async () => {
b.disabled = true; $('#pe').textContent = ''; b.disabled = true; $('#pe').textContent = '';
try { await jfetch('/api/plex/server', { method: 'POST', body: JSON.stringify({ id: b.dataset.id }) }); px.sec = null; route(); } try { await jfetch('/api/plex/server', { method: 'POST', body: JSON.stringify({ id: b.dataset.id }) }); px.sec = null; route(); }
catch (e) { b.disabled = false; $('#pe').textContent = e.message; } catch (e) {
b.disabled = false; $('#pe').textContent = e.message;
// Offer a hand-typed address (e.g. the server's LAN address) when automatic discovery fails.
$('#pm').innerHTML = `<p class="sub">Know the server's address? Enter it (for example <b>http://192.168.1.10:32400</b>).</p>
<form id="pf2"><input type="url" id="pu" placeholder="http://192.168.1.10:32400" autocomplete="off" autocapitalize="off" required>
<div class="bar"><button class="btn">Try this address</button></div></form>`;
$('#pf2').onsubmit = async ev => {
ev.preventDefault(); $('#pe').textContent = '';
try { await jfetch('/api/plex/server', { method: 'POST', body: JSON.stringify({ id: b.dataset.id, uri: $('#pu').value.trim() }) }); px.sec = null; route(); }
catch (e2) { $('#pe').textContent = e2.message; }
};
}
}); });
} catch (e) { if (e.status === 401) return plexSignIn(); v.innerHTML = `<div class="wrap"><h1>Music</h1><p class="err">${esc(e.message)}</p></div>`; } } catch (e) { if (e.status === 401) return plexSignIn(); v.innerHTML = `<div class="wrap"><h1>Music</h1><p class="err">${esc(e.message)}</p></div>`; }
} }
+24 -10
View File
@@ -193,23 +193,37 @@ async function plexApi(req, res) {
return json(res, 200, rs.filter(r => (r.provides || '').includes('server')).map(r => ({ id: r.clientIdentifier, name: r.name, owned: r.owned }))); 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') { if (u.pathname === '/api/plex/server' && req.method === 'POST') {
const { id } = JSON.parse(await readBody(req)); 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('/api/v2/resources?includeHttps=1&includeRelay=1', token)).find(x => x.clientIdentifier === id);
if (!r) return json(res, 404, { error: 'Server not found' }); if (!r) return json(res, 404, { error: 'Server not found' });
// Prefer direct remote connections, then relay, then local; first one that answers wins. // Candidates, best first: the server's LAN address over plain http (no DNS, works from a container on the
const order = c => (c.local ? 2 : c.relay ? 1 : 0); // same network), then its plex.direct names, then the public address, then Plex's relay.
let cands = [];
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)' }); }
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 = []; const tried = [];
for (const c of [...r.connections].sort((a, b) => order(a) - order(b))) { for (const c of cands) {
const label = (c.local ? 'local ' : c.relay ? 'relay ' : 'remote ') + new URL(c.uri).hostname.replace(/\.[0-9a-f]{20,}\.plex\.direct$/, '');
try { try {
const t = await fetch(c.uri + '/identity', { headers: { ...PLEX_HEADERS, 'X-Plex-Token': r.accessToken }, signal: AbortSignal.timeout(6000) }); // /identity needs no token, so the token is never sent to a host before we know it is this server.
if (!t.ok) { tried.push(`${label}: HTTP ${t.status}`); continue; } 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; }
const mid = (await t.json().catch(() => ({}))).MediaContainer?.machineIdentifier;
if (mid !== id) { tried.push(`${c.label}: a different server answered`); continue; }
setCookies(req, res, { plex_srv: signed(c.uri), plex_stk: r.accessToken, plex_name: r.name }); setCookies(req, res, { plex_srv: signed(c.uri), plex_stk: r.accessToken, plex_name: r.name });
return json(res, 200, { ok: true, name: r.name }); return json(res, 200, { ok: true, name: r.name, via: c.label });
} catch (e) { tried.push(`${label}: ${e.cause?.code || e.name || e.message}`); } } catch (e) { tried.push(`${c.label}: ${e.cause?.code || e.name || e.message}`); }
} }
console.error('Plex connect failed for', r.name, tried); console.error('Plex connect failed for', r.name, tried);
return json(res, 502, { error: 'Could not reach this server. Tried: ' + tried.slice(0, 4).join('; ') }); return json(res, 502, { error: 'Could not reach this server. Tried: ' + tried.slice(0, 8).join('; ') });
} }
if (u.pathname === '/api/plex/logout' && req.method === 'POST') { if (u.pathname === '/api/plex/logout' && req.method === 'POST') {
setCookies(req, res, { plex_token: '', plex_srv: '', plex_stk: '', plex_name: '' }); setCookies(req, res, { plex_token: '', plex_srv: '', plex_stk: '', plex_name: '' });