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
// 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}`);