From 860c30fdfeb9c672f41385b245deea60c83efe79 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Tue, 12 May 2026 15:18:01 +0900 Subject: [PATCH] Wire yt-dlp.exe prep into resource pack installer Add src/installer-rp/ytdlp.ts that downloads https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe into %appdata%/.mc_custom/yt-dlp.exe (fixed path, Windows-only since the installer is shipped as .exe). If the file is already there and --version works it is reused; otherwise it is re-downloaded and verified. The server's existing OS-aware ensureYtDlp stays intact. The install IPC handler now calls ensureYtDlpExe() in step 2-1 and logs the resolved path. Music / image / zip / place steps are still stubbed. Co-Authored-By: Claude Opus 4.7 --- src/installer-rp/main.ts | 8 +-- src/installer-rp/ytdlp.ts | 109 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+), 3 deletions(-) create mode 100644 src/installer-rp/ytdlp.ts diff --git a/src/installer-rp/main.ts b/src/installer-rp/main.ts index ce3b91f..c667677 100644 --- a/src/installer-rp/main.ts +++ b/src/installer-rp/main.ts @@ -8,6 +8,7 @@ import { URL } from 'node:url' 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' interface RpInstallerState { manifestUrl: string @@ -141,9 +142,10 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string await fsp.mkdir(tempRoot, { recursive: true }) try { - // 2-1. yt-dlp 준비 - sendLog('yt-dlp 준비 중… (TODO)') - // TODO: ensureYtDlp() — shared 모듈로 분리 예정. + // 2-1. yt-dlp 준비 (%appdata%/.mc_custom/yt-dlp.exe) + sendLog('yt-dlp 준비 중…') + const ytDlpBin = await ensureYtDlpExe(sendLog) + sendLog(`yt-dlp 경로: ${ytDlpBin}`) throwIfCancelled() // 2-2. 음악 다운로드 (1번부터 순차, ogg 변환) diff --git a/src/installer-rp/ytdlp.ts b/src/installer-rp/ytdlp.ts new file mode 100644 index 0000000..1e65524 --- /dev/null +++ b/src/installer-rp/ytdlp.ts @@ -0,0 +1,109 @@ +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' + +/** + * 리소스팩 간편설치기는 Windows .exe 로 배포되므로 yt-dlp.exe 한 종류만 사용. + * 경로: %appdata%/.mc_custom/yt-dlp.exe + */ +export function getYtDlpExePath(): string { + return path.join(getMcCustomDir(), 'yt-dlp.exe') +} + +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() + if (await canExecute(target)) { + log?.(`yt-dlp.exe 이미 있음: ${target}`) + return target + } + if (installPromise) return installPromise + + installPromise = (async () => { + try { + await fs.mkdir(path.dirname(target), { recursive: true }) + log?.(`yt-dlp.exe 다운로드 중: ${YT_DLP_DOWNLOAD_URL}`) + await downloadToFile(YT_DLP_DOWNLOAD_URL, target) + const okVersion = await probeVersion(target) + if (!okVersion) { + throw new Error('yt-dlp.exe 다운로드는 됐지만 실행 검증에 실패했습니다.') + } + log?.(`yt-dlp.exe 준비 완료: ${target}`) + return target + } catch (err) { + // 부분 다운로드 흔적 정리 + try { await fs.unlink(target) } catch { /* noop */ } + throw new Error( + 'yt-dlp.exe 자동 설치 실패: ' + + (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('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) + }) +}