Auto-install yt-dlp into %appdata%/.mc_custom on first use
The previous flow required the operator to manually install yt-dlp on the server. Now the server downloads the correct binary for the current OS/arch from GitHub Releases (latest) the first time a playlist fetch is requested, caches it under %appdata%/.mc_custom (resolved per-OS), chmod +x's it on POSIX, and skips the download on every subsequent call. - shared/paths.ts: add getAppDataDir() + getMcCustomDir() that map %appdata% to ~/.config (Linux), ~/Library/Application Support (macOS), or process.env.APPDATA (Windows) so the path is OS-agnostic. - server/youtube.ts: replace the old "probe PATH and bail" probeYtDlp with ensureYtDlp(): picks the right GitHub asset name per platform.arch (yt-dlp.exe / yt-dlp_macos / yt-dlp_linux / yt-dlp_linux_aarch64 / yt-dlp_linux_armv7l), downloads it with redirect-following https.get to the install path, chmods +x, then verifies with `--version` before reporting success. Uses an installPromise singleton so concurrent requests don't race the download. On any failure it cleans up the partial file and throws YtDlpUnavailableError with a real reason. - docs/yt-dlp-setup.md: note that auto-install is now the default; manual install is only for environments where the auto-download fails. Smoke-tested on this Linux x64 box: first call downloads to ~/.config/.mc_custom/yt-dlp_linux and reports `2026.03.17`; second call takes ~700ms (just the version probe) and reuses the cached file.
This commit is contained in:
@@ -1,7 +1,11 @@
|
|||||||
# yt-dlp 설치 가이드
|
# yt-dlp 설치 가이드
|
||||||
|
|
||||||
음악퀴즈 관리 사이트(`/op/list/.../playlist`) 기능에서 유튜브 플레이리스트 메타데이터를 가져올 때 서버에 `yt-dlp` 바이너리가 필요합니다.
|
> ✅ **기본 동작: 자동 설치.** 서버가 처음 플레이리스트를 불러올 때 `%appdata%/.mc_custom/`
|
||||||
설치돼 있지 않으면 사이트에 `"서버에 yt-dlp가 설치돼 있지 않습니다. (수동 입력으로 진행)"` 라고 표시되고, 사용자가 직접 곡을 추가해야 합니다.
|
> (Linux 는 `~/.config/.mc_custom/`, macOS 는 `~/Library/Application Support/.mc_custom/`)
|
||||||
|
> 에 현재 OS/아키텍처에 맞는 `yt-dlp` 바이너리를 GitHub Releases 에서 받아 권한까지 부여합니다.
|
||||||
|
> 이미 받아둔 게 있으면 그대로 재사용합니다. 따라서 **일반적으로는 아래 수동 설치가 필요 없습니다.**
|
||||||
|
>
|
||||||
|
> 자동 설치가 실패하는 환경(외부 인터넷 차단, 권한 부족 등)에서만 아래 절차로 수동 설치하세요.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { spawn } from 'node:child_process'
|
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 {
|
export interface YtPlaylistEntry {
|
||||||
id: string
|
id: string
|
||||||
@@ -9,34 +14,138 @@ export interface YtPlaylistEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class YtDlpUnavailableError extends Error {
|
export class YtDlpUnavailableError extends Error {
|
||||||
constructor() {
|
constructor(message?: string) {
|
||||||
super('서버에 yt-dlp가 설치돼 있지 않습니다. (수동 입력으로 진행)')
|
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/<asset> */
|
||||||
|
export function getYtDlpInstallPath(): string {
|
||||||
|
return path.join(getMcCustomDir(), getYtDlpAssetName())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 한 번에 한 다운로드만 진행하도록 락 (서버 동시 요청 보호). */
|
||||||
|
let installPromise: Promise<string> | null = null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* yt-dlp 가 시스템에 있는지와 그 경로를 빠르게 확인.
|
* %appdata%/.mc_custom/ 에 yt-dlp 가 준비됐는지 확인하고, 없으면 GitHub Releases 에서
|
||||||
* 없으면 YtDlpUnavailableError.
|
* 현재 OS/아키텍처용 바이너리를 자동으로 받아 설치한다. 성공 시 실행 경로 반환.
|
||||||
*/
|
*/
|
||||||
async function probeYtDlp(): Promise<string> {
|
export async function ensureYtDlp(): Promise<string> {
|
||||||
|
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<boolean> {
|
||||||
|
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<boolean> {
|
||||||
|
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<void> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const probe = spawn('yt-dlp', ['--version'], { stdio: ['ignore', 'pipe', 'pipe'] })
|
if (redirects > 8) {
|
||||||
let stderr = ''
|
reject(new Error('redirect 가 너무 많습니다.'))
|
||||||
probe.stderr.on('data', (chunk: Buffer) => (stderr += chunk.toString('utf8')))
|
return
|
||||||
probe.on('error', () => reject(new YtDlpUnavailableError()))
|
}
|
||||||
probe.on('close', (code) => {
|
const lib = url.startsWith('https://') ? https : http
|
||||||
if (code === 0) resolve('yt-dlp')
|
const req = lib.get(url, {
|
||||||
else reject(new Error(`yt-dlp 실행 실패 (code=${code}): ${stderr}`))
|
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.
|
* `--flat-playlist --dump-json` 출력은 한 줄당 한 JSON.
|
||||||
*/
|
*/
|
||||||
export async function fetchPlaylistEntries(url: string): Promise<YtPlaylistEntry[]> {
|
export async function fetchPlaylistEntries(url: string): Promise<YtPlaylistEntry[]> {
|
||||||
const bin = await probeYtDlp()
|
const bin = await ensureYtDlp()
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const child = spawn(bin, [
|
const child = spawn(bin, [
|
||||||
'--flat-playlist',
|
'--flat-playlist',
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
|
import os from 'node:os'
|
||||||
|
|
||||||
// 컴파일 후 dist/shared/paths.js → 2단계 상위가 프로젝트 루트.
|
// 컴파일 후 dist/shared/paths.js → 2단계 상위가 프로젝트 루트.
|
||||||
export const projectRoot = path.resolve(__dirname, '..', '..')
|
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 fileListDirPath = path.join(fileDirPath, 'list')
|
||||||
export const viewsDirPath = path.join(projectRoot, 'views')
|
export const viewsDirPath = path.join(projectRoot, 'views')
|
||||||
export const publicDirPath = path.join(projectRoot, 'public')
|
export const publicDirPath = path.join(projectRoot, 'public')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 환경의 "%appdata%" 디렉터리(OS별 표준 사용자 데이터 경로)를 반환.
|
||||||
|
* - Windows : %APPDATA% (보통 C:\Users\<user>\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')
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user