From ca348d1a4736281511a2c4859e34ea968447584d Mon Sep 17 00:00:00 2001 From: Carlos Santos <4a.santos@gmail.com> Date: Fri, 21 Mar 2025 15:13:20 +0100 Subject: [PATCH] backend: Enhance recording routes and controllers with new validation middleware and improve error handling --- backend/openapi/openvidu-meet-api.yaml | 247 +++++++++++++----- backend/package.json | 2 +- .../src/controllers/recording.controller.ts | 42 ++- .../recording-validator.middleware.ts | 90 +++++-- backend/src/models/error.model.ts | 12 +- backend/src/routes/recording.routes.ts | 39 ++- backend/src/services/recording.service.ts | 76 +++--- typings/src/recording.model.ts | 9 +- 8 files changed, 354 insertions(+), 163 deletions(-) diff --git a/backend/openapi/openvidu-meet-api.yaml b/backend/openapi/openvidu-meet-api.yaml index 23b1778..9361730 100644 --- a/backend/openapi/openvidu-meet-api.yaml +++ b/backend/openapi/openvidu-meet-api.yaml @@ -1,10 +1,10 @@ openapi: 3.0.1 info: version: v1 - title: OpenVidu Meet API + title: OpenVidu Meet REST API description: > - The OpenVidu Embedded API allows seamless integration of OpenVidu Meet rooms into your application. - This API provides endpoints to manage rooms, generate secure access URLs, and configure room preferences. + The OpenVidu Meet REST API allows seamless integration of OpenVidu Meet rooms into your application. + This REST API provides endpoints to manage rooms and recordings in OpenVidu Meet. termsOfService: https://openvidu.io/conditions/terms-of-service/ contact: name: OpenVidu @@ -400,9 +400,9 @@ paths: /recordings: post: operationId: createRecording - summary: Create a new OpenVidu Meet recording + summary: Start a recording description: > - Creates a new OpenVidu Meet recording for the specified room. + Start a new recording for an OpenVidu Meet room with the specified room ID. tags: - OpenVidu Meet - Recordings security: @@ -414,13 +414,13 @@ paths: schema: type: object required: - - roomName + - roomId properties: - roomName: + roomId: type: string - example: 'OpenVidu-123456' + example: 'room-123' description: > - The name of the room to record. + The unique identifier of the room to record. responses: '200': @@ -444,8 +444,17 @@ paths: schema: $ref: '#/components/schemas/Error' example: - code: 404 - message: 'Room not found' + name: 'Room Error' + message: 'The room "room-123" does not exist' + '409': + description: Conflict — The room is already being recorded + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + name: 'Recording Error' + message: 'The room "room-123" is already being recorded' '422': description: Unprocessable Entity — The request body is invalid content: @@ -466,16 +475,16 @@ paths: properties: field: type: string - example: 'roomName' + example: 'roomId' message: type: string - example: 'Room not found' + example: 'roomId not found' example: error: 'Unprocessable Entity' message: 'Invalid request body' details: - - field: 'roomName' - message: 'Room not found' + - field: 'roomId' + example: 'roomId not found' '500': description: Internal server error content: @@ -483,13 +492,14 @@ paths: schema: $ref: '#/components/schemas/Error' example: - code: 500 - message: 'Internal server error' + name: 'Unexpected Error' + message: 'Something went wrong' get: operationId: getRecordings - summary: Get a list of OpenVidu Meet recordings + summary: Get all recordings description: > - Retrieves a paginated list of OpenVidu Meet recordings + Retrieves a paginated list of all recordings available in the system. + You can apply filters to narrow down the results based on specific criteria. tags: - OpenVidu Meet - Recordings security: @@ -513,7 +523,7 @@ paths: You can provide multiple statuses as a comma-separated list (e.g., `status=ACTIVE,FAILED`). If not specified, recordings with any status will be returned. - > ⚠️ **Note:** Using this filter with multiple values or partial matches may impact performance for large datasets. + > ⚠️ **Note:** Using this filter may impact performance for large datasets. schema: type: string - name: roomId @@ -539,7 +549,7 @@ paths: type: string responses: '200': - description: Successfully retrieved the list of OpenVidu Meet recordings + description: Successfully retrieved the recording list content: application/json: schema: @@ -577,34 +587,53 @@ paths: code: 500 message: 'Internal server error' delete: - operationId: deleteMultipleRecordings - summary: Delete multiples OpenVidu Meet recordings + operationId: bulkDeleteRecordings + summary: Bulk delete recordings description: > - Deletes multiples OpenVidu Meet recordings with the specified recording IDs. + Deletes multiple recordings at once with the specified recording IDs. tags: - OpenVidu Meet - Recordings security: - apiKeyInHeader: [] - requestBody: - description: Recording IDs to delete - required: true - content: - application/json: - schema: - type: object - required: - - recordingIds - properties: - recordingIds: - type: array - items: - type: string - example: ['recording_123456', 'recording_123457'] - description: > - The IDs of the recordings to delete. + parameters: + - name: recordingIds + in: query + required: true + description: | + The unique IDs of the recordings to delete. + You can provide multiple recording IDs as a comma-separated list (e.g., `recordingIds=room-123--EG_XYZ--XX445,room-123--EG_XYZ--XX446`). + schema: + type: string responses: - '204': - description: Successfully deleted the OpenVidu Meet recordings + '200': + description: Bulk deletion completed. Includes lists of successfully deleted IDs and errors. + content: + application/json: + schema: + type: object + properties: + deleted: + type: array + items: + type: string + description: List of successfully deleted recording IDs. + example: + - 'room-123--EG_XYZ--XX445' + - 'room-123--EG_XYZ--XX446' + notDeleted: + type: array + description: List of recordings that could not be deleted along with the corresponding error messages. + items: + type: object + properties: + recordingId: + type: string + description: The unique identifier of the recording that was not deleted. + example: 'room-123--EG_XYZ--XX447' + error: + type: string + description: A message explaining why the deletion failed. + example: 'Recording not found' '401': description: Unauthorized — The API key is missing or invalid content: @@ -613,6 +642,36 @@ paths: $ref: '#/components/schemas/Error' example: message: 'Unauthorized' + '422': + description: Unprocessable Entity — The request body is invalid + content: + application/json: + schema: + type: object + properties: + error: + type: string + example: 'Unprocessable Entity' + message: + type: string + example: 'Invalid request body' + details: + type: array + items: + type: object + properties: + field: + type: string + example: 'recordingIds' + message: + type: string + example: 'recordingIds not found' + example: + error: 'Unprocessable Entity' + message: 'Invalid request body' + details: + - field: 'recordingIds' + example: 'recordingIds not found' '500': description: Internal server error content: @@ -620,14 +679,15 @@ paths: schema: $ref: '#/components/schemas/Error' example: - code: 500 - message: 'Internal server error' + name: 'Unexpected Error' + message: 'Something went wrong' /recordings/{recordingId}: put: operationId: stopRecording - summary: Stop an OpenVidu Meet recording + summary: Stop a recording description: > - Stops an OpenVidu Meet recording with the specified recording ID. + Stops a recording with the specified recording ID. + The recording must be in an `ACTIVE` state; otherwise, a 409 error is returned. tags: - OpenVidu Meet - Recordings security: @@ -636,12 +696,17 @@ paths: - name: recordingId in: path required: true - description: The ID of the recording to stop + description: The unique identifier of the recording to stop. schema: type: string + example: 'room-123--EG_XYZ--XX445' responses: - '204': - description: Successfully stopped the OpenVidu Meet recording + '200': + description: Successfully stopped the recording. + content: + application/json: + schema: + $ref: '#/components/schemas/MeetRecording' '401': description: Unauthorized — The API key is missing or invalid content: @@ -657,8 +722,17 @@ paths: schema: $ref: '#/components/schemas/Error' example: - code: 404 - message: 'Recording not found' + name: 'Recording Error' + message: 'Recording "room-123--EG_XYZ--XX445" not found' + '409': + description: Conflict — The recording is already stopped. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + name: 'Recording Error' + message: 'Recording "room-123--EG_XYZ--XX445" is already stopped' '500': description: Internal server error content: @@ -666,13 +740,13 @@ paths: schema: $ref: '#/components/schemas/Error' example: - code: 500 - message: 'Internal server error' + name: 'Unexpected Error' + message: 'Something went wrong' get: operationId: getRecording - summary: Get details of an OpenVidu Meet recording + summary: Get a recording description: > - Retrieves the details of an OpenVidu Meet recording with the specified recording ID. + Retrieves the details of a recording with the specified recording ID. tags: - OpenVidu Meet - Recordings security: @@ -681,12 +755,13 @@ paths: - name: recordingId in: path required: true - description: The ID of the recording to retrieve + description: The unique identifier of the recording to retrieve schema: type: string + example: 'room-123--EG_XYZ--XX445' responses: '200': - description: Successfully retrieved the OpenVidu Meet recording + description: Successfully retrieved the recording. content: application/json: schema: @@ -701,14 +776,14 @@ paths: example: message: 'Unauthorized' '404': - description: Recording not found + description: Recording not found — The recording with the specified ID was not found content: application/json: schema: $ref: '#/components/schemas/Error' example: - code: 404 - message: 'Recording not found' + name: 'Recording Error' + message: 'Recording "room-123--EG_XYZ--XX445" not found' '500': description: Internal server error content: @@ -716,13 +791,14 @@ paths: schema: $ref: '#/components/schemas/Error' example: - code: 500 - message: 'Internal server error' + name: 'Unexpected Error' + message: 'Something went wrong' delete: operationId: deleteRecording - summary: Delete an OpenVidu Meet recording + summary: Delete a recording description: > - Deletes an OpenVidu Meet recording with the specified recording ID. + Deletes a recording with the specified recording. + The recording will only be deleted if it exists and is not in progress (i.e., not in a state such as `ACTIVE`, `STARTING`, or `ENDING`). tags: - OpenVidu Meet - Recordings security: @@ -731,12 +807,13 @@ paths: - name: recordingId in: path required: true - description: The ID of the recording to delete + description: The unique identifier of the recording to delete. schema: type: string + example: 'room-123--EG_XYZ--XX445' responses: '204': - description: Successfully deleted the OpenVidu Meet recording + description: Recording successfully deleted. No content is returned. '401': description: Unauthorized — The API key is missing or invalid content: @@ -745,6 +822,24 @@ paths: $ref: '#/components/schemas/Error' example: message: 'Unauthorized' + '404': + description: Recording not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + name: 'Recording Error' + message: 'Recording "room-123--EG_XYZ--XX445" not found' + '409': + description: Conflict — The recording is in progress + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + name: 'Recording Error' + message: 'Recording "room-123--EG_XYZ--XX445" is in progress' '500': description: Internal server error content: @@ -752,8 +847,8 @@ paths: schema: $ref: '#/components/schemas/Error' example: - code: 500 - message: 'Internal server error' + name: 'Unexpected Error' + message: 'Something went wrong' /recordings/{recordingId}/stream: get: operationId: getRecordingStream @@ -1137,7 +1232,17 @@ components: type: string example: 'ACTIVE' description: > - The status of the recording. Possible values are "STARTING", "ACTIVE", "ENDING", "COMPLETE", "FAILED", "ABORTED" and "LIMITED_REACHED". + The status of the recording. + + Possible values: + - `STARTING` + - `ACTIVE` + - `ENDING` + - `COMPLETE` + - `FAILED` + - `ABORTED` + - `LIMITED_REACHED` + filename: type: string example: 'room-123--XX445.mp4' @@ -1183,5 +1288,7 @@ components: required: - message properties: + name: + type: string message: type: string diff --git a/backend/package.json b/backend/package.json index 9b1df03..01def85 100644 --- a/backend/package.json +++ b/backend/package.json @@ -29,7 +29,7 @@ "scripts": { "build:prod": "tsc", "postbuild:prod": "npm run generate:openapi-doc", - "generate:openapi-doc": "mkdir -p public/openapi && npx openapi-generate-html -i openapi/openvidu-meet-api.yaml --ui=stoplight --theme=dark --title 'OpenVidu Meet REST API' --description 'OpenVidu Meet REST API' -o public/openapi/index.html", + "generate:openapi-doc": "mkdir -p public/openapi && npx openapi-generate-html -i openapi/openvidu-meet-api.yaml --ui=stoplight --theme=light --title 'OpenVidu Meet REST API' --description 'OpenVidu Meet REST API' -o public/openapi/index.html", "start:prod": "node dist/src/server.js", "start:dev": "nodemon", "package:build": "npm run build:prod && npm pack", diff --git a/backend/src/controllers/recording.controller.ts b/backend/src/controllers/recording.controller.ts index e088484..d1d71bb 100644 --- a/backend/src/controllers/recording.controller.ts +++ b/backend/src/controllers/recording.controller.ts @@ -6,13 +6,11 @@ import { container } from '../config/dependency-injector.config.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 ${roomId}`); try { - logger.info(`Starting recording in ${roomId}`); - const recordingService = container.get(RecordingService); - const recordingInfo = await recordingService.startRecording(roomId); return res.status(200).json(recordingInfo); } catch (error) { @@ -28,12 +26,11 @@ export const startRecording = async (req: Request, res: Response) => { export const getRecordings = async (req: Request, res: Response) => { const logger = container.get(LoggerService); const recordingService = container.get(RecordingService); + const queryParams = req.query; + + logger.info('Getting all recordings'); try { - logger.info('Getting all recordings'); - - const queryParams = req.query; - const response = await recordingService.getAllRecordings(queryParams); return res.status(200).json(response); } catch (error) { @@ -48,16 +45,16 @@ export const getRecordings = async (req: Request, res: Response) => { export const bulkDeleteRecordings = async (req: Request, res: Response) => { const logger = container.get(LoggerService); + const recordingService = container.get(RecordingService); + const recordingIds = req.body.recordingIds; + + logger.info(`Deleting recordings: ${recordingIds}`); try { - const recordingIds = req.body.recordingIds; - logger.info(`Deleting recordings: ${recordingIds}`); - const recordingService = container.get(RecordingService); - // TODO: Check role to determine if the request is from an admin or a participant - await recordingService.bulkDeleteRecordings(recordingIds); + const { deleted, notDeleted } = await recordingService.bulkDeleteRecordings(recordingIds); - return res.status(204).json(); + return res.status(200).json({ deleted, notDeleted }); } catch (error) { if (error instanceof OpenViduMeetError) { logger.error(`Error deleting recordings: ${error.message}`); @@ -70,12 +67,11 @@ export const bulkDeleteRecordings = async (req: Request, res: Response) => { export const getRecording = async (req: Request, res: Response) => { const logger = container.get(LoggerService); + const recordingService = container.get(RecordingService); + const recordingId = req.params.recordingId; + logger.info(`Getting recording ${recordingId}`); try { - const recordingId = req.params.recordingId; - logger.info(`Getting recording ${recordingId}`); - const recordingService = container.get(RecordingService); - const recordingInfo = await recordingService.getRecording(recordingId); return res.status(200).json(recordingInfo); } catch (error) { @@ -110,16 +106,14 @@ export const stopRecording = async (req: Request, res: Response) => { 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 { - logger.info(`Deleting recording ${recordingId}`); - const recordingService = container.get(RecordingService); - // TODO: Check role to determine if the request is from an admin or a participant - const recordingInfo = await recordingService.deleteRecording(recordingId); - - return res.status(204).json(recordingInfo); + await recordingService.deleteRecording(recordingId); + return res.status(204); } catch (error) { if (error instanceof OpenViduMeetError) { logger.error(`Error deleting recording: ${error.message}`); diff --git a/backend/src/middlewares/request-validators/recording-validator.middleware.ts b/backend/src/middlewares/request-validators/recording-validator.middleware.ts index 6a9e7d6..e1449dc 100644 --- a/backend/src/middlewares/request-validators/recording-validator.middleware.ts +++ b/backend/src/middlewares/request-validators/recording-validator.middleware.ts @@ -1,45 +1,87 @@ +import { MeetRecordingFilters } from '@typings-ce'; import { Request, Response, NextFunction } from 'express'; import { z } from 'zod'; -const RecordingPostRequestSchema = z.object({ - roomId: z +const sanitizeId = (val: string): string => { + return val + .trim() // Remove leading and trailing spaces + .replace(/\s+/g, '-') // Replace spaces with hyphens + .replace(/[^a-zA-Z0-9-]/g, ''); // Remove special characters (only allow alphanumeric and hyphens) +}; + +const nonEmptySanitizedString = (fieldName: string) => + z .string() - .min(1, { message: 'roomId is required and cannot be empty' }) - .transform((val) => val.trim().replace(/\s+/g, '-')) + .min(1, { message: `${fieldName} is required and cannot be empty` }) + .transform(sanitizeId) + .refine((data) => data !== '', { + message: `${fieldName} cannot be empty after sanitization` + }); + +const StartRecordingRequestSchema = z.object({ + roomId: nonEmptySanitizedString('roomId') }); -const getRecordingsSchema = z.object({ +const GetRecordingSchema = z.object({ + recordingId: nonEmptySanitizedString('recordingId') +}); + +export const BulkDeleteRecordingsSchema = z.object({ + recordingIds: z.preprocess( + (arg) => { + if (typeof arg === 'string') { + // Si se recibe un string con valores separados por comas, + // se divide en array, eliminando espacios en blanco y valores vacíos. + return arg + .split(',') + .map((s) => s.trim()) + .filter((s) => s !== ''); + } + + return arg; + }, + z.array(nonEmptySanitizedString('recordingId')) + ) +}); + +const GetRecordingsFiltersSchema: z.ZodType = z.object({ maxItems: z.coerce - .number() - .int() - .optional() - .transform((val = 10) => (val > 100 ? 100 : val)) - .default(10), + .number() + .int() + .optional() + .transform((val = 10) => (val > 100 ? 100 : val)) + .default(10), status: z.string().optional(), roomId: z.string().optional(), nextPageToken: z.string().optional() }); -/** - * Middleware to validate the recording post request. - * - * This middleware uses the `RecordingPostRequestSchema` to validate the request body. - * If the validation fails, it rejects the request with an error response. - * If the validation succeeds, it passes control to the next middleware or route handler. - * - */ -export const withValidRecordingPostRequest = (req: Request, res: Response, next: NextFunction) => { - const { success, error } = RecordingPostRequestSchema.safeParse(req.body); +export const withValidStartRecordingRequest = (req: Request, res: Response, next: NextFunction) => { + const { success, error, data } = StartRecordingRequestSchema.safeParse(req.body); if (!success) { return rejectRequest(res, error); } + req.body = data; + + next(); +}; + +export const withValidRecordingIdRequest = (req: Request, res: Response, next: NextFunction) => { + const { success, error, data } = GetRecordingSchema.safeParse(req.params.recordingId); + + if (!success) { + return rejectRequest(res, error); + } + + req.params.recordingId = data.recordingId; + next(); }; export const withValidGetRecordingsRequest = (req: Request, res: Response, next: NextFunction) => { - const { success, error, data } = getRecordingsSchema.safeParse(req.query); + const { success, error, data } = GetRecordingsFiltersSchema.safeParse(req.query); if (!success) { return rejectRequest(res, error); @@ -53,14 +95,14 @@ export const withValidGetRecordingsRequest = (req: Request, res: Response, next: }; export const withValidRecordingBulkDeleteRequest = (req: Request, res: Response, next: NextFunction) => { - const { success, error } = z - .array(z.string().min(1, { message: 'recordingIds must be a non-empty string' })) - .safeParse(req.body); + const { success, error, data } = BulkDeleteRecordingsSchema.safeParse(req.query); if (!success) { return rejectRequest(res, error); } + req.query.recordingIds = data.recordingIds; + next(); }; diff --git a/backend/src/models/error.model.ts b/backend/src/models/error.model.ts index 811edd6..12db4f7 100644 --- a/backend/src/models/error.model.ts +++ b/backend/src/models/error.model.ts @@ -56,19 +56,23 @@ export const errorInvalidApiKey = (): OpenViduMeetError => { // Recording errors export const errorRecordingNotFound = (recordingId: string): OpenViduMeetError => { - return new OpenViduMeetError('Recording Error', `Recording ${recordingId} not found`, 404); + return new OpenViduMeetError('Recording Error', `Recording '${recordingId}' not found`, 404); }; export const errorRecordingNotStopped = (recordingId: string): OpenViduMeetError => { - return new OpenViduMeetError('Recording Error', `Recording ${recordingId} is not stopped yet`, 409); + return new OpenViduMeetError('Recording Error', `Recording '${recordingId}' is not stopped yet`, 409); }; export const errorRecordingNotReady = (recordingId: string): OpenViduMeetError => { - return new OpenViduMeetError('Recording Error', `Recording ${recordingId} is not ready yet`, 409); + return new OpenViduMeetError('Recording Error', `Recording '${recordingId}' is not ready yet`, 409); }; export const errorRecordingAlreadyStopped = (recordingId: string): OpenViduMeetError => { - return new OpenViduMeetError('Recording Error', `Recording ${recordingId} is already stopped`, 409); + return new OpenViduMeetError('Recording Error', `Recording '${recordingId}' is already stopped`, 409); +}; + +export const errorRecordingCannotBeStoppedWhileStarting = (recordingId: string): OpenViduMeetError => { + return new OpenViduMeetError('Recording Error', `Recording '${recordingId}' cannot be stopped while starting`, 409); }; export const errorRecordingAlreadyStarted = (roomName: string): OpenViduMeetError => { diff --git a/backend/src/routes/recording.routes.ts b/backend/src/routes/recording.routes.ts index f34baf5..b1d2edd 100644 --- a/backend/src/routes/recording.routes.ts +++ b/backend/src/routes/recording.routes.ts @@ -1,12 +1,19 @@ import { Router } from 'express'; import bodyParser from 'body-parser'; import * as recordingCtrl from '../controllers/recording.controller.js'; -import { withAuth, participantTokenValidator, tokenAndRoleValidator } from '../middlewares/auth.middleware.js'; -import { withRecordingEnabledAndCorrectPermissions } from '../middlewares/recording.middleware.js'; import { Role } from '@typings-ce'; +import { + withAuth, + participantTokenValidator, + tokenAndRoleValidator, + withRecordingEnabledAndCorrectPermissions, + withValidGetRecordingsRequest, + withValidRecordingBulkDeleteRequest, + withValidRecordingIdRequest, + withValidStartRecordingRequest +} from '../middlewares/index.js'; export const recordingRouter = Router(); - recordingRouter.use(bodyParser.urlencoded({ extended: true })); recordingRouter.use(bodyParser.json()); @@ -15,21 +22,35 @@ recordingRouter.post( '/', withAuth(participantTokenValidator), withRecordingEnabledAndCorrectPermissions, + withValidStartRecordingRequest, recordingCtrl.startRecording ); recordingRouter.put( '/:recordingId', withAuth(participantTokenValidator), - /* withRecordingEnabledAndCorrectPermissions,*/ recordingCtrl.stopRecording -); -recordingRouter.get( - '/:recordingId/stream', - withAuth(participantTokenValidator), - /*withRecordingEnabledAndCorrectPermissions,*/ recordingCtrl.streamRecording + /* withRecordingEnabledAndCorrectPermissions,*/ withValidRecordingIdRequest, + recordingCtrl.stopRecording ); + recordingRouter.delete( '/:recordingId', withAuth(tokenAndRoleValidator(Role.ADMIN), participantTokenValidator), /*withRecordingEnabledAndCorrectPermissions,*/ + withValidRecordingIdRequest, recordingCtrl.deleteRecording ); +recordingRouter.get('/:recordingId', withValidRecordingIdRequest, recordingCtrl.getRecording); +recordingRouter.get('/', withValidGetRecordingsRequest, recordingCtrl.getRecordings); +recordingRouter.delete('/', withValidRecordingBulkDeleteRequest, recordingCtrl.bulkDeleteRecordings); + +// Internal Recording Routes +export const internalRecordingRouter = Router(); +internalRecordingRouter.use(bodyParser.urlencoded({ extended: true })); +internalRecordingRouter.use(bodyParser.json()); + +internalRecordingRouter.get( + '/:recordingId/stream', + withAuth(participantTokenValidator), + /*withRecordingEnabledAndCorrectPermissions,*/ withValidRecordingIdRequest, + recordingCtrl.streamRecording +); diff --git a/backend/src/services/recording.service.ts b/backend/src/services/recording.service.ts index f6b2e45..5e186e8 100644 --- a/backend/src/services/recording.service.ts +++ b/backend/src/services/recording.service.ts @@ -1,17 +1,20 @@ -import { EncodedFileOutput, EncodedFileType, RoomCompositeOptions } from 'livekit-server-sdk'; +import { EgressStatus, EncodedFileOutput, EncodedFileType, RoomCompositeOptions } from 'livekit-server-sdk'; import { uid } from 'uid'; import { Readable } from 'stream'; import { LiveKitService } from './livekit.service.js'; import { errorRecordingAlreadyStarted, + errorRecordingAlreadyStopped, + errorRecordingCannotBeStoppedWhileStarting, errorRecordingNotFound, errorRecordingNotStopped, errorRoomNotFound, - internalError + internalError, + OpenViduMeetError } from '../models/error.model.js'; import { S3Service } from './s3.service.js'; import { LoggerService } from './logger.service.js'; -import { MeetRecordingInfo, MeetRecordingStatus } from '@typings-ce'; +import { MeetRecordingFilters, MeetRecordingInfo, MeetRecordingStatus } from '@typings-ce'; import { RecordingHelper } from '../helpers/recording.helper.js'; import { MEET_S3_BUCKET, MEET_S3_RECORDINGS_PREFIX, MEET_S3_SUBBUCKET } from '../environment.js'; import { RoomService } from './room.service.js'; @@ -21,13 +24,6 @@ import { RedisLockName } from '../models/index.js'; import ms from 'ms'; import { OpenViduComponentsAdapterHelper } from '../helpers/ov-components-adapter.helper.js'; -type GetAllRecordingsParams = { - maxItems?: number; - nextPageToken?: string; - roomId?: string; - status?: string; -}; - @injectable() export class RecordingService { protected readonly RECORDING_ACTIVE_LOCK_TTL = ms('6h'); @@ -43,16 +39,16 @@ export class RecordingService { let acquiredLock: RedisLock | null = null; try { + const room = await this.roomService.getOpenViduRoom(roomId); + + if (!room) throw errorRoomNotFound(roomId); + // Attempt to acquire lock. // Note: using a high TTL to prevent expiration during a long recording. acquiredLock = await this.acquireRoomRecordingActiveLock(roomId); if (!acquiredLock) throw errorRecordingAlreadyStarted(roomId); - const room = await this.roomService.getOpenViduRoom(roomId); - - if (!room) throw errorRoomNotFound(roomId); - const options = this.generateCompositeOptionsFromRequest(); const output = this.generateFileOutputFromRequest(roomId); const egressInfo = await this.livekitService.startRoomComposite(roomId, output, options); @@ -73,12 +69,24 @@ export class RecordingService { try { const { roomId, egressId } = RecordingHelper.extractInfoFromRecordingId(recordingId); - const egressArray = await this.livekitService.getActiveEgress(roomId, egressId); + const [egress] = await this.livekitService.getEgress(roomId, egressId); - if (egressArray.length === 0) { + if (!egress) { throw errorRecordingNotFound(egressId); } + switch (egress.status) { + case EgressStatus.EGRESS_ACTIVE: + // Everything is fine, the recording can be stopped. + break; + case EgressStatus.EGRESS_STARTING: + // The recording is still starting, it cannot be stopped yet. + throw errorRecordingCannotBeStoppedWhileStarting(recordingId); + default: + // The recording is already stopped. + throw errorRecordingAlreadyStopped(recordingId); + } + const egressInfo = await this.livekitService.stopEgress(egressId); return RecordingHelper.toRecordingInfo(egressInfo); @@ -116,21 +124,25 @@ export class RecordingService { * Deletes multiple recordings in bulk from S3. * For each provided egressId, the metadata and recording file are deleted (only if the status is stopped). * - * @param egressIds Array of recording identifiers. + * @param recordingIds Array of recording identifiers. * @returns An array with the MeetRecordingInfo of the successfully deleted recordings. */ - async bulkDeleteRecordings(egressIds: string[]): Promise { + async bulkDeleteRecordings( + recordingIds: string[] + ): Promise<{ deleted: string[]; notDeleted: { recordingId: string; error: string }[] }> { const keysToDelete: string[] = []; - const deletedRecordings: MeetRecordingInfo[] = []; + const deletedRecordings: string[] = []; + const notDeletedRecordings: { recordingId: string; error: string }[] = []; - for (const egressId of egressIds) { + for (const recordingId of recordingIds) { try { - const { filesToDelete, recordingInfo } = await this.getDeletableRecordingData(egressId); + const { filesToDelete } = await this.getDeletableRecordingData(recordingId); keysToDelete.push(...filesToDelete); - deletedRecordings.push(recordingInfo); - this.logger.verbose(`BulkDelete: Prepared recording ${egressId} for deletion.`); + deletedRecordings.push(recordingId); + this.logger.verbose(`BulkDelete: Prepared recording ${recordingId} for deletion.`); } catch (error) { - this.logger.error(`BulkDelete: Error processing recording ${egressId}: ${error}`); + this.logger.error(`BulkDelete: Error processing recording ${recordingId}: ${error}`); + notDeletedRecordings.push({ recordingId, error: (error as OpenViduMeetError).message }); } } @@ -146,7 +158,7 @@ export class RecordingService { this.logger.warn(`BulkDelete: No eligible recordings found for deletion.`); } - return deletedRecordings; + return { deleted: deletedRecordings, notDeleted: notDeletedRecordings }; } /** @@ -171,13 +183,16 @@ export class RecordingService { * - `nextPageToken`: (Optional) A token to retrieve the next page of results, if available. * @throws Will throw an error if there is an issue retrieving the recordings. */ - async getAllRecordings({ maxItems, nextPageToken, roomId, status }: GetAllRecordingsParams): Promise<{ + async getAllRecordings({ maxItems, nextPageToken, roomId, status }: MeetRecordingFilters): Promise<{ recordings: MeetRecordingInfo[]; isTruncated: boolean; nextPageToken?: string; }> { try { + // Construct the room prefix if a room ID is provided const roomPrefix = roomId ? `/${roomId}` : ''; + + // Retrieve the recordings from the S3 bucket const { Contents, IsTruncated, NextContinuationToken } = await this.s3Service.listObjectsPaginated( `${MEET_S3_RECORDINGS_PREFIX}/.metadata${roomPrefix}`, maxItems, @@ -190,6 +205,7 @@ export class RecordingService { } const promises: Promise[] = []; + // Retrieve the metadata for each recording Contents.forEach((item) => { if (item?.Key && item.Key.endsWith('.json')) { promises.push(this.s3Service.getObjectAsJson(item.Key) as Promise); @@ -199,21 +215,22 @@ export class RecordingService { let recordings: MeetRecordingInfo[] = await Promise.all(promises); if (status) { - // Filter recordings by status + // Filter recordings by status if a status filter is provided + // status is already an array of RegExp after middleware validation. + const statusArray = status .split(',') .map((s) => s.trim()) .filter(Boolean) .map((s) => new RegExp(this.sanitizeRegExp(s))); - recordings = recordings.filter((recording) => statusArray.some((regex) => regex.test(recording.status)) ); } this.logger.info(`Retrieved ${recordings.length} recordings.`); - + // Return the paginated list of recordings return { recordings, isTruncated: !!IsTruncated, nextPageToken: NextContinuationToken }; } catch (error) { this.logger.error(`Error getting recordings: ${error}`); @@ -221,7 +238,6 @@ export class RecordingService { } } - //TODO: Implement getRecordingAsStream method async getRecordingAsStream( recordingId: string, range?: string diff --git a/typings/src/recording.model.ts b/typings/src/recording.model.ts index 60832a6..5a72b3f 100644 --- a/typings/src/recording.model.ts +++ b/typings/src/recording.model.ts @@ -9,7 +9,7 @@ export const enum MeetRecordingStatus { } export const enum MeetRecordingOutputMode { - COMPOSED = 'COMPOSED' + COMPOSED = 'COMPOSED', } /** @@ -29,3 +29,10 @@ export interface MeetRecordingInfo { error?: string; details?: string; } + +export type MeetRecordingFilters = { + maxItems?: number; + nextPageToken?: string; + roomId?: string; + status?: string; +};