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/)) {
|
||||
|
||||
Reference in New Issue
Block a user