|
|
|
|
@@ -77,10 +77,10 @@ function pickMusicConcurrency(): number {
|
|
|
|
|
*/
|
|
|
|
|
const MUSIC_START_STAGGER_MS = 2000
|
|
|
|
|
|
|
|
|
|
/** 사진(썸네일) 다운로드 사이 최소 간격(ms). i.ytimg.com 429(rate limit) 를 유발하지 않도록 순차 요청을 살짝 벌린다. */
|
|
|
|
|
const IMAGE_REQUEST_INTERVAL_MS = 800
|
|
|
|
|
/** 음악(yt-dlp) 단계 직후엔 유튜브가 IP 를 일시 throttle 할 수 있어, 사진 단계 전 잠깐 쉬어 429 를 피한다. */
|
|
|
|
|
const IMAGE_PHASE_COOLDOWN_MS = 4000
|
|
|
|
|
/** 사진(썸네일) 동시 다운로드 상한. 순차 대신 병렬로 받아 속도를 높인다. 429 는 images.ts 의 지수 백오프 재시도가 흡수한다. */
|
|
|
|
|
const IMAGE_CONCURRENCY_CAP = 8
|
|
|
|
|
/** 음악(yt-dlp) 단계 직후 유튜브 IP throttle 이 남아 있을 수 있어, 사진 단계 전 아주 잠깐만 쉰다. */
|
|
|
|
|
const IMAGE_PHASE_COOLDOWN_MS = 1000
|
|
|
|
|
|
|
|
|
|
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms))
|
|
|
|
|
|
|
|
|
|
@@ -516,21 +516,26 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
|
|
|
|
|
sendLog(t('log.imageCooldown', { secs: Math.round(IMAGE_PHASE_COOLDOWN_MS / 1000) }))
|
|
|
|
|
await sleep(IMAGE_PHASE_COOLDOWN_MS)
|
|
|
|
|
}
|
|
|
|
|
let imageRequests = 0
|
|
|
|
|
for (let i = 0; i < imageTotal; i++) {
|
|
|
|
|
throwIfCancelled()
|
|
|
|
|
const entry = pack.list.images[i]
|
|
|
|
|
// 여러 장을 동시에 받아 속도를 높인다(순차+딜레이 대신 워커 풀). 유튜브 429 는
|
|
|
|
|
// 각 다운로드의 지수 백오프 재시도(images.ts)가 흡수하고, 첫 하드 실패를 만나면
|
|
|
|
|
// 새 작업 배정을 멈춘 뒤 워커가 모두 끝나면 그 오류를 던진다.
|
|
|
|
|
const images = pack.list.images
|
|
|
|
|
let imageNext = 0
|
|
|
|
|
let imageError: Error | null = null
|
|
|
|
|
async function imageWorker(): Promise<void> {
|
|
|
|
|
while (true) {
|
|
|
|
|
if (state.cancelRequested || imageError) return
|
|
|
|
|
const i = imageNext++
|
|
|
|
|
if (i >= imageTotal) return
|
|
|
|
|
const idx = i + 1
|
|
|
|
|
// 이전 시도에서 이미 정규화해둔 사진은 건너뛴다(이어받기).
|
|
|
|
|
const entry = images[i]
|
|
|
|
|
const coverPath = path.join(paintingDir, coverFileName(idx))
|
|
|
|
|
// 이전 시도에서 이미 정규화해둔 사진은 건너뛴다(이어받기).
|
|
|
|
|
if (await fileExists(coverPath)) {
|
|
|
|
|
sendLog(t('log.imageSkip', { idx }))
|
|
|
|
|
sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 100, status: 'done' })
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
// 실제 네트워크 요청을 하는 사진들 사이에만 간격을 둔다(건너뛴 것 사이엔 대기 없음).
|
|
|
|
|
if (imageRequests > 0) await sleep(IMAGE_REQUEST_INTERVAL_MS + Math.floor(Math.random() * 300))
|
|
|
|
|
imageRequests++
|
|
|
|
|
sendLog(t('log.imageDownloading', { idx }))
|
|
|
|
|
sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 10, status: 'running' })
|
|
|
|
|
let buf: Buffer
|
|
|
|
|
@@ -551,22 +556,31 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
|
|
|
|
|
// 이미 받은 사진은 건너뛰고 이어받는다는 안내를 덧붙인다.
|
|
|
|
|
const msg = /429/.test(rawMsg) ? `${rawMsg} — ${t('errors.imageRateLimitHint')}` : rawMsg
|
|
|
|
|
sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 0, status: 'error', message: msg })
|
|
|
|
|
throw new Error(t('errors.imageDownloadFailed', { idx, message: msg }))
|
|
|
|
|
if (!imageError) imageError = new Error(t('errors.imageDownloadFailed', { idx, message: msg }))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
throwIfCancelled()
|
|
|
|
|
if (state.cancelRequested || imageError) return
|
|
|
|
|
sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 60, status: 'running' })
|
|
|
|
|
const outPath = coverPath
|
|
|
|
|
try {
|
|
|
|
|
await normalizeToCover(buf, outPath)
|
|
|
|
|
await normalizeToCover(buf, coverPath)
|
|
|
|
|
} catch (err) {
|
|
|
|
|
// 변환 중 부분 생성된 PNG 제거(이어받기 시 완성본 오인 방지).
|
|
|
|
|
await fsp.rm(coverPath, { force: true }).catch(() => {})
|
|
|
|
|
sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 0, status: 'error', message: (err as Error).message })
|
|
|
|
|
throw new Error(t('errors.imageNormalizeFailed', { idx, message: (err as Error).message }))
|
|
|
|
|
if (!imageError) imageError = new Error(t('errors.imageNormalizeFailed', { idx, message: (err as Error).message }))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
sendLog(t('log.imageDone', { idx, name: path.basename(outPath) }))
|
|
|
|
|
sendLog(t('log.imageDone', { idx, name: path.basename(coverPath) }))
|
|
|
|
|
sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 100, status: 'done' })
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const imageWorkerCount = Math.min(IMAGE_CONCURRENCY_CAP, Math.max(1, imageTotal), Math.max(concurrency, 4))
|
|
|
|
|
const imageWorkers: Promise<void>[] = []
|
|
|
|
|
for (let w = 0; w < imageWorkerCount; w++) imageWorkers.push(imageWorker())
|
|
|
|
|
await Promise.all(imageWorkers)
|
|
|
|
|
throwIfCancelled()
|
|
|
|
|
if (imageError) throw imageError
|
|
|
|
|
|
|
|
|
|
// 2-4. 베이스 리소스팩 다운로드 (있을 때만)
|
|
|
|
|
throwIfCancelled()
|
|
|
|
|
@@ -688,8 +702,37 @@ app.whenReady().then(() => {
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// 종료 시 임시 파일(.temp) 정리. 예전엔 window-all-closed 에서 fsp.rm 을 fire-and-forget
|
|
|
|
|
// 으로 호출하고 곧바로 app.quit() 해서, 삭제가 끝나기 전에 프로세스가 죽어 미리 받아둔
|
|
|
|
|
// 내용이 남곤 했다. 또 '재시도 중' 이면 yt-dlp/ffmpeg 자식이 파일을 잠가 삭제가 실패한다.
|
|
|
|
|
// → 자식을 먼저 죽여 잠금을 풀고, 삭제가 끝날 때까지 종료를 미룬 뒤 실제로 종료한다.
|
|
|
|
|
let rpExitCleanupDone = false
|
|
|
|
|
async function cleanupTempOnExit(): Promise<void> {
|
|
|
|
|
state.cancelRequested = true
|
|
|
|
|
for (const child of state.activeChildren) {
|
|
|
|
|
try { if (!child.killed) child.kill() } catch { /* noop */ }
|
|
|
|
|
}
|
|
|
|
|
const tempDir = path.join(getMcCustomDir(), '.temp')
|
|
|
|
|
// 자식 종료로 파일 잠금이 풀릴 시간을 주며 몇 번 재시도.
|
|
|
|
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
|
|
|
await new Promise((r) => setTimeout(r, attempt === 0 ? 200 : 400))
|
|
|
|
|
try {
|
|
|
|
|
await fsp.rm(tempDir, { recursive: true, force: true })
|
|
|
|
|
if (!fs.existsSync(tempDir)) return
|
|
|
|
|
} catch { /* 잠금 등 — 다음 시도 */ }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
app.on('before-quit', (event) => {
|
|
|
|
|
if (rpExitCleanupDone) return
|
|
|
|
|
event.preventDefault()
|
|
|
|
|
void cleanupTempOnExit().finally(() => {
|
|
|
|
|
rpExitCleanupDone = true
|
|
|
|
|
app.quit()
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
app.on('window-all-closed', () => {
|
|
|
|
|
// 강제 종료 시에도 임시 파일은 정리.
|
|
|
|
|
fsp.rm(path.join(getMcCustomDir(), '.temp'), { recursive: true, force: true }).catch(() => {})
|
|
|
|
|
// app.quit() → before-quit 에서 임시 파일을 정리한 뒤 실제 종료.
|
|
|
|
|
if (process.platform !== 'darwin') app.quit()
|
|
|
|
|
})
|
|
|
|
|
|