import { Router } from 'express' import path from 'node:path' import multer, { MulterError } from 'multer' import type { Request, Response, NextFunction } from 'express' import { requireAuth } from '../auth.js' import { readAccounts } from '../store.js' import { changeVideoId, createFolder, createVideo, deleteFolder, deleteVideo, folderPathNames, getFolder, getFolderByPath, getVideoInFolder, getVideoByIdGlobal, isSafeVideoId, listChildFolders, listTopFolders, listVideosInFolder, moveUploadIntoVideoDir, renameFolder, updateVideo, videoExistsInFolder, savePlaylistDraft, getPlaylistDraft, deletePlaylistDraft } from '../storeDb.js' import { tmpDir } from '../paths.js' import { YtDlpUnavailableError, getJob, probeYoutube, probeYoutubePlaylist, startYoutubeDownload } from '../youtube.js' import { FfmpegUnavailableError, applyTrimToVideo } from '../editor.js' import { getToolsStatus, updateFfmpeg, updateYtDlp } from '../tools.js' import { cookiesStatus, saveCookies, deleteCookies } from '../cookies.js' import { collectFolderSegments } from './helpers.js' import { promises as fs } from 'node:fs' export const opRouter = Router() // 업로드 용량 상한. 기본 1 GiB. UPLOAD_MAX_BYTES 환경변수로 변경 가능. const DEFAULT_UPLOAD_MAX_BYTES = 1024 * 1024 * 1024 const uploadMaxBytes = (() => { const raw = process.env.UPLOAD_MAX_BYTES if (raw === undefined || raw.trim() === '') return DEFAULT_UPLOAD_MAX_BYTES const trimmed = raw.trim() 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` ) return DEFAULT_UPLOAD_MAX_BYTES } return Math.max(1, Math.floor(n)) })() const upload = multer({ dest: tmpDir, limits: { fileSize: uploadMaxBytes } }) // 쿠키 파일은 작다 — 별도 multer 로 1 MiB 로 제한해 대형 파일 오용을 막는다. const cookieUpload = multer({ dest: tmpDir, limits: { fileSize: 1024 * 1024 } }) function pickStr(v: unknown): string { if (Array.isArray(v)) return typeof v[0] === 'string' ? v[0] : '' 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') 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, (req, res, next) => { try { const folders = listTopFolders() res.render('op/dashboard', { userId: req.session.userId, folders }) } catch (err) { next(err) } }) // ─── 외부 도구(ffmpeg/yt-dlp) 상태 + 최신 버전 재설치 ─────────────────── opRouter.get('/op/tools', requireAuth, (req, res) => { res.json({ ok: true, tools: getToolsStatus() }) }) opRouter.post('/op/tools/:name/update', requireAuth, async (req, res) => { try { const name = req.params.name let tool if (name === 'yt-dlp') tool = await updateYtDlp() else if (name === 'ffmpeg') tool = await updateFfmpeg() else { res.status(400).json({ ok: false, message: '알 수 없는 도구입니다.' }) return } res.json({ ok: true, tool }) } catch (err) { res.status(500).json({ ok: false, message: (err as Error).message }) } }) // ─── YouTube 로그인 쿠키 (연령 제한 영상 다운로드용) ─────────────────────── opRouter.get('/op/cookies', requireAuth, (req, res) => { res.json({ ok: true, cookies: cookiesStatus() }) }) opRouter.post('/op/cookies', requireAuth, cookieUpload.single('file'), async (req, res) => { const file = req.file try { if (!file) throw new Error('cookies.txt 파일을 선택해 주세요.') const content = await fs.readFile(file.path, 'utf8') const status = await saveCookies(content) res.json({ ok: true, cookies: status }) } catch (err) { res.status(400).json({ ok: false, message: (err as Error).message }) } finally { if (file) await fs.rm(file.path, { force: true }).catch(() => {}) } }) opRouter.post('/op/cookies/delete', requireAuth, async (req, res) => { try { await deleteCookies() res.json({ ok: true, cookies: cookiesStatus() }) } catch (err) { res.status(400).json({ ok: false, message: (err as Error).message }) } }) function folderViewHandler(req: Request, res: Response, next: NextFunction): void { try { const segments = collectFolderSegments(req.params) const folder = getFolderByPath(segments) if (!folder) { res.status(404).send('폴더를 찾을 수 없습니다.') return } const subFolders = folder.parentId === null ? listChildFolders(folder.id) : [] const videos = listVideosInFolder(folder.id) res.render('op/folder', { userId: req.session.userId, 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/folders/:id/rename', requireAuth, async (req, res) => { try { 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.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 } // 영상은 1단계·2단계 폴더 어디서나 추가할 수 있다. (플레이리스트만 2단계 한정) const videoId = typeof req.query.id === 'string' ? req.query.id : null const video = videoId ? getVideoInFolder(folder.id, videoId) : null res.render('op/editor', { userId: req.session.userId, folder, breadcrumb: folderPathNames(folder.id), video }) } catch (err) { next(err) } } ) // ─── 플레이리스트 import 페이지 ───────────────────────────────────────── opRouter.get( '/op/folder/:topName/:subName/playlist/new', requireAuth, (req, res, next) => { try { const segments = collectFolderSegments(req.params) const folder = getFolderByPath(segments) if (!folder) { res.status(404).send('폴더를 찾을 수 없습니다.') return } if (folder.parentId === null) { res.status(400).send('플레이리스트는 하위 폴더에서만 등록할 수 있습니다.') return } res.render('op/playlist', { userId: req.session.userId, folder, breadcrumb: folderPathNames(folder.id) }) } catch (err) { next(err) } } ) // ─── 플레이리스트 임시저장(draft) ────────────────────────────────────── // 다운로드 시작 전 수정해둔 값(URL + 항목별 제목/ID/구간)을 폴더당 1개 저장/복원. /** 하위 폴더를 경로로 해석. 못 찾거나 최상위면 throw. */ function requireSubFolder(req: Request): { id: number } { const folder = getFolderByPath(collectFolderSegments(req.params)) if (!folder) throw new Error('폴더를 찾을 수 없습니다.') if (folder.parentId === null) { throw new Error('플레이리스트는 하위 폴더에서만 사용할 수 있습니다.') } return folder } // 임시저장 JSON 크기 상한 (과도한 payload 방지). 2 MiB 면 수백 항목도 충분. const DRAFT_MAX_CHARS = 2 * 1024 * 1024 opRouter.get( '/op/folder/:topName/:subName/playlist/draft', requireAuth, (req, res) => { try { const folder = requireSubFolder(req) const draft = getPlaylistDraft(folder.id) res.json({ ok: true, draft }) } catch (err) { res.status(400).json({ ok: false, message: (err as Error).message }) } } ) opRouter.post( '/op/folder/:topName/:subName/playlist/draft', requireAuth, (req, res) => { try { const folder = requireSubFolder(req) const data = req.body?.data if (typeof data !== 'object' || data === null) { throw new Error('저장할 데이터가 올바르지 않습니다.') } const json = JSON.stringify(data) if (json.length > DRAFT_MAX_CHARS) { throw new Error('임시저장 데이터가 너무 큽니다.') } const saved = savePlaylistDraft(folder.id, json) res.json({ ok: true, updatedAt: saved.updatedAt }) } catch (err) { res.status(400).json({ ok: false, message: (err as Error).message }) } } ) opRouter.post( '/op/folder/:topName/:subName/playlist/draft/delete', requireAuth, (req, res) => { try { const folder = requireSubFolder(req) deletePlaylistDraft(folder.id) res.json({ ok: true }) } catch (err) { res.status(400).json({ ok: false, message: (err as Error).message }) } } ) // ─── 영상 업로드 / 유튜브 ─────────────────────────────────────────────── 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 || '업로드 실패' }) }) } } opRouter.post( ['/op/folder/:topName/video/upload', '/op/folder/:topName/:subName/video/upload'], requireAuth, uploadSingle('file'), async (req, res) => { try { 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 destName = `original${ext}` const video = await createVideo({ folderId: folder.id, title, originalFile: destName, sourceType: 'upload' }) await moveUploadIntoVideoDir(folder.id, 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 }) } } ) 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 }) } } ) 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 }) } } ) /** 플레이리스트 URL 을 받아 항목 목록을 돌려준다. (등록은 별도 start 호출) */ opRouter.post( ['/op/folder/:topName/:subName/playlist/probe'], requireAuth, async (req, res) => { try { const folder = getFolderByPath(collectFolderSegments(req.params)) if (!folder) throw new Error('폴더를 찾을 수 없습니다.') if (folder.parentId === null) { throw new Error('플레이리스트는 하위 폴더에서만 등록할 수 있습니다.') } const url = pickStr(req.body.url).trim() if (!url) throw new Error('플레이리스트 URL 을 입력해 주세요.') const result = await probeYoutubePlaylist(url) res.json({ ok: true, entries: result.entries }) } 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 }) } } ) /** * UI 가 정리한 항목 목록을 받아 영상 row 를 만들고 다운로드 잡을 큐잉. * body.entries: [{ url, title, videoId }] * - videoId 는 클라이언트가 랜덤/시퀀셜 토글 결과로 이미 확정한 값 * - 형식/요청 내 중복/DB 충돌은 서버에서도 다시 검증 (정합성 + 동시성) */ opRouter.post( ['/op/folder/:topName/:subName/playlist/start'], requireAuth, async (req, res) => { try { const folder = getFolderByPath(collectFolderSegments(req.params)) if (!folder) throw new Error('폴더를 찾을 수 없습니다.') if (folder.parentId === null) { throw new Error('플레이리스트는 하위 폴더에서만 등록할 수 있습니다.') } // yt-dlp 가 아예 없으면 항목을 만들지조차 못하므로 사전에 던진다. // (단일 항목 시작 endpoint 와 동일한 정책) // startYoutubeDownload 내부에서도 호출하지만, 일괄 등록 중간 실패를 막기 위해 // 먼저 한 번 확인한다. // 결과는 무시 (path 캐싱 효과). // — YtDlpUnavailableError 가 던져지면 아래 catch 에서 503 으로 응답. // ts: getYtDlpPath 는 youtube.ts 에서 내부 함수라 import 하지 않고 // startYoutubeDownload 첫 호출 시점에 자연스럽게 노출됨. const rawEntries = Array.isArray(req.body.entries) ? req.body.entries : [] if (rawEntries.length === 0) { throw new Error('등록할 항목이 없습니다.') } type Cleaned = { url: string title: string videoId: string startSec: number endSec: number | null } const cleaned: Cleaned[] = [] const seen = new Set() for (let i = 0; i < rawEntries.length; i += 1) { const e = rawEntries[i] as Record const url = pickStr(e?.url).trim() const title = pickStr(e?.title).trim() const videoId = pickStr(e?.videoId).trim() if (!url) throw new Error(`항목 ${i + 1}: URL 이 비어 있습니다.`) if (!title) throw new Error(`항목 ${i + 1}: 제목이 비어 있습니다.`) if (!videoId) throw new Error(`항목 ${i + 1}: 영상 ID 가 비어 있습니다.`) if (!isSafeVideoId(videoId)) { throw new Error(`항목 ${i + 1}: 영상 ID 형식이 올바르지 않습니다 (영문/숫자/_/- 1~64자).`) } if (seen.has(videoId)) { throw new Error(`항목 ${i + 1}: 영상 ID "${videoId}" 가 요청 내에서 중복됩니다.`) } if (videoExistsInFolder(folder.id, videoId)) { throw new Error(`항목 ${i + 1}: 영상 ID "${videoId}" 가 이미 사용 중입니다.`) } // 자르기 구간(선택). 잘못된 값은 무시하고 전체로 처리. const startRaw = Number(e?.startSec) const startSec = Number.isFinite(startRaw) && startRaw > 0 ? startRaw : 0 const endRaw = Number(e?.endSec) const endSec = Number.isFinite(endRaw) && endRaw > startSec ? endRaw : null seen.add(videoId) cleaned.push({ url, title, videoId, startSec, endSec }) } // 검증 통과 → 순차적으로 잡 시작. createVideo 의 unique 제약이 마지막 안전망. const jobs: { jobId: string; videoId: string }[] = [] for (const entry of cleaned) { const job = await startYoutubeDownload({ folderId: folder.id, url: entry.url, title: entry.title, videoId: entry.videoId, startSec: entry.startSec, endSec: entry.endSec }) jobs.push({ jobId: job.id, videoId: job.videoId }) } res.json({ ok: true, jobs }) } 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) => { // 폴링 응답이 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) if (!job) { res.status(404).json({ ok: false, message: '작업을 찾을 수 없습니다.' }) return } res.json({ ok: true, job }) }) // ─── 영상 mutation (folderId + id 기반 — ID 는 폴더 안에서만 유일) ─────── /** POST body 의 folderId 를 검증해 폴더를 돌려준다. 없거나 못 찾으면 throw. */ function folderFromBody(req: Request): { id: number } { const fid = pickIntId(req.body?.folderId) if (fid === null) throw new Error('폴더 ID 가 올바르지 않습니다.') const folder = getFolder(fid) if (!folder) throw new Error('폴더를 찾을 수 없습니다.') return folder } opRouter.post('/op/videos/:id/rename', requireAuth, (req, res) => { try { const folder = folderFromBody(req) const id = req.params.id const title = pickStr(req.body.title).trim() if (!title) throw new Error('제목을 입력해 주세요.') if (!getVideoInFolder(folder.id, id)) throw new Error('영상을 찾을 수 없습니다.') updateVideo(folder.id, 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 folder = folderFromBody(req) const oldId = req.params.id const newId = pickStr(req.body.newId).trim() if (!newId) throw new Error('새 ID 를 입력해 주세요.') const video = await changeVideoId(folder.id, 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() : '' const folderId = pickIntId(req.query.folderId) if (!id) { res.json({ ok: true, available: false, reason: '비어 있음' }) return } if (!isSafeVideoId(id)) { res.json({ ok: true, available: false, reason: '형식: 영문/숫자/_/- (1~64자)' }) return } if (folderId === null) { res.json({ ok: true, available: false, reason: '폴더 정보 없음' }) return } const available = !videoExistsInFolder(folderId, id) res.json({ ok: true, available }) }) opRouter.post('/op/videos/:id/delete', requireAuth, async (req, res) => { try { const folder = folderFromBody(req) await deleteVideo(folder.id, 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 folder = folderFromBody(req) const id = req.params.id const video = getVideoInFolder(folder.id, 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 const endSec = endRaw === null || endRaw === undefined || endRaw === '' ? null : Number(endRaw) const trim = { startSec, endSec: endSec == null || Number.isNaN(endSec) ? null : endSec } updateVideo(folder.id, id, { trim, ...(title ? { title } : {}) }) // ffmpeg 가 없으면 trim 정보만 저장하고 안내. try { const outName = await applyTrimToVideo(folder.id, id, 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 }) } }) // ─── 헬퍼 ───────────────────────────────────────────────────────────── /** 폴더 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 = getVideoByIdGlobal(videoId) if (!v) return null const names = folderPathNames(v.folderId) return '/video/' + [...names, videoId].map((s) => encodeURIComponent(s)).join('/') }