diff --git a/docs/yt-dlp-setup.md b/docs/yt-dlp-setup.md index 2a5a04a..b484589 100644 --- a/docs/yt-dlp-setup.md +++ b/docs/yt-dlp-setup.md @@ -1,7 +1,11 @@ # yt-dlp 설치 가이드 -음악퀴즈 관리 사이트(`/op/list/.../playlist`) 기능에서 유튜브 플레이리스트 메타데이터를 가져올 때 서버에 `yt-dlp` 바이너리가 필요합니다. -설치돼 있지 않으면 사이트에 `"서버에 yt-dlp가 설치돼 있지 않습니다. (수동 입력으로 진행)"` 라고 표시되고, 사용자가 직접 곡을 추가해야 합니다. +> ✅ **기본 동작: 자동 설치.** 서버가 처음 플레이리스트를 불러올 때 `%appdata%/.mc_custom/` +> (Linux 는 `~/.config/.mc_custom/`, macOS 는 `~/Library/Application Support/.mc_custom/`) +> 에 현재 OS/아키텍처에 맞는 `yt-dlp` 바이너리를 GitHub Releases 에서 받아 권한까지 부여합니다. +> 이미 받아둔 게 있으면 그대로 재사용합니다. 따라서 **일반적으로는 아래 수동 설치가 필요 없습니다.** +> +> 자동 설치가 실패하는 환경(외부 인터넷 차단, 권한 부족 등)에서만 아래 절차로 수동 설치하세요. --- diff --git a/src/server/youtube.ts b/src/server/youtube.ts index 63310ea..f39803b 100644 --- a/src/server/youtube.ts +++ b/src/server/youtube.ts @@ -1,4 +1,9 @@ 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' export interface YtPlaylistEntry { id: string @@ -9,34 +14,138 @@ export interface YtPlaylistEntry { } export class YtDlpUnavailableError extends Error { - constructor() { - super('서버에 yt-dlp가 설치돼 있지 않습니다. (수동 입력으로 진행)') + constructor(message?: string) { + super(message || 'yt-dlp 를 준비하지 못했습니다. (수동 입력으로 진행)') } } +/** 현재 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 + /** - * yt-dlp 가 시스템에 있는지와 그 경로를 빠르게 확인. - * 없으면 YtDlpUnavailableError. + * %appdata%/.mc_custom/ 에 yt-dlp 가 준비됐는지 확인하고, 없으면 GitHub Releases 에서 + * 현재 OS/아키텍처용 바이너리를 자동으로 받아 설치한다. 성공 시 실행 경로 반환. */ -async function probeYtDlp(): Promise { +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('yt-dlp 다운로드는 됐지만 실행 검증에 실패했습니다.') + } + return target + } catch (err) { + // 실패 흔적(부분 다운로드) 삭제 + try { await fs.unlink(target) } catch { /* noop */ } + throw err instanceof YtDlpUnavailableError + ? err + : new YtDlpUnavailableError( + 'yt-dlp 자동 설치에 실패했습니다: ' + (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) => { - const probe = spawn('yt-dlp', ['--version'], { stdio: ['ignore', 'pipe', 'pipe'] }) - let stderr = '' - probe.stderr.on('data', (chunk: Buffer) => (stderr += chunk.toString('utf8'))) - probe.on('error', () => reject(new YtDlpUnavailableError())) - probe.on('close', (code) => { - if (code === 0) resolve('yt-dlp') - else reject(new Error(`yt-dlp 실행 실패 (code=${code}): ${stderr}`)) + if (redirects > 8) { + reject(new Error('redirect 가 너무 많습니다.')) + 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 을 yt-dlp 로 펼쳐 각 영상의 메타데이터를 가져온다. + * 플레이리스트 URL 을 펼쳐 각 영상의 메타데이터를 가져온다. * `--flat-playlist --dump-json` 출력은 한 줄당 한 JSON. */ export async function fetchPlaylistEntries(url: string): Promise { - const bin = await probeYtDlp() + const bin = await ensureYtDlp() return new Promise((resolve, reject) => { const child = spawn(bin, [ '--flat-playlist', diff --git a/src/shared/paths.ts b/src/shared/paths.ts index 6e8a3bc..30b0600 100644 --- a/src/shared/paths.ts +++ b/src/shared/paths.ts @@ -1,4 +1,5 @@ import path from 'node:path' +import os from 'node:os' // 컴파일 후 dist/shared/paths.js → 2단계 상위가 프로젝트 루트. export const projectRoot = path.resolve(__dirname, '..', '..') @@ -9,3 +10,24 @@ export const fileDirPath = path.join(projectRoot, 'file') export const fileListDirPath = path.join(fileDirPath, 'list') export const viewsDirPath = path.join(projectRoot, 'views') export const publicDirPath = path.join(projectRoot, 'public') + +/** + * 사용자 환경의 "%appdata%" 디렉터리(OS별 표준 사용자 데이터 경로)를 반환. + * - Windows : %APPDATA% (보통 C:\Users\\AppData\Roaming) + * - macOS : ~/Library/Application Support + * - Linux 등 : $XDG_CONFIG_HOME 또는 ~/.config + */ +export function getAppDataDir(): string { + if (process.platform === 'win32') { + return process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming') + } + if (process.platform === 'darwin') { + return path.join(os.homedir(), 'Library', 'Application Support') + } + return process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config') +} + +/** %appdata%/.mc_custom — 음악퀴즈 관련 외부 도구/캐시 보관 디렉터리. */ +export function getMcCustomDir(): string { + return path.join(getAppDataDir(), '.mc_custom') +}