diff --git a/package.json b/package.json index de208c8..8d5d905 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "minecraft-music-quiz-installer", - "version": "0.4.9", + "version": "0.4.10", "description": "마인크래프트 음악퀴즈 간편설치기 + 관리 사이트", "main": "dist/installer/main.js", "scripts": { diff --git a/src/installer-rp/main.ts b/src/installer-rp/main.ts index a6ae034..701871b 100644 --- a/src/installer-rp/main.ts +++ b/src/installer-rp/main.ts @@ -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 => new Promise((r) => setTimeout(r, ms)) @@ -516,58 +516,72 @@ 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] - const idx = i + 1 - // 이전 시도에서 이미 정규화해둔 사진은 건너뛴다(이어받기). - 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 - try { - buf = await downloadImage(entry.url, (info) => { - const secs = Math.ceil(info.delayMs / 1000) - sendLog(t('log.imageRetry', { idx, attempt: info.attempt, code: info.code ?? '-', secs })) - sendProgress({ - phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 10, status: 'running', - message: t('progress.imageRetry', { code: info.code ?? '-', secs }) + // 여러 장을 동시에 받아 속도를 높인다(순차+딜레이 대신 워커 풀). 유튜브 429 는 + // 각 다운로드의 지수 백오프 재시도(images.ts)가 흡수하고, 첫 하드 실패를 만나면 + // 새 작업 배정을 멈춘 뒤 워커가 모두 끝나면 그 오류를 던진다. + const images = pack.list.images + let imageNext = 0 + let imageError: Error | null = null + async function imageWorker(): Promise { + 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 + } + sendLog(t('log.imageDownloading', { idx })) + sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 10, status: 'running' }) + let buf: Buffer + try { + buf = await downloadImage(entry.url, (info) => { + const secs = Math.ceil(info.delayMs / 1000) + sendLog(t('log.imageRetry', { idx, attempt: info.attempt, code: info.code ?? '-', secs })) + sendProgress({ + phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 10, status: 'running', + message: t('progress.imageRetry', { code: info.code ?? '-', secs }) + }) }) - }) - } catch (err) { - // 부분 생성됐을 수 있는 커버 파일 제거(이어받기 시 완성본 오인 방지). - await fsp.rm(coverPath, { force: true }).catch(() => {}) - const rawMsg = (err as Error).message - // 429(rate limit)는 유튜브가 IP 를 일시 차단한 것. 잠시 뒤 다시 시도하면 - // 이미 받은 사진은 건너뛰고 이어받는다는 안내를 덧붙인다. - 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 })) + } catch (err) { + // 부분 생성됐을 수 있는 커버 파일 제거(이어받기 시 완성본 오인 방지). + await fsp.rm(coverPath, { force: true }).catch(() => {}) + const rawMsg = (err as Error).message + // 429(rate limit)는 유튜브가 IP 를 일시 차단한 것. 잠시 뒤 다시 시도하면 + // 이미 받은 사진은 건너뛰고 이어받는다는 안내를 덧붙인다. + 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 }) + if (!imageError) imageError = new Error(t('errors.imageDownloadFailed', { idx, message: msg })) + return + } + if (state.cancelRequested || imageError) return + sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 60, status: 'running' }) + try { + 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 }) + if (!imageError) imageError = new Error(t('errors.imageNormalizeFailed', { idx, message: (err as Error).message })) + return + } + sendLog(t('log.imageDone', { idx, name: path.basename(coverPath) })) + sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 100, status: 'done' }) } - throwIfCancelled() - sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 60, status: 'running' }) - const outPath = coverPath - try { - await normalizeToCover(buf, outPath) - } 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 })) - } - sendLog(t('log.imageDone', { idx, name: path.basename(outPath) })) - 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[] = [] + for (let w = 0; w < imageWorkerCount; w++) imageWorkers.push(imageWorker()) + await Promise.all(imageWorkers) + throwIfCancelled() + if (imageError) throw imageError + // 2-4. 베이스 리소스팩 다운로드 (있을 때만) throwIfCancelled() let baseZipPath: string | undefined