|
|
|
|
@@ -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()
|
|
|
|
|
|