import { spawn, type ChildProcess } from 'node:child_process' import { promises as fs } from 'node:fs' import path from 'node:path' 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 로 유튜브 영상에서 오디오만 추출해 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', '--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 = '' child.stdout?.on('data', (chunk: Buffer) => { const line = chunk.toString('utf8').trimEnd() if (line) opts.log?.(`yt-dlp> ${line}`) }) 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(`yt-dlp 가 신호 ${signal} 로 종료됨`)) return } if (code !== 0) { reject(new Error(`yt-dlp 종료 코드 ${code}: ${stderr.trim() || '(stderr 없음)'}`)) return } // .ogg 가 실제로 생성됐는지 확인 try { await fs.access(outPath) resolve(outPath) } catch { reject(new Error(`예상 출력파일이 없음: ${outPath}`)) } }) }) }