diff --git a/src/installer-rp/ffmpeg.ts b/src/installer-rp/ffmpeg.ts new file mode 100644 index 0000000..778f153 --- /dev/null +++ b/src/installer-rp/ffmpeg.ts @@ -0,0 +1,143 @@ +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 } from '../shared/paths.js' + +// extract-zip 은 CommonJS 기본 export 라 require 로 받음. +const extractZip: (source: string, options: { dir: string }) => Promise = require('extract-zip') + +/** + * 리소스팩 간편설치기는 Windows .exe 로 배포되므로 ffmpeg.exe 한 종류만 사용. + * 경로: %appdata%/.mc_custom/ffmpeg.exe + */ +export function getFfmpegExePath(): string { + return path.join(getMcCustomDir(), 'ffmpeg.exe') +} + +/** 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() + if (await canExecute(target)) { + log?.(`ffmpeg.exe 이미 있음: ${target}`) + return target + } + if (installPromise) return installPromise + + installPromise = (async () => { + const dir = getMcCustomDir() + 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?.(`ffmpeg.exe 다운로드 중: ${FFMPEG_ZIP_URL}`) + await downloadToFile(FFMPEG_ZIP_URL, zipPath) + log?.('ffmpeg zip 압축 해제 중…') + await extractZip(zipPath, { dir: extractDir }) + + const found = await findFile(extractDir, 'ffmpeg.exe') + if (!found) { + throw new Error('zip 내부에서 ffmpeg.exe 를 찾을 수 없습니다.') + } + // 같은 파일시스템(=같은 드라이브) 일 가능성이 높아 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('ffmpeg.exe 다운로드는 됐지만 실행 검증에 실패했습니다.') + log?.(`ffmpeg.exe 준비 완료: ${target}`) + return target + } catch (err) { + try { await fs.unlink(target) } catch { /* noop */ } + throw new Error( + 'ffmpeg.exe 자동 설치 실패: ' + + (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('redirect 가 너무 많습니다.')) + 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) + }) +} diff --git a/src/installer-rp/main.ts b/src/installer-rp/main.ts index c667677..67ba98c 100644 --- a/src/installer-rp/main.ts +++ b/src/installer-rp/main.ts @@ -5,10 +5,13 @@ import path from 'node:path' import fs from 'node:fs' import fsp from 'node:fs/promises' import { URL } from 'node:url' +import type { ChildProcess } from 'node:child_process' import type { Manifest, PackList } from '../shared/types.js' import { getAppDataDir, getMcCustomDir } from '../shared/paths.js' import type { RpFetchedPack } from './types.js' import { ensureYtDlpExe } from './ytdlp.js' +import { ensureFfmpegExe } from './ffmpeg.js' +import { downloadMusicTrack } from './music.js' interface RpInstallerState { manifestUrl: string @@ -17,6 +20,8 @@ interface RpInstallerState { selectedKey: string | null /** 현재 설치 진행 중인지 여부. 취소 신호로 사용. */ cancelRequested: boolean + /** 현재 실행 중인 외부 프로세스(yt-dlp/ffmpeg). 취소 시 kill 대상. */ + currentChild: ChildProcess | null } const DEFAULT_MANIFEST_URL = process.env.MANIFEST_URL ?? 'http://127.0.0.1:3000/manifest.json' @@ -26,7 +31,8 @@ const state: RpInstallerState = { baseUrl: deriveBaseUrl(DEFAULT_MANIFEST_URL), packs: new Map(), selectedKey: null, - cancelRequested: false + cancelRequested: false, + currentChild: null } let mainWindow: BrowserWindow | null = null @@ -142,17 +148,42 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string await fsp.mkdir(tempRoot, { recursive: true }) try { - // 2-1. yt-dlp 준비 (%appdata%/.mc_custom/yt-dlp.exe) + // 2-1. yt-dlp / ffmpeg 준비 (%appdata%/.mc_custom/{yt-dlp,ffmpeg}.exe) sendLog('yt-dlp 준비 중…') const ytDlpBin = await ensureYtDlpExe(sendLog) sendLog(`yt-dlp 경로: ${ytDlpBin}`) throwIfCancelled() + sendLog('ffmpeg 준비 중…') + const ffmpegBin = await ensureFfmpegExe(sendLog) + sendLog(`ffmpeg 경로: ${ffmpegBin}`) + throwIfCancelled() // 2-2. 음악 다운로드 (1번부터 순차, ogg 변환) - sendLog(`음악 다운로드 시작 (${pack.list.music.length}곡) … (TODO)`) + const musicDir = path.join(tempRoot, 'music') + await fsp.mkdir(musicDir, { recursive: true }) + sendLog(`음악 다운로드 시작 (${pack.list.music.length}곡)`) for (let i = 0; i < pack.list.music.length; i++) { throwIfCancelled() - sendLog(`${i + 1}번 노래 다운로드 중… (TODO)`) + const entry = pack.list.music[i] + sendLog(`${i + 1}번 노래 다운로드 중…`) + try { + const outPath = await downloadMusicTrack({ + ytdlpExe: ytDlpBin, + ffmpegExe: ffmpegBin, + tempDir: musicDir, + index: i + 1, + url: entry.url, + log: sendLog, + onChild: (c) => { state.currentChild = c } + }) + state.currentChild = null + sendLog(`${i + 1}번 노래 완료: ${path.basename(outPath)}`) + } catch (err) { + state.currentChild = null + // 취소된 경우는 throwIfCancelled 가 일관된 메시지로 다시 던지게 함. + if (state.cancelRequested) throwIfCancelled() + throw new Error(`${i + 1}번 노래 다운로드 실패: ${(err as Error).message}`) + } } // 2-3. 사진 다운로드 + painting variant 정규화 @@ -185,6 +216,9 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string ipcMain.handle('rp:install:cancel', async () => { state.cancelRequested = true sendLog('취소 요청됨. 진행 중 작업을 중단합니다…') + if (state.currentChild && !state.currentChild.killed) { + state.currentChild.kill() + } }) function throwIfCancelled(): void { diff --git a/src/installer-rp/music.ts b/src/installer-rp/music.ts new file mode 100644 index 0000000..489fe2e --- /dev/null +++ b/src/installer-rp/music.ts @@ -0,0 +1,69 @@ +import { spawn, type ChildProcess } from 'node:child_process' +import { promises as fs } from 'node:fs' +import path from 'node:path' + +export interface DownloadMusicOptions { + ytdlpExe: string + ffmpegExe: string + /** %appdata%/.mc_custom/.temp/ 같은 작업 폴더. */ + tempDir: string + /** 1부터 시작하는 곡 번호 (파일명 zero-pad 에 사용). */ + index: number + /** 유튜브 영상 주소. */ + url: string + log?: (line: string) => void + /** 현재 실행 중인 자식 프로세스를 외부에 알림 (취소용). */ + onChild?: (child: ChildProcess) => void +} + +/** + * yt-dlp 로 유튜브 영상에서 오디오만 추출해 vorbis(.ogg) 로 변환한다. + * 결과 파일 경로를 돌려준다. 실패하면 reject. + * + * 호출자는 onChild 콜백으로 받은 ChildProcess 에 .kill() 을 호출해 취소할 수 있다. + */ +export function downloadMusicTrack(opts: DownloadMusicOptions): Promise { + const padded = String(opts.index).padStart(2, '0') + const outBase = path.join(opts.tempDir, padded) + const outPath = outBase + '.ogg' + return new Promise((resolve, reject) => { + const args = [ + '--no-warnings', + '--no-playlist', + '--extract-audio', + '--audio-format', 'vorbis', + '--audio-quality', '0', + '--ffmpeg-location', opts.ffmpegExe, + '-o', outBase + '.%(ext)s', + opts.url + ] + const child = spawn(opts.ytdlpExe, args, { stdio: ['ignore', 'pipe', 'pipe'] }) + opts.onChild?.(child) + let stderr = '' + child.stdout?.on('data', (chunk: Buffer) => { + const line = chunk.toString('utf8').trimEnd() + if (line) opts.log?.(`yt-dlp> ${line}`) + }) + child.stderr?.on('data', (chunk: Buffer) => { + stderr += chunk.toString('utf8') + }) + child.on('error', (err) => reject(err)) + child.on('close', async (code, signal) => { + if (signal) { + reject(new Error(`yt-dlp 가 신호 ${signal} 로 종료됨`)) + return + } + if (code !== 0) { + reject(new Error(`yt-dlp 종료 코드 ${code}: ${stderr.trim() || '(stderr 없음)'}`)) + return + } + // .ogg 가 실제로 생성됐는지 확인 + try { + await fs.access(outPath) + resolve(outPath) + } catch { + reject(new Error(`예상 출력파일이 없음: ${outPath}`)) + } + }) + }) +}