Files
ytplayer/plans/done/004-coalesce-stream-resolves-a92d40.md

92 lines
3.3 KiB
Markdown

---
id: 004-coalesce-stream-resolves-a92d40
title: Coalesce concurrent resolveStreams calls for the same video
created: 2026-09-29
depends_on: [001-perf-timing-marks-105acc]
est_files: 1
---
# 004 — Coalesce concurrent resolveStreams calls
## Objective
`resolveStreams(videoId)` checks `streamCache` but has no in-flight map, so two
requests for the same id that arrive before the first finishes (a warm-up + the
real play, two devices, the media cache's `getInfo` + `/api/streams`) each spawn a
~6 s `yt-dlp -J`. After this plan, concurrent callers share ONE promise; a failure
is not cached (the next call retries).
## Context the executor must NOT rediscover
`server/server.js:457-494`:
```js
async function resolveStreams(videoId) {
const now = Date.now();
const cached = streamCache.get(videoId);
if (cached && now < cached.expiresAt) return cached;
const out = await runYtdlpResilient(['-J', '--no-warnings', `https://www.youtube.com/watch?v=${videoId}`]);
const info = JSON.parse(out);
...
streamCache.set(videoId, entry);
return entry;
}
```
`streamCache` and `STREAM_CACHE_MAX` are declared just above it.
## Steps
1. `server/server.js` — rename the existing function `resolveStreams` to
`resolveStreamsUncached` (definition only; body unchanged).
2. Directly after that function add:
```js
// One yt-dlp -J per video at a time: concurrent callers (warm-up + play,
// two devices, the media cache's getInfo) share the in-flight promise.
const inflightStreams = new Map(); // videoId -> Promise<entry>
function resolveStreams(videoId) {
const cached = streamCache.get(videoId);
if (cached && Date.now() < cached.expiresAt) return Promise.resolve(cached);
let p = inflightStreams.get(videoId);
if (!p) {
p = resolveStreamsUncached(videoId).finally(() => inflightStreams.delete(videoId));
inflightStreams.set(videoId, p);
}
return p;
}
```
All existing callers keep calling `resolveStreams` (it still returns a Promise).
3. Create `server/streams-inflight.test.js`? — NO. Keep it in-file; instead verify with the
script below.
## Out of scope / do NOT touch
- Cache TTL logic, `runYtdlpResilient`, format filtering.
## Verification
```bash
cd /home/user/ytplayer/server && grep -n "function resolveStreams\|function resolveStreamsUncached\|inflightStreams" server.js
bun build server.js --target=bun --outdir=/tmp/ytp-check >/dev/null && echo SERVER_OK
bun run test 2>&1 | grep -E "^ *[0-9]+ (pass|fail)"
```
Expected: both functions + map present, `SERVER_OK`, all server test files `0 fail`.
## 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: both functions and `inflightStreams` present, `bun build` ok, `bun run test` all 5 server files 0 fail (media-cache 23 pass is the original baseline).
- Executor Findings (verbatim): Plan executed successfully. Both renamed function and wrapper present in correct location. In-flight map correctly coalesces concurrent calls. All 58 server tests pass. Build completes with no errors. No deviations from plan requirements.