Warm the stream cache for likely next plays
This commit is contained in:
118
plans/done/005-warm-streams-on-intent-47b3d3.md
Normal file
118
plans/done/005-warm-streams-on-intent-47b3d3.md
Normal file
@@ -0,0 +1,118 @@
|
||||
---
|
||||
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=<id>` → `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=<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);
|
||||
});
|
||||
```
|
||||
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).
|
||||
Reference in New Issue
Block a user