Chores, Family page, genre search, child accounts, wider Search and Notifications center
build-container / build (push) Successful in 4m6s

- Chores tab with repeats, points and a week/month board
- Family page: calendar, chores, shopping, notes, chat and tasks on one start page
- Genre search in the Store, Free Books and LibriVox
- Child accounts: safe content, daily limit and bedtime, ask a parent
- Search now covers notes, chat, contacts, chores, recipes and your books
- Notifications center: bell with a per-person list of chat, reminders and announcements
- Roadmap, README and tests updated

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-26 11:48:55 -04:00
1 parent cf292e3bc7
commit 0383da87f5
38 files changed
+720 -79

No files matched your search

+22 -2
View File
@@ -1,7 +1,7 @@
// 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 users = require('./users'), genres = require('./genres');
const prog = require('./progress')('librivox-progress.json'); // keyed lv:<book id>: seconds into the whole book
const BASE = () => (process.env.LIBRIVOX_URL || 'https://librivox.org').replace(/\/+$/, '');
@@ -23,6 +23,18 @@ async function api(query) { // LibriVox answers 404 with a message when nothing
}
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 || '' });
// ---- genres: LibriVox's own genre filter does not work, so the genres of its catalogue are gathered once (a page of 100 books at a time, with their genres) into a small
// index kept in the data folder, and refreshed weekly in the background. Searching by genre looks through that index.
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data'), IDX = () => path.join(DATA_DIR, 'librivox-index.json');
let idx = null, building = null;
const loadIdx = () => { if (idx) return idx; try { const j = JSON.parse(fs.readFileSync(IDX(), 'utf8')); if (j && Array.isArray(j.books)) idx = j; } catch { /* none yet */ } return idx; };
async function crawl() {
const pages = +process.env.LIBRIVOX_INDEX_PAGES || 30, books = [], seen = new Set();
for (let i = 0; i < pages; i += 2) for (const l of await Promise.all([i, i + 1].filter(n => n < pages).map(n => api(`extended=1&limit=100&offset=${n * 100}`).catch(() => [])))) for (const b of l) if (!seen.has(b.id)) { seen.add(b.id); books.push({ ...summary(b), genres: (b.genres || []).map(g => g.name).filter(Boolean) }); }
if (books.length) { idx = { t: Date.now(), books }; fs.mkdirSync(path.dirname(IDX()), { recursive: true }); const tmp = IDX() + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(idx)); fs.renameSync(tmp, IDX()); }
}
function ensureIndex() { const ix = loadIdx(); if ((!ix || Date.now() - ix.t > 7 * 864e5) && !building) building = crawl().catch(e => console.warn('[librivox] index:', e.message)).finally(() => { building = null; }); return ix; }
const inGenre = (b, g) => b.genres.some(n => g.lv.test(n));
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');
@@ -38,6 +50,14 @@ 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 === '/genres' && req.method === 'GET') { const ix = ensureIndex(); return json(res, 200, { building: !!building, ready: !!ix, genres: genres.list().map(g => ({ ...g, count: ix ? ix.books.filter(b => inGenre(b, genres.byId(g.id))).length : undefined })) }); }
const me0 = users.current(), safe = !!(me0 && me0.kid && me0.kid.on && me0.kid.safe); if (safe && p === '/search' && !genres.KID.includes(u.searchParams.get('genre') || '')) u.searchParams.set('genre', 'children'); // (a child account browses a few safe genres)
if (p === '/search' && req.method === 'GET' && u.searchParams.get('genre')) { // by genre: from the index
const g = genres.byId(u.searchParams.get('genre')), q = (u.searchParams.get('q') || '').trim().slice(0, 100).toLowerCase(), page = Math.min(50, Math.max(1, +u.searchParams.get('page') || 1)); if (!g) return json(res, 200, { items: [], more: false });
const ix = ensureIndex(); if (!ix) return json(res, 200, { items: [], more: false, building: true });
const words = q.split(/\s+/).filter(Boolean), hits = ix.books.filter(b => inGenre(b, g) && words.every(w => (b.title + ' ' + b.author + ' ' + b.description).toLowerCase().includes(w)));
return json(res, 200, { items: hits.slice((page - 1) * 20, page * 20).map(({ genres: gs, ...b }) => ({ ...b, cover: b.cover ? `/api/ab/free/cover/${b.id}?u=${encodeURIComponent(b.cover)}` : '' })), more: hits.length > page * 20, building: !!building, total: hits.length });
}
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}`)];
@@ -83,4 +103,4 @@ async function handle(req, res) {
} catch (e) { return json(res, e.code >= 400 && e.code < 600 ? e.code : 502, { error: e.message }); }
}
module.exports = { handle, book, allowed };
module.exports = { handle, book, allowed, crawl, loadIdx };