--- id: 005-warm-streams-on-intent-47b3d3 title: Warm the stream cache for likely next plays created: 2026-09-29 depends_on: [004-coalesce-stream-resolves-a92d40] est_files: 2 --- # 005 — Warm the stream cache for likely next plays ## Objective A cold `/api/streams` costs ~6 s of yt-dlp; a cached one ~0 s (1.2 s total on prod, all network). Start that work before the user taps: - new `GET /api/streams/warm?v=` → `204` immediately, resolves in the background (shares the in-flight promise from plan 004), at most 2 warm resolves at a time, skipped when the server already holds a ready media copy. - the client warms: the top 3 search results after a search renders; a card on `pointerdown` (touch/mouse down fires ~100–300 ms before `click`); the next 2 queue items when a song starts playing. ## Context the executor must NOT rediscover - `server/server.js` `resolveStreams(videoId)` returns a Promise and dedupes (plan 004). - `media.getReady(videoId)` (server/media-cache.js) resolves the ready row or null. - `server/server.js:532` — `app.get('/api/streams', async (c) => {` — register the new route directly ABOVE it (Hono matches `/api/streams/warm` separately anyway). - Client `frontend/app.js`: - `const YT_ID_RE = /^[A-Za-z0-9_-]{11}$/;` at ~line 1202 (declared later in the file than the helper you add — fine, it is only read at call time). - `runSearchQuery` success path (~line 8603): `searchResults = results; … renderList(); RecentSearches.cacheResults(q, results);` - `renderCard(v, index, list)` at ~line 7725; it has `card.addEventListener('click', (e) => {` at ~line 7781. - globals `queue` (array of video objects) and `queueIndex` (~line 353). - master `playing` listener at ~line 2287 (`el.addEventListener('playing', () => { if (!masterIs(el)) return; …`). - `WEB` constant is true for the PWA; `cachedIds` is a Set of ids saved on this device. ## Steps 1. `server/server.js` — above `app.get('/api/streams', …)` add: ```js // GET /api/streams/warm?v= — fire-and-forget: resolve streams into // streamCache so the real /api/streams a moment later is instant. Bounded // so a scrolling user can't queue dozens of yt-dlp processes. const WARM_MAX = 2; let warmActive = 0; app.get('/api/streams/warm', async (c) => { const id = (c.req.query('v') || '').trim(); if (!/^[A-Za-z0-9_-]{11}$/.test(id)) return c.body(null, 204); if (warmActive >= WARM_MAX) return c.body(null, 204); try { if (await media.getReady(id)) return c.body(null, 204); } catch { /* fall through */ } warmActive++; resolveStreams(id).catch(() => {}).finally(() => { warmActive--; }); return c.body(null, 204); }); ``` 2. `frontend/app.js` — directly after the `const API = { … };` object (~line 320) add: ```js // Pre-resolve streams for videos the user is likely to play next (server // /api/streams/warm). Each id is warmed at most once per 20 min per tab. const warmedAt = new Map(); function warmStreams(ids) { if (!WEB || navigator.onLine === false) return; const now = Date.now(); for (const id of ids) { if (!id || !/^[A-Za-z0-9_-]{11}$/.test(id) || cachedIds.has(id)) continue; if (now - (warmedAt.get(id) || 0) < 20 * 60_000) continue; warmedAt.set(id, now); fetch(`/api/streams/warm?v=${encodeURIComponent(id)}`, { priority: 'low' }).catch(() => {}); } } ``` 3. `frontend/app.js` `runSearchQuery` — after `RecentSearches.cacheResults(q, results);` add `warmStreams(results.slice(0, 3).map((r) => r.id));` 4. `frontend/app.js` `renderCard` — directly BEFORE `card.addEventListener('click', (e) => {` add: ```js card.addEventListener('pointerdown', () => warmStreams([v.id]), { passive: true }); ``` 5. `frontend/app.js` master `playing` listener — after `if (!masterIs(el)) return;` (and after the plan-001 perf lines if present) add: ```js if (Array.isArray(queue) && queueIndex >= 0) warmStreams(queue.slice(queueIndex + 1, queueIndex + 3).map((x) => x && x.id)); ``` ## Out of scope / do NOT touch - Do not warm on hover/scroll, do not warm uploads (`upl_…`) — they need no yt-dlp. - Do not change `/api/streams` itself. ## Verification ```bash cd /home/user/ytplayer && node --check frontend/app.js && echo APP_OK cd server && bun build server.js --target=bun --outdir=/tmp/ytp-check >/dev/null && echo SERVER_OK [ -e public ] || ln -s ../frontend public; PORT=3997 bun server.js >/tmp/ytp005.log 2>&1 & SRV=$!; sleep 4 curl -s -o /dev/null -w '%{http_code}\n' 'http://localhost:3997/api/streams/warm?v=bad' curl -s -o /dev/null -w '%{http_code}\n' 'http://localhost:3997/api/streams/warm?v=dQw4w9WgXcQ' kill $SRV; true cd .. && node --test frontend/*.test.js 2>&1 | tail -3 ``` Expected: `APP_OK`, `SERVER_OK`, `204`, `204`, tests `fail 0`. ## Report format (executor: follow exactly) Output ONLY the following, no other prose: 1. `git diff` (unified) of all changes. 2. Raw output of the Verification commands. 3. `Findings:` — max 10 lines. Do not commit. Do not push. Do not touch files outside the Steps. ## Execution log - Executor: in-session Agent (haiku). Attempts: 1. Fix rounds: 0. - Orchestrator re-ran Verification: `node --check` ok, `SERVER_OK`, `/api/streams/warm` returns 204 for a bad id and a valid id, 52 frontend tests pass, `warmStreams` appears 4 times in app.js. - Executor Findings (verbatim): All steps completed successfully. The /api/streams/warm endpoint returns 204 for both invalid IDs and valid YouTube IDs as expected. The warmStreams client function correctly validates IDs against the regex pattern and rate-limits warming to 20 minutes per ID. All 52 unit tests pass with zero failures. No deviations from the plan. - Orchestrator note: the executor started the server from the repo root, leaving an untracked `public` symlink and `data/` database at the root; deleted before commit (not part of the plan).