Serialize saves, disable idle timeout, cap savable length at 3h
This commit is contained in:
@@ -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}`);
|
||||||
|
|||||||
Reference in New Issue
Block a user