Seafile sign-in follows the person across devices; sturdier Plex player; audiobooks frame with server-side secret sign-in
build-container / build (push) Successful in 3m16s

- Seafile token is saved on the server (encrypted) so Settings shows the real signed-in state everywhere
- Player: whole-track cache for current and next two, stall recovery, instant track changes, shuffle/repeat, Plex play counts, transcode fallback, Plex request timeout
- Audio tab: toolbar, full-height kept-alive frame; /api/audiobooks/enter adds the secret login link on the server

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-21 08:26:18 -04:00
1 parent 1a29d500d3
commit bc3710807d
8 files changed
+263 -44

No files matched your search

+20 -1
View File
@@ -10,6 +10,18 @@ const PORT = process.env.PORT || 8080;
const SEAFILE_URL = (process.env.SEAFILE_URL || 'https://seafile.necloud.us').replace(/\/$/, '');
const ONLYOFFICE_URL = (process.env.ONLYOFFICE_URL || 'https://onlyoffice.necloud.us').replace(/\/$/, '');
const AUDIOBOOKS_URL = (process.env.AUDIOBOOKS_URL || 'https://audiobooks.necloud.us').replace(/\/$/, '');
const AUDIOBOOKS_SSO_URL = process.env.AUDIOBOOKS_SSO_URL || AUDIOBOOKS_URL + '/sso';
const AUDIOBOOKS_SSO_SECRET = process.env.AUDIOBOOKS_SSO_SECRET || '';
// Back-door sign-in for the audiobooks site. AUDIOBOOKS_LOGIN_LINK is the site's own secret link (the key is part of it); anyone who has it
// is signed in there with full access, so it lives only in the server's environment: never sent to the page, never logged.
// Alternatively AUDIOBOOKS_SSO_SECRET makes short-lived signed links per person.
const AUDIOBOOKS_LOGIN_LINK = process.env.AUDIOBOOKS_LOGIN_LINK || '';
function audiobooksLogin(user) {
if (AUDIOBOOKS_LOGIN_LINK) return AUDIOBOOKS_LOGIN_LINK;
if (!AUDIOBOOKS_SSO_SECRET) return '';
const body = Buffer.from(JSON.stringify({ u: user.name, id: user.id, exp: Math.floor(Date.now() / 1000) + 120, n: crypto.randomBytes(8).toString('hex') })).toString('base64url');
return AUDIOBOOKS_SSO_URL + '?token=' + body + '.' + crypto.createHmac('sha256', AUDIOBOOKS_SSO_SECRET).update(body).digest('base64url');
}
const auth = require('./auth');
const cal = require('./cal');
const tasks = require('./tasks');
@@ -306,7 +318,9 @@ async function plexProxy(req, res) {
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,
// A server that accepts the connection but never answers must not leave the player waiting forever: give up after 15 s without a response.
const attempt = async s => { const hc = new AbortController(), t = setTimeout(() => hc.abort(), 15000); try { return await tryFetch(s, AbortSignal.any([ac.signal, hc.signal])); } finally { clearTimeout(t); } };
const tryFetch = (s, signal) => fetch(s.srv + req.url.slice('/plex'.length), { 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; }
@@ -381,6 +395,7 @@ const ACCESS = [
[/^\/sf\//, ['files', 'office']],
[/^\/api\/oo\/config$/, ['office', 'files']],
[/^\/(api\/plex\/|plex\/)/, ['music']],
[/^\/api\/audiobooks\//, ['audio']],
[/^\/api\/cal\//, ['calendar']],
[/^\/api\/tasks/, ['tasks']],
[/^\/api\/(search|ddg)$/, ['search']],
@@ -424,6 +439,10 @@ async function handleRequest(req, res) {
if (pathname === '/api/search') return search.handle(req, res);
if (req.url.startsWith('/api/tasks')) return tasks.handle(req, res);
if (req.url.startsWith('/api/cal/')) return cal.handle(req, res);
if (pathname === '/api/audiobooks/enter') { // the secret sign-in link is added here, on the server, and never appears in the app's page or script
const to = audiobooksLogin(user);
res.writeHead(302, { location: to || AUDIOBOOKS_URL, 'cache-control': 'no-store', 'referrer-policy': 'no-referrer' }); return res.end();
}
if (req.url.startsWith('/api/plex/')) return plexApi(req, res);
if (req.url.startsWith('/plex/') && req.method === 'GET') return plexProxy(req, res);
if (req.url.startsWith('/sf/')) return proxySeafile(req, res);