Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 48d5223671 | |||
| 51af4464e8 |
@@ -205,6 +205,7 @@
|
|||||||
},
|
},
|
||||||
"log": {
|
"log": {
|
||||||
"manifestDownload": "manifest 다운로드: {{url}}",
|
"manifestDownload": "manifest 다운로드: {{url}}",
|
||||||
|
"downloadRetry": "다운로드 일시 오류({{reason}}) — {{attempt}}번째 재시도, {{secs}}초 후",
|
||||||
"packLoadFail": "pack 로드 실패 ({{file}}): {{message}}",
|
"packLoadFail": "pack 로드 실패 ({{file}}): {{message}}",
|
||||||
"packsLoaded": "로드된 음악퀴즈: {{count}}개",
|
"packsLoaded": "로드된 음악퀴즈: {{count}}개",
|
||||||
"selectedPack": "선택: {{key}}",
|
"selectedPack": "선택: {{key}}",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "minecraft-music-quiz-installer",
|
"name": "minecraft-music-quiz-installer",
|
||||||
"version": "0.4.11",
|
"version": "0.4.13",
|
||||||
"description": "마인크래프트 음악퀴즈 간편설치기 + 관리 사이트",
|
"description": "마인크래프트 음악퀴즈 간편설치기 + 관리 사이트",
|
||||||
"main": "dist/installer/main.js",
|
"main": "dist/installer/main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -38,6 +38,14 @@ export function downloadMusicTrack(opts: DownloadMusicOptions): Promise<string>
|
|||||||
// 단일 파일이 아니라 HLS/DASH fragmented 스트림일 때 청크를 병렬로.
|
// 단일 파일이 아니라 HLS/DASH fragmented 스트림일 때 청크를 병렬로.
|
||||||
// 일반 progressive 다운로드에는 영향 없음.
|
// 일반 progressive 다운로드에는 영향 없음.
|
||||||
'--concurrent-fragments', '5',
|
'--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',
|
||||||
// 진행률 표시 안정화 (yt-dlp 가 \r 대신 새 줄로 출력).
|
// 진행률 표시 안정화 (yt-dlp 가 \r 대신 새 줄로 출력).
|
||||||
'--newline',
|
'--newline',
|
||||||
'--extract-audio',
|
'--extract-audio',
|
||||||
|
|||||||
@@ -109,29 +109,61 @@ process.on('unhandledRejection', (reason) => {
|
|||||||
try { sendLog(t('log.internalError', { message: reason instanceof Error ? reason.message : String(reason) })) } catch {}
|
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) => {
|
return new Promise((resolve, reject) => {
|
||||||
const target = new URL(url)
|
const target = new URL(url)
|
||||||
const transport = target.protocol === 'https:' ? https : http
|
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) => {
|
const request = transport.get(target, { timeout: 30000 }, (response) => {
|
||||||
if (response.statusCode === 301 || response.statusCode === 302) {
|
const code = response.statusCode ?? 0
|
||||||
const redirect = response.headers.location
|
if ((code === 301 || code === 302 || code === 303 || code === 307 || code === 308) && response.headers.location) {
|
||||||
if (redirect) {
|
|
||||||
response.resume()
|
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
|
return
|
||||||
}
|
}
|
||||||
}
|
if (DOWNLOAD_TRANSIENT_CODES.has(code) && attempt < DOWNLOAD_MAX_RETRIES) {
|
||||||
if ((response.statusCode ?? 0) >= 400) {
|
|
||||||
response.resume()
|
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
|
return
|
||||||
}
|
}
|
||||||
const chunks: Buffer[] = []
|
const chunks: Buffer[] = []
|
||||||
response.on('data', (chunk: Buffer) => chunks.push(chunk))
|
response.on('data', (chunk: Buffer) => chunks.push(chunk))
|
||||||
response.on('end', () => resolve(Buffer.concat(chunks)))
|
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'))))
|
request.on('timeout', () => request.destroy(new Error(t('errors.requestTimeout'))))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user