IndexedDB search library (1000x100, 15-day copies, Update search), server video metadata for instant local results

This commit is contained in:
Claude
2026-10-02 02:15:28 +00:00
parent 1b10a994dd
commit ee248d2303
7 changed files with 225 additions and 56 deletions

View File

@@ -123,6 +123,17 @@ export async function initDb() {
);
CREATE INDEX IF NOT EXISTS idx_search_cache_lru ON search_cache (last_access);
-- Every video the server has ever seen in a search: lets a search show
-- known videos at once while YouTube is still loading.
CREATE TABLE IF NOT EXISTS video_meta (
id TEXT PRIMARY KEY,
card TEXT NOT NULL, -- the result card, JSON
hay TEXT NOT NULL, -- lowercased title + channel, for matching
seen INTEGER NOT NULL DEFAULT 1, -- times it appeared in a search
updated_at INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_video_meta_upd ON video_meta (updated_at);
-- Shared per-video documents (kind = lyrics | chapters), visible to every
-- user. The live copy is here; every save also lands in video_note_revs
-- as a full snapshot, which is the server-side backup and undo history.

View File

@@ -58,3 +58,38 @@ export async function stats() {
const t = (await db.execute('SELECT COUNT(*) AS n, COALESCE(SUM(size),0) AS b FROM search_cache')).rows[0];
return { queries: Number(t.n), bytes: Number(t.b), maxQueries: MAX_ROWS, maxBytes: MAX_BYTES };
}
// ---- Known-video metadata: instant results while YouTube loads ----
export const MAX_VIDEOS = Number(process.env.VIDEO_META_MAX) || 500_000;
export async function rememberVideos(cards) {
const now = Date.now();
const rows = (cards || []).filter((c) => c && typeof c.id === 'string' && c.title);
if (!rows.length) return;
await db.batch(rows.map((c) => ({
sql: `INSERT INTO video_meta (id, card, hay, seen, updated_at) VALUES (?,?,?,1,?)
ON CONFLICT(id) DO UPDATE SET card=excluded.card, hay=excluded.hay, seen=seen+1, updated_at=excluded.updated_at`,
args: [c.id, JSON.stringify(c), `${c.title} ${c.channel || ''}`.toLowerCase(), now],
})));
if (Math.random() < 0.02) trimVideos().catch(() => {});
}
// Cards whose title/channel contain EVERY word of the query, most-seen first.
export async function searchVideos(q, limit = 60) {
const words = String(q).toLowerCase().split(/\s+/).filter(Boolean).slice(0, 8);
if (!words.length) return [];
const like = (w) => '%' + w.replace(/[\\%_]/g, '\\$&') + '%';
const r = await db.execute({
sql: `SELECT card FROM video_meta WHERE ${words.map(() => "hay LIKE ? ESCAPE '\\'").join(' AND ')}
ORDER BY seen DESC, updated_at DESC LIMIT ?`,
args: [...words.map(like), limit],
});
return r.rows.map((x) => { try { return JSON.parse(x.card); } catch { return null; } }).filter(Boolean);
}
export async function trimVideos(max = MAX_VIDEOS) {
const n = Number((await db.execute('SELECT COUNT(*) AS n FROM video_meta')).rows[0].n);
if (n <= max) return 0;
await db.execute({ sql: 'DELETE FROM video_meta WHERE id IN (SELECT id FROM video_meta ORDER BY updated_at ASC LIMIT ?)', args: [n - max] });
return n - max;
}

View File

@@ -18,3 +18,14 @@ test('trim evicts least-recently-used rows over the row cap', async () => {
expect(dropped).toBeGreaterThan(0);
expect((await sc.stats()).queries).toBe(3);
});
test('known videos are remembered and found by every word', async () => {
await sc.rememberVideos([
{ id: 'a1', title: 'Way Maker - Sinach', channel: 'Worship' },
{ id: 'b2', title: 'Way Maker (live)', channel: 'Other' },
{ id: 'c3', title: 'Goodness of God', channel: 'Worship' },
]);
expect((await sc.searchVideos('way maker')).map((c) => c.id).sort()).toEqual(['a1', 'b2']);
expect((await sc.searchVideos('maker worship')).map((c) => c.id)).toEqual(['a1']);
expect(await sc.searchVideos('100%')).toEqual([]);
});

View File

@@ -428,6 +428,7 @@ function fetchYoutube(q) {
const fetchedAt = Date.now();
if (searchCache.size >= SEARCH_CACHE_MAX) searchCache.delete(searchCache.keys().next().value);
searchCache.set(key, { yt, fetchedAt });
if (yt.length) searchCacheDb.rememberVideos(yt).catch(() => {});
if (yt.length) searchCacheDb.put(q, yt).catch((e) => console.warn(`[search] cache write: ${e.message}`));
return { yt, fetchedAt };
})().finally(() => inflightSearches.delete(key));
@@ -440,6 +441,16 @@ const searchLibrary = async (q) => {
try { return (await notesDb.listUploads({ q, limit: 20 })).map(uploads.card); } catch { return []; }
};
// GET /api/search/local?q=<query> — videos this server already knows (from every
// earlier search) plus its own uploads; answers in milliseconds so the app can
// show something while the real search is still loading.
app.get('/api/search/local', async (c) => {
const q = (c.req.query('q') || '').trim();
if (!q) return c.json({ ok: false, error: 'empty query' }, 400);
const [mine, known] = await Promise.all([searchLibrary(q), searchCacheDb.searchVideos(q).catch(() => [])]);
return c.json({ ok: true, results: [...mine, ...known], local: true });
});
// GET /api/search?q=<query>[&refresh=1]
// Memory → persistent cache (fresh 10 min, then stale-while-revalidate up to 15
// days: answered instantly, refreshed in the background) → YouTube. `refresh=1`