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

@@ -28,8 +28,9 @@ ENV HOST=0.0.0.0
ENV PORT=3000
# ffmpeg: 트림·60fps·FHD 변환. ca-certificates: yt-dlp https.
# xz-utils: 관리자 화면에서 최신 ffmpeg 정적 빌드(.tar.xz) 재설치 시 tar -J 압축 해제에 필요.
RUN apt-get update \
&& apt-get install -y --no-install-recommends ffmpeg ca-certificates \
&& apt-get install -y --no-install-recommends ffmpeg ca-certificates xz-utils \
&& rm -rf /var/lib/apt/lists/*
# yt-dlp 바이너리는 BuildKit 이 빌드 타임에 받아 넣는다 (이미지에 wget 불필요).

View File

@@ -79,4 +79,53 @@
})
})
}
// 도구 관리(ffmpeg / yt-dlp)
function renderTool(name, tool) {
var card = document.querySelector('.toolCard[data-tool="' + name + '"]')
if (!card) return
var versionEl = card.querySelector('[data-field="version"]')
var pathEl = card.querySelector('[data-field="path"]')
if (!tool || !tool.available) {
versionEl.textContent = '설치되지 않음'
pathEl.textContent = ''
return
}
versionEl.textContent = (tool.version || '버전 미상') + (tool.managed ? ' · 직접 설치본' : '')
pathEl.textContent = tool.path || ''
}
function loadTools() {
fetch('/op/tools').then(function (r) { return r.json() }).then(function (j) {
if (!j.ok) return
renderTool('yt-dlp', j.tools.ytDlp)
renderTool('ffmpeg', j.tools.ffmpeg)
}).catch(function () {})
}
document.querySelectorAll('[data-action="update-tool"]').forEach(function (btn) {
btn.addEventListener('click', function () {
var name = btn.getAttribute('data-name')
var card = btn.closest('.toolCard')
var statusEl = card.querySelector('[data-field="status"]')
btn.disabled = true
statusEl.textContent = '재설치 중… (수십 초 걸릴 수 있습니다)'
fetch('/op/tools/' + encodeURIComponent(name) + '/update', { method: 'POST' })
.then(function (r) { return r.json() })
.then(function (j) {
if (j.ok) {
statusEl.textContent = '완료'
renderTool(name, j.tool)
} else {
statusEl.textContent = '실패: ' + (j.message || '알 수 없는 오류')
}
})
.catch(function (err) {
statusEl.textContent = '실패: ' + (err && err.message ? err.message : '요청 오류')
})
.then(function () { btn.disabled = false })
})
})
if (document.querySelector('.toolsSection')) loadTools()
})()

View File

@@ -102,6 +102,25 @@ body.siteBody.centerLayout { display: flex; align-items: center; justify-content
.folderIcon { font-size: 40px; }
.folderName { font-size: 15px; word-break: break-all; text-align: center; }
/* tools (ffmpeg / yt-dlp) */
.toolsSection { margin-top: 40px; }
.toolsSection h2 { margin-bottom: 6px; }
.toolsSection .muted { margin-bottom: 16px; }
.toolGrid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.toolCard {
background: var(--bg-card); border: 1px solid var(--border);
border-radius: 12px; padding: 16px; display: flex; flex-direction: column; gap: 10px;
}
.toolHead { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; }
.toolName { font-size: 16px; font-weight: 600; }
.toolVersion { font-size: 13px; color: var(--accent); text-align: right; }
.toolPath { font-size: 12px; word-break: break-all; }
.toolRow { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.toolStatus { font-size: 12px; }
/* video grid */
.videoGrid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));

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'
const p = resolveBinary('ffmpeg')
if (!p) throw new FfmpegUnavailableError()
resolvedFfmpegPath = p
return p
}
throw new FfmpegUnavailableError()
/** 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/)) {

View File

@@ -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')

View File

@@ -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
View 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()
}

View File

@@ -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

View File

@@ -30,6 +30,35 @@
</div>
<% }) %>
</section>
<section class="toolsSection">
<h2>도구 관리</h2>
<p class="muted">영상 다운로드·변환에 쓰는 외부 도구입니다. 너무 예전 버전이라 오류가 나거나 다운로드가 안 되면 최신 버전으로 재설치하세요. (Linux 런타임 기준)</p>
<div class="toolGrid">
<div class="toolCard" data-tool="yt-dlp">
<div class="toolHead">
<span class="toolName">yt-dlp</span>
<span class="toolVersion" data-field="version">확인 중…</span>
</div>
<div class="toolPath muted" data-field="path"></div>
<div class="toolRow">
<button type="button" class="secondaryButton" data-action="update-tool" data-name="yt-dlp">최신으로 재설치</button>
<span class="toolStatus muted" data-field="status"></span>
</div>
</div>
<div class="toolCard" data-tool="ffmpeg">
<div class="toolHead">
<span class="toolName">ffmpeg</span>
<span class="toolVersion" data-field="version">확인 중…</span>
</div>
<div class="toolPath muted" data-field="path"></div>
<div class="toolRow">
<button type="button" class="secondaryButton" data-action="update-tool" data-name="ffmpeg">최신으로 재설치</button>
<span class="toolStatus muted" data-field="status"></span>
</div>
</div>
</div>
</section>
</main>
<div class="ctxMenu" id="ctxMenu" hidden>