perf(installer-rp): parallel music download (3 concurrent) + fragmented chunks
- yt-dlp 인자에 --concurrent-fragments 5 추가 (HLS/DASH 청크 병렬 다운로드) - yt-dlp 인자에 --newline 추가 (진행률 라인 안정화) - 음악 다운로드 루프를 단일 순차 → worker pool 3개 동시 처리로 전환 - state.currentChild (단일) → state.activeChildren (Set) 으로 확장, 취소 시 실행 중인 모든 자식 프로세스 kill - UI 는 카드 그리드라 병렬 진행 상태가 그대로 표시됨 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -23,10 +23,13 @@ interface RpInstallerState {
|
||||
selectedKey: string | null
|
||||
/** 현재 설치 진행 중인지 여부. 취소 신호로 사용. */
|
||||
cancelRequested: boolean
|
||||
/** 현재 실행 중인 외부 프로세스(yt-dlp/ffmpeg). 취소 시 kill 대상. */
|
||||
currentChild: ChildProcess | null
|
||||
/** 현재 실행 중인 외부 프로세스들(yt-dlp/ffmpeg). 취소 시 모두 kill. */
|
||||
activeChildren: Set<ChildProcess>
|
||||
}
|
||||
|
||||
/** 동시 yt-dlp 프로세스 수. 너무 높이면 유튜브가 throttle. */
|
||||
const MUSIC_CONCURRENCY = 3
|
||||
|
||||
const DEFAULT_MANIFEST_URL = process.env.MANIFEST_URL ?? 'http://127.0.0.1:3000/manifest.json'
|
||||
|
||||
const state: RpInstallerState = {
|
||||
@@ -35,7 +38,7 @@ const state: RpInstallerState = {
|
||||
packs: new Map(),
|
||||
selectedKey: null,
|
||||
cancelRequested: false,
|
||||
currentChild: null
|
||||
activeChildren: new Set()
|
||||
}
|
||||
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
@@ -196,47 +199,65 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
|
||||
sendProgress({ phase: 'prep', message: '준비 완료', done: true })
|
||||
throwIfCancelled()
|
||||
|
||||
// 2-2. 음악 다운로드 (1번부터 순차, ogg 변환)
|
||||
// 2-2. 음악 다운로드 (MUSIC_CONCURRENCY 개씩 병렬, ogg 변환)
|
||||
const musicDir = path.join(tempRoot, 'music')
|
||||
await fsp.mkdir(musicDir, { recursive: true })
|
||||
sendLog(`음악 다운로드 시작 (${musicTotal}곡)`)
|
||||
for (let i = 0; i < musicTotal; i++) {
|
||||
throwIfCancelled()
|
||||
const entry = pack.list.music[i]
|
||||
const idx = i + 1
|
||||
sendLog(`${idx}번 노래 다운로드 중…`)
|
||||
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 0, status: 'running' })
|
||||
try {
|
||||
const outPath = await downloadMusicTrack({
|
||||
ytdlpExe: ytDlpBin,
|
||||
ffmpegExe: ffmpegBin,
|
||||
tempDir: musicDir,
|
||||
index: idx,
|
||||
url: entry.url,
|
||||
log: sendLog,
|
||||
onChild: (c) => { state.currentChild = c },
|
||||
onProgress: (pct) => {
|
||||
// 다운로드(0~90%) + 변환(90~100%) 으로 매핑.
|
||||
sendProgress({
|
||||
phase: 'item', kind: 'music', index: idx, total: musicTotal,
|
||||
percent: Math.min(90, pct * 0.9), status: 'running'
|
||||
})
|
||||
sendLog(`음악 다운로드 시작 (${musicTotal}곡, 동시 ${MUSIC_CONCURRENCY}개)`)
|
||||
|
||||
// 클로저 안에서 narrowing 이 풀리지 않도록 로컬 alias.
|
||||
const musicList = pack.list.music
|
||||
let nextIndex = 0
|
||||
async function musicWorker(): Promise<void> {
|
||||
while (true) {
|
||||
if (state.cancelRequested) return
|
||||
const i = nextIndex++
|
||||
if (i >= musicTotal) return
|
||||
const entry = musicList[i]
|
||||
const idx = i + 1
|
||||
sendLog(`${idx}번 노래 다운로드 시작`)
|
||||
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 0, status: 'running' })
|
||||
let child: ChildProcess | null = null
|
||||
try {
|
||||
const outPath = await downloadMusicTrack({
|
||||
ytdlpExe: ytDlpBin,
|
||||
ffmpegExe: ffmpegBin,
|
||||
tempDir: musicDir,
|
||||
index: idx,
|
||||
url: entry.url,
|
||||
log: sendLog,
|
||||
onChild: (c) => {
|
||||
child = c
|
||||
state.activeChildren.add(c)
|
||||
},
|
||||
onProgress: (pct) => {
|
||||
// 다운로드(0~90%) + 변환(90~100%) 으로 매핑.
|
||||
sendProgress({
|
||||
phase: 'item', kind: 'music', index: idx, total: musicTotal,
|
||||
percent: Math.min(90, pct * 0.9), status: 'running'
|
||||
})
|
||||
}
|
||||
})
|
||||
if (child) state.activeChildren.delete(child)
|
||||
sendLog(`${idx}번 노래 완료: ${path.basename(outPath)}`)
|
||||
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 100, status: 'done' })
|
||||
} catch (err) {
|
||||
if (child) state.activeChildren.delete(child)
|
||||
if (state.cancelRequested) {
|
||||
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 0, status: 'error', message: '취소됨' })
|
||||
return
|
||||
}
|
||||
})
|
||||
state.currentChild = null
|
||||
sendLog(`${idx}번 노래 완료: ${path.basename(outPath)}`)
|
||||
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 100, status: 'done' })
|
||||
} catch (err) {
|
||||
state.currentChild = null
|
||||
if (state.cancelRequested) {
|
||||
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 0, status: 'error', message: '취소됨' })
|
||||
throwIfCancelled()
|
||||
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 0, status: 'error', message: (err as Error).message })
|
||||
throw new Error(`${idx}번 노래 다운로드 실패: ${(err as Error).message}`)
|
||||
}
|
||||
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 0, status: 'error', message: (err as Error).message })
|
||||
throw new Error(`${idx}번 노래 다운로드 실패: ${(err as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const workerCount = Math.min(MUSIC_CONCURRENCY, musicTotal)
|
||||
const workers: Promise<void>[] = []
|
||||
for (let w = 0; w < workerCount; w++) workers.push(musicWorker())
|
||||
await Promise.all(workers)
|
||||
throwIfCancelled()
|
||||
|
||||
// 2-3. 사진 다운로드 + painting variant 정규화
|
||||
const paintingDir = path.join(tempRoot, 'painting')
|
||||
await fsp.mkdir(paintingDir, { recursive: true })
|
||||
@@ -296,9 +317,9 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
|
||||
|
||||
ipcMain.handle('rp:install:cancel', async () => {
|
||||
state.cancelRequested = true
|
||||
sendLog('취소 요청됨. 진행 중 작업을 중단합니다…')
|
||||
if (state.currentChild && !state.currentChild.killed) {
|
||||
state.currentChild.kill()
|
||||
sendLog(`취소 요청됨. 실행 중 프로세스 ${state.activeChildren.size}개 중단…`)
|
||||
for (const child of state.activeChildren) {
|
||||
if (!child.killed) child.kill()
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -32,6 +32,11 @@ export function downloadMusicTrack(opts: DownloadMusicOptions): Promise<string>
|
||||
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',
|
||||
|
||||
Reference in New Issue
Block a user