| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365 |
- import { Router, Request, Response } from 'express';
- import multer from 'multer';
- import path from 'path';
- import fs from 'fs';
- import { v4 as uuidv4 } from 'uuid';
- import { prisma, TranscodeStatus } from '../lib/prisma';
- import { authMiddleware } from '../lib/auth';
- import { startTranscodeJob } from '../worker/dispatcher';
- const router = Router();
- router.use(authMiddleware);
- const str = (v: string | string[] | undefined): string => Array.isArray(v) ? v[0] ?? '' : (v ?? '');
- // ── Multer ────────────────────────────────────────────────────────────────────
- const UPLOAD_DIR = process.env.UPLOAD_DIR || './uploads';
- const MAX_SIZE = (parseInt(process.env.MAX_FILE_SIZE_MB || '500') * 1024 * 1024);
- fs.mkdirSync(UPLOAD_DIR, { recursive: true });
- const storage = multer.diskStorage({
- destination: (_req, _file, cb) => cb(null, UPLOAD_DIR),
- filename: (_req, file, cb) => {
- const ext = path.extname(file.originalname);
- cb(null, `${uuidv4()}${ext}`);
- },
- });
- class MulterFileTypeError extends Error {
- code = 'ONLY_VIDEO';
- statusCode = 400;
- constructor() {
- super('Only video files are allowed');
- this.name = 'MulterFileTypeError';
- }
- }
- const ALLOWED_VIDEO_MIMETYPES = [
- 'video/mp4',
- 'video/quicktime', // MOV (ProRes, H.264, etc.)
- 'video/webm', // VP8, VP9, AV1
- 'video/x-msvideo', // AVI
- 'video/mpeg', // MPEG
- 'video/x-matroska', // MKV
- 'video/3gpp', // 3GP
- 'video/3gpp2', // 3G2
- 'video/ogg', // OGV (Theora)
- 'video/x-ms-wmv', // WMV
- 'video/mp2t', // TS
- ];
- const upload = multer({
- storage,
- limits: { fileSize: MAX_SIZE },
- fileFilter: (_req, file, cb) => {
- if (file.mimetype.startsWith('video/')) {
- cb(null, true);
- } else {
- cb(new MulterFileTypeError());
- }
- },
- });
- // GET /api/assets — list assets for a project
- router.get('/', async (req: Request, res: Response) => {
- try {
- const { projectId } = req.query;
- if (!projectId || typeof projectId !== 'string') {
- res.status(400).json({ error: 'projectId query param required' });
- return;
- }
- const isAdmin = req.user!.globalRole === 'ADMIN';
- // Verify user has access (admin bypass)
- if (!isAdmin) {
- const membership = await prisma.projectMember.findFirst({
- where: { projectId: projectId as string, userId: req.user!.userId },
- });
- if (!membership) {
- res.status(403).json({ error: 'Forbidden' });
- return;
- }
- }
- const assets = await prisma.asset.findMany({
- where: { projectId },
- include: {
- _count: { select: { comments: true } },
- },
- orderBy: { createdAt: 'desc' },
- });
- res.json({ assets });
- } catch (err) {
- console.error('List assets error:', err);
- res.status(500).json({ error: 'Internal server error' });
- }
- });
- // GET /api/assets/:id/status — lightweight polling endpoint for transcode progress
- router.get('/:id/status', async (req: Request, res: Response) => {
- try {
- const isAdmin = req.user!.globalRole === 'ADMIN';
- const asset = await prisma.asset.findFirst({
- where: {
- id: str(req.params.id),
- ...(isAdmin ? {} : { project: { members: { some: { userId: req.user!.userId } } } }),
- },
- select: {
- id: true,
- title: true,
- hlsPath: true,
- transcodeStatus: true,
- transcodeProgress: true,
- transcodeError: true,
- thumbnail: true,
- duration: true,
- codec: true,
- status: true,
- },
- });
- if (!asset) {
- res.status(404).json({ error: 'Asset not found' });
- return;
- }
- res.json({ asset });
- } catch (err) {
- console.error('Asset status error:', err);
- res.status(500).json({ error: 'Internal server error' });
- }
- });
- // GET /api/assets/:id
- router.get('/:id', async (req: Request, res: Response) => {
- try {
- const isAdmin = req.user!.globalRole === 'ADMIN';
- const asset = await prisma.asset.findFirst({
- where: {
- id: str(req.params.id),
- ...(isAdmin ? {} : { project: { members: { some: { userId: req.user!.userId } } } }),
- },
- include: {
- project: {
- include: {
- members: {
- include: { user: { select: { id: true, name: true, email: true, avatarUrl: true } } },
- },
- },
- },
- comments: {
- include: {
- user: { select: { id: true, name: true, email: true, avatarUrl: true } },
- resolvedBy: { select: { id: true, name: true, email: true, avatarUrl: true } },
- requestedBy: { select: { id: true, name: true, email: true, avatarUrl: true } },
- replies: {
- include: { user: { select: { id: true, name: true, email: true, avatarUrl: true } } },
- },
- },
- where: { parentId: null },
- orderBy: { timestamp: 'asc' },
- },
- },
- });
- if (!asset) {
- res.status(404).json({ error: 'Asset not found' });
- return;
- }
- res.json({ asset });
- } catch (err) {
- console.error('Get asset error:', err);
- res.status(500).json({ error: 'Internal server error' });
- }
- });
- // POST /api/assets/upload — upload video
- router.post('/upload', upload.single('video'), async (req: Request, res: Response) => {
- try {
- if (!req.file) {
- res.status(400).json({ error: 'No video file provided' });
- return;
- }
- const { projectId, title } = req.body;
- if (!projectId) {
- res.status(400).json({ error: 'projectId is required' });
- return;
- }
- // Verify user has access
- const membership = await prisma.projectMember.findFirst({
- where: { projectId, userId: req.user!.userId },
- });
- if (!membership || !['ADMIN', 'EDITOR'].includes(membership.role)) {
- fs.unlinkSync(req.file.path);
- res.status(403).json({ error: 'Forbidden — must be admin or editor' });
- return;
- }
- const assetTitle = title || path.parse(req.file.originalname).name;
- // Create asset immediately with PROCESSING status — worker fills in the rest
- const asset = await prisma.asset.create({
- data: {
- projectId,
- title: assetTitle,
- filename: req.file.filename,
- filePath: req.file.filename,
- mimeType: req.file.mimetype,
- transcodeStatus: TranscodeStatus.PENDING,
- transcodeProgress: 0,
- },
- });
- // Fork worker (non-blocking) — no await, runs in background
- startTranscodeJob({
- assetId: asset.id,
- videoPath: req.file.path,
- outputDir: UPLOAD_DIR,
- });
- res.status(201).json({ asset });
- } catch (err) {
- console.error('Upload error:', err);
- res.status(500).json({ error: 'Internal server error' });
- }
- });
- // PUT /api/assets/:id/status — update approval status
- router.put('/:id/status', async (req: Request, res: Response) => {
- try {
- const { status } = req.body;
- const validStatuses = ['PENDING_REVIEW', 'CHANGES_REQUESTED', 'APPROVED', 'REJECTED'];
- if (!validStatuses.includes(status)) {
- res.status(400).json({ error: 'Invalid status' });
- return;
- }
- const isAdmin = req.user!.globalRole === 'ADMIN';
- const asset = await prisma.asset.findFirst({
- where: {
- id: str(req.params.id),
- ...(isAdmin ? {} : { project: { members: { some: { userId: req.user!.userId } } } }),
- },
- });
- if (!asset) {
- res.status(404).json({ error: 'Asset not found' });
- return;
- }
- const updated = await prisma.asset.update({
- where: { id: str(req.params.id) },
- data: { status: status as any },
- });
- res.json({ asset: updated });
- } catch (err) {
- console.error('Update status error:', err);
- res.status(500).json({ error: 'Internal server error' });
- }
- });
- // POST /api/assets/:id/transcode/cancel — stop/restart a transcode job
- router.post('/:id/transcode/cancel', async (req: Request, res: Response) => {
- try {
- const isAdmin = req.user!.globalRole === 'ADMIN';
- const asset = await prisma.asset.findFirst({
- where: {
- id: str(req.params.id),
- ...(isAdmin ? {} : { project: { members: { some: { userId: req.user!.userId } } } }),
- },
- });
- if (!asset) {
- res.status(404).json({ error: 'Asset not found' });
- return;
- }
- if (asset.transcodeStatus === 'COMPLETED') {
- res.status(400).json({ error: 'Cannot cancel a completed transcode job' });
- return;
- }
- // Reset to PENDING — worker will pick it up again on next poll
- // Clean up any partially-written HLS directory
- if (asset.hlsPath) {
- const hlsDir = path.join(UPLOAD_DIR, 'hls', asset.id);
- if (fs.existsSync(hlsDir)) {
- fs.rmSync(hlsDir, { recursive: true, force: true });
- }
- }
- const updated = await prisma.asset.update({
- where: { id: str(req.params.id) },
- data: {
- transcodeStatus: TranscodeStatus.PENDING,
- transcodeProgress: 0,
- transcodeError: null,
- hlsPath: null,
- },
- });
- res.json({ asset: updated });
- } catch (err) {
- console.error('Cancel transcode error:', err);
- res.status(500).json({ error: 'Internal server error' });
- }
- });
- // DELETE /api/assets/:id
- router.delete('/:id', async (req: Request, res: Response) => {
- try {
- const isAdmin = req.user!.globalRole === 'ADMIN';
- const asset = await prisma.asset.findFirst({
- where: {
- id: str(req.params.id),
- ...(isAdmin ? {} : { project: { members: { some: { userId: req.user!.userId, role: { in: ['ADMIN', 'EDITOR'] } } } } }),
- },
- });
- if (!asset) {
- res.status(404).json({ error: 'Asset not found' });
- return;
- }
- // Delete from disk
- const fullPath = path.join(UPLOAD_DIR, asset.filePath);
- if (fs.existsSync(fullPath)) {
- fs.unlinkSync(fullPath);
- }
- if (asset.thumbnail) {
- const thumbPath = path.join(UPLOAD_DIR, asset.thumbnail);
- if (fs.existsSync(thumbPath)) {
- fs.unlinkSync(thumbPath);
- }
- }
- // Delete HLS directory (all segments + playlist)
- if (asset.hlsPath) {
- const hlsDir = path.join(UPLOAD_DIR, 'hls', asset.id);
- if (fs.existsSync(hlsDir)) {
- fs.rmSync(hlsDir, { recursive: true, force: true });
- }
- }
- await prisma.asset.delete({ where: { id: str(req.params.id) } });
- res.json({ message: 'Asset deleted' });
- } catch (err) {
- console.error('Delete asset error:', err);
- res.status(500).json({ error: 'Internal server error' });
- }
- });
- export default router;
|