Serialize saves, disable idle timeout, cap savable length at 3h

This commit is contained in:
Jonathan Sykes
2026-08-24 00:21:18 +08:00
parent 04c419d151
commit c141959654

View File

@@ -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 // 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 // googlevideo slices over the same link, one long save starved every
// concurrent playback (stalled at ~27 s, 11 KB/s). Leave headroom. // 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. // 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. // didn't match the extraction client.
// Live streams have no end: yt-dlp/ffmpeg would pull the HLS manifest forever // 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. // (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) { async function assertNotLive(videoId) {
const { info } = await resolveStreams(videoId); const { info } = await resolveStreams(videoId);
if (info.is_live || info.live_status === 'is_live' || info.live_status === 'post_live') { 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 tmp = `${tmpdir()}/${tmpBase}.mp4`;
const ctl = new AbortController(); const ctl = new AbortController();
entry = { waiters: 0, tmpBase, ctl }; entry = { waiters: 0, tmpBase, ctl };
entry.promise = runYtdlp([ entry.promise = withSaveSlot(() => {
`https://www.youtube.com/watch?v=${videoId}`, if (ctl.signal.aborted) throw new Error('save cancelled');
'--no-warnings', '--no-playlist', return runYtdlp([
...formatArgs, `https://www.youtube.com/watch?v=${videoId}`,
'--limit-rate', DOWNLOAD_RATE, '--no-warnings', '--no-playlist',
'-o', tmp, ...formatArgs,
], { signal: ctl.signal }).then(() => ({ tmp, size: statSync(tmp).size })); '--limit-rate', DOWNLOAD_RATE,
'-o', tmp,
], { signal: ctl.signal });
}).then(() => ({ tmp, size: statSync(tmp).size }));
inflightDownloads.set(key, entry); inflightDownloads.set(key, entry);
} }
entry.waiters++; entry.waiters++;
@@ -776,14 +789,14 @@ async function ytdlpEditedDownloadResponse(videoId, fp, keep, signal) {
let size, fd; let size, fd;
try { try {
// 1) Grab the full source (video+audio merged) so ffmpeg has both streams. // 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}`, `https://www.youtube.com/watch?v=${videoId}`,
'--no-warnings', '--no-playlist', '--no-warnings', '--no-playlist',
'-f', 'bv*[height<=720][ext=mp4]+ba[ext=m4a]/bv*[height<=720]+ba/b[ext=mp4]/b', '-f', 'bv*[height<=720][ext=mp4]+ba[ext=m4a]/bv*[height<=720]+ba/b[ext=mp4]/b',
'--merge-output-format', 'mp4', '--merge-output-format', 'mp4',
'--limit-rate', DOWNLOAD_RATE, '--limit-rate', DOWNLOAD_RATE,
'-o', srcTmp, '-o', srcTmp,
], { signal }); ], { signal }));
// 2) Trim + concat the keep segments into the final custom video. // 2) Trim + concat the keep segments into the final custom video.
await runFfmpeg([ await runFfmpeg([
'-y', '-hide_banner', '-loglevel', 'error', '-y', '-hide_banner', '-loglevel', 'error',
@@ -1223,9 +1236,12 @@ async function main() {
port: PORT, port: PORT,
fetch: app.fetch, fetch: app.fetch,
// Default is 10s, which killed /api/download proxy streams whenever the // Default is 10s, which killed /api/download proxy streams whenever the
// connection went idle mid-transfer. 240s covers slow saves; Bun caps // connection went idle mid-transfer. 240s then killed every save whose
// this field at 255. // server-side yt-dlp phase (no bytes sent yet) ran longer than 4 min —
idleTimeout: 240, // 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}`); console.log(`[ytplayer] Listening → http://localhost:${PORT}`);