backend: Implement participant name reservation system

- Added ParticipantNameService to manage unique participant name reservations.
- Integrated name reservation in ParticipantService during token generation.
- Implemented cleanup of expired name reservations in LivekitWebhookService.
- Enhanced RedisService with atomic operations for name reservation.
- Updated internal configuration for participant name reservation limits.
- Added tests for participant name reservation and release functionality.
- Updated frontend dependencies to use the latest version of openvidu-components-angular.
This commit is contained in:
Carlos Santos 2025-08-18 18:49:46 +02:00
parent 8203be2687
commit a636ad485f
17 changed files with 889 additions and 88 deletions

View File

@ -21,8 +21,6 @@
$ref: '../../components/responses/forbidden-error.yaml'
'404':
$ref: '../../components/responses/error-room-not-found.yaml'
'409':
$ref: '../../components/responses/internal/error-participant-already-exists.yaml'
'422':
$ref: '../../components/responses/validation-error.yaml'
'500':

View File

@ -1182,22 +1182,22 @@
}
},
"node_modules/@babel/core": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.0.tgz",
"integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==",
"version": "7.28.3",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.3.tgz",
"integrity": "sha512-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.0",
"@babel/generator": "^7.28.3",
"@babel/helper-compilation-targets": "^7.27.2",
"@babel/helper-module-transforms": "^7.27.3",
"@babel/helpers": "^7.27.6",
"@babel/parser": "^7.28.0",
"@babel/helper-module-transforms": "^7.28.3",
"@babel/helpers": "^7.28.3",
"@babel/parser": "^7.28.3",
"@babel/template": "^7.27.2",
"@babel/traverse": "^7.28.0",
"@babel/types": "^7.28.0",
"@babel/traverse": "^7.28.3",
"@babel/types": "^7.28.2",
"convert-source-map": "^2.0.0",
"debug": "^4.1.0",
"gensync": "^1.0.0-beta.2",
@ -1223,14 +1223,14 @@
}
},
"node_modules/@babel/generator": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.0.tgz",
"integrity": "sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==",
"version": "7.28.3",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz",
"integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.28.0",
"@babel/types": "^7.28.0",
"@babel/parser": "^7.28.3",
"@babel/types": "^7.28.2",
"@jridgewell/gen-mapping": "^0.3.12",
"@jridgewell/trace-mapping": "^0.3.28",
"jsesc": "^3.0.2"
@ -1291,15 +1291,15 @@
}
},
"node_modules/@babel/helper-module-transforms": {
"version": "7.27.3",
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.3.tgz",
"integrity": "sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg==",
"version": "7.28.3",
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz",
"integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-module-imports": "^7.27.1",
"@babel/helper-validator-identifier": "^7.27.1",
"@babel/traverse": "^7.27.3"
"@babel/traverse": "^7.28.3"
},
"engines": {
"node": ">=6.9.0"
@ -1349,9 +1349,9 @@
}
},
"node_modules/@babel/helpers": {
"version": "7.28.2",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.2.tgz",
"integrity": "sha512-/V9771t+EgXz62aCcyofnQhGM8DQACbRhvzKFsXKC9QM+5MadF8ZmIm0crDMaz3+o0h0zXfJnd4EhbYbxsrcFw==",
"version": "7.28.3",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.3.tgz",
"integrity": "sha512-PTNtvUQihsAsDHMOP5pfobP8C6CM4JWXmP8DrEIt46c3r2bf87Ua1zoqevsMo9g+tWDwgWrFP5EIxuBx5RudAw==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -1363,13 +1363,13 @@
}
},
"node_modules/@babel/parser": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz",
"integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==",
"version": "7.28.3",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.3.tgz",
"integrity": "sha512-7+Ey1mAgYqFAx2h0RuoxcQT5+MlG3GTV0TQrgr7/ZliKsm/MNDxVVutlWaziMq7wJNAz8MTqz55XLpWvva6StA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.28.0"
"@babel/types": "^7.28.2"
},
"bin": {
"parser": "bin/babel-parser.js"
@ -1633,18 +1633,18 @@
}
},
"node_modules/@babel/traverse": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.0.tgz",
"integrity": "sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg==",
"version": "7.28.3",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.3.tgz",
"integrity": "sha512-7w4kZYHneL3A6NP2nxzHvT3HCZ7puDZZjFMqDpBPECub79sTtSO5CGXDkKrTQq8ksAwfD/XI2MRFX23njdDaIQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.0",
"@babel/generator": "^7.28.3",
"@babel/helper-globals": "^7.28.0",
"@babel/parser": "^7.28.0",
"@babel/parser": "^7.28.3",
"@babel/template": "^7.27.2",
"@babel/types": "^7.28.0",
"@babel/types": "^7.28.2",
"debug": "^4.3.1"
},
"engines": {
@ -2326,9 +2326,9 @@
}
},
"node_modules/@inquirer/confirm": {
"version": "5.1.14",
"resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.14.tgz",
"integrity": "sha512-5yR4IBfe0kXe59r1YCTG8WXkUbl7Z35HK87Sw+WUyGD8wNUx7JvY7laahzeytyE1oLn74bQnL7hstctQxisQ8Q==",
"version": "5.1.15",
"resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.15.tgz",
"integrity": "sha512-SwHMGa8Z47LawQN0rog0sT+6JpiL0B7eW9p1Bb7iCeKDGTI5Ez25TSc2l8kw52VV7hA4sX/C78CGkMrKXfuspA==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -2548,14 +2548,14 @@
}
},
"node_modules/@inquirer/prompts": {
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.8.2.tgz",
"integrity": "sha512-nqhDw2ZcAUrKNPwhjinJny903bRhI0rQhiDz1LksjeRxqa36i3l75+4iXbOy0rlDpLJGxqtgoPavQjmmyS5UJw==",
"version": "7.8.3",
"resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.8.3.tgz",
"integrity": "sha512-iHYp+JCaCRktM/ESZdpHI51yqsDgXu+dMs4semzETftOaF8u5hwlqnbIsuIR/LrWZl8Pm1/gzteK9I7MAq5HTA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@inquirer/checkbox": "^4.2.1",
"@inquirer/confirm": "^5.1.14",
"@inquirer/confirm": "^5.1.15",
"@inquirer/editor": "^4.2.17",
"@inquirer/expand": "^4.0.17",
"@inquirer/input": "^4.2.1",
@ -2739,9 +2739,9 @@
}
},
"node_modules/@isaacs/cliui/node_modules/ansi-regex": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz",
"integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==",
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.0.tgz",
"integrity": "sha512-TKY5pyBkHyADOPYlRT9Lx6F544mPl0vS5Ew7BJ45hA08Q+t3GjbueLliBWN3sMICk6+y7HdyxSzC4bWS8baBdg==",
"license": "MIT",
"engines": {
"node": ">=12"
@ -3648,9 +3648,9 @@
"license": "MIT"
},
"node_modules/@livekit/protocol": {
"version": "1.39.3",
"resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.39.3.tgz",
"integrity": "sha512-hfOnbwPCeZBEvMRdRhU2sr46mjGXavQcrb3BFRfG+Gm0Z7WUSeFdy5WLstXJzEepz17Iwp/lkGwJ4ZgOOYfPuA==",
"version": "1.40.0",
"resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.40.0.tgz",
"integrity": "sha512-1q0TNqlSTDW9ZuQCYTLDusyO+StvAXPmECQgyuszThDjzhTwQOkA6Rv7B1wPcvpTcwieIG0vuAO4cw4+kg+xWA==",
"license": "Apache-2.0",
"dependencies": {
"@bufbuild/protobuf": "^1.10.0"
@ -7517,9 +7517,9 @@
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.200",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.200.tgz",
"integrity": "sha512-rFCxROw7aOe4uPTfIAx+rXv9cEcGx+buAF4npnhtTqCJk5KDFRnh3+KYj7rdVh6lsFt5/aPs+Irj9rZ33WMA7w==",
"version": "1.5.203",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.203.tgz",
"integrity": "sha512-uz4i0vLhfm6dLZWbz/iH88KNDV+ivj5+2SA+utpgjKaj9Q0iDLuwk6Idhe9BTxciHudyx6IvTvijhkPvFGUQ0g==",
"dev": true,
"license": "ISC"
},
@ -12366,14 +12366,14 @@
}
},
"node_modules/openapi-generate-html/node_modules/inquirer": {
"version": "12.9.2",
"resolved": "https://registry.npmjs.org/inquirer/-/inquirer-12.9.2.tgz",
"integrity": "sha512-XPukbomHpZc3GAajQdAcuqa5NCIFhUcLMcXXSpJLM2RW/u/5JHLxjLF206GNTJARib8XBBRqyMbaNrDzXROdoA==",
"version": "12.9.3",
"resolved": "https://registry.npmjs.org/inquirer/-/inquirer-12.9.3.tgz",
"integrity": "sha512-Hpw2JWdrYY8xJSmhU05Idd5FPshQ1CZErH00WO+FK6fKxkBeqj+E+yFXSlERZLKtzWeQYFCMfl8U2TK9SvVbtQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@inquirer/core": "^10.1.15",
"@inquirer/prompts": "^7.8.2",
"@inquirer/prompts": "^7.8.3",
"@inquirer/type": "^3.0.8",
"ansi-escapes": "^4.3.2",
"mute-stream": "^2.0.0",

View File

@ -24,7 +24,8 @@ import {
TaskSchedulerService,
TokenService,
UserService,
FrontendEventService
FrontendEventService,
ParticipantNameService
} from '../services/index.js';
export const container: Container = new Container();
@ -61,6 +62,7 @@ export const registerDependencies = () => {
container.bind(FrontendEventService).toSelf().inSingletonScope();
container.bind(LiveKitService).toSelf().inSingletonScope();
container.bind(RoomService).toSelf().inSingletonScope();
container.bind(ParticipantNameService).toSelf().inSingletonScope();
container.bind(ParticipantService).toSelf().inSingletonScope();
container.bind(RecordingService).toSelf().inSingletonScope();
container.bind(OpenViduWebhookService).toSelf().inSingletonScope();

View File

@ -17,6 +17,10 @@ const INTERNAL_CONFIG = {
PARTICIPANT_TOKEN_EXPIRATION: '2h',
RECORDING_TOKEN_EXPIRATION: '2h',
// Participant name reservations
PARTICIPANT_MAX_CONCURRENT_NAME_REQUESTS: '20', // Maximum number of request by the same name at the same time allowed
PARTICIPANT_NAME_RESERVATION_TTL: '12h' as StringValue, // Time-to-live for participant name reservations
// Headers for API requests
API_KEY_HEADER: 'x-api-key',
PARTICIPANT_ROLE_HEADER: 'x-participant-role',

View File

@ -44,6 +44,9 @@ export const lkWebhookHandler = async (req: Request, res: Response) => {
case 'participant_joined':
await lkWebhookService.handleParticipantJoined(room!, participant!);
break;
case 'participant_left':
await lkWebhookService.handleParticipantLeft(room!, participant!);
break;
case 'room_started':
await lkWebhookService.handleRoomStarted(room!);
break;

View File

@ -10,6 +10,11 @@ export const enum RedisKeyName {
ARCHIVED_ROOM = `${RedisKeyPrefix.BASE}archived_room:`,
USER = `${RedisKeyPrefix.BASE}user:`,
API_KEYS = `${RedisKeyPrefix.BASE}api_keys:`,
//Tracks all currently reserved participant names per room (with TTL for auto-expiration).
ROOM_PARTICIPANTS = `${RedisKeyPrefix.BASE}room_participants:`,
// Stores released numeric suffixes (per base name) in a sorted set, so that freed numbers
// can be reused efficiently instead of always incrementing to the next highest number.
PARTICIPANT_NAME_POOL = `${RedisKeyPrefix.BASE}participant_pool:`
}
export const enum RedisLockPrefix {

View File

@ -13,6 +13,7 @@ export * from './auth.service.js';
export * from './livekit.service.js';
export * from './frontend-event.service.js';
export * from './room.service.js';
export * from './participant-name.service.js';
export * from './participant.service.js';
export * from './recording.service.js';
export * from './openvidu-webhook.service.js';

View File

@ -10,6 +10,7 @@ import {
MeetStorageService,
MutexService,
OpenViduWebhookService,
ParticipantService,
RecordingService,
RoomService,
DistributedEventService
@ -29,6 +30,7 @@ export class LivekitWebhookService {
@inject(MutexService) protected mutexService: MutexService,
@inject(DistributedEventService) protected distributedEventService: DistributedEventService,
@inject(FrontendEventService) protected frontendEventService: FrontendEventService,
@inject(ParticipantService) protected participantService: ParticipantService,
@inject(LoggerService) protected logger: LoggerService
) {
this.webhookReceiver = new WebhookReceiver(LIVEKIT_API_KEY, LIVEKIT_API_SECRET);
@ -158,6 +160,25 @@ export class LivekitWebhookService {
}
}
/**
* Handles the 'participant_left' event by releasing the participant's reserved name
* to make it available for other participants.
* @param room - Information about the room where the participant left.
* @param participant - Information about the participant who left.
*/
async handleParticipantLeft(room: Room, participant: ParticipantInfo) {
// Skip if the participant is an egress participant
if (this.livekitService.isEgressParticipant(participant)) return;
try {
// Release the participant's reserved name
await this.participantService.releaseParticipantName(room.name, participant.name);
this.logger.verbose(`Released name for participant '${participant.name}' in room '${room.name}'`);
} catch (error) {
this.logger.error('Error releasing participant name on participant left:', error);
}
}
/**
* Handles a room started event from LiveKit.
*
@ -210,7 +231,10 @@ export class LivekitWebhookService {
tasks.push(this.roomService.bulkDeleteRooms([roomName], true));
}
tasks.push(this.recordingService.releaseRecordingLockIfNoEgress(roomName));
tasks.push(
this.participantService.cleanupParticipantNames(roomName),
this.recordingService.releaseRecordingLockIfNoEgress(roomName)
);
await Promise.all(tasks);
} catch (error) {
this.logger.error(`Error handling room finished event: ${error}`);

View File

@ -0,0 +1,314 @@
import { inject, injectable } from 'inversify';
import { RedisKeyName } from '../models/redis.model.js';
import { LoggerService, RedisService } from './index.js';
import ms from 'ms';
import INTERNAL_CONFIG from '../config/internal-config.js';
@injectable()
export class ParticipantNameService {
private readonly MAX_CONCURRENT_NAME_REQUESTS = Number(INTERNAL_CONFIG.PARTICIPANT_MAX_CONCURRENT_NAME_REQUESTS);
private readonly PARTICIPANT_NAME_TTL = ms(INTERNAL_CONFIG.PARTICIPANT_NAME_RESERVATION_TTL);
constructor(
@inject(RedisService) protected redisService: RedisService,
@inject(LoggerService) protected logger: LoggerService
) {}
/**
* Reserves a unique participant name for a room using atomic operations.
* If the requested name is taken, it generates alternatives with incremental suffixes.
*
* @param roomId - The room identifier
* @param requestedName - The desired participant name
* @returns Promise<string> - The reserved unique name
* @throws Error if unable to reserve a unique name after max retries
*/
async reserveUniqueName(roomId: string, requestedName: string): Promise<string> {
const participantsKey = `${RedisKeyName.ROOM_PARTICIPANTS}${roomId}`;
// Normalize the base name for case-insensitive comparisons
const normalizedBaseName = requestedName.toLowerCase();
// First, try to reserve the exact requested name
const reservedOriginal = await this.tryReserveName(participantsKey, normalizedBaseName);
if (reservedOriginal) {
this.logger.verbose(`Reserved original name '${requestedName}' for room '${roomId}'`);
return requestedName;
}
// If original name is taken, generate alternatives with atomic counter
for (let attempt = 1; attempt <= this.MAX_CONCURRENT_NAME_REQUESTS; attempt++) {
const alternativeName = await this.generateAlternativeName(roomId, normalizedBaseName, attempt);
const reserved = await this.tryReserveName(participantsKey, alternativeName);
if (reserved) {
this.logger.verbose(
`Reserved alternative name '${alternativeName}' for room '${roomId}' (attempt ${attempt})`
);
// Return alternative name with original case
const suffix = alternativeName.replace(`${normalizedBaseName}_`, '');
return `${requestedName}_${suffix}`;
}
}
throw new Error(
`Unable to reserve unique name for '${requestedName}' in room '${roomId}' after ${this.MAX_CONCURRENT_NAME_REQUESTS} attempts`
);
}
/**
* Releases a reserved participant name, making it available for others.
*
* @param roomId - The room identifier
* @param participantName - The name to release
*/
/**
* Releases a reserved participant name, making it available for others.
* Also returns the number suffix to the available pool for reuse.
*
* @param roomId - The room identifier
* @param participantName - The name to release
*/
async releaseName(roomId: string, participantName: string): Promise<void> {
const participantsKey = `${RedisKeyName.ROOM_PARTICIPANTS}${roomId}`;
// Normalize the name for case-insensitive checks
const normalizedName = participantName.toLowerCase();
try {
await this.redisService.delete(`${participantsKey}:${normalizedName}`);
// If this is a numbered variant (e.g., "Alice_2"), return the number to the pool
const numberMatch = participantName.match(/^(.+)_(\d+)$/);
if (numberMatch) {
const baseName = numberMatch[1];
const number = parseInt(numberMatch[2], 10);
await this.returnNumberToPool(roomId, baseName, number);
}
this.logger.verbose(`Released name '${participantName}' for room '${roomId}'`);
} catch (error) {
this.logger.warn(`Error releasing name '${participantName}' for room '${roomId}':`, error);
}
}
/**
* Checks if a participant name is currently reserved in a room.
*
* @param roomId - The room identifier
* @param participantName - The name to check
* @returns Promise<boolean> - True if the name is reserved
*/
async isNameReserved(roomId: string, participantName: string): Promise<boolean> {
// Normalize the name for case-insensitive checks
const normalizedName = participantName.toLowerCase();
const participantsKey = `${RedisKeyName.ROOM_PARTICIPANTS}${roomId}`;
return await this.redisService.exists(`${participantsKey}:${normalizedName}`);
}
/**
* Gets all currently reserved names in a room.
*
* @param roomId - The room identifier
* @returns Promise<string[]> - Array of reserved participant names
*/
async getReservedNames(roomId: string): Promise<string[]> {
const participantsKey = `${RedisKeyName.ROOM_PARTICIPANTS}${roomId}`;
const pattern = `${participantsKey}:*`;
try {
const keys = await this.redisService.getKeys(pattern);
return keys.map((key) => key.replace(`${participantsKey}:`, ''));
} catch (error) {
this.logger.error(`Error getting reserved names for room '${roomId}':`, error);
return [];
}
}
/**
* Cleans up expired participant reservations for a room.
* This should be called periodically or when a room is cleaned up.
*
* @param roomId - The room identifier
*/
async cleanupExpiredReservations(roomId: string): Promise<void> {
const participantsKey = `${RedisKeyName.ROOM_PARTICIPANTS}${roomId}`;
const participantsPoolKey = `${RedisKeyName.PARTICIPANT_NAME_POOL}${roomId}`;
const pattern = `${participantsKey}:*`;
const poolPattern = `${participantsPoolKey}:*`;
try {
const [participantKeys, poolKeys] = await Promise.all([
this.redisService.getKeys(pattern),
this.redisService.getKeys(poolPattern)
]);
this.logger.verbose(
`Found ${participantKeys.length} participant reservations to check for room '${roomId}'`
);
// Redis TTL will automatically clean up expired keys, but we can force cleanup if needed
const promises = participantKeys.map((key) => this.redisService.delete(key));
await Promise.all(promises);
this.logger.verbose(
`Cleaned up ${participantKeys.length} expired participant names reservations for room '${roomId}'`
);
// Clean up expired participant name numbers from the pool
this.logger.verbose(`Found ${poolKeys.length} participant name numbers to check for room '${roomId}'`);
const poolPromises = poolKeys.map((key) => this.redisService.delete(key));
await Promise.all(poolPromises);
this.logger.verbose(`Cleaned up ${poolKeys.length} expired participant name numbers for room '${roomId}'`);
} catch (error) {
this.logger.error(`Error cleaning up reservations for room '${roomId}':`, error);
}
}
/**
* Attempts to atomically reserve a specific name using Redis SET with NX (not exists) option.
*
* @private
* @param participantsKey - The Redis key prefix for participants
* @param name - The name to reserve
* @returns Promise<boolean> - True if reservation was successful
*/
private async tryReserveName(participantsKey: string, name: string): Promise<boolean> {
// Normalize the name for case-insensitive checks
const normalizedName = name.toLowerCase();
const nameKey = `${participantsKey}:${normalizedName}`;
const timestamp = Date.now().toString();
try {
return await this.redisService.setIfNotExists(nameKey, timestamp, this.PARTICIPANT_NAME_TTL);
} catch (error) {
this.logger.warn(`Error trying to reserve name '${name}':`, error);
return false;
}
}
/**
* Generates an alternative name using a pool of available numbers.
* First tries to get a number from the available pool, then generates the next sequential number.
*
* @private
* @param roomId - The room identifier
* @param baseName - The base name to append number to
* @param fallbackSuffix - Fallback suffix if Redis fails
* @returns Promise<string> - The generated alternative name
*/
private async generateAlternativeName(roomId: string, baseName: string, fallbackSuffix: number): Promise<string> {
try {
// Normalize the base name for case-insensitive checks
const normalizedBaseName = baseName.toLowerCase();
// First try to get an available number from the pool
const availableNumber = await this.getNumberFromPool(roomId, normalizedBaseName);
if (availableNumber !== null) {
return `${baseName}_${availableNumber}`;
}
// If no number available in pool, find the next sequential number
const nextNumber = await this.findNextAvailableNumber(roomId, baseName);
return `${baseName}_${nextNumber}`;
} catch (error) {
this.logger.warn(`Error generating alternative name, using fallback:`, error);
// Fallback to simple incremental suffix if Redis fails
return `${baseName}_${fallbackSuffix}`;
}
}
/**
* Gets the smallest available number from the pool for reuse.
*
* @private
* @param roomId - The room identifier
* @param baseName - The base name
* @returns Promise<number | null> - Available number or null if pool is empty
*/
private async getNumberFromPool(roomId: string, baseName: string): Promise<number | null> {
const poolKey = `${RedisKeyName.PARTICIPANT_NAME_POOL}${roomId}:${baseName}`;
try {
// Get the smallest number from the sorted set and remove it atomically
const results = await this.redisService.popMinFromSortedSet(poolKey, 1);
if (results.length > 0) {
const number = parseInt(results[0], 10);
this.logger.verbose(`Reusing number ${number} from pool for '${baseName}' in room '${roomId}'`);
return number;
}
return null;
} catch (error) {
this.logger.warn(`Error getting number from pool:`, error);
return null;
}
}
/**
* Finds the next available sequential number by checking existing participants.
*
* @private
* @param roomId - The room identifier
* @param baseName - The base name
* @returns Promise<number> - The next available number
*/
private async findNextAvailableNumber(roomId: string, baseName: string): Promise<number> {
const participantsKey = `${RedisKeyName.ROOM_PARTICIPANTS}${roomId}`;
const pattern = `${participantsKey}:${baseName}_*`;
try {
const existingKeys = await this.redisService.getKeys(pattern);
const usedNumbers = new Set<number>();
// Extract all used numbers
for (const key of existingKeys) {
const name = key.replace(`${participantsKey}:`, '');
const numberMatch = name.match(/^.+_(\d+)$/);
if (numberMatch) {
usedNumbers.add(parseInt(numberMatch[1], 10));
}
}
// Find the smallest available number starting from 1
let nextNumber = 1;
while (usedNumbers.has(nextNumber)) {
nextNumber++;
}
this.logger.verbose(`Generated new sequential number ${nextNumber} for '${baseName}' in room '${roomId}'`);
return nextNumber;
} catch (error) {
this.logger.warn(`Error finding next available number:`, error);
// Fallback to timestamp-based number if everything fails
return Date.now() % 10000;
}
}
/**
* Returns a number to the available pool for reuse.
*
* @private
* @param roomId - The room identifier
* @param baseName - The base name
* @param number - The number to return to pool
*/
private async returnNumberToPool(roomId: string, baseName: string, number: number): Promise<void> {
const poolKey = `${RedisKeyName.PARTICIPANT_NAME_POOL}${roomId}:${baseName}`;
try {
// Add number to sorted set (score = number for natural ordering)
await this.redisService.addToSortedSet(poolKey, number, number.toString());
// Set TTL on pool key to prevent memory leaks
await this.redisService.setExpiration(poolKey, this.PARTICIPANT_NAME_TTL);
this.logger.verbose(`Returned number ${number} to pool for '${baseName}' in room '${roomId}'`);
} catch (error) {
this.logger.warn(`Error returning number to pool:`, error);
}
}
}

View File

@ -10,11 +10,17 @@ import { ParticipantInfo } from 'livekit-server-sdk';
import { MeetRoomHelper } from '../helpers/room.helper.js';
import { validateMeetTokenMetadata } from '../middlewares/index.js';
import {
errorParticipantAlreadyExists,
errorParticipantIdentityNotProvided,
errorParticipantNotFound
} from '../models/error.model.js';
import { FrontendEventService, LiveKitService, LoggerService, RoomService, TokenService } from './index.js';
import {
FrontendEventService,
LiveKitService,
LoggerService,
ParticipantNameService,
RoomService,
TokenService
} from './index.js';
@injectable()
export class ParticipantService {
@ -23,7 +29,8 @@ export class ParticipantService {
@inject(RoomService) protected roomService: RoomService,
@inject(LiveKitService) protected livekitService: LiveKitService,
@inject(FrontendEventService) protected frontendEventService: FrontendEventService,
@inject(TokenService) protected tokenService: TokenService
@inject(TokenService) protected tokenService: TokenService,
@inject(ParticipantNameService) protected participantNameService: ParticipantNameService
) {}
async generateOrRefreshParticipantToken(
@ -32,34 +39,49 @@ export class ParticipantService {
refresh = false
): Promise<string> {
const { roomId, secret, participantName, participantIdentity } = participantOptions;
let finalParticipantName = participantName;
let finalParticipantOptions: ParticipantOptions = participantOptions;
if (participantName) {
if (!refresh) {
// Check if participant with same participantName exists in the room
const participantExists = await this.participantExists(roomId, participantName, 'name');
if (participantExists) {
this.logger.verbose(`Participant '${participantName}' already exists in room '${roomId}'`);
throw errorParticipantAlreadyExists(participantName, roomId);
}
} else {
if (refresh) {
if (!participantIdentity) {
throw errorParticipantIdentityNotProvided();
}
this.logger.verbose(`Refreshing participant token for '${participantIdentity}' in room '${roomId}'`);
// Check if participant with same participantIdentity exists in the room
const participantExists = await this.participantExists(roomId, participantIdentity, 'identity');
if (!participantExists) {
this.logger.verbose(`Participant '${participantName}' does not exist in room '${roomId}'`);
throw errorParticipantNotFound(participantName, roomId);
this.logger.verbose(`Participant '${participantIdentity}' does not exist in room '${roomId}'`);
throw errorParticipantNotFound(participantIdentity, roomId);
}
} else {
this.logger.verbose(`Generating participant token for '${participantName}' in room '${roomId}'`);
try {
// Reserve a unique name for the participant
finalParticipantName = await this.participantNameService.reserveUniqueName(roomId, participantName);
this.logger.verbose(`Reserved unique name '${finalParticipantName}' for room '${roomId}'`);
} catch (error) {
this.logger.error(
`Failed to reserve unique name '${participantName}' for room '${roomId}':`,
error
);
throw error;
}
// Update participantOptions with the final participant name
finalParticipantOptions = {
...participantOptions,
participantName: finalParticipantName
};
}
}
const role = await this.roomService.getRoomRoleBySecret(roomId, secret);
const token = await this.generateParticipantToken(participantOptions, role, currentRoles);
this.logger.verbose(`Participant token generated for room '${roomId}'`);
const token = await this.generateParticipantToken(finalParticipantOptions, role, currentRoles);
this.logger.verbose(`Participant token generated for room '${roomId}' with name '${finalParticipantName}'`);
return token;
}
@ -186,4 +208,41 @@ export class ParticipantService {
}
};
}
/**
* Releases a participant's reserved name when they disconnect.
* This should be called when a participant leaves the room to free up the name.
*
* @param roomId - The room identifier
* @param participantName - The participant name to release
*/
async releaseParticipantName(roomId: string, participantName: string): Promise<void> {
try {
await this.participantNameService.releaseName(roomId, participantName);
this.logger.verbose(`Released participant name '${participantName}' for room '${roomId}'`);
} catch (error) {
this.logger.warn(`Error releasing participant name '${participantName}' for room '${roomId}':`, error);
}
}
/**
* Gets all currently reserved participant names in a room.
* Useful for debugging and monitoring.
*
* @param roomId - The room identifier
* @returns Promise<string[]> - Array of reserved participant names
*/
async getReservedNames(roomId: string): Promise<string[]> {
return await this.participantNameService.getReservedNames(roomId);
}
/**
* Cleans up expired participant name reservations for a room.
* This can be called during room cleanup or periodically.
*
* @param roomId - The room identifier
*/
async cleanupParticipantNames(roomId: string): Promise<void> {
await this.participantNameService.cleanupExpiredReservations(roomId);
}
}

View File

@ -206,18 +206,26 @@ export class RedisService extends EventEmitter {
* @returns {Promise<string>} - A promise that resolves to 'OK' if the operation is successful.
* @throws {Error} - Throws an error if the value type is invalid or if there is an issue setting the value in Redis.
*/
async set(key: string, value: any, withTTL = true): Promise<string> {
async set(key: string, value: string | number | boolean | object, withTTL = true): Promise<string> {
try {
const valueType = typeof value;
if (valueType === 'string' || valueType === 'number' || valueType === 'boolean') {
if (valueType === 'string' || valueType === 'number') {
if (withTTL) {
await this.redisPublisher.set(key, value, 'EX', this.DEFAULT_TTL);
await this.redisPublisher.set(key, value.toString(), 'EX', this.DEFAULT_TTL);
} else {
await this.redisPublisher.set(key, value);
await this.redisPublisher.set(key, value.toString());
}
} else if (valueType === 'boolean') {
const stringValue = value.toString();
if (withTTL) {
await this.redisPublisher.set(key, stringValue, 'EX', this.DEFAULT_TTL);
} else {
await this.redisPublisher.set(key, stringValue);
}
} else if (valueType === 'object') {
await this.redisPublisher.hmset(key, value);
await this.redisPublisher.hmset(key, value as Record<string, string | number>);
if (withTTL) await this.redisPublisher.expire(key, this.DEFAULT_TTL);
} else {
@ -250,6 +258,81 @@ export class RedisService extends EventEmitter {
}
}
/**
* Atomically sets a value only if the key doesn't exist (SET with NX option).
*
* @param {string} key - The key to set
* @param {string} value - The value to set
* @param {number} [ttlSeconds] - Optional TTL in seconds
* @returns {Promise<boolean>} - True if the key was set, false if it already existed
*/
async setIfNotExists(key: string, value: string, ttlSeconds?: number): Promise<boolean> {
try {
let result: string | null;
if (ttlSeconds) {
result = await this.redisPublisher.set(key, value, 'EX', ttlSeconds, 'NX');
} else {
result = (await this.redisPublisher.setnx(key, value)) ? 'OK' : null;
}
return result === 'OK';
} catch (error) {
this.logger.error('Error setting value with NX option in Redis', error);
throw error;
}
}
/**
* Sets an expiration time on an existing key.
*
* @param {string} key - The key to set expiration on
* @param {number} ttlSeconds - TTL in seconds
* @returns {Promise<boolean>} - True if expiration was set successfully
*/
async setExpiration(key: string, ttlSeconds: number): Promise<boolean> {
try {
const result = await this.redisPublisher.expire(key, ttlSeconds);
return result === 1;
} catch (error) {
this.logger.error('Error setting expiration in Redis', error);
throw error;
}
}
/**
* Removes and returns the member with the lowest score from a sorted set.
*
* @param {string} key - The key of the sorted set
* @param {number} count - Number of members to pop (default: 1)
* @returns {Promise<string[]>} - Array of popped members
*/
async popMinFromSortedSet(key: string, count = 1): Promise<string[]> {
try {
return await this.redisPublisher.zpopmin(key, count);
} catch (error) {
this.logger.error('Error popping min from sorted set in Redis', error);
throw error;
}
}
/**
* Adds one or more members to a sorted set.
*
* @param {string} key - The key of the sorted set
* @param {number} score - The score for the member
* @param {string} member - The member to add
* @returns {Promise<number>} - Number of elements added
*/
async addToSortedSet(key: string, score: number, member: string): Promise<number> {
try {
return await this.redisPublisher.zadd(key, score, member);
} catch (error) {
this.logger.error('Error adding to sorted set in Redis', error);
throw error;
}
}
cleanup() {
this.logger.verbose('Cleaning up Redis connections');
this.redisPublisher.quit();

View File

@ -57,7 +57,7 @@ export class TokenService {
if (participantName && !participantIdentity) {
// Generate participant identity based on name and unique ID
const identityPrefix = participantName.replace(/\s+/g, ''); // Remove all spaces
participantIdentity = `${identityPrefix}-${uid(5)}`;
participantIdentity = `${identityPrefix}-${uid(8)}`;
}
const metadata: MeetTokenMetadata = {

View File

@ -1,4 +1,4 @@
import { afterAll, beforeAll, describe, expect, it } from '@jest/globals';
import { afterEach, beforeAll, beforeEach, describe, expect, it } from '@jest/globals';
import { ParticipantRole } from '../../../../src/typings/ce/participant.js';
import { expectValidationError, expectValidParticipantTokenResponse } from '../../../helpers/assertion-helpers.js';
import {
@ -17,10 +17,14 @@ describe('Participant API Tests', () => {
beforeAll(async () => {
startTestServer();
});
beforeEach(async () => {
roomData = await setupSingleRoom();
});
afterAll(async () => {
// Force to cleanup participant name reservations after each test
afterEach(async () => {
await disconnectFakeParticipants();
await deleteAllRooms();
});
@ -87,14 +91,35 @@ describe('Participant API Tests', () => {
);
});
it('should fail with 409 when participant already exists in the room', async () => {
it('should success when participant already exists in the room', async () => {
roomData = await setupSingleRoom(true);
const response = await generateParticipantToken({
let response = await generateParticipantToken({
roomId: roomData.room.roomId,
secret: roomData.moderatorSecret,
participantName
});
expect(response.status).toBe(409);
// First participant using API. LK CLI participants can reuse the same name.
expectValidParticipantTokenResponse(
response,
roomData.room.roomId,
ParticipantRole.MODERATOR,
participantName
);
response = await generateParticipantToken({
roomId: roomData.room.roomId,
secret: roomData.moderatorSecret,
participantName
});
// Second participant using API, the participant name should be unique
expectValidParticipantTokenResponse(
response,
roomData.room.roomId,
ParticipantRole.MODERATOR,
participantName + '_1'
);
// Recreate the room without the participant
roomData = await setupSingleRoom();

View File

@ -0,0 +1,283 @@
import { describe, expect, it, beforeEach, afterEach, beforeAll } from '@jest/globals';
import { container, registerDependencies } from '../../../src/config/index.js';
import { ParticipantNameService } from '../../../src/services/participant-name.service.js';
import { RedisService } from '../../../src/services/redis.service.js';
import ms from 'ms';
describe('ParticipantNameService', () => {
let participantNameService: ParticipantNameService;
let redisService: RedisService;
const testRoomId = 'test-room-unique-names';
beforeAll(async () => {
registerDependencies();
participantNameService = container.get(ParticipantNameService);
redisService = container.get(RedisService);
});
beforeEach(async () => {
// Clean up any existing test data
await cleanupTestData();
});
afterEach(async () => {
// Clean up test data after each test
await cleanupTestData();
});
async function cleanupTestData() {
try {
const pattern = `ov_meet:room_participants:${testRoomId}:*`;
const keys = await redisService.getKeys(pattern);
if (keys.length > 0) {
await redisService.delete(keys);
}
const counterPattern = `ov_meet:participant_counter:${testRoomId}:*`;
const counterKeys = await redisService.getKeys(counterPattern);
if (counterKeys.length > 0) {
await redisService.delete(counterKeys);
}
} catch (error) {
// Ignore cleanup errors
}
}
describe('Reserve unique participant name', () => {
it('should reserve the original name when available', async () => {
const requestedName = 'Participant';
const reservedName = await participantNameService.reserveUniqueName(testRoomId, requestedName);
expect(reservedName).toBe(requestedName);
// Verify the name is actually reserved
const isReserved = await participantNameService.isNameReserved(testRoomId, requestedName);
expect(isReserved).toBe(true);
});
it('should treat names as case-insensitive if required', async () => {
await participantNameService.reserveUniqueName(testRoomId, 'Participant');
const reserved2 = await participantNameService.reserveUniqueName(testRoomId, 'participant');
expect(reserved2).toBe('participant_1');
});
it('should generate alternative names when original is taken', async () => {
const requestedName = 'Participant';
// Reserve the original name
const firstReservation = await participantNameService.reserveUniqueName(testRoomId, requestedName);
expect(firstReservation).toBe(requestedName);
// Try to reserve the same name again - should get alternative
const secondReservation = await participantNameService.reserveUniqueName(testRoomId, requestedName);
expect(secondReservation).toBe(`${requestedName}_1`);
// Try again - should get next alternative
const thirdReservation = await participantNameService.reserveUniqueName(testRoomId, requestedName);
expect(thirdReservation).toBe(`${requestedName}_2`);
});
it('should handle concurrent reservations properly', async () => {
const requestedName = 'Participant';
const concurrentRequests = 5;
// Simulate concurrent requests for the same name
const reservationPromises = Array.from({ length: concurrentRequests }, () =>
participantNameService.reserveUniqueName(testRoomId, requestedName)
);
const reservedNames = await Promise.all(reservationPromises);
// All names should be unique
const uniqueNames = new Set(reservedNames);
expect(uniqueNames.size).toBe(concurrentRequests);
// First name should be the original
expect(reservedNames).toContain(requestedName);
// Others should be alternatives
for (let i = 1; i < concurrentRequests; i++) {
expect(reservedNames).toContain(`${requestedName}_${i}`);
}
});
it('should reuse numbers when participants disconnect', async () => {
const requestedName = 'Participant';
// Reserve multiple names
const name1 = await participantNameService.reserveUniqueName(testRoomId, requestedName);
const name2 = await participantNameService.reserveUniqueName(testRoomId, requestedName);
const name3 = await participantNameService.reserveUniqueName(testRoomId, requestedName);
expect(name1).toBe('Participant');
expect(name2).toBe('Participant_1');
expect(name3).toBe('Participant_2');
// Release the middle one
await participantNameService.releaseName(testRoomId, name2);
// Next reservation should reuse the released number
const name4 = await participantNameService.reserveUniqueName(testRoomId, requestedName);
expect(name4).toBe('Participant_1'); // Should reuse the released number
});
it('should maintain optimal numbering after multiple releases', async () => {
const requestedName = 'Optimized';
// Create several names
const names: string[] = [];
for (let i = 0; i < 5; i++) {
names.push(await participantNameService.reserveUniqueName(testRoomId, requestedName));
}
expect(names).toEqual(['Optimized', 'Optimized_1', 'Optimized_2', 'Optimized_3', 'Optimized_4']);
// Release some names (simulate participants leaving)
await participantNameService.releaseName(testRoomId, 'Optimized_1');
await participantNameService.releaseName(testRoomId, 'Optimized_3');
// New participants should get the lowest available numbers
const newName1 = await participantNameService.reserveUniqueName(testRoomId, requestedName);
const newName2 = await participantNameService.reserveUniqueName(testRoomId, requestedName);
expect(newName1).toBe('Optimized_1'); // Lowest available
expect(newName2).toBe('Optimized_3'); // Next lowest available
});
});
describe('releaseName', () => {
it('should release a reserved name', async () => {
const participantName = 'Participant';
// Reserve a name
await participantNameService.reserveUniqueName(testRoomId, participantName);
expect(await participantNameService.isNameReserved(testRoomId, participantName)).toBe(true);
// Release the name
await participantNameService.releaseName(testRoomId, participantName);
expect(await participantNameService.isNameReserved(testRoomId, participantName)).toBe(false);
});
it('should allow reusing a released name', async () => {
const participantName = 'Frank';
// Reserve, release, and reserve again
await participantNameService.reserveUniqueName(testRoomId, participantName);
await participantNameService.releaseName(testRoomId, participantName);
const newReservation = await participantNameService.reserveUniqueName(testRoomId, participantName);
expect(newReservation).toBe(participantName);
});
});
describe('getReservedNames', () => {
it('should return all reserved names in a room in lowercase', async () => {
const names = ['Grace', 'Henry', 'Iris'];
// Reserve multiple names
for (const name of names) {
await participantNameService.reserveUniqueName(testRoomId, name);
}
const reservedNames = await participantNameService.getReservedNames(testRoomId);
for (const name of names) {
expect(reservedNames).toContain(name.toLowerCase());
}
});
it('should return empty array when no names are reserved', async () => {
const reservedNames = await participantNameService.getReservedNames(testRoomId);
expect(reservedNames).toEqual([]);
});
});
describe('Reserve unique participant name - edge cases', () => {
it('should be able to reserve same 20 names', async () => {
const requestedName = 'LimitTest';
const promises: Promise<string>[] = [];
const twentyNames = participantNameService['MAX_CONCURRENT_NAME_REQUESTS'];
for (let i = 0; i <= twentyNames; i++) {
promises.push(participantNameService.reserveUniqueName(testRoomId, requestedName));
}
// Los primeros MAX_RETRIES deben resolverse bien
const results = await Promise.allSettled(promises);
const fulfilled = results.filter((r) => r.status === 'fulfilled') as PromiseFulfilledResult<string>[];
const rejected = results.filter((r) => r.status === 'rejected') as PromiseRejectedResult[];
console.log(fulfilled);
expect(fulfilled.length).toBe(twentyNames + 1); // +1 for the original name
expect(rejected.length).toBe(0);
});
it('should handle race condition when reusing released numbers', async () => {
const requestedName = 'RaceTest';
// Try to reserve two names
const n1 = await participantNameService.reserveUniqueName(testRoomId, requestedName);
const n2 = await participantNameService.reserveUniqueName(testRoomId, requestedName);
expect([n1, n2]).toEqual(['RaceTest', 'RaceTest_1']);
// Release _1
await participantNameService.releaseName(testRoomId, n2);
// Try to reserve again concurrently
const [c1, c2] = await Promise.all([
participantNameService.reserveUniqueName(testRoomId, requestedName),
participantNameService.reserveUniqueName(testRoomId, requestedName)
]);
// One of them should be _1 and the other should be _2
expect([c1, c2].sort()).toEqual(['RaceTest_1', 'RaceTest_2']);
});
it('should reuse expired names after TTL', async () => {
(participantNameService as any)['PARTICIPANT_NAME_TTL'] = ms('1ms');
const requestedName = 'TTLTest';
// Reserva con TTL muy corto (simulado)
const name = await participantNameService.reserveUniqueName(testRoomId, requestedName);
expect(name).toBe('TTLTest');
// Wait for TTL to expire
await new Promise((resolve) =>
setTimeout(resolve, (participantNameService['PARTICIPANT_NAME_TTL'] + 1) * 1000)
);
// Try to reserve again
const newName = await participantNameService.reserveUniqueName(testRoomId, requestedName);
expect(newName).toBe('TTLTest'); // Reuse original name
});
it('should keep names isolated per room', async () => {
const requestedName = 'Isolated';
// Reserve in two different rooms
const room1Name = await participantNameService.reserveUniqueName('room1', requestedName);
const room2Name = await participantNameService.reserveUniqueName('room2', requestedName);
// Both names should be isolated
expect(room1Name).toBe(requestedName);
expect(room2Name).toBe(requestedName);
});
it('should treat names case-insensitively if normalization is enabled', async () => {
const requestedName = 'CaseTest';
const n1 = await participantNameService.reserveUniqueName(testRoomId, requestedName);
expect(n1).toBe('CaseTest');
// Try to reserve with different casing
const n2 = await participantNameService.reserveUniqueName(testRoomId, 'casetest');
expect(n2).toBe('casetest_1'); // Should return alternative name with original case
});
});
});

View File

@ -21,7 +21,7 @@
"core-js": "^3.38.1",
"jwt-decode": "^4.0.0",
"livekit-server-sdk": "^2.10.2",
"openvidu-components-angular": "^3.4.0-dev14",
"openvidu-components-angular": "^3.4.0-dev15",
"rxjs": "7.8.1",
"tslib": "^2.3.0",
"unique-names-generator": "^4.7.1",
@ -13576,9 +13576,9 @@
}
},
"node_modules/openvidu-components-angular": {
"version": "3.4.0-dev14",
"resolved": "https://registry.npmjs.org/openvidu-components-angular/-/openvidu-components-angular-3.4.0-dev14.tgz",
"integrity": "sha512-pvuQCgDqmweWdEn40x6DtN27A1pRBo4tVAFSAciM27EQtDXsV30k0uqC2khdrZfYeGcK1usvqcOmXEloQ4Aw9Q==",
"version": "3.4.0-dev15",
"resolved": "https://registry.npmjs.org/openvidu-components-angular/-/openvidu-components-angular-3.4.0-dev15.tgz",
"integrity": "sha512-rH/jPOx+ZgCIxbKW1+nvEh5qbJR5V6pbWs8SFWd1gV1V7wUJYoFI+iXxO0naaWHifq0rYNO0NoLpz2hjJqPQpQ==",
"dependencies": {
"tslib": "^2.3.0"
},

View File

@ -37,7 +37,7 @@
"core-js": "^3.38.1",
"jwt-decode": "^4.0.0",
"livekit-server-sdk": "^2.10.2",
"openvidu-components-angular": "^3.4.0-dev14",
"openvidu-components-angular": "^3.4.0-dev15",
"rxjs": "7.8.1",
"tslib": "^2.3.0",
"unique-names-generator": "^4.7.1",

View File

@ -1,7 +1,7 @@
@if (showMeeting) {
<ov-videoconference
[token]="participantToken"
[participantName]="participantName"
[prejoin]="true"
[prejoinDisplayParticipantName]="false"
[videoEnabled]="features().videoEnabled"