backend: Enhance recording routes and controllers with new validation middleware and improve error handling

This commit is contained in:
Carlos Santos 2025-03-21 15:13:20 +01:00
parent e69f1dfb4b
commit ca348d1a47
8 changed files with 354 additions and 163 deletions

View File

@ -1,10 +1,10 @@
openapi: 3.0.1 openapi: 3.0.1
info: info:
version: v1 version: v1
title: OpenVidu Meet API title: OpenVidu Meet REST API
description: > description: >
The OpenVidu Embedded API allows seamless integration of OpenVidu Meet rooms into your application. The OpenVidu Meet REST 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. This REST API provides endpoints to manage rooms and recordings in OpenVidu Meet.
termsOfService: https://openvidu.io/conditions/terms-of-service/ termsOfService: https://openvidu.io/conditions/terms-of-service/
contact: contact:
name: OpenVidu name: OpenVidu
@ -400,9 +400,9 @@ paths:
/recordings: /recordings:
post: post:
operationId: createRecording operationId: createRecording
summary: Create a new OpenVidu Meet recording summary: Start a recording
description: > 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: tags:
- OpenVidu Meet - Recordings - OpenVidu Meet - Recordings
security: security:
@ -414,13 +414,13 @@ paths:
schema: schema:
type: object type: object
required: required:
- roomName - roomId
properties: properties:
roomName: roomId:
type: string type: string
example: 'OpenVidu-123456' example: 'room-123'
description: > description: >
The name of the room to record. The unique identifier of the room to record.
responses: responses:
'200': '200':
@ -444,8 +444,17 @@ paths:
schema: schema:
$ref: '#/components/schemas/Error' $ref: '#/components/schemas/Error'
example: example:
code: 404 name: 'Room Error'
message: 'Room not found' 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': '422':
description: Unprocessable Entity — The request body is invalid description: Unprocessable Entity — The request body is invalid
content: content:
@ -466,16 +475,16 @@ paths:
properties: properties:
field: field:
type: string type: string
example: 'roomName' example: 'roomId'
message: message:
type: string type: string
example: 'Room not found' example: 'roomId not found'
example: example:
error: 'Unprocessable Entity' error: 'Unprocessable Entity'
message: 'Invalid request body' message: 'Invalid request body'
details: details:
- field: 'roomName' - field: 'roomId'
message: 'Room not found' example: 'roomId not found'
'500': '500':
description: Internal server error description: Internal server error
content: content:
@ -483,13 +492,14 @@ paths:
schema: schema:
$ref: '#/components/schemas/Error' $ref: '#/components/schemas/Error'
example: example:
code: 500 name: 'Unexpected Error'
message: 'Internal server error' message: 'Something went wrong'
get: get:
operationId: getRecordings operationId: getRecordings
summary: Get a list of OpenVidu Meet recordings summary: Get all recordings
description: > 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: tags:
- OpenVidu Meet - Recordings - OpenVidu Meet - Recordings
security: security:
@ -513,7 +523,7 @@ paths:
You can provide multiple statuses as a comma-separated list (e.g., `status=ACTIVE,FAILED`). 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. 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: schema:
type: string type: string
- name: roomId - name: roomId
@ -539,7 +549,7 @@ paths:
type: string type: string
responses: responses:
'200': '200':
description: Successfully retrieved the list of OpenVidu Meet recordings description: Successfully retrieved the recording list
content: content:
application/json: application/json:
schema: schema:
@ -577,34 +587,53 @@ paths:
code: 500 code: 500
message: 'Internal server error' message: 'Internal server error'
delete: delete:
operationId: deleteMultipleRecordings operationId: bulkDeleteRecordings
summary: Delete multiples OpenVidu Meet recordings summary: Bulk delete recordings
description: > description: >
Deletes multiples OpenVidu Meet recordings with the specified recording IDs. Deletes multiple recordings at once with the specified recording IDs.
tags: tags:
- OpenVidu Meet - Recordings - OpenVidu Meet - Recordings
security: security:
- apiKeyInHeader: [] - apiKeyInHeader: []
requestBody: parameters:
description: Recording IDs to delete - name: recordingIds
in: query
required: true 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:
'200':
description: Bulk deletion completed. Includes lists of successfully deleted IDs and errors.
content: content:
application/json: application/json:
schema: schema:
type: object type: object
required:
- recordingIds
properties: properties:
recordingIds: deleted:
type: array type: array
items: items:
type: string type: string
example: ['recording_123456', 'recording_123457'] description: List of successfully deleted recording IDs.
description: > example:
The IDs of the recordings to delete. - 'room-123--EG_XYZ--XX445'
responses: - 'room-123--EG_XYZ--XX446'
'204': notDeleted:
description: Successfully deleted the OpenVidu Meet recordings 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': '401':
description: Unauthorized — The API key is missing or invalid description: Unauthorized — The API key is missing or invalid
content: content:
@ -613,6 +642,36 @@ paths:
$ref: '#/components/schemas/Error' $ref: '#/components/schemas/Error'
example: example:
message: 'Unauthorized' 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': '500':
description: Internal server error description: Internal server error
content: content:
@ -620,14 +679,15 @@ paths:
schema: schema:
$ref: '#/components/schemas/Error' $ref: '#/components/schemas/Error'
example: example:
code: 500 name: 'Unexpected Error'
message: 'Internal server error' message: 'Something went wrong'
/recordings/{recordingId}: /recordings/{recordingId}:
put: put:
operationId: stopRecording operationId: stopRecording
summary: Stop an OpenVidu Meet recording summary: Stop a recording
description: > 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: tags:
- OpenVidu Meet - Recordings - OpenVidu Meet - Recordings
security: security:
@ -636,12 +696,17 @@ paths:
- name: recordingId - name: recordingId
in: path in: path
required: true required: true
description: The ID of the recording to stop description: The unique identifier of the recording to stop.
schema: schema:
type: string type: string
example: 'room-123--EG_XYZ--XX445'
responses: responses:
'204': '200':
description: Successfully stopped the OpenVidu Meet recording description: Successfully stopped the recording.
content:
application/json:
schema:
$ref: '#/components/schemas/MeetRecording'
'401': '401':
description: Unauthorized — The API key is missing or invalid description: Unauthorized — The API key is missing or invalid
content: content:
@ -657,8 +722,17 @@ paths:
schema: schema:
$ref: '#/components/schemas/Error' $ref: '#/components/schemas/Error'
example: example:
code: 404 name: 'Recording Error'
message: 'Recording not found' 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': '500':
description: Internal server error description: Internal server error
content: content:
@ -666,13 +740,13 @@ paths:
schema: schema:
$ref: '#/components/schemas/Error' $ref: '#/components/schemas/Error'
example: example:
code: 500 name: 'Unexpected Error'
message: 'Internal server error' message: 'Something went wrong'
get: get:
operationId: getRecording operationId: getRecording
summary: Get details of an OpenVidu Meet recording summary: Get a recording
description: > 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: tags:
- OpenVidu Meet - Recordings - OpenVidu Meet - Recordings
security: security:
@ -681,12 +755,13 @@ paths:
- name: recordingId - name: recordingId
in: path in: path
required: true required: true
description: The ID of the recording to retrieve description: The unique identifier of the recording to retrieve
schema: schema:
type: string type: string
example: 'room-123--EG_XYZ--XX445'
responses: responses:
'200': '200':
description: Successfully retrieved the OpenVidu Meet recording description: Successfully retrieved the recording.
content: content:
application/json: application/json:
schema: schema:
@ -701,14 +776,14 @@ paths:
example: example:
message: 'Unauthorized' message: 'Unauthorized'
'404': '404':
description: Recording not found description: Recording not found — The recording with the specified ID was not found
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/Error' $ref: '#/components/schemas/Error'
example: example:
code: 404 name: 'Recording Error'
message: 'Recording not found' message: 'Recording "room-123--EG_XYZ--XX445" not found'
'500': '500':
description: Internal server error description: Internal server error
content: content:
@ -716,13 +791,14 @@ paths:
schema: schema:
$ref: '#/components/schemas/Error' $ref: '#/components/schemas/Error'
example: example:
code: 500 name: 'Unexpected Error'
message: 'Internal server error' message: 'Something went wrong'
delete: delete:
operationId: deleteRecording operationId: deleteRecording
summary: Delete an OpenVidu Meet recording summary: Delete a recording
description: > 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: tags:
- OpenVidu Meet - Recordings - OpenVidu Meet - Recordings
security: security:
@ -731,12 +807,13 @@ paths:
- name: recordingId - name: recordingId
in: path in: path
required: true required: true
description: The ID of the recording to delete description: The unique identifier of the recording to delete.
schema: schema:
type: string type: string
example: 'room-123--EG_XYZ--XX445'
responses: responses:
'204': '204':
description: Successfully deleted the OpenVidu Meet recording description: Recording successfully deleted. No content is returned.
'401': '401':
description: Unauthorized — The API key is missing or invalid description: Unauthorized — The API key is missing or invalid
content: content:
@ -745,6 +822,24 @@ paths:
$ref: '#/components/schemas/Error' $ref: '#/components/schemas/Error'
example: example:
message: 'Unauthorized' 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': '500':
description: Internal server error description: Internal server error
content: content:
@ -752,8 +847,8 @@ paths:
schema: schema:
$ref: '#/components/schemas/Error' $ref: '#/components/schemas/Error'
example: example:
code: 500 name: 'Unexpected Error'
message: 'Internal server error' message: 'Something went wrong'
/recordings/{recordingId}/stream: /recordings/{recordingId}/stream:
get: get:
operationId: getRecordingStream operationId: getRecordingStream
@ -1137,7 +1232,17 @@ components:
type: string type: string
example: 'ACTIVE' example: 'ACTIVE'
description: > 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: filename:
type: string type: string
example: 'room-123--XX445.mp4' example: 'room-123--XX445.mp4'
@ -1183,5 +1288,7 @@ components:
required: required:
- message - message
properties: properties:
name:
type: string
message: message:
type: string type: string

View File

@ -29,7 +29,7 @@
"scripts": { "scripts": {
"build:prod": "tsc", "build:prod": "tsc",
"postbuild:prod": "npm run generate:openapi-doc", "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:prod": "node dist/src/server.js",
"start:dev": "nodemon", "start:dev": "nodemon",
"package:build": "npm run build:prod && npm pack", "package:build": "npm run build:prod && npm pack",

View File

@ -6,13 +6,11 @@ import { container } from '../config/dependency-injector.config.js';
export const startRecording = async (req: Request, res: Response) => { export const startRecording = async (req: Request, res: Response) => {
const logger = container.get(LoggerService); const logger = container.get(LoggerService);
const recordingService = container.get(RecordingService);
const { roomId } = req.body; const { roomId } = req.body;
logger.info(`Starting recording in ${roomId}`);
try { try {
logger.info(`Starting recording in ${roomId}`);
const recordingService = container.get(RecordingService);
const recordingInfo = await recordingService.startRecording(roomId); const recordingInfo = await recordingService.startRecording(roomId);
return res.status(200).json(recordingInfo); return res.status(200).json(recordingInfo);
} catch (error) { } catch (error) {
@ -28,12 +26,11 @@ export const startRecording = async (req: Request, res: Response) => {
export const getRecordings = async (req: Request, res: Response) => { export const getRecordings = async (req: Request, res: Response) => {
const logger = container.get(LoggerService); const logger = container.get(LoggerService);
const recordingService = container.get(RecordingService); const recordingService = container.get(RecordingService);
try {
logger.info('Getting all recordings');
const queryParams = req.query; const queryParams = req.query;
logger.info('Getting all recordings');
try {
const response = await recordingService.getAllRecordings(queryParams); const response = await recordingService.getAllRecordings(queryParams);
return res.status(200).json(response); return res.status(200).json(response);
} catch (error) { } catch (error) {
@ -48,16 +45,16 @@ export const getRecordings = async (req: Request, res: Response) => {
export const bulkDeleteRecordings = async (req: Request, res: Response) => { export const bulkDeleteRecordings = async (req: Request, res: Response) => {
const logger = container.get(LoggerService); const logger = container.get(LoggerService);
const recordingService = container.get(RecordingService);
const recordingIds = req.body.recordingIds;
logger.info(`Deleting recordings: ${recordingIds}`);
try { 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 // 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) { } catch (error) {
if (error instanceof OpenViduMeetError) { if (error instanceof OpenViduMeetError) {
logger.error(`Error deleting recordings: ${error.message}`); 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) => { export const getRecording = async (req: Request, res: Response) => {
const logger = container.get(LoggerService); const logger = container.get(LoggerService);
const recordingService = container.get(RecordingService);
try {
const recordingId = req.params.recordingId; const recordingId = req.params.recordingId;
logger.info(`Getting recording ${recordingId}`); logger.info(`Getting recording ${recordingId}`);
const recordingService = container.get(RecordingService);
try {
const recordingInfo = await recordingService.getRecording(recordingId); const recordingInfo = await recordingService.getRecording(recordingId);
return res.status(200).json(recordingInfo); return res.status(200).json(recordingInfo);
} catch (error) { } catch (error) {
@ -110,16 +106,14 @@ export const stopRecording = async (req: Request, res: Response) => {
export const deleteRecording = async (req: Request, res: Response) => { export const deleteRecording = async (req: Request, res: Response) => {
const logger = container.get(LoggerService); const logger = container.get(LoggerService);
const recordingService = container.get(RecordingService);
const recordingId = req.params.recordingId; const recordingId = req.params.recordingId;
logger.info(`Deleting recording ${recordingId}`);
try { 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 // TODO: Check role to determine if the request is from an admin or a participant
const recordingInfo = await recordingService.deleteRecording(recordingId); await recordingService.deleteRecording(recordingId);
return res.status(204);
return res.status(204).json(recordingInfo);
} catch (error) { } catch (error) {
if (error instanceof OpenViduMeetError) { if (error instanceof OpenViduMeetError) {
logger.error(`Error deleting recording: ${error.message}`); logger.error(`Error deleting recording: ${error.message}`);

View File

@ -1,14 +1,50 @@
import { MeetRecordingFilters } from '@typings-ce';
import { Request, Response, NextFunction } from 'express'; import { Request, Response, NextFunction } from 'express';
import { z } from 'zod'; import { z } from 'zod';
const RecordingPostRequestSchema = z.object({ const sanitizeId = (val: string): string => {
roomId: z 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() .string()
.min(1, { message: 'roomId is required and cannot be empty' }) .min(1, { message: `${fieldName} is required and cannot be empty` })
.transform((val) => val.trim().replace(/\s+/g, '-')) .transform(sanitizeId)
.refine((data) => data !== '', {
message: `${fieldName} cannot be empty after sanitization`
}); });
const getRecordingsSchema = z.object({ const StartRecordingRequestSchema = z.object({
roomId: nonEmptySanitizedString('roomId')
});
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<MeetRecordingFilters> = z.object({
maxItems: z.coerce maxItems: z.coerce
.number() .number()
.int() .int()
@ -20,26 +56,32 @@ const getRecordingsSchema = z.object({
nextPageToken: z.string().optional() nextPageToken: z.string().optional()
}); });
/** export const withValidStartRecordingRequest = (req: Request, res: Response, next: NextFunction) => {
* Middleware to validate the recording post request. const { success, error, data } = StartRecordingRequestSchema.safeParse(req.body);
*
* 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);
if (!success) { if (!success) {
return rejectRequest(res, error); 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(); next();
}; };
export const withValidGetRecordingsRequest = (req: Request, res: Response, next: NextFunction) => { 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) { if (!success) {
return rejectRequest(res, error); 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) => { export const withValidRecordingBulkDeleteRequest = (req: Request, res: Response, next: NextFunction) => {
const { success, error } = z const { success, error, data } = BulkDeleteRecordingsSchema.safeParse(req.query);
.array(z.string().min(1, { message: 'recordingIds must be a non-empty string' }))
.safeParse(req.body);
if (!success) { if (!success) {
return rejectRequest(res, error); return rejectRequest(res, error);
} }
req.query.recordingIds = data.recordingIds;
next(); next();
}; };

View File

@ -56,19 +56,23 @@ export const errorInvalidApiKey = (): OpenViduMeetError => {
// Recording errors // Recording errors
export const errorRecordingNotFound = (recordingId: string): OpenViduMeetError => { 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 => { 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 => { 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 => { 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 => { export const errorRecordingAlreadyStarted = (roomName: string): OpenViduMeetError => {

View File

@ -1,12 +1,19 @@
import { Router } from 'express'; import { Router } from 'express';
import bodyParser from 'body-parser'; import bodyParser from 'body-parser';
import * as recordingCtrl from '../controllers/recording.controller.js'; 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 { Role } from '@typings-ce';
import {
withAuth,
participantTokenValidator,
tokenAndRoleValidator,
withRecordingEnabledAndCorrectPermissions,
withValidGetRecordingsRequest,
withValidRecordingBulkDeleteRequest,
withValidRecordingIdRequest,
withValidStartRecordingRequest
} from '../middlewares/index.js';
export const recordingRouter = Router(); export const recordingRouter = Router();
recordingRouter.use(bodyParser.urlencoded({ extended: true })); recordingRouter.use(bodyParser.urlencoded({ extended: true }));
recordingRouter.use(bodyParser.json()); recordingRouter.use(bodyParser.json());
@ -15,21 +22,35 @@ recordingRouter.post(
'/', '/',
withAuth(participantTokenValidator), withAuth(participantTokenValidator),
withRecordingEnabledAndCorrectPermissions, withRecordingEnabledAndCorrectPermissions,
withValidStartRecordingRequest,
recordingCtrl.startRecording recordingCtrl.startRecording
); );
recordingRouter.put( recordingRouter.put(
'/:recordingId', '/:recordingId',
withAuth(participantTokenValidator), withAuth(participantTokenValidator),
/* withRecordingEnabledAndCorrectPermissions,*/ recordingCtrl.stopRecording /* withRecordingEnabledAndCorrectPermissions,*/ withValidRecordingIdRequest,
); recordingCtrl.stopRecording
recordingRouter.get(
'/:recordingId/stream',
withAuth(participantTokenValidator),
/*withRecordingEnabledAndCorrectPermissions,*/ recordingCtrl.streamRecording
); );
recordingRouter.delete( recordingRouter.delete(
'/:recordingId', '/:recordingId',
withAuth(tokenAndRoleValidator(Role.ADMIN), participantTokenValidator), withAuth(tokenAndRoleValidator(Role.ADMIN), participantTokenValidator),
/*withRecordingEnabledAndCorrectPermissions,*/ /*withRecordingEnabledAndCorrectPermissions,*/
withValidRecordingIdRequest,
recordingCtrl.deleteRecording 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
);

View File

@ -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 { uid } from 'uid';
import { Readable } from 'stream'; import { Readable } from 'stream';
import { LiveKitService } from './livekit.service.js'; import { LiveKitService } from './livekit.service.js';
import { import {
errorRecordingAlreadyStarted, errorRecordingAlreadyStarted,
errorRecordingAlreadyStopped,
errorRecordingCannotBeStoppedWhileStarting,
errorRecordingNotFound, errorRecordingNotFound,
errorRecordingNotStopped, errorRecordingNotStopped,
errorRoomNotFound, errorRoomNotFound,
internalError internalError,
OpenViduMeetError
} from '../models/error.model.js'; } from '../models/error.model.js';
import { S3Service } from './s3.service.js'; import { S3Service } from './s3.service.js';
import { LoggerService } from './logger.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 { RecordingHelper } from '../helpers/recording.helper.js';
import { MEET_S3_BUCKET, MEET_S3_RECORDINGS_PREFIX, MEET_S3_SUBBUCKET } from '../environment.js'; import { MEET_S3_BUCKET, MEET_S3_RECORDINGS_PREFIX, MEET_S3_SUBBUCKET } from '../environment.js';
import { RoomService } from './room.service.js'; import { RoomService } from './room.service.js';
@ -21,13 +24,6 @@ import { RedisLockName } from '../models/index.js';
import ms from 'ms'; import ms from 'ms';
import { OpenViduComponentsAdapterHelper } from '../helpers/ov-components-adapter.helper.js'; import { OpenViduComponentsAdapterHelper } from '../helpers/ov-components-adapter.helper.js';
type GetAllRecordingsParams = {
maxItems?: number;
nextPageToken?: string;
roomId?: string;
status?: string;
};
@injectable() @injectable()
export class RecordingService { export class RecordingService {
protected readonly RECORDING_ACTIVE_LOCK_TTL = ms('6h'); protected readonly RECORDING_ACTIVE_LOCK_TTL = ms('6h');
@ -43,16 +39,16 @@ export class RecordingService {
let acquiredLock: RedisLock | null = null; let acquiredLock: RedisLock | null = null;
try { try {
const room = await this.roomService.getOpenViduRoom(roomId);
if (!room) throw errorRoomNotFound(roomId);
// Attempt to acquire lock. // Attempt to acquire lock.
// Note: using a high TTL to prevent expiration during a long recording. // Note: using a high TTL to prevent expiration during a long recording.
acquiredLock = await this.acquireRoomRecordingActiveLock(roomId); acquiredLock = await this.acquireRoomRecordingActiveLock(roomId);
if (!acquiredLock) throw errorRecordingAlreadyStarted(roomId); if (!acquiredLock) throw errorRecordingAlreadyStarted(roomId);
const room = await this.roomService.getOpenViduRoom(roomId);
if (!room) throw errorRoomNotFound(roomId);
const options = this.generateCompositeOptionsFromRequest(); const options = this.generateCompositeOptionsFromRequest();
const output = this.generateFileOutputFromRequest(roomId); const output = this.generateFileOutputFromRequest(roomId);
const egressInfo = await this.livekitService.startRoomComposite(roomId, output, options); const egressInfo = await this.livekitService.startRoomComposite(roomId, output, options);
@ -73,12 +69,24 @@ export class RecordingService {
try { try {
const { roomId, egressId } = RecordingHelper.extractInfoFromRecordingId(recordingId); 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); 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); const egressInfo = await this.livekitService.stopEgress(egressId);
return RecordingHelper.toRecordingInfo(egressInfo); return RecordingHelper.toRecordingInfo(egressInfo);
@ -116,21 +124,25 @@ export class RecordingService {
* Deletes multiple recordings in bulk from S3. * Deletes multiple recordings in bulk from S3.
* For each provided egressId, the metadata and recording file are deleted (only if the status is stopped). * 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. * @returns An array with the MeetRecordingInfo of the successfully deleted recordings.
*/ */
async bulkDeleteRecordings(egressIds: string[]): Promise<MeetRecordingInfo[]> { async bulkDeleteRecordings(
recordingIds: string[]
): Promise<{ deleted: string[]; notDeleted: { recordingId: string; error: string }[] }> {
const keysToDelete: 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 { try {
const { filesToDelete, recordingInfo } = await this.getDeletableRecordingData(egressId); const { filesToDelete } = await this.getDeletableRecordingData(recordingId);
keysToDelete.push(...filesToDelete); keysToDelete.push(...filesToDelete);
deletedRecordings.push(recordingInfo); deletedRecordings.push(recordingId);
this.logger.verbose(`BulkDelete: Prepared recording ${egressId} for deletion.`); this.logger.verbose(`BulkDelete: Prepared recording ${recordingId} for deletion.`);
} catch (error) { } 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.`); 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. * - `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. * @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[]; recordings: MeetRecordingInfo[];
isTruncated: boolean; isTruncated: boolean;
nextPageToken?: string; nextPageToken?: string;
}> { }> {
try { try {
// Construct the room prefix if a room ID is provided
const roomPrefix = roomId ? `/${roomId}` : ''; const roomPrefix = roomId ? `/${roomId}` : '';
// Retrieve the recordings from the S3 bucket
const { Contents, IsTruncated, NextContinuationToken } = await this.s3Service.listObjectsPaginated( const { Contents, IsTruncated, NextContinuationToken } = await this.s3Service.listObjectsPaginated(
`${MEET_S3_RECORDINGS_PREFIX}/.metadata${roomPrefix}`, `${MEET_S3_RECORDINGS_PREFIX}/.metadata${roomPrefix}`,
maxItems, maxItems,
@ -190,6 +205,7 @@ export class RecordingService {
} }
const promises: Promise<MeetRecordingInfo>[] = []; const promises: Promise<MeetRecordingInfo>[] = [];
// Retrieve the metadata for each recording
Contents.forEach((item) => { Contents.forEach((item) => {
if (item?.Key && item.Key.endsWith('.json')) { if (item?.Key && item.Key.endsWith('.json')) {
promises.push(this.s3Service.getObjectAsJson(item.Key) as Promise<MeetRecordingInfo>); promises.push(this.s3Service.getObjectAsJson(item.Key) as Promise<MeetRecordingInfo>);
@ -199,21 +215,22 @@ export class RecordingService {
let recordings: MeetRecordingInfo[] = await Promise.all(promises); let recordings: MeetRecordingInfo[] = await Promise.all(promises);
if (status) { 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 const statusArray = status
.split(',') .split(',')
.map((s) => s.trim()) .map((s) => s.trim())
.filter(Boolean) .filter(Boolean)
.map((s) => new RegExp(this.sanitizeRegExp(s))); .map((s) => new RegExp(this.sanitizeRegExp(s)));
recordings = recordings.filter((recording) => recordings = recordings.filter((recording) =>
statusArray.some((regex) => regex.test(recording.status)) statusArray.some((regex) => regex.test(recording.status))
); );
} }
this.logger.info(`Retrieved ${recordings.length} recordings.`); this.logger.info(`Retrieved ${recordings.length} recordings.`);
// Return the paginated list of recordings
return { recordings, isTruncated: !!IsTruncated, nextPageToken: NextContinuationToken }; return { recordings, isTruncated: !!IsTruncated, nextPageToken: NextContinuationToken };
} catch (error) { } catch (error) {
this.logger.error(`Error getting recordings: ${error}`); this.logger.error(`Error getting recordings: ${error}`);
@ -221,7 +238,6 @@ export class RecordingService {
} }
} }
//TODO: Implement getRecordingAsStream method
async getRecordingAsStream( async getRecordingAsStream(
recordingId: string, recordingId: string,
range?: string range?: string

View File

@ -9,7 +9,7 @@ export const enum MeetRecordingStatus {
} }
export const enum MeetRecordingOutputMode { export const enum MeetRecordingOutputMode {
COMPOSED = 'COMPOSED' COMPOSED = 'COMPOSED',
} }
/** /**
@ -29,3 +29,10 @@ export interface MeetRecordingInfo {
error?: string; error?: string;
details?: string; details?: string;
} }
export type MeetRecordingFilters = {
maxItems?: number;
nextPageToken?: string;
roomId?: string;
status?: string;
};