Files
minecraft_launcher/src/installer-rp/ytdlp.ts
claude-bot d5f88e0e76 yt-dlp/ffmpeg: reinstall latest on failure, retry once
오래된 yt-dlp/ffmpeg 가 유튜브 변경을 못 따라가 다운로드가 실패할 때
최신 버전으로 강제 재설치 후 한 번 더 시도한다.

- server youtube.ts: ensureYtDlp(force) 추가(캐시·zipapp 삭제 후 최신 재다운로드).
  fetchVideoMeta/fetchPlaylistEntries 를 runYtDlp 로 묶어 1차 실패 시
  강제 재설치 후 재시도.
- installer ytdlp.ts/ffmpeg.ts: ensure*Exe(log, force) 추가.
- installer main.ts: 음악 워커가 곡 다운로드 실패 시 전역 1회 강제 재설치
  (refreshBinariesOnce) 후 해당 곡을 1회 재시도.
2026-06-05 16:08:26 +09:00

142 lines
4.6 KiB
TypeScript

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<void> {
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<string> | null = null
/**
* %appdata%/.mc_custom/yt-dlp.exe 가 없거나 실행 불가능하면 GitHub Releases
* 의 최신 yt-dlp.exe 를 받아 설치하고, 그 절대경로를 돌려준다.
*/
export async function ensureYtDlpExe(
log?: (line: string) => void,
force = false
): Promise<string> {
const target = getYtDlpExePath()
await migrateLegacyExe(target)
if (!force && await canExecute(target)) {
log?.(t('log.ytdlpExists', { path: target }))
return target
}
if (force) {
// 강제 재설치: 오래됐을 수 있는 캐시본을 지워 최신 버전을 받게 한다.
log?.(t('log.ytdlpReinstall'))
try { await fs.unlink(target) } catch { /* noop */ }
}
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<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))
})
}
/** 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(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)
})
}