Files
minecraft_launcher/src/installer-rp/images.ts
claude-bot f6df5f936c installer-rp: retry image download on HTTP 429/5xx with backoff
i.ytimg.com 썸네일 서버가 연속 요청을 속도제한(HTTP 429)하면 사진
다운로드가 즉시 실패해 전체 설치가 중단됐다. 일시적 상태코드
(408/425/429/5xx)와 네트워크 오류를 Retry-After 우선 + 지수 백오프(jitter)로
최대 5회 재시도하도록 fetchBuffer 를 보강. v0.3.9.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-06-07 23:36:30 +09:00

180 lines
6.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { promises as fs } from 'node:fs'
import path from 'node:path'
import http from 'node:http'
import https from 'node:https'
import { URL } from 'node:url'
import sharp from 'sharp'
import { loadComponentI18n } from '../shared/i18n.js'
const { t } = loadComponentI18n('installer-rp')
/** painting variant 텍스처의 최대 변 길이(px). 슬롯 4x4 × 256px. */
const MAX_SIDE = 1024
/** 유튜브 URL 에서 영상 ID 만 뽑아낸다. 못 찾으면 빈 문자열. */
export function ytIdFromUrl(url: string): string {
try {
const u = new URL(url)
if (u.hostname === 'youtu.be') return u.pathname.replace(/^\//, '')
if (/youtube\.com$/i.test(u.hostname) || /^(www\.|m\.)?youtube\.com$/i.test(u.hostname)) {
const v = u.searchParams.get('v')
if (v) return v
// shorts/<id>, embed/<id> 형태도 대응
const m = u.pathname.match(/\/(?:shorts|embed)\/([^/]+)/)
if (m) return m[1]
}
return ''
} catch {
return ''
}
}
/**
* 일시적(transient) 으로 보고 재시도할 HTTP 상태코드.
* 429 = Too Many Requests (i.ytimg.com 썸네일 서버가 연속 요청을 속도제한).
* 5xx 게이트웨이 계열도 잠깐 뒤 다시 받으면 성공하는 경우가 많다.
*/
const TRANSIENT_CODES = new Set([408, 425, 429, 500, 502, 503, 504])
const MAX_RETRIES = 5
/** 백오프 상한(ms). Retry-After 헤더가 비정상적으로 커도 이 이상은 기다리지 않는다. */
const MAX_BACKOFF_MS = 60000
/** Retry-After 헤더(초 또는 HTTP-date) → 대기 ms. 못 읽으면 null. */
function parseRetryAfter(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(MAX_BACKOFF_MS, Math.max(0, secs * 1000))
const date = Date.parse(v)
if (!Number.isNaN(date)) return Math.min(MAX_BACKOFF_MS, Math.max(0, date - Date.now()))
return null
}
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms))
/**
* 단순 HTTP/HTTPS GET (302 따라감).
* 429/5xx 등 일시적 오류는 지수 백오프(+jitter, Retry-After 우선)로 최대
* MAX_RETRIES 회 재시도한다. 그 외 4xx 나 재시도 소진 시 reject.
*/
function fetchBuffer(url: string, redirects = 0, attempt = 0): Promise<Buffer> {
return new Promise((resolve, reject) => {
if (redirects > 8) {
reject(new Error(t('common.tooManyRedirects')))
return
}
const target = new URL(url)
const lib = target.protocol === 'https:' ? https : http
const retryLater = (headerDelay: number | null): void => {
const backoff = Math.min(MAX_BACKOFF_MS, 1000 * 2 ** attempt) + Math.floor(Math.random() * 500)
const delay = headerDelay ?? backoff
sleep(delay).then(() => fetchBuffer(url, redirects, attempt + 1).then(resolve, reject))
}
const req = lib.get(target, {
timeout: 30000,
headers: { 'user-agent': 'mc-music-quiz-rp-installer' }
}, (res) => {
const code = res.statusCode || 0
if (code >= 300 && code < 400 && res.headers.location) {
res.resume()
fetchBuffer(new URL(res.headers.location, target).toString(), redirects + 1, attempt)
.then(resolve, reject)
return
}
if (TRANSIENT_CODES.has(code) && attempt < MAX_RETRIES) {
res.resume()
retryLater(parseRetryAfter(res.headers['retry-after']))
return
}
if (code !== 200) {
res.resume()
reject(new Error(`HTTP ${code}`))
return
}
const chunks: Buffer[] = []
res.on('data', (c: Buffer) => chunks.push(c))
res.on('end', () => resolve(Buffer.concat(chunks)))
})
req.on('error', (err) => {
// 연결 끊김/리셋 등 네트워크 오류도 몇 번은 재시도.
if (attempt < MAX_RETRIES) {
retryLater(null)
return
}
reject(err)
})
req.on('timeout', () => req.destroy(new Error(t('common.requestTimeout'))))
})
}
/**
* data: URL 이면 그 안에 들어 있는 바이트를 바로 Buffer 로 디코드한다.
* data: URL 은 이미지 데이터 자체를 품고 있어 네트워크 요청이 필요 없으며,
* http/https 만 다루는 fetchBuffer 에 넘기면 `Protocol "data:" not supported`
* 로 터지므로 여기서 가로챈다. data: URL 이 아니면 null.
*/
function decodeDataUrl(url: string): Buffer | null {
if (!/^data:/i.test(url)) return null
const comma = url.indexOf(',')
if (comma < 0) throw new Error(t('errors.imageDataUrlInvalid'))
const meta = url.slice(5, comma)
const data = url.slice(comma + 1)
// `;base64` 가 있으면 base64, 없으면 percent-encoding 된 텍스트.
const buf = /;base64/i.test(meta)
? Buffer.from(data, 'base64')
: Buffer.from(decodeURIComponent(data), 'utf8')
if (buf.length === 0) throw new Error(t('errors.imageDataUrlInvalid'))
return buf
}
/**
* 이미지 URL 을 다운로드해 Buffer 로 돌려준다.
* - data: URL 이면 내장 바이트를 바로 디코드 (네트워크 없음).
* - 유튜브 영상 URL 이면 `i.ytimg.com/vi/<id>/maxresdefault.jpg` 1차 →
* 실패하면 `hqdefault.jpg` 로 폴백.
* - 그 외 URL 은 HTTP GET 으로 그대로 받음.
*/
export async function downloadImage(rawUrl: string): Promise<Buffer> {
const dataBuf = decodeDataUrl(rawUrl)
if (dataBuf) return dataBuf
const ytId = ytIdFromUrl(rawUrl)
if (ytId) {
try {
return await fetchBuffer(`https://i.ytimg.com/vi/${ytId}/maxresdefault.jpg`)
} catch {
return await fetchBuffer(`https://i.ytimg.com/vi/${ytId}/hqdefault.jpg`)
}
}
return fetchBuffer(rawUrl)
}
/**
* painting variant 슬롯 규격(정사각 1:1, ≤1024×1024)에 맞춰 정규화.
* 알고리즘 (docs/add.md):
* 1) s = min(가로, 세로) → 가운데 정사각 크롭 (s×s)
* 2) s > 1024 이면 1024×1024 로 축소 (Lanczos)
* 3) s ≤ 1024 이면 그대로 (업스케일 없음)
* 결과를 PNG 로 outPath 에 저장.
*/
export async function normalizeToCover(buffer: Buffer, outPath: string): Promise<void> {
const img = sharp(buffer)
const meta = await img.metadata()
const w = meta.width ?? 0
const h = meta.height ?? 0
if (w <= 0 || h <= 0) throw new Error(t('errors.imageMetaUnknown'))
const s = Math.min(w, h)
const left = Math.floor((w - s) / 2)
const top = Math.floor((h - s) / 2)
let pipeline = img.extract({ left, top, width: s, height: s })
if (s > MAX_SIDE) {
pipeline = pipeline.resize(MAX_SIDE, MAX_SIDE, { kernel: 'lanczos3' })
}
await fs.mkdir(path.dirname(outPath), { recursive: true })
await pipeline.png().toFile(outPath)
}
/** cover_NN.png 파일명을 만든다 (NN 2자리 0패딩). */
export function coverFileName(index: number): string {
return `cover_${String(index).padStart(2, '0')}.png`
}