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
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

View File

@ -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",

View File

@ -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}`);

View File

@ -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<MeetRecordingFilters> = 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();
};

View File

@ -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 => {

View File

@ -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
);

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 { 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<MeetRecordingInfo[]> {
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<MeetRecordingInfo>[] = [];
// 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<MeetRecordingInfo>);
@ -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

View File

@ -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;
};