build-container / build (push) Canceled after 2m57s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
73 lines
4.7 KiB
JavaScript
73 lines
4.7 KiB
JavaScript
// Audiobooks from the audiobook player (https://git.necloud.us/nate/audiobook-player) at AUDIOBOOKS_URL: the library, covers, chapters, the audio itself
|
|
// and each person's listening position. The hub signs in to the player as the person (their audiobook sign-in from Settings), or, when they have
|
|
// none saved, with the site's shared secret link (AUDIOBOOKS_LOGIN_LINK), so no password or link ever reaches the browser.
|
|
const { Readable } = require('stream');
|
|
const settings = require('./settings');
|
|
const users = require('./users');
|
|
|
|
const BASE = (process.env.AUDIOBOOKS_URL || 'https://audiobooks.necloud.us').replace(/\/$/, '');
|
|
const LINK = process.env.AUDIOBOOKS_LOGIN_LINK || '';
|
|
const COOKIE = 'oa_session';
|
|
const sessions = new Map(); // person id -> the player's session cookie
|
|
const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); };
|
|
const cookieOf = r => { const c = (r.headers.getSetCookie ? r.headers.getSetCookie() : []).map(x => x.split(';')[0]).find(x => x.startsWith(COOKIE + '=')); return c || ''; };
|
|
|
|
// Hub path -> player path (the player itself only serves read-only calls for these)
|
|
const ROUTES = [
|
|
[/^\/books$/, () => '/api/books'],
|
|
[/^\/books\/([^/]+)\/(chapters\/flat|cover)$/, m => `/api/books/${m[1]}/${m[2]}`],
|
|
[/^\/books\/([^/]+)\/filelist$/, m => `/admin/api/books/${m[1]}/files`],
|
|
[/^\/books\/([^/]+)\/files\/([^/]+)$/, m => `/api/books/${m[1]}/files/${m[2]}`],
|
|
];
|
|
|
|
async function signIn(me) {
|
|
const c = settings.get('ab_creds') || {};
|
|
let r;
|
|
if (c.u && c.p) r = await fetch(BASE + '/login', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ username: c.u, password: c.p }), redirect: 'manual', signal: AbortSignal.timeout(15000) });
|
|
else if (LINK) r = await fetch(LINK, { redirect: 'manual', signal: AbortSignal.timeout(15000) });
|
|
else throw Object.assign(new Error('Add your audiobook sign-in in Settings first'), { code: 503 });
|
|
const ck = cookieOf(r);
|
|
if (!ck) throw Object.assign(new Error(c.u ? 'The audiobook site did not accept your saved sign-in' : 'The audiobook site did not accept the shared sign-in'), { code: 502 });
|
|
sessions.set(me.id, ck); return ck;
|
|
}
|
|
|
|
async function upstream(me, path, init = {}, retry = true) {
|
|
const ck = sessions.get(me.id) || await signIn(me);
|
|
const r = await fetch(BASE + path, { ...init, headers: { ...init.headers, cookie: ck }, redirect: 'manual' });
|
|
if ((r.status === 401 || r.status === 302) && retry) { sessions.delete(me.id); await r.body?.cancel().catch(() => {}); return upstream(me, path, init, false); }
|
|
return r;
|
|
}
|
|
|
|
async function handle(req, res) {
|
|
const me = users.current();
|
|
const u = new URL(req.url, 'http://x'), p = u.pathname.slice('/api/ab'.length) || '/';
|
|
const ctl = new AbortController(); res.on('close', () => ctl.abort());
|
|
try {
|
|
if (p === '/progress') {
|
|
if (req.method === 'POST') {
|
|
const chunks = []; let n = 0; for await (const c of req) { n += c.length; if (n > 1e6) return json(res, 413, { error: 'Too large' }); chunks.push(c); }
|
|
const r = await upstream(me, '/sync/progress', { method: 'POST', body: Buffer.concat(chunks), headers: { 'content-type': 'application/json' }, signal: ctl.signal });
|
|
res.writeHead(r.status, { 'content-type': 'application/json', 'cache-control': 'no-store' }); return res.end(Buffer.from(await r.arrayBuffer()));
|
|
}
|
|
if (req.method !== 'GET') return json(res, 405, { error: 'Method not allowed' });
|
|
const r = await upstream(me, '/sync/progress', { signal: ctl.signal });
|
|
res.writeHead(r.status, { 'content-type': 'application/json', 'cache-control': 'no-store' }); return res.end(Buffer.from(await r.arrayBuffer()));
|
|
}
|
|
if (req.method !== 'GET') return json(res, 405, { error: 'Method not allowed' });
|
|
let to = null; for (const [re, f] of ROUTES) { const m = re.exec(p); if (m) { to = f(m); break; } }
|
|
if (!to) return json(res, 404, { error: 'Not found' });
|
|
const headers = req.headers.range ? { range: req.headers.range } : {};
|
|
const r = await upstream(me, to, { headers, signal: ctl.signal });
|
|
const out = { 'cache-control': p.endsWith('/cover') ? 'private, max-age=86400' : 'no-store' };
|
|
for (const k of ['content-type', 'content-length', 'content-range', 'accept-ranges']) if (r.headers.get(k)) out[k] = r.headers.get(k);
|
|
res.writeHead(r.status, out);
|
|
if (!r.body) return res.end();
|
|
Readable.fromWeb(r.body).on('error', () => res.destroy()).pipe(res);
|
|
} catch (e) {
|
|
if (res.headersSent) return res.destroy();
|
|
json(res, e.code || 502, { error: e.code ? e.message : 'The audiobook site did not answer' });
|
|
}
|
|
}
|
|
|
|
module.exports = { handle };
|