feat(folders): nested folder tree (max depth 2) + id-based mutations
- new storeDb.ts: DB+FS layer (folders w/ parent_id, videos w/ global id)
- depth cap: sub-folder cannot have children (enforced in createFolder)
- video id: editable anytime (changeVideoId), uniqueness via PK
- routes:
- public /folder/:topName[/:subName], /video/:topName[/:subName]/:videoId
- admin /op/folder/:topName[/:subName], id-based /op/folders/:id/*
and /op/videos/:id/* (rename/changeId/delete/save + idAvailable check)
- views: breadcrumb + subFolder grid; 플레이리스트 추가 button shows at 2단계
- client JS: id-based mutation endpoints, share URL data attribute,
in-site player pushes share URL to address bar
- store.ts slimmed to Account + readAccounts (FS layer moved to storeDb)
- pickIntId accepts numeric JSON bodies
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
403
src/routes/op.ts
403
src/routes/op.ts
@@ -2,24 +2,26 @@ 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 { readAccounts } from '../store.js'
|
||||
import {
|
||||
changeVideoId,
|
||||
createFolder,
|
||||
createVideo,
|
||||
deleteFolder,
|
||||
deleteVideo,
|
||||
folderPath,
|
||||
listFolders,
|
||||
listVideos,
|
||||
loadVideoMeta,
|
||||
moveUploadIntoVideo,
|
||||
newVideoId,
|
||||
readAccounts,
|
||||
folderPathNames,
|
||||
getFolder,
|
||||
getFolderByPath,
|
||||
getVideo,
|
||||
listChildFolders,
|
||||
listTopFolders,
|
||||
listVideosInFolder,
|
||||
moveUploadIntoVideoDir,
|
||||
renameFolder,
|
||||
sanitizeFolderName,
|
||||
saveVideoMeta,
|
||||
type VideoMeta
|
||||
} from '../store.js'
|
||||
updateVideo,
|
||||
videoIdExists
|
||||
} from '../storeDb.js'
|
||||
import { tmpDir } from '../paths.js'
|
||||
import {
|
||||
YtDlpUnavailableError,
|
||||
@@ -32,9 +34,6 @@ import { FfmpegUnavailableError, applyTrimToVideo } from '../editor.js'
|
||||
export const opRouter = Router()
|
||||
|
||||
// 업로드 용량 상한. 기본 1 GiB. UPLOAD_MAX_BYTES 환경변수로 변경 가능.
|
||||
// - 비어있거나 미설정이면 기본 1 GiB
|
||||
// - "0" 또는 "Infinity" 만 명시적 무제한으로 인정
|
||||
// - 잘못된 값 (오타 등) 은 기본 1 GiB 로 fallback (경고 출력)
|
||||
const DEFAULT_UPLOAD_MAX_BYTES = 1024 * 1024 * 1024
|
||||
const uploadMaxBytes = (() => {
|
||||
const raw = process.env.UPLOAD_MAX_BYTES
|
||||
@@ -43,7 +42,9 @@ const uploadMaxBytes = (() => {
|
||||
if (trimmed === '0' || trimmed.toLowerCase() === 'infinity') return Infinity
|
||||
const n = Number(trimmed)
|
||||
if (!Number.isFinite(n) || n <= 0) {
|
||||
console.warn(`[upload] invalid UPLOAD_MAX_BYTES=${JSON.stringify(raw)}; falling back to default ${DEFAULT_UPLOAD_MAX_BYTES} bytes`)
|
||||
console.warn(
|
||||
`[upload] invalid UPLOAD_MAX_BYTES=${JSON.stringify(raw)}; falling back to default ${DEFAULT_UPLOAD_MAX_BYTES} bytes`
|
||||
)
|
||||
return DEFAULT_UPLOAD_MAX_BYTES
|
||||
}
|
||||
return Math.max(1, Math.floor(n))
|
||||
@@ -58,6 +59,22 @@ function pickStr(v: unknown): string {
|
||||
return typeof v === 'string' ? v : ''
|
||||
}
|
||||
|
||||
function pickIntId(v: unknown): number | null {
|
||||
// JSON body 로 들어오면 number, form 으로 들어오면 string. 둘 다 지원.
|
||||
let n: number
|
||||
if (typeof v === 'number') {
|
||||
n = v
|
||||
} else {
|
||||
const s = pickStr(v).trim()
|
||||
if (!s) return null
|
||||
n = Number(s)
|
||||
}
|
||||
if (!Number.isFinite(n) || n < 1 || Math.floor(n) !== n) return null
|
||||
return n
|
||||
}
|
||||
|
||||
// ─── 인증 ─────────────────────────────────────────────────────────────
|
||||
|
||||
opRouter.get('/op', (req, res) => {
|
||||
if (req.session?.userId) {
|
||||
res.redirect('/op/dashboard')
|
||||
@@ -88,119 +105,114 @@ opRouter.post('/op/logout', (req, res) => {
|
||||
})
|
||||
})
|
||||
|
||||
opRouter.get('/op/dashboard', requireAuth, async (req, res, next) => {
|
||||
// ─── 폴더 뷰 ──────────────────────────────────────────────────────────
|
||||
|
||||
opRouter.get('/op/dashboard', requireAuth, (req, res, next) => {
|
||||
try {
|
||||
const folders = await listFolders()
|
||||
const folders = listTopFolders()
|
||||
res.render('op/dashboard', { userId: req.session.userId, folders })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
opRouter.post('/op/folders', requireAuth, async (req, res, next) => {
|
||||
function folderViewHandler(req: Request, res: Response, next: NextFunction): void {
|
||||
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) {
|
||||
const segments = collectFolderSegments(req.params)
|
||||
const folder = getFolderByPath(segments)
|
||||
if (!folder) {
|
||||
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', {
|
||||
const subFolders = folder.parentId === null ? listChildFolders(folder.id) : []
|
||||
const videos = listVideosInFolder(folder.id)
|
||||
res.render('op/folder', {
|
||||
userId: req.session.userId,
|
||||
folder: safe,
|
||||
video
|
||||
folder,
|
||||
breadcrumb: folderPathNames(folder.id),
|
||||
subFolders,
|
||||
videos,
|
||||
isSubFolder: folder.parentId !== null
|
||||
})
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
}
|
||||
opRouter.get('/op/folder/:topName', requireAuth, folderViewHandler)
|
||||
opRouter.get('/op/folder/:topName/:subName', requireAuth, folderViewHandler)
|
||||
|
||||
// ─── 폴더 mutation (id 기반) ───────────────────────────────────────────
|
||||
|
||||
opRouter.post('/op/folders', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const name = pickStr(req.body.name)
|
||||
const parentIdRaw = req.body.parentId
|
||||
const parentId =
|
||||
parentIdRaw === undefined || parentIdRaw === null || parentIdRaw === ''
|
||||
? null
|
||||
: pickIntId(parentIdRaw)
|
||||
if (parentIdRaw !== undefined && parentIdRaw !== null && parentIdRaw !== '' && parentId === null) {
|
||||
throw new Error('상위 폴더 ID 가 올바르지 않습니다.')
|
||||
}
|
||||
const folder = await createFolder({ name, parentId })
|
||||
res.json({ ok: true, folder })
|
||||
} catch (err) {
|
||||
res.status(400).json({ ok: false, message: (err as Error).message })
|
||||
}
|
||||
})
|
||||
|
||||
opRouter.post('/op/folder/:name/video/rename', requireAuth, async (req, res) => {
|
||||
opRouter.post('/op/folders/:id/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)
|
||||
const id = pickIntId(req.params.id)
|
||||
if (id === null) throw new Error('폴더 ID 가 올바르지 않습니다.')
|
||||
const newName = pickStr(req.body.newName)
|
||||
const folder = await renameFolder(id, newName)
|
||||
res.json({ ok: true, folder })
|
||||
} catch (err) {
|
||||
res.status(400).json({ ok: false, message: (err as Error).message })
|
||||
}
|
||||
})
|
||||
|
||||
opRouter.post('/op/folders/:id/delete', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const id = pickIntId(req.params.id)
|
||||
if (id === null) throw new Error('폴더 ID 가 올바르지 않습니다.')
|
||||
await deleteFolder(id)
|
||||
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 })
|
||||
}
|
||||
})
|
||||
// ─── 영상 에디터 페이지 ────────────────────────────────────────────────
|
||||
|
||||
opRouter.get(
|
||||
['/op/folder/:topName/video/editor', '/op/folder/:topName/:subName/video/editor'],
|
||||
requireAuth,
|
||||
(req, res, next) => {
|
||||
try {
|
||||
const segments = collectFolderSegments(req.params)
|
||||
const folder = getFolderByPath(segments)
|
||||
if (!folder) {
|
||||
res.status(404).send('폴더를 찾을 수 없습니다.')
|
||||
return
|
||||
}
|
||||
const videoId = typeof req.query.id === 'string' ? req.query.id : null
|
||||
const video = videoId ? getVideo(videoId) : null
|
||||
res.render('op/editor', {
|
||||
userId: req.session.userId,
|
||||
folder,
|
||||
breadcrumb: folderPathNames(folder.id),
|
||||
video
|
||||
})
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// ─── 영상 업로드 / 유튜브 ───────────────────────────────────────────────
|
||||
|
||||
// multer 가 던지는 LIMIT_FILE_SIZE 같은 에러를 라우트 핸들러가 잡지 못해
|
||||
// 글로벌 에러 핸들러로 새서 stack trace 가 그대로 노출되던 문제를 막는다.
|
||||
function uploadSingle(fieldName: string) {
|
||||
const mw = upload.single(fieldName)
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
@@ -219,80 +231,76 @@ function uploadSingle(fieldName: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// 업로드: 단일 파일. multipart/form-data, fields: title, file
|
||||
opRouter.post(
|
||||
'/op/folder/:name/video/upload',
|
||||
['/op/folder/:topName/video/upload', '/op/folder/:topName/:subName/video/upload'],
|
||||
requireAuth,
|
||||
uploadSingle('file'),
|
||||
async (req, res) => {
|
||||
try {
|
||||
const safe = sanitizeFolderName(req.params.name)
|
||||
if (!safe) throw new Error('폴더 이름이 올바르지 않습니다.')
|
||||
const folder = getFolderByPath(collectFolderSegments(req.params))
|
||||
if (!folder) 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,
|
||||
const video = await createVideo({
|
||||
folderId: folder.id,
|
||||
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 })
|
||||
sourceType: 'upload'
|
||||
})
|
||||
await moveUploadIntoVideoDir(video.id, file.path, destName)
|
||||
res.json({ ok: true, videoId: video.id })
|
||||
} 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
|
||||
opRouter.post(
|
||||
['/op/folder/:topName/video/youtube/probe', '/op/folder/:topName/:subName/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 })
|
||||
}
|
||||
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
|
||||
opRouter.post(
|
||||
['/op/folder/:topName/video/youtube/start', '/op/folder/:topName/:subName/video/youtube/start'],
|
||||
requireAuth,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const folder = getFolderByPath(collectFolderSegments(req.params))
|
||||
if (!folder) 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({ folderId: folder.id, 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 })
|
||||
}
|
||||
res.status(400).json({ ok: false, message: (err as Error).message })
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
opRouter.get('/op/job/:id', requireAuth, (req, res) => {
|
||||
// 폴링 응답이 304 로 캐싱되면 브라우저가 body 없는 응답을 돌려줘서
|
||||
// 클라이언트의 r.json() 이 reject → 폴링이 중단된다. 항상 신선한 응답.
|
||||
// 폴링 응답이 304 로 캐싱되면 클라이언트의 r.json() 이 reject → 폴링 중단된다.
|
||||
res.set('Cache-Control', 'no-store, no-cache, must-revalidate')
|
||||
res.set('Pragma', 'no-cache')
|
||||
const job = getJob(req.params.id)
|
||||
@@ -303,12 +311,58 @@ opRouter.get('/op/job/:id', requireAuth, (req, res) => {
|
||||
res.json({ ok: true, job })
|
||||
})
|
||||
|
||||
// 편집 저장: trim 정보를 받아 ffmpeg 로 edited.<ext> 생성. 원본은 그대로 보존.
|
||||
opRouter.post('/op/folder/:name/video/save', requireAuth, async (req, res) => {
|
||||
// ─── 영상 mutation (id 기반 — 전역 유일 ID) ─────────────────────────────
|
||||
|
||||
opRouter.post('/op/videos/:id/rename', requireAuth, (req, res) => {
|
||||
try {
|
||||
const safe = sanitizeFolderName(req.params.name)
|
||||
if (!safe) throw new Error('폴더 이름이 올바르지 않습니다.')
|
||||
const id = pickStr(req.body.id)
|
||||
const id = req.params.id
|
||||
const title = pickStr(req.body.title).trim()
|
||||
if (!title) throw new Error('제목을 입력해 주세요.')
|
||||
if (!getVideo(id)) throw new Error('영상을 찾을 수 없습니다.')
|
||||
updateVideo(id, { title })
|
||||
res.json({ ok: true })
|
||||
} catch (err) {
|
||||
res.status(400).json({ ok: false, message: (err as Error).message })
|
||||
}
|
||||
})
|
||||
|
||||
opRouter.post('/op/videos/:id/changeId', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const oldId = req.params.id
|
||||
const newId = pickStr(req.body.newId).trim()
|
||||
if (!newId) throw new Error('새 ID 를 입력해 주세요.')
|
||||
const video = await changeVideoId(oldId, newId)
|
||||
res.json({ ok: true, video })
|
||||
} catch (err) {
|
||||
res.status(400).json({ ok: false, message: (err as Error).message })
|
||||
}
|
||||
})
|
||||
|
||||
/** 새 ID 가 사용 가능한지 미리 확인 (인풋 옆 실시간 표시용). */
|
||||
opRouter.get('/op/videos/idAvailable', requireAuth, (req, res) => {
|
||||
const id = typeof req.query.id === 'string' ? req.query.id.trim() : ''
|
||||
if (!id) {
|
||||
res.json({ ok: true, available: false, reason: '비어 있음' })
|
||||
return
|
||||
}
|
||||
const available = !videoIdExists(id)
|
||||
res.json({ ok: true, available })
|
||||
})
|
||||
|
||||
opRouter.post('/op/videos/:id/delete', requireAuth, async (req, res) => {
|
||||
try {
|
||||
await deleteVideo(req.params.id)
|
||||
res.json({ ok: true })
|
||||
} catch (err) {
|
||||
res.status(400).json({ ok: false, message: (err as Error).message })
|
||||
}
|
||||
})
|
||||
|
||||
opRouter.post('/op/videos/:id/save', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const id = req.params.id
|
||||
const video = getVideo(id)
|
||||
if (!video) throw new Error('영상을 찾을 수 없습니다.')
|
||||
const title = pickStr(req.body.title).trim()
|
||||
const startSec = Number(req.body.startSec ?? 0) || 0
|
||||
const endRaw = req.body.endSec
|
||||
@@ -316,14 +370,14 @@ opRouter.post('/op/folder/:name/video/save', requireAuth, async (req, res) => {
|
||||
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)
|
||||
const trim = {
|
||||
startSec,
|
||||
endSec: endSec == null || Number.isNaN(endSec) ? null : endSec
|
||||
}
|
||||
updateVideo(id, { trim, ...(title ? { title } : {}) })
|
||||
// ffmpeg 가 없으면 trim 정보만 저장하고 안내.
|
||||
try {
|
||||
const outName = await applyTrimToVideo(safe, id, meta.trim)
|
||||
const outName = await applyTrimToVideo(id, trim)
|
||||
res.json({ ok: true, editedFile: outName, note: '편집본 저장 완료' })
|
||||
} catch (err) {
|
||||
if (err instanceof FfmpegUnavailableError) {
|
||||
@@ -340,3 +394,26 @@ opRouter.post('/op/folder/:name/video/save', requireAuth, async (req, res) => {
|
||||
res.status(400).json({ ok: false, message: (err as Error).message })
|
||||
}
|
||||
})
|
||||
|
||||
// ─── 헬퍼 ─────────────────────────────────────────────────────────────
|
||||
|
||||
function collectFolderSegments(params: Record<string, string | undefined>): string[] {
|
||||
const out: string[] = []
|
||||
if (params.topName) out.push(params.topName)
|
||||
if (params.subName) out.push(params.subName)
|
||||
return out
|
||||
}
|
||||
|
||||
/** 폴더 ID → admin URL. 클라이언트가 form action 만들 때 미사용이면 제거 가능. */
|
||||
export function adminFolderUrl(folderId: number): string {
|
||||
const names = folderPathNames(folderId)
|
||||
return '/op/folder/' + names.map((s) => encodeURIComponent(s)).join('/')
|
||||
}
|
||||
|
||||
/** 비디오 ID → 공개 공유 URL (/video/topName/[subName/]videoId). */
|
||||
export function videoShareUrl(videoId: string): string | null {
|
||||
const v = getVideo(videoId)
|
||||
if (!v) return null
|
||||
const names = folderPathNames(v.folderId)
|
||||
return '/video/' + [...names, videoId].map((s) => encodeURIComponent(s)).join('/')
|
||||
}
|
||||
|
||||
@@ -1,84 +1,108 @@
|
||||
import { Router, type RequestHandler } from 'express'
|
||||
import path from 'node:path'
|
||||
import { promises as fs } from 'node:fs'
|
||||
import {
|
||||
findVideoAnywhere,
|
||||
folderPath,
|
||||
listFolders,
|
||||
listVideos,
|
||||
loadVideoMeta,
|
||||
sanitizeFolderName,
|
||||
videoDir,
|
||||
videoFileFsPath
|
||||
} from '../store.js'
|
||||
getFolderByPath,
|
||||
getVideo,
|
||||
listChildFolders,
|
||||
listTopFolders,
|
||||
listVideosInFolder,
|
||||
folderPathNames,
|
||||
videoFileFsPath,
|
||||
type Folder,
|
||||
type Video
|
||||
} from '../storeDb.js'
|
||||
|
||||
export const publicRouter = Router()
|
||||
|
||||
publicRouter.get('/', async (_req, res, next) => {
|
||||
publicRouter.get('/', (_req, res, next) => {
|
||||
try {
|
||||
const folders = await listFolders()
|
||||
const folders = listTopFolders()
|
||||
res.render('index', { folders })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
publicRouter.get('/folder/:name', async (req, res, next) => {
|
||||
/**
|
||||
* 폴더 보기 (공개).
|
||||
* - /folder/:topName — 최상위 폴더 (자식 폴더 + 영상 혼합 가능)
|
||||
* - /folder/:topName/:subName — 서브폴더 (영상만)
|
||||
*/
|
||||
const folderViewHandler: RequestHandler = (req, res, next) => {
|
||||
try {
|
||||
const safe = sanitizeFolderName(req.params.name)
|
||||
if (!safe) {
|
||||
const segments = collectFolderSegments(req.params)
|
||||
const folder = getFolderByPath(segments)
|
||||
if (!folder) {
|
||||
res.status(404).send('폴더를 찾을 수 없습니다.')
|
||||
return
|
||||
}
|
||||
// 존재 확인
|
||||
try {
|
||||
await fs.access(folderPath(safe))
|
||||
} catch {
|
||||
res.status(404).send('폴더를 찾을 수 없습니다.')
|
||||
return
|
||||
}
|
||||
const videos = await listVideos(safe)
|
||||
res.render('folder', { folder: safe, videos, isAdmin: false })
|
||||
const subFolders = folder.parentId === null ? listChildFolders(folder.id) : []
|
||||
const videos = listVideosInFolder(folder.id)
|
||||
res.render('folder', {
|
||||
folder,
|
||||
breadcrumb: folderPathNames(folder.id),
|
||||
subFolders,
|
||||
videos,
|
||||
isAdmin: false,
|
||||
isSubFolder: folder.parentId !== null
|
||||
})
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
publicRouter.get('/folder/:topName', folderViewHandler)
|
||||
publicRouter.get('/folder/:topName/:subName', folderViewHandler)
|
||||
|
||||
publicRouter.get('/player/:videoId', async (req, res, next) => {
|
||||
/**
|
||||
* 영상 보기 (공개).
|
||||
* - /video/:topName/:videoId
|
||||
* - /video/:topName/:subName/:videoId
|
||||
*
|
||||
* 경로의 마지막 세그먼트는 영상 ID. 앞쪽은 그 영상이 속한 폴더 경로와 일치해야 한다.
|
||||
* (DB lookup 한 번이므로 path 가 ID 의 정합성 검증 역할도 함)
|
||||
*/
|
||||
const videoViewHandler: RequestHandler = (req, res, next) => {
|
||||
try {
|
||||
const found = await findVideoAnywhere(req.params.videoId)
|
||||
if (!found) {
|
||||
const { folderSegments, videoId } = collectVideoSegments(req.params)
|
||||
const video = getVideo(videoId)
|
||||
if (!video) {
|
||||
res.status(404).send('영상을 찾을 수 없습니다.')
|
||||
return
|
||||
}
|
||||
res.render('player', { folder: found.folder, video: found.meta })
|
||||
const folderPath = folderPathNames(video.folderId)
|
||||
if (!segmentsEqual(folderPath, folderSegments)) {
|
||||
res.status(404).send('영상 경로가 일치하지 않습니다.')
|
||||
return
|
||||
}
|
||||
res.render('player', { video, breadcrumb: folderPath })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
publicRouter.get('/video/:topName/:videoId', videoViewHandler)
|
||||
publicRouter.get('/video/:topName/:subName/:videoId', videoViewHandler)
|
||||
|
||||
/**
|
||||
* 영상 파일 스트리밍.
|
||||
* - 짧은 외부 공유용 경로: /file/video/:videoId
|
||||
* - 기존 경로: /api/video/:videoId/file (호환용으로 유지)
|
||||
* - ?edited=0 이면 원본을 강제하고, 기본은 편집본 있으면 편집본.
|
||||
* 영상 파일 스트리밍 (짧은 ID-only URL — 외부 공유 호환용).
|
||||
* - GET /file/video/:videoId
|
||||
* - GET /api/video/:videoId/file (legacy alias)
|
||||
* ?edited=0 이면 원본을 강제, 기본은 편집본 있으면 편집본.
|
||||
*/
|
||||
const streamVideoHandler: RequestHandler = async (req, res, next) => {
|
||||
const streamVideoHandler: RequestHandler = (req, res, next) => {
|
||||
try {
|
||||
const found = await findVideoAnywhere(req.params.videoId)
|
||||
if (!found) {
|
||||
const video = getVideo(req.params.videoId)
|
||||
if (!video) {
|
||||
res.status(404).end()
|
||||
return
|
||||
}
|
||||
const editedParam = typeof req.query.edited === 'string' ? req.query.edited : ''
|
||||
const wantOriginal = editedParam === '0' || editedParam === 'false'
|
||||
const fileName =
|
||||
!wantOriginal && found.meta.editedFile ? found.meta.editedFile : found.meta.originalFile
|
||||
!wantOriginal && video.editedFile ? video.editedFile : video.originalFile
|
||||
if (!fileName || fileName.includes('%(ext)s')) {
|
||||
res.status(404).end()
|
||||
return
|
||||
}
|
||||
const fsPath = videoFileFsPath(found.folder, found.meta.id, fileName)
|
||||
const fsPath = videoFileFsPath(video.id, fileName)
|
||||
res.sendFile(fsPath)
|
||||
} catch (err) {
|
||||
next(err)
|
||||
@@ -87,16 +111,49 @@ const streamVideoHandler: RequestHandler = async (req, res, next) => {
|
||||
publicRouter.get('/file/video/:videoId', streamVideoHandler)
|
||||
publicRouter.get('/api/video/:videoId/file', streamVideoHandler)
|
||||
|
||||
/** 비디오 메타 조회 (플레이어/관리자 양쪽에서 사용) */
|
||||
publicRouter.get('/api/video/:videoId', async (req, res, next) => {
|
||||
/** 비디오 메타 조회 (플레이어/관리자 양쪽에서 사용). */
|
||||
publicRouter.get('/api/video/:videoId', (req, res, next) => {
|
||||
try {
|
||||
const found = await findVideoAnywhere(req.params.videoId)
|
||||
if (!found) {
|
||||
const video = getVideo(req.params.videoId)
|
||||
if (!video) {
|
||||
res.status(404).json({ ok: false, message: '영상을 찾을 수 없습니다.' })
|
||||
return
|
||||
}
|
||||
res.json({ ok: true, folder: found.folder, video: found.meta })
|
||||
res.json({
|
||||
ok: true,
|
||||
video,
|
||||
breadcrumb: folderPathNames(video.folderId)
|
||||
})
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// ─── 헬퍼 ─────────────────────────────────────────────────────────────
|
||||
|
||||
function collectFolderSegments(params: Record<string, string | undefined>): string[] {
|
||||
const out: string[] = []
|
||||
if (params.topName) out.push(params.topName)
|
||||
if (params.subName) out.push(params.subName)
|
||||
return out
|
||||
}
|
||||
|
||||
function collectVideoSegments(params: Record<string, string | undefined>): {
|
||||
folderSegments: string[]
|
||||
videoId: string
|
||||
} {
|
||||
const folderSegments: string[] = []
|
||||
if (params.topName) folderSegments.push(params.topName)
|
||||
if (params.subName) folderSegments.push(params.subName)
|
||||
return { folderSegments, videoId: params.videoId ?? '' }
|
||||
}
|
||||
|
||||
function segmentsEqual(a: string[], b: string[]): boolean {
|
||||
if (a.length !== b.length) return false
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (a[i] !== b[i]) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export type { Folder, Video }
|
||||
|
||||
Reference in New Issue
Block a user