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') // extract-zip 은 CommonJS 기본 export 라 require 로 받음. const extractZip: (source: string, options: { dir: string }) => Promise = require('extract-zip') /** * 리소스팩 간편설치기는 Windows .exe 로 배포되므로 ffmpeg.exe 한 종류만 사용. * 경로: %appdata%/.mc_custom/installer/ffmpeg.exe */ export function getFfmpegExePath(): string { return path.join(getMcCustomInstallerDir(), 'ffmpeg.exe') } /** * 0.2.1 이전 버전이 `.mc_custom/ffmpeg.exe` 에 받아둔 파일이 있으면 새 위치로 * 옮긴다. */ async function migrateLegacyExe(target: string): Promise { const legacy = path.join(getMcCustomDir(), 'ffmpeg.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 */ } } } /** BtbN/FFmpeg-Builds 의 win64-gpl 빌드. zip 내부에 bin/ffmpeg.exe 가 들어 있음. */ const FFMPEG_ZIP_URL = 'https://github.com/BtbN/FFmpeg-Builds/releases/latest/download/ffmpeg-master-latest-win64-gpl.zip' let installPromise: Promise | null = null /** * %appdata%/.mc_custom/ffmpeg.exe 가 없거나 실행 불가하면 BtbN 빌드 zip 에서 * ffmpeg.exe 만 추출해 설치하고 절대경로를 돌려준다. */ export async function ensureFfmpegExe( log?: (line: string) => void ): Promise { const target = getFfmpegExePath() await migrateLegacyExe(target) if (await canExecute(target)) { log?.(t('log.ffmpegExists', { path: target })) return target } if (installPromise) return installPromise installPromise = (async () => { const dir = getMcCustomInstallerDir() const zipPath = path.join(dir, '.tmp_ffmpeg.zip') const extractDir = path.join(dir, '.tmp_ffmpeg') try { await fs.mkdir(dir, { recursive: true }) // 이전 시도의 임시 파일/폴더 정리 await fs.rm(zipPath, { force: true }) await fs.rm(extractDir, { recursive: true, force: true }) log?.(t('log.ffmpegDownloading', { url: FFMPEG_ZIP_URL })) await downloadToFile(FFMPEG_ZIP_URL, zipPath) log?.(t('log.ffmpegExtracting')) await extractZip(zipPath, { dir: extractDir }) const found = await findFile(extractDir, 'ffmpeg.exe') if (!found) { throw new Error(t('errors.ffmpegNotInZip')) } // 같은 파일시스템(=같은 드라이브) 일 가능성이 높아 rename 시도, 실패 시 copyFile fallback. try { await fs.rename(found, target) } catch { await fs.copyFile(found, target) } const ok = await probeVersion(target) if (!ok) throw new Error(t('errors.ffmpegVerifyFailed')) log?.(t('log.ffmpegReady', { path: target })) return target } catch (err) { try { await fs.unlink(target) } catch { /* noop */ } throw new Error( t('errors.ffmpegInstallFailed', { message: err instanceof Error ? err.message : String(err) }) ) } finally { // 임시 파일/폴더 정리 await fs.rm(zipPath, { force: true }).catch(() => {}) await fs.rm(extractDir, { recursive: true, force: true }).catch(() => {}) 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)) }) } async function findFile(root: string, name: string): Promise { const entries = await fs.readdir(root, { withFileTypes: true }) for (const e of entries) { const full = path.join(root, e.name) if (e.isFile() && e.name.toLowerCase() === name.toLowerCase()) return full if (e.isDirectory()) { const inner = await findFile(full, name) if (inner) return inner } } return null } /** 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) }) }