Compare commits

...

5 Commits

Author SHA1 Message Date
9abf6da5f1 fix(rp): 노래 403 개선 — 플레이어 클라이언트 다변화 + 곡별 지연 재시도
v0.4.12 의 yt-dlp 내부 재시도(--retries)로도 남는 403(특정 클라이언트/스트림 URL
차단, 예: android_vr)에 대응:
- yt-dlp 에 --extractor-args youtube:player_client=default,web_safari,tv,ios 추가.
  한 클라이언트의 URL 이 403 이어도 다른 클라이언트 포맷으로 받는다(클라이언트명
  유효성은 실제 yt-dlp 로 검증).
- 실패한 곡의 순차 재시도를 1회 → 최대 3회로, 시도마다 점점 더(최대 20s) 쉬어
  유튜브 rate-limit 이 풀릴 시간을 준다. 0.4.13→0.4.14.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-22 00:45:33 +09:00
48d5223671 fix(installer): 다운로드 일시 오류 시 자동 재시도(백오프)
메인 간편설치기의 fetchBuffer/downloadFile 은 재시도가 없어, 너무 빠르게/자주
요청해 생기는 일시적 오류(429·5xx·네트워크 끊김·타임아웃, 일부 403)에 곧바로
실패했다. RP 설치기처럼 일시 오류를 지수 백오프(Retry-After 우선)로 최대 3회
재시도하도록 보강(모드 다중 다운로드/서버·맵·리소스팩·매니페스트 모두 적용).
404 등 영구 오류는 그대로 실패. 0.4.12→0.4.13.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-20 18:30:27 +09:00
51af4464e8 fix(rp): yt-dlp 403(Forbidden) 간헐 실패에 내부 재시도 추가
증상: 첫 곡은 정상인데 두 번째 곡부터 "HTTP Error 403: Forbidden — unable to
download video data" 로 노래 다운로드 실패(재시도하면 됨). 유튜브가 미디어 스트림에
간헐적 403 을 주는 케이스.
- yt-dlp 인자에 --retries 10 / --fragment-retries 10 / --extractor-retries 3 /
  --retry-sleep http:exp=1:30 / --socket-timeout 30 추가 → yt-dlp 가 스스로
  재시도·재추출해 한 번의 403 으로 곡이 실패하지 않게 함.
- 인자 유효성은 실제 yt-dlp 로 파싱 검증. 0.4.11→0.4.12.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-20 16:32:21 +09:00
971992b530 fix(rp): 종료 시 임시 파일 정리 보장 (실패→재시도 중 닫아도 삭제)
기존 window-all-closed 는 fsp.rm 을 fire-and-forget 로 부르고 곧바로 app.quit()
하여 삭제 완료 전에 프로세스가 죽었고, '재시도 중' 에는 yt-dlp/ffmpeg 자식이 파일을
잠가 삭제가 실패했다. before-quit 에서 종료를 미루고 자식을 먼저 종료해 잠금을 푼 뒤,
.temp 를 재시도 삭제(잠금 해제 대기)한 다음 실제 종료하도록 변경. 0.4.10→0.4.11.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-20 16:18:18 +09:00
868bee7931 perf(rp): 사진 다운로드 병렬화로 속도 개선
한 장씩 순차+딜레이(0.8s)로 느리던 사진 다운로드를 워커 풀(동시 4~8, CPU 기반)로
병렬화. 장간 고정 딜레이 제거, 음악 단계 후 쿨다운 4s→1s 로 축소. 429 는 기존
지수 백오프 재시도(images.ts)가 흡수하고, 첫 하드 실패 시 새 작업 배정을 멈춘 뒤
워커가 드레인되면 그 오류를 던진다(이어받기·취소 동작 유지). 0.4.9→0.4.10.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-20 13:54:53 +09:00
6 changed files with 172 additions and 70 deletions

View File

@@ -94,6 +94,7 @@
"musicTrackDone": "{{idx}}번 노래 완료: {{name}}",
"musicTrackSkip": "{{idx}}번 노래는 이전에 받아둠 → 건너뜀(이어받기)",
"musicRefreshRetry": "{{count}}곡 다운로드 실패 → yt-dlp/ffmpeg 최신 버전으로 재설치 후 실패한 곡만 재시도",
"musicTrackRetryWait": "{{idx}}번 노래 재시도 {{attempt}}회차 — 유튜브 속도제한(403) 회피로 {{secs}}초 대기 후 다시 시도",
"ytdlpReinstall": "yt-dlp.exe 최신 버전으로 강제 재설치 중…",
"ffmpegReinstall": "ffmpeg.exe 최신 버전으로 강제 재설치 중…",
"imageStart": "사진 다운로드 시작 ({{total}}장)",

View File

@@ -205,6 +205,7 @@
},
"log": {
"manifestDownload": "manifest 다운로드: {{url}}",
"downloadRetry": "다운로드 일시 오류({{reason}}) — {{attempt}}번째 재시도, {{secs}}초 후",
"packLoadFail": "pack 로드 실패 ({{file}}): {{message}}",
"packsLoaded": "로드된 음악퀴즈: {{count}}개",
"selectedPack": "선택: {{key}}",

View File

@@ -1,6 +1,6 @@
{
"name": "minecraft-music-quiz-installer",
"version": "0.4.9",
"version": "0.4.14",
"description": "마인크래프트 음악퀴즈 간편설치기 + 관리 사이트",
"main": "dist/installer/main.js",
"scripts": {

View File

@@ -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))
@@ -493,11 +493,25 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
await refreshBinariesOnce()
throwIfCancelled()
nextMusicStartAt = Date.now()
// 403(Forbidden)은 대개 유튜브의 일시적 rate-limit 이라, 곡마다 점점 더 오래
// 쉬면서 여러 번 다시 시도한다(마지막 시도에서만 에러 진행률 표시).
const MAX_TRACK_RETRIES = 3
for (const i of failed) {
throwIfCancelled()
let ok = false
for (let attempt = 1; attempt <= MAX_TRACK_RETRIES; attempt++) {
throwIfCancelled()
if (attempt > 1) {
const waitMs = Math.min(20000, 3000 * attempt)
sendLog(t('log.musicTrackRetryWait', { idx: i + 1, attempt, secs: Math.round(waitMs / 1000) }))
await sleep(waitMs)
throwIfCancelled()
}
await acquireMusicStartSlot()
throwIfCancelled()
const ok = await tryDownloadTrack(i, true)
ok = await tryDownloadTrack(i, attempt === MAX_TRACK_RETRIES)
if (ok) break
}
if (!ok) {
throwIfCancelled()
const idx = i + 1
@@ -516,21 +530,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 +570,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 +716,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()
})

View File

@@ -38,6 +38,17 @@ export function downloadMusicTrack(opts: DownloadMusicOptions): Promise<string>
// 단일 파일이 아니라 HLS/DASH fragmented 스트림일 때 청크를 병렬로.
// 일반 progressive 다운로드에는 영향 없음.
'--concurrent-fragments', '5',
// 유튜브가 미디어 스트림에 간헐적으로 403(Forbidden) 을 준다(특히 두 번째 곡부터
// throttle/클라이언트 이슈). yt-dlp 가 스스로 재시도·재추출하도록 해서 한 번의
// 403 으로 곡 전체가 실패하지 않게 한다.
'--retries', '10',
'--fragment-retries', '10',
'--extractor-retries', '3',
'--retry-sleep', 'http:exp=1:30',
'--socket-timeout', '30',
// 특정 플레이어 클라이언트(예: android_vr)의 스트림 URL 이 403 되는 경우가 있어
// 여러 클라이언트를 함께 시도한다. 하나가 막혀도 다른 클라이언트 포맷으로 받는다.
'--extractor-args', 'youtube:player_client=default,web_safari,tv,ios',
// 진행률 표시 안정화 (yt-dlp 가 \r 대신 새 줄로 출력).
'--newline',
'--extract-audio',

View File

@@ -109,29 +109,61 @@ process.on('unhandledRejection', (reason) => {
try { sendLog(t('log.internalError', { message: reason instanceof Error ? reason.message : String(reason) })) } catch {}
})
function fetchBuffer(url: string): Promise<Buffer> {
// 너무 빠르게/자주 요청해 생기는 일시적 오류(429 Too Many Requests, 5xx, 네트워크
// 끊김/타임아웃 등)는 잠깐 쉬고 몇 번 다시 시도한다. 403 도 일부 CDN 이 rate-limit 에
// 쓰므로 포함. 그 외 4xx(404 등)나 재시도 소진 시에는 그대로 실패.
const DOWNLOAD_TRANSIENT_CODES = new Set([403, 408, 425, 429, 500, 502, 503, 504])
const DOWNLOAD_MAX_RETRIES = 3
const DOWNLOAD_MAX_BACKOFF_MS = 30000
/** Retry-After 헤더(초 또는 HTTP-date) → 대기 ms. 못 읽으면 null. */
function parseRetryAfterMs(h: string | string[] | undefined): number | null {
if (!h) return null
const v = Array.isArray(h) ? h[0] : h
const secs = Number(v)
if (Number.isFinite(secs)) return Math.min(DOWNLOAD_MAX_BACKOFF_MS, Math.max(0, secs * 1000))
const date = Date.parse(v)
if (!Number.isNaN(date)) return Math.min(DOWNLOAD_MAX_BACKOFF_MS, Math.max(0, date - Date.now()))
return null
}
function fetchBuffer(url: string, redirects = 0, attempt = 0): Promise<Buffer> {
return new Promise((resolve, reject) => {
const target = new URL(url)
const transport = target.protocol === 'https:' ? https : http
const retryLater = (headerDelay: number | null, reason: string): void => {
const backoff = Math.min(DOWNLOAD_MAX_BACKOFF_MS, 1000 * 2 ** attempt) + Math.floor(Math.random() * 500)
const delay = headerDelay ?? backoff
sendLog(t('log.downloadRetry', { reason, attempt: attempt + 1, secs: Math.ceil(delay / 1000) }))
sleep(delay).then(() => fetchBuffer(url, redirects, attempt + 1).then(resolve, reject))
}
const request = transport.get(target, { timeout: 30000 }, (response) => {
if (response.statusCode === 301 || response.statusCode === 302) {
const redirect = response.headers.location
if (redirect) {
const code = response.statusCode ?? 0
if ((code === 301 || code === 302 || code === 303 || code === 307 || code === 308) && response.headers.location) {
response.resume()
fetchBuffer(new URL(redirect, target).toString()).then(resolve, reject)
if (redirects > 8) { reject(new Error(`HTTP ${code} (too many redirects)`)); return }
fetchBuffer(new URL(response.headers.location, target).toString(), redirects + 1, attempt).then(resolve, reject)
return
}
}
if ((response.statusCode ?? 0) >= 400) {
if (DOWNLOAD_TRANSIENT_CODES.has(code) && attempt < DOWNLOAD_MAX_RETRIES) {
response.resume()
reject(new Error(`HTTP ${response.statusCode}`))
retryLater(parseRetryAfterMs(response.headers['retry-after']), `HTTP ${code}`)
return
}
if (code >= 400) {
response.resume()
reject(new Error(`HTTP ${code}`))
return
}
const chunks: Buffer[] = []
response.on('data', (chunk: Buffer) => chunks.push(chunk))
response.on('end', () => resolve(Buffer.concat(chunks)))
})
request.on('error', reject)
request.on('error', (err) => {
// 연결 끊김/리셋 등 네트워크 오류도 몇 번은 재시도.
if (attempt < DOWNLOAD_MAX_RETRIES) { retryLater(null, (err as Error).message); return }
reject(err)
})
request.on('timeout', () => request.destroy(new Error(t('errors.requestTimeout'))))
})
}