From a48ea300cd76438a53b621dacdeabe1a6ac91e5a Mon Sep 17 00:00:00 2001 From: Jonathan Sykes Date: Thu, 15 Oct 2026 22:50:31 +0800 Subject: [PATCH] Search streams the rest: after the first page the app asks for each further page as the server pages YouTube and appends it --- frontend/app.js | 49 ++++++++++++++++++++++++++---------------------- server/server.js | 18 +++++++++++++++++- 2 files changed, 44 insertions(+), 23 deletions(-) diff --git a/frontend/app.js b/frontend/app.js index 1c73537..40ffe12 100755 --- a/frontend/app.js +++ b/frontend/app.js @@ -583,8 +583,8 @@ async function opfsClear() { let _currentBlobUrl = null; const API = { - search: (query, { refresh = false } = {}) => WEB - ? webFetch(`/api/search?q=${encodeURIComponent(query)}${refresh ? '&refresh=1' : ''}`) + search: (query, { refresh = false, after = 0 } = {}) => WEB + ? webFetch(`/api/search?q=${encodeURIComponent(query)}${refresh ? '&refresh=1' : ''}${after ? `&after=${after}` : ''}`) : call('yt.search', 'yt_search', { query }), related: (meta, { refresh = false } = {}) => WEB && /^[\w-]{11}$/.test(meta.id) ? webFetch(`/api/related?${new URLSearchParams({ videoId: meta.id, title: meta.title || '', channel: meta.channel || meta.artist || '', ...(refresh ? { refresh: '1' } : {}) })}`) @@ -8118,26 +8118,31 @@ function wireUI() { if (refresh) toast('Search updated'); perfMeasure('ytp:search', 'ytp:search-start'); // The server answered with YouTube's first page and is still fetching - // the rest; pick up the full list quietly once it has landed. - if (res.partial) setTimeout(async () => { - if (mySeq !== searchSeq || view.type !== 'search') return; - try { - const more = await API.search(q); - const full = merged(more?.results || []); - if (mySeq !== searchSeq || !more?.ok || full.length <= results.length) return; - const shown = searchResults; - const prefix = shown.length <= full.length && shown.every((v, i) => full[i] && full[i].id === v.id); - if (prefix && els.cards.children.length) { - // Grow the same array, so cards already drawn queue the full list too. - const from = shown.length; - shown.push(...full.slice(from)); - searchResults = shown; - appendListCards(shown, from); - } else { searchResults = full; renderList(); } - RecentSearches.cacheResults(q, full, Date.now()); - showSearchMeta(q, Date.now()); - } catch { /* keep the first page */ } - }, 10000); + // the rest; keep asking for anything beyond what we have (the server holds + // each ask until the next page lands) and append every page as it comes. + if (res.partial) (async () => { + let have = (res.results || []).length; // what the server has sent so far + for (let round = 0; round < 40 && mySeq === searchSeq && view.type === 'search'; round++) { + let more; + try { more = await API.search(q, { after: have }); } catch { return; /* keep what is shown */ } + if (mySeq !== searchSeq || !more?.ok) return; + const got = more.results || []; + if (got.length > have) { + have = got.length; + const full = merged(got); + const shown = searchResults; + const prefix = shown.length <= full.length && shown.every((v, i) => full[i] && full[i].id === v.id); + if (prefix && els.cards.children.length) { + // Grow the same array, so cards already drawn queue the full list too. + const from = shown.length; + shown.push(...full.slice(from)); + appendListCards(shown, from); + } else { searchResults = full; renderList(); } + showSearchMeta(q, Date.now()); + } + if (!more.partial) { RecentSearches.cacheResults(q, searchResults, Date.now()); return; } + } + })(); } catch (err) { networkDone = true; if (mySeq !== searchSeq) return; diff --git a/server/server.js b/server/server.js index d99586d..d178022 100644 --- a/server/server.js +++ b/server/server.js @@ -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) {