Tonne of debugging - getting ready for the production builds

This commit is contained in:
2026-02-16 10:44:18 -07:00
parent a77306fac2
commit 7895ce683e
1367 changed files with 404191 additions and 2005 deletions

View File

@@ -0,0 +1,3 @@
import { FastifyInstance } from 'fastify';
export declare function commentsRoutes(fastify: FastifyInstance): Promise<void>;
//# sourceMappingURL=comments.routes.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"comments.routes.d.ts","sourceRoot":"","sources":["../../../../src/modules/media/routes/comments.routes.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AAG1C,wBAAsB,cAAc,CAAC,OAAO,EAAE,eAAe,iBAE5D"}

View File

@@ -0,0 +1,8 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.commentsRoutes = commentsRoutes;
// TODO: Implement comments routes
async function commentsRoutes(fastify) {
// Placeholder - no routes yet
}
//# sourceMappingURL=comments.routes.js.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"comments.routes.js","sourceRoot":"","sources":["../../../../src/modules/media/routes/comments.routes.ts"],"names":[],"mappings":";;AAGA,wCAEC;AAHD,kCAAkC;AAC3B,KAAK,UAAU,cAAc,CAAC,OAAwB;IAC3D,8BAA8B;AAChC,CAAC"}

View File

@@ -0,0 +1,7 @@
import { FastifyInstance } from 'fastify';
/**
* Public Media Gallery API Routes
* Handles public video listing, upvotes, comments, and admin operations
*/
export declare function publicMediaRoutes(fastify: FastifyInstance): Promise<void>;
//# sourceMappingURL=public-media.routes.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"public-media.routes.d.ts","sourceRoot":"","sources":["../../../../src/modules/media/routes/public-media.routes.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAgC,MAAM,SAAS,CAAC;AAmBxE;;;GAGG;AAEH,wBAAsB,iBAAiB,CAAC,OAAO,EAAE,eAAe,iBA42B/D"}

View File

@@ -0,0 +1,740 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.publicMediaRoutes = publicMediaRoutes;
const database_1 = require("../../../config/database");
const auth_1 = require("../middleware/auth");
const logger_1 = require("../../../utils/logger");
const session_service_1 = require("../services/session.service");
const public_media_schemas_1 = require("../schemas/public-media.schemas");
const fs_1 = require("fs");
const promises_1 = require("fs/promises");
/**
* Public Media Gallery API Routes
* Handles public video listing, upvotes, comments, and admin operations
*/
async function publicMediaRoutes(fastify) {
/**
* GET /videos (LEGACY ROUTE)
* Compatibility endpoint for public-media app (port 3100)
* Converts page-based pagination to offset-based and transforms response format
*/
fastify.get('/videos', {
preHandler: auth_1.optionalAuth,
}, async (request, reply) => {
try {
// Convert page-based to offset-based pagination
const page = parseInt(request.query.page || '1');
const limit = parseInt(request.query.limit || '48');
const offset = (page - 1) * limit;
const { search, category, sort } = request.query;
// Check if user is admin
const ADMIN_ROLES = ['SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'];
const isAdmin = request.user && ADMIN_ROLES.includes(request.user.role);
// Build WHERE clause (same logic as /public endpoint)
const where = {
isPublished: true, // Only show published videos
};
if (!isAdmin) {
where.isLocked = false;
}
if (category) {
where.category = category;
}
if (search) {
where.filename = {
contains: search,
mode: 'insensitive',
};
}
// Determine sort order
let orderBy = {};
switch (sort) {
case 'recent':
orderBy = { publishedAt: 'desc' };
break;
case 'popular':
orderBy = { upvoteCount: 'desc' };
break;
case 'most_viewed':
orderBy = { viewCount: 'desc' };
break;
default:
orderBy = { publishedAt: 'desc' };
}
// Execute queries in parallel
const [videos, total] = await Promise.all([
database_1.prisma.video.findMany({
where,
select: {
id: true,
filename: true,
category: true,
durationSeconds: true,
quality: true,
orientation: true,
thumbnailPath: true,
fileSize: true,
viewCount: true,
upvoteCount: true,
commentCount: true,
createdAt: true,
},
orderBy,
take: limit,
skip: offset,
}),
database_1.prisma.video.count({ where }),
]);
// Calculate total pages
const totalPages = Math.ceil(total / limit);
// Transform to legacy format expected by public-media app
return {
data: videos.map((video) => ({
id: video.id.toString(), // int → string
title: video.filename.replace(/\.[^.]+$/, ''), // filename without extension
fileName: video.filename, // camelCase
fileSize: Number(video.fileSize || 0), // BigInt → number
duration: video.durationSeconds || 0, // rename field
width: 0, // not stored, placeholder
height: 0, // not stored, placeholder
orientation: video.orientation || 'horizontal',
category: video.category,
viewCount: video.viewCount || 0,
createdAt: video.createdAt.toISOString(),
updatedAt: video.createdAt.toISOString(), // no updatedAt field, use createdAt
thumbnailUrl: video.thumbnailPath ? `/api/media/public/${video.id}/thumbnail` : undefined,
streamUrl: `/media/public/${video.category}/${video.filename}`, // static file path
})),
total,
page,
limit,
totalPages,
};
}
catch (error) {
logger_1.logger.error('Error fetching videos (legacy endpoint):', error);
return reply.code(500).send({
message: 'Failed to fetch videos',
error: error.message,
});
}
});
/**
* GET /public
* List public videos with filtering, sorting, and pagination
*/
fastify.get('/public', {
preHandler: auth_1.optionalAuth,
}, async (request, reply) => {
try {
// Validate query params
const parseResult = public_media_schemas_1.listPublicMediaSchema.safeParse(request.query);
if (!parseResult.success) {
return reply.code(400).send({
message: 'Invalid query parameters',
errors: parseResult.error.errors,
});
}
const { limit, offset, sort, search, category } = parseResult.data;
// Check if user is admin
const ADMIN_ROLES = ['SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'];
const isAdmin = request.user && ADMIN_ROLES.includes(request.user.role);
// Build WHERE clause
const where = {
isPublished: true, // Only show published videos
};
// Non-admins can't see locked videos
if (!isAdmin) {
where.isLocked = false;
}
// Category filter
if (category) {
where.category = category;
}
// Search filter (searches filename)
if (search) {
where.filename = {
contains: search,
mode: 'insensitive',
};
}
// Determine sort order
let orderBy = {};
switch (sort) {
case 'recent':
orderBy = { publishedAt: 'desc' };
break;
case 'popular':
orderBy = { upvoteCount: 'desc' };
break;
case 'most_viewed':
orderBy = { viewCount: 'desc' };
break;
default:
orderBy = { publishedAt: 'desc' };
}
// Execute queries in parallel
const [videos, total] = await Promise.all([
database_1.prisma.video.findMany({
where,
select: {
id: true,
filename: true,
category: true,
durationSeconds: true,
quality: true,
orientation: true,
thumbnailPath: true,
fileSize: true,
viewCount: true,
upvoteCount: true,
commentCount: true,
createdAt: true,
isLocked: true,
position: true,
publishedAt: true,
},
orderBy,
take: limit,
skip: offset,
}),
database_1.prisma.video.count({ where }),
]);
return {
videos,
pagination: {
total,
limit,
offset,
hasMore: offset + limit < total,
},
};
}
catch (error) {
logger_1.logger.error('Error listing public media:', error);
return reply.code(500).send({
message: 'Failed to list videos',
error: error.message,
});
}
});
/**
* GET /public/:id
* Get single video details
*/
fastify.get('/public/:id', {
preHandler: auth_1.optionalAuth,
}, async (request, reply) => {
try {
const videoId = parseInt(request.params.id);
if (isNaN(videoId)) {
return reply.code(400).send({ message: 'Invalid video ID' });
}
const video = await database_1.prisma.video.findFirst({
where: {
id: videoId,
isPublished: true, // Only show published videos
},
select: {
id: true,
filename: true,
category: true,
durationSeconds: true,
quality: true,
orientation: true,
thumbnailPath: true,
fileSize: true,
viewCount: true,
upvoteCount: true,
commentCount: true,
finishCount: true,
totalWatchTime: true,
createdAt: true,
publishedAt: true,
isLocked: true,
position: true,
uploaderId: true,
},
});
if (!video) {
return reply.code(404).send({ message: 'Video not found' });
}
// Check if locked and user is not admin
const ADMIN_ROLES = ['SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'];
const isAdmin = request.user && ADMIN_ROLES.includes(request.user.role);
if (video.isLocked && !isAdmin) {
return reply.code(403).send({
message: 'This video is locked',
});
}
return { video };
}
catch (error) {
logger_1.logger.error('Error fetching public media:', error);
return reply.code(500).send({
message: 'Failed to fetch video',
error: error.message,
});
}
});
/**
* POST /public/:id/upvote
* Toggle upvote for a video
*/
fastify.post('/public/:id/upvote', async (request, reply) => {
try {
const videoId = parseInt(request.params.id);
if (isNaN(videoId)) {
return reply.code(400).send({ message: 'Invalid video ID' });
}
// Get or create session
let sessionId;
try {
sessionId = await (0, session_service_1.getOrCreateSession)(request);
}
catch (error) {
return reply.code(400).send({
message: 'Session required',
error: error.message,
});
}
// Check if video exists and is published
const video = await database_1.prisma.video.findFirst({
where: {
id: videoId,
isPublished: true,
},
select: { id: true },
});
if (!video) {
return reply.code(404).send({ message: 'Video not found' });
}
// Check if upvote already exists
const existingUpvote = await database_1.prisma.upvote.findFirst({
where: {
mediaId: videoId,
sessionId,
},
});
if (existingUpvote) {
// Remove upvote (toggle off)
await database_1.prisma.$transaction([
database_1.prisma.upvote.delete({
where: { id: existingUpvote.id },
}),
database_1.prisma.video.update({
where: { id: videoId },
data: {
upvoteCount: {
decrement: 1,
},
},
}),
]);
logger_1.logger.info(`Removed upvote for video ${videoId} from session ${sessionId}`);
return { upvoted: false };
}
else {
// Add upvote (toggle on)
await database_1.prisma.$transaction([
database_1.prisma.upvote.create({
data: {
mediaId: videoId,
sessionId,
},
}),
database_1.prisma.video.update({
where: { id: videoId },
data: {
upvoteCount: {
increment: 1,
},
},
}),
]);
logger_1.logger.info(`Added upvote for video ${videoId} from session ${sessionId}`);
return { upvoted: true };
}
}
catch (error) {
logger_1.logger.error('Error toggling upvote:', error);
return reply.code(500).send({
message: 'Failed to toggle upvote',
error: error.message,
});
}
});
/**
* GET /public/:id/upvote-status
* Check if current session has upvoted a video
*/
fastify.get('/public/:id/upvote-status', async (request, reply) => {
try {
const videoId = parseInt(request.params.id);
if (isNaN(videoId)) {
return reply.code(400).send({ message: 'Invalid video ID' });
}
// Read sessionId from header (don't create if missing)
const sessionId = request.headers['x-session-id'];
if (!sessionId) {
return { upvoted: false };
}
// Check if upvote exists
const upvote = await database_1.prisma.upvote.findFirst({
where: {
mediaId: videoId,
sessionId,
},
});
return { upvoted: !!upvote };
}
catch (error) {
logger_1.logger.error('Error checking upvote status:', error);
return reply.code(500).send({
message: 'Failed to check upvote status',
error: error.message,
});
}
});
/**
* GET /public/:id/comments
* List comments for a video
*/
fastify.get('/public/:id/comments', async (request, reply) => {
try {
const videoId = parseInt(request.params.id);
const limit = parseInt(request.query.limit || '20');
const offset = parseInt(request.query.offset || '0');
if (isNaN(videoId)) {
return reply.code(400).send({ message: 'Invalid video ID' });
}
// Check if video exists and is published
const video = await database_1.prisma.video.findFirst({
where: {
id: videoId,
isPublished: true,
},
select: { id: true },
});
if (!video) {
return reply.code(404).send({ message: 'Video not found' });
}
// Fetch comments (hide hidden ones)
const [comments, total] = await Promise.all([
database_1.prisma.comment.findMany({
where: {
mediaId: videoId,
isHidden: false,
},
select: {
id: true,
content: true,
createdAt: true,
sessionId: true,
userId: true,
safetyStatus: true,
user: {
select: {
name: true,
email: true,
},
},
},
orderBy: {
createdAt: 'desc',
},
take: limit,
skip: offset,
}),
database_1.prisma.comment.count({
where: {
mediaId: videoId,
isHidden: false,
},
}),
]);
return {
comments,
pagination: {
total,
limit,
offset,
hasMore: offset + limit < total,
},
};
}
catch (error) {
logger_1.logger.error('Error listing comments:', error);
return reply.code(500).send({
message: 'Failed to list comments',
error: error.message,
});
}
});
/**
* POST /public/:id/comments
* Add a comment to a video
*/
fastify.post('/public/:id/comments', async (request, reply) => {
try {
const videoId = parseInt(request.params.id);
if (isNaN(videoId)) {
return reply.code(400).send({ message: 'Invalid video ID' });
}
// Validate request body
const parseResult = public_media_schemas_1.addCommentSchema.safeParse(request.body);
if (!parseResult.success) {
return reply.code(400).send({
message: 'Invalid comment data',
errors: parseResult.error.errors,
});
}
const { content } = parseResult.data;
// Get or create session
let sessionId;
try {
sessionId = await (0, session_service_1.getOrCreateSession)(request);
}
catch (error) {
return reply.code(400).send({
message: 'Session required',
error: error.message,
});
}
// Check if video exists and is published
const video = await database_1.prisma.video.findFirst({
where: {
id: videoId,
isPublished: true,
},
select: { id: true },
});
if (!video) {
return reply.code(404).send({ message: 'Video not found' });
}
// Create comment and increment counter in transaction
const [comment] = await database_1.prisma.$transaction([
database_1.prisma.comment.create({
data: {
mediaId: videoId,
sessionId,
userId: request.user?.id || null,
content,
safetyStatus: 'pending',
},
select: {
id: true,
content: true,
createdAt: true,
sessionId: true,
userId: true,
safetyStatus: true,
},
}),
database_1.prisma.video.update({
where: { id: videoId },
data: {
commentCount: {
increment: 1,
},
},
}),
]);
logger_1.logger.info(`Added comment ${comment.id} for video ${videoId} from session ${sessionId}`);
return { comment };
}
catch (error) {
logger_1.logger.error('Error adding comment:', error);
return reply.code(500).send({
message: 'Failed to add comment',
error: error.message,
});
}
});
/**
* GET /public/:id/thumbnail
* Serve thumbnail image for a video
*/
fastify.get('/public/:id/thumbnail', async (request, reply) => {
try {
const videoId = parseInt(request.params.id);
if (isNaN(videoId)) {
return reply.code(400).send({ message: 'Invalid video ID' });
}
// Fetch video with thumbnail path (published only)
const video = await database_1.prisma.video.findFirst({
where: {
id: videoId,
isPublished: true,
},
select: {
thumbnailPath: true,
},
});
if (!video) {
return reply.code(404).send({ message: 'Video not found' });
}
if (!video.thumbnailPath) {
return reply.code(404).send({ message: 'Thumbnail not found' });
}
// Check if file exists
try {
await (0, promises_1.access)(video.thumbnailPath, promises_1.constants.R_OK);
}
catch {
logger_1.logger.warn(`Thumbnail file not found: ${video.thumbnailPath}`);
return reply.code(404).send({ message: 'Thumbnail file not found' });
}
// Stream the file
const stream = (0, fs_1.createReadStream)(video.thumbnailPath);
// Set content type based on file extension
const ext = video.thumbnailPath.toLowerCase().split('.').pop();
const contentType = ext === 'png' ? 'image/png' : ext === 'webp' ? 'image/webp' : 'image/jpeg';
return reply.type(contentType).send(stream);
}
catch (error) {
logger_1.logger.error('Error serving thumbnail:', error);
return reply.code(500).send({
message: 'Failed to serve thumbnail',
error: error.message,
});
}
});
/**
* POST /public/bulk-lock
* Lock multiple videos (admin only)
*/
fastify.post('/public/bulk-lock', {
preHandler: auth_1.requireAdminRole,
}, async (request, reply) => {
try {
// Validate request body
const parseResult = public_media_schemas_1.bulkLockSchema.safeParse(request.body);
if (!parseResult.success) {
return reply.code(400).send({
message: 'Invalid request',
errors: parseResult.error.errors,
});
}
const { ids } = parseResult.data;
const userId = request.user?.id;
// Update all videos at once (only published videos)
const result = await database_1.prisma.video.updateMany({
where: {
id: {
in: ids,
},
isPublished: true,
},
data: {
isLocked: true,
lockedAt: new Date(),
lockedById: userId,
},
});
logger_1.logger.info(`Locked ${result.count} videos (IDs: ${ids.join(', ')}) by user ${userId}`);
return {
success: true,
count: result.count,
};
}
catch (error) {
logger_1.logger.error('Error bulk locking videos:', error);
return reply.code(500).send({
message: 'Failed to lock videos',
error: error.message,
});
}
});
/**
* POST /public/bulk-unlock
* Unlock multiple videos (admin only)
*/
fastify.post('/public/bulk-unlock', {
preHandler: auth_1.requireAdminRole,
}, async (request, reply) => {
try {
// Validate request body
const parseResult = public_media_schemas_1.bulkUnlockSchema.safeParse(request.body);
if (!parseResult.success) {
return reply.code(400).send({
message: 'Invalid request',
errors: parseResult.error.errors,
});
}
const { ids } = parseResult.data;
const userId = request.user?.id;
// Update all videos at once (only published videos)
const result = await database_1.prisma.video.updateMany({
where: {
id: {
in: ids,
},
isPublished: true,
},
data: {
isLocked: false,
lockedAt: null,
lockedById: null,
},
});
logger_1.logger.info(`Unlocked ${result.count} videos (IDs: ${ids.join(', ')}) by user ${userId}`);
return {
success: true,
count: result.count,
};
}
catch (error) {
logger_1.logger.error('Error bulk unlocking videos:', error);
return reply.code(500).send({
message: 'Failed to unlock videos',
error: error.message,
});
}
});
/**
* DELETE /public/:id
* Unpublish a video from public gallery (admin only)
* NOTE: This unpublishes instead of deleting - interactions are preserved
*/
fastify.delete('/public/:id', {
preHandler: auth_1.requireAdminRole,
}, async (request, reply) => {
try {
const videoId = parseInt(request.params.id);
if (isNaN(videoId)) {
return reply.code(400).send({ message: 'Invalid video ID' });
}
// Check if video exists and is published
const video = await database_1.prisma.video.findFirst({
where: {
id: videoId,
isPublished: true,
},
select: { id: true },
});
if (!video) {
return reply.code(404).send({ message: 'Video not found' });
}
// Unpublish the video (preserves upvotes, comments, views)
await database_1.prisma.video.update({
where: { id: videoId },
data: {
isPublished: false,
publishedAt: null,
// Keep category for re-publishing
},
});
logger_1.logger.info(`Unpublished video ${videoId} by user ${request.user?.id}`);
return { success: true };
}
catch (error) {
logger_1.logger.error('Error unpublishing video:', error);
return reply.code(500).send({
message: 'Failed to unpublish video',
error: error.message,
});
}
});
}
//# sourceMappingURL=public-media.routes.js.map

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,3 @@
import { FastifyInstance } from 'fastify';
export declare function reactionsRoutes(fastify: FastifyInstance): Promise<void>;
//# sourceMappingURL=reactions.routes.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"reactions.routes.d.ts","sourceRoot":"","sources":["../../../../src/modules/media/routes/reactions.routes.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAgC,MAAM,SAAS,CAAC;AAsCxE,wBAAsB,eAAe,CAAC,OAAO,EAAE,eAAe,iBAgG7D"}

View File

@@ -0,0 +1,102 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.reactionsRoutes = reactionsRoutes;
const database_1 = require("../../../config/database");
const auth_1 = require("../middleware/auth");
// Rebranded reaction emojis (6 standard social reactions)
const REACTION_EMOJIS = {
like: '👍',
love: '❤️',
laugh: '😂',
wow: '😮',
sad: '😢',
angry: '😠',
};
// Format video timestamp as MM:SS or H:MM:SS
function formatVideoTime(seconds) {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
if (h > 0) {
return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
}
return `${m}:${s.toString().padStart(2, '0')}`;
}
async function reactionsRoutes(fastify) {
// Add reaction (authenticated users only)
fastify.post('/', {
preHandler: auth_1.authenticate,
}, async (request, reply) => {
const { mediaId, reactionType, videoTimestamp } = request.body;
const userId = request.user.id;
// Validate reaction type
if (!REACTION_EMOJIS[reactionType]) {
return reply.code(400).send({ message: 'Invalid reaction type' });
}
// Check if video exists
const video = await database_1.prisma.video.findUnique({
where: { id: mediaId },
});
if (!video) {
return reply.code(404).send({ message: 'Video not found' });
}
// Create reaction
const reaction = await database_1.prisma.videoReaction.create({
data: {
mediaId,
userId,
reactionType: reactionType, // Cast string to ReactionType enum
videoTimestamp,
createdAt: new Date(),
},
});
return {
success: true,
reaction: {
...reaction,
emoji: REACTION_EMOJIS[reactionType],
formattedTime: formatVideoTime(videoTimestamp),
},
};
});
// Get reactions
fastify.get('/', async (request, reply) => {
const { mediaId, userId, limit = '50' } = request.query;
const where = {};
// Filter by mediaId if provided
if (mediaId) {
where.mediaId = parseInt(mediaId);
}
// Filter by userId if provided
if (userId) {
where.userId = userId;
}
const reactions = await database_1.prisma.videoReaction.findMany({
where,
orderBy: {
createdAt: 'desc',
},
take: parseInt(limit),
});
// Enhance with emojis and formatted times
const enhancedReactions = reactions.map(r => ({
...r,
emoji: REACTION_EMOJIS[r.reactionType] || '❓',
formattedTime: formatVideoTime(r.videoTimestamp),
}));
return {
reactions: enhancedReactions,
};
});
// Get reaction config (returns available reactions)
fastify.get('/config', async (request, reply) => {
return {
reactions: Object.entries(REACTION_EMOJIS).map(([type, emoji]) => ({
type,
emoji,
label: type.charAt(0).toUpperCase() + type.slice(1),
})),
};
});
}
//# sourceMappingURL=reactions.routes.js.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"reactions.routes.js","sourceRoot":"","sources":["../../../../src/modules/media/routes/reactions.routes.ts"],"names":[],"mappings":";;AAsCA,0CAgGC;AArID,uDAAkD;AAClD,6CAAkD;AAElD,0DAA0D;AAC1D,MAAM,eAAe,GAA2B;IAC9C,IAAI,EAAE,IAAI;IACV,IAAI,EAAE,IAAI;IACV,KAAK,EAAE,IAAI;IACX,GAAG,EAAE,IAAI;IACT,GAAG,EAAE,IAAI;IACT,KAAK,EAAE,IAAI;CACZ,CAAC;AAEF,6CAA6C;AAC7C,SAAS,eAAe,CAAC,OAAe;IACtC,MAAM,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,OAAO,GAAG,IAAI,CAAC,CAAC;IACrC,MAAM,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,OAAO,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC,CAAC;IAC5C,MAAM,CAAC,GAAG,OAAO,GAAG,EAAE,CAAC;IAEvB,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC;QACV,OAAO,GAAG,CAAC,IAAI,CAAC,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,CAAC,EAAE,GAAG,CAAC,IAAI,CAAC,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,CAAC,EAAE,GAAG,CAAC,EAAE,CAAC;IAClF,CAAC;IACD,OAAO,GAAG,CAAC,IAAI,CAAC,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,CAAC,EAAE,GAAG,CAAC,EAAE,CAAC;AACjD,CAAC;AAcM,KAAK,UAAU,eAAe,CAAC,OAAwB;IAC5D,0CAA0C;IAC1C,OAAO,CAAC,IAAI,CACV,GAAG,EACH;QACE,UAAU,EAAE,mBAAY;KACzB,EACD,KAAK,EAAE,OAAkD,EAAE,KAAK,EAAE,EAAE;QAClE,MAAM,EAAE,OAAO,EAAE,YAAY,EAAE,cAAc,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC;QAC/D,MAAM,MAAM,GAAG,OAAO,CAAC,IAAK,CAAC,EAAE,CAAC;QAEhC,yBAAyB;QACzB,IAAI,CAAC,eAAe,CAAC,YAAY,CAAC,EAAE,CAAC;YACnC,OAAO,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,OAAO,EAAE,uBAAuB,EAAE,CAAC,CAAC;QACpE,CAAC;QAED,wBAAwB;QACxB,MAAM,KAAK,GAAG,MAAM,iBAAM,CAAC,KAAK,CAAC,UAAU,CAAC;YAC1C,KAAK,EAAE,EAAE,EAAE,EAAE,OAAO,EAAE;SACvB,CAAC,CAAC;QAEH,IAAI,CAAC,KAAK,EAAE,CAAC;YACX,OAAO,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,OAAO,EAAE,iBAAiB,EAAE,CAAC,CAAC;QAC9D,CAAC;QAED,kBAAkB;QAClB,MAAM,QAAQ,GAAG,MAAM,iBAAM,CAAC,aAAa,CAAC,MAAM,CAAC;YACjD,IAAI,EAAE;gBACJ,OAAO;gBACP,MAAM;gBACN,YAAY,EAAE,YAAmB,EAAE,mCAAmC;gBACtE,cAAc;gBACd,SAAS,EAAE,IAAI,IAAI,EAAE;aACtB;SACF,CAAC,CAAC;QAEH,OAAO;YACL,OAAO,EAAE,IAAI;YACb,QAAQ,EAAE;gBACR,GAAG,QAAQ;gBACX,KAAK,EAAE,eAAe,CAAC,YAAY,CAAC;gBACpC,aAAa,EAAE,eAAe,CAAC,cAAc,CAAC;aAC/C;SACF,CAAC;IACJ,CAAC,CACF,CAAC;IAEF,gBAAgB;IAChB,OAAO,CAAC,GAAG,CACT,GAAG,EACH,KAAK,EAAE,OAA2D,EAAE,KAAK,EAAE,EAAE;QAC3E,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,KAAK,GAAG,IAAI,EAAE,GAAG,OAAO,CAAC,KAAK,CAAC;QAExD,MAAM,KAAK,GAAQ,EAAE,CAAC;QAEtB,gCAAgC;QAChC,IAAI,OAAO,EAAE,CAAC;YACZ,KAAK,CAAC,OAAO,GAAG,QAAQ,CAAC,OAAO,CAAC,CAAC;QACpC,CAAC;QAED,+BAA+B;QAC/B,IAAI,MAAM,EAAE,CAAC;YACX,KAAK,CAAC,MAAM,GAAG,MAAM,CAAC;QACxB,CAAC;QAED,MAAM,SAAS,GAAG,MAAM,iBAAM,CAAC,aAAa,CAAC,QAAQ,CAAC;YACpD,KAAK;YACL,OAAO,EAAE;gBACP,SAAS,EAAE,MAAM;aAClB;YACD,IAAI,EAAE,QAAQ,CAAC,KAAK,CAAC;SACtB,CAAC,CAAC;QAEH,0CAA0C;QAC1C,MAAM,iBAAiB,GAAG,SAAS,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;YAC5C,GAAG,CAAC;YACJ,KAAK,EAAE,eAAe,CAAC,CAAC,CAAC,YAAY,CAAC,IAAI,GAAG;YAC7C,aAAa,EAAE,eAAe,CAAC,CAAC,CAAC,cAAc,CAAC;SACjD,CAAC,CAAC,CAAC;QAEJ,OAAO;YACL,SAAS,EAAE,iBAAiB;SAC7B,CAAC;IACJ,CAAC,CACF,CAAC;IAEF,oDAAoD;IACpD,OAAO,CAAC,GAAG,CAAC,SAAS,EAAE,KAAK,EAAE,OAAuB,EAAE,KAAK,EAAE,EAAE;QAC9D,OAAO;YACL,SAAS,EAAE,MAAM,CAAC,OAAO,CAAC,eAAe,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,EAAE,KAAK,CAAC,EAAE,EAAE,CAAC,CAAC;gBACjE,IAAI;gBACJ,KAAK;gBACL,KAAK,EAAE,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,WAAW,EAAE,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC;aACpD,CAAC,CAAC;SACJ,CAAC;IACJ,CAAC,CAAC,CAAC;AACL,CAAC"}

View File

@@ -0,0 +1,3 @@
import { FastifyInstance } from 'fastify';
export declare function uploadRoutes(fastify: FastifyInstance): Promise<void>;
//# sourceMappingURL=upload.routes.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"upload.routes.d.ts","sourceRoot":"","sources":["../../../../src/modules/media/routes/upload.routes.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAgC,MAAM,SAAS,CAAC;AAgSxE,wBAAsB,YAAY,CAAC,OAAO,EAAE,eAAe,iBAkB1D"}

View File

@@ -0,0 +1,265 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.uploadRoutes = uploadRoutes;
const promises_1 = require("stream/promises");
const fs_1 = require("fs");
const promises_2 = require("fs/promises");
const path_1 = require("path");
const crypto_1 = require("crypto");
const database_1 = require("../../../config/database");
const ffprobe_service_1 = require("../services/ffprobe.service");
const thumbnail_service_1 = require("../services/thumbnail.service");
const logger_1 = require("../../../utils/logger");
const zod_1 = require("zod");
const auth_1 = require("../middleware/auth");
// Allowed video extensions
const ALLOWED_EXTENSIONS = ['.mp4', '.mov', '.avi', '.mkv', '.webm', '.m4v', '.flv'];
// Zod schema for upload metadata
const UploadMetadataSchema = zod_1.z.object({
title: zod_1.z.string().optional(),
producer: zod_1.z.string().optional(),
creator: zod_1.z.string().optional(),
});
/**
* Upload a single video file
*/
async function uploadVideo(request, reply) {
let tempFilePath = null;
try {
// Get the uploaded file
const data = await request.file();
if (!data) {
return reply.code(400).send({ message: 'No file uploaded' });
}
// Validate file extension
const ext = (0, path_1.extname)(data.filename).toLowerCase();
if (!ALLOWED_EXTENSIONS.includes(ext)) {
return reply.code(400).send({
message: `Invalid file type. Allowed: ${ALLOWED_EXTENSIONS.join(', ')}`,
});
}
// Extract metadata fields from form data
const metadataFields = data.fields;
const metadata = {
title: metadataFields.title?.value,
producer: metadataFields.producer?.value,
creator: metadataFields.creator?.value,
};
// Validate metadata
const validatedMetadata = UploadMetadataSchema.parse(metadata);
// Generate unique filename
const filename = `${(0, crypto_1.randomUUID)()}${ext}`;
const inboxDir = '/media/local/inbox';
// Ensure inbox directory exists
await (0, promises_2.mkdir)(inboxDir, { recursive: true });
const filePath = (0, path_1.join)(inboxDir, filename);
tempFilePath = filePath;
// Stream file to disk
logger_1.logger.info(`Uploading video to ${filePath}`);
await (0, promises_1.pipeline)(data.file, (0, fs_1.createWriteStream)(filePath));
// Validate video file
logger_1.logger.info(`Validating video file: ${filePath}`);
const isValid = await (0, ffprobe_service_1.validateVideoFile)(filePath);
if (!isValid) {
await (0, promises_2.unlink)(filePath);
return reply.code(400).send({ message: 'Invalid or corrupted video file' });
}
// Extract metadata
logger_1.logger.info(`Extracting metadata from: ${filePath}`);
const videoMetadata = await (0, ffprobe_service_1.extractVideoMetadata)(filePath);
// Insert into database
const video = await database_1.prisma.video.create({
data: {
path: filePath, // Store full path: /media/local/inbox/uuid.mp4
filename,
originalFilename: data.filename,
title: validatedMetadata.title || data.filename,
durationSeconds: videoMetadata.durationSeconds,
width: videoMetadata.width,
height: videoMetadata.height,
orientation: videoMetadata.orientation,
quality: videoMetadata.quality,
hasAudio: videoMetadata.hasAudio,
fileSize: videoMetadata.fileSize,
directoryType: 'inbox',
isValid: true,
producer: validatedMetadata.producer || null,
creator: validatedMetadata.creator || null,
},
});
logger_1.logger.info(`Video uploaded successfully: ${video.id}`);
// Generate thumbnail
try {
const thumbnailPath = await thumbnail_service_1.ThumbnailService.generateThumbnail({
videoPath: filePath,
videoId: video.id,
duration: videoMetadata.durationSeconds,
orientation: videoMetadata.orientation,
});
// Update video with thumbnail path
await database_1.prisma.video.update({
where: { id: video.id },
data: { thumbnailPath },
});
logger_1.logger.info(`Thumbnail generated for video ${video.id}`);
}
catch (thumbnailError) {
// Log error but don't fail the upload
logger_1.logger.error(`Failed to generate thumbnail for video ${video.id}:`, thumbnailError);
}
return reply.code(201).send({
message: 'Video uploaded successfully',
video,
});
}
catch (error) {
// Clean up file on error
if (tempFilePath) {
try {
await (0, promises_2.unlink)(tempFilePath);
}
catch (cleanupError) {
logger_1.logger.error('Failed to clean up file after error:', cleanupError);
}
}
logger_1.logger.error('Video upload failed:', error);
if (error instanceof zod_1.z.ZodError) {
return reply.code(400).send({
message: 'Invalid metadata',
errors: error.errors,
});
}
return reply.code(500).send({
message: error instanceof Error ? error.message : 'Upload failed',
});
}
}
/**
* Upload multiple video files in batch
*/
async function uploadBatch(request, reply) {
try {
const files = request.files();
const results = [];
for await (const file of files) {
let tempFilePath = null;
try {
// Validate file extension
const ext = (0, path_1.extname)(file.filename).toLowerCase();
if (!ALLOWED_EXTENSIONS.includes(ext)) {
results.push({
filename: file.filename,
success: false,
error: `Invalid file type. Allowed: ${ALLOWED_EXTENSIONS.join(', ')}`,
});
continue;
}
// Generate unique filename
const filename = `${(0, crypto_1.randomUUID)()}${ext}`;
const inboxDir = '/media/local/inbox';
// Ensure inbox directory exists
await (0, promises_2.mkdir)(inboxDir, { recursive: true });
const filePath = (0, path_1.join)(inboxDir, filename);
tempFilePath = filePath;
// Stream file to disk
await (0, promises_1.pipeline)(file.file, (0, fs_1.createWriteStream)(filePath));
// Validate video file
const isValid = await (0, ffprobe_service_1.validateVideoFile)(filePath);
if (!isValid) {
await (0, promises_2.unlink)(filePath);
results.push({
filename: file.filename,
success: false,
error: 'Invalid or corrupted video file',
});
continue;
}
// Extract metadata
const videoMetadata = await (0, ffprobe_service_1.extractVideoMetadata)(filePath);
// Insert into database
const video = await database_1.prisma.video.create({
data: {
path: filePath, // Store full path: /media/local/inbox/uuid.mp4
filename,
originalFilename: file.filename,
title: file.filename,
durationSeconds: videoMetadata.durationSeconds,
width: videoMetadata.width,
height: videoMetadata.height,
orientation: videoMetadata.orientation,
quality: videoMetadata.quality,
hasAudio: videoMetadata.hasAudio,
fileSize: videoMetadata.fileSize,
directoryType: 'inbox',
isValid: true,
},
});
// Generate thumbnail
try {
const thumbnailPath = await thumbnail_service_1.ThumbnailService.generateThumbnail({
videoPath: filePath,
videoId: video.id,
duration: videoMetadata.durationSeconds,
orientation: videoMetadata.orientation,
});
// Update video with thumbnail path
await database_1.prisma.video.update({
where: { id: video.id },
data: { thumbnailPath },
});
logger_1.logger.info(`Thumbnail generated for video ${video.id}`);
}
catch (thumbnailError) {
// Log error but don't fail the upload
logger_1.logger.error(`Failed to generate thumbnail for video ${video.id}:`, thumbnailError);
}
results.push({
filename: file.filename,
success: true,
video,
});
logger_1.logger.info(`Batch upload successful: ${file.filename} -> ${video.id}`);
}
catch (error) {
// Clean up file on error
if (tempFilePath) {
try {
await (0, promises_2.unlink)(tempFilePath);
}
catch (cleanupError) {
logger_1.logger.error('Failed to clean up file after error:', cleanupError);
}
}
logger_1.logger.error(`Batch upload failed for ${file.filename}:`, error);
results.push({
filename: file.filename,
success: false,
error: error instanceof Error ? error.message : 'Upload failed',
});
}
}
const successCount = results.filter((r) => r.success).length;
const failCount = results.length - successCount;
return reply.code(207).send({
message: `Batch upload complete: ${successCount} succeeded, ${failCount} failed`,
results,
});
}
catch (error) {
logger_1.logger.error('Batch upload failed:', error);
return reply.code(500).send({
message: error instanceof Error ? error.message : 'Batch upload failed',
});
}
}
async function uploadRoutes(fastify) {
// Single file upload
fastify.post('/upload', {
preHandler: auth_1.requireAdminRole,
}, uploadVideo);
// Batch upload
fastify.post('/upload/batch', {
preHandler: auth_1.requireAdminRole,
}, uploadBatch);
}
//# sourceMappingURL=upload.routes.js.map

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,3 @@
import { FastifyInstance } from 'fastify';
export declare function videoActionsRoutes(fastify: FastifyInstance): Promise<void>;
//# sourceMappingURL=video-actions.routes.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"video-actions.routes.d.ts","sourceRoot":"","sources":["../../../../src/modules/media/routes/video-actions.routes.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAgC,MAAM,SAAS,CAAC;AAUxE,wBAAsB,kBAAkB,CAAC,OAAO,EAAE,eAAe,iBAgUhE"}

View File

@@ -0,0 +1,248 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.videoActionsRoutes = videoActionsRoutes;
const database_1 = require("../../../config/database");
const auth_1 = require("../middleware/auth");
const video_analytics_service_1 = require("../services/video-analytics.service");
const logger_1 = require("../../../utils/logger");
const jsonwebtoken_1 = require("jsonwebtoken");
const env_1 = require("../../../config/env");
async function videoActionsRoutes(fastify) {
/**
* POST /videos/:id/duplicate
* Duplicate a video with a new title
*/
fastify.post('/:id/duplicate', {
preHandler: auth_1.requireAdminRole,
}, async (request, reply) => {
const videoId = parseInt(request.params.id);
const { title } = request.body || {};
try {
const originalVideo = await database_1.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 database_1.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,
directoryType: originalVideo.directoryType,
category: originalVideo.category,
uploaderId: originalVideo.uploaderId,
},
});
logger_1.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_1.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
*/
fastify.post('/:id/replace', {
preHandler: auth_1.requireAdminRole,
}, async (request, reply) => {
const videoId = parseInt(request.params.id);
const { newPath, newFilename, durationSeconds, width, height, fileSize } = request.body;
try {
const existingVideo = await database_1.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 database_1.prisma.video.update({
where: { id: videoId },
data: {
path: newPath,
filename: newFilename,
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_1.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_1.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('/:id/analytics', {
preHandler: auth_1.requireAdminRole,
}, async (request, reply) => {
const videoId = parseInt(request.params.id);
const { startDate, endDate } = request.query;
try {
const analytics = await video_analytics_service_1.videoAnalyticsService.getVideoAnalytics(videoId, startDate ? new Date(startDate) : undefined, endDate ? new Date(endDate) : undefined);
return analytics;
}
catch (error) {
logger_1.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('/:id/reset-analytics', {
preHandler: auth_1.requireAdminRole,
}, async (request, reply) => {
const videoId = parseInt(request.params.id);
try {
await video_analytics_service_1.videoAnalyticsService.resetAnalytics(videoId);
return {
success: true,
message: 'Analytics reset successfully',
};
}
catch (error) {
logger_1.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 temporary preview link with expiring JWT token
*/
fastify.get('/:id/preview-link', {
preHandler: auth_1.requireAdminRole,
}, async (request, reply) => {
const videoId = parseInt(request.params.id);
try {
const video = await database_1.prisma.video.findUnique({
where: { id: videoId },
});
if (!video) {
return reply.code(404).send({ message: 'Video not found' });
}
// Generate JWT token that expires in 24 hours
const expiryHours = parseInt(process.env.VIDEO_PREVIEW_LINK_EXPIRY_HOURS || '24');
const token = (0, jsonwebtoken_1.sign)({
videoId,
purpose: 'preview',
}, env_1.env.JWT_ACCESS_SECRET, { expiresIn: `${expiryHours}h` });
const previewUrl = `${env_1.env.MEDIA_API_URL}/api/videos/${videoId}/preview?token=${token}`;
logger_1.logger.info(`Generated preview link for video ${videoId}`, { expiresInHours: expiryHours });
return {
previewUrl,
expiresAt: new Date(Date.now() + expiryHours * 60 * 60 * 1000).toISOString(),
expiryHours,
};
}
catch (error) {
logger_1.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('/analytics/top', {
preHandler: auth_1.requireAdminRole,
}, async (request, reply) => {
const metric = request.query.metric || 'views';
const limit = parseInt(request.query.limit || '10');
try {
const topVideos = await video_analytics_service_1.videoAnalyticsService.getTopVideos(metric, limit);
return {
metric,
videos: topVideos,
};
}
catch (error) {
logger_1.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: auth_1.requireAdminRole,
}, async (request, reply) => {
try {
const [totalVideos, totalViews, totalWatchTime, avgCompletionRate] = await Promise.all([
database_1.prisma.video.count(),
database_1.prisma.video.aggregate({
_sum: {
viewCount: true,
},
}),
database_1.prisma.video.aggregate({
_sum: {
totalWatchTimeSeconds: true,
},
}),
database_1.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_1.logger.error('Failed to get analytics overview', { error });
return reply.code(500).send({ message: 'Failed to fetch analytics overview' });
}
});
}
//# sourceMappingURL=video-actions.routes.js.map

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,3 @@
import { FastifyInstance } from 'fastify';
export declare function videoScheduleRoutes(fastify: FastifyInstance): Promise<void>;
//# sourceMappingURL=video-schedule.routes.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"video-schedule.routes.d.ts","sourceRoot":"","sources":["../../../../src/modules/media/routes/video-schedule.routes.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAgC,MAAM,SAAS,CAAC;AAkBxE,wBAAsB,mBAAmB,CAAC,OAAO,EAAE,eAAe,iBA0VjE"}

View File

@@ -0,0 +1,266 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.videoScheduleRoutes = videoScheduleRoutes;
const database_1 = require("../../../config/database");
const auth_1 = require("../middleware/auth");
const video_schedule_queue_service_1 = require("../../../services/video-schedule-queue.service");
const logger_1 = require("../../../utils/logger");
const zod_1 = require("zod");
// Validation schemas
const schedulePublishSchema = zod_1.z.object({
publishAt: zod_1.z.string().datetime(),
timezone: zod_1.z.string().optional().default('UTC'),
});
const scheduleUnpublishSchema = zod_1.z.object({
unpublishAt: zod_1.z.string().datetime(),
timezone: zod_1.z.string().optional().default('UTC'),
});
async function videoScheduleRoutes(fastify) {
/**
* POST /videos/:id/schedule-publish
* Schedule a video to be published at a specific time
*/
fastify.post('/:id/schedule-publish', {
preHandler: auth_1.requireAdminRole,
}, async (request, reply) => {
const videoId = parseInt(request.params.id);
const { publishAt, timezone } = request.body;
// Validate input
try {
schedulePublishSchema.parse(request.body);
}
catch (error) {
return reply.code(400).send({ message: 'Invalid input', error });
}
try {
// Verify video exists
const video = await database_1.prisma.video.findUnique({
where: { id: videoId },
});
if (!video) {
return reply.code(404).send({ message: 'Video not found' });
}
// Parse publish time
const publishDate = new Date(publishAt);
const now = new Date();
if (publishDate <= now) {
return reply.code(400).send({ message: 'Publish time must be in the future' });
}
// Get user ID from request (set by auth middleware)
const userId = request.user?.id;
if (!userId) {
return reply.code(401).send({ message: 'Unauthorized' });
}
// Schedule the publish
const result = await video_schedule_queue_service_1.videoScheduleQueueService.schedulePublish(videoId, publishDate, userId);
logger_1.logger.info(`Scheduled video ${videoId} to publish at ${publishDate.toISOString()}`);
return {
success: true,
message: 'Video scheduled for publish',
scheduledFor: result.scheduledFor,
jobId: result.jobId,
};
}
catch (error) {
logger_1.logger.error('Failed to schedule video publish', { error, videoId });
return reply.code(500).send({
message: error instanceof Error ? error.message : 'Failed to schedule video',
});
}
});
/**
* POST /videos/:id/schedule-unpublish
* Schedule a video to be unpublished at a specific time
*/
fastify.post('/:id/schedule-unpublish', {
preHandler: auth_1.requireAdminRole,
}, async (request, reply) => {
const videoId = parseInt(request.params.id);
const { unpublishAt, timezone } = request.body;
// Validate input
try {
scheduleUnpublishSchema.parse(request.body);
}
catch (error) {
return reply.code(400).send({ message: 'Invalid input', error });
}
try {
// Verify video exists
const video = await database_1.prisma.video.findUnique({
where: { id: videoId },
});
if (!video) {
return reply.code(404).send({ message: 'Video not found' });
}
// Parse unpublish time
const unpublishDate = new Date(unpublishAt);
const now = new Date();
if (unpublishDate <= now) {
return reply.code(400).send({ message: 'Unpublish time must be in the future' });
}
// Get user ID from request
const userId = request.user?.id;
if (!userId) {
return reply.code(401).send({ message: 'Unauthorized' });
}
// Schedule the unpublish
const result = await video_schedule_queue_service_1.videoScheduleQueueService.scheduleUnpublish(videoId, unpublishDate, userId);
logger_1.logger.info(`Scheduled video ${videoId} to unpublish at ${unpublishDate.toISOString()}`);
return {
success: true,
message: 'Video scheduled for unpublish',
scheduledFor: result.scheduledFor,
jobId: result.jobId,
};
}
catch (error) {
logger_1.logger.error('Failed to schedule video unpublish', { error, videoId });
return reply.code(500).send({
message: error instanceof Error ? error.message : 'Failed to schedule video',
});
}
});
/**
* DELETE /videos/:id/schedule/:action
* Cancel a scheduled publish or unpublish
*/
fastify.delete('/:id/schedule/:action', {
preHandler: auth_1.requireAdminRole,
}, async (request, reply) => {
const videoId = parseInt(request.params.id);
const action = request.params.action;
if (!['publish', 'unpublish'].includes(action)) {
return reply.code(400).send({ message: 'Invalid action. Must be "publish" or "unpublish"' });
}
try {
await video_schedule_queue_service_1.videoScheduleQueueService.cancelSchedule(videoId, action);
logger_1.logger.info(`Cancelled ${action} schedule for video ${videoId}`);
return {
success: true,
message: `${action} schedule cancelled`,
};
}
catch (error) {
logger_1.logger.error(`Failed to cancel ${action} schedule`, { error, videoId });
return reply.code(500).send({
message: `Failed to cancel ${action} schedule`,
});
}
});
/**
* GET /videos/schedules/upcoming
* Get all upcoming scheduled publish/unpublish operations
*/
fastify.get('/schedules/upcoming', {
preHandler: auth_1.requireAdminRole,
}, async (request, reply) => {
const limit = parseInt(request.query.limit || '50');
try {
const schedules = await video_schedule_queue_service_1.videoScheduleQueueService.getUpcomingSchedules(limit);
return {
schedules,
total: schedules.length,
};
}
catch (error) {
logger_1.logger.error('Failed to get upcoming schedules', { error });
return reply.code(500).send({ message: 'Failed to fetch schedules' });
}
});
/**
* GET /videos/:id/schedule-history
* Get schedule history for a specific video
*/
fastify.get('/:id/schedule-history', {
preHandler: auth_1.requireAdminRole,
}, async (request, reply) => {
const videoId = parseInt(request.params.id);
const limit = parseInt(request.query.limit || '10');
try {
const history = await video_schedule_queue_service_1.videoScheduleQueueService.getScheduleHistory(videoId, limit);
return {
videoId,
history,
};
}
catch (error) {
logger_1.logger.error('Failed to get schedule history', { error, videoId });
return reply.code(500).send({ message: 'Failed to fetch schedule history' });
}
});
/**
* GET /videos/schedules/stats
* Get queue statistics
*/
fastify.get('/schedules/stats', {
preHandler: auth_1.requireAdminRole,
}, async (request, reply) => {
try {
const stats = await video_schedule_queue_service_1.videoScheduleQueueService.getStats();
return stats;
}
catch (error) {
logger_1.logger.error('Failed to get schedule stats', { error });
return reply.code(500).send({ message: 'Failed to fetch stats' });
}
});
/**
* POST /videos/schedules/pause
* Pause the schedule queue
*/
fastify.post('/schedules/pause', {
preHandler: auth_1.requireAdminRole,
}, async (request, reply) => {
try {
await video_schedule_queue_service_1.videoScheduleQueueService.pause();
return {
success: true,
message: 'Schedule queue paused',
};
}
catch (error) {
logger_1.logger.error('Failed to pause schedule queue', { error });
return reply.code(500).send({ message: 'Failed to pause queue' });
}
});
/**
* POST /videos/schedules/resume
* Resume the schedule queue
*/
fastify.post('/schedules/resume', {
preHandler: auth_1.requireAdminRole,
}, async (request, reply) => {
try {
await video_schedule_queue_service_1.videoScheduleQueueService.resume();
return {
success: true,
message: 'Schedule queue resumed',
};
}
catch (error) {
logger_1.logger.error('Failed to resume schedule queue', { error });
return reply.code(500).send({ message: 'Failed to resume queue' });
}
});
/**
* POST /videos/schedules/cleanup
* Clean up old completed jobs
*/
fastify.post('/schedules/cleanup', {
preHandler: auth_1.requireAdminRole,
}, async (request, reply) => {
try {
const cleaned = await video_schedule_queue_service_1.videoScheduleQueueService.cleanup();
return {
success: true,
message: `Cleaned ${cleaned} old jobs`,
cleaned,
};
}
catch (error) {
logger_1.logger.error('Failed to cleanup schedule queue', { error });
return reply.code(500).send({ message: 'Failed to cleanup queue' });
}
});
}
//# sourceMappingURL=video-schedule.routes.js.map

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,3 @@
import { FastifyInstance } from 'fastify';
export declare function videoStreamingRoutes(fastify: FastifyInstance): Promise<void>;
//# sourceMappingURL=video-streaming.routes.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"video-streaming.routes.d.ts","sourceRoot":"","sources":["../../../../src/modules/media/routes/video-streaming.routes.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAgC,MAAM,SAAS,CAAC;AAuCxE,wBAAsB,oBAAoB,CAAC,OAAO,EAAE,eAAe,iBAiOlE"}

View File

@@ -0,0 +1,227 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.videoStreamingRoutes = videoStreamingRoutes;
const fs_1 = require("fs");
const promises_1 = require("fs/promises");
const path_1 = require("path");
const mime_types_1 = require("mime-types");
const database_1 = require("../../../config/database");
const logger_1 = require("../../../utils/logger");
/**
* Parse range header for video seeking
* Example: "bytes=0-1024" or "bytes=1024-"
*/
function parseRange(range, fileSize) {
const parts = range.replace(/bytes=/, '').split('-');
const start = parseInt(parts[0], 10);
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
if (isNaN(start) || isNaN(end) || start > end || end >= fileSize) {
return null;
}
return { start, end };
}
/**
* Get video file stats safely
*/
async function getFileStats(filePath) {
return new Promise((resolve) => {
(0, fs_1.stat)(filePath, (err, stats) => {
if (err) {
resolve(null);
}
else {
resolve({ size: stats.size });
}
});
});
}
async function videoStreamingRoutes(fastify) {
/**
* Stream video file with HTTP range support for seeking
* GET /api/videos/:id/stream
* Public endpoint (no auth) - videos are public by default
*/
fastify.get('/:id/stream', async (request, reply) => {
try {
const videoId = parseInt(request.params.id);
if (isNaN(videoId)) {
return reply.code(400).send({ message: 'Invalid video ID' });
}
// Fetch video from database
const video = await database_1.prisma.video.findUnique({
where: { id: videoId },
});
if (!video) {
return reply.code(404).send({ message: 'Video not found' });
}
// Security: Validate path doesn't contain traversal attempts
if (video.path.includes('..') || video.filename.includes('..')) {
logger_1.logger.warn(`Path traversal attempt detected: ${video.path}/${video.filename}`);
return reply.code(403).send({ message: 'Access denied' });
}
// Construct full file path
// Handle both new format (full path) and legacy format (directory only)
const filePath = video.path.endsWith(video.filename)
? video.path
: (0, path_1.join)(video.path, video.filename);
// Check file exists
try {
await (0, promises_1.access)(filePath);
}
catch {
logger_1.logger.error(`Video file not found on disk: ${filePath}`);
return reply.code(404).send({ message: 'Video file not found' });
}
// Get file stats
const stats = await getFileStats(filePath);
if (!stats) {
return reply.code(500).send({ message: 'Failed to read video file' });
}
const fileSize = stats.size;
// Determine MIME type
const mimeType = (0, mime_types_1.lookup)(video.filename) || 'video/mp4';
// Handle range requests for seeking
const rangeHeader = request.headers.range;
if (rangeHeader) {
const range = parseRange(rangeHeader, fileSize);
if (!range) {
return reply.code(416).send({ message: 'Invalid range' });
}
const { start, end } = range;
const contentLength = end - start + 1;
// Set partial content headers
reply.code(206);
reply.header('Content-Range', `bytes ${start}-${end}/${fileSize}`);
reply.header('Accept-Ranges', 'bytes');
reply.header('Content-Length', contentLength);
reply.header('Content-Type', mimeType);
reply.header('Cache-Control', 'public, max-age=31536000'); // 1 year
// Stream the requested range
const stream = (0, fs_1.createReadStream)(filePath, { start, end });
return reply.send(stream);
}
else {
// No range header - stream entire file
reply.header('Content-Length', fileSize);
reply.header('Content-Type', mimeType);
reply.header('Accept-Ranges', 'bytes');
reply.header('Cache-Control', 'public, max-age=31536000'); // 1 year
const stream = (0, fs_1.createReadStream)(filePath);
return reply.send(stream);
}
}
catch (error) {
logger_1.logger.error('Video streaming error:', error);
return reply.code(500).send({
message: error instanceof Error ? error.message : 'Failed to stream video',
});
}
});
/**
* Serve video thumbnail
* GET /api/videos/:id/thumbnail
* Public endpoint - returns thumbnail image or 404
*/
fastify.get('/:id/thumbnail', async (request, reply) => {
try {
const videoId = parseInt(request.params.id);
if (isNaN(videoId)) {
return reply.code(400).send({ message: 'Invalid video ID' });
}
// Fetch video from database
const video = await database_1.prisma.video.findUnique({
where: { id: videoId },
});
if (!video) {
return reply.code(404).send({ message: 'Video not found' });
}
// Check if thumbnail exists
if (!video.thumbnailPath) {
return reply.code(404).send({ message: 'Thumbnail not found' });
}
// Security: Validate path
if (video.thumbnailPath.includes('..')) {
logger_1.logger.warn(`Path traversal attempt detected: ${video.thumbnailPath}`);
return reply.code(403).send({ message: 'Access denied' });
}
// Check file exists
try {
await (0, promises_1.access)(video.thumbnailPath);
}
catch {
logger_1.logger.error(`Thumbnail file not found on disk: ${video.thumbnailPath}`);
return reply.code(404).send({ message: 'Thumbnail file not found' });
}
// Determine MIME type
const mimeType = (0, mime_types_1.lookup)(video.thumbnailPath) || 'image/jpeg';
// Read and send thumbnail
const thumbnailBuffer = await (0, promises_1.readFile)(video.thumbnailPath);
reply.header('Content-Type', mimeType);
reply.header('Content-Length', thumbnailBuffer.length);
reply.header('Cache-Control', 'public, max-age=31536000'); // 1 year
return reply.send(thumbnailBuffer);
}
catch (error) {
logger_1.logger.error('Thumbnail serving error:', error);
return reply.code(500).send({
message: error instanceof Error ? error.message : 'Failed to serve thumbnail',
});
}
});
/**
* Get public video metadata for embedding
* GET /api/videos/:id/metadata
* Public endpoint - returns essential metadata for video players
*/
fastify.get('/:id/metadata', async (request, reply) => {
try {
const videoId = parseInt(request.params.id);
if (isNaN(videoId)) {
return reply.code(400).send({ message: 'Invalid video ID' });
}
// Fetch video from database
const video = await database_1.prisma.video.findUnique({
where: { id: videoId },
});
if (!video) {
return reply.code(404).send({ message: 'Video not found' });
}
// Construct public URLs
const baseUrl = process.env.MEDIA_API_PUBLIC_URL || 'http://localhost:4100';
const streamUrl = `${baseUrl}/api/videos/${video.id}/stream`;
const thumbnailUrl = video.thumbnailPath
? `${baseUrl}/api/videos/${video.id}/thumbnail`
: null;
// Return public metadata
return {
id: video.id,
title: video.title || video.filename,
durationSeconds: video.durationSeconds,
width: video.width,
height: video.height,
orientation: video.orientation,
hasAudio: video.hasAudio,
quality: video.quality,
streamUrl,
thumbnailUrl,
createdAt: video.createdAt,
};
}
catch (error) {
logger_1.logger.error('Metadata retrieval error:', error);
return reply.code(500).send({
message: error instanceof Error ? error.message : 'Failed to retrieve metadata',
});
}
});
/**
* Health check for streaming routes
*/
fastify.get('/stream/health', async () => {
return {
status: 'ok',
service: 'video-streaming',
};
});
}
//# sourceMappingURL=video-streaming.routes.js.map

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,3 @@
import { FastifyInstance } from 'fastify';
export declare function videoTrackingRoutes(fastify: FastifyInstance): Promise<void>;
//# sourceMappingURL=video-tracking.routes.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"video-tracking.routes.d.ts","sourceRoot":"","sources":["../../../../src/modules/media/routes/video-tracking.routes.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAgC,MAAM,SAAS,CAAC;AA8BxE,wBAAsB,mBAAmB,CAAC,OAAO,EAAE,eAAe,iBAqPjE"}

View File

@@ -0,0 +1,207 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.videoTrackingRoutes = videoTrackingRoutes;
const auth_1 = require("../middleware/auth");
const video_analytics_service_1 = require("../services/video-analytics.service");
const logger_1 = require("../../../utils/logger");
const zod_1 = require("zod");
// Rate limiting: 100 requests per minute per IP for tracking
const trackingRateLimit = {
max: 100,
timeWindow: '1 minute',
};
// Validation schemas
const recordViewSchema = zod_1.z.object({
videoId: zod_1.z.number(),
referer: zod_1.z.string().optional(),
});
const recordEventSchema = zod_1.z.object({
videoId: zod_1.z.number(),
viewId: zod_1.z.number().optional(),
eventType: zod_1.z.enum(['play', 'pause', 'seek', 'complete']),
timestamp: zod_1.z.number().min(0),
});
const updateWatchTimeSchema = zod_1.z.object({
viewId: zod_1.z.number(),
watchTimeSeconds: zod_1.z.number().min(0),
});
async function videoTrackingRoutes(fastify) {
/**
* POST /track/view
* Record a new video view (called when video starts loading)
* Public endpoint - no auth required, but optionally uses auth if available
*/
fastify.post('/view', {
preHandler: auth_1.optionalAuth,
config: {
rateLimit: trackingRateLimit,
},
}, async (request, reply) => {
const { videoId, referer } = request.body;
// Validate input
try {
recordViewSchema.parse(request.body);
}
catch (error) {
return reply.code(400).send({ message: 'Invalid input', error });
}
try {
// Get user ID if authenticated
const userId = request.user?.id;
// Get IP address and user agent from request
const ipAddress = request.ip;
const userAgent = request.headers['user-agent'];
// Record the view
const viewId = await video_analytics_service_1.videoAnalyticsService.recordView({
videoId,
userId,
ipAddress,
userAgent,
referer,
});
return {
success: true,
viewId,
};
}
catch (error) {
logger_1.logger.error('Failed to record view', { error, videoId });
// Don't fail the request - we don't want analytics failures to break playback
return {
success: false,
viewId: null,
};
}
});
/**
* POST /track/event
* Record a video event (play, pause, seek, complete)
* Public endpoint - no auth required
*/
fastify.post('/event', {
config: {
rateLimit: trackingRateLimit,
},
}, async (request, reply) => {
const { videoId, viewId, eventType, timestamp } = request.body;
// Validate input
try {
recordEventSchema.parse(request.body);
}
catch (error) {
return reply.code(400).send({ message: 'Invalid input', error });
}
try {
await video_analytics_service_1.videoAnalyticsService.recordEvent({
videoId,
viewId,
eventType,
timestamp,
});
return {
success: true,
};
}
catch (error) {
logger_1.logger.error('Failed to record event', { error, videoId, eventType });
// Don't fail the request - analytics failures shouldn't break playback
return {
success: false,
};
}
});
/**
* POST /track/heartbeat
* Update watch time for a view (called every 10 seconds during playback)
* Public endpoint - no auth required
*/
fastify.post('/heartbeat', {
config: {
rateLimit: {
max: 200, // Higher limit for heartbeats (every 10s)
timeWindow: '1 minute',
},
},
}, async (request, reply) => {
const { viewId, watchTimeSeconds } = request.body;
// Validate input
try {
updateWatchTimeSchema.parse(request.body);
}
catch (error) {
return reply.code(400).send({ message: 'Invalid input', error });
}
try {
await video_analytics_service_1.videoAnalyticsService.updateWatchTime(viewId, watchTimeSeconds);
return {
success: true,
};
}
catch (error) {
logger_1.logger.error('Failed to update watch time', { error, viewId });
// Don't fail the request
return {
success: false,
};
}
});
/**
* POST /track/batch
* Batch record multiple events (useful for reducing requests)
* Public endpoint - no auth required
*/
fastify.post('/batch', {
config: {
rateLimit: {
max: 50,
timeWindow: '1 minute',
},
},
}, async (request, reply) => {
const { events } = request.body;
if (!Array.isArray(events) || events.length === 0) {
return reply.code(400).send({ message: 'Events array is required' });
}
if (events.length > 50) {
return reply.code(400).send({ message: 'Maximum 50 events per batch' });
}
try {
const results = await Promise.allSettled(events.map(async (event) => {
switch (event.type) {
case 'view':
return video_analytics_service_1.videoAnalyticsService.recordView(event.data);
case 'event':
return video_analytics_service_1.videoAnalyticsService.recordEvent(event.data);
case 'heartbeat':
return video_analytics_service_1.videoAnalyticsService.updateWatchTime(event.data.viewId, event.data.watchTimeSeconds);
default:
throw new Error(`Unknown event type: ${event.type}`);
}
}));
const successful = results.filter((r) => r.status === 'fulfilled').length;
const failed = results.filter((r) => r.status === 'rejected').length;
return {
success: true,
total: events.length,
successful,
failed,
};
}
catch (error) {
logger_1.logger.error('Failed to process batch tracking', { error });
return reply.code(500).send({ message: 'Failed to process batch' });
}
});
/**
* GET /track/health
* Health check for tracking endpoints
*/
fastify.get('/health', async (request, reply) => {
return {
status: 'ok',
service: 'video-tracking',
timestamp: new Date().toISOString(),
};
});
}
//# sourceMappingURL=video-tracking.routes.js.map

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,3 @@
import { FastifyInstance } from 'fastify';
export declare function videosRoutes(fastify: FastifyInstance): Promise<void>;
//# sourceMappingURL=videos.routes.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"videos.routes.d.ts","sourceRoot":"","sources":["../../../../src/modules/media/routes/videos.routes.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAgC,MAAM,SAAS,CAAC;AAmBxE,wBAAsB,YAAY,CAAC,OAAO,EAAE,eAAe,iBAoc1D"}

View File

@@ -0,0 +1,369 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.videosRoutes = videosRoutes;
const database_1 = require("../../../config/database");
const auth_1 = require("../middleware/auth");
const zod_1 = require("zod");
const promises_1 = require("fs/promises");
const thumbnail_service_1 = require("../services/thumbnail.service");
const logger_1 = require("../../../utils/logger");
async function videosRoutes(fastify) {
// List videos (admin only)
fastify.get('/', {
preHandler: auth_1.requireAdminRole,
}, async (request, reply) => {
const limit = parseInt(request.query.limit || '50');
const offset = parseInt(request.query.offset || '0');
const search = request.query.search;
const orientation = request.query.orientation;
const producers = request.query.producers?.split(',').filter(Boolean);
// Build Prisma WHERE clause
const where = {};
if (search) {
where.title = {
contains: search,
mode: 'insensitive',
};
}
if (orientation) {
where.orientation = orientation;
}
if (producers && producers.length > 0) {
where.producer = {
in: producers,
};
}
const videos = await database_1.prisma.video.findMany({
where,
select: {
id: true,
title: true,
filename: true,
durationSeconds: true,
fileSize: true,
width: true,
height: true,
orientation: true,
producer: true,
thumbnailPath: true,
createdAt: true,
isPublished: true,
publishedAt: true,
scheduledPublishAt: true,
scheduledUnpublishAt: true,
category: true,
},
orderBy: {
createdAt: 'desc',
},
take: limit,
skip: offset,
});
// Get total count
const total = await database_1.prisma.video.count({ where });
// Map videos to include thumbnailUrl
const videosWithThumbnails = videos.map((video) => ({
...video,
duration: video.durationSeconds, // Add duration alias for frontend
thumbnailUrl: video.thumbnailPath ? `/media/videos/${video.id}/thumbnail` : null,
}));
return {
videos: videosWithThumbnails,
total,
limit,
offset,
};
});
// Get single video (admin only for now)
fastify.get('/:id', {
preHandler: auth_1.requireAdminRole,
}, async (request, reply) => {
const videoId = parseInt(request.params.id);
const video = await database_1.prisma.video.findUnique({
where: { id: videoId },
});
if (!video) {
return reply.code(404).send({ message: 'Video not found' });
}
return {
video: {
...video,
duration: video.durationSeconds,
thumbnailUrl: video.thumbnailPath ? `/media/videos/${video.id}/thumbnail` : null,
},
};
});
// Get list of producers (admin only)
fastify.get('/producers', {
preHandler: auth_1.requireAdminRole,
}, async (request, reply) => {
const videos = await database_1.prisma.video.findMany({
where: {
producer: { not: null },
},
select: {
producer: true,
},
distinct: ['producer'],
});
return videos.map((v) => v.producer).filter(Boolean);
});
// Health check for videos routes
fastify.get('/health', async (request, reply) => {
// Test database connection by counting videos
const count = await database_1.prisma.video.count();
return {
status: 'ok',
videosCount: count,
};
});
// ========================================================================
// PUBLISHING ROUTES (replaces copy-to-public)
// ========================================================================
// Zod schemas for publishing
const PublishSchema = zod_1.z.object({
category: zod_1.z.enum(['videos', 'curated', 'compilations', 'playback', 'highlights']),
});
const BulkPublishSchema = zod_1.z.object({
videoIds: zod_1.z.array(zod_1.z.number().int().positive()).min(1).max(100),
category: zod_1.z.enum(['videos', 'curated', 'compilations', 'playback', 'highlights']),
});
const BulkUnpublishSchema = zod_1.z.object({
videoIds: zod_1.z.array(zod_1.z.number().int().positive()).min(1).max(100),
});
// POST /videos/:id/publish - Publish single video
fastify.post('/:id/publish', { preHandler: auth_1.requireAdminRole }, async (request, reply) => {
const videoId = parseInt(request.params.id);
const parseResult = PublishSchema.safeParse(request.body);
if (!parseResult.success) {
return reply.code(400).send({ message: 'Invalid category', errors: parseResult.error.errors });
}
const { category } = parseResult.data;
try {
const video = await database_1.prisma.video.update({
where: { id: videoId },
data: {
isPublished: true,
publishedAt: new Date(),
category,
},
});
logger_1.logger.info(`Video ${videoId} published to ${category}`);
return { success: true, video };
}
catch (error) {
logger_1.logger.error(`Error publishing video ${videoId}:`, error);
return reply.code(500).send({ message: 'Failed to publish video', error: error.message });
}
});
// POST /videos/:id/unpublish - Unpublish single video
fastify.post('/:id/unpublish', { preHandler: auth_1.requireAdminRole }, async (request, reply) => {
const videoId = parseInt(request.params.id);
try {
const video = await database_1.prisma.video.update({
where: { id: videoId },
data: {
isPublished: false,
publishedAt: null,
// Keep category for re-publishing
},
});
logger_1.logger.info(`Video ${videoId} unpublished`);
return { success: true, video };
}
catch (error) {
logger_1.logger.error(`Error unpublishing video ${videoId}:`, error);
return reply.code(500).send({ message: 'Failed to unpublish video', error: error.message });
}
});
// POST /videos/bulk-publish - Publish multiple videos
fastify.post('/bulk-publish', { preHandler: auth_1.requireAdminRole }, async (request, reply) => {
const parseResult = BulkPublishSchema.safeParse(request.body);
if (!parseResult.success) {
return reply.code(400).send({ message: 'Invalid request', errors: parseResult.error.errors });
}
const { videoIds, category } = parseResult.data;
try {
const result = await database_1.prisma.video.updateMany({
where: { id: { in: videoIds } },
data: {
isPublished: true,
publishedAt: new Date(),
category,
},
});
logger_1.logger.info(`Bulk published ${result.count} videos to ${category}`);
return { success: true, count: result.count };
}
catch (error) {
logger_1.logger.error(`Error bulk publishing videos:`, error);
return reply.code(500).send({ message: 'Failed to publish videos', error: error.message });
}
});
// POST /videos/bulk-unpublish - Unpublish multiple videos
fastify.post('/bulk-unpublish', { preHandler: auth_1.requireAdminRole }, async (request, reply) => {
const parseResult = BulkUnpublishSchema.safeParse(request.body);
if (!parseResult.success) {
return reply.code(400).send({ message: 'Invalid request', errors: parseResult.error.errors });
}
const { videoIds } = parseResult.data;
try {
const result = await database_1.prisma.video.updateMany({
where: { id: { in: videoIds } },
data: {
isPublished: false,
publishedAt: null,
},
});
logger_1.logger.info(`Bulk unpublished ${result.count} videos`);
return { success: true, count: result.count };
}
catch (error) {
logger_1.logger.error(`Error bulk unpublishing videos:`, error);
return reply.code(500).send({ message: 'Failed to unpublish videos', error: error.message });
}
});
// POST /videos/:id/lock - Lock published video
fastify.post('/:id/lock', { preHandler: auth_1.requireAdminRole }, async (request, reply) => {
const videoId = parseInt(request.params.id);
const userId = request.user?.id;
try {
const video = await database_1.prisma.video.update({
where: { id: videoId },
data: {
isLocked: true,
lockedAt: new Date(),
lockedById: userId,
},
});
logger_1.logger.info(`Video ${videoId} locked by user ${userId}`);
return { success: true, video };
}
catch (error) {
logger_1.logger.error(`Error locking video ${videoId}:`, error);
return reply.code(500).send({ message: 'Failed to lock video', error: error.message });
}
});
// POST /videos/:id/unlock - Unlock published video
fastify.post('/:id/unlock', { preHandler: auth_1.requireAdminRole }, async (request, reply) => {
const videoId = parseInt(request.params.id);
try {
const video = await database_1.prisma.video.update({
where: { id: videoId },
data: {
isLocked: false,
lockedAt: null,
lockedById: null,
},
});
logger_1.logger.info(`Video ${videoId} unlocked`);
return { success: true, video };
}
catch (error) {
logger_1.logger.error(`Error unlocking video ${videoId}:`, error);
return reply.code(500).send({ message: 'Failed to unlock video', error: error.message });
}
});
// ========================================================================
// THUMBNAIL GENERATION ROUTES
// ========================================================================
// POST /videos/:id/generate-thumbnail - Generate thumbnail for single video
fastify.post('/:id/generate-thumbnail', { preHandler: auth_1.requireAdminRole }, async (request, reply) => {
const videoId = parseInt(request.params.id);
try {
const video = await database_1.prisma.video.findUnique({
where: { id: videoId },
});
if (!video) {
return reply.code(404).send({ message: 'Video not found' });
}
// Check if video file exists
try {
await (0, promises_1.access)(video.path);
}
catch {
return reply.code(400).send({ message: 'Video file not found on disk' });
}
// Generate thumbnail
const thumbnailPath = await thumbnail_service_1.ThumbnailService.generateThumbnail({
videoPath: video.path,
videoId: video.id,
duration: video.durationSeconds,
orientation: video.orientation,
});
// Update video with thumbnail path
const updatedVideo = await database_1.prisma.video.update({
where: { id: videoId },
data: { thumbnailPath },
});
logger_1.logger.info(`Thumbnail generated for video ${videoId}`);
return { success: true, video: updatedVideo };
}
catch (error) {
logger_1.logger.error(`Error generating thumbnail for video ${videoId}:`, error);
return reply.code(500).send({ message: 'Failed to generate thumbnail', error: error.message });
}
});
// POST /videos/bulk-generate-thumbnails - Generate thumbnails for all videos without them
fastify.post('/bulk-generate-thumbnails', { preHandler: auth_1.requireAdminRole }, async (request, reply) => {
try {
// Find all videos without thumbnails
const videos = await database_1.prisma.video.findMany({
where: {
thumbnailPath: null,
isValid: true,
},
select: {
id: true,
path: true,
durationSeconds: true,
orientation: true,
},
});
logger_1.logger.info(`Found ${videos.length} videos without thumbnails`);
const results = {
total: videos.length,
succeeded: 0,
failed: 0,
errors: [],
};
for (const video of videos) {
try {
// Check if video file exists
await (0, promises_1.access)(video.path);
// Generate thumbnail
const thumbnailPath = await thumbnail_service_1.ThumbnailService.generateThumbnail({
videoPath: video.path,
videoId: video.id,
duration: video.durationSeconds,
orientation: video.orientation,
});
// Update video with thumbnail path
await database_1.prisma.video.update({
where: { id: video.id },
data: { thumbnailPath },
});
results.succeeded++;
logger_1.logger.info(`Thumbnail generated for video ${video.id}`);
}
catch (error) {
results.failed++;
results.errors.push({
videoId: video.id,
error: error.message,
});
logger_1.logger.error(`Failed to generate thumbnail for video ${video.id}:`, error);
}
}
return {
success: true,
results,
};
}
catch (error) {
logger_1.logger.error('Error bulk generating thumbnails:', error);
return reply.code(500).send({ message: 'Failed to bulk generate thumbnails', error: error.message });
}
});
}
//# sourceMappingURL=videos.routes.js.map

File diff suppressed because one or more lines are too long