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:
Claude
2026-05-31 23:36:02 +09:00
parent 3560dcb802
commit c5faa8808c
17 changed files with 1207 additions and 577 deletions

View File

@@ -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('/')
}

View File

@@ -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 }