IndexedDB search library (1000x100, 15-day copies, Update search), server video metadata for instant local results
This commit is contained in:
11
server/db.js
11
server/db.js
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
|
||||
@@ -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`
|
||||
|
||||
Reference in New Issue
Block a user