5.9 KiB
5.9 KiB
id, title, created, depends_on, est_files
| id | title | created | depends_on | est_files | |
|---|---|---|---|---|---|
| 005-warm-streams-on-intent-47b3d3 | Warm the stream cache for likely next plays | 2026-09-29 |
|
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=<id>→204immediately, 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 beforeclick); the next 2 queue items when a song starts playing.
Context the executor must NOT rediscover
server/server.jsresolveStreams(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/warmseparately 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).runSearchQuerysuccess path (~line 8603):searchResults = results; … renderList(); RecentSearches.cacheResults(q, results);renderCard(v, index, list)at ~line 7725; it hascard.addEventListener('click', (e) => {at ~line 7781.- globals
queue(array of video objects) andqueueIndex(~line 353). - master
playinglistener at ~line 2287 (el.addEventListener('playing', () => { if (!masterIs(el)) return; …). WEBconstant is true for the PWA;cachedIdsis a Set of ids saved on this device.
Steps
server/server.js— aboveapp.get('/api/streams', …)add:// GET /api/streams/warm?v=<id> — 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); });frontend/app.js— directly after theconst API = { … };object (~line 320) add:// 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(() => {}); } }frontend/app.jsrunSearchQuery— afterRecentSearches.cacheResults(q, results);addwarmStreams(results.slice(0, 3).map((r) => r.id));frontend/app.jsrenderCard— directly BEFOREcard.addEventListener('click', (e) => {add:card.addEventListener('pointerdown', () => warmStreams([v.id]), { passive: true });frontend/app.jsmasterplayinglistener — afterif (!masterIs(el)) return;(and after the plan-001 perf lines if present) add: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/streamsitself.
Verification
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:
git diff(unified) of all changes.- Raw output of the Verification commands.
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 --checkok,SERVER_OK,/api/streams/warmreturns 204 for a bad id and a valid id, 52 frontend tests pass,warmStreamsappears 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
publicsymlink anddata/database at the root; deleted before commit (not part of the plan).