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 }