server-youtube: diagnostic detail + PATH fallback when bundled yt-dlp won't run

probeVersion() now captures stderr/exit-code/signal/spawn-error instead of
returning a bare boolean, and ensureYtDlp() tries the bundled binary first,
falls back to `yt-dlp(.exe)` on PATH if the bundled one won't execute (AV
block, missing libc symbol, broken download), and only then re-downloads.
The final user-facing error includes the per-attempt diagnostics so we can
actually see WHY verification failed instead of the opaque
"yt-dlp 다운로드는 됐지만 실행 검증에 실패했습니다." message.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 16:24:54 +09:00
parent 5c13648f63
commit b769f453a3
2 changed files with 82 additions and 42 deletions

View File

@@ -220,6 +220,7 @@
"youtube": { "youtube": {
"ytdlpUnavailable": "yt-dlp 를 준비하지 못했습니다. (수동 입력으로 진행)", "ytdlpUnavailable": "yt-dlp 를 준비하지 못했습니다. (수동 입력으로 진행)",
"ytdlpVerifyFailed": "yt-dlp 다운로드는 됐지만 실행 검증에 실패했습니다.", "ytdlpVerifyFailed": "yt-dlp 다운로드는 됐지만 실행 검증에 실패했습니다.",
"ytdlpVerifyFailedDetail": "yt-dlp 를 사용할 수 없습니다. 시도한 경로 진단: {{detail}}",
"ytdlpInstallFailed": "yt-dlp 자동 설치에 실패했습니다: {{message}}", "ytdlpInstallFailed": "yt-dlp 자동 설치에 실패했습니다: {{message}}",
"ytdlpVideoFailed": "yt-dlp 영상 조회 실패 (code={{code}}): {{detail}}", "ytdlpVideoFailed": "yt-dlp 영상 조회 실패 (code={{code}}): {{detail}}",
"ytdlpPlaylistFailed": "yt-dlp 플레이리스트 조회 실패 (code={{code}}): {{detail}}", "ytdlpPlaylistFailed": "yt-dlp 플레이리스트 조회 실패 (code={{code}}): {{detail}}",

View File

@@ -40,40 +40,25 @@ export function getYtDlpInstallPath(): string {
/** 한 번에 한 다운로드만 진행하도록 락 (서버 동시 요청 보호). */ /** 한 번에 한 다운로드만 진행하도록 락 (서버 동시 요청 보호). */
let installPromise: Promise<string> | null = null let installPromise: Promise<string> | null = null
type ProbeResult = { ok: true } | { ok: false; detail: string }
/** /**
* %appdata%/.mc_custom/ 에 yt-dlp 가 준비됐는지 확인하고, 없으면 GitHub Releases 에서 * %appdata%/.mc_custom/ 에 yt-dlp 가 준비됐는지 확인하고, 없으면 GitHub Releases 에서
* 현재 OS/아키텍처용 바이너리를 자동으로 받아 설치한다. 성공 시 실행 경로 반환. * 현재 OS/아키텍처용 바이너리를 자동으로 받아 설치한다. 성공 시 실행 경로 반환.
* 다운로드된 바이너리가 실행되지 않는 환경(antivirus, 권한 문제 등)이라면
* PATH 의 `yt-dlp(.exe)` 로 폴백하고, 그것도 실패하면 진단정보가 포함된 에러를 던진다.
*/ */
export async function ensureYtDlp(): Promise<string> { export async function ensureYtDlp(): Promise<string> {
const target = getYtDlpInstallPath() const target = getYtDlpInstallPath()
// 이미 설치돼 있고 실행 가능하면 그대로 사용 // Fast path: 이미 설치돼 있고 실행도 잘 되면 그대로 사용
if (await canExecute(target)) return target if (await fileExists(target)) {
const probe = await probeVersion(target)
if (probe.ok) return target
}
if (installPromise) return installPromise if (installPromise) return installPromise
installPromise = (async () => { installPromise = (async () => {
try { try {
const dir = getMcCustomDir() return await prepareYtDlp(target)
await fs.mkdir(dir, { recursive: true })
const asset = getYtDlpAssetName()
const url = `https://github.com/yt-dlp/yt-dlp/releases/latest/download/${asset}`
await downloadToFile(url, target)
// POSIX 계열은 실행 권한 부여
if (process.platform !== 'win32') {
await fs.chmod(target, 0o755)
}
// 검증
const okVersion = await probeVersion(target)
if (!okVersion) {
throw new YtDlpUnavailableError(t('youtube.ytdlpVerifyFailed'))
}
return target
} catch (err) {
// 실패 흔적(부분 다운로드) 삭제
try { await fs.unlink(target) } catch { /* noop */ }
throw err instanceof YtDlpUnavailableError
? err
: new YtDlpUnavailableError(
t('youtube.ytdlpInstallFailed', { message: err instanceof Error ? err.message : String(err) })
)
} finally { } finally {
installPromise = null installPromise = null
} }
@@ -81,31 +66,85 @@ export async function ensureYtDlp(): Promise<string> {
return installPromise return installPromise
} }
async function canExecute(filePath: string): Promise<boolean> { async function prepareYtDlp(target: string): Promise<string> {
try { const diagnostics: string[] = []
await fs.access(filePath, fsConst.F_OK)
} catch { // 1. 기존 파일이 있으면 우선 그걸로 시도
return false if (await fileExists(target)) {
} const probe = await probeVersion(target)
// POSIX 면 X 비트도 확인 if (probe.ok) return target
if (process.platform !== 'win32') { diagnostics.push(`기존 ${path.basename(target)} 검증 실패: ${probe.detail}`)
try {
await fs.access(filePath, fsConst.X_OK)
} catch {
return false
}
}
// 실제로 --version 으로 한 번 더 확인
return probeVersion(filePath)
} }
function probeVersion(bin: string): Promise<boolean> { // 2. PATH 에 yt-dlp(.exe) 가 시스템 전역으로 설치돼 있으면 그걸 사용
const pathCmd = process.platform === 'win32' ? 'yt-dlp.exe' : 'yt-dlp'
const pathProbe = await probeVersion(pathCmd)
if (pathProbe.ok) return pathCmd
diagnostics.push(`PATH 의 ${pathCmd} 사용 불가: ${pathProbe.detail}`)
// 3. 최후 수단: 새로 다운로드해서 시도
try {
await fs.mkdir(getMcCustomDir(), { recursive: true })
const asset = getYtDlpAssetName()
const url = `https://github.com/yt-dlp/yt-dlp/releases/latest/download/${asset}`
try { await fs.unlink(target) } catch { /* noop */ }
await downloadToFile(url, target)
if (process.platform !== 'win32') {
await fs.chmod(target, 0o755)
}
const probe = await probeVersion(target)
if (probe.ok) return target
diagnostics.push(`새로 받은 ${asset} 검증 실패: ${probe.detail}`)
try { await fs.unlink(target) } catch { /* noop */ }
} catch (err) {
diagnostics.push(`다운로드 실패: ${err instanceof Error ? err.message : String(err)}`)
try { await fs.unlink(target) } catch { /* noop */ }
}
throw new YtDlpUnavailableError(
t('youtube.ytdlpVerifyFailedDetail', { detail: diagnostics.join(' | ') })
)
}
async function fileExists(filePath: string): Promise<boolean> {
try {
await fs.access(filePath, fsConst.F_OK)
return true
} catch {
return false
}
}
function probeVersion(bin: string): Promise<ProbeResult> {
return new Promise((resolve) => { return new Promise((resolve) => {
const child = spawn(bin, ['--version'], { stdio: ['ignore', 'pipe', 'pipe'] }) let child: ReturnType<typeof spawn>
let ok = false try {
child.stdout.on('data', () => { ok = true }) child = spawn(bin, ['--version'], { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true })
child.on('error', () => resolve(false)) } catch (err) {
child.on('close', (code) => resolve(ok && code === 0)) resolve({ ok: false, detail: `spawn throw: ${err instanceof Error ? err.message : String(err)}` })
return
}
let stdout = ''
let stderr = ''
child.stdout?.on('data', (chunk: Buffer) => { stdout += chunk.toString('utf8') })
child.stderr?.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8') })
child.on('error', (err: NodeJS.ErrnoException) => {
const code = err.code ? `${err.code} ` : ''
resolve({ ok: false, detail: `spawn error: ${code}${err.message}` })
})
child.on('close', (code, signal) => {
const out = stdout.trim()
if (out && code === 0) {
resolve({ ok: true })
return
}
const parts: string[] = []
parts.push(`exit=${code === null ? `signal:${signal}` : code}`)
if (!out) parts.push('stdout=(empty)')
const errLine = stderr.trim().split('\n')[0]
if (errLine) parts.push(`stderr="${errLine.slice(0, 200)}"`)
resolve({ ok: false, detail: parts.join(', ') })
})
}) })
} }