installer-rp: defer yt-dlp/ffmpeg reinstall until all music workers finish

Avoid the Windows file-lock race where one worker deletes/overwrites
yt-dlp.exe/ffmpeg.exe while sibling workers still run those processes.
Now pass 1 downloads all tracks and collects failures without any
mid-flight refresh; after Promise.all (no live child processes), the
binaries are force-reinstalled once and only the failed tracks retry.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
This commit is contained in:
2026-06-05 16:18:12 +09:00
parent d5f88e0e76
commit 399f4af808
2 changed files with 79 additions and 51 deletions

View File

@@ -82,7 +82,7 @@
"musicStart": "음악 다운로드 시작 ({{total}}곡, 동시 {{concurrency}}개, 시차 {{stagger}}ms)",
"musicTrackStart": "{{idx}}번 노래 다운로드 시작",
"musicTrackDone": "{{idx}}번 노래 완료: {{name}}",
"musicRetryAfterRefresh": "{{idx}}번 노래 실패({{message}}) → yt-dlp/ffmpeg 최신 버전으로 재설치 후 재시도",
"musicRefreshRetry": "{{count}}곡 다운로드 실패 → yt-dlp/ffmpeg 최신 버전으로 재설치 후 실패한 곡만 재시도",
"ytdlpReinstall": "yt-dlp.exe 최신 버전으로 강제 재설치 중…",
"ffmpegReinstall": "ffmpeg.exe 최신 버전으로 강제 재설치 중…",
"imageStart": "사진 다운로드 시작 ({{total}}장)",

View File

@@ -347,6 +347,59 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
// 클로저 안에서 narrowing 이 풀리지 않도록 로컬 alias.
const musicList = pack.list.music
// 곡별 마지막 실패 메시지(재시도 단계에서 최종 에러 메시지로 사용).
const failedMessages = new Map<number, string>()
// 한 곡을 한 번 받아본다. 성공 true / 실패 false.
// emitErrorProgress=false 면 실패해도 UI 에 'error' 상태를 보내지 않는다(재시도 예정).
async function tryDownloadTrack(i: number, emitErrorProgress: boolean): Promise<boolean> {
const entry = musicList[i]
const idx = i + 1
sendLog(t('log.musicTrackStart', { 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(t('log.musicTrackDone', { idx, name: path.basename(outPath) }))
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 100, status: 'done' })
return true
} 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: t('progress.cancelled') })
return false
}
failedMessages.set(i, (err as Error).message)
if (emitErrorProgress) {
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 0, status: 'error', message: (err as Error).message })
}
return false
}
}
// 1차 다운로드: 동시 워커로 전부 받아보고, 실패한 곡 인덱스만 모은다.
// 여기서는 yt-dlp/ffmpeg 재설치를 하지 않는다(다른 워커가 같은 exe 를 실행 중일 수
// 있어 Windows 파일 잠금으로 삭제/덮어쓰기가 실패할 수 있기 때문).
const failed: number[] = []
let nextIndex = 0
async function musicWorker(): Promise<void> {
while (true) {
@@ -356,56 +409,8 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
// 시차 게이트: 새 다운로드 시작은 직전 시작과 최소 MUSIC_START_STAGGER_MS 간격을 둠.
await acquireMusicStartSlot()
if (state.cancelRequested) return
const entry = musicList[i]
const idx = i + 1
sendLog(t('log.musicTrackStart', { idx }))
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 0, status: 'running' })
// 한 곡당 최대 2회 시도: 1차 실패 시 yt-dlp/ffmpeg 를 최신으로 강제
// 재설치(전역 1회)한 뒤 같은 곡을 다시 받아본다.
let attemptedRefresh = false
while (true) {
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(t('log.musicTrackDone', { idx, name: path.basename(outPath) }))
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 100, status: 'done' })
break
} 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: t('progress.cancelled') })
return
}
if (!attemptedRefresh) {
attemptedRefresh = true
sendLog(t('log.musicRetryAfterRefresh', { idx, message: (err as Error).message }))
await refreshBinariesOnce()
if (state.cancelRequested) return
continue
}
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 0, status: 'error', message: (err as Error).message })
throw new Error(t('errors.musicDownloadFailed', { idx, message: (err as Error).message }))
}
}
const ok = await tryDownloadTrack(i, false)
if (!ok && !state.cancelRequested) failed.push(i)
}
}
@@ -415,6 +420,29 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
await Promise.all(workers)
throwIfCancelled()
// 1차에서 실패한 곡이 있으면, 모든 워커가 끝나 실행 중인 yt-dlp/ffmpeg 자식
// 프로세스가 하나도 없는 지금 시점에 단 한 번 최신 버전으로 강제 재설치한다.
// (각 워커 promise 는 자식 프로세스 close 후 resolve 되므로 여기선 exe 가 잠겨
// 있지 않다 → Windows 파일 잠금 문제 없음.) 그런 다음 실패한 곡만 순차 재시도.
if (failed.length > 0) {
failed.sort((a, b) => a - b)
sendLog(t('log.musicRefreshRetry', { count: failed.length }))
await refreshBinariesOnce()
throwIfCancelled()
nextMusicStartAt = Date.now()
for (const i of failed) {
throwIfCancelled()
await acquireMusicStartSlot()
throwIfCancelled()
const ok = await tryDownloadTrack(i, true)
if (!ok) {
throwIfCancelled()
const idx = i + 1
throw new Error(t('errors.musicDownloadFailed', { idx, message: failedMessages.get(i) ?? '' }))
}
}
}
// 2-3. 사진 다운로드 + painting variant 정규화
const paintingDir = path.join(tempRoot, 'painting')
await fsp.mkdir(paintingDir, { recursive: true })