// Free audiobooks from LibriVox (public-domain books read by volunteers). The hub searches LibriVox's own catalogue, hands the browser each chapter's audio // address (the recordings are hosted by the Internet Archive), keeps each person's shelf of saved books and where they got to in each one. const fs = require('fs'), path = require('path'); const users = require('./users'); const prog = require('./progress')('librivox-progress.json'); // keyed lv:: seconds into the whole book const BASE = () => (process.env.LIBRIVOX_URL || 'https://librivox.org').replace(/\/+$/, ''); const HOSTS = () => ['librivox.org', 'archive.org', new URL(BASE()).hostname]; const allowed = u => { try { const x = new URL(u); return /^https?:$/.test(x.protocol) && HOSTS().some(h => x.hostname === h || x.hostname.endsWith('.' + h)); } catch { return false; } }; const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); }; const fail = (code, message) => Object.assign(new Error(message), { code }); const plain = s => String(s || '').replace(/<[^>]+>/g, ' ').replace(/ /g, ' ').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/�?39;/g, "'").replace(/\s+/g, ' ').trim(); const shelfFile = () => path.join(users.dir(), 'librivox.json'); const shelf = () => { try { const l = JSON.parse(fs.readFileSync(shelfFile(), 'utf8')); return Array.isArray(l) ? l : []; } catch { return []; } }; const saveShelf = l => { const f = shelfFile(), tmp = f + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(l), { mode: 0o600 }); fs.renameSync(tmp, f); }; async function api(query) { // LibriVox answers 404 with a message when nothing matches const url = `${BASE()}/api/feed/audiobooks/?format=json&coverart=1&${query}`; let r; try { r = await fetch(url, { headers: { 'user-agent': 'NE-Hub/1.0', accept: 'application/json' }, signal: AbortSignal.timeout(12000) }); } catch (e) { throw fail(502, e.name === 'TimeoutError' ? 'LibriVox did not answer in time' : 'Could not reach LibriVox'); } const text = await r.text(); let j; try { j = JSON.parse(text); } catch { if (r.status === 404) return []; throw fail(502, 'LibriVox sent something unexpected'); } if (!r.ok || !Array.isArray(j.books)) { if (r.status === 404 || (j && j.error)) return []; throw fail(502, 'LibriVox answered ' + r.status); } return j.books; } const author = b => (b.authors || []).map(a => [a.first_name, a.last_name].filter(Boolean).join(' ')).join(', '); const summary = b => ({ id: String(b.id), title: plain(b.title), author: author(b), secs: Math.round(+b.totaltimesecs || 0), language: b.language || '', year: b.copyright_year || '', description: plain(b.description).slice(0, 800), cover: b.coverart_jpg || b.coverart_thumbnail || '', link: b.url_librivox || '' }); const cache = new Map(); // book id -> { t, book } (chapters included) async function book(id) { if (!/^\d{1,9}$/.test(id)) throw fail(400, 'Bad book'); const c = cache.get(id); if (c && Date.now() - c.t < 3600e3) return c.book; const [b] = await api(`id=${id}&extended=1`); if (!b) throw fail(404, 'Book not found'); const chapters = (b.sections || []).filter(s => allowed(s.listen_url)).map((s, i) => ({ n: i, title: plain(s.title) || 'Chapter ' + (i + 1), secs: Math.round(+s.playtime || 0), url: s.listen_url, reader: (s.readers || []).map(r => r.display_name).filter(Boolean).join(', ') })); const full = { ...summary(b), chapters }; if (cache.size > 100) cache.clear(); cache.set(id, { t: Date.now(), book: full }); return full; } const publicBook = b => ({ ...b, chapters: b.chapters.map(({ url, ...c }) => c), cover: b.cover ? `/api/ab/free/cover/${b.id}` : '' }); const readBody = async req => { const c = []; let n = 0; for await (const d of req) { n += d.length; if (n > 1e5) throw fail(413, 'Too large'); c.push(d); } try { return JSON.parse(Buffer.concat(c).toString('utf8') || '{}'); } catch { throw fail(400, 'Bad request'); } }; async function handle(req, res) { const u = new URL(req.url, 'http://x'), p = u.pathname.slice('/api/ab/free'.length) || '/'; try { let m; if (p === '/search' && req.method === 'GET') { const q = (u.searchParams.get('q') || '').trim().slice(0, 100), page = Math.min(50, Math.max(1, +u.searchParams.get('page') || 1)), off = (page - 1) * 20; const lists = q ? await Promise.all([api(`title=${encodeURIComponent(q)}&limit=20&offset=${off}`), q.split(/\s+/).length <= 2 ? api(`author=${encodeURIComponent(q.split(/\s+/).pop())}&limit=20&offset=${off}`) : []]) : [await api(`limit=20&offset=${off}`)]; const seen = new Set(), items = []; for (const b of lists.flat()) if (!seen.has(String(b.id))) { seen.add(String(b.id)); items.push(summary(b)); } return json(res, 200, { items: items.map(b => ({ ...b, cover: b.cover ? `/api/ab/free/cover/${b.id}?u=${encodeURIComponent(b.cover)}` : '' })), more: lists.flat().length >= 20 }); } if ((m = /^\/book\/(\d+)$/.exec(p)) && req.method === 'GET') return json(res, 200, publicBook(await book(m[1]))); if ((m = /^\/audio\/(\d+)\/(\d+)$/.exec(p)) && req.method === 'GET') { // the recording itself is on the Internet Archive; the browser is sent straight there (it supports seeking) const b = await book(m[1]), c = b.chapters[+m[2]]; if (!c) throw fail(404, 'No such chapter'); res.writeHead(302, { location: c.url, 'cache-control': 'private, max-age=600', 'referrer-policy': 'no-referrer' }); return res.end(); } if ((m = /^\/cover\/(\d+)$/.exec(p)) && req.method === 'GET') { let cu = u.searchParams.get('u'); if (!cu || !allowed(cu)) { const b = await book(m[1]); cu = b.cover; } if (!cu || !allowed(cu)) throw fail(404, 'No cover'); let r = await fetch(cu, { headers: { 'user-agent': 'NE-Hub/1.0' }, redirect: 'manual', signal: AbortSignal.timeout(10000) }); for (let i = 0; i < 4 && [301, 302, 303, 307, 308].includes(r.status); i++) { const to = new URL(r.headers.get('location') || '', cu).href; if (!allowed(to)) throw fail(502, 'Bad cover address'); cu = to; r = await fetch(cu, { headers: { 'user-agent': 'NE-Hub/1.0' }, redirect: 'manual', signal: AbortSignal.timeout(10000) }); } const type = (r.headers.get('content-type') || '').split(';')[0].trim().toLowerCase(); if (!r.ok || !/^image\/(jpeg|png|gif|webp)$/.test(type)) throw fail(404, 'No cover'); const buf = Buffer.from(await r.arrayBuffer()); if (buf.length > 3e6) throw fail(502, 'Cover too large'); res.writeHead(200, { 'content-type': type, 'cache-control': 'private, max-age=604800' }); return res.end(buf); } if (p === '/shelf' && req.method === 'GET') return json(res, 200, shelf().map(b => ({ ...b, cover: b.cover ? `/api/ab/free/cover/${b.id}` : '' }))); if (p === '/shelf' && req.method === 'POST') { const b = await book(String((await readBody(req)).id || '')), l = shelf().filter(x => x.id !== b.id); l.push({ id: b.id, title: b.title, author: b.author, secs: b.secs, language: b.language, cover: b.cover, added: Date.now() }); saveShelf(l); return json(res, 200, { saved: true }); } if ((m = /^\/shelf\/(\d+)$/.exec(p)) && req.method === 'DELETE') { saveShelf(shelf().filter(x => x.id !== m[1])); return json(res, 200, { removed: true }); } if (p === '/progress') { if (req.method === 'GET') return json(res, 200, prog.load()); if (req.method === 'POST') return json(res, 200, prog.merge(await readBody(req))); } return json(res, 404, { error: 'Not found' }); } catch (e) { return json(res, e.code >= 400 && e.code < 600 ? e.code : 502, { error: e.message }); } } module.exports = { handle };