backend: Enhance recording routes and controllers with new validation middleware and improve error handling
This commit is contained in:
parent
e69f1dfb4b
commit
ca348d1a47
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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}`);
|
||||
|
||||
@ -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();
|
||||
};
|
||||
|
||||
|
||||
@ -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 => {
|
||||
|
||||
@ -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
|
||||
);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user