feat: implement video site per README spec
- Express + EJS + express-session stack (auth/navbar ported from minecraft_launcher)
- Public: main folder list, folder video grid, internal popup player (/player/:videoId)
- Admin (/op): login, folder CRUD with right-click context menu + add-folder modal
- Admin folder: video grid with right-click edit/rename/delete, "영상 추가" -> editor
- Video editor: drag-drop upload, file picker, YouTube URL probe (ETA + 5분 경고),
background yt-dlp download with progress polling, navbar title edit, trim controls,
save runs ffmpeg trim (original preserved)
- Filesystem storage under data/folders/<name>/<videoId>/{meta.json, original.<ext>, edited.<ext>}
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
300
src/routes/op.ts
Normal file
300
src/routes/op.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
import { Router } from 'express'
|
||||
import path from 'node:path'
|
||||
import multer from 'multer'
|
||||
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()
|
||||
|
||||
const upload = multer({
|
||||
dest: tmpDir,
|
||||
limits: { fileSize: 4 * 1024 * 1024 * 1024 } // 4GB
|
||||
})
|
||||
|
||||
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 })
|
||||
}
|
||||
})
|
||||
|
||||
// 업로드: 단일 파일. multipart/form-data, fields: title, file
|
||||
opRouter.post(
|
||||
'/op/folder/:name/video/upload',
|
||||
requireAuth,
|
||||
upload.single('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 })
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user