Keep one long-lived yt-dlp worker process instead of spawning per call

This commit is contained in:
Claude
2026-09-30 07:04:53 +00:00
parent b8658f3a9d
commit 9cac21afce
8 changed files with 259 additions and 12 deletions

View File

@@ -50,6 +50,7 @@ import { createPartyHub } from './party.js';
import { registerUploadRoutes } from './uploads.js';
import * as innertube from './innertube.js';
import QRCode from 'qrcode';
import { createYtdlpPool } from './ytdlp-pool.js';
import { dirname, join as pathJoin } from 'node:path';
// A media proxy must not die because one client's stream hit an edge case
@@ -142,7 +143,7 @@ const CHANNEL_LIMIT = 60;
// concurrent request — including in-flight /api/download proxy streams, which
// Bun then kills at its idle timeout ("fetch failed" mid-download on clients).
// Rejects on non-zero exit.
function runYtdlp(args, { signal } = {}) {
function runYtdlpSpawn(args, { signal } = {}) {
return new Promise((resolve, reject) => {
const child = spawn(YTDLP, args, { stdio: ['ignore', 'pipe', 'pipe'] });
const t0 = Date.now();
@@ -170,6 +171,29 @@ function runYtdlp(args, { signal } = {}) {
});
}
// Read-only calls (-J, --dump-json) go to long-lived workers that import
// yt_dlp once (~1 s saved per call). Downloads keep spawning. Pool trouble
// (not a yt-dlp error) falls back to a spawn. YTDLP_WORKER=0 disables it.
const ytdlpPool = process.env.YTDLP_WORKER === '0' ? null : createYtdlpPool({
ytdlpPath: Bun.which(YTDLP) || YTDLP,
size: Math.max(1, Number(process.env.YTDLP_WORKERS) || 2),
});
function runYtdlp(args, opts = {}) {
if (!opts.pooled || !ytdlpPool || opts.signal) return runYtdlpSpawn(args, opts);
const t0 = Date.now();
return ytdlpPool.run(args).then(
(out) => { console.log(`[ytdlp] pooled ${Date.now() - t0}ms ok`); return out; },
(err) => {
if (err.poolInfra) return runYtdlpSpawn(args, opts);
// A bot check can stick to a long-lived process: replace the workers and
// answer this call the old way, from a fresh process.
if (BOT_CHECK_RE.test(err.message)) { ytdlpPool.recycle(); return runYtdlpSpawn(args, opts); }
console.log(`[ytdlp] pooled ${Date.now() - t0}ms fail`);
throw err;
},
);
}
// YouTube intermittently answers the default (web) innertube client with
// "Sign in to confirm you're not a bot" — a per-IP rate signal, not a
// per-video one, so the SAME video that just saved fine fails minutes later
@@ -396,7 +420,7 @@ app.get('/api/search', async (c) => {
`ytsearch${SEARCH_LIMIT}:${q}`,
'--dump-json', '--flat-playlist',
'--no-warnings', '--ignore-errors',
]);
], { pooled: true });
yt = parseCards(out);
}
const results = [...mine, ...yt];
@@ -421,7 +445,7 @@ app.get('/api/channel', async (c) => {
'--dump-json', '--flat-playlist',
'--no-warnings', '--ignore-errors',
'--playlist-end', String(CHANNEL_LIMIT),
]);
], { pooled: true });
const results = parseCards(out);
// Extract channel name + URL from the first record
const first = results[0];
@@ -476,7 +500,7 @@ async function resolveStreamsUncached(videoId) {
const cached = streamCache.get(videoId);
if (cached && now < cached.expiresAt) return cached;
const out = await runYtdlpResilient(['-J', '--no-warnings', `https://www.youtube.com/watch?v=${videoId}`]);
const out = await runYtdlpResilient(['-J', '--no-warnings', `https://www.youtube.com/watch?v=${videoId}`], { pooled: true });
const info = JSON.parse(out);
const raw = Array.isArray(info.formats) ? info.formats : [];
const formats = [];
@@ -1760,7 +1784,7 @@ app.get('/api/playlist/expand', async (c) => {
'--dump-json', '--flat-playlist',
'--no-warnings', '--ignore-errors',
'--playlist-end', '201',
]);
], { pooled: true });
const parsed = parseCards(out);
let title = '';
for (const line of out.split('\n')) {