From 8ed3b7d82a2754a62c6aebc9c4d643b96c3c46bf Mon Sep 17 00:00:00 2001 From: "Claude (owner)" Date: Fri, 5 Jun 2026 16:02:25 +0900 Subject: [PATCH] =?UTF-8?q?feat(tools):=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=EC=97=90=EC=84=9C=20ffmpeg/yt-dlp=20?= =?UTF-8?q?=EC=B5=9C=EC=8B=A0=20=EB=B2=84=EC=A0=84=20=EC=9E=AC=EC=84=A4?= =?UTF-8?q?=EC=B9=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 오래된 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 --- Dockerfile | 3 +- public/dashboard.js | 49 +++++++++++ public/styles.css | 19 ++++ src/editor.ts | 53 +++++++++-- src/paths.ts | 3 + src/routes/op.ts | 22 +++++ src/tools.ts | 194 +++++++++++++++++++++++++++++++++++++++++ src/youtube.ts | 24 +++-- views/op/dashboard.ejs | 29 ++++++ 9 files changed, 379 insertions(+), 17 deletions(-) create mode 100644 src/tools.ts diff --git a/Dockerfile b/Dockerfile index 87bc63f..04b1db4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 불필요). diff --git a/public/dashboard.js b/public/dashboard.js index 8ed0d5d..38ff717 100644 --- a/public/dashboard.js +++ b/public/dashboard.js @@ -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() })() diff --git a/public/styles.css b/public/styles.css index 6a2957a..04b3dc1 100644 --- a/public/styles.css +++ b/public/styles.css @@ -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)); diff --git a/src/editor.ts b/src/editor.ts index b252c9a..9efdecf 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -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 = {} for (const line of String(r.stdout).split(/\r?\n/)) { diff --git a/src/paths.ts b/src/paths.ts index 2720039..72f378e 100644 --- a/src/paths.ts +++ b/src/paths.ts @@ -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') diff --git a/src/routes/op.ts b/src/routes/op.ts index 9ed34d7..09b3cc1 100644 --- a/src/routes/op.ts +++ b/src/routes/op.ts @@ -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) diff --git a/src/tools.ts b/src/tools.ts new file mode 100644 index 0000000..c8eef98 --- /dev/null +++ b/src/tools.ts @@ -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 { + 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 { + 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> { + const result: Record = {} + for (const n of names) result[n] = null + async function walk(dir: string): Promise { + 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 { + 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() +} diff --git a/src/youtube.ts b/src/youtube.ts index e2b8939..5f7a02e 100644 --- a/src/youtube.ts +++ b/src/youtube.ts @@ -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 diff --git a/views/op/dashboard.ejs b/views/op/dashboard.ejs index aeb1be0..6460d40 100644 --- a/views/op/dashboard.ejs +++ b/views/op/dashboard.ejs @@ -30,6 +30,35 @@ <% }) %> + +
+

도구 관리

+

영상 다운로드·변환에 쓰는 외부 도구입니다. 너무 예전 버전이라 오류가 나거나 다운로드가 안 되면 최신 버전으로 재설치하세요. (Linux 런타임 기준)

+
+
+
+ yt-dlp + 확인 중… +
+
+
+ + +
+
+
+
+ ffmpeg + 확인 중… +
+
+
+ + +
+
+
+