Files
minecraft_launcher/src/installer-rp/music.ts
claude-bot 6cd402121b i18n: 리소스팩 설치기 UI 문구를 locales/installer-rp/ko-kr.json 으로 분리
- 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>
2026-05-13 04:00:31 +09:00

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 })))
}
})
})
}