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:
Claude (owner)
2026-06-05 16:02:25 +09:00
parent f9a26aaa86
commit 8ed3b7d82a
9 changed files with 379 additions and 17 deletions

View File

@@ -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/)) {