Search: answer on the first YouTube page, fill the rest in background; never cache empty results; gzip search JSON

This commit is contained in:
Jonathan Sykes
2026-10-15 10:30:20 +08:00
parent c242641b2d
commit 5c94a7d46a
2 changed files with 49 additions and 13 deletions

View File

@@ -383,14 +383,22 @@ const inflightSearches = new Map(); // lowercased q -> Promise<{ yt, youtubeErro
// 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.
// Resolves as soon as the first InnerTube page arrives (~20 cards, under a
// second); the remaining pages keep loading in the background and land in the
// caches, so the next ask for the same query gets the full list. Paging all
// 200 cards before answering took 7-12 s and timed out on slow phone links.
function fetchYoutube(q) {
const key = q.toLowerCase();
if (inflightSearches.has(key)) return inflightSearches.get(key);
const p = (async () => {
let answered = false, resolveEarly;
const early = new Promise((r) => { resolveEarly = r; });
const full = (async () => {
let yt = [];
if (process.env.SEARCH_INNERTUBE !== '0') {
try {
yt = await innertube.searchDeep(q, { limit: searchCacheDb.PER_QUERY });
yt = await innertube.searchDeep(q, { limit: searchCacheDb.PER_QUERY, onPage: (cards) => {
if (!answered && cards.length) { answered = true; resolveEarly({ yt: cards, fetchedAt: Date.now(), partial: true }); }
} });
} catch (e) {
console.warn(`[search] innertube failed, using yt-dlp: ${e.message}`);
}
@@ -404,12 +412,17 @@ function fetchYoutube(q) {
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) await searchCacheDb.rememberVideos(yt);
if (yt.length) searchCacheDb.put(q, yt).catch((e) => console.warn(`[search] cache write: ${e.message}`));
// An empty answer is a transient failure, never a result worth caching.
if (yt.length) {
if (searchCache.size >= SEARCH_CACHE_MAX) searchCache.delete(searchCache.keys().next().value);
searchCache.set(key, { yt, fetchedAt });
await searchCacheDb.rememberVideos(yt);
searchCacheDb.put(q, yt).catch((e) => console.warn(`[search] cache write: ${e.message}`));
}
return { yt, fetchedAt };
})().finally(() => inflightSearches.delete(key));
full.catch((e) => { if (answered) console.warn(`[search] background fill: ${e.message}`); });
const p = Promise.race([early, full]);
inflightSearches.set(key, p);
return p;
}
@@ -439,7 +452,13 @@ app.get('/api/search', async (c) => {
if (!q) return c.json({ ok: false, error: 'empty query' }, 400);
const key = q.toLowerCase();
const mine = await searchLibrary(q);
const answer = (yt, extra = {}) => c.json({ ok: true, results: [...mine, ...yt], ...extra });
const answer = (yt, extra = {}) => {
const body = JSON.stringify({ ok: true, results: [...mine, ...yt], ...extra });
const headers = { 'Content-Type': 'application/json; charset=utf-8', Vary: 'Accept-Encoding' };
if (/\bgzip\b/.test(c.req.header('accept-encoding') || '') && body.length > 1024)
return new Response(Bun.gzipSync(body), { headers: { ...headers, 'Content-Encoding': 'gzip' } });
return new Response(body, { headers });
};
if (c.req.query('refresh') !== '1') {
let hit = searchCache.get(key);
if (!hit) {
@@ -454,8 +473,9 @@ app.get('/api/search', async (c) => {
}
}
try {
const { yt, fetchedAt } = await fetchYoutube(q);
return answer(yt, { fetchedAt });
const { yt, fetchedAt, partial } = await fetchYoutube(q);
if (!yt.length && !mine.length) return c.json({ ok: false, error: 'YouTube returned no results right now, try again' }, 503);
return answer(yt, partial ? { fetchedAt, partial: true } : { 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);