Files
ytplayer/plans/queue/005-warm-streams-on-intent-47b3d3.md

5.0 KiB
Raw Blame History

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
004-coalesce-stream-resolves-a92d40
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:
    // 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:
    // 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:
    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:
    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

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.