Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9abf6da5f1 | |||
| 48d5223671 | |||
| 51af4464e8 | |||
| 971992b530 |
@@ -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}}장)",
|
||||
|
||||
@@ -205,6 +205,7 @@
|
||||
},
|
||||
"log": {
|
||||
"manifestDownload": "manifest 다운로드: {{url}}",
|
||||
"downloadRetry": "다운로드 일시 오류({{reason}}) — {{attempt}}번째 재시도, {{secs}}초 후",
|
||||
"packLoadFail": "pack 로드 실패 ({{file}}): {{message}}",
|
||||
"packsLoaded": "로드된 음악퀴즈: {{count}}개",
|
||||
"selectedPack": "선택: {{key}}",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "minecraft-music-quiz-installer",
|
||||
"version": "0.4.10",
|
||||
"version": "0.4.14",
|
||||
"description": "마인크래프트 음악퀴즈 간편설치기 + 관리 사이트",
|
||||
"main": "dist/installer/main.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -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()
|
||||
await acquireMusicStartSlot()
|
||||
throwIfCancelled()
|
||||
const ok = await tryDownloadTrack(i, true)
|
||||
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()
|
||||
ok = await tryDownloadTrack(i, attempt === MAX_TRACK_RETRIES)
|
||||
if (ok) break
|
||||
}
|
||||
if (!ok) {
|
||||
throwIfCancelled()
|
||||
const idx = i + 1
|
||||
@@ -702,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()
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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) {
|
||||
response.resume()
|
||||
fetchBuffer(new URL(redirect, target).toString()).then(resolve, reject)
|
||||
return
|
||||
}
|
||||
}
|
||||
if ((response.statusCode ?? 0) >= 400) {
|
||||
const code = response.statusCode ?? 0
|
||||
if ((code === 301 || code === 302 || code === 303 || code === 307 || code === 308) && response.headers.location) {
|
||||
response.resume()
|
||||
reject(new Error(`HTTP ${response.statusCode}`))
|
||||
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 (DOWNLOAD_TRANSIENT_CODES.has(code) && attempt < DOWNLOAD_MAX_RETRIES) {
|
||||
response.resume()
|
||||
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'))))
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user