Files
make_video_site/src/routes/op.ts
claude-bot cb9406d88e 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>
2026-05-15 17:13:47 +09:00

326 lines
10 KiB
TypeScript

import { Router } from 'express'
import path from 'node:path'
import multer, { MulterError } from 'multer'
import type { Request, Response, NextFunction } from 'express'
import { promises as fs } from 'node:fs'
import { requireAuth } from '../auth.js'
import {
createFolder,
deleteFolder,
deleteVideo,
folderPath,
listFolders,
listVideos,
loadVideoMeta,
moveUploadIntoVideo,
newVideoId,
readAccounts,
renameFolder,
sanitizeFolderName,
saveVideoMeta,
type VideoMeta
} from '../store.js'
import { tmpDir } from '../paths.js'
import {
YtDlpUnavailableError,
getJob,
probeYoutube,
startYoutubeDownload
} from '../youtube.js'
import { FfmpegUnavailableError, applyTrimToVideo } from '../editor.js'
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({
dest: tmpDir,
limits: { fileSize: uploadMaxBytes }
})
function pickStr(v: unknown): string {
if (Array.isArray(v)) return typeof v[0] === 'string' ? v[0] : ''
return typeof v === 'string' ? v : ''
}
opRouter.get('/op', (req, res) => {
if (req.session?.userId) {
res.redirect('/op/dashboard')
return
}
res.render('op/login', { error: null })
})
opRouter.post('/op', async (req, res, next) => {
try {
const password = pickStr(req.body.password)
const accounts = await readAccounts()
const matched = accounts.find((a) => a.password === password)
if (!matched) {
res.status(401).render('op/login', { error: '비밀번호가 올바르지 않습니다.' })
return
}
req.session.userId = matched.id
res.redirect('/op/dashboard')
} catch (err) {
next(err)
}
})
opRouter.post('/op/logout', (req, res) => {
req.session.destroy(() => {
res.redirect('/op')
})
})
opRouter.get('/op/dashboard', requireAuth, async (req, res, next) => {
try {
const folders = await listFolders()
res.render('op/dashboard', { userId: req.session.userId, folders })
} catch (err) {
next(err)
}
})
opRouter.post('/op/folders', requireAuth, async (req, res, next) => {
try {
const name = pickStr(req.body.name)
const safe = await createFolder(name)
res.json({ ok: true, name: safe })
} catch (err) {
res.status(400).json({ ok: false, message: (err as Error).message })
}
})
opRouter.post('/op/folders/rename', requireAuth, async (req, res) => {
try {
const oldName = pickStr(req.body.oldName)
const newName = pickStr(req.body.newName)
const result = await renameFolder(oldName, newName)
res.json({ ok: true, name: result })
} catch (err) {
res.status(400).json({ ok: false, message: (err as Error).message })
}
})
opRouter.post('/op/folders/delete', requireAuth, async (req, res) => {
try {
const name = pickStr(req.body.name)
await deleteFolder(name)
res.json({ ok: true })
} catch (err) {
res.status(400).json({ ok: false, message: (err as Error).message })
}
})
opRouter.get('/op/folder/:name', requireAuth, async (req, res, next) => {
try {
const safe = sanitizeFolderName(req.params.name)
if (!safe) {
res.status(404).send('폴더를 찾을 수 없습니다.')
return
}
try {
await fs.access(folderPath(safe))
} catch {
res.status(404).send('폴더를 찾을 수 없습니다.')
return
}
const videos = await listVideos(safe)
res.render('op/folder', { userId: req.session.userId, folder: safe, videos })
} catch (err) {
next(err)
}
})
opRouter.get('/op/folder/:name/video/editor', requireAuth, async (req, res, next) => {
try {
const safe = sanitizeFolderName(req.params.name)
if (!safe) {
res.status(404).send('폴더를 찾을 수 없습니다.')
return
}
const videoId = typeof req.query.id === 'string' ? req.query.id : null
let video: VideoMeta | null = null
if (videoId) {
video = await loadVideoMeta(safe, videoId)
}
res.render('op/editor', {
userId: req.session.userId,
folder: safe,
video
})
} catch (err) {
next(err)
}
})
opRouter.post('/op/folder/:name/video/rename', requireAuth, async (req, res) => {
try {
const safe = sanitizeFolderName(req.params.name)
if (!safe) throw new Error('폴더 이름이 올바르지 않습니다.')
const id = pickStr(req.body.id)
const title = pickStr(req.body.title).trim()
if (!title) throw new Error('제목을 입력해 주세요.')
const meta = await loadVideoMeta(safe, id)
if (!meta) throw new Error('영상을 찾을 수 없습니다.')
meta.title = title
await saveVideoMeta(safe, meta)
res.json({ ok: true })
} catch (err) {
res.status(400).json({ ok: false, message: (err as Error).message })
}
})
opRouter.post('/op/folder/:name/video/delete', requireAuth, async (req, res) => {
try {
const safe = sanitizeFolderName(req.params.name)
if (!safe) throw new Error('폴더 이름이 올바르지 않습니다.')
const id = pickStr(req.body.id)
await deleteVideo(safe, id)
res.json({ ok: true })
} catch (err) {
res.status(400).json({ ok: false, message: (err as Error).message })
}
})
// 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
opRouter.post(
'/op/folder/:name/video/upload',
requireAuth,
uploadSingle('file'),
async (req, res) => {
try {
const safe = sanitizeFolderName(req.params.name)
if (!safe) throw new Error('폴더 이름이 올바르지 않습니다.')
const file = req.file
if (!file) throw new Error('파일이 없습니다.')
const title = pickStr(req.body?.title).trim() || file.originalname
const ext = (path.extname(file.originalname) || '.mp4').toLowerCase()
const videoId = newVideoId()
const destName = `original${ext}`
await moveUploadIntoVideo(safe, videoId, file.path, destName)
const now = new Date().toISOString()
const meta = {
id: videoId,
title,
originalFile: destName,
editedFile: null,
durationSec: null,
sourceType: 'upload' as const,
sourceUrl: null,
trim: null,
createdAt: now,
updatedAt: now
}
await saveVideoMeta(safe, meta)
res.json({ ok: true, videoId, folder: safe })
} catch (err) {
res.status(400).json({ ok: false, message: (err as Error).message })
}
}
)
// 유튜브 프로브: 다운받기 전에 길이/사이즈/예상시간/5분초과경고
opRouter.post('/op/folder/:name/video/youtube/probe', requireAuth, async (req, res) => {
try {
const url = pickStr(req.body.url).trim()
if (!url) throw new Error('URL 을 입력해 주세요.')
const probe = await probeYoutube(url)
res.json({ ok: true, probe })
} catch (err) {
if (err instanceof YtDlpUnavailableError) {
res.status(503).json({ ok: false, message: err.message, code: 'NO_YTDLP' })
return
}
res.status(400).json({ ok: false, message: (err as Error).message })
}
})
opRouter.post('/op/folder/:name/video/youtube/start', requireAuth, async (req, res) => {
try {
const safe = sanitizeFolderName(req.params.name)
if (!safe) throw new Error('폴더 이름이 올바르지 않습니다.')
const url = pickStr(req.body.url).trim()
const title = pickStr(req.body.title).trim() || undefined
if (!url) throw new Error('URL 을 입력해 주세요.')
const job = await startYoutubeDownload({ folder: safe, url, title })
res.json({ ok: true, jobId: job.id, videoId: job.videoId })
} catch (err) {
if (err instanceof YtDlpUnavailableError) {
res.status(503).json({ ok: false, message: err.message, code: 'NO_YTDLP' })
return
}
res.status(400).json({ ok: false, message: (err as Error).message })
}
})
opRouter.get('/op/job/:id', requireAuth, (req, res) => {
const job = getJob(req.params.id)
if (!job) {
res.status(404).json({ ok: false, message: '작업을 찾을 수 없습니다.' })
return
}
res.json({ ok: true, job })
})
// 편집 저장: trim 정보를 받아 ffmpeg 로 edited.<ext> 생성. 원본은 그대로 보존.
opRouter.post('/op/folder/:name/video/save', requireAuth, async (req, res) => {
try {
const safe = sanitizeFolderName(req.params.name)
if (!safe) throw new Error('폴더 이름이 올바르지 않습니다.')
const id = pickStr(req.body.id)
const title = pickStr(req.body.title).trim()
const startSec = Number(req.body.startSec ?? 0) || 0
const endRaw = req.body.endSec
const endSec =
endRaw === null || endRaw === undefined || endRaw === ''
? null
: Number(endRaw)
const meta = await loadVideoMeta(safe, id)
if (!meta) throw new Error('영상을 찾을 수 없습니다.')
if (title) meta.title = title
meta.trim = { startSec, endSec: endSec == null || Number.isNaN(endSec) ? null : endSec }
await saveVideoMeta(safe, meta)
// ffmpeg 가 없으면 trim 정보만 저장하고 안내.
try {
const outName = await applyTrimToVideo(safe, id, meta.trim)
res.json({ ok: true, editedFile: outName, note: '편집본 저장 완료' })
} catch (err) {
if (err instanceof FfmpegUnavailableError) {
res.json({
ok: true,
editedFile: null,
note: 'ffmpeg 가 설치되지 않아 편집본을 만들지 못했습니다. trim 설정만 저장됐습니다.'
})
return
}
throw err
}
} catch (err) {
res.status(400).json({ ok: false, message: (err as Error).message })
}
})