Audiobooks: talk to OpenAudible directly with its own username and password (OPENAUDIBLE_URL)
build-container / build (push) Successful in 9m46s
build-container / build (push) Successful in 9m46s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9231315186
commit
aa2133ed9a
7 files changed
+86
-6
No files matched your search
@@ -1,10 +1,15 @@
|
||||
// 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.
|
||||
// Audiobooks: the library, covers, chapters, the audio itself and each person's listening position. Two ways to reach them:
|
||||
// - OPENAUDIBLE_URL set: the hub talks to OpenAudible directly, signing in with the username and password saved in Settings (OpenAudible's own login;
|
||||
// OPENAUDIBLE_USER / OPENAUDIBLE_PASSWORD are used when none are saved). The hub keeps each person's listening position itself.
|
||||
// - otherwise: through the audiobook player (https://git.necloud.us/nate/audiobook-player) at AUDIOBOOKS_URL, signing in to it as the person
|
||||
// (the account in Settings), or with the shared secret link (AUDIOBOOKS_LOGIN_LINK). The player keeps the positions.
|
||||
// No password or link ever reaches the browser.
|
||||
const { Readable } = require('stream');
|
||||
const fs = require('fs'), path = require('path');
|
||||
const settings = require('./settings');
|
||||
const users = require('./users');
|
||||
|
||||
const OA = (process.env.OPENAUDIBLE_URL || '').replace(/\/$/, ''); // direct mode
|
||||
const BASE = (process.env.AUDIOBOOKS_URL || 'https://audiobooks.necloud.us').replace(/\/$/, '');
|
||||
const LINK = process.env.AUDIOBOOKS_LOGIN_LINK || '';
|
||||
const COOKIE = 'oa_session';
|
||||
@@ -31,7 +36,27 @@ async function signIn(me) {
|
||||
sessions.set(me.id, ck); return ck;
|
||||
}
|
||||
|
||||
// Direct to OpenAudible: Basic auth with the saved login (or the server's), and a clear message when it is refused.
|
||||
async function direct(path, init = {}) {
|
||||
const c = settings.get('ab_creds') || {}, u = c.u || process.env.OPENAUDIBLE_USER || '', p = c.u ? c.p : process.env.OPENAUDIBLE_PASSWORD || '';
|
||||
const headers = { ...init.headers }; if (u) headers.authorization = 'Basic ' + Buffer.from(u + ':' + p).toString('base64');
|
||||
else throw Object.assign(new Error('Add your OpenAudible username and password in Settings first'), { code: 503 });
|
||||
const r = await fetch(OA + path, { ...init, headers, redirect: 'manual' });
|
||||
if (r.status === 401) { await r.body?.cancel().catch(() => {}); throw Object.assign(new Error('OpenAudible did not accept the username and password saved in Settings'), { code: 502 }); }
|
||||
return r;
|
||||
}
|
||||
|
||||
// Listening positions kept by the hub (direct mode): one file per person, the newest time wins for each book.
|
||||
const progFile = () => path.join(users.dir(), 'audiobook-progress.json');
|
||||
const progLoad = () => { try { return JSON.parse(fs.readFileSync(progFile(), 'utf8')); } catch { return {}; } };
|
||||
function progMerge(body) {
|
||||
const mine = progLoad();
|
||||
for (const [k, v] of Object.entries(body || {})) if (v && typeof v === 'object' && (v.t || 0) >= ((mine[k] || {}).t || 0)) mine[k] = { pos: +v.pos || 0, dur: +v.dur || 0, t: +v.t || 0 };
|
||||
const f = progFile(), tmp = f + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(mine), { mode: 0o600 }); fs.renameSync(tmp, f); return mine;
|
||||
}
|
||||
|
||||
async function upstream(me, path, init = {}, retry = true) {
|
||||
if (OA) return direct(path, init);
|
||||
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); }
|
||||
@@ -44,6 +69,13 @@ async function handle(req, res) {
|
||||
const ctl = new AbortController(); res.on('close', () => ctl.abort());
|
||||
try {
|
||||
if (p === '/progress') {
|
||||
if (OA) { // positions live in the hub
|
||||
if (req.method === 'GET') return json(res, 200, progLoad());
|
||||
if (req.method !== 'POST') return json(res, 405, { error: 'Method not allowed' });
|
||||
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); }
|
||||
let body; try { body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'); } catch { return json(res, 400, { error: 'Bad request' }); }
|
||||
return json(res, 200, progMerge(body));
|
||||
}
|
||||
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 });
|
||||
@@ -60,6 +92,16 @@ async function handle(req, res) {
|
||||
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);
|
||||
const want = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range || ''), total = +r.headers.get('content-length') || 0;
|
||||
if (OA && want && r.status === 200 && total && (want[1] || want[2])) { // OpenAudible sent the whole file: phones need byte ranges, so cut the piece out here
|
||||
const a = want[1] === '' ? Math.max(0, total - +want[2]) : +want[1], b = want[1] === '' || want[2] === '' ? total - 1 : Math.min(+want[2], total - 1);
|
||||
if (a > b || a >= total) { await r.body.cancel().catch(() => {}); res.writeHead(416, { 'content-range': 'bytes */' + total }); return res.end(); }
|
||||
res.writeHead(206, { ...out, 'content-range': `bytes ${a}-${b}/${total}`, 'content-length': String(b - a + 1), 'accept-ranges': 'bytes' });
|
||||
let at = 0; const rd = r.body.getReader();
|
||||
try { for (;;) { const { done, value } = await rd.read(); if (done) break; const end = at + value.length; if (end > a && at <= b) { if (!res.write(value.subarray(Math.max(0, a - at), Math.min(value.length, b + 1 - at)))) await new Promise(ok => res.once('drain', ok)); } at = end; if (at > b) break; } }
|
||||
finally { rd.cancel().catch(() => {}); res.end(); }
|
||||
return;
|
||||
}
|
||||
res.writeHead(r.status, out);
|
||||
if (!r.body) return res.end();
|
||||
Readable.fromWeb(r.body).on('error', () => res.destroy()).pipe(res);
|
||||
|
||||
Reference in new issue
Block a user