import Fastify from 'fastify'; import cors from '@fastify/cors'; import multipart from '@fastify/multipart'; import { env } from './config/env'; import { logger } from './utils/logger'; import { videosRoutes } from './modules/media/routes/videos.routes'; import { videoStreamingRoutes } from './modules/media/routes/video-streaming.routes'; import { reactionsRoutes } from './modules/media/routes/reactions.routes'; import { publicRoutes } from './modules/media/routes/public.routes'; import { chatStreamRoutes } from './modules/media/routes/chat-stream.routes'; import { commentsRoutes } from './modules/media/routes/comments.routes'; import { uploadRoutes } from './modules/media/routes/upload.routes'; import { videoActionsRoutes } from './modules/media/routes/video-actions.routes'; import { videoScheduleRoutes } from './modules/media/routes/video-schedule.routes'; import { videoTrackingRoutes } from './modules/media/routes/video-tracking.routes'; import { commentAdminRoutes } from './modules/media/routes/comment-admin.routes'; import { chatNotificationsRoutes } from './modules/media/routes/chat-notifications.routes'; import { chatThreadsRoutes } from './modules/media/routes/chat-threads.routes'; import { userProfileRoutes } from './modules/media/routes/user-profile.routes'; import { shortsRoutes } from './modules/media/routes/shorts.routes'; import { upvoteRoutes } from './modules/media/routes/upvote.routes'; import { videoScheduleQueueService } from './services/video-schedule-queue.service'; import { videoFetchQueueService } from './services/video-fetch-queue.service'; import { hlsTranscodeQueueService } from './services/hls-transcode-queue.service'; import { fetchRoutes } from './modules/media/routes/fetch.routes'; import { hlsRoutes } from './modules/media/routes/hls.routes'; import { playlistsPublicRoutes } from './modules/media/routes/playlists-public.routes'; import { playlistsUserRoutes } from './modules/media/routes/playlists-user.routes'; import { playlistsAdminRoutes } from './modules/media/routes/playlists-admin.routes'; import { photosRoutes } from './modules/media/routes/photos.routes'; import { photoUploadRoutes } from './modules/media/routes/photo-upload.routes'; import { photoAlbumsRoutes } from './modules/media/routes/photo-albums.routes'; import { photosPublicRoutes } from './modules/media/routes/photos-public.routes'; import { photoEngagementRoutes } from './modules/media/routes/photo-engagement.routes'; import { documentUploadRoutes } from './modules/media/routes/document-upload.routes'; import { documentsRoutes } from './modules/media/routes/documents.routes'; import { mediaErrorHandler } from './modules/media/middleware/error-handler'; // Add BigInt serialization support for Prisma BigInt fields // This converts BigInt values to strings when JSON.stringify() is called (BigInt.prototype as any).toJSON = function() { return this.toString(); }; const fastify = Fastify({ logger: { level: env.NODE_ENV === 'production' ? 'info' : 'debug', }, maxParamLength: 500, trustProxy: true, }); fastify.setErrorHandler(mediaErrorHandler); // Graceful shutdown handler process.on('SIGTERM', async () => { logger.info('SIGTERM received, shutting down gracefully...'); await videoScheduleQueueService.close(); await videoFetchQueueService.close(); await hlsTranscodeQueueService.close(); fastify.close(() => { logger.info('Media API server closed'); process.exit(0); }); }); // Global error handlers process.on('unhandledRejection', (reason, promise) => { logger.error('Unhandled Promise Rejection in Media API', { reason: JSON.stringify(reason), promise: JSON.stringify(promise) }); }); process.on('uncaughtException', (error) => { logger.error('Uncaught Exception in Media API', { error: error instanceof Error ? error.message : JSON.stringify(error) }); fastify.close(() => { process.exit(1); }); }); // Start server const start = async () => { try { // CORS configuration — allow admin app + MkDocs docs site const allowedOrigins = env.CORS_ORIGINS.split(',').map(o => o.trim()); // Auto-add MkDocs origins so video cards/players work in docs const mkdocsOrigin = `http://localhost:${env.MKDOCS_PORT || 4003}`; if (!allowedOrigins.includes(mkdocsOrigin)) { allowedOrigins.push(mkdocsOrigin); } // Also allow the docs subdomain in production (docs.domain.org) for (const origin of [...allowedOrigins]) { const match = origin.match(/^(https?:\/\/)app\./); if (match) { const docsOrigin = origin.replace(/^(https?:\/\/)app\./, '$1docs.'); if (!allowedOrigins.includes(docsOrigin)) { allowedOrigins.push(docsOrigin); } } } await fastify.register(cors, { origin: (origin, cb) => { // Allow requests with no origin (mobile apps, curl, etc.) if (!origin) { cb(null, true); return; } // Check if origin is in allowed list if (allowedOrigins.includes(origin)) { cb(null, true); } else { cb(new Error('CORS not allowed'), false); } }, credentials: true, }); // Multipart support for file uploads (10GB limit) await fastify.register(multipart, { limits: { fileSize: env.MAX_UPLOAD_SIZE_GB * 1024 * 1024 * 1024, }, }); // Health check fastify.get('/health', async () => { return { status: 'ok', timestamp: new Date().toISOString(), service: 'media-api' }; }); // Register routes await fastify.register(videosRoutes, { prefix: '/api/videos' }); await fastify.register(videoStreamingRoutes, { prefix: '/api/videos' }); await fastify.register(uploadRoutes, { prefix: '/api/videos' }); await fastify.register(videoActionsRoutes, { prefix: '/api/videos' }); await fastify.register(videoScheduleRoutes, { prefix: '/api/videos' }); await fastify.register(hlsRoutes, { prefix: '/api' }); await fastify.register(videoTrackingRoutes, { prefix: '/api/track' }); await fastify.register(reactionsRoutes, { prefix: '/api/reactions' }); await fastify.register(publicRoutes, { prefix: '/api' }); await fastify.register(commentsRoutes, { prefix: '/api' }); await fastify.register(chatStreamRoutes, { prefix: '/api' }); await fastify.register(commentAdminRoutes, { prefix: '/api/media' }); await fastify.register(chatNotificationsRoutes, { prefix: '/api/media' }); // Signed URL generation (replaces ?token=JWT pattern, 2026-04-12). const { signRoutes } = await import('./modules/media/routes/sign.routes'); await fastify.register(signRoutes, { prefix: '/api/media' }); await fastify.register(chatThreadsRoutes, { prefix: '/api/media' }); await fastify.register(userProfileRoutes, { prefix: '/api/media' }); await fastify.register(fetchRoutes, { prefix: '/api/videos' }); await fastify.register(shortsRoutes, { prefix: '/api' }); await fastify.register(upvoteRoutes, { prefix: '/api' }); await fastify.register(playlistsPublicRoutes, { prefix: '/api/playlists' }); await fastify.register(playlistsUserRoutes, { prefix: '/api/playlists' }); await fastify.register(playlistsAdminRoutes, { prefix: '/api/media' }); // Photo gallery routes await fastify.register(photosRoutes, { prefix: '/api/photos' }); await fastify.register(photoUploadRoutes, { prefix: '/api/photos' }); await fastify.register(photoAlbumsRoutes, { prefix: '/api/albums' }); await fastify.register(photosPublicRoutes, { prefix: '/api' }); await fastify.register(photoEngagementRoutes, { prefix: '/api' }); // Document routes (PDFs, docx, etc. for volunteer resources) await fastify.register(documentUploadRoutes, { prefix: '/api/documents' }); await fastify.register(documentsRoutes, { prefix: '/api/documents' }); // 404 handler for unmatched routes fastify.setNotFoundHandler((_request, reply) => { reply.status(404).send({ error: { message: 'Route not found', code: 'NOT_FOUND' } }); }); const port = env.MEDIA_API_PORT; const host = '0.0.0.0'; await fastify.listen({ port, host }); logger.info(`Media API listening on http://${host}:${port}`); // Start video schedule queue worker videoScheduleQueueService.startWorker(); logger.info('Video schedule queue worker initialized'); // Start video fetch queue worker videoFetchQueueService.startWorker(); logger.info('Video fetch queue worker initialized'); // Start HLS transcode worker (always on; the ENABLE_HLS_TRANSCODE flag // gates enqueue, not worker registration, so existing PENDING jobs from // a prior run still process if the flag was flipped back on). hlsTranscodeQueueService.startWorker(); logger.info('HLS transcode queue worker initialized'); if (env.ENABLE_MEDIA_FEATURES !== 'true') { logger.warn('Media features are disabled (ENABLE_MEDIA_FEATURES=false)'); } } catch (err) { logger.error('Media API startup error', { error: err instanceof Error ? err.message : JSON.stringify(err) }); process.exit(1); } }; start();