Search streams the rest: after the first page the app asks for each further page as the server pages YouTube and appends it

This commit is contained in:
Jonathan Sykes
2026-10-15 22:50:31 +08:00
parent 17115dc8e7
commit a48ea300cd
2 changed files with 44 additions and 23 deletions

View File

@@ -403,6 +403,7 @@ const SEARCH_CACHE_TTL_MS = 10 * 60_000; // fresh: answered from memory
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? }>
const inflightProgress = new Map(); // lowercased q -> { cards, done, waiters } while pages are still arriving
const searchCacheCounters = { hits: 0, misses: 0 };
// The YouTube side of one search (up to 200 cards), shared by every caller asking
@@ -416,11 +417,15 @@ function fetchYoutube(q) {
if (inflightSearches.has(key)) return inflightSearches.get(key);
let answered = false, resolveEarly;
const early = new Promise((r) => { resolveEarly = r; });
const progress = { cards: [], done: false, waiters: new Set() };
const wake = () => { for (const w of progress.waiters) w(); progress.waiters.clear(); };
inflightProgress.set(key, progress);
const full = (async () => {
let yt = [];
if (process.env.SEARCH_INNERTUBE !== '0') {
try {
yt = await innertube.searchDeep(q, { limit: searchCacheDb.PER_QUERY, onPage: (cards) => {
progress.cards = cards; wake();
if (!answered && cards.length) { answered = true; resolveEarly({ yt: cards, fetchedAt: Date.now(), partial: true }); }
} });
} catch (e) {
@@ -444,7 +449,7 @@ function fetchYoutube(q) {
searchCacheDb.put(q, yt).catch((e) => console.warn(`[search] cache write: ${e.message}`));
}
return { yt, fetchedAt };
})().finally(() => inflightSearches.delete(key));
})().finally(() => { inflightSearches.delete(key); progress.done = true; wake(); setTimeout(() => { if (inflightProgress.get(key) === progress) inflightProgress.delete(key); }, 30_000); });
full.catch((e) => { if (answered) console.warn(`[search] background fill: ${e.message}`); });
const p = Promise.race([early, full]);
inflightSearches.set(key, p);
@@ -503,6 +508,17 @@ app.get('/api/search', async (c) => {
return new Response(Bun.gzipSync(body), { headers: { ...headers, 'Content-Encoding': 'gzip' } });
return new Response(body, { headers });
};
// after=N: the app shows N results and the rest are still being paged in.
// Answer as soon as there are more (≈ one page per second), or after 4 s;
// `partial` stays true until the last page is in.
const after = Number(c.req.query('after'));
if (Number.isFinite(after) && after > 0 && inflightProgress.has(key)) {
const pr = inflightProgress.get(key);
if (!pr.done && mine.length + pr.cards.length <= after) {
await new Promise((resolve) => { const t = setTimeout(resolve, 4000); pr.waiters.add(() => { clearTimeout(t); resolve(); }); });
}
return answer(pr.cards, pr.done ? { fetchedAt: Date.now() } : { fetchedAt: Date.now(), partial: true });
}
if (c.req.query('refresh') !== '1') {
let hit = searchCache.get(key);
if (!hit) {