feat: npm run setup (yt-dlp + deps + build); lift upload size limit
scripts/setup.mjs runs `npm install`, downloads the platform-specific yt-dlp binary from GitHub releases to ./bin/yt-dlp (which src/youtube.ts already prefers), checks for ffmpeg and prints install hints, then runs `tsc`. One command replaces three for fresh checkouts. While verifying setup, hit `MulterError: File too large` (LIMIT_FILE_SIZE) on a 10 GB mkv upload, and ETXTBSY on freshly downloaded yt-dlp. - ETXTBSY: the redirect path in downloadFile opened a writestream to the destination before following the redirect, so the (unused) outer stream still held the file open when the post-download spawnSync ran. Split redirect-following from file writing so only the final 200 response opens the destination file. - LIMIT_FILE_SIZE: removed the hard-coded 4 GB cap. Upload limit now defaults to Infinity and is configurable via UPLOAD_MAX_BYTES. Wrapped multer's middleware so its errors (LIMIT_FILE_SIZE etc.) come back as a clean 413 JSON instead of a stack trace from the global error handler. - Also disabled Node's default 5 minute requestTimeout so 10 GB uploads over slow links don't get cut mid-stream. Configurable via HTTP_REQUEST_TIMEOUT_MS. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,5 +1,7 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
|
bin/yt-dlp
|
||||||
|
bin/yt-dlp.exe
|
||||||
data/folders/*
|
data/folders/*
|
||||||
!data/folders/.gitkeep
|
!data/folders/.gitkeep
|
||||||
data/jobs/*
|
data/jobs/*
|
||||||
|
|||||||
17
README.md
17
README.md
@@ -3,20 +3,31 @@
|
|||||||
|
|
||||||
## 실행
|
## 실행
|
||||||
|
|
||||||
|
처음 한 번은 `setup` 으로 의존성 + yt-dlp 바이너리 + 빌드까지 한 번에 끝낼 수 있어요:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run setup # npm install + ./bin/yt-dlp 다운로드 + tsc
|
||||||
|
npm start # 기본 http://127.0.0.1:3000 (PORT=3000, HOST=127.0.0.1)
|
||||||
|
```
|
||||||
|
|
||||||
|
수동으로 단계별로 하고 싶다면:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
npm run build
|
npm run build
|
||||||
npm start # 기본 http://127.0.0.1:3000 (PORT=3000, HOST=127.0.0.1)
|
npm start
|
||||||
```
|
```
|
||||||
|
|
||||||
- 외부 노출이 필요하면 `HOST=0.0.0.0 npm start`
|
- 외부 노출이 필요하면 `HOST=0.0.0.0 npm start`
|
||||||
- 관리자 비밀번호는 `account.json` 의 `password` 값 (초기값 `admin`, 운영 시 반드시 변경)
|
- 관리자 비밀번호는 `account.json` 의 `password` 값 (초기값 `admin`, 운영 시 반드시 변경)
|
||||||
- 세션 비밀은 `SESSION_SECRET` 환경변수로 덮어쓰기 권장
|
- 세션 비밀은 `SESSION_SECRET` 환경변수로 덮어쓰기 권장
|
||||||
|
- 업로드 용량 한도: 기본 무제한. 제한하려면 `UPLOAD_MAX_BYTES=<바이트>` 설정
|
||||||
|
- 대용량 업로드용 HTTP 요청 타임아웃: 기본 무제한(0). 필요시 `HTTP_REQUEST_TIMEOUT_MS=<밀리초>`
|
||||||
|
|
||||||
## 외부 의존
|
## 외부 의존
|
||||||
|
|
||||||
- `yt-dlp` — YouTube 영상 가져오기 (`PATH` 또는 `./bin/yt-dlp` 에 설치)
|
- `yt-dlp` — YouTube 영상 가져오기. `npm run setup` 이 `./bin/yt-dlp` 로 자동 설치하지만 PATH 에 이미 있어도 됩니다.
|
||||||
- `ffmpeg` — 영상 트림 저장 (`PATH` 에 설치). 없으면 trim 설정만 저장됩니다.
|
- `ffmpeg` — 영상 트림 저장 (`PATH` 에 설치). 없으면 trim 설정만 저장됩니다. `npm run setup` 이 설치 여부를 검사해 안내 메시지를 출력합니다.
|
||||||
|
|
||||||
## 데이터 위치
|
## 데이터 위치
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/app.js",
|
"main": "dist/app.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"setup": "node scripts/setup.mjs",
|
||||||
"build": "tsc -p tsconfig.json",
|
"build": "tsc -p tsconfig.json",
|
||||||
"start": "node dist/app.js",
|
"start": "node dist/app.js",
|
||||||
"dev": "tsc -p tsconfig.json && node dist/app.js"
|
"dev": "tsc -p tsconfig.json && node dist/app.js"
|
||||||
|
|||||||
163
scripts/setup.mjs
Normal file
163
scripts/setup.mjs
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// 한 번에 의존성 설치 + yt-dlp 바이너리 다운로드 + TypeScript 빌드까지 수행한다.
|
||||||
|
// 사용: `npm run setup`
|
||||||
|
//
|
||||||
|
// yt-dlp 는 GitHub Releases 에서 현재 OS/arch 용 바이너리를 받아 ./bin/yt-dlp(.exe) 로 둔다.
|
||||||
|
// src/youtube.ts 가 이 경로를 우선 탐색하므로 PATH 설치 없이도 동작한다.
|
||||||
|
// ffmpeg 는 시스템 패키지라 자동 설치하지 않고, 없으면 안내만 출력한다.
|
||||||
|
|
||||||
|
import { spawn, spawnSync } from 'node:child_process'
|
||||||
|
import { promises as fs, createWriteStream } from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import https from 'node:https'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
|
const projectRoot = path.resolve(path.dirname(__filename), '..')
|
||||||
|
const binDir = path.join(projectRoot, 'bin')
|
||||||
|
|
||||||
|
function log(msg) { console.log(`[setup] ${msg}`) }
|
||||||
|
function warn(msg) { console.warn(`[setup] ${msg}`) }
|
||||||
|
|
||||||
|
function ytDlpAssetName() {
|
||||||
|
if (process.platform === 'win32') return 'yt-dlp.exe'
|
||||||
|
if (process.platform === 'darwin') return 'yt-dlp_macos'
|
||||||
|
if (process.platform === 'linux') {
|
||||||
|
if (process.arch === 'arm64') return 'yt-dlp_linux_aarch64'
|
||||||
|
if (process.arch === 'arm') return 'yt-dlp_linux_armv7l'
|
||||||
|
return 'yt-dlp_linux'
|
||||||
|
}
|
||||||
|
return 'yt-dlp'
|
||||||
|
}
|
||||||
|
|
||||||
|
function ytDlpLocalPath() {
|
||||||
|
return path.join(binDir, process.platform === 'win32' ? 'yt-dlp.exe' : 'yt-dlp')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 리다이렉트 먼저 따라간 뒤, 최종 200 응답이 확인된 시점에만 파일을 연다.
|
||||||
|
// 이렇게 안 하면 redirect 단계에서 만들어둔 빈 writestream 이 dest 를 잡고 있어
|
||||||
|
// 다운로드 직후 spawn 호출이 Linux 의 ETXTBSY 로 실패한다.
|
||||||
|
function fetchFollowingRedirects(url, depth = 0) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (depth > 5) return reject(new Error('redirect 너무 많음'))
|
||||||
|
https.get(url, { headers: { 'user-agent': 'make-video-site-setup' } }, (res) => {
|
||||||
|
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||||||
|
res.resume()
|
||||||
|
fetchFollowingRedirects(res.headers.location, depth + 1).then(resolve, reject)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (res.statusCode !== 200) {
|
||||||
|
res.resume()
|
||||||
|
reject(new Error(`HTTP ${res.statusCode} for ${url}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resolve(res)
|
||||||
|
}).on('error', reject)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadFile(url, dest) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
fetchFollowingRedirects(url).then((res) => {
|
||||||
|
const file = createWriteStream(dest)
|
||||||
|
const onErr = (err) => {
|
||||||
|
file.close()
|
||||||
|
fs.unlink(dest).catch(() => undefined)
|
||||||
|
reject(err)
|
||||||
|
}
|
||||||
|
res.on('error', onErr)
|
||||||
|
file.on('error', onErr)
|
||||||
|
file.on('finish', () => file.close(() => resolve()))
|
||||||
|
res.pipe(file)
|
||||||
|
}, reject)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureYtDlp() {
|
||||||
|
const target = ytDlpLocalPath()
|
||||||
|
// 이미 있으면 --version 으로 점검
|
||||||
|
try {
|
||||||
|
await fs.access(target)
|
||||||
|
const r = spawnSync(target, ['--version'])
|
||||||
|
if (r.status === 0) {
|
||||||
|
log(`yt-dlp 이미 설치됨 (${target}, version ${String(r.stdout).trim()})`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
warn(`기존 ${target} 실행 실패 — 다시 받습니다.`)
|
||||||
|
} catch { /* 없으면 그냥 진행 */ }
|
||||||
|
|
||||||
|
await fs.mkdir(binDir, { recursive: true })
|
||||||
|
const asset = ytDlpAssetName()
|
||||||
|
const url = `https://github.com/yt-dlp/yt-dlp/releases/latest/download/${asset}`
|
||||||
|
log(`yt-dlp 다운로드 시작: ${url}`)
|
||||||
|
await downloadFile(url, target)
|
||||||
|
if (process.platform !== 'win32') {
|
||||||
|
await fs.chmod(target, 0o755)
|
||||||
|
}
|
||||||
|
// 갓 닫은 실행 파일이 즉시 spawn 되면 일부 환경에서 ETXTBSY/ENOEXEC 가 나는 경우가 있어
|
||||||
|
// 짧게 백오프하며 재시도한다.
|
||||||
|
let version = null
|
||||||
|
let lastErr = null
|
||||||
|
for (let attempt = 0; attempt < 5; attempt++) {
|
||||||
|
if (attempt > 0) await new Promise((r) => setTimeout(r, 200 * attempt))
|
||||||
|
const r = spawnSync(target, ['--version'])
|
||||||
|
if (r.status === 0) {
|
||||||
|
version = String(r.stdout).trim()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
lastErr = r.error || new Error(`exit ${r.status}, stderr: ${String(r.stderr).trim()}`)
|
||||||
|
}
|
||||||
|
if (!version) {
|
||||||
|
throw new Error(`yt-dlp 설치는 끝났지만 실행이 실패했습니다 (${target}): ${lastErr?.message || lastErr}`)
|
||||||
|
}
|
||||||
|
log(`yt-dlp 설치 완료: ${target} (version ${version})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkFfmpeg() {
|
||||||
|
const r = spawnSync('ffmpeg', ['-version'])
|
||||||
|
if (r.status === 0) {
|
||||||
|
const firstLine = String(r.stdout).split('\n')[0]
|
||||||
|
log(`ffmpeg 확인됨: ${firstLine}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
warn('ffmpeg 가 PATH 에 없습니다. 영상 trim 저장이 필요하면 다음 중 하나로 설치해 주세요:')
|
||||||
|
warn(' Ubuntu/Debian: sudo apt-get install -y ffmpeg')
|
||||||
|
warn(' macOS: brew install ffmpeg')
|
||||||
|
warn(' Windows: winget install Gyan.FFmpeg 또는 https://www.gyan.dev/ffmpeg/builds/')
|
||||||
|
}
|
||||||
|
|
||||||
|
function runCmd(cmd, args, opts = {}) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const child = spawn(cmd, args, { stdio: 'inherit', shell: process.platform === 'win32', ...opts })
|
||||||
|
child.on('error', reject)
|
||||||
|
child.on('close', (code) => {
|
||||||
|
if (code === 0) resolve()
|
||||||
|
else reject(new Error(`${cmd} ${args.join(' ')} 실패 (code=${code})`))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
log(`프로젝트 루트: ${projectRoot}`)
|
||||||
|
|
||||||
|
// npm 의존성 — postinstall 로도 들어올 수 있으므로 SKIP 옵션 제공.
|
||||||
|
if (process.env.SKIP_NPM_INSTALL !== '1') {
|
||||||
|
log('npm install 실행')
|
||||||
|
await runCmd('npm', ['install', '--no-audit', '--no-fund'], { cwd: projectRoot })
|
||||||
|
} else {
|
||||||
|
log('SKIP_NPM_INSTALL=1 → npm install 생략')
|
||||||
|
}
|
||||||
|
|
||||||
|
await ensureYtDlp()
|
||||||
|
checkFfmpeg()
|
||||||
|
|
||||||
|
log('TypeScript 빌드')
|
||||||
|
await runCmd('npx', ['tsc', '-p', 'tsconfig.json'], { cwd: projectRoot })
|
||||||
|
|
||||||
|
log('완료. `npm start` 로 서버를 띄울 수 있습니다.')
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error('[setup] 실패:', err.message || err)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
@@ -72,10 +72,14 @@ async function main(): Promise<void> {
|
|||||||
res.status(500).send(`서버 오류: ${message}`)
|
res.status(500).send(`서버 오류: ${message}`)
|
||||||
})
|
})
|
||||||
|
|
||||||
app.listen(PORT, HOST, () => {
|
const server = app.listen(PORT, HOST, () => {
|
||||||
console.log(`[server] http://${HOST}:${PORT}`)
|
console.log(`[server] http://${HOST}:${PORT}`)
|
||||||
console.log(`[server] views: ${path.relative(process.cwd(), viewsDir)}`)
|
console.log(`[server] views: ${path.relative(process.cwd(), viewsDir)}`)
|
||||||
})
|
})
|
||||||
|
// 10GB 같은 큰 영상 업로드가 Node 의 기본 requestTimeout(5분) 에 걸려 끊기는 걸 막는다.
|
||||||
|
// 0 = 무제한. 필요하면 HTTP_REQUEST_TIMEOUT_MS 환경변수로 조정.
|
||||||
|
server.requestTimeout = Number(process.env.HTTP_REQUEST_TIMEOUT_MS ?? 0)
|
||||||
|
// 헤더 단계 타임아웃은 짧게 유지(기본 60초). 0 으로 해버리면 slowloris 류에 취약.
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch((err) => {
|
main().catch((err) => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Router } from 'express'
|
import { Router } from 'express'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import multer from 'multer'
|
import multer, { MulterError } from 'multer'
|
||||||
|
import type { Request, Response, NextFunction } from 'express'
|
||||||
import { promises as fs } from 'node:fs'
|
import { promises as fs } from 'node:fs'
|
||||||
import { requireAuth } from '../auth.js'
|
import { requireAuth } from '../auth.js'
|
||||||
import {
|
import {
|
||||||
@@ -30,9 +31,13 @@ import { FfmpegUnavailableError, applyTrimToVideo } from '../editor.js'
|
|||||||
|
|
||||||
export const opRouter = Router()
|
export const opRouter = Router()
|
||||||
|
|
||||||
|
// 업로드 용량 상한은 환경변수 UPLOAD_MAX_BYTES 로 조정. 기본은 사실상 무제한(Infinity).
|
||||||
|
const uploadMaxBytes = process.env.UPLOAD_MAX_BYTES
|
||||||
|
? Math.max(1, Number(process.env.UPLOAD_MAX_BYTES))
|
||||||
|
: Infinity
|
||||||
const upload = multer({
|
const upload = multer({
|
||||||
dest: tmpDir,
|
dest: tmpDir,
|
||||||
limits: { fileSize: 4 * 1024 * 1024 * 1024 } // 4GB
|
limits: { fileSize: uploadMaxBytes }
|
||||||
})
|
})
|
||||||
|
|
||||||
function pickStr(v: unknown): string {
|
function pickStr(v: unknown): string {
|
||||||
@@ -181,11 +186,31 @@ opRouter.post('/op/folder/:name/video/delete', requireAuth, async (req, res) =>
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// multer 가 던지는 LIMIT_FILE_SIZE 같은 에러를 라우트 핸들러가 잡지 못해
|
||||||
|
// 글로벌 에러 핸들러로 새서 stack trace 가 그대로 노출되던 문제를 막는다.
|
||||||
|
function uploadSingle(fieldName: string) {
|
||||||
|
const mw = upload.single(fieldName)
|
||||||
|
return (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
mw(req, res, (err: unknown) => {
|
||||||
|
if (!err) return next()
|
||||||
|
if (err instanceof MulterError) {
|
||||||
|
const message =
|
||||||
|
err.code === 'LIMIT_FILE_SIZE'
|
||||||
|
? `파일이 너무 큽니다. (한도: ${uploadMaxBytes === Infinity ? '무제한' : uploadMaxBytes + ' bytes'}; UPLOAD_MAX_BYTES 환경변수로 조정)`
|
||||||
|
: `업로드 실패: ${err.message}`
|
||||||
|
res.status(413).json({ ok: false, code: err.code, message })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res.status(400).json({ ok: false, message: (err as Error).message || '업로드 실패' })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 업로드: 단일 파일. multipart/form-data, fields: title, file
|
// 업로드: 단일 파일. multipart/form-data, fields: title, file
|
||||||
opRouter.post(
|
opRouter.post(
|
||||||
'/op/folder/:name/video/upload',
|
'/op/folder/:name/video/upload',
|
||||||
requireAuth,
|
requireAuth,
|
||||||
upload.single('file'),
|
uploadSingle('file'),
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const safe = sanitizeFolderName(req.params.name)
|
const safe = sanitizeFolderName(req.params.name)
|
||||||
|
|||||||
Reference in New Issue
Block a user