From 36c98990404f43f42d7ca6e9f110cc20b43a4abd Mon Sep 17 00:00:00 2001 From: "Claude (owner)" Date: Mon, 1 Jun 2026 00:07:42 +0900 Subject: [PATCH] fix(playlist): cap concurrent downloads (P1 review fix) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 플레이리스트 일괄 등록 시 모든 항목이 즉시 yt-dlp+ffmpeg 를 띄워 서버를 박살내던 문제 수정. `startYoutubeDownload` 는 더 이상 직접 runJob 을 setImmediate 로 띄우지 않고, `queued` 상태로만 등록한 뒤 `pump()` 가 활성 잡 수를 보고 슬롯이 비었을 때만 launchJob 한다. 완료/실패 시 `finally` 에서 카운터를 줄이고 다음 큐를 펌핑. 기본 동시 실행 한도는 2 (네트워크 다운로드 + CPU 후처리가 한쌍은 같이 굴러갈 만한 선). `MAX_DOWNLOAD_CONCURRENCY=N` 환경변수로 조정. 스모크 (yt-dlp 스텁 + 3s sleep): - MAX=1, 3건 등록 → t+0.5s: 1 downloading + 2 queued → t+4s: 1 done + 1 downloading + 1 queued → t+10s: 3 done - MAX=2 (기본), 4건 등록 → t+0.5s: 2 downloading + 2 queued → t+8s: 4 done Co-Authored-By: Claude Opus 4.7 --- src/youtube.ts | 69 ++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 61 insertions(+), 8 deletions(-) diff --git a/src/youtube.ts b/src/youtube.ts index d392a08..f8db973 100644 --- a/src/youtube.ts +++ b/src/youtube.ts @@ -185,11 +185,62 @@ export interface DownloadJob { const jobs = new Map() +// 동시 실행 상한. 플레이리스트 일괄 등록 시 yt-dlp+ffmpeg 가 N개 동시에 떠 +// 서버가 터지는 것을 막는다. 기본 2 — 네트워크-바운드 다운로드와 CPU-바운드 +// 60fps 후처리가 한 번에 한쌍 정도는 같이 진행될 수 있게. +const MAX_CONCURRENCY = (() => { + const raw = Number(process.env.MAX_DOWNLOAD_CONCURRENCY) + if (Number.isFinite(raw) && raw >= 1) return Math.floor(raw) + return 2 +})() +let activeJobCount = 0 + async function persistJob(job: DownloadJob): Promise { await fs.mkdir(jobsDir, { recursive: true }) await fs.writeFile(path.join(jobsDir, `${job.id}.json`), JSON.stringify(job, null, 2)) } +/** + * 대기 큐를 끌어올린다. + * - 동시 실행 슬롯이 남는 동안, 가장 먼저 등록된 `queued` 잡부터 launchJob 호출. + * - launchJob 의 finally 에서 다시 호출되어 새 슬롯을 채운다. + */ +function pump(): void { + if (activeJobCount >= MAX_CONCURRENCY) return + for (const job of jobs.values()) { + if (activeJobCount >= MAX_CONCURRENCY) return + if (job.status !== 'queued') continue + let bin: string + try { + bin = getYtDlpPath() + } catch (err) { + // yt-dlp 가 중간에 사라진 경우. 이 잡만 실패시키고 다음 잡으로 진행 (다음도 같은 사유로 실패하겠지만). + job.status = 'error' + job.error = err instanceof Error ? err.message : String(err) + job.finishedAt = new Date().toISOString() + void persistJob(job) + continue + } + launchJob(job, bin) + } +} + +function launchJob(job: DownloadJob, bin: string): void { + activeJobCount += 1 + runJob(job, bin) + .catch((err) => { + job.status = 'error' + job.error = err instanceof Error ? err.message : String(err) + job.finishedAt = new Date().toISOString() + void persistJob(job) + }) + .finally(() => { + activeJobCount -= 1 + // 비동기 완료 시점에 다음 큐 슬롯을 즉시 채운다. + pump() + }) +} + export function getJob(id: string): DownloadJob | null { return jobs.get(id) ?? null } @@ -206,11 +257,16 @@ export interface StartDownloadOpts { videoId?: string } -/** 백그라운드 yt-dlp 다운로드를 시작. videoId 도 함께 생성/저장한다. */ +/** + * 백그라운드 yt-dlp 다운로드를 큐잉. videoId 도 함께 생성/저장한다. + * 실제 실행은 동시성 한도(MAX_CONCURRENCY) 안에서 pump() 가 결정한다. + */ export async function startYoutubeDownload(opts: StartDownloadOpts): Promise { const folder = getFolder(opts.folderId) if (!folder) throw new Error('폴더를 찾을 수 없습니다.') - const bin = getYtDlpPath() // 없으면 즉시 던짐 + // yt-dlp 없으면 큐잉도 안 한다. 100건 등록 후 전부 error 로 깨지는 것보다 + // 즉시 endpoint 가 503 으로 거절하는 편이 낫다. + getYtDlpPath() const video = await createVideo({ id: opts.videoId, @@ -237,12 +293,9 @@ export async function startYoutubeDownload(opts: StartDownloadOpts): Promise runJob(job, bin).catch((err) => { - job.status = 'error' - job.error = err instanceof Error ? err.message : String(err) - job.finishedAt = new Date().toISOString() - void persistJob(job) - })) + // 같은 tick 에서 여러 startYoutubeDownload 가 연달아 호출될 수 있으니 + // pump 는 다음 tick 에 한 번만 돌아도 충분 (반복 호출이 안전하긴 함). + setImmediate(pump) return job }