import { spawn, type ChildProcess } from 'node:child_process' import { promises as fs } from 'node:fs' import path from 'node:path' import { loadComponentI18n } from '../shared/i18n.js' const { t } = loadComponentI18n('installer-rp') export interface DownloadMusicOptions { ytdlpExe: string ffmpegExe: string /** %appdata%/.mc_custom/.temp/ 같은 작업 폴더. */ tempDir: string /** 1부터 시작하는 곡 번호 (파일명 zero-pad 에 사용). */ index: number /** 유튜브 영상 주소. */ url: string log?: (line: string) => void /** 현재 실행 중인 자식 프로세스를 외부에 알림 (취소용). */ onChild?: (child: ChildProcess) => void /** yt-dlp 의 다운로드 퍼센트 (0~100). 변환 단계는 별도. */ onProgress?: (percent: number) => void } /** * yt-dlp 로 유튜브 영상에서 오디오만 추출해 vorbis(.ogg) 로 변환한다. * 결과 파일 경로를 돌려준다. 실패하면 reject. * * 호출자는 onChild 콜백으로 받은 ChildProcess 에 .kill() 을 호출해 취소할 수 있다. */ export function downloadMusicTrack(opts: DownloadMusicOptions): Promise { const padded = String(opts.index).padStart(2, '0') const outBase = path.join(opts.tempDir, padded) const outPath = outBase + '.ogg' return new Promise((resolve, reject) => { const args = [ '--no-warnings', '--no-playlist', // 단일 파일이 아니라 HLS/DASH fragmented 스트림일 때 청크를 병렬로. // 일반 progressive 다운로드에는 영향 없음. '--concurrent-fragments', '5', // 진행률 표시 안정화 (yt-dlp 가 \r 대신 새 줄로 출력). '--newline', '--extract-audio', '--audio-format', 'vorbis', '--audio-quality', '0', '--ffmpeg-location', opts.ffmpegExe, '-o', outBase + '.%(ext)s', opts.url ] const child = spawn(opts.ytdlpExe, args, { stdio: ['ignore', 'pipe', 'pipe'] }) opts.onChild?.(child) let stderr = '' let stdoutBuf = '' let lastReportedPct = -1 child.stdout?.on('data', (chunk: Buffer) => { stdoutBuf += chunk.toString('utf8') // yt-dlp 는 `[download] 3.3% of 3.72MiB at ...` 형식으로 // \r 로 같은 줄을 갱신한다. \r 과 \n 을 모두 split 해서 마지막 진행률을 뽑는다. const lines = stdoutBuf.split(/[\r\n]/) stdoutBuf = lines.pop() ?? '' for (const raw of lines) { const line = raw.trimEnd() if (!line) continue opts.log?.(t('log.ytdlpLine', { line })) const m = line.match(/\[download\]\s+([\d.]+)%/) if (m) { const pct = Math.min(100, Math.max(0, parseFloat(m[1]))) // 너무 잦은 이벤트를 피하기 위해 1% 단위로만 전달. if (Math.floor(pct) !== lastReportedPct) { lastReportedPct = Math.floor(pct) opts.onProgress?.(pct) } } } }) child.stderr?.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8') }) child.on('error', (err) => reject(err)) child.on('close', async (code, signal) => { if (signal) { reject(new Error(t('errors.ytdlpSignal', { signal: String(signal) }))) return } if (code !== 0) { reject(new Error( t('errors.ytdlpExit', { code: code ?? '', stderr: stderr.trim() || t('errors.ytdlpNoStderr') }) )) return } // .ogg 가 실제로 생성됐는지 확인 try { await fs.access(outPath) resolve(outPath) } catch { reject(new Error(t('errors.ytdlpMissingOutput', { path: outPath }))) } }) }) }