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

View File

@@ -347,23 +347,16 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
// 클로저 안에서 narrowing 이 풀리지 않도록 로컬 alias. // 클로저 안에서 narrowing 이 풀리지 않도록 로컬 alias.
const musicList = pack.list.music const musicList = pack.list.music
let nextIndex = 0 // 곡별 마지막 실패 메시지(재시도 단계에서 최종 에러 메시지로 사용).
async function musicWorker(): Promise<void> { const failedMessages = new Map<number, string>()
while (true) {
if (state.cancelRequested) return // 한 곡을 한 번 받아본다. 성공 true / 실패 false.
const i = nextIndex++ // emitErrorProgress=false 면 실패해도 UI 에 'error' 상태를 보내지 않는다(재시도 예정).
if (i >= musicTotal) return async function tryDownloadTrack(i: number, emitErrorProgress: boolean): Promise<boolean> {
// 시차 게이트: 새 다운로드 시작은 직전 시작과 최소 MUSIC_START_STAGGER_MS 간격을 둠.
await acquireMusicStartSlot()
if (state.cancelRequested) return
const entry = musicList[i] const entry = musicList[i]
const idx = i + 1 const idx = i + 1
sendLog(t('log.musicTrackStart', { idx })) sendLog(t('log.musicTrackStart', { idx }))
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 0, status: 'running' }) 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 let child: ChildProcess | null = null
try { try {
const outPath = await downloadMusicTrack({ const outPath = await downloadMusicTrack({
@@ -388,24 +381,36 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
if (child) state.activeChildren.delete(child) if (child) state.activeChildren.delete(child)
sendLog(t('log.musicTrackDone', { idx, name: path.basename(outPath) })) sendLog(t('log.musicTrackDone', { idx, name: path.basename(outPath) }))
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 100, status: 'done' }) sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 100, status: 'done' })
break return true
} catch (err) { } catch (err) {
if (child) state.activeChildren.delete(child) if (child) state.activeChildren.delete(child)
if (state.cancelRequested) { if (state.cancelRequested) {
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 0, status: 'error', message: t('progress.cancelled') }) sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 0, status: 'error', message: t('progress.cancelled') })
return return false
}
if (!attemptedRefresh) {
attemptedRefresh = true
sendLog(t('log.musicRetryAfterRefresh', { idx, message: (err as Error).message }))
await refreshBinariesOnce()
if (state.cancelRequested) return
continue
} }
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 }) 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 })) }
return false
} }
} }
// 1차 다운로드: 동시 워커로 전부 받아보고, 실패한 곡 인덱스만 모은다.
// 여기서는 yt-dlp/ffmpeg 재설치를 하지 않는다(다른 워커가 같은 exe 를 실행 중일 수
// 있어 Windows 파일 잠금으로 삭제/덮어쓰기가 실패할 수 있기 때문).
const failed: number[] = []
let nextIndex = 0
async function musicWorker(): Promise<void> {
while (true) {
if (state.cancelRequested) return
const i = nextIndex++
if (i >= musicTotal) return
// 시차 게이트: 새 다운로드 시작은 직전 시작과 최소 MUSIC_START_STAGGER_MS 간격을 둠.
await acquireMusicStartSlot()
if (state.cancelRequested) return
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) await Promise.all(workers)
throwIfCancelled() 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 정규화 // 2-3. 사진 다운로드 + painting variant 정규화
const paintingDir = path.join(tempRoot, 'painting') const paintingDir = path.join(tempRoot, 'painting')
await fsp.mkdir(paintingDir, { recursive: true }) await fsp.mkdir(paintingDir, { recursive: true })