import { spawn } from 'node:child_process' import { promises as fs, createWriteStream, constants as fsConst } from 'node:fs' import path from 'node:path' import https from 'node:https' import http from 'node:http' import { getMcCustomDir, getMcCustomInstallerDir } from '../shared/paths.js' import { loadComponentI18n } from '../shared/i18n.js' const { t } = loadComponentI18n('installer-rp') /** * 리소스팩 간편설치기는 Windows .exe 로 배포되므로 yt-dlp.exe 한 종류만 사용. * 경로: %appdata%/.mc_custom/installer/yt-dlp.exe */ export function getYtDlpExePath(): string { return path.join(getMcCustomInstallerDir(), 'yt-dlp.exe') } /** * 0.2.1 이전 버전이 `.mc_custom/yt-dlp.exe` 에 받아둔 파일이 있으면 새 위치로 * 옮긴다. 마인크래프트 게임 폴더 루트가 외부 도구 파일로 더럽혀지지 않도록. */ async function migrateLegacyExe(target: string): Promise { const legacy = path.join(getMcCustomDir(), 'yt-dlp.exe') if (legacy === target) return try { await fs.access(legacy, fsConst.F_OK) } catch { return } try { await fs.mkdir(path.dirname(target), { recursive: true }) await fs.rename(legacy, target) } catch { // 권한·드라이브 문제 등으로 실패하면 그냥 새로 받으면 되므로 무시. try { await fs.unlink(legacy) } catch { /* noop */ } } } const YT_DLP_DOWNLOAD_URL = 'https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe' let installPromise: Promise | null = null /** * %appdata%/.mc_custom/yt-dlp.exe 가 없거나 실행 불가능하면 GitHub Releases * 의 최신 yt-dlp.exe 를 받아 설치하고, 그 절대경로를 돌려준다. */ export async function ensureYtDlpExe( log?: (line: string) => void ): Promise { const target = getYtDlpExePath() await migrateLegacyExe(target) if (await canExecute(target)) { log?.(t('log.ytdlpExists', { path: target })) return target } if (installPromise) return installPromise installPromise = (async () => { try { await fs.mkdir(path.dirname(target), { recursive: true }) log?.(t('log.ytdlpDownloading', { url: YT_DLP_DOWNLOAD_URL })) await downloadToFile(YT_DLP_DOWNLOAD_URL, target) const okVersion = await probeVersion(target) if (!okVersion) { throw new Error(t('errors.ytdlpVerifyFailed')) } log?.(t('log.ytdlpReady', { path: target })) return target } catch (err) { // 부분 다운로드 흔적 정리 try { await fs.unlink(target) } catch { /* noop */ } throw new Error( t('errors.ytdlpInstallFailed', { message: err instanceof Error ? err.message : String(err) }) ) } finally { installPromise = null } })() return installPromise } async function canExecute(filePath: string): Promise { try { await fs.access(filePath, fsConst.F_OK) } catch { return false } return probeVersion(filePath) } function probeVersion(bin: string): Promise { return new Promise((resolve) => { const child = spawn(bin, ['--version'], { stdio: ['ignore', 'pipe', 'pipe'] }) let ok = false child.stdout.on('data', () => { ok = true }) child.on('error', () => resolve(false)) child.on('close', (code) => resolve(ok && code === 0)) }) } /** GitHub Releases 의 latest URL 은 302 리다이렉트를 사용하므로 따라가며 받음. */ function downloadToFile(url: string, dest: string, redirects = 0): Promise { return new Promise((resolve, reject) => { if (redirects > 8) { reject(new Error(t('common.tooManyRedirects'))) return } const lib = url.startsWith('https://') ? https : http const req = lib.get(url, { headers: { 'user-agent': 'mc-music-quiz-rp-installer' } }, (res) => { const code = res.statusCode || 0 if (code >= 300 && code < 400 && res.headers.location) { res.resume() downloadToFile(res.headers.location, dest, redirects + 1).then(resolve, reject) return } if (code !== 200) { res.resume() reject(new Error(`HTTP ${code} (${url})`)) return } const out = createWriteStream(dest) res.pipe(out) out.on('finish', () => out.close((err) => err ? reject(err) : resolve())) out.on('error', reject) res.on('error', reject) }) req.on('error', reject) }) }