Persistent server search cache: 200 results per query, 250k queries, 15-day stale-while-revalidate, refresh=1
This commit is contained in:
100
server/server.js
100
server/server.js
@@ -56,6 +56,7 @@ import { registerIntakeRoutes } from './p2p-intake.js';
|
||||
import { createP2pHub, holdersPayload, createRehydrator } from './p2p-hub.js';
|
||||
import { sha256Range } from './hash.js';
|
||||
import * as innertube from './innertube.js';
|
||||
import * as searchCacheDb from './search-cache.js';
|
||||
import QRCode from 'qrcode';
|
||||
import { createYtdlpPool } from './ytdlp-pool.js';
|
||||
import { dirname, join as pathJoin } from 'node:path';
|
||||
@@ -396,69 +397,78 @@ app.get('/api/version', (c) =>
|
||||
// resolveStreams()' streamCache below, just keyed by normalized query text
|
||||
// instead of videoId. Only a full success is cached, so a transient yt-dlp
|
||||
// failure still gets retried on the next request.
|
||||
const SEARCH_CACHE_MAX = 200;
|
||||
const SEARCH_CACHE_MAX = 200; // hot in-memory layer
|
||||
const SEARCH_CACHE_TTL_MS = 10 * 60_000; // fresh: answered from memory
|
||||
const SEARCH_CACHE_STALE_MS = 6 * 3600_000; // stale-while-revalidate window
|
||||
const searchCache = new Map(); // lowercased q -> { results, expiresAt }
|
||||
const inflightSearches = new Map(); // lowercased q -> Promise<{ results, youtubeError? }>
|
||||
const SEARCH_CACHE_STALE_MS = 15 * 24 * 3600_000; // persistent copy: stale-while-revalidate for 15 days
|
||||
const searchCache = new Map(); // lowercased q -> { yt, fetchedAt } (YouTube cards only)
|
||||
const inflightSearches = new Map(); // lowercased q -> Promise<{ yt, youtubeError? }>
|
||||
|
||||
// One search, shared by every caller asking the same thing at the same time.
|
||||
function fetchSearch(q) {
|
||||
// The YouTube side of one search (up to 200 cards), shared by every caller asking
|
||||
// the same thing at the same time, and written to memory + the persistent cache.
|
||||
function fetchYoutube(q) {
|
||||
const key = q.toLowerCase();
|
||||
if (inflightSearches.has(key)) return inflightSearches.get(key);
|
||||
const p = (async () => {
|
||||
// This server's own library first — and it still answers when YouTube
|
||||
// (yt-dlp) is unreachable or rate-limited.
|
||||
let mine = [];
|
||||
try { mine = (await notesDb.listUploads({ q, limit: 20 })).map(uploads.card); } catch { /* library optional */ }
|
||||
try {
|
||||
let yt = [];
|
||||
if (process.env.SEARCH_INNERTUBE !== '0') {
|
||||
try {
|
||||
yt = await innertube.search(q);
|
||||
} catch (e) {
|
||||
console.warn(`[search] innertube failed, using yt-dlp: ${e.message}`);
|
||||
}
|
||||
let yt = [];
|
||||
if (process.env.SEARCH_INNERTUBE !== '0') {
|
||||
try {
|
||||
yt = await innertube.searchDeep(q, { limit: searchCacheDb.PER_QUERY });
|
||||
} catch (e) {
|
||||
console.warn(`[search] innertube failed, using yt-dlp: ${e.message}`);
|
||||
}
|
||||
if (!yt.length) {
|
||||
const out = await runYtdlpResilient([
|
||||
`ytsearch${SEARCH_LIMIT}:${q}`,
|
||||
'--dump-json', '--flat-playlist',
|
||||
'--no-warnings', '--ignore-errors',
|
||||
], { pooled: true });
|
||||
yt = parseCards(out);
|
||||
}
|
||||
const results = [...mine, ...yt];
|
||||
if (searchCache.size >= SEARCH_CACHE_MAX) searchCache.delete(searchCache.keys().next().value);
|
||||
searchCache.set(key, { results, expiresAt: Date.now() + SEARCH_CACHE_TTL_MS });
|
||||
return { results };
|
||||
} catch (err) {
|
||||
if (mine.length) return { results: mine, youtubeError: err.message };
|
||||
throw err;
|
||||
}
|
||||
if (!yt.length) {
|
||||
const out = await runYtdlpResilient([
|
||||
`ytsearch${SEARCH_LIMIT}:${q}`,
|
||||
'--dump-json', '--flat-playlist',
|
||||
'--no-warnings', '--ignore-errors',
|
||||
], { pooled: true });
|
||||
yt = parseCards(out);
|
||||
}
|
||||
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.put(q, yt).catch((e) => console.warn(`[search] cache write: ${e.message}`));
|
||||
return { yt, fetchedAt };
|
||||
})().finally(() => inflightSearches.delete(key));
|
||||
inflightSearches.set(key, p);
|
||||
return p;
|
||||
}
|
||||
|
||||
// GET /api/search?q=<query>
|
||||
// Fresh cache → instant. Stale (up to 6 h) → answered instantly from the old
|
||||
// copy while a refresh runs in the background, so a repeat search never waits
|
||||
// on yt-dlp/InnerTube. Only a full success is cached, so a transient failure
|
||||
// still gets retried on the next request.
|
||||
const searchLibrary = async (q) => {
|
||||
// This server's own library first — it still answers when YouTube is unreachable.
|
||||
try { return (await notesDb.listUploads({ q, limit: 20 })).map(uploads.card); } catch { return []; }
|
||||
};
|
||||
|
||||
// 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`
|
||||
// (the app's "Update search" button) skips every cache. Only a full success is
|
||||
// cached, so a transient failure gets retried on the next request.
|
||||
app.get('/api/search', async (c) => {
|
||||
const q = (c.req.query('q') || '').trim();
|
||||
if (!q) return c.json({ ok: false, error: 'empty query' }, 400);
|
||||
const cached = searchCache.get(q.toLowerCase());
|
||||
const age = cached ? Date.now() - (cached.expiresAt - SEARCH_CACHE_TTL_MS) : Infinity;
|
||||
if (cached && Date.now() < cached.expiresAt) return c.json({ ok: true, results: cached.results });
|
||||
if (cached && age < SEARCH_CACHE_STALE_MS) {
|
||||
fetchSearch(q).catch(() => { /* keep serving the stale copy */ });
|
||||
return c.json({ ok: true, results: cached.results, stale: true });
|
||||
const key = q.toLowerCase();
|
||||
const mine = await searchLibrary(q);
|
||||
const answer = (yt, extra = {}) => c.json({ ok: true, results: [...mine, ...yt], ...extra });
|
||||
if (c.req.query('refresh') !== '1') {
|
||||
let hit = searchCache.get(key);
|
||||
if (!hit) {
|
||||
const row = await searchCacheDb.get(q).catch(() => null);
|
||||
if (row) { hit = { yt: row.results, fetchedAt: row.fetchedAt }; searchCache.set(key, hit); }
|
||||
}
|
||||
const age = hit ? Date.now() - hit.fetchedAt : Infinity;
|
||||
if (hit && age < SEARCH_CACHE_TTL_MS) return answer(hit.yt, { fetchedAt: hit.fetchedAt });
|
||||
if (hit && age < SEARCH_CACHE_STALE_MS) {
|
||||
fetchYoutube(q).catch(() => { /* keep serving the stale copy */ });
|
||||
return answer(hit.yt, { stale: true, fetchedAt: hit.fetchedAt });
|
||||
}
|
||||
}
|
||||
try {
|
||||
return c.json({ ok: true, ...(await fetchSearch(q)) });
|
||||
const { yt, fetchedAt } = await fetchYoutube(q);
|
||||
return answer(yt, { fetchedAt });
|
||||
} catch (err) {
|
||||
if (mine.length) return c.json({ ok: true, results: mine, youtubeError: err.message });
|
||||
return c.json({ ok: false, error: err.message }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user