From b769f453a361a491b0e270cfe357ad3a68ce1eac Mon Sep 17 00:00:00 2001 From: claude-bot Date: Mon, 25 May 2026 16:24:54 +0900 Subject: [PATCH] server-youtube: diagnostic detail + PATH fallback when bundled yt-dlp won't run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- locales/server/ko-kr.json | 1 + src/server/youtube.ts | 123 +++++++++++++++++++++++++------------- 2 files changed, 82 insertions(+), 42 deletions(-) diff --git a/locales/server/ko-kr.json b/locales/server/ko-kr.json index 7817d0d..f3ee3b8 100644 --- a/locales/server/ko-kr.json +++ b/locales/server/ko-kr.json @@ -220,6 +220,7 @@ "youtube": { "ytdlpUnavailable": "yt-dlp 를 준비하지 못했습니다. (수동 입력으로 진행)", "ytdlpVerifyFailed": "yt-dlp 다운로드는 됐지만 실행 검증에 실패했습니다.", + "ytdlpVerifyFailedDetail": "yt-dlp 를 사용할 수 없습니다. 시도한 경로 진단: {{detail}}", "ytdlpInstallFailed": "yt-dlp 자동 설치에 실패했습니다: {{message}}", "ytdlpVideoFailed": "yt-dlp 영상 조회 실패 (code={{code}}): {{detail}}", "ytdlpPlaylistFailed": "yt-dlp 플레이리스트 조회 실패 (code={{code}}): {{detail}}", diff --git a/src/server/youtube.ts b/src/server/youtube.ts index cb74429..7eda016 100644 --- a/src/server/youtube.ts +++ b/src/server/youtube.ts @@ -40,40 +40,25 @@ export function getYtDlpInstallPath(): string { /** 한 번에 한 다운로드만 진행하도록 락 (서버 동시 요청 보호). */ let installPromise: Promise | null = null +type ProbeResult = { ok: true } | { ok: false; detail: string } + /** * %appdata%/.mc_custom/ 에 yt-dlp 가 준비됐는지 확인하고, 없으면 GitHub Releases 에서 * 현재 OS/아키텍처용 바이너리를 자동으로 받아 설치한다. 성공 시 실행 경로 반환. + * 다운로드된 바이너리가 실행되지 않는 환경(antivirus, 권한 문제 등)이라면 + * PATH 의 `yt-dlp(.exe)` 로 폴백하고, 그것도 실패하면 진단정보가 포함된 에러를 던진다. */ export async function ensureYtDlp(): Promise { const target = getYtDlpInstallPath() - // 이미 설치돼 있고 실행 가능하면 그대로 사용 - if (await canExecute(target)) return target + // Fast path: 이미 설치돼 있고 실행도 잘 되면 그대로 사용 + if (await fileExists(target)) { + const probe = await probeVersion(target) + if (probe.ok) return target + } if (installPromise) return installPromise installPromise = (async () => { try { - const dir = getMcCustomDir() - 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) }) - ) + return await prepareYtDlp(target) } finally { installPromise = null } @@ -81,31 +66,85 @@ export async function ensureYtDlp(): Promise { return installPromise } -async function canExecute(filePath: string): Promise { +async function prepareYtDlp(target: string): Promise { + const diagnostics: string[] = [] + + // 1. 기존 파일이 있으면 우선 그걸로 시도 + if (await fileExists(target)) { + const probe = await probeVersion(target) + if (probe.ok) return target + diagnostics.push(`기존 ${path.basename(target)} 검증 실패: ${probe.detail}`) + } + + // 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 { try { await fs.access(filePath, fsConst.F_OK) + return true } catch { return false } - // POSIX 면 X 비트도 확인 - if (process.platform !== 'win32') { - try { - await fs.access(filePath, fsConst.X_OK) - } catch { - return false - } - } - // 실제로 --version 으로 한 번 더 확인 - return probeVersion(filePath) } -function probeVersion(bin: string): Promise { +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)) + let child: ReturnType + try { + child = spawn(bin, ['--version'], { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true }) + } catch (err) { + 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(', ') }) + }) }) }