diff --git a/meet-ce/backend/openapi/components/requestBodies/start-recording-request.yaml b/meet-ce/backend/openapi/components/requestBodies/start-recording-request.yaml index 204f3774..123bfd8c 100644 --- a/meet-ce/backend/openapi/components/requestBodies/start-recording-request.yaml +++ b/meet-ce/backend/openapi/components/requestBodies/start-recording-request.yaml @@ -9,3 +9,22 @@ content: type: string description: The unique identifier of the room to record. example: 'room-123' + config: + type: object + description: | + Optional configuration to override the room's recording configuration for this specific recording. + If not provided, the recording will use the configuration defined in the room's config. + properties: + layout: + type: string + enum: + - grid + - speaker + - single-speaker + example: speaker + description: | + Defines the layout of the recording. This will override the room's default recording layout. + Options are: + - `grid`: All participants are shown in a grid layout. + - `speaker`: The active speaker is shown prominently, with other participants in smaller thumbnails. + - `single-speaker`: Only the active speaker is shown in the recording. diff --git a/meet-ce/backend/openapi/components/responses/success-start-recording.yaml b/meet-ce/backend/openapi/components/responses/success-start-recording.yaml index 5f75aa01..749295c2 100644 --- a/meet-ce/backend/openapi/components/responses/success-start-recording.yaml +++ b/meet-ce/backend/openapi/components/responses/success-start-recording.yaml @@ -8,6 +8,7 @@ content: roomId: 'room-123' roomName: 'room' status: 'active' + layout: 'speaker' filename: 'room-123--XX445.mp4' startDate: 1600000000000 headers: diff --git a/meet-ce/backend/openapi/components/responses/success-stop-recording.yaml b/meet-ce/backend/openapi/components/responses/success-stop-recording.yaml index 0712a02c..ecabb619 100644 --- a/meet-ce/backend/openapi/components/responses/success-stop-recording.yaml +++ b/meet-ce/backend/openapi/components/responses/success-stop-recording.yaml @@ -14,6 +14,7 @@ content: roomId: 'room-123' roomName: 'room' status: 'ending' + layout: 'speaker' filename: 'room-123--XX445.mp4' startDate: 1600000000000 details: 'End reason: StopEgress API' diff --git a/meet-ce/backend/openapi/paths/recordings.yaml b/meet-ce/backend/openapi/paths/recordings.yaml index aab55a47..d888c78b 100644 --- a/meet-ce/backend/openapi/paths/recordings.yaml +++ b/meet-ce/backend/openapi/paths/recordings.yaml @@ -4,6 +4,14 @@ summary: Start a recording description: > Start a new recording for an OpenVidu Meet room with the specified room ID. + + + By default, the recording will use the configuration defined in the room's settings. + However, you can optionally provide a configuration override in the request body to customize + the recording settings (e.g., layout) for this specific recording session. + + + If a configuration override is provided, those values will take precedence over the room's configuration. tags: - OpenVidu Meet - Recordings security: diff --git a/meet-ce/backend/src/controllers/recording.controller.ts b/meet-ce/backend/src/controllers/recording.controller.ts index 074dc8df..f95c4441 100644 --- a/meet-ce/backend/src/controllers/recording.controller.ts +++ b/meet-ce/backend/src/controllers/recording.controller.ts @@ -18,11 +18,11 @@ import { getBaseUrl } from '../utils/url.utils.js'; export const startRecording = async (req: Request, res: Response) => { const logger = container.get(LoggerService); const recordingService = container.get(RecordingService); - const { roomId } = req.body; + const { roomId, config } = req.body; logger.info(`Starting recording in room '${roomId}'`); try { - const recordingInfo = await recordingService.startRecording(roomId); + const recordingInfo = await recordingService.startRecording(roomId, config); res.setHeader( 'Location', `${getBaseUrl()}${INTERNAL_CONFIG.API_BASE_PATH_V1}/recordings/${recordingInfo.recordingId}` diff --git a/meet-ce/backend/src/models/zod-schemas/recording.schema.ts b/meet-ce/backend/src/models/zod-schemas/recording.schema.ts index 9e78752b..1db9e1f4 100644 --- a/meet-ce/backend/src/models/zod-schemas/recording.schema.ts +++ b/meet-ce/backend/src/models/zod-schemas/recording.schema.ts @@ -1,4 +1,4 @@ -import { MeetRecordingFilters, MeetRecordingStatus } from '@openvidu-meet/typings'; +import { MeetRecordingFilters, MeetRecordingLayout, MeetRecordingStatus } from '@openvidu-meet/typings'; import { z } from 'zod'; import { nonEmptySanitizedRoomId } from './room.schema.js'; @@ -50,7 +50,10 @@ export const nonEmptySanitizedRecordingId = (fieldName: string) => ); export const StartRecordingReqSchema = z.object({ - roomId: nonEmptySanitizedRoomId('roomId') + roomId: nonEmptySanitizedRoomId('roomId'), + config: z.object({ + layout: z.nativeEnum(MeetRecordingLayout).optional() + }).optional() }); export const RecordingFiltersSchema: z.ZodType = z.object({ diff --git a/meet-ce/backend/src/services/recording.service.ts b/meet-ce/backend/src/services/recording.service.ts index dee3b3ce..5b799efe 100644 --- a/meet-ce/backend/src/services/recording.service.ts +++ b/meet-ce/backend/src/services/recording.service.ts @@ -1,6 +1,7 @@ import { MeetRecordingFilters, MeetRecordingInfo, + MeetRecordingLayout, MeetRecordingStatus, MeetRoom, MeetRoomConfig @@ -51,7 +52,10 @@ export class RecordingService { @inject(LoggerService) protected logger: LoggerService ) {} - async startRecording(roomId: string): Promise { + async startRecording( + roomId: string, + configOverride?: { layout?: MeetRecordingLayout } + ): Promise { let acquiredLock: RedisLock | null = null; let eventListener!: (info: Record) => void; let recordingId = ''; @@ -106,7 +110,7 @@ export class RecordingService { const startRecordingPromise = (async (): Promise => { try { - const options = this.generateCompositeOptionsFromRequest(room.config); + const options = this.generateCompositeOptionsFromRequest(room.config, configOverride); const output = this.generateFileOutputFromRequest(roomId); const egressInfo = await this.livekitService.startRoomComposite(roomId, output, options); @@ -700,13 +704,20 @@ export class RecordingService { /** * Generates composite options for recording based on the provided room configuration. + * If configOverride is provided, its values will take precedence over room configuration. * * @param roomConfig The room configuration + * @param configOverride Optional configuration override from the request * @returns The generated RoomCompositeOptions object. */ - protected generateCompositeOptionsFromRequest({ recording }: MeetRoomConfig): RoomCompositeOptions { + protected generateCompositeOptionsFromRequest( + roomConfig: MeetRoomConfig, + configOverride?: { layout?: MeetRecordingLayout } + ): RoomCompositeOptions { + const roomRecordingConfig = roomConfig.recording; + const layout = configOverride?.layout ?? roomRecordingConfig.layout; return { - layout: recording.layout + layout // customBaseUrl: customLayout, // audioOnly: false, // videoOnly: false diff --git a/meet-ce/backend/tests/helpers/request-helpers.ts b/meet-ce/backend/tests/helpers/request-helpers.ts index 7c88047b..acf5b31d 100644 --- a/meet-ce/backend/tests/helpers/request-helpers.ts +++ b/meet-ce/backend/tests/helpers/request-helpers.ts @@ -588,13 +588,19 @@ export const endMeeting = async (roomId: string, moderatorToken: string) => { return response; }; -export const startRecording = async (roomId: string) => { +export const startRecording = async (roomId: string, config?: { layout?: string }) => { checkAppIsRunning(); + const body: { roomId: string; config?: { layout?: string } } = { roomId }; + + if (config) { + body.config = config; + } + return await request(app) .post(`${INTERNAL_CONFIG.API_BASE_PATH_V1}/recordings`) .set(INTERNAL_CONFIG.API_KEY_HEADER, MEET_ENV.INITIAL_API_KEY) - .send({ roomId }); + .send(body); }; export const stopRecording = async (recordingId: string) => { diff --git a/meet-ce/backend/tests/integration/api/recordings/start-recording.test.ts b/meet-ce/backend/tests/integration/api/recordings/start-recording.test.ts index f46793fc..09c7ccef 100644 --- a/meet-ce/backend/tests/integration/api/recordings/start-recording.test.ts +++ b/meet-ce/backend/tests/integration/api/recordings/start-recording.test.ts @@ -1,5 +1,5 @@ import { afterAll, afterEach, beforeAll, describe, expect, it } from '@jest/globals'; -import { MeetRoom } from '@openvidu-meet/typings'; +import { MeetRecordingLayout, MeetRoom } from '@openvidu-meet/typings'; import { container } from '../../../../src/config/dependency-injector.config.js'; import { setInternalConfig } from '../../../../src/config/internal-config.js'; import { errorRoomNotFound } from '../../../../src/models/error.model.js'; @@ -207,4 +207,77 @@ describe('Recording API Tests', () => { }); }); }); + + describe('Start Recording with Config Override', () => { + beforeAll(async () => { + // Create a room and join a participant + context = await setupMultiRoomTestContext(1, true); + ({ room } = context.getRoomByIndex(0)!); + }); + + afterEach(async () => { + await disconnectFakeParticipants(); + await stopAllRecordings(); + }); + + afterAll(async () => { + await disconnectFakeParticipants(); + await Promise.all([deleteAllRooms(), deleteAllRecordings()]); + context = null; + }); + + it('should start recording with default room layout when no config override is provided', async () => { + const response = await startRecording(room.roomId); + const recordingId = response.body.recordingId; + + expectValidStartRecordingResponse(response, room.roomId, room.roomName); + // Verify the recording uses the room's default layout (grid) + expect(response.body.layout).toBe(MeetRecordingLayout.GRID); + + const stopResponse = await stopRecording(recordingId); + expectValidStopRecordingResponse(stopResponse, recordingId, room.roomId, room.roomName); + }); + + it('should override room layout when config with layout is provided', async () => { + const response = await startRecording(room.roomId, { layout: MeetRecordingLayout.SPEAKER }); + const recordingId = response.body.recordingId; + + expectValidStartRecordingResponse(response, room.roomId, room.roomName); + // Verify the recording uses the overridden layout + expect(response.body.layout).toBe(MeetRecordingLayout.SPEAKER); + + const stopResponse = await stopRecording(recordingId); + expectValidStopRecordingResponse(stopResponse, recordingId, room.roomId, room.roomName); + }); + + it('should override room layout with single-speaker layout', async () => { + const response = await startRecording(room.roomId, { layout: MeetRecordingLayout.SINGLE_SPEAKER }); + const recordingId = response.body.recordingId; + + expectValidStartRecordingResponse(response, room.roomId, room.roomName); + // Verify the recording uses the overridden layout + expect(response.body.layout).toBe(MeetRecordingLayout.SINGLE_SPEAKER); + + const stopResponse = await stopRecording(recordingId); + expectValidStopRecordingResponse(stopResponse, recordingId, room.roomId, room.roomName); + }); + + it('should reject invalid layout in config override', async () => { + const response = await startRecording(room.roomId, { layout: 'invalid-layout' }); + + expectValidationError(response, 'config.layout', 'Invalid enum value'); + }); + + it('should accept empty config object and use room defaults', async () => { + const response = await startRecording(room.roomId, {}); + const recordingId = response.body.recordingId; + + expectValidStartRecordingResponse(response, room.roomId, room.roomName); + // Verify the recording uses the room's default layout + expect(response.body.layout).toBe(MeetRecordingLayout.GRID); + + const stopResponse = await stopRecording(recordingId); + expectValidStopRecordingResponse(stopResponse, recordingId, room.roomId, room.roomName); + }); + }); });