changemaker.lite/api/dist/modules/media/routes/video-streaming.routes.js

227 lines
9.2 KiB
JavaScript

"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