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 에 있어야 동작 } /** 로컬 설치 경로: %appdata%/.mc_custom/ */ export function getYtDlpInstallPath(): string { return path.join(getMcCustomDir(), getYtDlpAssetName()) } /** 한 번에 한 다운로드만 진행하도록 락 (서버 동시 요청 보호). */ let installPromise: Promise | null = null /** * %appdata%/.mc_custom/ 에 yt-dlp 가 준비됐는지 확인하고, 없으면 GitHub Releases 에서 * 현재 OS/아키텍처용 바이너리를 자동으로 받아 설치한다. 성공 시 실행 경로 반환. */ export async function ensureYtDlp(): Promise { const target = getYtDlpInstallPath() // 이미 설치돼 있고 실행 가능하면 그대로 사용 if (await canExecute(target)) 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) }) ) } finally { installPromise = null } })() return installPromise } async function canExecute(filePath: string): Promise { try { await fs.access(filePath, fsConst.F_OK) } 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 { 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(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) }) } /** * 단일 영상 URL 의 메타데이터를 가져온다. * `--no-playlist` 로 플레이리스트 URL 이 들어와도 단일 영상 정보만 뽑음. */ export async function fetchVideoMeta(url: string): Promise { const bin = await ensureYtDlp() return new Promise((resolve, reject) => { const child = spawn(bin, [ '--dump-json', '--no-warnings', '--no-playlist', '--skip-download', url ], { stdio: ['ignore', 'pipe', 'pipe'] }) 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) => reject(err)) child.on('close', (code) => { if (code !== 0) { reject(new Error(t('youtube.ytdlpVideoFailed', { code: String(code), detail: stderr.trim() || stdout.trim() }))) return } const line = stdout.trim().split('\n').find((l) => l.trim().length > 0) if (!line) { resolve(null); return } try { const obj = JSON.parse(line) as Record const id = typeof obj.id === 'string' ? obj.id : '' if (!id) { resolve(null); return } resolve({ 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}` }) } catch (err) { reject(err) } }) }) } /** * 플레이리스트 URL 을 펼쳐 각 영상의 메타데이터를 가져온다. * `--flat-playlist --dump-json` 출력은 한 줄당 한 JSON. */ export async function fetchPlaylistEntries(url: string): Promise { const bin = await ensureYtDlp() return new Promise((resolve, reject) => { const child = spawn(bin, [ '--flat-playlist', '--dump-json', '--no-warnings', url ], { stdio: ['ignore', 'pipe', 'pipe'] }) 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) => reject(err)) child.on('close', (code) => { if (code !== 0) { reject(new Error(t('youtube.ytdlpPlaylistFailed', { code: String(code), detail: stderr.trim() || stdout.trim() }))) return } 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 { // 한 줄이 깨져도 나머지는 살림 } } resolve(parsed) }) }) }