문제: alpine/distroless 처럼 glibc 가 없는 도커 베이스 이미지에서는 PyInstaller 로 빌드된 yt-dlp_linux 의 동적 링커가 없어 execve 가 ENOENT 를 반환. 이전에는 npm run setup 이 통째로 실패해서 Docker 빌드를 차단했음. 수정: - 다운로드는 됐지만 --version 검증이 실패하면 throw 하지 않고 안내만 출력 후 계속 진행. 못 쓰는 바이너리는 unlink 해서 혼동 방지. - SKIP_YT_DLP=1 환경변수로 다운로드 자체를 건너뛸 수 있게 추가. - 도커/PATH 설치 가이드를 warn 으로 같이 노출 (apt/apk/pip 명령). - README 외부 의존 섹션에도 slim base / SKIP_YT_DLP 안내 추가. src/youtube.ts 의 PATH fallback 은 그대로라 시스템에 yt-dlp 가 설치돼 있으면 런타임에 자동으로 그것을 사용합니다. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
188 lines
7.0 KiB
JavaScript
188 lines
7.0 KiB
JavaScript
#!/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)
|
|
})
|
|
}
|
|
|
|
function ytDlpInstallHint() {
|
|
warn('이 환경에서는 번들 yt-dlp 가 실행되지 않습니다 (libc 없는 슬림/도커 베이스 이미지일 가능성).')
|
|
warn('PATH 에 yt-dlp 를 직접 설치하면 src/youtube.ts 가 우선 그것을 사용합니다:')
|
|
warn(' Debian/Ubuntu 기반 이미지: apt-get update && apt-get install -y yt-dlp')
|
|
warn(' Alpine 기반 이미지: apk add --no-cache yt-dlp')
|
|
warn(' pip 사용: pip install --break-system-packages yt-dlp')
|
|
warn('YouTube 가져오기 기능이 필요 없다면 무시하거나 SKIP_YT_DLP=1 로 설정해 받지 않을 수 있습니다.')
|
|
}
|
|
|
|
async function ensureYtDlp() {
|
|
if (process.env.SKIP_YT_DLP === '1') {
|
|
log('SKIP_YT_DLP=1 → yt-dlp 다운로드 생략')
|
|
return
|
|
}
|
|
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}`)
|
|
try {
|
|
await downloadFile(url, target)
|
|
} catch (err) {
|
|
warn(`yt-dlp 다운로드 실패: ${err.message || err}`)
|
|
ytDlpInstallHint()
|
|
return
|
|
}
|
|
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) {
|
|
// 다운로드는 됐는데 실행이 안 되는 환경 (예: glibc 없는 alpine, distroless).
|
|
// setup 을 통째로 실패시키지 않고 안내만 출력하고 빠진다.
|
|
warn(`yt-dlp 다운로드는 됐지만 실행 검증에 실패했습니다 (${target}): ${lastErr?.message || lastErr}`)
|
|
try { await fs.unlink(target) } catch { /* ignore */ }
|
|
ytDlpInstallHint()
|
|
return
|
|
}
|
|
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)
|
|
})
|