import { FastifyInstance } from 'fastify'; import { prisma } from '../../../config/database'; import { requireAdminRole } from '../middleware/auth'; import { videoAnalyticsService } from '../services/video-analytics.service'; import { logger } from '../../../utils/logger'; import { env } from '../../../config/env'; import { signMediaPath } from '../../../utils/signed-url'; import { copyFile } from 'fs/promises'; import { join, dirname, basename, extname, normalize, resolve } from 'path'; import { z } from 'zod'; const UpdateVideoSchema = z.object({ title: z.string().min(1).max(500).optional(), producer: z.string().max(200).nullable().optional(), creator: z.string().max(200).nullable().optional(), category: z.enum(['videos', 'curated', 'compilations', 'playback', 'highlights']).nullable().optional(), tags: z.array(z.string().max(100)).max(50).nullable().optional(), quality: z.string().max(50).nullable().optional(), position: z.number().int().min(0).nullable().optional(), isShort: z.boolean().optional(), accessLevel: z.enum(['free', 'member', 'premium']).optional(), }); export async function videoActionsRoutes(fastify: FastifyInstance) { /** * PATCH /videos/:id * Update video metadata */ fastify.patch<{ Params: { id: string } }>( '/:id', { preHandler: requireAdminRole, }, async (request, reply) => { const videoId = parseInt(request.params.id); const parseResult = UpdateVideoSchema.safeParse(request.body); if (!parseResult.success) { return reply.code(400).send({ message: 'Invalid input', errors: parseResult.error.errors }); } try { const video = await prisma.video.findUnique({ where: { id: videoId } }); if (!video) { return reply.code(404).send({ message: 'Video not found' }); } const data: any = {}; const updates = parseResult.data; if (updates.title !== undefined) data.title = updates.title; if (updates.producer !== undefined) data.producer = updates.producer; if (updates.creator !== undefined) data.creator = updates.creator; if (updates.category !== undefined) data.category = updates.category; if (updates.tags !== undefined) data.tags = updates.tags; if (updates.quality !== undefined) data.quality = updates.quality; if (updates.position !== undefined) data.position = updates.position; if (updates.isShort !== undefined) data.isShort = updates.isShort; if (updates.accessLevel !== undefined) data.accessLevel = updates.accessLevel; const updatedVideo = await prisma.video.update({ where: { id: videoId }, data, }); logger.info(`Updated video ${videoId} metadata`, { fields: Object.keys(data) }); return { success: true, video: { ...updatedVideo, duration: updatedVideo.durationSeconds, thumbnailUrl: updatedVideo.thumbnailPath ? `/media/videos/${updatedVideo.id}/thumbnail` : null, }, }; } catch (error) { logger.error('Failed to update video', { error, videoId }); return reply.code(500).send({ message: 'Failed to update video' }); } } ); /** * POST /videos/:id/duplicate * Duplicate a video with a new title */ fastify.post<{ Params: { id: string }; Body: { title?: string } }>( '/:id/duplicate', { preHandler: requireAdminRole, }, async (request, reply) => { const videoId = parseInt(request.params.id); const { title } = request.body || {}; try { const originalVideo = await prisma.video.findUnique({ where: { id: videoId }, }); if (!originalVideo) { return reply.code(404).send({ message: 'Video not found' }); } // Create duplicate with new title const duplicateTitle = title || `${originalVideo.title || originalVideo.filename} (Copy)`; const duplicate = await prisma.video.create({ data: { path: originalVideo.path, // Same file path filename: originalVideo.filename, producer: originalVideo.producer, creator: originalVideo.creator, title: duplicateTitle, durationSeconds: originalVideo.durationSeconds, quality: originalVideo.quality, orientation: originalVideo.orientation, hasAudio: originalVideo.hasAudio, fileSize: originalVideo.fileSize, fileHash: originalVideo.fileHash, width: originalVideo.width, height: originalVideo.height, thumbnailPath: originalVideo.thumbnailPath, tags: originalVideo.tags as any, directoryType: originalVideo.directoryType, category: originalVideo.category, uploaderId: originalVideo.uploaderId, }, }); logger.info(`Duplicated video ${videoId} to ${duplicate.id}`, { originalTitle: originalVideo.title, newTitle: duplicateTitle }); return { success: true, duplicate: { id: duplicate.id, title: duplicate.title, }, }; } catch (error) { logger.error('Failed to duplicate video', { error, videoId }); return reply.code(500).send({ message: 'Failed to duplicate video' }); } } ); /** * POST /videos/:id/replace * Replace video file while keeping metadata and URL * Note: This endpoint accepts a new file path - actual file upload should go through upload routes */ const ReplaceVideoSchema = z.object({ newPath: z.string().min(1).max(500), newFilename: z.string().min(1).max(255), durationSeconds: z.number().optional(), width: z.number().int().optional(), height: z.number().int().optional(), fileSize: z.number().optional(), }); fastify.post<{ Params: { id: string }; Body: z.infer; }>( '/:id/replace', { preHandler: requireAdminRole, }, async (request, reply) => { const videoId = parseInt(request.params.id); // Validate input with Zod const parseResult = ReplaceVideoSchema.safeParse(request.body); if (!parseResult.success) { return reply.code(400).send({ message: 'Invalid input' }); } const { newPath, newFilename, durationSeconds, width, height, fileSize } = parseResult.data; // Path traversal protection: resolve against allowed base and verify containment const MEDIA_BASE = '/media/local'; if (newPath.includes('\0') || newFilename.includes('\0')) { return reply.code(400).send({ message: 'Invalid file path' }); } const resolvedPath = resolve(MEDIA_BASE, newPath); if (!resolvedPath.startsWith(resolve(MEDIA_BASE))) { return reply.code(400).send({ message: 'Invalid file path: must be within media directory' }); } const sanitizedFilename = basename(newFilename); try { const existingVideo = await prisma.video.findUnique({ where: { id: videoId }, }); if (!existingVideo) { return reply.code(404).send({ message: 'Video not found' }); } // Update video with new file details const updatedVideo = await prisma.video.update({ where: { id: videoId }, data: { path: resolvedPath, filename: sanitizedFilename, originalPath: existingVideo.path, // Save old path for reference originalFilename: existingVideo.filename, durationSeconds: durationSeconds || existingVideo.durationSeconds, width: width || existingVideo.width, height: height || existingVideo.height, fileSize: fileSize ? BigInt(fileSize) : existingVideo.fileSize, lastValidated: new Date(), thumbnailPath: null, // Clear thumbnail, will be regenerated }, }); logger.info(`Replaced video file for ${videoId}`, { oldPath: existingVideo.path, newPath }); return { success: true, video: { id: updatedVideo.id, title: updatedVideo.title, filename: updatedVideo.filename, }, }; } catch (error) { logger.error('Failed to replace video', { error, videoId }); return reply.code(500).send({ message: 'Failed to replace video' }); } } ); /** * GET /videos/:id/analytics * Get detailed analytics for a video */ fastify.get<{ Params: { id: string }; Querystring: { startDate?: string; endDate?: string }; }>( '/:id/analytics', { preHandler: requireAdminRole, }, async (request, reply) => { const videoId = parseInt(request.params.id); const { startDate, endDate } = request.query; // Validate date parameters const parsedStart = startDate ? new Date(startDate) : undefined; const parsedEnd = endDate ? new Date(endDate) : undefined; if ((parsedStart && isNaN(parsedStart.getTime())) || (parsedEnd && isNaN(parsedEnd.getTime()))) { return reply.code(400).send({ message: 'Invalid date format for startDate or endDate' }); } try { const analytics = await videoAnalyticsService.getVideoAnalytics( videoId, parsedStart, parsedEnd, ); return analytics; } catch (error) { logger.error('Failed to get video analytics', { error, videoId }); if (error instanceof Error && error.message === 'Video not found') { return reply.code(404).send({ message: 'Video not found' }); } return reply.code(500).send({ message: 'Failed to fetch analytics' }); } } ); /** * POST /videos/:id/reset-analytics * Reset all analytics for a video */ fastify.post<{ Params: { id: string } }>( '/:id/reset-analytics', { preHandler: requireAdminRole, }, async (request, reply) => { const videoId = parseInt(request.params.id); try { await videoAnalyticsService.resetAnalytics(videoId); return { success: true, message: 'Analytics reset successfully', }; } catch (error) { logger.error('Failed to reset analytics', { error, videoId }); return reply.code(500).send({ message: 'Failed to reset analytics' }); } } ); /** * GET /videos/:id/preview-link * Generate a shareable, time-limited preview link. * * Uses path-bound HMAC signatures (sig/exp/uid) — same scheme as * POST /api/media/sign — instead of the legacy `?token=` form, * which leaked full session tokens via access logs/referer headers. * The signature carries only the admin's user-id and is bound to the * stream URL, so it can be safely shared with stakeholders. */ fastify.get<{ Params: { id: string } }>( '/:id/preview-link', { preHandler: requireAdminRole, }, async (request, reply) => { const videoId = parseInt(request.params.id); try { const video = await prisma.video.findUnique({ where: { id: videoId }, }); if (!video) { return reply.code(404).send({ message: 'Video not found' }); } const expiryHours = parseInt(process.env.VIDEO_PREVIEW_LINK_EXPIRY_HOURS || '24'); const ttlSeconds = expiryHours * 60 * 60; const userId = request.user!.id; const streamPath = `/api/videos/${videoId}/stream`; const signed = signMediaPath(streamPath, userId, ttlSeconds); const query = `sig=${signed.sig}&exp=${signed.exp}&uid=${signed.uid}`; const previewUrl = `${env.MEDIA_API_PUBLIC_URL}${streamPath}?${query}`; logger.info(`Generated preview link for video ${videoId}`, { expiresInHours: expiryHours }); return { previewUrl, expiresAt: new Date(Number(signed.exp) * 1000).toISOString(), expiryHours, }; } catch (error) { logger.error('Failed to generate preview link', { error, videoId }); return reply.code(500).send({ message: 'Failed to generate preview link' }); } } ); /** * GET /videos/analytics/top * Get top performing videos */ fastify.get<{ Querystring: { metric?: 'views' | 'watchTime'; limit?: string }; }>( '/analytics/top', { preHandler: requireAdminRole, }, async (request, reply) => { const metric = request.query.metric || 'views'; const limit = parseInt(request.query.limit || '10'); try { const topVideos = await videoAnalyticsService.getTopVideos(metric, limit); return { metric, videos: topVideos, }; } catch (error) { logger.error('Failed to get top videos', { error, metric }); return reply.code(500).send({ message: 'Failed to fetch top videos' }); } } ); /** * GET /videos/analytics/overview * Get global analytics overview across all videos */ fastify.get( '/analytics/overview', { preHandler: requireAdminRole, }, async (request, reply) => { try { const [totalVideos, totalViews, totalWatchTime, avgCompletionRate] = await Promise.all([ prisma.video.count(), prisma.video.aggregate({ _sum: { viewCount: true, }, }), prisma.video.aggregate({ _sum: { totalWatchTimeSeconds: true, }, }), prisma.video.aggregate({ _avg: { completionRate: true, }, }), ]); return { totalVideos, totalViews: totalViews._sum.viewCount || 0, totalWatchTimeSeconds: totalWatchTime._sum.totalWatchTimeSeconds || 0, averageCompletionRate: avgCompletionRate._avg.completionRate?.toNumber() || 0, }; } catch (error) { logger.error('Failed to get analytics overview', { error }); return reply.code(500).send({ message: 'Failed to fetch analytics overview' }); } } ); /** * POST /videos/bulk-access-level * Set access level on multiple videos at once */ fastify.post<{ Body: { videoIds: number[]; accessLevel: string }; }>( '/bulk-access-level', { preHandler: requireAdminRole, }, async (request, reply) => { const schema = z.object({ videoIds: z.array(z.number().int()).min(1).max(500), accessLevel: z.enum(['free', 'member', 'premium']), }); const parseResult = schema.safeParse(request.body); if (!parseResult.success) { return reply.code(400).send({ message: 'Invalid input', errors: parseResult.error.errors }); } const { videoIds, accessLevel } = parseResult.data; try { const result = await prisma.video.updateMany({ where: { id: { in: videoIds } }, data: { accessLevel }, }); logger.info(`Bulk updated access level to "${accessLevel}" for ${result.count} videos`, { videoIds }); return { success: true, updatedCount: result.count, }; } catch (error) { logger.error('Failed to bulk update access level', { error, videoIds }); return reply.code(500).send({ message: 'Failed to update access levels' }); } } ); }