Open Library in the book store; podcast topic search and transcript search; Recipes (Mealie) with a shopping list synced to Tasks; Contacts linked to Mail and the Calendar
build-container / build (push) Successful in 7m21s

- Store: Open Library search, books fetched from the Internet Archive (free editions only)
- Podcast: Podcast Index discovery by name and topic (optional key); search what is said across transcripts and play from that moment
- Recipes: browse, import from a link, add ingredients to Mealie's shopping list, mirrored two ways with a Shopping list in Tasks
- Contacts: per-person address book, vCard import/export, recipient suggestions in Mail, birthdays in the Calendar

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-26 08:55:32 -04:00
1 parent 0617664d97
commit 176b1b8169
28 files changed
+799 -46

No files matched your search

+16 -3
View File
@@ -1,4 +1,4 @@
// Book store: free, DRM-free books from Project Gutenberg (its own catalogue feed), Standard Ebooks (its public pages), the Internet Archive (public-domain EPUBs) and any OPDS catalogue an
// Book store: free, DRM-free books from Project Gutenberg (its own catalogue feed), Standard Ebooks (its public pages), the Internet Archive (public-domain EPUBs), Open Library (its catalogue of free-to-read editions, which are held by the Internet Archive) and any OPDS catalogue an
// 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');
@@ -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 },
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' };
const PREFER = ['epub', 'azw3', 'mobi', 'azw', 'pdf'];
@@ -133,10 +134,21 @@ async function archiveResolve(src, ref) {
return { url: `${src.base}/download/${ref}/${f.name.split('/').map(encodeURIComponent).join('/')}`, format: 'epub' };
}
async function openLibrarySearch(src, q, page) {
const j = await getJson(src, `${src.base}/search.json?q=${encodeURIComponent(q)}&has_fulltext=true&fields=key,title,author_name,cover_i,ia,ebook_access&limit=20&page=${page}`);
const docs = j.docs || [], items = [];
for (const d of docs) { // only editions anyone may read (not "borrowable", which has DRM); the Internet Archive identifier is what gets downloaded
const ia = [].concat(d.ia || []).find(x => /^[\w.-]+$/.test(x)); if (!ia || d.ebook_access !== 'public') continue;
items.push({ ref: ia, format: 'epub', title: d.title || ia, author: [].concat(d.author_name || []).slice(0, 3).join(', '), summary: '', cover: d.cover_i ? `https://covers.openlibrary.org/b/id/${+d.cover_i}-M.jpg` : '' });
}
return { items, more: page * 20 < (j.numFound || 0) };
}
async function searchOne(src, q, page) {
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 === '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 || '' });
@@ -176,13 +188,14 @@ async function handle(req, res, p) {
}
if (p === '/add' && req.method === 'POST') {
const b = await readBody(req), src = sourceOf(String(b.source || '')), ref = String(b.ref || '');
let target;
let target, dl = src;
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 === '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(src, target.url, { accept: 'application/epub+zip, application/octet-stream, */*;q=0.5', timeout: 120000 }), MAX_BOOK);
const buf = 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);