Keep search results with thumbnails until cleared in Settings; add stale-while-revalidate search and longer asset caching

This commit is contained in:
Claude
2026-10-01 22:04:51 +00:00
parent 61a8f63385
commit 14e2307a92
6 changed files with 273 additions and 58 deletions

View File

@@ -396,46 +396,69 @@ 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 = 100;
const SEARCH_CACHE_TTL_MS = 3 * 60_000;
const SEARCH_CACHE_MAX = 200;
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? }>
// One search, shared by every caller asking the same thing at the same time.
function fetchSearch(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}`);
}
}
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;
}
})().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.
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 cacheKey = q.toLowerCase();
const cached = searchCache.get(cacheKey);
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 });
// 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 */ }
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 });
}
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}`);
}
}
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(cacheKey, { results, expiresAt: Date.now() + SEARCH_CACHE_TTL_MS });
return c.json({ ok: true, results });
return c.json({ ok: true, ...(await fetchSearch(q)) });
} catch (err) {
if (mine.length) return c.json({ ok: true, results: mine, youtubeError: err.message });
return c.json({ ok: false, error: err.message }, 500);
}
});
@@ -2172,7 +2195,11 @@ app.get('/*', async (c, next) => {
const ext = p.slice(p.lastIndexOf('.') + 1).toLowerCase();
return sendCompressed(c, compressedEntry(p, raw, MIME[ext] || 'application/octet-stream'), 'no-cache');
});
app.use('/*', serveStatic({ root: './public', onFound: (_path, c) => { c.header('Cache-Control', 'no-cache'); } }));
// Self-hosted fonts and icons rarely change: let the browser keep them for 30 days
// (the service worker precaches the shell anyway); everything else revalidates.
app.use('/*', serveStatic({ root: './public', onFound: (path, c) => {
c.header('Cache-Control', /(^|\/)(fonts|icons)\//.test(path) ? 'public, max-age=2592000' : 'no-cache');
} }));
// SPA fallback — return index.html for any unmatched path
app.get('/*', indexHtml);