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' import { t } from './i18n.js' export interface YtPlaylistEntry { id: string title: string channel: string durationSec: number url: string } export class YtDlpUnavailableError extends Error { constructor(message?: string) { super(message || t('youtube.ytdlpUnavailable')) } } /** 현재 OS/아키텍처에서 GitHub Releases 가 제공하는 yt-dlp 파일 이름. */ function getYtDlpAssetName(): string { if (process.platform === 'win32') return 'yt-dlp.exe' if (process.platform === 'darwin') return 'yt-dlp_macos' if (process.platform === 'linux') { if (process.arch === 'arm64') return 'yt-dlp_linux_aarch64' if (process.arch === 'arm') return 'yt-dlp_linux_armv7l' return 'yt-dlp_linux' } return 'yt-dlp' // 그 외 OS: 순수 파이썬 zipapp. python3 가 PATH 에 있어야 동작 } /** * 로컬 설치 경로: OS별 사용자 데이터 디렉터리 안의 .mc_custom/. * - Windows: %APPDATA%/.mc_custom/yt-dlp.exe * - macOS : ~/Library/Application Support/.mc_custom/yt-dlp_macos * - Linux 등: $XDG_CONFIG_HOME 또는 ~/.config/.mc_custom/yt-dlp_linux (arch 따라 다름) */ export function getYtDlpInstallPath(): string { return path.join(getMcCustomDir(), getYtDlpAssetName()) } /** 순수 파이썬 zipapp(`yt-dlp`) 의 로컬 설치 경로. python3 가 PATH 에 있어야 동작. */ function getYtDlpZipappPath(): string { return path.join(getMcCustomDir(), 'yt-dlp_zipapp') } /** 한 번에 한 다운로드만 진행하도록 락 (서버 동시 요청 보호). */ let installPromise: Promise | null = null type ProbeResult = { ok: true } | { ok: false; detail: string } /** * .mc_custom/ 디렉터리에 yt-dlp 가 준비됐는지 확인하고, 없으면 GitHub Releases 에서 * 현재 OS/아키텍처용 네이티브 바이너리를 자동으로 받아 설치한다. 성공 시 실행 경로 반환. * * 네이티브 바이너리가 실행되지 않는 환경(glibc 미스매치, musl libc, antivirus 차단 등) * 이면 다음 순서로 폴백한다: * 1) PATH 의 `yt-dlp(.exe)` (시스템에 따로 깐 거) * 2) (POSIX 한정) 범용 파이썬 zipapp `yt-dlp` 를 다운로드 후 shebang 실행 — python3 필요 * 전부 실패하면 각 시도의 진단정보가 포함된 에러를 던진다. */ export async function ensureYtDlp(force = false): Promise { const target = getYtDlpInstallPath() if (!force) { // Fast path: 이미 설치돼 있고 실행도 잘 되면 그대로 사용 if (await fileExists(target)) { const probe = await probeVersion(target) if (probe.ok) return target } // Fast path: 네이티브가 안 도는 환경에서 이전에 받아둔 zipapp 이 살아있으면 그걸 재사용 if (process.platform !== 'win32') { const zipappPath = getYtDlpZipappPath() if (await fileExists(zipappPath)) { const probe = await probeVersion(zipappPath) if (probe.ok) return zipappPath } } } else { // 강제 재설치: 캐시된(=오래됐을 수 있는) 바이너리를 지워 최신으로 다시 받게 한다. try { await fs.unlink(target) } catch { /* noop */ } if (process.platform !== 'win32') { try { await fs.unlink(getYtDlpZipappPath()) } catch { /* noop */ } } } if (installPromise) return installPromise installPromise = (async () => { try { return await prepareYtDlp(target, force) } finally { installPromise = null } })() return installPromise } async function prepareYtDlp(target: string, force = false): Promise { const diagnostics: string[] = [] // 강제 재설치(force)면 기존 캐시·PATH 시도를 건너뛰고 곧장 최신 버전을 받는다. if (!force) { // 1a. 기존 네이티브 파일이 있으면 우선 그걸로 시도 if (await fileExists(target)) { const probe = await probeVersion(target) if (probe.ok) return target diagnostics.push(`기존 ${path.basename(target)} 검증 실패: ${probe.detail}`) } // 1b. (POSIX) 기존 zipapp 이 있으면 재다운로드 전에 먼저 시도 if (process.platform !== 'win32') { const existingZipapp = getYtDlpZipappPath() if (await fileExists(existingZipapp)) { const probe = await probeVersion(existingZipapp) if (probe.ok) return existingZipapp diagnostics.push(`기존 yt-dlp_zipapp 검증 실패: ${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) } else { // Windows: 인터넷에서 받은 파일에는 NTFS ADS 'Zone.Identifier' 가 붙어 // SmartScreen/Attachment Manager 가 실행을 막을 수 있다. 베스트에포트로 제거. try { await fs.unlink(`${target}:Zone.Identifier`) } catch { /* noop */ } } 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 */ } } // 4. POSIX 한정 최후 폴백: 범용 파이썬 zipapp `yt-dlp` 다운로드 후 shebang 실행. // 네이티브 바이너리가 glibc/musl/arch 문제로 못 도는 리눅스 환경이라도 // python3 가 PATH 에 있으면 동작한다. ~ 3MB 짜리 스크립트. if (process.platform !== 'win32') { const zipappPath = getYtDlpZipappPath() try { try { await fs.unlink(zipappPath) } catch { /* noop */ } await downloadToFile('https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp', zipappPath) await fs.chmod(zipappPath, 0o755) const probe = await probeVersion(zipappPath) if (probe.ok) return zipappPath diagnostics.push(`zipapp yt-dlp 검증 실패: ${probe.detail} (python3 누락이거나 PATH 에 없음)`) try { await fs.unlink(zipappPath) } catch { /* noop */ } } catch (err) { diagnostics.push(`zipapp 다운로드 실패: ${err instanceof Error ? err.message : String(err)}`) try { await fs.unlink(zipappPath) } 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 } } function probeVersion(bin: string): Promise { return new Promise((resolve) => { 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(', ') }) }) }) } /** 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(t('youtube.tooManyRedirects'))) return } const lib = url.startsWith('https://') ? https : http const req = lib.get(url, { headers: { 'user-agent': 'mc-music-quiz-launcher' } }, (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) }) } /** yt-dlp 를 한 번 실행하고 종료코드·stdout·stderr 를 모은다. reject 하지 않는다. */ function spawnYtDlp(bin: string, args: string[]): Promise<{ code: number | null; stdout: string; stderr: string }> { return new Promise((resolve) => { let child: ReturnType try { child = spawn(bin, args, { stdio: ['ignore', 'pipe', 'pipe'] }) } catch (err) { resolve({ code: null, stdout: '', stderr: err instanceof Error ? err.message : String(err) }) return } let stdout = '' let stderr = '' let settled = false const done = (r: { code: number | null; stdout: string; stderr: string }) => { if (settled) return settled = true resolve(r) } child.stdout?.on('data', (chunk: Buffer) => (stdout += chunk.toString('utf8'))) child.stderr?.on('data', (chunk: Buffer) => (stderr += chunk.toString('utf8'))) child.on('error', (err) => done({ code: null, stdout, stderr: stderr || (err as Error).message })) child.on('close', (code) => done({ code, stdout, stderr })) }) } /** * yt-dlp 를 실행하고 stdout 을 돌려준다. 첫 시도가 실패(0 이 아닌 종료코드/실행 불가)하면 * yt-dlp 가 오래돼 유튜브 변경을 못 따라가는 상황일 수 있으므로, 최신 버전으로 강제 * 재설치한 뒤 한 번 더 시도한다. 그래도 실패하면 makeError 로 만든 에러를 던진다. */ async function runYtDlp(args: string[], makeError: (code: string, detail: string) => Error): Promise { let bin = await ensureYtDlp() let res = await spawnYtDlp(bin, args) if (res.code !== 0) { let refreshed = false try { bin = await ensureYtDlp(true) refreshed = true } catch { /* 재설치 실패 시 아래에서 원래 실패로 보고 */ } if (refreshed) { res = await spawnYtDlp(bin, args) } if (res.code !== 0) { throw makeError(String(res.code), res.stderr.trim() || res.stdout.trim()) } } return res.stdout } /** * 단일 영상 URL 의 메타데이터를 가져온다. * `--no-playlist` 로 플레이리스트 URL 이 들어와도 단일 영상 정보만 뽑음. */ export async function fetchVideoMeta(url: string): Promise { const stdout = await runYtDlp( ['--dump-json', '--no-warnings', '--no-playlist', '--skip-download', url], (code, detail) => new Error(t('youtube.ytdlpVideoFailed', { code, detail })) ) const line = stdout.trim().split('\n').find((l) => l.trim().length > 0) if (!line) return null const obj = JSON.parse(line) as Record const id = typeof obj.id === 'string' ? obj.id : '' if (!id) return null return { id, title: typeof obj.title === 'string' ? obj.title : '', channel: typeof obj.channel === 'string' ? obj.channel : (typeof obj.uploader === 'string' ? obj.uploader : ''), durationSec: typeof obj.duration === 'number' ? Math.floor(obj.duration) : 0, url: typeof obj.webpage_url === 'string' && obj.webpage_url.length > 0 ? obj.webpage_url : `https://www.youtube.com/watch?v=${id}` } } /** * 플레이리스트 URL 을 펼쳐 각 영상의 메타데이터를 가져온다. * `--flat-playlist --dump-json` 출력은 한 줄당 한 JSON. */ export async function fetchPlaylistEntries(url: string): Promise { const stdout = await runYtDlp( ['--flat-playlist', '--dump-json', '--no-warnings', url], (code, detail) => new Error(t('youtube.ytdlpPlaylistFailed', { code, detail })) ) const lines = stdout.split('\n').map((l) => l.trim()).filter((l) => l.length > 0) const parsed: YtPlaylistEntry[] = [] for (const line of lines) { try { const obj = JSON.parse(line) as Record const id = typeof obj.id === 'string' ? obj.id : '' if (!id) continue parsed.push({ id, title: typeof obj.title === 'string' ? obj.title : '', channel: typeof obj.channel === 'string' ? obj.channel : (typeof obj.uploader === 'string' ? obj.uploader : ''), durationSec: typeof obj.duration === 'number' ? Math.floor(obj.duration) : 0, url: typeof obj.url === 'string' && obj.url.length > 0 ? obj.url : `https://www.youtube.com/watch?v=${id}` }) } catch { // 한 줄이 깨져도 나머지는 살림 } } return parsed }