feat(tools): 관리자 화면에서 ffmpeg/yt-dlp 최신 버전 재설치
오래된 ffmpeg/yt-dlp 로 다운로드·변환이 실패할 때 관리자 대시보드에서 최신 버전으로 재설치할 수 있게 한다. 재설치 바이너리는 data 볼륨 하위 data/bin 에 두어 컨테이너 재생성에도 유지하고, 바이너리 해석 시 PATH 보다 우선 사용한다. - src/paths.ts: binDir(data/bin) 추가 - src/editor.ts: binDir 우선 ffmpeg 해석 + getFfprobePath/resetFfmpegResolution - src/youtube.ts: binDir 우선 yt-dlp 해석 + resetYtDlpResolution - src/tools.ts(신규): 버전 조회 + 최신 재설치(GitHub yt-dlp, johnvansickle ffmpeg) - src/routes/op.ts: GET /op/tools, POST /op/tools/:name/update - views/op/dashboard.ejs, public/dashboard.js, public/styles.css: 도구 관리 UI - Dockerfile: ffmpeg 정적 빌드(.tar.xz) 해제용 xz-utils 추가 Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ import { spawn, spawnSync } from 'node:child_process'
|
||||
import { promises as fs, existsSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { getVideo, updateVideo, videoDiskDir, type VideoTrim } from './storeDb.js'
|
||||
import { binDir } from './paths.js'
|
||||
|
||||
export class FfmpegUnavailableError extends Error {
|
||||
constructor() {
|
||||
@@ -9,21 +10,53 @@ export class FfmpegUnavailableError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 바이너리 해석: data/bin 에 재설치본이 있으면 그것을 우선 쓰고,
|
||||
* 없으면 PATH 에서 찾는다. 둘 다 없으면 null.
|
||||
* (win32 면 .exe 후보도 본다.)
|
||||
*/
|
||||
function resolveBinary(name: string): string | null {
|
||||
const candidates =
|
||||
process.platform === 'win32'
|
||||
? [path.join(binDir, name + '.exe'), path.join(binDir, name), name]
|
||||
: [path.join(binDir, name), name]
|
||||
for (const cand of candidates) {
|
||||
const r = spawnSync(cand, ['-version'])
|
||||
if (r.status === 0) return cand
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
let resolvedFfmpegPath: string | null = null
|
||||
let resolvedFfprobePath: string | null = null
|
||||
|
||||
export function getFfmpegPath(): string {
|
||||
if (resolvedFfmpegPath) return resolvedFfmpegPath
|
||||
const r = spawnSync('ffmpeg', ['-version'])
|
||||
if (r.status === 0) {
|
||||
resolvedFfmpegPath = 'ffmpeg'
|
||||
return 'ffmpeg'
|
||||
}
|
||||
throw new FfmpegUnavailableError()
|
||||
const p = resolveBinary('ffmpeg')
|
||||
if (!p) throw new FfmpegUnavailableError()
|
||||
resolvedFfmpegPath = p
|
||||
return p
|
||||
}
|
||||
|
||||
/** ffprobe 경로. data/bin 우선, 없으면 PATH. 못 찾으면 null (호출부에서 graceful 처리). */
|
||||
function getFfprobePath(): string | null {
|
||||
if (resolvedFfprobePath) return resolvedFfprobePath
|
||||
const p = resolveBinary('ffprobe')
|
||||
if (p) resolvedFfprobePath = p
|
||||
return p
|
||||
}
|
||||
|
||||
/** data/bin 에 새 바이너리를 설치한 뒤 캐시된 해석 결과를 비운다 (다음 호출에서 재탐색). */
|
||||
export function resetFfmpegResolution(): void {
|
||||
resolvedFfmpegPath = null
|
||||
resolvedFfprobePath = null
|
||||
}
|
||||
|
||||
/** 입력 영상의 평균 fps 를 ffprobe 로 조회. 실패하면 null. */
|
||||
export function probeVideoFps(inputPath: string): number | null {
|
||||
const r = spawnSync('ffprobe', [
|
||||
const probe = getFfprobePath()
|
||||
if (!probe) return null
|
||||
const r = spawnSync(probe, [
|
||||
'-v', 'error',
|
||||
'-select_streams', 'v:0',
|
||||
'-show_entries', 'stream=avg_frame_rate',
|
||||
@@ -55,14 +88,16 @@ function probeVideoMeta(inputPath: string): {
|
||||
height: number | null
|
||||
durationSec: number | null
|
||||
} {
|
||||
const r = spawnSync('ffprobe', [
|
||||
const empty = { fps: null, width: null, height: null, durationSec: null }
|
||||
const probe = getFfprobePath()
|
||||
if (!probe) return empty
|
||||
const r = spawnSync(probe, [
|
||||
'-v', 'error',
|
||||
'-select_streams', 'v:0',
|
||||
'-show_entries', 'stream=avg_frame_rate,width,height:format=duration',
|
||||
'-of', 'default=noprint_wrappers=1',
|
||||
inputPath
|
||||
])
|
||||
const empty = { fps: null, width: null, height: null, durationSec: null }
|
||||
if (r.status !== 0) return empty
|
||||
const fields: Record<string, string> = {}
|
||||
for (const line of String(r.stdout).split(/\r?\n/)) {
|
||||
|
||||
@@ -14,6 +14,9 @@ export const foldersDir = path.join(dataDir, 'folders')
|
||||
export const jobsDir = path.join(dataDir, 'jobs')
|
||||
export const tmpDir = path.join(dataDir, 'tmp')
|
||||
export const dbPath = path.join(dataDir, 'app.db')
|
||||
// 런타임에 최신 버전으로 재설치한 ffmpeg/yt-dlp 바이너리를 두는 곳.
|
||||
// data 볼륨 하위라 컨테이너를 재생성해도 유지된다. 바이너리 해석 시 PATH 보다 우선.
|
||||
export const binDir = path.join(dataDir, 'bin')
|
||||
|
||||
export const viewsDir = path.join(projectRoot, 'views')
|
||||
export const publicDir = path.join(projectRoot, 'public')
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
startYoutubeDownload
|
||||
} from '../youtube.js'
|
||||
import { FfmpegUnavailableError, applyTrimToVideo } from '../editor.js'
|
||||
import { getToolsStatus, updateFfmpeg, updateYtDlp } from '../tools.js'
|
||||
import { collectFolderSegments } from './helpers.js'
|
||||
|
||||
export const opRouter = Router()
|
||||
@@ -119,6 +120,27 @@ opRouter.get('/op/dashboard', requireAuth, (req, res, next) => {
|
||||
}
|
||||
})
|
||||
|
||||
// ─── 외부 도구(ffmpeg/yt-dlp) 상태 + 최신 버전 재설치 ───────────────────
|
||||
opRouter.get('/op/tools', requireAuth, (req, res) => {
|
||||
res.json({ ok: true, tools: getToolsStatus() })
|
||||
})
|
||||
|
||||
opRouter.post('/op/tools/:name/update', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const name = req.params.name
|
||||
let tool
|
||||
if (name === 'yt-dlp') tool = await updateYtDlp()
|
||||
else if (name === 'ffmpeg') tool = await updateFfmpeg()
|
||||
else {
|
||||
res.status(400).json({ ok: false, message: '알 수 없는 도구입니다.' })
|
||||
return
|
||||
}
|
||||
res.json({ ok: true, tool })
|
||||
} catch (err) {
|
||||
res.status(500).json({ ok: false, message: (err as Error).message })
|
||||
}
|
||||
})
|
||||
|
||||
function folderViewHandler(req: Request, res: Response, next: NextFunction): void {
|
||||
try {
|
||||
const segments = collectFolderSegments(req.params)
|
||||
|
||||
194
src/tools.ts
Normal file
194
src/tools.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* 외부 도구(ffmpeg / yt-dlp) 버전 조회 + 최신 버전 재설치.
|
||||
*
|
||||
* 동기:
|
||||
* - yt-dlp 는 YouTube 변경에 맞춰 자주 깨진다. 오래된 버전이면 다운로드가 실패하므로
|
||||
* 운영 중에 최신 버전으로 갈아끼울 수 있어야 한다.
|
||||
* - 새 바이너리는 data 볼륨 하위 `data/bin` 에 설치한다(컨테이너 재생성에도 유지).
|
||||
* 바이너리 해석은 `data/bin` 을 PATH/이미지 기본본보다 먼저 본다(editor.ts / youtube.ts).
|
||||
*
|
||||
* Linux 컨테이너(운영 런타임) 기준으로 구현. 다른 OS 에서는 명확한 에러를 던진다.
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { promises as fs, createWriteStream } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import https from 'node:https'
|
||||
import { binDir } from './paths.js'
|
||||
import { getFfmpegPath, resetFfmpegResolution } from './editor.js'
|
||||
import { getYtDlpPath, resetYtDlpResolution } from './youtube.js'
|
||||
|
||||
export interface ToolStatus {
|
||||
name: 'ffmpeg' | 'yt-dlp'
|
||||
available: boolean
|
||||
version: string | null
|
||||
path: string | null
|
||||
/** data/bin 에 재설치된(직접 관리하는) 바이너리인지 여부. */
|
||||
managed: boolean
|
||||
}
|
||||
|
||||
function parseFfmpegVersion(out: string): string | null {
|
||||
const first = String(out).split(/\r?\n/)[0] || ''
|
||||
const m = /ffmpeg version (\S+)/.exec(first)
|
||||
return m ? m[1] : null
|
||||
}
|
||||
|
||||
function ffmpegStatus(): ToolStatus {
|
||||
try {
|
||||
const p = getFfmpegPath()
|
||||
const r = spawnSync(p, ['-version'], { encoding: 'utf8' })
|
||||
const ok = r.status === 0
|
||||
return {
|
||||
name: 'ffmpeg',
|
||||
available: ok,
|
||||
version: ok ? parseFfmpegVersion(r.stdout) : null,
|
||||
path: p,
|
||||
managed: p.startsWith(binDir)
|
||||
}
|
||||
} catch {
|
||||
return { name: 'ffmpeg', available: false, version: null, path: null, managed: false }
|
||||
}
|
||||
}
|
||||
|
||||
function ytDlpStatus(): ToolStatus {
|
||||
try {
|
||||
const p = getYtDlpPath()
|
||||
const r = spawnSync(p, ['--version'], { encoding: 'utf8' })
|
||||
const ok = r.status === 0
|
||||
return {
|
||||
name: 'yt-dlp',
|
||||
available: ok,
|
||||
version: ok ? String(r.stdout).trim() : null,
|
||||
path: p,
|
||||
managed: p.startsWith(binDir)
|
||||
}
|
||||
} catch {
|
||||
return { name: 'yt-dlp', available: false, version: null, path: null, managed: false }
|
||||
}
|
||||
}
|
||||
|
||||
export function getToolsStatus(): { ffmpeg: ToolStatus; ytDlp: ToolStatus } {
|
||||
return { ffmpeg: ffmpegStatus(), ytDlp: ytDlpStatus() }
|
||||
}
|
||||
|
||||
/** HTTPS 다운로드. 3xx 리다이렉트를 직접 따라간다(GitHub→objects.githubusercontent 등). */
|
||||
function download(url: string, dest: string, redirectsLeft = 5): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = https.get(
|
||||
url,
|
||||
{ headers: { 'User-Agent': 'make-video-site' } },
|
||||
(res) => {
|
||||
const status = res.statusCode || 0
|
||||
if (status >= 300 && status < 400 && res.headers.location) {
|
||||
res.resume()
|
||||
if (redirectsLeft <= 0) {
|
||||
reject(new Error('리다이렉트 한도 초과'))
|
||||
return
|
||||
}
|
||||
const next = new URL(res.headers.location, url).toString()
|
||||
download(next, dest, redirectsLeft - 1).then(resolve, reject)
|
||||
return
|
||||
}
|
||||
if (status !== 200) {
|
||||
res.resume()
|
||||
reject(new Error('HTTP ' + status + ' — ' + url))
|
||||
return
|
||||
}
|
||||
const out = createWriteStream(dest)
|
||||
res.pipe(out)
|
||||
out.on('finish', () => out.close(() => resolve()))
|
||||
out.on('error', reject)
|
||||
}
|
||||
)
|
||||
req.on('error', reject)
|
||||
req.setTimeout(180000, () => {
|
||||
req.destroy(new Error('다운로드 타임아웃'))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function assertLinux(): void {
|
||||
if (process.platform !== 'linux') {
|
||||
throw new Error('자동 재설치는 Linux 런타임에서만 지원합니다. 현재: ' + process.platform)
|
||||
}
|
||||
}
|
||||
|
||||
/** 최신 yt-dlp 를 data/bin/yt-dlp 로 내려받아 교체한다. */
|
||||
export async function updateYtDlp(): Promise<ToolStatus> {
|
||||
assertLinux()
|
||||
const asset = process.arch === 'arm64' ? 'yt-dlp_linux_aarch64' : 'yt-dlp_linux'
|
||||
const url = `https://github.com/yt-dlp/yt-dlp/releases/latest/download/${asset}`
|
||||
await fs.mkdir(binDir, { recursive: true })
|
||||
const tmp = path.join(binDir, 'yt-dlp.download')
|
||||
const dest = path.join(binDir, 'yt-dlp')
|
||||
await fs.rm(tmp, { force: true })
|
||||
await download(url, tmp)
|
||||
await fs.chmod(tmp, 0o755)
|
||||
// 검증: 내려받은 바이너리가 실제로 실행되는지 확인 후에만 교체.
|
||||
const v = spawnSync(tmp, ['--version'], { encoding: 'utf8' })
|
||||
if (v.status !== 0) {
|
||||
await fs.rm(tmp, { force: true })
|
||||
throw new Error('내려받은 yt-dlp 가 실행되지 않습니다.')
|
||||
}
|
||||
await fs.rename(tmp, dest)
|
||||
resetYtDlpResolution()
|
||||
return ytDlpStatus()
|
||||
}
|
||||
|
||||
/** 추출 디렉토리에서 지정한 이름의 파일을 재귀로 찾는다. */
|
||||
async function findBinaries(
|
||||
root: string,
|
||||
names: string[]
|
||||
): Promise<Record<string, string | null>> {
|
||||
const result: Record<string, string | null> = {}
|
||||
for (const n of names) result[n] = null
|
||||
async function walk(dir: string): Promise<void> {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true })
|
||||
for (const e of entries) {
|
||||
const full = path.join(dir, e.name)
|
||||
if (e.isDirectory()) {
|
||||
await walk(full)
|
||||
} else if (names.includes(e.name) && result[e.name] === null) {
|
||||
result[e.name] = full
|
||||
}
|
||||
}
|
||||
}
|
||||
await walk(root)
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 최신 ffmpeg 정적 빌드(johnvansickle)를 받아 ffmpeg/ffprobe 를 data/bin 으로 교체한다.
|
||||
* 압축 해제에 xz(tar -J)가 필요하다 — 런타임 이미지에 xz-utils 가 포함돼야 한다.
|
||||
*/
|
||||
export async function updateFfmpeg(): Promise<ToolStatus> {
|
||||
assertLinux()
|
||||
const arch =
|
||||
process.arch === 'arm64' ? 'arm64' : process.arch === 'x64' ? 'amd64' : null
|
||||
if (!arch) throw new Error('지원하지 않는 아키텍처: ' + process.arch)
|
||||
const url = `https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-${arch}-static.tar.xz`
|
||||
await fs.mkdir(binDir, { recursive: true })
|
||||
const work = path.join(binDir, 'ffmpeg-dl')
|
||||
await fs.rm(work, { recursive: true, force: true })
|
||||
await fs.mkdir(work, { recursive: true })
|
||||
try {
|
||||
const tarPath = path.join(work, 'ffmpeg.tar.xz')
|
||||
await download(url, tarPath)
|
||||
const ex = spawnSync('tar', ['-xJf', tarPath, '-C', work], { encoding: 'utf8' })
|
||||
if (ex.status !== 0) {
|
||||
throw new Error('압축 해제 실패(xz 필요): ' + (ex.stderr || ex.error?.message || '').trim())
|
||||
}
|
||||
const found = await findBinaries(work, ['ffmpeg', 'ffprobe'])
|
||||
if (!found.ffmpeg) throw new Error('압축에서 ffmpeg 바이너리를 찾지 못했습니다.')
|
||||
for (const name of ['ffmpeg', 'ffprobe'] as const) {
|
||||
const src = found[name]
|
||||
if (!src) continue
|
||||
const dest = path.join(binDir, name)
|
||||
await fs.copyFile(src, dest)
|
||||
await fs.chmod(dest, 0o755)
|
||||
}
|
||||
} finally {
|
||||
await fs.rm(work, { recursive: true, force: true })
|
||||
}
|
||||
resetFfmpegResolution()
|
||||
return ffmpegStatus()
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { spawn, spawnSync } from 'node:child_process'
|
||||
import { promises as fs } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { jobsDir, projectRoot } from './paths.js'
|
||||
import { binDir, jobsDir, projectRoot } from './paths.js'
|
||||
import { upscaleOriginalTo60Fps, applyTrimToVideo } from './editor.js'
|
||||
import {
|
||||
createVideo,
|
||||
@@ -22,13 +22,18 @@ let resolvedYtDlpPath: string | null = null
|
||||
|
||||
export function getYtDlpPath(): string {
|
||||
if (resolvedYtDlpPath) return resolvedYtDlpPath
|
||||
// 1) 프로젝트 bin/yt-dlp(.exe)
|
||||
const localCandidates =
|
||||
// 0) data/bin/yt-dlp — 런타임에 최신으로 재설치한 바이너리 (가장 우선, 볼륨 영속)
|
||||
// 1) 프로젝트 bin/yt-dlp(.exe) — 이미지에 포함된 기본 바이너리
|
||||
const candidates =
|
||||
process.platform === 'win32'
|
||||
? ['bin/yt-dlp.exe', 'bin/yt-dlp']
|
||||
: ['bin/yt-dlp']
|
||||
for (const rel of localCandidates) {
|
||||
const abs = path.join(projectRoot, rel)
|
||||
? [
|
||||
path.join(binDir, 'yt-dlp.exe'),
|
||||
path.join(binDir, 'yt-dlp'),
|
||||
path.join(projectRoot, 'bin/yt-dlp.exe'),
|
||||
path.join(projectRoot, 'bin/yt-dlp')
|
||||
]
|
||||
: [path.join(binDir, 'yt-dlp'), path.join(projectRoot, 'bin/yt-dlp')]
|
||||
for (const abs of candidates) {
|
||||
const r = spawnSync(abs, ['--version'])
|
||||
if (r.status === 0) {
|
||||
resolvedYtDlpPath = abs
|
||||
@@ -44,6 +49,11 @@ export function getYtDlpPath(): string {
|
||||
throw new YtDlpUnavailableError()
|
||||
}
|
||||
|
||||
/** data/bin 에 새 yt-dlp 를 설치한 뒤 캐시된 해석 결과를 비운다. */
|
||||
export function resetYtDlpResolution(): void {
|
||||
resolvedYtDlpPath = null
|
||||
}
|
||||
|
||||
export interface ProbeResult {
|
||||
title: string
|
||||
durationSec: number
|
||||
|
||||
Reference in New Issue
Block a user