Tonne of debugging - getting ready for the production builds
This commit is contained in:
3
api/dist/modules/media/routes/comments.routes.d.ts
vendored
Normal file
3
api/dist/modules/media/routes/comments.routes.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
export declare function commentsRoutes(fastify: FastifyInstance): Promise<void>;
|
||||
//# sourceMappingURL=comments.routes.d.ts.map
|
||||
1
api/dist/modules/media/routes/comments.routes.d.ts.map
vendored
Normal file
1
api/dist/modules/media/routes/comments.routes.d.ts.map
vendored
Normal 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"}
|
||||
8
api/dist/modules/media/routes/comments.routes.js
vendored
Normal file
8
api/dist/modules/media/routes/comments.routes.js
vendored
Normal 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
|
||||
1
api/dist/modules/media/routes/comments.routes.js.map
vendored
Normal file
1
api/dist/modules/media/routes/comments.routes.js.map
vendored
Normal 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"}
|
||||
7
api/dist/modules/media/routes/public-media.routes.d.ts
vendored
Normal file
7
api/dist/modules/media/routes/public-media.routes.d.ts
vendored
Normal 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
|
||||
1
api/dist/modules/media/routes/public-media.routes.d.ts.map
vendored
Normal file
1
api/dist/modules/media/routes/public-media.routes.d.ts.map
vendored
Normal 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"}
|
||||
740
api/dist/modules/media/routes/public-media.routes.js
vendored
Normal file
740
api/dist/modules/media/routes/public-media.routes.js
vendored
Normal 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
|
||||
1
api/dist/modules/media/routes/public-media.routes.js.map
vendored
Normal file
1
api/dist/modules/media/routes/public-media.routes.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
3
api/dist/modules/media/routes/reactions.routes.d.ts
vendored
Normal file
3
api/dist/modules/media/routes/reactions.routes.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
export declare function reactionsRoutes(fastify: FastifyInstance): Promise<void>;
|
||||
//# sourceMappingURL=reactions.routes.d.ts.map
|
||||
1
api/dist/modules/media/routes/reactions.routes.d.ts.map
vendored
Normal file
1
api/dist/modules/media/routes/reactions.routes.d.ts.map
vendored
Normal 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"}
|
||||
102
api/dist/modules/media/routes/reactions.routes.js
vendored
Normal file
102
api/dist/modules/media/routes/reactions.routes.js
vendored
Normal 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
|
||||
1
api/dist/modules/media/routes/reactions.routes.js.map
vendored
Normal file
1
api/dist/modules/media/routes/reactions.routes.js.map
vendored
Normal 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"}
|
||||
3
api/dist/modules/media/routes/upload.routes.d.ts
vendored
Normal file
3
api/dist/modules/media/routes/upload.routes.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
export declare function uploadRoutes(fastify: FastifyInstance): Promise<void>;
|
||||
//# sourceMappingURL=upload.routes.d.ts.map
|
||||
1
api/dist/modules/media/routes/upload.routes.d.ts.map
vendored
Normal file
1
api/dist/modules/media/routes/upload.routes.d.ts.map
vendored
Normal 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"}
|
||||
265
api/dist/modules/media/routes/upload.routes.js
vendored
Normal file
265
api/dist/modules/media/routes/upload.routes.js
vendored
Normal 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
|
||||
1
api/dist/modules/media/routes/upload.routes.js.map
vendored
Normal file
1
api/dist/modules/media/routes/upload.routes.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
3
api/dist/modules/media/routes/video-actions.routes.d.ts
vendored
Normal file
3
api/dist/modules/media/routes/video-actions.routes.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
export declare function videoActionsRoutes(fastify: FastifyInstance): Promise<void>;
|
||||
//# sourceMappingURL=video-actions.routes.d.ts.map
|
||||
1
api/dist/modules/media/routes/video-actions.routes.d.ts.map
vendored
Normal file
1
api/dist/modules/media/routes/video-actions.routes.d.ts.map
vendored
Normal 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"}
|
||||
248
api/dist/modules/media/routes/video-actions.routes.js
vendored
Normal file
248
api/dist/modules/media/routes/video-actions.routes.js
vendored
Normal 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
|
||||
1
api/dist/modules/media/routes/video-actions.routes.js.map
vendored
Normal file
1
api/dist/modules/media/routes/video-actions.routes.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
3
api/dist/modules/media/routes/video-schedule.routes.d.ts
vendored
Normal file
3
api/dist/modules/media/routes/video-schedule.routes.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
export declare function videoScheduleRoutes(fastify: FastifyInstance): Promise<void>;
|
||||
//# sourceMappingURL=video-schedule.routes.d.ts.map
|
||||
1
api/dist/modules/media/routes/video-schedule.routes.d.ts.map
vendored
Normal file
1
api/dist/modules/media/routes/video-schedule.routes.d.ts.map
vendored
Normal 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"}
|
||||
266
api/dist/modules/media/routes/video-schedule.routes.js
vendored
Normal file
266
api/dist/modules/media/routes/video-schedule.routes.js
vendored
Normal 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
|
||||
1
api/dist/modules/media/routes/video-schedule.routes.js.map
vendored
Normal file
1
api/dist/modules/media/routes/video-schedule.routes.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
3
api/dist/modules/media/routes/video-streaming.routes.d.ts
vendored
Normal file
3
api/dist/modules/media/routes/video-streaming.routes.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
export declare function videoStreamingRoutes(fastify: FastifyInstance): Promise<void>;
|
||||
//# sourceMappingURL=video-streaming.routes.d.ts.map
|
||||
1
api/dist/modules/media/routes/video-streaming.routes.d.ts.map
vendored
Normal file
1
api/dist/modules/media/routes/video-streaming.routes.d.ts.map
vendored
Normal 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"}
|
||||
227
api/dist/modules/media/routes/video-streaming.routes.js
vendored
Normal file
227
api/dist/modules/media/routes/video-streaming.routes.js
vendored
Normal 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
|
||||
1
api/dist/modules/media/routes/video-streaming.routes.js.map
vendored
Normal file
1
api/dist/modules/media/routes/video-streaming.routes.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
3
api/dist/modules/media/routes/video-tracking.routes.d.ts
vendored
Normal file
3
api/dist/modules/media/routes/video-tracking.routes.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
export declare function videoTrackingRoutes(fastify: FastifyInstance): Promise<void>;
|
||||
//# sourceMappingURL=video-tracking.routes.d.ts.map
|
||||
1
api/dist/modules/media/routes/video-tracking.routes.d.ts.map
vendored
Normal file
1
api/dist/modules/media/routes/video-tracking.routes.d.ts.map
vendored
Normal 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"}
|
||||
207
api/dist/modules/media/routes/video-tracking.routes.js
vendored
Normal file
207
api/dist/modules/media/routes/video-tracking.routes.js
vendored
Normal 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
|
||||
1
api/dist/modules/media/routes/video-tracking.routes.js.map
vendored
Normal file
1
api/dist/modules/media/routes/video-tracking.routes.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
3
api/dist/modules/media/routes/videos.routes.d.ts
vendored
Normal file
3
api/dist/modules/media/routes/videos.routes.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
export declare function videosRoutes(fastify: FastifyInstance): Promise<void>;
|
||||
//# sourceMappingURL=videos.routes.d.ts.map
|
||||
1
api/dist/modules/media/routes/videos.routes.d.ts.map
vendored
Normal file
1
api/dist/modules/media/routes/videos.routes.d.ts.map
vendored
Normal 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"}
|
||||
369
api/dist/modules/media/routes/videos.routes.js
vendored
Normal file
369
api/dist/modules/media/routes/videos.routes.js
vendored
Normal 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
|
||||
1
api/dist/modules/media/routes/videos.routes.js.map
vendored
Normal file
1
api/dist/modules/media/routes/videos.routes.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user