"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