import { spawn, spawnSync } from 'node:child_process' import { promises as fs } from 'node:fs' import path from 'node:path' import { jobsDir, projectRoot } from './paths.js' import { upscaleOriginalTo60Fps } from './editor.js' import { createVideo, getFolder, getVideo, updateVideo, videoDiskDir } from './storeDb.js' export class YtDlpUnavailableError extends Error { constructor(message?: string) { super(message || 'yt-dlp 가 설치되어 있지 않습니다. yt-dlp 를 PATH 에 설치한 뒤 다시 시도해 주세요.') } } let resolvedYtDlpPath: string | null = null export function getYtDlpPath(): string { if (resolvedYtDlpPath) return resolvedYtDlpPath // 1) 프로젝트 bin/yt-dlp(.exe) const localCandidates = process.platform === 'win32' ? ['bin/yt-dlp.exe', 'bin/yt-dlp'] : ['bin/yt-dlp'] for (const rel of localCandidates) { const abs = path.join(projectRoot, rel) const r = spawnSync(abs, ['--version']) if (r.status === 0) { resolvedYtDlpPath = abs return abs } } // 2) PATH 에서 검색 const r = spawnSync('yt-dlp', ['--version']) if (r.status === 0) { resolvedYtDlpPath = 'yt-dlp' return 'yt-dlp' } throw new YtDlpUnavailableError() } export interface ProbeResult { title: string durationSec: number filesizeApprox: number | null /** 추정 다운로드 ETA (초). filesize_approx / 가정대역폭. 모를 때 null. */ etaSec: number | null warnOver5min: boolean } const ASSUMED_BPS = 5 * 1024 * 1024 // 5 MB/s 가정. 실측은 어렵지만 경고 트리거용으로만 사용. export async function probeYoutube(url: string): Promise { const bin = getYtDlpPath() return new Promise((resolve, reject) => { const child = spawn(bin, [ '--no-warnings', '--skip-download', '--print', '%(title)s\n%(duration)s\n%(filesize_approx)s' ].concat([url])) let stdout = '' let stderr = '' child.stdout.on('data', (chunk) => (stdout += chunk.toString())) child.stderr.on('data', (chunk) => (stderr += chunk.toString())) child.on('error', (err) => reject(err)) child.on('close', (code) => { if (code !== 0) { reject(new Error(stderr.trim() || `yt-dlp probe 실패 (code=${code})`)) return } const lines = stdout.trim().split('\n') const title = lines[0] || '' const durationSec = Number(lines[1]) || 0 const sizeRaw = lines[2] const filesizeApprox = sizeRaw && sizeRaw !== 'NA' && Number.isFinite(Number(sizeRaw)) ? Number(sizeRaw) : null const etaSec = filesizeApprox ? Math.round(filesizeApprox / ASSUMED_BPS) : null const warnOver5min = (etaSec ?? 0) > 5 * 60 || durationSec > 60 * 60 resolve({ title, durationSec, filesizeApprox, etaSec, warnOver5min }) }) }) } // ─── 플레이리스트 probe ──────────────────────────────────────────────────── export interface PlaylistEntry { /** 단일 영상 시작 endpoint 에 그대로 넘기는 watch URL. */ url: string title: string /** 메타에 없으면 null. UI 표시용. */ durationSec: number | null /** 1 부터 시작하는 원래 플레이리스트 위치 — 시퀀셜 ID zero-pad 자릿수 계산용. */ originalIndex: number } export interface PlaylistProbeResult { entries: PlaylistEntry[] } /** yt-dlp 로 플레이리스트의 각 항목 메타만 빠르게 긁어온다 (실제 다운로드 X). */ export async function probeYoutubePlaylist(url: string): Promise { const bin = getYtDlpPath() return new Promise((resolve, reject) => { // --flat-playlist + --dump-json 한 줄당 한 JSON 항목. const child = spawn(bin, [ '--no-warnings', '--flat-playlist', '--dump-json', url ]) let stdout = '' let stderr = '' child.stdout.on('data', (chunk) => (stdout += chunk.toString())) child.stderr.on('data', (chunk) => (stderr += chunk.toString())) child.on('error', (err) => reject(err)) child.on('close', (code) => { if (code !== 0) { reject(new Error(stderr.trim() || `yt-dlp playlist probe 실패 (code=${code})`)) return } const entries: PlaylistEntry[] = [] let idx = 0 for (const line of stdout.split(/\r?\n/)) { const trimmed = line.trim() if (!trimmed) continue let obj: Record try { obj = JSON.parse(trimmed) as Record } catch { continue } // 상위 _type==='playlist' 같은 컨테이너 라인이 섞이면 스킵. if (obj._type === 'playlist') continue idx += 1 const id = typeof obj.id === 'string' ? obj.id : null const webpageUrl = typeof obj.webpage_url === 'string' && obj.webpage_url ? obj.webpage_url : null const rawUrl = typeof obj.url === 'string' && obj.url ? obj.url : null const entryUrl = webpageUrl || rawUrl || (id ? `https://www.youtube.com/watch?v=${id}` : null) if (!entryUrl) continue const title = typeof obj.title === 'string' && obj.title.trim() ? obj.title.trim() : `항목 ${idx}` const dur = obj.duration const durationSec = typeof dur === 'number' && Number.isFinite(dur) ? Math.round(dur) : null entries.push({ url: entryUrl, title, durationSec, originalIndex: idx }) } if (entries.length === 0) { reject(new Error('플레이리스트에서 항목을 찾지 못했습니다.')) return } resolve({ entries }) }) }) } // ─── 백그라운드 다운로드 잡 ──────────────────────────────────────────────── export type JobStatus = 'queued' | 'downloading' | 'done' | 'error' export interface DownloadJob { id: string folderId: number videoId: string url: string status: JobStatus progress: number // 0..100 message: string startedAt: string finishedAt: string | null outputFile: string | null error: string | null } 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 } export function listActiveJobs(): DownloadJob[] { return Array.from(jobs.values()).filter((j) => j.status === 'queued' || j.status === 'downloading') } export interface StartDownloadOpts { folderId: number url: string title?: string /** 호출자가 영상 ID 를 지정 (플레이리스트 일괄 등록용). 미지정 시 랜덤 생성. */ videoId?: string } /** * 백그라운드 yt-dlp 다운로드를 큐잉. videoId 도 함께 생성/저장한다. * 실제 실행은 동시성 한도(MAX_CONCURRENCY) 안에서 pump() 가 결정한다. */ export async function startYoutubeDownload(opts: StartDownloadOpts): Promise { const folder = getFolder(opts.folderId) if (!folder) throw new Error('폴더를 찾을 수 없습니다.') // yt-dlp 없으면 큐잉도 안 한다. 100건 등록 후 전부 error 로 깨지는 것보다 // 즉시 endpoint 가 503 으로 거절하는 편이 낫다. getYtDlpPath() const video = await createVideo({ id: opts.videoId, folderId: opts.folderId, title: opts.title?.trim() || '제목 없음', originalFile: 'original.%(ext)s', // 다운로드 완료 후 실제 확장자로 갱신 sourceType: 'youtube', sourceUrl: opts.url }) const now = new Date().toISOString() const job: DownloadJob = { id: 'job-' + Math.random().toString(36).slice(2, 14), folderId: opts.folderId, videoId: video.id, url: opts.url, status: 'queued', progress: 0, message: '대기 중', startedAt: now, finishedAt: null, outputFile: null, error: null } jobs.set(job.id, job) void persistJob(job) // 같은 tick 에서 여러 startYoutubeDownload 가 연달아 호출될 수 있으니 // pump 는 다음 tick 에 한 번만 돌아도 충분 (반복 호출이 안전하긴 함). setImmediate(pump) return job } async function runJob(job: DownloadJob, bin: string): Promise { job.status = 'downloading' job.message = '다운로드 시작' await persistJob(job) const dir = videoDiskDir(job.videoId) // yt-dlp 가 다운로드 후 결정한 실제 파일명을 알려주도록 --print after_move:filepath // 진행률은 --newline + --progress-template 으로 stdout 줄 단위 파싱. const args = [ '--no-warnings', '--no-playlist', '--newline', // 해상도 캡: FHD(≤1080p) 안에서만 비디오를 선택. FHD 가 아예 없는 영상은 // 마지막 fallback 으로 무제한 best 를 받는다 (그 경우 후처리에서 FHD 로 다운스케일). '-f', 'bv*[height<=1080]+ba/b[height<=1080]/bv*+ba/b', // 가능한 한 부드러운 영상을 받기 위해 fps 를 최우선으로 정렬한다. // yt-dlp 기본 정렬은 fps 를 가중치로 안 써서 60fps 가 있어도 30fps 를 잡아오는 일이 잦다. // 우선순위: fps → 해상도 → 비트레이트 → 확장자. // 60fps 가 아예 없는 영상은 자연스럽게 그 영상의 최고 fps 로 폴백된다. '-S', 'fps,res,br,ext', '--progress-template', 'download:PROGRESS %(progress._percent_str)s', '--print', 'after_move:OUT %(filepath)s', '-o', path.join(dir, 'original.%(ext)s'), job.url ] // 다운로드 단계는 전체 진행률의 0~50% 를 차지하고, 60fps 후처리가 50~99%. // (yt-dlp 가 video+audio 두 스트림을 받으면 각 스트림이 0→100 % 를 반복하므로 // 단조 증가만 인정해 막대가 역행하지 않게 한다.) let downloadPctRaw = 0 return new Promise((resolve, reject) => { // yt-dlp 는 파이썬 스크립트라 stdout 이 pipe 로 연결되면 block-buffered 가 되어 // 진행률 라인들이 즉시 흘러나오지 않는다. PYTHONUNBUFFERED=1 로 강제 unbuffered. // 이게 없으면 "다운로드 끝나자마자 한꺼번에 progress 0→50 점프" 처럼 보임. const child = spawn(bin, args, { env: { ...process.env, PYTHONUNBUFFERED: '1' } }) let outputFile: string | null = null let stderrTail = '' child.stdout.on('data', (chunk) => { const text = chunk.toString() for (const line of text.split(/\r?\n/)) { const m = /PROGRESS\s+([\d.]+)%/.exec(line) if (m) { const pct = Number(m[1]) if (Number.isFinite(pct)) { downloadPctRaw = Math.max(downloadPctRaw, Math.min(100, pct)) const mapped = Math.round(downloadPctRaw * 0.5) // 0..50 if (mapped > job.progress) job.progress = mapped job.message = `다운로드 ${Math.round(downloadPctRaw)}%` } } const o = /^OUT\s+(.+)$/.exec(line.trim()) if (o) outputFile = o[1].trim() } }) child.stderr.on('data', (chunk) => { stderrTail = (stderrTail + chunk.toString()).slice(-2000) }) child.on('error', (err) => reject(err)) child.on('close', async (code) => { if (code !== 0) { reject(new Error(stderrTail.trim() || `yt-dlp 실패 (code=${code})`)) return } // 실제 파일명 결정 let finalName = 'original' if (outputFile) { finalName = path.basename(outputFile) } else { // fallback: 디렉토리 내 original.* 찾기 const entries = await fs.readdir(dir).catch(() => []) const found = entries.find((n) => n.startsWith('original.')) if (found) finalName = found } // 사용자가 "원본도 60fps 로" 요청 — yt-dlp 가 fps 1순위로 잡아도 60fps 자체가 // 없는 영상이 있으니, 다운로드 후 원본을 ffprobe 로 확인해 <60fps 면 // minterpolate 로 60fps 까지 끌어올린다. 실패해도 원본은 그대로 둠. try { // 50% 부터 시작해 50~99% 구간을 ffmpeg 진행률이 채우게 한다. job.progress = 50 job.message = '60fps 변환 준비 중' await persistJob(job) // ffmpeg 진행률은 매우 자주 들어오므로 디스크 persist 는 throttle. let lastPersistAt = 0 let lastBumpPct = 0 const bumped = await upscaleOriginalTo60Fps(dir, finalName, (pct) => { if (pct <= lastBumpPct) return lastBumpPct = pct const mapped = 50 + Math.round((pct / 100) * 49) // 50..99 if (mapped > job.progress) job.progress = mapped job.message = `60fps 변환 ${Math.round(pct)}%` const now = Date.now() if (now - lastPersistAt > 2000) { lastPersistAt = now void persistJob(job) } }) if (bumped !== finalName) { job.message = '60fps 변환 완료' finalName = bumped } else { // upscaleOriginalTo60Fps 가 inputName 을 그대로 돌려준 경우는 // (a) 이미 60fps 이상이거나 (b) ffmpeg 없거나 (c) 보간 실패. // 어느 경우든 후처리 단계는 끝났다고 보고 진행률만 99 까지 채운다. job.progress = 99 } await persistJob(job) } catch (err) { // 후처리 실패는 다운로드 자체를 실패시키지 않는다. 원본 보존이 우선. console.error('[youtube] 60fps 후처리 실패:', err) } const meta = getVideo(job.videoId) if (meta) { updateVideo(job.videoId, { originalFile: finalName }) } job.progress = 100 job.status = 'done' job.message = '완료' job.outputFile = finalName job.finishedAt = new Date().toISOString() await persistJob(job) resolve() }) }) }