diff --git a/README.md b/README.md
index f15bd37..da5c007 100644
--- a/README.md
+++ b/README.md
@@ -61,6 +61,7 @@ e.g. hub.necloud.us. Settings: `SEAFILE_URL`, `ONLYOFFICE_URL` env vars in docke
- **Synced**: where you got to, bookmarks, highlights (four colours) and notes are kept on the server, so a book opens where you left off on any device; two devices adding notes at once keep both (the newest edit of the same note wins). Continue reading on the library page, sorting and search, and *Copy as text* exports a book's notes.
- **Book store** (Books -> Book store): search free, DRM-free books from **Project Gutenberg** (through Gutendex), **Standard Ebooks**, the **Internet Archive** (public-domain EPUBs only; borrow-only items are refused) and any **OPDS catalogue** an admin adds under *Catalogues* (Calibre-Web, Kavita, Komga and so on; an optional username and password are kept encrypted on the server and never sent to the browser). One search covers every source, results show their cover and source, and *Add to library* downloads the book on the server straight onto your shelf. The server only ever contacts a source's own addresses (redirects to other sites are refused), covers come through the hub, and a catalogue book must live on that catalogue's own address. Settings: `GUTENDEX_URL`, `STANDARD_EBOOKS_URL` and `ARCHIVE_URL` can point at mirrors. `node tools/store-test.js` tests it against fake catalogues; it has not been run against the live sites from the build environment, so if one of them changes its feed format, the line under the search box names the source that failed.
- The reader scripts are in `public/vendor/reader/` (epub.js, JSZip, pdf.js, mammoth, the MOBI parser) and load only when a book is opened or added. `node tools/books-test.js` tests the server; `python3 tools/mkbooks.py
` makes a small EPUB, PDF and MOBI to try. **DRM-protected books are refused** (a Kindle file with copy protection is detected on upload and the reason is shown); only DRM-free books work. The MOBI reader was tried on a generated MOBI 6 file, not on real Kindle files, so tell me about any book that opens oddly. Not included: text-to-speech, offline reading.
+ **Free audiobooks** (Audiobooks -> Free audiobooks; works without any audiobook sign-in): search **LibriVox**, thousands of public-domain books read aloud by volunteers, by title or author. A book shows its length, language, description and chapters; *Play* (or *Resume*) queues every chapter in the same player as Music so the next one follows by itself, and starting a book puts it on your shelf (*My free audiobooks*, with a progress bar; *Remove from shelf* in the book's sheet). Where you got to is kept on the server per person, so it follows you between devices. The hub searches LibriVox and hands the browser each chapter's address, but only addresses on LibriVox or the Internet Archive, which hosts the recordings; covers come through the hub. `LIBRIVOX_URL` points at a mirror. `node tools/librivox-test.js` tests it against a fake LibriVox; it has not been run against the live site from the build environment.
- **Signal**: your Signal chats inside the app, **on desktop-class screens only** (a window at least 900 px wide with a mouse or trackpad; phones and touch tablets don't
see it). Besides the Signal tab there is a chat button on **every** screen that opens a panel over whatever you are doing (close it with the x; the
expand button opens the full page). New messages get your attention with a toast, a chime, the unread count in the browser tab title and on the installed app's icon, and
diff --git a/librivox.js b/librivox.js
new file mode 100644
index 0000000..9999ae1
--- /dev/null
+++ b/librivox.js
@@ -0,0 +1,73 @@
+// 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 };
diff --git a/package.json b/package.json
index d2cbf99..987b91f 100644
--- a/package.json
+++ b/package.json
@@ -5,7 +5,7 @@
"main": "server.js",
"scripts": {
"start": "node server.js",
- "test": "for t in smoke tasks push users cal plex player lyrics signal mail mail-imap files podcast abapi abdirect books store; do node tools/$t-test.js || exit 1; done",
+ "test": "for t in smoke tasks push users cal plex player lyrics signal mail mail-imap files podcast abapi abdirect books store librivox; do node tools/$t-test.js || exit 1; done",
"audit": "npm audit --omit=dev --audit-level=high"
},
"keywords": [],
diff --git a/public/app.js b/public/app.js
index f9231a9..fb42710 100644
--- a/public/app.js
+++ b/public/app.js
@@ -198,7 +198,7 @@ function openMore() {
el.querySelectorAll('a.row').forEach(a => a.addEventListener('click', closeSheet));
}
-const routesOf = () => ({ video: viewVideo, planner: viewPlanner, home: viewHome, mail: viewMail, files: viewFiles, office: viewOffice, audio: args => args[0] === 'classic' ? viewAudio() : viewBooks(args), verse: () => location.replace('#/links'), podcast: args => viewPodcast(args), read: args => viewReader(args), music: viewMusic, tasks: viewTasks, search: viewSearch, calendar: viewCalendar, signal: viewSignal, links: viewLinks, settings: viewSettings, edit: viewEdit });
+const routesOf = () => ({ video: viewVideo, planner: viewPlanner, home: viewHome, mail: viewMail, files: viewFiles, office: viewOffice, audio: args => args[0] === 'classic' ? viewAudio() : args[0] === 'free' ? viewFreeBooks() : viewBooks(args), verse: () => location.replace('#/links'), podcast: args => viewPodcast(args), read: args => viewReader(args), music: viewMusic, tasks: viewTasks, search: viewSearch, calendar: viewCalendar, signal: viewSignal, links: viewLinks, settings: viewSettings, edit: viewEdit });
let editor;
// Menu on a tablet or computer: collapse it to icons (wide screens), or hide it completely. Remembered on this device.
const navPref = () => { try { return JSON.parse(localStorage.getItem('nav_pref')) || {}; } catch { return {}; } };
diff --git a/public/audiobooks-view.js b/public/audiobooks-view.js
index 58ee784..9cb46aa 100644
--- a/public/audiobooks-view.js
+++ b/public/audiobooks-view.js
@@ -67,7 +67,7 @@ async function viewBooks(args = []) {
abBooks = Array.isArray(data) ? data : (data.books || Object.values(data));
} catch (e) {
if (!location.hash.startsWith('#/audio')) return;
- v.innerHTML = `