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:
@@ -28,8 +28,9 @@ ENV HOST=0.0.0.0
|
|||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
|
|
||||||
# ffmpeg: 트림·60fps·FHD 변환. ca-certificates: yt-dlp https.
|
# ffmpeg: 트림·60fps·FHD 변환. ca-certificates: yt-dlp https.
|
||||||
|
# xz-utils: 관리자 화면에서 최신 ffmpeg 정적 빌드(.tar.xz) 재설치 시 tar -J 압축 해제에 필요.
|
||||||
RUN apt-get update \
|
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/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# yt-dlp 바이너리는 BuildKit 이 빌드 타임에 받아 넣는다 (이미지에 wget 불필요).
|
# yt-dlp 바이너리는 BuildKit 이 빌드 타임에 받아 넣는다 (이미지에 wget 불필요).
|
||||||
|
|||||||
@@ -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()
|
||||||
})()
|
})()
|
||||||
|
|||||||
@@ -102,6 +102,25 @@ body.siteBody.centerLayout { display: flex; align-items: center; justify-content
|
|||||||
.folderIcon { font-size: 40px; }
|
.folderIcon { font-size: 40px; }
|
||||||
.folderName { font-size: 15px; word-break: break-all; text-align: center; }
|
.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 */
|
/* video grid */
|
||||||
.videoGrid {
|
.videoGrid {
|
||||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { spawn, spawnSync } from 'node:child_process'
|
|||||||
import { promises as fs, existsSync } from 'node:fs'
|
import { promises as fs, existsSync } from 'node:fs'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import { getVideo, updateVideo, videoDiskDir, type VideoTrim } from './storeDb.js'
|
import { getVideo, updateVideo, videoDiskDir, type VideoTrim } from './storeDb.js'
|
||||||
|
import { binDir } from './paths.js'
|
||||||
|
|
||||||
export class FfmpegUnavailableError extends Error {
|
export class FfmpegUnavailableError extends Error {
|
||||||
constructor() {
|
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 resolvedFfmpegPath: string | null = null
|
||||||
|
let resolvedFfprobePath: string | null = null
|
||||||
|
|
||||||
export function getFfmpegPath(): string {
|
export function getFfmpegPath(): string {
|
||||||
if (resolvedFfmpegPath) return resolvedFfmpegPath
|
if (resolvedFfmpegPath) return resolvedFfmpegPath
|
||||||
const r = spawnSync('ffmpeg', ['-version'])
|
const p = resolveBinary('ffmpeg')
|
||||||
if (r.status === 0) {
|
if (!p) throw new FfmpegUnavailableError()
|
||||||
resolvedFfmpegPath = 'ffmpeg'
|
resolvedFfmpegPath = p
|
||||||
return 'ffmpeg'
|
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. */
|
/** 입력 영상의 평균 fps 를 ffprobe 로 조회. 실패하면 null. */
|
||||||
export function probeVideoFps(inputPath: string): number | 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',
|
'-v', 'error',
|
||||||
'-select_streams', 'v:0',
|
'-select_streams', 'v:0',
|
||||||
'-show_entries', 'stream=avg_frame_rate',
|
'-show_entries', 'stream=avg_frame_rate',
|
||||||
@@ -55,14 +88,16 @@ function probeVideoMeta(inputPath: string): {
|
|||||||
height: number | null
|
height: number | null
|
||||||
durationSec: 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',
|
'-v', 'error',
|
||||||
'-select_streams', 'v:0',
|
'-select_streams', 'v:0',
|
||||||
'-show_entries', 'stream=avg_frame_rate,width,height:format=duration',
|
'-show_entries', 'stream=avg_frame_rate,width,height:format=duration',
|
||||||
'-of', 'default=noprint_wrappers=1',
|
'-of', 'default=noprint_wrappers=1',
|
||||||
inputPath
|
inputPath
|
||||||
])
|
])
|
||||||
const empty = { fps: null, width: null, height: null, durationSec: null }
|
|
||||||
if (r.status !== 0) return empty
|
if (r.status !== 0) return empty
|
||||||
const fields: Record<string, string> = {}
|
const fields: Record<string, string> = {}
|
||||||
for (const line of String(r.stdout).split(/\r?\n/)) {
|
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 jobsDir = path.join(dataDir, 'jobs')
|
||||||
export const tmpDir = path.join(dataDir, 'tmp')
|
export const tmpDir = path.join(dataDir, 'tmp')
|
||||||
export const dbPath = path.join(dataDir, 'app.db')
|
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 viewsDir = path.join(projectRoot, 'views')
|
||||||
export const publicDir = path.join(projectRoot, 'public')
|
export const publicDir = path.join(projectRoot, 'public')
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
startYoutubeDownload
|
startYoutubeDownload
|
||||||
} from '../youtube.js'
|
} from '../youtube.js'
|
||||||
import { FfmpegUnavailableError, applyTrimToVideo } from '../editor.js'
|
import { FfmpegUnavailableError, applyTrimToVideo } from '../editor.js'
|
||||||
|
import { getToolsStatus, updateFfmpeg, updateYtDlp } from '../tools.js'
|
||||||
import { collectFolderSegments } from './helpers.js'
|
import { collectFolderSegments } from './helpers.js'
|
||||||
|
|
||||||
export const opRouter = Router()
|
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 {
|
function folderViewHandler(req: Request, res: Response, next: NextFunction): void {
|
||||||
try {
|
try {
|
||||||
const segments = collectFolderSegments(req.params)
|
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 { spawn, spawnSync } from 'node:child_process'
|
||||||
import { promises as fs } from 'node:fs'
|
import { promises as fs } from 'node:fs'
|
||||||
import path from 'node:path'
|
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 { upscaleOriginalTo60Fps, applyTrimToVideo } from './editor.js'
|
||||||
import {
|
import {
|
||||||
createVideo,
|
createVideo,
|
||||||
@@ -22,13 +22,18 @@ let resolvedYtDlpPath: string | null = null
|
|||||||
|
|
||||||
export function getYtDlpPath(): string {
|
export function getYtDlpPath(): string {
|
||||||
if (resolvedYtDlpPath) return resolvedYtDlpPath
|
if (resolvedYtDlpPath) return resolvedYtDlpPath
|
||||||
// 1) 프로젝트 bin/yt-dlp(.exe)
|
// 0) data/bin/yt-dlp — 런타임에 최신으로 재설치한 바이너리 (가장 우선, 볼륨 영속)
|
||||||
const localCandidates =
|
// 1) 프로젝트 bin/yt-dlp(.exe) — 이미지에 포함된 기본 바이너리
|
||||||
|
const candidates =
|
||||||
process.platform === 'win32'
|
process.platform === 'win32'
|
||||||
? ['bin/yt-dlp.exe', 'bin/yt-dlp']
|
? [
|
||||||
: ['bin/yt-dlp']
|
path.join(binDir, 'yt-dlp.exe'),
|
||||||
for (const rel of localCandidates) {
|
path.join(binDir, 'yt-dlp'),
|
||||||
const abs = path.join(projectRoot, rel)
|
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'])
|
const r = spawnSync(abs, ['--version'])
|
||||||
if (r.status === 0) {
|
if (r.status === 0) {
|
||||||
resolvedYtDlpPath = abs
|
resolvedYtDlpPath = abs
|
||||||
@@ -44,6 +49,11 @@ export function getYtDlpPath(): string {
|
|||||||
throw new YtDlpUnavailableError()
|
throw new YtDlpUnavailableError()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** data/bin 에 새 yt-dlp 를 설치한 뒤 캐시된 해석 결과를 비운다. */
|
||||||
|
export function resetYtDlpResolution(): void {
|
||||||
|
resolvedYtDlpPath = null
|
||||||
|
}
|
||||||
|
|
||||||
export interface ProbeResult {
|
export interface ProbeResult {
|
||||||
title: string
|
title: string
|
||||||
durationSec: number
|
durationSec: number
|
||||||
|
|||||||
@@ -30,6 +30,35 @@
|
|||||||
</div>
|
</div>
|
||||||
<% }) %>
|
<% }) %>
|
||||||
</section>
|
</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>
|
</main>
|
||||||
|
|
||||||
<div class="ctxMenu" id="ctxMenu" hidden>
|
<div class="ctxMenu" id="ctxMenu" hidden>
|
||||||
|
|||||||
Reference in New Issue
Block a user