#!/usr/bin/env node // 한 번에 의존성 설치 + yt-dlp 바이너리 다운로드 + TypeScript 빌드까지 수행한다. // 사용: `npm run setup` // // yt-dlp 는 GitHub Releases 에서 현재 OS/arch 용 바이너리를 받아 ./bin/yt-dlp(.exe) 로 둔다. // src/youtube.ts 가 이 경로를 우선 탐색하므로 PATH 설치 없이도 동작한다. // ffmpeg 는 시스템 패키지라 자동 설치하지 않고, 없으면 안내만 출력한다. import { spawn, spawnSync } from 'node:child_process' import { promises as fs, createWriteStream } from 'node:fs' import path from 'node:path' import https from 'node:https' import { fileURLToPath } from 'node:url' const __filename = fileURLToPath(import.meta.url) const projectRoot = path.resolve(path.dirname(__filename), '..') const binDir = path.join(projectRoot, 'bin') function log(msg) { console.log(`[setup] ${msg}`) } function warn(msg) { console.warn(`[setup] ${msg}`) } function ytDlpAssetName() { 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' } function ytDlpLocalPath() { return path.join(binDir, process.platform === 'win32' ? 'yt-dlp.exe' : 'yt-dlp') } // 리다이렉트 먼저 따라간 뒤, 최종 200 응답이 확인된 시점에만 파일을 연다. // 이렇게 안 하면 redirect 단계에서 만들어둔 빈 writestream 이 dest 를 잡고 있어 // 다운로드 직후 spawn 호출이 Linux 의 ETXTBSY 로 실패한다. function fetchFollowingRedirects(url, depth = 0) { return new Promise((resolve, reject) => { if (depth > 5) return reject(new Error('redirect 너무 많음')) https.get(url, { headers: { 'user-agent': 'make-video-site-setup' } }, (res) => { if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { res.resume() fetchFollowingRedirects(res.headers.location, depth + 1).then(resolve, reject) return } if (res.statusCode !== 200) { res.resume() reject(new Error(`HTTP ${res.statusCode} for ${url}`)) return } resolve(res) }).on('error', reject) }) } function downloadFile(url, dest) { return new Promise((resolve, reject) => { fetchFollowingRedirects(url).then((res) => { const file = createWriteStream(dest) const onErr = (err) => { file.close() fs.unlink(dest).catch(() => undefined) reject(err) } res.on('error', onErr) file.on('error', onErr) file.on('finish', () => file.close(() => resolve())) res.pipe(file) }, reject) }) } async function ensureYtDlp() { const target = ytDlpLocalPath() // 이미 있으면 --version 으로 점검 try { await fs.access(target) const r = spawnSync(target, ['--version']) if (r.status === 0) { log(`yt-dlp 이미 설치됨 (${target}, version ${String(r.stdout).trim()})`) return } warn(`기존 ${target} 실행 실패 — 다시 받습니다.`) } catch { /* 없으면 그냥 진행 */ } await fs.mkdir(binDir, { recursive: true }) const asset = ytDlpAssetName() const url = `https://github.com/yt-dlp/yt-dlp/releases/latest/download/${asset}` log(`yt-dlp 다운로드 시작: ${url}`) await downloadFile(url, target) if (process.platform !== 'win32') { await fs.chmod(target, 0o755) } // 갓 닫은 실행 파일이 즉시 spawn 되면 일부 환경에서 ETXTBSY/ENOEXEC 가 나는 경우가 있어 // 짧게 백오프하며 재시도한다. let version = null let lastErr = null for (let attempt = 0; attempt < 5; attempt++) { if (attempt > 0) await new Promise((r) => setTimeout(r, 200 * attempt)) const r = spawnSync(target, ['--version']) if (r.status === 0) { version = String(r.stdout).trim() break } lastErr = r.error || new Error(`exit ${r.status}, stderr: ${String(r.stderr).trim()}`) } if (!version) { throw new Error(`yt-dlp 설치는 끝났지만 실행이 실패했습니다 (${target}): ${lastErr?.message || lastErr}`) } log(`yt-dlp 설치 완료: ${target} (version ${version})`) } function checkFfmpeg() { const r = spawnSync('ffmpeg', ['-version']) if (r.status === 0) { const firstLine = String(r.stdout).split('\n')[0] log(`ffmpeg 확인됨: ${firstLine}`) return } warn('ffmpeg 가 PATH 에 없습니다. 영상 trim 저장이 필요하면 다음 중 하나로 설치해 주세요:') warn(' Ubuntu/Debian: sudo apt-get install -y ffmpeg') warn(' macOS: brew install ffmpeg') warn(' Windows: winget install Gyan.FFmpeg 또는 https://www.gyan.dev/ffmpeg/builds/') } function runCmd(cmd, args, opts = {}) { return new Promise((resolve, reject) => { const child = spawn(cmd, args, { stdio: 'inherit', shell: process.platform === 'win32', ...opts }) child.on('error', reject) child.on('close', (code) => { if (code === 0) resolve() else reject(new Error(`${cmd} ${args.join(' ')} 실패 (code=${code})`)) }) }) } async function main() { log(`프로젝트 루트: ${projectRoot}`) // npm 의존성 — postinstall 로도 들어올 수 있으므로 SKIP 옵션 제공. if (process.env.SKIP_NPM_INSTALL !== '1') { log('npm install 실행') await runCmd('npm', ['install', '--no-audit', '--no-fund'], { cwd: projectRoot }) } else { log('SKIP_NPM_INSTALL=1 → npm install 생략') } await ensureYtDlp() checkFfmpeg() log('TypeScript 빌드') await runCmd('npx', ['tsc', '-p', 'tsconfig.json'], { cwd: projectRoot }) log('완료. `npm start` 로 서버를 띄울 수 있습니다.') } main().catch((err) => { console.error('[setup] 실패:', err.message || err) process.exit(1) })