330 lines
10 KiB
TypeScript
330 lines
10 KiB
TypeScript
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();
|
|
};
|