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/, embed/ 형태도 대응 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 => 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 { 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//maxresdefault.jpg` 1차 → * 실패하면 `hqdefault.jpg` 로 폴백. * - 그 외 URL 은 HTTP GET 으로 그대로 받음. */ export async function downloadImage(rawUrl: string): Promise { 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 { 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` }