- main/preload/ytdlp/ffmpeg/music/images/pack/renderer 전반에서 로그·에러·진행 메시지 문자열을 locales/installer-rp/ko-kr.json 사전 키로 교체 - preload 에 loadLocale 추가, main 에 rp:i18n:dict IPC 핸들러 추가 - 패키징된 .exe 에서도 한국어 사전이 적용되도록 electron-builder.yml 의 extraResources 에 locales/ 폴더 추가 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
104 lines
3.6 KiB
TypeScript
104 lines
3.6 KiB
TypeScript
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<string> {
|
|
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 })))
|
|
}
|
|
})
|
|
})
|
|
}
|