import archiver from 'archiver'; import { Request, Response } from 'express'; import { Readable } from 'stream'; import { container } from '../config/index.js'; import INTERNAL_CONFIG from '../config/internal-config.js'; import { RecordingHelper } from '../helpers/index.js'; import { errorRecordingNotFound, errorRecordingsNotFromSameRoom, handleError, internalError, rejectRequestFromMeetError } from '../models/error.model.js'; import { LoggerService, MeetStorageService, RecordingService } from '../services/index.js'; export const startRecording = async (req: Request, res: Response) => { const logger = container.get(LoggerService); const recordingService = container.get(RecordingService); const { roomId } = req.body; logger.info(`Starting recording in room '${roomId}'`); try { const recordingInfo = await recordingService.startRecording(roomId); res.setHeader( 'Location', `${req.protocol}://${req.get('host')}${INTERNAL_CONFIG.API_BASE_PATH_V1}/recordings/${recordingInfo.recordingId}` ); return res.status(201).json(recordingInfo); } catch (error) { handleError(res, error, `starting recording in room '${roomId}'`); } }; export const getRecordings = async (req: Request, res: Response) => { const logger = container.get(LoggerService); const recordingService = container.get(RecordingService); const queryParams = req.query; // If recording token is present, retrieve only recordings for the room associated with the token const payload = req.session?.tokenClaims; if (payload && payload.video) { const roomId = payload.video.room; queryParams.roomId = roomId; logger.info(`Getting recordings for room '${roomId}'`); } else { logger.info('Getting all recordings'); } try { const { recordings, isTruncated, nextPageToken } = await recordingService.getAllRecordings(queryParams); const maxItems = Number(queryParams.maxItems); return res.status(200).json({ recordings, pagination: { isTruncated, nextPageToken, maxItems } }); } catch (error) { handleError(res, error, 'getting recordings'); } }; export const bulkDeleteRecordings = async (req: Request, res: Response) => { const logger = container.get(LoggerService); const recordingService = container.get(RecordingService); const { recordingIds } = req.query; // If recording token is present, delete only recordings for the room associated with the token const payload = req.session?.tokenClaims; let roomId: string | undefined; if (payload && payload.video) { roomId = payload.video.room; } logger.info(`Deleting recordings: ${recordingIds}`); try { const recordingIdsArray = (recordingIds as string).split(','); const { deleted, failed } = await recordingService.bulkDeleteRecordingsAndAssociatedFiles( recordingIdsArray, roomId ); // All recordings were successfully deleted if (deleted.length > 0 && failed.length === 0) { return res.status(200).json({ message: 'All recordings deleted successfully', deleted }); } // Some or all recordings could not be deleted return res.status(400).json({ message: `${failed.length} recording(s) could not be deleted`, deleted, failed }); } catch (error) { handleError(res, error, 'deleting recordings'); } }; export const getRecording = async (req: Request, res: Response) => { const logger = container.get(LoggerService); const recordingService = container.get(RecordingService); const recordingId = req.params.recordingId; const fields = req.query.fields as string | undefined; logger.info(`Getting recording '${recordingId}'`); try { const recordingInfo = await recordingService.getRecording(recordingId, fields); return res.status(200).json(recordingInfo); } catch (error) { handleError(res, error, `getting recording '${recordingId}'`); } }; export const stopRecording = async (req: Request, res: Response) => { const logger = container.get(LoggerService); const recordingId = req.params.recordingId; try { logger.info(`Stopping recording '${recordingId}'`); const recordingService = container.get(RecordingService); const recordingInfo = await recordingService.stopRecording(recordingId); res.setHeader( 'Location', `${req.protocol}://${req.get('host')}${INTERNAL_CONFIG.API_BASE_PATH_V1}/recordings/${recordingId}` ); return res.status(202).json(recordingInfo); } catch (error) { handleError(res, error, `stopping recording '${recordingId}'`); } }; export const deleteRecording = async (req: Request, res: Response) => { const logger = container.get(LoggerService); const recordingService = container.get(RecordingService); const recordingId = req.params.recordingId; logger.info(`Deleting recording '${recordingId}'`); try { await recordingService.deleteRecording(recordingId); return res.status(200).json({ message: `Recording '${recordingId}' deleted successfully` }); } catch (error) { handleError(res, error, `deleting recording '${recordingId}'`); } }; /** * Get recording media * * This controller endpoint retrieves a recording by its ID and streams it as a video/mp4 file. * It supports HTTP range requests, allowing for features like video seeking and partial downloads. * */ export const getRecordingMedia = async (req: Request, res: Response) => { const logger = container.get(LoggerService); const recordingId = req.params.recordingId; const range = req.headers.range; let fileStream: Readable | undefined; try { logger.info(`Streaming recording '${recordingId}'`); const recordingService = container.get(RecordingService); const result = await recordingService.getRecordingAsStream(recordingId, range); const { fileSize, start, end } = result; fileStream = result.fileStream; fileStream.on('error', (streamError) => { logger.error(`Error streaming recording '${recordingId}': ${streamError.message}`); if (!res.headersSent) { const error = internalError(`streaming recording '${recordingId}'`); rejectRequestFromMeetError(res, error); } res.end(); }); // Handle client disconnection req.on('close', () => { if (fileStream && !fileStream.destroyed) { logger.debug(`Client closed connection for recording media '${recordingId}'`); fileStream.destroy(); } }); // Handle partial requests (HTTP Range requests) if (range && fileSize && start !== undefined && end !== undefined) { const contentLength = end - start + 1; // Set headers for partial content response res.writeHead(206, { 'Content-Range': `bytes ${start}-${end}/${fileSize}`, 'Accept-Ranges': 'bytes', 'Content-Length': contentLength, 'Content-Type': 'video/mp4', 'Cache-Control': 'public, max-age=3600' }); } else { // Set headers for full content response res.writeHead(200, { 'Accept-Ranges': 'bytes', 'Content-Type': 'video/mp4', 'Content-Length': fileSize || undefined, 'Cache-Control': 'public, max-age=3600' }); } fileStream .pipe(res) .on('finish', () => { logger.debug(`Finished streaming recording '${recordingId}'`); res.end(); }) .on('error', (err) => { logger.error(`Error in response stream for recording '${recordingId}': ${err.message}`); if (!res.headersSent) { res.status(500).end(); } }); } catch (error) { if (fileStream && !fileStream.destroyed) { fileStream.destroy(); } handleError(res, error, `streaming recording '${recordingId}'`); } }; export const getRecordingUrl = async (req: Request, res: Response) => { const logger = container.get(LoggerService); const recordingId = req.params.recordingId; const privateAccess = req.query.privateAccess === 'true'; logger.info(`Getting URL for recording '${recordingId}'`); try { const storageService = container.get(MeetStorageService); const recordingSecrets = await storageService.getAccessRecordingSecrets(recordingId); if (!recordingSecrets) { const error = errorRecordingNotFound(recordingId); return rejectRequestFromMeetError(res, error); } const secret = privateAccess ? recordingSecrets.privateAccessSecret : recordingSecrets.publicAccessSecret; const recordingUrl = `${req.protocol}://${req.get('host')}/recording/${recordingId}?secret=${secret}`; return res.status(200).json({ url: recordingUrl }); } catch (error) { handleError(res, error, `getting URL for recording '${recordingId}'`); } }; export const downloadRecordingsZip = async (req: Request, res: Response) => { const logger = container.get(LoggerService); const recordingService = container.get(RecordingService); const recordingIds = req.query.recordingIds as string; const recordingIdsArray = (recordingIds as string).split(','); // If recording token is present, download only recordings for the room associated with the token const payload = req.session?.tokenClaims; let roomId: string | undefined; if (payload && payload.video) { roomId = payload.video.room; } // Filter recording IDs if a room ID is provided let validRecordingIds = recordingIdsArray; if (roomId) { validRecordingIds = recordingIdsArray.filter((recordingId) => { const { roomId: recRoomId } = RecordingHelper.extractInfoFromRecordingId(recordingId); const isValid = recRoomId === roomId; if (!isValid) { logger.warn(`Skipping recording '${recordingId}' as it does not belong to room '${roomId}'`); } return isValid; }); } if (validRecordingIds.length === 0) { logger.warn(`None of the provided recording IDs belong to room '${roomId}'`); const error = errorRecordingsNotFromSameRoom(roomId!); return rejectRequestFromMeetError(res, error); } logger.info(`Creating ZIP for recordings: ${recordingIds}`); res.setHeader('Content-Type', 'application/zip'); res.setHeader('Content-Disposition', 'attachment; filename="recordings.zip"'); const archive = archiver('zip', { zlib: { level: 0 } }); // Handle errors in the archive archive.on('error', (err) => { logger.error(`ZIP archive error: ${err.message}`); res.status(500).end(); }); // Pipe the archive to the response archive.pipe(res); for (const recordingId of validRecordingIds) { try { logger.debug(`Adding recording '${recordingId}' to ZIP`); const result = await recordingService.getRecordingAsStream(recordingId); const recordingInfo = await recordingService.getRecording(recordingId, 'filename'); const filename = recordingInfo.filename || `${recordingId}.mp4`; archive.append(result.fileStream, { name: filename }); } catch (error) { logger.error(`Error adding recording '${recordingId}' to ZIP: ${error}`); } } // Finalize the archive archive.finalize(); };