Add ffmpeg prep and music ogg download to rp installer
Add src/installer-rp/ffmpeg.ts that downloads BtbN/FFmpeg-Builds win64-gpl zip into %appdata%/.mc_custom/, extracts ffmpeg.exe out of bin/, drops it at %appdata%/.mc_custom/ffmpeg.exe and verifies with `ffmpeg -version`. Reuses existing extract-zip dep. Add src/installer-rp/music.ts that spawns yt-dlp with --extract-audio --audio-format vorbis --ffmpeg-location <ffmpeg.exe> to produce <tempDir>/NN.ogg per track. Streams yt-dlp stdout to the log channel and reports stderr on non-zero exit. Wire both into the install IPC handler: step 2-1 now preps both binaries, step 2-2 iterates the music list and downloads each track. Track the currently running child process in state so the cancel button can kill it instead of waiting for it to finish. Image / zip / place steps remain stubbed. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
143
src/installer-rp/ffmpeg.ts
Normal file
143
src/installer-rp/ffmpeg.ts
Normal file
@@ -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<void> = 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<string> | null = null
|
||||
|
||||
/**
|
||||
* %appdata%/.mc_custom/ffmpeg.exe 가 없거나 실행 불가하면 BtbN 빌드 zip 에서
|
||||
* ffmpeg.exe 만 추출해 설치하고 절대경로를 돌려준다.
|
||||
*/
|
||||
export async function ensureFfmpegExe(
|
||||
log?: (line: string) => void
|
||||
): Promise<string> {
|
||||
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<boolean> {
|
||||
try { await fs.access(filePath, fsConst.F_OK) } catch { return false }
|
||||
return probeVersion(filePath)
|
||||
}
|
||||
|
||||
function probeVersion(bin: string): Promise<boolean> {
|
||||
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<string | null> {
|
||||
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<void> {
|
||||
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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user