227 lines
9.2 KiB
JavaScript
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
|