465 lines
15 KiB
TypeScript
465 lines
15 KiB
TypeScript
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<typeof ReplaceVideoSchema>;
|
|
}>(
|
|
'/: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=<JWT>` 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' });
|
|
}
|
|
}
|
|
);
|
|
}
|