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:
70
src/app.ts
Normal file
70
src/app.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import express from 'express'
|
||||
import session from 'express-session'
|
||||
import path from 'node:path'
|
||||
import fsp from 'node:fs/promises'
|
||||
import { dataDir, foldersDir, jobsDir, tmpDir, publicDir, viewsDir } from './paths.js'
|
||||
import { publicRouter } from './routes/public.js'
|
||||
import { opRouter } from './routes/op.js'
|
||||
|
||||
async function ensureDirs(): Promise<void> {
|
||||
for (const dir of [dataDir, foldersDir, jobsDir, tmpDir]) {
|
||||
await fsp.mkdir(dir, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
await ensureDirs()
|
||||
|
||||
const PORT = Number(process.env.PORT ?? 3000)
|
||||
const HOST = process.env.HOST ?? '127.0.0.1'
|
||||
|
||||
const app = express()
|
||||
app.set('view engine', 'ejs')
|
||||
app.set('views', viewsDir)
|
||||
app.set('trust proxy', 1)
|
||||
|
||||
app.use(express.urlencoded({ extended: true }))
|
||||
app.use(express.json({ limit: '4mb' }))
|
||||
|
||||
app.use(session({
|
||||
secret: process.env.SESSION_SECRET ?? 'make-video-site-dev-secret',
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
cookie: {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
maxAge: 1000 * 60 * 60 * 8
|
||||
}
|
||||
}))
|
||||
|
||||
// account.json 직접 노출 차단
|
||||
app.use((req, res, next) => {
|
||||
if (/^\/account\.json/i.test(req.path)) {
|
||||
res.status(404).send('Not Found')
|
||||
return
|
||||
}
|
||||
next()
|
||||
})
|
||||
|
||||
app.use('/static', express.static(publicDir))
|
||||
|
||||
app.use('/', publicRouter)
|
||||
app.use('/', opRouter)
|
||||
|
||||
app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||
console.error(err)
|
||||
const message = err instanceof Error ? err.message : '알 수 없는 오류'
|
||||
if (res.headersSent) return
|
||||
res.status(500).send(`서버 오류: ${message}`)
|
||||
})
|
||||
|
||||
app.listen(PORT, HOST, () => {
|
||||
console.log(`[server] http://${HOST}:${PORT}`)
|
||||
console.log(`[server] views: ${path.relative(process.cwd(), viewsDir)}`)
|
||||
})
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
19
src/auth.ts
Normal file
19
src/auth.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { Request, Response, NextFunction } from 'express'
|
||||
|
||||
declare module 'express-session' {
|
||||
interface SessionData {
|
||||
userId?: string
|
||||
}
|
||||
}
|
||||
|
||||
export function requireAuth(req: Request, res: Response, next: NextFunction): void {
|
||||
if (req.session?.userId) {
|
||||
next()
|
||||
return
|
||||
}
|
||||
if (req.method === 'GET') {
|
||||
res.redirect('/op')
|
||||
return
|
||||
}
|
||||
res.status(401).json({ ok: false, message: '인증이 필요합니다.' })
|
||||
}
|
||||
93
src/editor.ts
Normal file
93
src/editor.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { spawn, spawnSync } from 'node:child_process'
|
||||
import { promises as fs } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { videoDir, loadVideoMeta, saveVideoMeta, type VideoTrim } from './store.js'
|
||||
|
||||
export class FfmpegUnavailableError extends Error {
|
||||
constructor() {
|
||||
super('ffmpeg 가 설치되어 있지 않습니다. ffmpeg 를 PATH 에 설치한 뒤 다시 시도해 주세요.')
|
||||
}
|
||||
}
|
||||
|
||||
let resolvedFfmpegPath: string | null = null
|
||||
|
||||
export function getFfmpegPath(): string {
|
||||
if (resolvedFfmpegPath) return resolvedFfmpegPath
|
||||
const r = spawnSync('ffmpeg', ['-version'])
|
||||
if (r.status === 0) {
|
||||
resolvedFfmpegPath = 'ffmpeg'
|
||||
return 'ffmpeg'
|
||||
}
|
||||
throw new FfmpegUnavailableError()
|
||||
}
|
||||
|
||||
/**
|
||||
* 원본 파일을 그대로 둔 채 trim 결과를 edited.<ext> 로 저장한다.
|
||||
* stream copy 를 우선 시도해 빠르게 자르고, 실패하면 재인코딩.
|
||||
*/
|
||||
export async function applyTrimToVideo(
|
||||
folder: string,
|
||||
videoId: string,
|
||||
trim: VideoTrim
|
||||
): Promise<string> {
|
||||
const bin = getFfmpegPath()
|
||||
const meta = await loadVideoMeta(folder, videoId)
|
||||
if (!meta) throw new Error('비디오를 찾을 수 없습니다.')
|
||||
const dir = videoDir(folder, videoId)
|
||||
const inputPath = path.join(dir, meta.originalFile)
|
||||
await fs.access(inputPath)
|
||||
|
||||
const ext = path.extname(meta.originalFile) || '.mp4'
|
||||
const outName = `edited${ext}`
|
||||
const outPath = path.join(dir, outName)
|
||||
const tmpPath = outPath + '.tmp' + ext
|
||||
|
||||
const startSec = Math.max(0, Number(trim.startSec) || 0)
|
||||
const endSec = trim.endSec == null ? null : Math.max(startSec, Number(trim.endSec))
|
||||
|
||||
const baseArgs = ['-y', '-ss', String(startSec)]
|
||||
if (endSec !== null) baseArgs.push('-to', String(endSec))
|
||||
baseArgs.push('-i', inputPath)
|
||||
|
||||
// 시도 1: stream copy (빠름)
|
||||
const copyArgs = [...baseArgs, '-c', 'copy', '-movflags', '+faststart', tmpPath]
|
||||
let ok = await runFfmpeg(bin, copyArgs)
|
||||
if (!ok) {
|
||||
// 시도 2: 재인코딩
|
||||
const encArgs = [
|
||||
...baseArgs,
|
||||
'-c:v', 'libx264', '-preset', 'veryfast', '-crf', '23',
|
||||
'-c:a', 'aac', '-b:a', '128k',
|
||||
'-movflags', '+faststart',
|
||||
tmpPath
|
||||
]
|
||||
ok = await runFfmpeg(bin, encArgs)
|
||||
if (!ok) throw new Error('ffmpeg trim 실패')
|
||||
}
|
||||
await fs.rename(tmpPath, outPath)
|
||||
|
||||
meta.editedFile = outName
|
||||
meta.trim = { startSec, endSec }
|
||||
await saveVideoMeta(folder, meta)
|
||||
return outName
|
||||
}
|
||||
|
||||
function runFfmpeg(bin: string, args: string[]): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(bin, args)
|
||||
let stderr = ''
|
||||
child.stderr.on('data', (c) => {
|
||||
stderr = (stderr + c.toString()).slice(-2000)
|
||||
})
|
||||
child.on('error', () => resolve(false))
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve(true)
|
||||
} else {
|
||||
// 디버그용 stderr 만 콘솔로
|
||||
console.error('[ffmpeg] failed:', stderr.split('\n').slice(-5).join('\n'))
|
||||
resolve(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
19
src/paths.ts
Normal file
19
src/paths.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
// CommonJS/ESM 둘 다 호환되도록 import.meta 가 있을 때만 사용.
|
||||
// tsc(NodeNext) + .js 임포트로 dist 가 ESM 으로 출력되므로 import.meta.url 이 살아있다.
|
||||
const here = fileURLToPath(import.meta.url)
|
||||
const hereDir = path.dirname(here)
|
||||
|
||||
// dist/ 또는 src/ 에서 실행되어도 동일하게 프로젝트 루트를 가리키도록 한다.
|
||||
export const projectRoot = path.resolve(hereDir, '..')
|
||||
|
||||
export const dataDir = path.join(projectRoot, 'data')
|
||||
export const foldersDir = path.join(dataDir, 'folders')
|
||||
export const jobsDir = path.join(dataDir, 'jobs')
|
||||
export const tmpDir = path.join(dataDir, 'tmp')
|
||||
|
||||
export const viewsDir = path.join(projectRoot, 'views')
|
||||
export const publicDir = path.join(projectRoot, 'public')
|
||||
export const accountJsonPath = path.join(projectRoot, 'account.json')
|
||||
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 })
|
||||
}
|
||||
})
|
||||
94
src/routes/public.ts
Normal file
94
src/routes/public.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { Router } 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'
|
||||
|
||||
export const publicRouter = Router()
|
||||
|
||||
publicRouter.get('/', async (_req, res, next) => {
|
||||
try {
|
||||
const folders = await listFolders()
|
||||
res.render('index', { folders })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
publicRouter.get('/folder/:name', 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('folder', { folder: safe, videos, isAdmin: false })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
publicRouter.get('/player/:videoId', async (req, res, next) => {
|
||||
try {
|
||||
const found = await findVideoAnywhere(req.params.videoId)
|
||||
if (!found) {
|
||||
res.status(404).send('영상을 찾을 수 없습니다.')
|
||||
return
|
||||
}
|
||||
res.render('player', { folder: found.folder, video: found.meta })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
/** 영상 파일 스트리밍. ?edited=1 이면 편집본을, 아니면 원본을 보낸다. */
|
||||
publicRouter.get('/api/video/:videoId/file', async (req, res, next) => {
|
||||
try {
|
||||
const found = await findVideoAnywhere(req.params.videoId)
|
||||
if (!found) {
|
||||
res.status(404).end()
|
||||
return
|
||||
}
|
||||
const wantEdited = req.query.edited === '1' || req.query.edited === 'true'
|
||||
const fileName =
|
||||
wantEdited && found.meta.editedFile ? found.meta.editedFile : found.meta.originalFile
|
||||
if (!fileName || fileName.includes('%(ext)s')) {
|
||||
res.status(404).end()
|
||||
return
|
||||
}
|
||||
const fsPath = videoFileFsPath(found.folder, found.meta.id, fileName)
|
||||
res.sendFile(fsPath)
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
/** 비디오 메타 조회 (플레이어/관리자 양쪽에서 사용) */
|
||||
publicRouter.get('/api/video/:videoId', async (req, res, next) => {
|
||||
try {
|
||||
const found = await findVideoAnywhere(req.params.videoId)
|
||||
if (!found) {
|
||||
res.status(404).json({ ok: false, message: '영상을 찾을 수 없습니다.' })
|
||||
return
|
||||
}
|
||||
res.json({ ok: true, folder: found.folder, video: found.meta })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
220
src/store.ts
Normal file
220
src/store.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
import { promises as fs, createReadStream, createWriteStream } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { foldersDir, accountJsonPath } from './paths.js'
|
||||
|
||||
export interface Account {
|
||||
id: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface VideoTrim {
|
||||
startSec: number
|
||||
endSec: number | null // null = until end
|
||||
}
|
||||
|
||||
export interface VideoMeta {
|
||||
id: string
|
||||
title: string
|
||||
originalFile: string // relative to <folder>/<id>/
|
||||
editedFile: string | null
|
||||
durationSec: number | null
|
||||
sourceType: 'upload' | 'youtube'
|
||||
sourceUrl: string | null
|
||||
trim: VideoTrim | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export async function readAccounts(): Promise<Account[]> {
|
||||
try {
|
||||
const raw = await fs.readFile(accountJsonPath, 'utf8')
|
||||
const parsed = JSON.parse(raw)
|
||||
if (!Array.isArray(parsed)) return []
|
||||
return parsed.filter(
|
||||
(entry): entry is Account =>
|
||||
typeof entry?.id === 'string' && typeof entry?.password === 'string'
|
||||
)
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return []
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// 폴더/영상 이름에 사용 가능한 문자만 허용. 경로 탈출 방지.
|
||||
const SAFE_NAME = /^[\p{L}\p{N}_\- ]+$/u
|
||||
|
||||
export function sanitizeFolderName(raw: string): string {
|
||||
const trimmed = (raw || '').trim()
|
||||
if (trimmed.length === 0 || trimmed.length > 80) return ''
|
||||
if (!SAFE_NAME.test(trimmed)) return ''
|
||||
if (trimmed === '.' || trimmed === '..') return ''
|
||||
return trimmed
|
||||
}
|
||||
|
||||
export function isSafeVideoId(id: string): boolean {
|
||||
return /^[a-zA-Z0-9_-]{8,64}$/.test(id)
|
||||
}
|
||||
|
||||
export async function ensureFoldersDir(): Promise<void> {
|
||||
await fs.mkdir(foldersDir, { recursive: true })
|
||||
}
|
||||
|
||||
export async function listFolders(): Promise<string[]> {
|
||||
await ensureFoldersDir()
|
||||
const entries = await fs.readdir(foldersDir, { withFileTypes: true })
|
||||
return entries
|
||||
.filter((e) => e.isDirectory())
|
||||
.map((e) => e.name)
|
||||
.filter((name) => sanitizeFolderName(name).length > 0)
|
||||
.sort((a, b) => a.localeCompare(b, 'ko'))
|
||||
}
|
||||
|
||||
export async function createFolder(name: string): Promise<string> {
|
||||
const safe = sanitizeFolderName(name)
|
||||
if (!safe) throw new Error('폴더 이름이 올바르지 않습니다.')
|
||||
const target = path.join(foldersDir, safe)
|
||||
await fs.mkdir(target, { recursive: false }).catch((err) => {
|
||||
if (err.code === 'EEXIST') throw new Error('이미 존재하는 폴더입니다.')
|
||||
throw err
|
||||
})
|
||||
return safe
|
||||
}
|
||||
|
||||
export async function renameFolder(oldName: string, newName: string): Promise<string> {
|
||||
const oldSafe = sanitizeFolderName(oldName)
|
||||
const newSafe = sanitizeFolderName(newName)
|
||||
if (!oldSafe || !newSafe) throw new Error('폴더 이름이 올바르지 않습니다.')
|
||||
if (oldSafe === newSafe) return oldSafe
|
||||
const from = path.join(foldersDir, oldSafe)
|
||||
const to = path.join(foldersDir, newSafe)
|
||||
try {
|
||||
await fs.access(to)
|
||||
throw new Error('이미 존재하는 폴더입니다.')
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err
|
||||
}
|
||||
await fs.rename(from, to)
|
||||
return newSafe
|
||||
}
|
||||
|
||||
export async function deleteFolder(name: string): Promise<void> {
|
||||
const safe = sanitizeFolderName(name)
|
||||
if (!safe) throw new Error('폴더 이름이 올바르지 않습니다.')
|
||||
const target = path.join(foldersDir, safe)
|
||||
await fs.rm(target, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
export function folderPath(name: string): string {
|
||||
const safe = sanitizeFolderName(name)
|
||||
if (!safe) throw new Error('폴더 이름이 올바르지 않습니다.')
|
||||
return path.join(foldersDir, safe)
|
||||
}
|
||||
|
||||
export function videoDir(folder: string, videoId: string): string {
|
||||
if (!isSafeVideoId(videoId)) throw new Error('비디오 ID가 올바르지 않습니다.')
|
||||
return path.join(folderPath(folder), videoId)
|
||||
}
|
||||
|
||||
export function videoMetaPath(folder: string, videoId: string): string {
|
||||
return path.join(videoDir(folder, videoId), 'meta.json')
|
||||
}
|
||||
|
||||
export async function listVideos(folder: string): Promise<VideoMeta[]> {
|
||||
const dir = folderPath(folder)
|
||||
try {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true })
|
||||
const metas: VideoMeta[] = []
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue
|
||||
if (!isSafeVideoId(entry.name)) continue
|
||||
const meta = await loadVideoMeta(folder, entry.name).catch(() => null)
|
||||
if (meta) metas.push(meta)
|
||||
}
|
||||
metas.sort((a, b) => (b.createdAt < a.createdAt ? -1 : 1))
|
||||
return metas
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return []
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadVideoMeta(folder: string, videoId: string): Promise<VideoMeta | null> {
|
||||
try {
|
||||
const raw = await fs.readFile(videoMetaPath(folder, videoId), 'utf8')
|
||||
return JSON.parse(raw) as VideoMeta
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
export async function findVideoAnywhere(
|
||||
videoId: string
|
||||
): Promise<{ folder: string; meta: VideoMeta } | null> {
|
||||
if (!isSafeVideoId(videoId)) return null
|
||||
const folders = await listFolders()
|
||||
for (const folder of folders) {
|
||||
const meta = await loadVideoMeta(folder, videoId).catch(() => null)
|
||||
if (meta) return { folder, meta }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export async function saveVideoMeta(folder: string, meta: VideoMeta): Promise<void> {
|
||||
const dir = videoDir(folder, meta.id)
|
||||
await fs.mkdir(dir, { recursive: true })
|
||||
meta.updatedAt = new Date().toISOString()
|
||||
await fs.writeFile(videoMetaPath(folder, meta.id), JSON.stringify(meta, null, 2))
|
||||
}
|
||||
|
||||
export async function deleteVideo(folder: string, videoId: string): Promise<void> {
|
||||
const dir = videoDir(folder, videoId)
|
||||
await fs.rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
export function newVideoId(): string {
|
||||
// URL-safe 22-char id (uuid 압축).
|
||||
return randomUUID().replace(/-/g, '').slice(0, 24)
|
||||
}
|
||||
|
||||
export function videoFileFsPath(folder: string, videoId: string, rel: string): string {
|
||||
// rel 은 meta 에 저장된 파일명. 경로 탈출 방지.
|
||||
if (rel.includes('/') || rel.includes('\\') || rel.includes('..')) {
|
||||
throw new Error('잘못된 파일 경로입니다.')
|
||||
}
|
||||
return path.join(videoDir(folder, videoId), rel)
|
||||
}
|
||||
|
||||
/** 업로드 임시 파일을 비디오 디렉토리로 이동. */
|
||||
export async function moveUploadIntoVideo(
|
||||
folder: string,
|
||||
videoId: string,
|
||||
tmpFile: string,
|
||||
destName: string
|
||||
): Promise<void> {
|
||||
if (destName.includes('/') || destName.includes('\\') || destName.includes('..')) {
|
||||
throw new Error('잘못된 파일명입니다.')
|
||||
}
|
||||
const dir = videoDir(folder, videoId)
|
||||
await fs.mkdir(dir, { recursive: true })
|
||||
const target = path.join(dir, destName)
|
||||
try {
|
||||
await fs.rename(tmpFile, target)
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'EXDEV') {
|
||||
// 다른 디바이스에 있으면 copy + unlink
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const r = createReadStream(tmpFile)
|
||||
const w = createWriteStream(target)
|
||||
r.on('error', reject)
|
||||
w.on('error', reject)
|
||||
w.on('close', () => resolve())
|
||||
r.pipe(w)
|
||||
})
|
||||
await fs.unlink(tmpFile).catch(() => undefined)
|
||||
} else {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
243
src/youtube.ts
Normal file
243
src/youtube.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import { spawn, spawnSync } from 'node:child_process'
|
||||
import { promises as fs } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { jobsDir, projectRoot } from './paths.js'
|
||||
import {
|
||||
loadVideoMeta,
|
||||
newVideoId,
|
||||
saveVideoMeta,
|
||||
sanitizeFolderName,
|
||||
videoDir,
|
||||
type VideoMeta
|
||||
} from './store.js'
|
||||
|
||||
export class YtDlpUnavailableError extends Error {
|
||||
constructor(message?: string) {
|
||||
super(message || 'yt-dlp 가 설치되어 있지 않습니다. yt-dlp 를 PATH 에 설치한 뒤 다시 시도해 주세요.')
|
||||
}
|
||||
}
|
||||
|
||||
let resolvedYtDlpPath: string | null = null
|
||||
|
||||
export function getYtDlpPath(): string {
|
||||
if (resolvedYtDlpPath) return resolvedYtDlpPath
|
||||
// 1) 프로젝트 bin/yt-dlp(.exe)
|
||||
const localCandidates =
|
||||
process.platform === 'win32'
|
||||
? ['bin/yt-dlp.exe', 'bin/yt-dlp']
|
||||
: ['bin/yt-dlp']
|
||||
for (const rel of localCandidates) {
|
||||
const abs = path.join(projectRoot, rel)
|
||||
const r = spawnSync(abs, ['--version'])
|
||||
if (r.status === 0) {
|
||||
resolvedYtDlpPath = abs
|
||||
return abs
|
||||
}
|
||||
}
|
||||
// 2) PATH 에서 검색
|
||||
const r = spawnSync('yt-dlp', ['--version'])
|
||||
if (r.status === 0) {
|
||||
resolvedYtDlpPath = 'yt-dlp'
|
||||
return 'yt-dlp'
|
||||
}
|
||||
throw new YtDlpUnavailableError()
|
||||
}
|
||||
|
||||
export interface ProbeResult {
|
||||
title: string
|
||||
durationSec: number
|
||||
filesizeApprox: number | null
|
||||
/** 추정 다운로드 ETA (초). filesize_approx / 가정대역폭. 모를 때 null. */
|
||||
etaSec: number | null
|
||||
warnOver5min: boolean
|
||||
}
|
||||
|
||||
const ASSUMED_BPS = 5 * 1024 * 1024 // 5 MB/s 가정. 실측은 어렵지만 경고 트리거용으로만 사용.
|
||||
|
||||
export async function probeYoutube(url: string): Promise<ProbeResult> {
|
||||
const bin = getYtDlpPath()
|
||||
return new Promise<ProbeResult>((resolve, reject) => {
|
||||
const child = spawn(bin, [
|
||||
'--no-warnings',
|
||||
'--skip-download',
|
||||
'--print',
|
||||
'%(title)s\n%(duration)s\n%(filesize_approx)s'
|
||||
].concat([url]))
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
child.stdout.on('data', (chunk) => (stdout += chunk.toString()))
|
||||
child.stderr.on('data', (chunk) => (stderr += chunk.toString()))
|
||||
child.on('error', (err) => reject(err))
|
||||
child.on('close', (code) => {
|
||||
if (code !== 0) {
|
||||
reject(new Error(stderr.trim() || `yt-dlp probe 실패 (code=${code})`))
|
||||
return
|
||||
}
|
||||
const lines = stdout.trim().split('\n')
|
||||
const title = lines[0] || ''
|
||||
const durationSec = Number(lines[1]) || 0
|
||||
const sizeRaw = lines[2]
|
||||
const filesizeApprox =
|
||||
sizeRaw && sizeRaw !== 'NA' && Number.isFinite(Number(sizeRaw))
|
||||
? Number(sizeRaw)
|
||||
: null
|
||||
const etaSec = filesizeApprox ? Math.round(filesizeApprox / ASSUMED_BPS) : null
|
||||
const warnOver5min = (etaSec ?? 0) > 5 * 60 || durationSec > 60 * 60
|
||||
resolve({ title, durationSec, filesizeApprox, etaSec, warnOver5min })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ─── 백그라운드 다운로드 잡 ────────────────────────────────────────────────
|
||||
export type JobStatus = 'queued' | 'downloading' | 'done' | 'error'
|
||||
|
||||
export interface DownloadJob {
|
||||
id: string
|
||||
folder: string
|
||||
videoId: string
|
||||
url: string
|
||||
status: JobStatus
|
||||
progress: number // 0..100
|
||||
message: string
|
||||
startedAt: string
|
||||
finishedAt: string | null
|
||||
outputFile: string | null
|
||||
error: string | null
|
||||
}
|
||||
|
||||
const jobs = new Map<string, DownloadJob>()
|
||||
|
||||
async function persistJob(job: DownloadJob): Promise<void> {
|
||||
await fs.mkdir(jobsDir, { recursive: true })
|
||||
await fs.writeFile(path.join(jobsDir, `${job.id}.json`), JSON.stringify(job, null, 2))
|
||||
}
|
||||
|
||||
export function getJob(id: string): DownloadJob | null {
|
||||
return jobs.get(id) ?? null
|
||||
}
|
||||
|
||||
export function listActiveJobs(): DownloadJob[] {
|
||||
return Array.from(jobs.values()).filter((j) => j.status === 'queued' || j.status === 'downloading')
|
||||
}
|
||||
|
||||
export interface StartDownloadOpts {
|
||||
folder: string
|
||||
url: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
/** 백그라운드 yt-dlp 다운로드를 시작. videoId 도 함께 생성/저장한다. */
|
||||
export async function startYoutubeDownload(opts: StartDownloadOpts): Promise<DownloadJob> {
|
||||
const safeFolder = sanitizeFolderName(opts.folder)
|
||||
if (!safeFolder) throw new Error('폴더 이름이 올바르지 않습니다.')
|
||||
const bin = getYtDlpPath() // 없으면 즉시 던짐
|
||||
const videoId = newVideoId()
|
||||
const dir = videoDir(safeFolder, videoId)
|
||||
await fs.mkdir(dir, { recursive: true })
|
||||
|
||||
const now = new Date().toISOString()
|
||||
const meta: VideoMeta = {
|
||||
id: videoId,
|
||||
title: opts.title?.trim() || '제목 없음',
|
||||
originalFile: 'original.%(ext)s', // 다운로드 완료 후 실제 확장자로 갱신
|
||||
editedFile: null,
|
||||
durationSec: null,
|
||||
sourceType: 'youtube',
|
||||
sourceUrl: opts.url,
|
||||
trim: null,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
}
|
||||
await saveVideoMeta(safeFolder, meta)
|
||||
|
||||
const job: DownloadJob = {
|
||||
id: newVideoId(),
|
||||
folder: safeFolder,
|
||||
videoId,
|
||||
url: opts.url,
|
||||
status: 'queued',
|
||||
progress: 0,
|
||||
message: '대기 중',
|
||||
startedAt: now,
|
||||
finishedAt: null,
|
||||
outputFile: null,
|
||||
error: null
|
||||
}
|
||||
jobs.set(job.id, job)
|
||||
void persistJob(job)
|
||||
setImmediate(() => runJob(job, bin).catch((err) => {
|
||||
job.status = 'error'
|
||||
job.error = err instanceof Error ? err.message : String(err)
|
||||
job.finishedAt = new Date().toISOString()
|
||||
void persistJob(job)
|
||||
}))
|
||||
return job
|
||||
}
|
||||
|
||||
async function runJob(job: DownloadJob, bin: string): Promise<void> {
|
||||
job.status = 'downloading'
|
||||
job.message = '다운로드 시작'
|
||||
await persistJob(job)
|
||||
|
||||
const dir = videoDir(job.folder, job.videoId)
|
||||
// yt-dlp 가 다운로드 후 결정한 실제 파일명을 알려주도록 --print after_move:filepath
|
||||
// 진행률은 --newline + --progress-template 으로 stdout 줄 단위 파싱.
|
||||
const args = [
|
||||
'--no-warnings',
|
||||
'--no-playlist',
|
||||
'--newline',
|
||||
'--progress-template', 'download:PROGRESS %(progress._percent_str)s',
|
||||
'--print', 'after_move:OUT %(filepath)s',
|
||||
'-o', path.join(dir, 'original.%(ext)s'),
|
||||
job.url
|
||||
]
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const child = spawn(bin, args)
|
||||
let outputFile: string | null = null
|
||||
let stderrTail = ''
|
||||
child.stdout.on('data', (chunk) => {
|
||||
const text = chunk.toString()
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const m = /PROGRESS\s+([\d.]+)%/.exec(line)
|
||||
if (m) {
|
||||
job.progress = Math.min(99, Math.round(Number(m[1])))
|
||||
job.message = `다운로드 ${job.progress}%`
|
||||
}
|
||||
const o = /^OUT\s+(.+)$/.exec(line.trim())
|
||||
if (o) outputFile = o[1].trim()
|
||||
}
|
||||
})
|
||||
child.stderr.on('data', (chunk) => {
|
||||
stderrTail = (stderrTail + chunk.toString()).slice(-2000)
|
||||
})
|
||||
child.on('error', (err) => reject(err))
|
||||
child.on('close', async (code) => {
|
||||
if (code !== 0) {
|
||||
reject(new Error(stderrTail.trim() || `yt-dlp 실패 (code=${code})`))
|
||||
return
|
||||
}
|
||||
// 실제 파일명 결정
|
||||
let finalName = 'original'
|
||||
if (outputFile) {
|
||||
finalName = path.basename(outputFile)
|
||||
} else {
|
||||
// fallback: 디렉토리 내 original.* 찾기
|
||||
const entries = await fs.readdir(dir).catch(() => [])
|
||||
const found = entries.find((n) => n.startsWith('original.'))
|
||||
if (found) finalName = found
|
||||
}
|
||||
const meta = await loadVideoMeta(job.folder, job.videoId)
|
||||
if (meta) {
|
||||
meta.originalFile = finalName
|
||||
await saveVideoMeta(job.folder, meta)
|
||||
}
|
||||
job.progress = 100
|
||||
job.status = 'done'
|
||||
job.message = '완료'
|
||||
job.outputFile = finalName
|
||||
job.finishedAt = new Date().toISOString()
|
||||
await persistJob(job)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user