diff --git a/server/server.js b/server/server.js index 2d01a3b..0a0de05 100644 --- a/server/server.js +++ b/server/server.js @@ -47,7 +47,17 @@ const FFMPEG = process.env.FFMPEG_PATH || 'ffmpeg'; // whole downlink (~7.6 MB/s measured), and since /api/play fetches its own // googlevideo slices over the same link, one long save starved every // concurrent playback (stalled at ~27 s, 11 KB/s). Leave headroom. -const DOWNLOAD_RATE = process.env.DOWNLOAD_RATE || '3M'; +const DOWNLOAD_RATE = process.env.DOWNLOAD_RATE || '2M'; + +// Saves run ONE AT A TIME. Two concurrent rate-capped saves plus playback +// still filled the homelab's ~7.5 MB/s downlink and playback starved, so +// additional saves wait their turn (the client just sees a longer save). +let saveChain = Promise.resolve(); +function withSaveSlot(fn) { + const run = saveChain.then(fn, fn); + saveChain = run.catch(() => {}); + return run; +} // ---------------------------------------------------------------------------- // BUILD_TAG — must be DETERMINISTIC across restarts of identical code. @@ -682,7 +692,7 @@ app.get('/api/play', async (c) => { // didn't match the extraction client. // Live streams have no end: yt-dlp/ffmpeg would pull the HLS manifest forever // (one such "save" ran for an hour and ate 14 GB). Refuse them up front. -const MAX_SAVE_SECONDS = Number(process.env.MAX_SAVE_SECONDS || 4 * 3600); +const MAX_SAVE_SECONDS = Number(process.env.MAX_SAVE_SECONDS || 3 * 3600); async function assertNotLive(videoId) { const { info } = await resolveStreams(videoId); if (info.is_live || info.live_status === 'is_live' || info.live_status === 'post_live') { @@ -707,13 +717,16 @@ async function ytdlpDownloadResponse(videoId, fp, formatArgs, signal) { const tmp = `${tmpdir()}/${tmpBase}.mp4`; const ctl = new AbortController(); entry = { waiters: 0, tmpBase, ctl }; - entry.promise = runYtdlp([ - `https://www.youtube.com/watch?v=${videoId}`, - '--no-warnings', '--no-playlist', - ...formatArgs, - '--limit-rate', DOWNLOAD_RATE, - '-o', tmp, - ], { signal: ctl.signal }).then(() => ({ tmp, size: statSync(tmp).size })); + entry.promise = withSaveSlot(() => { + if (ctl.signal.aborted) throw new Error('save cancelled'); + return runYtdlp([ + `https://www.youtube.com/watch?v=${videoId}`, + '--no-warnings', '--no-playlist', + ...formatArgs, + '--limit-rate', DOWNLOAD_RATE, + '-o', tmp, + ], { signal: ctl.signal }); + }).then(() => ({ tmp, size: statSync(tmp).size })); inflightDownloads.set(key, entry); } entry.waiters++; @@ -776,14 +789,14 @@ async function ytdlpEditedDownloadResponse(videoId, fp, keep, signal) { let size, fd; try { // 1) Grab the full source (video+audio merged) so ffmpeg has both streams. - await runYtdlp([ + await withSaveSlot(() => runYtdlp([ `https://www.youtube.com/watch?v=${videoId}`, '--no-warnings', '--no-playlist', '-f', 'bv*[height<=720][ext=mp4]+ba[ext=m4a]/bv*[height<=720]+ba/b[ext=mp4]/b', '--merge-output-format', 'mp4', '--limit-rate', DOWNLOAD_RATE, '-o', srcTmp, - ], { signal }); + ], { signal })); // 2) Trim + concat the keep segments into the final custom video. await runFfmpeg([ '-y', '-hide_banner', '-loglevel', 'error', @@ -1223,9 +1236,12 @@ async function main() { port: PORT, fetch: app.fetch, // Default is 10s, which killed /api/download proxy streams whenever the - // connection went idle mid-transfer. 240s covers slow saves; Bun caps - // this field at 255. - idleTimeout: 240, + // connection went idle mid-transfer. 240s then killed every save whose + // server-side yt-dlp phase (no bytes sent yet) ran longer than 4 min — + // long videos at the rate cap — and the client retried in a loop. + // 0 disables the idle timeout entirely (verified with a 300s request); + // runaway downloads are bounded by MAX_SAVE_SECONDS + kill-on-disconnect. + idleTimeout: 0, }); console.log(`[ytplayer] Listening → http://localhost:${PORT}`);