From e2a60792b9c340b797c3c5eb6dc3f4f1cff97680 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 06:50:31 +0000 Subject: [PATCH] Coalesce concurrent resolveStreams calls for the same video --- plans/INDEX.md | 2 +- .../004-coalesce-stream-resolves-a92d40.md | 6 ++++++ server/server.js | 16 +++++++++++++++- 3 files changed, 22 insertions(+), 2 deletions(-) rename plans/{active => done}/004-coalesce-stream-resolves-a92d40.md (84%) diff --git a/plans/INDEX.md b/plans/INDEX.md index 5dcadc5..b2d9da9 100644 --- a/plans/INDEX.md +++ b/plans/INDEX.md @@ -11,7 +11,7 @@ green, app boots with no JS errors, P2P on by default, offline boot works). | 001 | 001-perf-timing-marks-105acc | Add startup, search and play timing marks plus yt-dlp duration logs | done | Add startup, search and play timing marks plus yt-dlp duration logs | baseline numbers for the rest | | 002 | 002-compress-and-etag-shell-bd459c | Serve the app shell gzip/brotli-compressed with ETags | done | Serve the app shell gzip/brotli-compressed with ETags | app.js 426 KB → 94 KB (br) | | 003 | 003-self-host-fonts-89466b | Self-host the three web fonts and drop the render-blocking Google Fonts CSS | done | Self-host the three web fonts and drop the render-blocking Google Fonts CSS | needs network once; CSP font-src fix | -| 004 | 004-coalesce-stream-resolves-a92d40 | Coalesce concurrent resolveStreams calls for the same video | in-progress | | | +| 004 | 004-coalesce-stream-resolves-a92d40 | Coalesce concurrent resolveStreams calls for the same video | done | Coalesce concurrent resolveStreams calls for the same video | | | 005 | 005-warm-streams-on-intent-47b3d3 | Warm the stream cache for likely next plays | queued | | cold play 7.5 s → cached 1.2 s | | 006 | 006-innertube-search-48066b | Answer searches from YouTube InnerTube directly with yt-dlp fallback | queued | | search 4–5 s → ~0.8 s | | 007 | 007-ytdlp-worker-045800 | Keep one long-lived yt-dlp worker process instead of spawning per call | queued | | ~1 s per yt-dlp call | diff --git a/plans/active/004-coalesce-stream-resolves-a92d40.md b/plans/done/004-coalesce-stream-resolves-a92d40.md similarity index 84% rename from plans/active/004-coalesce-stream-resolves-a92d40.md rename to plans/done/004-coalesce-stream-resolves-a92d40.md index 4a11dbb..4433f54 100644 --- a/plans/active/004-coalesce-stream-resolves-a92d40.md +++ b/plans/done/004-coalesce-stream-resolves-a92d40.md @@ -83,3 +83,9 @@ Output ONLY the following, no other prose: 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. diff --git a/server/server.js b/server/server.js index 0b12f70..c75f169 100644 --- a/server/server.js +++ b/server/server.js @@ -459,7 +459,7 @@ function parseExpiry(url) { // Resolve (and briefly cache) a video's playable formats via yt-dlp -J. The // cache spares a fresh ~2-3s yt-dlp run on every Range request the media // element fires; its TTL is bounded a minute inside the URLs' own expiry. -async function resolveStreams(videoId) { +async function resolveStreamsUncached(videoId) { const now = Date.now(); const cached = streamCache.get(videoId); if (cached && now < cached.expiresAt) return cached; @@ -498,6 +498,20 @@ async function resolveStreams(videoId) { return entry; } +// 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 +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; +} + const isVideoFmt = (f) => f.vcodec && f.vcodec !== 'none'; const isAudioFmt = (f) => f.acodec && f.acodec !== 'none';