diff --git a/locales/installer-rp/ko-kr.json b/locales/installer-rp/ko-kr.json index e73d1df..a76583c 100644 --- a/locales/installer-rp/ko-kr.json +++ b/locales/installer-rp/ko-kr.json @@ -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}}장)", diff --git a/src/installer-rp/main.ts b/src/installer-rp/main.ts index c48a24c..e1e0691 100644 --- a/src/installer-rp/main.ts +++ b/src/installer-rp/main.ts @@ -347,6 +347,59 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string // 클로저 안에서 narrowing 이 풀리지 않도록 로컬 alias. const musicList = pack.list.music + // 곡별 마지막 실패 메시지(재시도 단계에서 최종 에러 메시지로 사용). + const failedMessages = new Map() + + // 한 곡을 한 번 받아본다. 성공 true / 실패 false. + // emitErrorProgress=false 면 실패해도 UI 에 'error' 상태를 보내지 않는다(재시도 예정). + async function tryDownloadTrack(i: number, emitErrorProgress: boolean): Promise { + 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 { 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 })