Store: Smashwords as a family-filtered book source; free audiobooks stream through the hub; extensions and Sites on Home
build-container / build (push) Failing after 1m30s

- Smashwords: free EPUBs from its shelf feed, filtered (adult/erotica flags, LGBTQ+, keywords, unlabelled romance); admin saves a Smashwords sign-in (encrypted) used to download
- LibriVox chapter audio is fetched and streamed by the hub (with seeking) instead of redirecting the browser to archive.org
- Home: a My apps section with installed extensions and Sites, also in the command palette

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-26 10:14:13 -04:00
1 parent 3c42d1ecd5
commit dc8265a850
11 files changed
+223 -15

No files matched your search

+13 -5
View File
@@ -2,7 +2,7 @@
// admin adds (Calibre-Web, Kavita, ...). The hub server does all the talking to those sites: searching, covers and downloading a chosen book straight onto the
// signed-in person's shelf (books.js), so the browser never contacts them and no catalogue sign-in reaches the browser.
const fs = require('fs'), path = require('path'), crypto = require('crypto');
const users = require('./users'), auth = require('./auth'), books = require('./books');
const users = require('./users'), auth = require('./auth'), books = require('./books'), smashwords = require('./smashwords');
const UA = 'NE-Hub/1.0 (self-hosted e-book reader)';
const MAX_BOOK = 300 * 1024 * 1024, MAX_COVER = 3 * 1024 * 1024, MAX_TEXT = 4 * 1024 * 1024;
@@ -11,6 +11,7 @@ const BUILTIN = {
gutenberg: { name: 'Project Gutenberg', base: () => env('GUTENBERG_URL', 'https://www.gutenberg.org'), hosts: ['gutenberg.org'] }, // its own catalogue feed (fast and official)
standardebooks: { name: 'Standard Ebooks', base: () => env('STANDARD_EBOOKS_URL', 'https://standardebooks.org'), hosts: ['standardebooks.org'] }, // its public pages: the OPDS feed is for paying members only
archive: { name: 'Internet Archive', base: () => env('ARCHIVE_URL', 'https://archive.org'), hosts: ['archive.org'], needsQuery: true },
smashwords: { name: 'Smashwords', base: () => env('SMASHWORDS_URL', 'https://www.smashwords.com'), hosts: ['smashwords.com', 'dwtr67e3ikfml.cloudfront.net'] }, // free EPUBs, with a family filter; downloading needs an account (see smashwords.js)
openlibrary: { name: 'Open Library', base: () => env('OPENLIBRARY_URL', 'https://openlibrary.org'), hosts: ['openlibrary.org'], needsQuery: true }, // search here; the book itself comes from the Internet Archive
};
const FORMAT_OF = { 'application/epub+zip': 'epub', 'application/pdf': 'pdf', 'application/x-mobipocket-ebook': 'mobi', 'application/vnd.amazon.mobi8-ebook': 'azw3', 'application/vnd.amazon.ebook': 'azw' };
@@ -144,15 +145,16 @@ async function openLibrarySearch(src, q, page) {
return { items, more: page * 20 < (j.numFound || 0) };
}
async function searchOne(src, q, page) {
async function searchOne(src, q, page, everywhere) {
if (src.id === 'gutenberg') return gutenbergSearch(src, q, page);
if (src.id === 'standardebooks') return standardSearch(src, q, page);
if (src.id === 'archive') return q ? archiveSearch(src, q, page) : { items: [], more: false };
if (src.id === 'smashwords') return smashwords.search(q, page, !everywhere);
if (src.id === 'openlibrary') return q ? openLibrarySearch(src, q, page) : { items: [], more: false };
return opdsSearch(src, q, page);
}
const stamp = (src, it) => ({ ...it, source: src.id, sourceName: src.name, cover: it.cover ? `/api/read/store/cover?s=${encodeURIComponent(src.id)}&u=${encodeURIComponent(it.cover)}` : '', coverUrl: it.cover || '' });
const listSources = () => [...Object.keys(BUILTIN), ...customs().map(c => c.id)].map(id => { const s = sourceOf(id); return { id, name: s.name, builtin: s.builtin, needsQuery: !!s.needsQuery }; });
const listSources = () => [...Object.keys(BUILTIN), ...customs().map(c => c.id)].map(id => { const s = sourceOf(id); return { id, name: s.name, builtin: s.builtin, needsQuery: !!s.needsQuery, ...(id === 'smashwords' ? { account: smashwords.account() } : {}) }; });
async function handle(req, res, p) {
const me = users.current();
@@ -168,6 +170,11 @@ async function handle(req, res, p) {
catch (e) { saveCustoms(customs().filter(x => x.id !== id)); throw fail(e.code || 502, e.code ? e.message : 'Could not read that catalogue: ' + e.message); }
return json(res, 200, { id, name });
}
if (p === '/smashwords' && req.method === 'POST') { // the Smashwords sign-in (admin): checked by signing in, then kept encrypted
if (!me || me.role !== 'admin') throw fail(403, 'Only an admin can add the Smashwords sign-in');
const b = await readBody(req); return json(res, 200, await smashwords.signIn(b.email, b.pass));
}
if (p === '/smashwords' && req.method === 'DELETE') { if (!me || me.role !== 'admin') throw fail(403, 'Only an admin can remove the Smashwords sign-in'); smashwords.signOut(); return json(res, 200, smashwords.account()); }
let m;
if ((m = /^\/sources\/(\w+)$/.exec(p)) && req.method === 'DELETE') {
if (!me || me.role !== 'admin') throw fail(403, 'Only an admin can remove a catalogue');
@@ -176,7 +183,7 @@ async function handle(req, res, p) {
if (p === '/search' && req.method === 'GET') {
const u = new URL(req.url, 'http://x'), q = (u.searchParams.get('q') || '').trim().slice(0, 120), page = Math.min(20, Math.max(1, +u.searchParams.get('page') || 1)), want = u.searchParams.get('source') || 'all';
const ids = want === 'all' ? listSources().map(s => s.id) : [want], lists = [], errors = {}; let more = false;
await Promise.all(ids.map(async id => { let src; try { src = sourceOf(id); const r = await searchOne(src, q, page); lists.push([id, r.items.map(it => stamp(src, it))]); more = more || r.more; } catch (e) { const nm = src ? src.name : id, msg = e.name === 'TimeoutError' ? 'did not answer in time' : e.message; errors[id] = msg.startsWith(nm) ? msg : nm + ': ' + msg; } }));
await Promise.all(ids.map(async id => { let src; try { src = sourceOf(id); const r = await searchOne(src, q, page, want === 'all'); lists.push([id, r.items.map(it => stamp(src, it))]); more = more || r.more; } catch (e) { const nm = src ? src.name : id, msg = e.name === 'TimeoutError' ? 'did not answer in time' : e.message; errors[id] = msg.startsWith(nm) ? msg : nm + ': ' + msg; } }));
lists.sort((a, b) => ids.indexOf(a[0]) - ids.indexOf(b[0]));
const items = []; for (let i = 0; lists.some(l => l[1][i]); i++) for (const l of lists) if (l[1][i]) items.push(l[1][i]); // one from each catalogue in turn
return json(res, 200, { items, errors, more });
@@ -192,10 +199,11 @@ async function handle(req, res, p) {
if (src.id === 'gutenberg') target = await gutenbergResolve(src, ref);
else if (src.id === 'standardebooks') target = await standardResolve(src, ref);
else if (src.id === 'archive') target = await archiveResolve(src, ref);
else if (src.id === 'smashwords') target = { format: 'epub' };
else if (src.id === 'openlibrary') { dl = sourceOf('archive'); target = await archiveResolve(dl, ref); } // (Open Library's free editions are Internet Archive items)
else { if (!sameSite(src, new URL(ref, src.base))) throw fail(400, 'Bad book address'); target = { url: ref, format: String(b.format || 'epub') }; }
const ext = books.TYPES[target.format] ? target.format : 'epub';
const buf = await limited(await get(dl, target.url, { accept: 'application/epub+zip, application/octet-stream, */*;q=0.5', timeout: 120000 }), MAX_BOOK);
const buf = src.id === 'smashwords' ? await smashwords.download(ref) : await limited(await get(dl, target.url, { accept: 'application/epub+zip, application/octet-stream, */*;q=0.5', timeout: 120000 }), MAX_BOOK);
let cover; const cu = String(b.cover || ''); if (cu) { try { const r = await get(src, cu, { accept: 'image/*' }), type = (r.headers.get('content-type') || '').split(';')[0].trim().toLowerCase(); cover = { type, data: await limited(r, MAX_COVER) }; } catch {} }
let book; try { book = books.addBook(buf, ext, { title: String(b.title || '').slice(0, 200), author: String(b.author || '').slice(0, 200) }, cover); } catch (e) { throw fail(400, e.message); }
return json(res, 200, book);