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(); // ── CORS preflight (must be before authMiddleware — OPTIONS carries no auth token) router.options('/', (_req, res) => { res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,PATCH,OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Authorization,Content-Type,X-Requested-With'); res.sendStatus(200); }); router.options('/upload', (_req, res) => { res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,PATCH,OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Authorization,Content-Type,X-Requested-With'); res.sendStatus(200); }); 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 (paginated) 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 page = Math.max(1, parseInt(req.query.page as string) || 1); const limit = Math.min(100, Math.max(1, parseInt(req.query.limit as string) || 20)); const skip = (page - 1) * limit; const search = typeof req.query.search === 'string' ? req.query.search.trim() : ''; const status = typeof req.query.status === 'string' ? req.query.status.trim() : ''; const where: Record = { projectId }; if (search) where.title = { contains: search, mode: 'insensitive' }; if (status) where.status = status; const [assets, total] = await Promise.all([ prisma.asset.findMany({ where, skip, take: limit, include: { uploader: { select: { id: true, name: true, email: true, avatarUrl: true } }, _count: { select: { comments: { where: { deleted: false } } } }, shareLinks: { select: { id: true }, take: 1 }, }, orderBy: { createdAt: 'desc' }, }), prisma.asset.count({ where }), ]); res.json({ assets, total, page, limit, totalPages: Math.ceil(total / limit) }); } 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, transcodePaused: 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: { uploader: { select: { id: true, name: true, email: true, avatarUrl: true } }, 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, folderId } = 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; } // ── Quota check ────────────────────────────────────────────────────────── const uploader = await prisma.user.findUnique({ where: { id: req.user!.userId } }); if (!uploader) { fs.unlinkSync(req.file.path); res.status(401).json({ error: 'User not found' }); return; } const fileSize = BigInt(req.file.size); if (uploader.storageUsed + fileSize > uploader.storageQuota) { fs.unlinkSync(req.file.path); const usedMB = (Number(uploader.storageUsed) / 1024 / 1024).toFixed(1); const quotaMB = (Number(uploader.storageQuota) / 1024 / 1024).toFixed(1); const fileMB = (Number(fileSize) / 1024 / 1024).toFixed(1); res.status(507).json({ error: `Storage quota exceeded. Used: ${usedMB} MB / ${quotaMB} MB. File size: ${fileMB} MB.`, }); 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, uploaderId: req.user!.userId, title: assetTitle, filename: req.file.filename, originalFilename: req.file.originalname, filePath: req.file.filename, mimeType: req.file.mimetype, fileSize, transcodeStatus: TranscodeStatus.PENDING, transcodeProgress: 0, }, }); // Increment storageUsed (add raw video file size) await prisma.user.update({ where: { id: req.user!.userId }, data: { storageUsed: { increment: fileSize } }, }).catch(() => { /* user may have been deleted, ignore */ }); // Link asset to folder if provided if (folderId && typeof folderId === 'string') { await prisma.folderAsset.upsert({ where: { folderId_assetId: { folderId, assetId: asset.id } }, create: { folderId, assetId: asset.id }, update: {}, }).catch(() => { /* folder may not exist, ignore */ }); } // 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' }); } }); // POST /api/assets/:id/transcode/pause — pause a running transcode job router.post('/:id/transcode/pause', 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 (!['PENDING', 'UPLOADING', 'PROCESSING'].includes(asset.transcodeStatus)) { res.status(400).json({ error: 'Cannot pause a completed or failed transcode' }); return; } const updated = await prisma.asset.update({ where: { id: str(req.params.id) }, data: { transcodePaused: true }, }); res.json({ asset: updated }); } catch (err) { console.error('Pause transcode error:', err); res.status(500).json({ error: 'Internal server error' }); } }); // POST /api/assets/:id/transcode/resume — resume a paused transcode job router.post('/:id/transcode/resume', 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.transcodePaused) { res.status(400).json({ error: 'Transcode is not paused' }); return; } const updated = await prisma.asset.update({ where: { id: str(req.params.id) }, data: { transcodePaused: false }, }); res.json({ asset: updated }); } catch (err) { console.error('Resume 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 }); } } // Decrement uploader's storageUsed if (BigInt(asset.fileSize) > BigInt(0) && asset.uploaderId) { await prisma.user.update({ where: { id: asset.uploaderId }, data: { storageUsed: { decrement: asset.fileSize } }, }).catch(() => { /* user may have been deleted or no uploader, ignore */ }); } 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' }); } }); // POST /api/assets/admin/reprocess-all — admin-only: reset all PROCESSING jobs to PENDING router.post('/admin/reprocess-all', async (req: Request, res: Response) => { try { if (req.user!.globalRole !== 'ADMIN') { res.status(403).json({ error: 'Admin access required' }); return; } const stuck = await prisma.asset.findMany({ where: { transcodeStatus: 'PROCESSING', transcodePaused: false }, select: { id: true }, }); if (stuck.length === 0) { res.json({ message: 'No stuck jobs found', count: 0 }); return; } await prisma.asset.updateMany({ where: { id: { in: stuck.map(s => s.id) } }, data: { transcodeStatus: 'PENDING', transcodeProgress: 0 }, }); res.json({ message: `Reset ${stuck.length} stuck job(s) to PENDING`, count: stuck.length }); } catch (err) { console.error('Reprocess-all error:', err); res.status(500).json({ error: 'Internal server error' }); } }); export default router;