Compare commits

..

10 Commits

Author SHA1 Message Date
Cyril 26cb99b81c (frontend) apply OneToOneFocusLayout to main room 1-to-1
Show remote fullscreen with local thumbnail when no pin.
2026-08-26 10:26:12 +02:00
Cyril 5bc04409de ♻️(frontend) use OneToOneFocusLayout in PiP stage
Replace PipFocusLayout with the shared focus layout.
2026-08-26 10:25:48 +02:00
Cyril 78a06ab55f (frontend) add shared OneToOneFocusLayout component
Reuse the PiP main + thumbnail pattern for room and PiP.
2026-08-26 10:25:48 +02:00
Cyril 1443ef12eb ♻️(frontend) extract getTrackKey to shared layout utils
Move track key helper out of pip so layouts can reuse it.
2026-08-26 10:25:48 +02:00
lebaudantoine f1d3799434 🔖(minor) bump release to 1.29.0 2026-08-25 23:22:29 +02:00
lebaudantoine e59aaaa998 📝(changelog) fix a minor changelog issue
Wrongly added to an old section during a rebase.
2026-08-25 23:14:16 +02:00
lebaudantoine 76a24d4787 ️(backend) replace blocking Redis KEYS with cursor-based SCAN
`cache.keys()` runs Redis `KEYS`, a full-keyspace scan on Redis's
single thread that blocks everything else, including session reads
in the same cache. Its cost scales with total keys, not matches,
and some managed providers disable `KEYS` entirely.

The trusted-lobby feature made this urgent: the waiting-list
endpoint scanned on every poll, and its polling audience grows from
a few admins to potentially every authenticated participant.

Switch to cursor-based `SCAN` via two `core.utils` helpers, deleting
in bounded batches so cleanup of a large room cannot block either.
A single seam also lets us forbid raw `cache.keys()` going forward.

`SCAN` still iterates the keyspace incrementally on the polled
path. If monitoring flags it, the follow-up is a per-room set
index — out of scope here since it changes the lobby storage model.
2026-08-25 22:48:47 +02:00
lebaudantoine 943b81676b (frontend) let authenticated users manage the lobby on trusted rooms
Frontend counterpart of the trusted-lobby backend feature: on
`trusted` rooms, any authenticated participant sees the waiting
notification and can accept or deny entry requests, not only admins
and owners.

Gating moves from role to capability: `useCanManageLobby` mirrors
the backend permission and derives from `useRoomData()`. Room
metadata is already synced into the query cache, so an access-level
change mid-meeting recomputes the capability on every client with no
new sync mechanism. It is only a UI gate; the backend re-checks
everything per request and fails closed.

Fetching moves into a single room-level `LobbyProvider`: the hook
was previously instantiated by two components and only worked
because React Query deduplicated their queries. The provider owns
one query and an explicit state machine - ways in (connection
established, ParticipantWaiting broadcast, panel opened, rights
regained while the panel is open) all arm and fetch; ways out
(rights lost, server 401/403) disarm and clear the cached list so
nothing stale can render.

Polling is tiered by audience since managers grow from a few admins
to potentially the whole room: 1s when acting (panel open),
10s when the notification is shown, and zero when the list is empty -
decided in the refetchInterval callback because structural sharing
suppresses data-keyed effects on identical empty responses. A quiet
room costs nothing; fetches are triggered by uncorrelated human
events, never synchronized across the room (the rights-regained
trigger is panel-gated for this reason).
2026-08-25 22:48:47 +02:00
lebaudantoine 7369379106 (backend) let any authenticated user manage the lobby on trusted rooms
On rooms with the `trusted` access level, any authenticated user
connected to the meeting can now manage the lobby. Requested by
several organizations, and a step toward generalized lobby
management once hubs and groups land (same organization only).

Being authenticated is not enough to grant the capability: a
`trusted` room means "trusted to join", not "trusted to decide who
else joins from outside the call". The new `CanManageLobby`
permission therefore also requires the requester to be currently
connected to the meeting, verified against LiveKit and failing
closed, like `IsPresentInMeeting`. The access level itself is never
cached and always read fresh, so an owner switching the room back to
`restricted` revokes the capability on the very next request - the
one guarantee we did not want to trade for performance.

Performance is traded elsewhere: the waiting list is polled by every
lobby manager, and on a trusted room that audience grows from a few
admins to potentially the whole meeting. Hitting LiveKit once per
poll per participant would not survive that fan-out, so presence is
memoized in Redis (`PresenceCache`, `PRESENCE_CACHE_TIMEOUT`, 1h).
Entries are created lazily because only the minority of participants
who actually manage a lobby ever need one, and only positive answers
are cached because a sticky negative would lock out someone joining
right after a miss for the whole TTL. Eager invalidation on
`participant_left`, `room_finished` and admin kick keeps the cache
honest; the TTL is the safety net when an event is lost, and its
value bounds how long a departed participant could still act.

Trade-offs in this v0:

* `PRESENCE_CLEAR_ON_PARTICIPANT_LEFT` gates the eager invalidation
  on `participant_left`: its cost is one Redis DELETE per departure,
  for every departure, so we want to be able to measure it in
  production and turn it off independently of the feature. When
  disabled, invalidation relies on `room_finished` and the TTL only,
  widening the stale window above.
* This can put non-trivial pressure on the cache at scale; the
  rollout will need to be monitored closely.
* The `participant_left` webhook must be enabled in the LiveKit
  deployment, otherwise eager invalidation silently degrades to the
  TTL-only behavior.
2026-08-25 22:48:47 +02:00
lebaudantoine c02d54b6ff ️(frontend) apply frugal constraint to the active meeting audio track
Apply the `VOICE_AUDIO_CONSTRAINTS` to the audio track used during
the active meeting to reduce bandwidth usage.

* Originally proposed by trummerschlunk to reduce bandwidth, but
  previously restricted to the join screen preview.
* Backport the standard voice constraints (48 kHz sample rate,
  mono channel, 16-bit sample size) to the active call session, as
  requested by the BBBA team.
2026-08-25 22:33:42 +02:00
49 changed files with 846 additions and 1098 deletions
+1
View File
@@ -75,6 +75,7 @@ db.sqlite3
# IDEs # IDEs
.idea/ .idea/
.vscode/ .vscode/
.cursor/
*.iml *.iml
.devcontainer .devcontainer
+10 -2
View File
@@ -10,7 +10,13 @@ and this project adheres to
### Added ### Added
- ✨(backend) update a room's access level and configuration from the external API - ✨(frontend) share OneToOneFocusLayout between PiP and main room
## [1.29.0] - 2026-08-25
### Added
- ✨(any) let any authenticated user manage the lobby on trusted rooms
### Changed ### Changed
@@ -22,6 +28,9 @@ and this project adheres to
- ⬆️(frontend) upgrade @tanstack/react-query from 5.101.1 to 5.101.4 - ⬆️(frontend) upgrade @tanstack/react-query from 5.101.1 to 5.101.4
- ⬆️(frontend) upgrade @pandacss/preset-panda from 1.11.3 to 1.12.0 - ⬆️(frontend) upgrade @pandacss/preset-panda from 1.11.3 to 1.12.0
- ⬆️(frontend) upgrade posthog-js from 1.404.1 to 1.409.5 - ⬆️(frontend) upgrade posthog-js from 1.404.1 to 1.409.5
- ⚡️(frontend) apply frugal constraint to the active meeting audio track
- ⚡️(backend) replace blocking Redis KEYS with cursor-based SCAN
- ✨(summary) add hostname to analytics properties
## [1.28.0] - 2026-08-24 ## [1.28.0] - 2026-08-24
@@ -60,7 +69,6 @@ and this project adheres to
- 🔥(frontend) drop unused vendored ConnectionObserver - 🔥(frontend) drop unused vendored ConnectionObserver
- 🐛(frontend) vendor formatChatMessageLinks and trim surrounding newlines - 🐛(frontend) vendor formatChatMessageLinks and trim surrounding newlines
- ✨(summary) add hostname to analytics properties
### Fixed ### Fixed
+1 -76
View File
@@ -16,7 +16,7 @@ info:
* `rooms:list` List rooms accessible to the delegated user. * `rooms:list` List rooms accessible to the delegated user.
* `rooms:retrieve` Retrieve details of a specific room. * `rooms:retrieve` Retrieve details of a specific room.
* `rooms:create` Create new rooms. * `rooms:create` Create new rooms.
* `rooms:update` Update the access level and configuration of existing rooms. * `rooms:update` **Coming soon** Update existing rooms, e.g., add attendees to a room.
* `rooms:delete` **Coming soon** Delete rooms generated by the application. * `rooms:delete` **Coming soon** Delete rooms generated by the application.
#### Upcoming Features #### Upcoming Features
@@ -310,67 +310,6 @@ paths:
'404': '404':
$ref: '#/components/responses/RoomNotFoundError' $ref: '#/components/responses/RoomNotFoundError'
patch:
tags:
- Rooms
summary: Update a room
description: |
Partially updates a room. Only the delegated user's rooms where they are
administrator or owner can be updated; any other role gets a `403`.
**Updatable fields:** `access_level` and `configuration`. Every other field
(`id`, `name`, `slug`, `pin_code`) is read-only and silently ignored when sent.
`configuration` is replaced as a whole, it is not merged with the stored one.
Send the complete object you want the room to end up with.
Full replacement (`PUT`) is not supported. Use `PATCH` instead.
operationId: updateRoom
security:
- BearerAuth: [rooms:update]
parameters:
- name: id
in: path
required: true
description: Room UUID
schema:
type: string
format: uuid
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/RoomUpdate'
examples:
accessLevelOnly:
summary: Change the access level
value:
access_level: "restricted"
configurationOnly:
summary: Replace the room configuration
value:
configuration:
everyone_can_mute: true
responses:
'200':
description: Room updated successfully
content:
application/json:
schema:
$ref: '#/components/schemas/Room'
'400':
$ref: '#/components/responses/BadRequestError'
'401':
$ref: '#/components/responses/UnauthorizedError'
'403':
$ref: '#/components/responses/ForbiddenError'
'404':
$ref: '#/components/responses/RoomNotFoundError'
'405':
description: |
Method not allowed, `PUT` is not supported on this endpoint.
components: components:
securitySchemes: securitySchemes:
BearerAuth: BearerAuth:
@@ -447,17 +386,6 @@ components:
configuration: configuration:
$ref: '#/components/schemas/RoomConfiguration' $ref: '#/components/schemas/RoomConfiguration'
RoomUpdate:
type: object
description: |
Fields that can be updated on an existing room. Both are optional, omitted
fields keep their current value.
properties:
access_level:
$ref: '#/components/schemas/RoomAccessLevel'
configuration:
$ref: '#/components/schemas/RoomConfiguration'
RoomConfiguration: RoomConfiguration:
type: object type: object
description: | description: |
@@ -499,9 +427,6 @@ components:
- `public`: Anyone with the room link can join directly, no authentication required. - `public`: Anyone with the room link can join directly, no authentication required.
- `trusted`: Authenticated users join directly. Unauthenticated users wait in the lobby for approval. - `trusted`: Authenticated users join directly. Unauthenticated users wait in the lobby for approval.
- `restricted`: Only participants explicitly trusted by the owner bypass the lobby. Everyone else waits for approval regardless of authentication. - `restricted`: Only participants explicitly trusted by the owner bypass the lobby. Everyone else waits for approval regardless of authentication.
`public` is rejected with a `400` unless the deployment explicitly enables it
for this API. This applies both when creating a room and when updating one.
example: "trusted" example: "trusted"
Room: Room:
+1 -76
View File
@@ -20,7 +20,7 @@ info:
* `lasuite_visio:rooms:list` List rooms accessible to the delegated user. * `lasuite_visio:rooms:list` List rooms accessible to the delegated user.
* `lasuite_visio:rooms:retrieve` Retrieve details of a specific room. * `lasuite_visio:rooms:retrieve` Retrieve details of a specific room.
* `lasuite_visio:rooms:create` Create new rooms. * `lasuite_visio:rooms:create` Create new rooms.
* `lasuite_visio:rooms:update` Update the access level and configuration of existing rooms. * `lasuite_visio:rooms:update` **Coming soon** Update existing rooms, e.g., add attendees to a room.
* `lasuite_visio:rooms:delete` **Coming soon** Delete rooms generated by the application. * `lasuite_visio:rooms:delete` **Coming soon** Delete rooms generated by the application.
#### Upcoming Features #### Upcoming Features
@@ -206,67 +206,6 @@ paths:
'404': '404':
$ref: '#/components/responses/RoomNotFoundError' $ref: '#/components/responses/RoomNotFoundError'
patch:
tags:
- Rooms
summary: Update a room
description: |
Partially updates a room. Only rooms where the user is administrator or
owner can be updated; any other role gets a `403`.
**Updatable fields:** `access_level` and `configuration`. Every other field
(`id`, `name`, `slug`, `pin_code`) is read-only and silently ignored when sent.
`configuration` is replaced as a whole, it is not merged with the stored one.
Send the complete object you want the room to end up with.
Full replacement (`PUT`) is not supported. Use `PATCH` instead.
operationId: updateRoom
security:
- BearerAuth: [rooms:update]
parameters:
- name: id
in: path
required: true
description: Room UUID
schema:
type: string
format: uuid
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/RoomUpdate'
examples:
accessLevelOnly:
summary: Change the access level
value:
access_level: "restricted"
configurationOnly:
summary: Replace the room configuration
value:
configuration:
everyone_can_mute: true
responses:
'200':
description: Room updated successfully
content:
application/json:
schema:
$ref: '#/components/schemas/Room'
'400':
$ref: '#/components/responses/BadRequestError'
'401':
$ref: '#/components/responses/UnauthorizedError'
'403':
$ref: '#/components/responses/ForbiddenError'
'404':
$ref: '#/components/responses/RoomNotFoundError'
'405':
description: |
Method not allowed, `PUT` is not supported on this endpoint.
components: components:
securitySchemes: securitySchemes:
BearerAuth: BearerAuth:
@@ -288,17 +227,6 @@ components:
configuration: configuration:
$ref: '#/components/schemas/RoomConfiguration' $ref: '#/components/schemas/RoomConfiguration'
RoomUpdate:
type: object
description: |
Fields that can be updated on an existing room. Both are optional, omitted
fields keep their current value.
properties:
access_level:
$ref: '#/components/schemas/RoomAccessLevel'
configuration:
$ref: '#/components/schemas/RoomConfiguration'
RoomConfiguration: RoomConfiguration:
type: object type: object
description: | description: |
@@ -340,9 +268,6 @@ components:
- `public`: Anyone with the room link can join directly, no authentication required. - `public`: Anyone with the room link can join directly, no authentication required.
- `trusted`: Authenticated users join directly. Unauthenticated users wait in the lobby for approval. - `trusted`: Authenticated users join directly. Unauthenticated users wait in the lobby for approval.
- `restricted`: Only participants explicitly trusted by the owner bypass the lobby. Everyone else waits for approval regardless of authentication. - `restricted`: Only participants explicitly trusted by the owner bypass the lobby. Everyone else waits for approval regardless of authentication.
`public` is rejected with a `400` unless the deployment explicitly enables it
for this API. This applies both when creating a room and when updating one.
example: "trusted" example: "trusted"
Room: Room:
+1 -1
View File
@@ -1,7 +1,7 @@
[project] [project]
name = "agents" name = "agents"
version = "1.28.0" version = "1.29.0"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
"livekit-agents==1.6.7", "livekit-agents==1.6.7",
+1 -1
View File
@@ -9,7 +9,7 @@ resolution-markers = [
[[package]] [[package]]
name = "agents" name = "agents"
version = "1.28.0" version = "1.29.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "livekit-agents" }, { name = "livekit-agents" },
-1
View File
@@ -8,7 +8,6 @@ class AnalyticsEvent(StrEnum):
# Rooms # Rooms
ROOM_CREATED = "room_created" ROOM_CREATED = "room_created"
ROOM_UPDATED = "room_updated"
# Roomkit (meeting-room SIP devices) # Roomkit (meeting-room SIP devices)
ROOMKIT_JOINED = "roomkit_joined" ROOMKIT_JOINED = "roomkit_joined"
+46 -1
View File
@@ -5,7 +5,7 @@ from django.http import Http404
from rest_framework import permissions from rest_framework import permissions
from ..models import RoleChoices from ..models import RoleChoices, RoomAccessLevel
from ..services.participants_management import ( from ..services.participants_management import (
ParticipantNotFoundException, ParticipantNotFoundException,
ParticipantsManagement, ParticipantsManagement,
@@ -198,3 +198,48 @@ class IsPresentInMeeting(permissions.BasePermission):
return False return False
except ParticipantsManagementException: except ParticipantsManagementException:
return False return False
class CanManageLobby(permissions.BasePermission):
"""Grant lobby management (list/accept/deny waiting participants).
- Room admins/owners can always manage the lobby.
- When the room access level is TRUSTED, any authenticated user who is
currently connected to the meeting can manage the lobby. Presence is
verified cache-first (Redis), falling back to the LiveKit API.
Access level is always read fresh from the DB; only presence is cached,
so changing the room to RESTRICTED takes effect immediately.
"""
message = "You are not allowed to manage this room's lobby."
# pylint: disable=too-many-return-statements
def has_object_permission(self, request, view, obj): # noqa: PLR0911
"""Check privileges first, then the trusted-room presence path."""
user = request.user
if not user or not user.is_authenticated:
return False
# Product choice: lobby management is reserved for session-authenticated
# users with a real account, not holders of a LiveKit room token.
if request.auth and hasattr(request.auth, "video"):
return False
if obj.is_administrator_or_owner(user):
return True
if obj.access_level != RoomAccessLevel.TRUSTED:
return False
self.message = "You must be connected to the meeting to manage its lobby."
try:
return ParticipantsManagement().check_if_in_meeting_cached(
room_name=str(obj.pk), identity=str(user.sub)
)
except ParticipantNotFoundException:
return False
except ParticipantsManagementException:
return False
+27 -4
View File
@@ -89,7 +89,11 @@ from core.services.participants_management import (
ParticipantsManagementException, ParticipantsManagementException,
) )
from core.services.room_creation import RoomCreation from core.services.room_creation import RoomCreation
from core.services.room_management import sync_room_metadata from core.services.room_management import (
RoomManagement,
RoomManagementException,
RoomNotFoundException,
)
from core.services.room_roles import ( from core.services.room_roles import (
RoomRoleError, RoomRoleError,
RoomRoleService, RoomRoleService,
@@ -366,7 +370,26 @@ class RoomViewSet(
): ):
return return
sync_room_metadata(room) metadata = {
"configuration": room.configuration,
"access_level": room.access_level,
}
try:
RoomManagement().update_metadata(
room_name=str(room.id),
metadata=metadata,
)
except RoomNotFoundException:
logger.info(
"LiveKit room %s does not exist yet, skipping metadata sync",
room.id,
)
except RoomManagementException:
logger.warning(
"Failed to sync metadata to LiveKit for room %s",
room.id,
)
@decorators.action( @decorators.action(
detail=True, detail=True,
@@ -513,7 +536,7 @@ class RoomViewSet(
methods=["post"], methods=["post"],
url_path="enter", url_path="enter",
permission_classes=[ permission_classes=[
permissions.HasPrivilegesOnRoom, permissions.CanManageLobby,
], ],
) )
def allow_participant_to_enter(self, request, pk=None): # pylint: disable=unused-argument def allow_participant_to_enter(self, request, pk=None): # pylint: disable=unused-argument
@@ -551,7 +574,7 @@ class RoomViewSet(
methods=["GET"], methods=["GET"],
url_path="waiting-participants", url_path="waiting-participants",
permission_classes=[ permission_classes=[
permissions.HasPrivilegesOnRoom, permissions.CanManageLobby,
], ],
) )
def list_waiting_participants(self, request, pk=None): # pylint: disable=unused-argument def list_waiting_participants(self, request, pk=None): # pylint: disable=unused-argument
+20 -62
View File
@@ -25,7 +25,6 @@ from rest_framework import (
from core import analytics, api, models from core import analytics, api, models
from core.api.feature_flag import FeatureFlag from core.api.feature_flag import FeatureFlag
from core.services.jwt_token import JwtTokenService from core.services.jwt_token import JwtTokenService
from core.services.room_management import sync_room_metadata
from ..services.provisional_user_service import ( from ..services.provisional_user_service import (
ProvisionalUserCreationDisabledError, ProvisionalUserCreationDisabledError,
@@ -143,7 +142,6 @@ class RoomViewSet(
mixins.CreateModelMixin, mixins.CreateModelMixin,
mixins.RetrieveModelMixin, mixins.RetrieveModelMixin,
mixins.ListModelMixin, mixins.ListModelMixin,
mixins.UpdateModelMixin,
viewsets.GenericViewSet, viewsets.GenericViewSet,
): ):
"""Application-delegated API for room management. """Application-delegated API for room management.
@@ -156,12 +154,8 @@ class RoomViewSet(
- list: List rooms the user has access to (requires 'rooms:list' scope) - list: List rooms the user has access to (requires 'rooms:list' scope)
- retrieve: Get room details (requires 'rooms:retrieve' scope) - retrieve: Get room details (requires 'rooms:retrieve' scope)
- create: Create a new room owned by the user (requires 'rooms:create' scope) - create: Create a new room owned by the user (requires 'rooms:create' scope)
- partial_update: Update a room's access level and configuration, for
administrators and owners only (requires 'rooms:update' scope)
""" """
http_method_names = ["get", "post", "patch", "head", "options"]
authentication_classes = [ authentication_classes = [
authentication.ApplicationJWTAuthentication, authentication.ApplicationJWTAuthentication,
authentication.AddonsJWTAuthentication, authentication.AddonsJWTAuthentication,
@@ -195,38 +189,6 @@ class RoomViewSet(
serializer = self.get_serializer(queryset, many=True) serializer = self.get_serializer(queryset, many=True)
return drf_response.Response(serializer.data) return drf_response.Response(serializer.data)
def _track_room_event(self, room, event, **extra_properties):
"""Log a room operation for auditing and forward it to analytics."""
auth_method = type(self.request.successful_authenticator).__name__
client_id = (self.request.auth or {}).get("client_id", "unknown")
# Log for auditing
details = "".join(f", {key}={value}" for key, value in extra_properties.items())
logger.info(
"Room %s via application: room_id=%s, user_id=%s, client_id=%s, auth_method=%s%s",
event.removeprefix("room_"),
room.id,
self.request.user.id,
client_id,
auth_method,
details,
)
analytics.capture(
self.request.user,
event,
{
"room_id": str(room.pk),
"access_level": room.access_level,
"client_id": client_id,
"external_api": True,
"auth_method": auth_method,
**extra_properties,
"$set": {"email": self.request.user.email},
},
)
def perform_create(self, serializer): def perform_create(self, serializer):
"""Set the current user as owner of the newly created room.""" """Set the current user as owner of the newly created room."""
room = serializer.save() room = serializer.save()
@@ -236,31 +198,27 @@ class RoomViewSet(
role=models.RoleChoices.OWNER, role=models.RoleChoices.OWNER,
) )
self._track_room_event(room, analytics.AnalyticsEvent.ROOM_CREATED) auth_method = type(self.request.successful_authenticator).__name__
client_id = (self.request.auth or {}).get("client_id", "unknown")
def perform_update(self, serializer): # Log for auditing
"""Persist the room update, sync it to LiveKit, then log and track it.""" logger.info(
"Room created via application: room_id=%s, user_id=%s, client_id=%s, auth_method=%s",
previous_values = { room.id,
"access_level": serializer.instance.access_level, self.request.user.id,
"configuration": serializer.instance.configuration, client_id,
} auth_method,
room = serializer.save()
# Report the fields that actually changed, not the ones that were submitted.
updated_fields = sorted(
field
for field, previous_value in previous_values.items()
if getattr(room, field) != previous_value
) )
if updated_fields: analytics.capture(
sync_room_metadata(room) self.request.user,
analytics.AnalyticsEvent.ROOM_CREATED,
self._track_room_event( {
room, "room_id": str(room.pk),
analytics.AnalyticsEvent.ROOM_UPDATED, "access_level": room.access_level,
updated_fields=updated_fields, "client_id": client_id,
previous_access_level=previous_values["access_level"], "external_api": True,
"auth_method": auth_method,
"$set": {"email": self.request.user.email},
},
) )
+1 -1
View File
@@ -48,7 +48,7 @@ class ResourceFactory(factory.django.DjangoModelFactory):
else: else:
UserResourceAccessFactory(resource=self, user=item[0], role=item[1]) UserResourceAccessFactory(resource=self, user=item[0], role=item[1])
self.save() self.save()
class UserResourceAccessFactory(factory.django.DjangoModelFactory): class UserResourceAccessFactory(factory.django.DjangoModelFactory):
@@ -23,6 +23,7 @@ from core.recording.services.recording_events import (
) )
from .lobby import LobbyService from .lobby import LobbyService
from .presence import PresenceCache
from .room_management import ( from .room_management import (
RoomManagement, RoomManagement,
RoomManagementException, RoomManagementException,
@@ -99,6 +100,7 @@ class LiveKitEventsService:
"egress_ended": self._handle_egress_ended, "egress_ended": self._handle_egress_ended,
"room_started": self._handle_room_started, "room_started": self._handle_room_started,
"room_finished": self._handle_room_finished, "room_finished": self._handle_room_finished,
"participant_left": self._handle_participant_left,
} }
token_verifier = api.TokenVerifier( token_verifier = api.TokenVerifier(
@@ -107,6 +109,7 @@ class LiveKitEventsService:
) )
self.webhook_receiver = api.WebhookReceiver(token_verifier) self.webhook_receiver = api.WebhookReceiver(token_verifier)
self.lobby_service = LobbyService() self.lobby_service = LobbyService()
self.presence_cache = PresenceCache()
self.sip_management = SIPManagement() self.sip_management = SIPManagement()
self.recording_events = RecordingEventsService() self.recording_events = RecordingEventsService()
@@ -285,9 +288,31 @@ class LiveKitEventsService:
f"Failed to delete sip dispatch rule for room {room_id}" f"Failed to delete sip dispatch rule for room {room_id}"
) from e ) from e
self.presence_cache.clear_room(room_id)
try: try:
self.lobby_service.clear_room_cache(room_id) self.lobby_service.clear_room_cache(room_id)
except Exception as e: except Exception as e:
raise ActionFailedError( raise ActionFailedError(
f"Failed to clear room cache for room {room_id}" f"Failed to clear room cache for room {room_id}"
) from e ) from e
def _handle_participant_left(self, data):
"""Handle 'participant_left': invalidate the presence cache.
Presence entries are created lazily (only for users who administrate
the lobby of a trusted room), so for most participants this delete is
a no-op DEL on a key that never existed. Eager invalidation shrinks
the window during which a departed participant could still act on a
trusted room's lobby (cache hit until TTL expiry). It is gated behind
`PRESENCE_CLEAR_ON_PARTICIPANT_LEFT` so its production impact can be
measured and the behaviour reverted independently of the feature.
When disabled, invalidation relies on `room_finished` and the TTL.
"""
if not settings.PRESENCE_CLEAR_ON_PARTICIPANT_LEFT:
return
identity = data.participant.identity
if not identity:
return
self.presence_cache.clear(data.room.name, identity)
+4 -8
View File
@@ -270,7 +270,7 @@ class LobbyService:
"""List all waiting participants for a room.""" """List all waiting participants for a room."""
pattern = self._get_cache_key(room_id, "*") pattern = self._get_cache_key(room_id, "*")
keys = cache.keys(pattern) keys = list(cache.iter_keys(pattern, itersize=utils.CACHE_SCAN_ITERSIZE))
if not keys: if not keys:
return [] return []
@@ -345,13 +345,9 @@ class LobbyService:
def clear_room_cache(self, room_id: UUID) -> None: def clear_room_cache(self, room_id: UUID) -> None:
"""Clear all participant entries from the cache for a specific room.""" """Clear all participant entries from the cache for a specific room."""
pattern = self._get_cache_key(room_id, "*") cache.delete_pattern(
keys = cache.keys(pattern) self._get_cache_key(room_id, "*"), itersize=utils.CACHE_SCAN_ITERSIZE
)
if not keys:
return
cache.delete_many(keys)
def clear_participant_cache(self, room_id: UUID, participant_id: str) -> None: def clear_participant_cache(self, room_id: UUID, participant_id: str) -> None:
"""Clear a given participant entry from the cache for a specific room.""" """Clear a given participant entry from the cache for a specific room."""
@@ -20,6 +20,7 @@ from livekit.protocol.models import ParticipantInfo
from core import utils from core import utils
from .lobby import LobbyService from .lobby import LobbyService
from .presence import PresenceCache
logger = getLogger(__name__) logger = getLogger(__name__)
@@ -72,7 +73,9 @@ class ParticipantsManagement:
@async_to_sync @async_to_sync
async def remove(self, room_name: str, identity: str): async def remove(self, room_name: str, identity: str):
"""Remove a participant from a room and clear their lobby cache.""" """Remove a participant from a room and clear their lobby/presence cache."""
PresenceCache().clear(room_name, identity)
try: try:
LobbyService().clear_participant_cache( LobbyService().clear_participant_cache(
@@ -156,6 +159,30 @@ class ParticipantsManagement:
finally: finally:
await lkapi.aclose() await lkapi.aclose()
def check_if_in_meeting_cached(self, room_name: str, identity: str) -> bool:
"""Cache-first variant of `check_if_in_meeting`.
Cache hit -> True without touching LiveKit.
Cache miss -> ask LiveKit; memoize only positive answers.
Raises the same exceptions as `check_if_in_meeting` so callers keep
failing closed the same way.
"""
if not room_name or not identity:
return False
presence_cache = PresenceCache()
if presence_cache.is_marked_present(room_name, identity):
return True
present = self.check_if_in_meeting(room_name=room_name, identity=identity)
if present:
presence_cache.mark_present(room_name, identity)
return present
@async_to_sync @async_to_sync
async def check_if_in_meeting(self, room_name: str, identity: str) -> bool: async def check_if_in_meeting(self, room_name: str, identity: str) -> bool:
"""Check whether `identity` is currently a participant in `room_name`. """Check whether `identity` is currently a participant in `room_name`.
+52
View File
@@ -0,0 +1,52 @@
"""Presence cache.
Redis-backed memo of "this identity is currently connected to this room".
This module is intentionally a *pure cache store* with no dependency on other
services, so that `participants_management` (which talks to LiveKit) can
import it without creating an import cycle. The composition of "check cache,
fall back to LiveKit" lives in
`ParticipantsManagement.check_if_in_meeting_cached`.
Only positive answers are stored: a sticky negative would lock out someone
who joins right after a miss for the whole TTL. The TTL is a safety net in
case an invalidation webhook is lost.
"""
from uuid import UUID
from django.conf import settings
from django.core.cache import cache
from core.utils import CACHE_SCAN_ITERSIZE
class PresenceCache:
"""Store and invalidate (room, identity) presence entries."""
@staticmethod
def _get_cache_key(room_id: UUID | str, identity: str) -> str:
"""Cache key for a (room, identity) presence entry."""
return f"{settings.PRESENCE_KEY_PREFIX}_{room_id!s}_{identity}"
def is_marked_present(self, room_id: UUID | str, identity: str) -> bool:
"""Return True if a positive presence entry exists in cache."""
return bool(cache.get(self._get_cache_key(room_id, identity)))
def mark_present(self, room_id: UUID | str, identity: str) -> None:
"""Record that `identity` is in `room_id`."""
cache.set(
self._get_cache_key(room_id, identity),
True,
timeout=settings.PRESENCE_CACHE_TIMEOUT,
)
def clear(self, room_id: UUID | str, identity: str) -> None:
"""Forget presence for one participant (e.g. on participant_left)."""
cache.delete(self._get_cache_key(room_id, identity))
def clear_room(self, room_id: UUID | str) -> None:
"""Forget presence for every participant of a room (on room_finished)."""
cache.delete_pattern(
self._get_cache_key(room_id, "*"), itersize=CACHE_SCAN_ITERSIZE
)
@@ -116,32 +116,3 @@ class RoomManagement:
raise RoomManagementException("Could not delete room") from e raise RoomManagementException("Could not delete room") from e
finally: finally:
await lkapi.aclose() await lkapi.aclose()
def sync_room_metadata(room):
"""Push a room's configuration and access level to its LiveKit room metadata.
Failures are swallowed: a room that is not live yet, or a LiveKit hiccup,
should never fail the request that triggered the update.
"""
metadata = {
"configuration": room.configuration,
"access_level": room.access_level,
}
try:
RoomManagement().update_metadata(
room_name=str(room.id),
metadata=metadata,
)
except RoomNotFoundException:
logger.info(
"LiveKit room %s does not exist yet, skipping metadata sync",
room.id,
)
except RoomManagementException:
logger.warning(
"Failed to sync metadata to LiveKit for room %s",
room.id,
)
@@ -389,7 +389,7 @@ def test_allow_participant_to_enter_anonymous():
def test_allow_participant_to_enter_non_owner(): def test_allow_participant_to_enter_non_owner():
"""Non-privileged users should not be allowed to manage entry requests.""" """Non-privileged users should not be allowed to manage entry requests."""
room = RoomFactory() room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
user = UserFactory() user = UserFactory()
client = APIClient() client = APIClient()
client.force_login(user) client.force_login(user)
@@ -522,7 +522,7 @@ def test_list_waiting_participants_anonymous():
def test_list_waiting_participants_non_owner(): def test_list_waiting_participants_non_owner():
"""Non-privileged users should not be allowed to list waiting participants.""" """Non-privileged users should not be allowed to list waiting participants."""
room = RoomFactory() room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
user = UserFactory() user = UserFactory()
client = APIClient() client = APIClient()
client.force_login(user) client.force_login(user)
@@ -0,0 +1,106 @@
"""Trusted rooms: any authenticated participant present in the meeting can manage the lobby."""
from unittest import mock
import pytest
from rest_framework.test import APIClient
from core.factories import RoomFactory, UserFactory
from core.models import RoomAccessLevel
from core.services.presence import PresenceCache
pytestmark = pytest.mark.django_db
@mock.patch(
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
)
def test_trusted_room_present_user_can_list_waiting(mock_check):
"""Authenticated + present in a trusted room -> 200, LiveKit asked once then cached."""
mock_check.return_value = True
user = UserFactory()
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
client = APIClient()
client.force_login(user)
url = f"/api/v1.0/rooms/{room.id}/waiting-participants/"
assert client.get(url).status_code == 200
assert client.get(url).status_code == 200
assert mock_check.call_count == 1
@mock.patch(
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
)
def test_trusted_room_absent_user_forbidden(mock_check):
"""Authenticated but not connected to the meeting -> 403."""
mock_check.return_value = False
user = UserFactory()
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
client = APIClient()
client.force_login(user)
response = client.get(f"/api/v1.0/rooms/{room.id}/waiting-participants/")
assert response.status_code == 403
@mock.patch(
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
)
def test_restricted_room_present_user_forbidden(mock_check):
"""Presence is not enough on a restricted room; LiveKit must not even be asked."""
user = UserFactory()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
client = APIClient()
client.force_login(user)
response = client.get(f"/api/v1.0/rooms/{room.id}/waiting-participants/")
assert response.status_code == 403
mock_check.assert_not_called()
@mock.patch(
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
)
def test_trusted_room_presence_cleared_after_leave(mock_check):
"""Once the presence cache is cleared (participant_left), LiveKit is re-checked."""
mock_check.return_value = True
user = UserFactory()
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
client = APIClient()
client.force_login(user)
url = f"/api/v1.0/rooms/{room.id}/waiting-participants/"
assert client.get(url).status_code == 200
PresenceCache().clear(room.id, str(user.sub))
mock_check.return_value = False
assert client.get(url).status_code == 403
assert mock_check.call_count == 2
def test_trusted_room_anonymous_forbidden():
"""Anonymous users never manage the lobby."""
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
response = APIClient().get(f"/api/v1.0/rooms/{room.id}/waiting-participants/")
assert response.status_code == 401
@mock.patch(
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
)
def test_trusted_room_present_user_can_accept_entry(mock_check):
"""Authenticated + present in a trusted room can accept a waiting participant."""
mock_check.return_value = True
user = UserFactory()
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/enter/",
{"participant_id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def", "allow_entry": True},
)
# Permission passed; 404 because that participant isn't actually waiting.
assert response.status_code == 404
assert response.json() == {"message": "Participant not found."}
@@ -381,11 +381,38 @@ def test_api_rooms_update_administrators_of_another():
assert other_room.slug == "old-name" assert other_room.slug == "old-name"
@pytest.mark.parametrize("exception", [RoomNotFoundException, RoomManagementException]) @patch.object(RoomManagement, "update_metadata", side_effect=RoomNotFoundException)
@patch.object(RoomManagement, "update_metadata") def test_api_rooms_update_livekit_room_not_found(mock_update_metadata):
def test_api_rooms_update_livekit_sync_failure(mock_update_metadata, exception): """Should not fail the API request when the LiveKit room does not exist yet."""
"""Should not fail the API request when the LiveKit metadata sync fails.""" user = UserFactory()
mock_update_metadata.side_effect = exception room = RoomFactory(
users=[(user, random.choice(["administrator", "owner"]))],
configuration={},
)
client = APIClient()
client.force_login(user)
response = client.patch(
f"/api/v1.0/rooms/{room.id!s}/",
{"configuration": {"can_publish_sources": ["camera"]}},
format="json",
)
assert response.status_code == 200
room.refresh_from_db()
assert room.configuration == {"can_publish_sources": ["camera"]}
mock_update_metadata.assert_called_once_with(
room_name=str(room.id),
metadata={
"access_level": room.access_level,
"configuration": {"can_publish_sources": ["camera"]},
},
)
@patch.object(RoomManagement, "update_metadata", side_effect=RoomManagementException)
def test_api_rooms_update_livekit_sync_failure(mock_update_metadata):
"""Should not fail the API request when the LiveKit metadata sync fails."""
user = UserFactory() user = UserFactory()
room = RoomFactory( room = RoomFactory(
users=[(user, random.choice(["administrator", "owner"]))], users=[(user, random.choice(["administrator", "owner"]))],
@@ -854,3 +854,31 @@ def test_receive_ignores_connection_test_room(
mock_handle_room_started.assert_not_called() mock_handle_room_started.assert_not_called()
mock_handle_room_finished.assert_not_called() mock_handle_room_finished.assert_not_called()
@mock.patch("core.services.presence.cache.delete")
def test_participant_left_clearing_gated_by_setting(mock_delete, service, settings):
"""PRESENCE_CLEAR_ON_PARTICIPANT_LEFT toggles eager presence invalidation."""
data = mock.Mock()
data.room.name = "room-name"
data.participant.identity = "user-sub"
settings.PRESENCE_CLEAR_ON_PARTICIPANT_LEFT = False
service._handle_participant_left(data) # pylint: disable=protected-access
mock_delete.assert_not_called()
settings.PRESENCE_CLEAR_ON_PARTICIPANT_LEFT = True
service._handle_participant_left(data) # pylint: disable=protected-access
mock_delete.assert_called_once()
@mock.patch("core.services.presence.cache.delete")
def test_participant_left_without_identity_is_ignored(mock_delete, service, settings):
"""No cache operation when the webhook carries no identity."""
settings.PRESENCE_CLEAR_ON_PARTICIPANT_LEFT = True
data = mock.Mock()
data.room.name = "room-name"
data.participant.identity = ""
service._handle_participant_left(data) # pylint: disable=protected-access
mock_delete.assert_not_called()
+11 -10
View File
@@ -24,6 +24,7 @@ from core.services.lobby import (
LobbyParticipantStatus, LobbyParticipantStatus,
LobbyService, LobbyService,
) )
from core.services.presence import CACHE_SCAN_ITERSIZE
from core.utils import NotificationError from core.utils import NotificationError
pytestmark = pytest.mark.django_db pytestmark = pytest.mark.django_db
@@ -578,14 +579,14 @@ def test_get_participant_parsing_error(
@mock.patch("core.services.lobby.cache") @mock.patch("core.services.lobby.cache")
def test_list_waiting_participants_empty(mock_cache, lobby_service): def test_list_waiting_participants_empty(mock_cache, lobby_service):
"""Test listing waiting participants when none exist.""" """Test listing waiting participants when none exist."""
mock_cache.keys.return_value = [] mock_cache.iter_keys.return_value = []
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED) room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
result = lobby_service.list_waiting_participants(room.id) result = lobby_service.list_waiting_participants(room.id)
assert result == [] assert result == []
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*" pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
mock_cache.keys.assert_called_once_with(pattern) mock_cache.iter_keys.assert_called_once_with(pattern, itersize=CACHE_SCAN_ITERSIZE)
mock_cache.get_many.assert_not_called() mock_cache.get_many.assert_not_called()
@@ -594,7 +595,7 @@ def test_list_waiting_participants(mock_cache, lobby_service, participant_dict):
"""Test listing waiting participants with valid data.""" """Test listing waiting participants with valid data."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED) room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1" cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
mock_cache.keys.return_value = [cache_key] mock_cache.iter_keys.return_value = [cache_key]
mock_cache.get_many.return_value = {cache_key: participant_dict} mock_cache.get_many.return_value = {cache_key: participant_dict}
result = lobby_service.list_waiting_participants(room.id) result = lobby_service.list_waiting_participants(room.id)
@@ -603,7 +604,7 @@ def test_list_waiting_participants(mock_cache, lobby_service, participant_dict):
assert result[0]["status"] == "waiting" assert result[0]["status"] == "waiting"
assert result[0]["username"] == "test-username" assert result[0]["username"] == "test-username"
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*" pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
mock_cache.keys.assert_called_once_with(pattern) mock_cache.iter_keys.assert_called_once_with(pattern, itersize=CACHE_SCAN_ITERSIZE)
mock_cache.get_many.assert_called_once_with([cache_key]) mock_cache.get_many.assert_called_once_with([cache_key])
@@ -628,7 +629,7 @@ def test_list_waiting_participants_multiple(mock_cache, lobby_service):
"color": "#654321", "color": "#654321",
} }
mock_cache.keys.return_value = [cache_key1, cache_key2] mock_cache.iter_keys.return_value = [cache_key1, cache_key2]
mock_cache.get_many.return_value = { mock_cache.get_many.return_value = {
cache_key1: participant1, cache_key1: participant1,
cache_key2: participant2, cache_key2: participant2,
@@ -646,7 +647,7 @@ def test_list_waiting_participants_multiple(mock_cache, lobby_service):
assert all(p["status"] == "waiting" for p in result) assert all(p["status"] == "waiting" for p in result)
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*" pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
mock_cache.keys.assert_called_once_with(pattern) mock_cache.iter_keys.assert_called_once_with(pattern, itersize=CACHE_SCAN_ITERSIZE)
mock_cache.get_many.assert_called_once_with([cache_key1, cache_key2]) mock_cache.get_many.assert_called_once_with([cache_key1, cache_key2])
@@ -655,7 +656,7 @@ def test_list_waiting_participants_corrupted_data(mock_cache, lobby_service):
"""Test listing waiting participants with corrupted data.""" """Test listing waiting participants with corrupted data."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED) room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1" cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
mock_cache.keys.return_value = [cache_key] mock_cache.iter_keys.return_value = [cache_key]
mock_cache.get_many.return_value = {cache_key: {"invalid": "data"}} mock_cache.get_many.return_value = {cache_key: {"invalid": "data"}}
result = lobby_service.list_waiting_participants(room.id) result = lobby_service.list_waiting_participants(room.id)
@@ -680,7 +681,7 @@ def test_list_waiting_participants_partially_corrupted(mock_cache, lobby_service
corrupted_participant = {"invalid": "data"} corrupted_participant = {"invalid": "data"}
mock_cache.keys.return_value = [cache_key1, cache_key2] mock_cache.iter_keys.return_value = [cache_key1, cache_key2]
mock_cache.get_many.return_value = { mock_cache.get_many.return_value = {
cache_key1: corrupted_participant, cache_key1: corrupted_participant,
cache_key2: valid_participant, cache_key2: valid_participant,
@@ -699,7 +700,7 @@ def test_list_waiting_participants_partially_corrupted(mock_cache, lobby_service
# Verify both cache keys were queried # Verify both cache keys were queried
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*" pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
mock_cache.keys.assert_called_once_with(pattern) mock_cache.iter_keys.assert_called_once_with(pattern, itersize=CACHE_SCAN_ITERSIZE)
mock_cache.get_many.assert_called_once_with([cache_key1, cache_key2]) mock_cache.get_many.assert_called_once_with([cache_key1, cache_key2])
@@ -723,7 +724,7 @@ def test_list_waiting_participants_non_waiting(mock_cache, lobby_service):
"color": "#654321", "color": "#654321",
} }
mock_cache.keys.return_value = [cache_key1, cache_key2] mock_cache.iter_keys.return_value = [cache_key1, cache_key2]
mock_cache.get_many.return_value = { mock_cache.get_many.return_value = {
cache_key1: participant1, cache_key1: participant1,
cache_key2: participant2, cache_key2: participant2,
@@ -0,0 +1,108 @@
"""Tests for the presence cache and the cached presence check."""
# pylint: disable=W0212
from unittest import mock
from uuid import uuid4
from django.core.cache import cache
import pytest
from core.services.participants_management import (
ParticipantNotFoundException,
ParticipantsManagement,
ParticipantsManagementException,
)
from core.services.presence import PresenceCache
@mock.patch(
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
)
def test_presence_cache_hit_skips_livekit(mock_check):
"""A cached positive answer must not call LiveKit."""
room_id, identity = str(uuid4()), "user-sub"
PresenceCache().mark_present(room_id, identity)
assert (
ParticipantsManagement().check_if_in_meeting_cached(room_id, identity) is True
)
mock_check.assert_not_called()
@mock.patch(
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
)
def test_presence_cache_miss_calls_livekit_and_caches_positive(mock_check):
"""On a miss, LiveKit is asked once and a positive answer is memoized."""
mock_check.return_value = True
room_id, identity = str(uuid4()), "user-sub"
service = ParticipantsManagement()
assert service.check_if_in_meeting_cached(room_id, identity) is True
assert service.check_if_in_meeting_cached(room_id, identity) is True
assert mock_check.call_count == 1
@mock.patch(
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
)
def test_presence_negative_not_cached(mock_check):
"""Negative answers are never memoized."""
mock_check.return_value = False
room_id, identity = str(uuid4()), "user-sub"
service = ParticipantsManagement()
assert service.check_if_in_meeting_cached(room_id, identity) is False
assert service.check_if_in_meeting_cached(room_id, identity) is False
assert mock_check.call_count == 2
@mock.patch(
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
)
def test_presence_errors_propagate_and_cache_nothing(mock_check):
"""LiveKit errors propagate to the caller (which fails closed); nothing cached."""
room_id, identity = str(uuid4()), "user-sub"
for exc in (ParticipantNotFoundException(), ParticipantsManagementException()):
mock_check.side_effect = exc
with pytest.raises(type(exc)):
ParticipantsManagement().check_if_in_meeting_cached(room_id, identity)
assert cache.get(PresenceCache._get_cache_key(room_id, identity)) is None
def test_presence_clear_and_clear_room():
"""clear() removes one entry, clear_room() removes all entries of a room."""
room_id, other_room = str(uuid4()), str(uuid4())
presence = PresenceCache()
presence.mark_present(room_id, "a")
presence.mark_present(room_id, "b")
presence.mark_present(other_room, "a")
presence.clear(room_id, "a")
assert presence.is_marked_present(room_id, "a") is False
assert presence.is_marked_present(room_id, "b") is True
presence.clear_room(room_id)
assert presence.is_marked_present(room_id, "b") is False
assert presence.is_marked_present(other_room, "a") is True
def test_presence_clear_room_scans_in_pages():
"""clear_room removes every match, even across several SCAN pages,
and only within the room."""
room_id, other_room = str(uuid4()), str(uuid4())
presence = PresenceCache()
for i in range(7):
presence.mark_present(room_id, f"user-{i}")
presence.mark_present(other_room, "user-0")
# An itersize smaller than the match count forces delete_pattern to
# page through several SCAN cursors rather than finish in one pass.
with mock.patch("core.utils.CACHE_SCAN_ITERSIZE", 3):
presence.clear_room(room_id)
assert all(not presence.is_marked_present(room_id, f"user-{i}") for i in range(7))
assert presence.is_marked_present(other_room, "user-0") is True
@@ -5,13 +5,10 @@ from unittest import mock
import pytest import pytest
from livekit.api import TwirpError from livekit.api import TwirpError
from core.factories import RoomFactory
from core.models import RoomAccessLevel
from core.services.room_management import ( from core.services.room_management import (
RoomManagement, RoomManagement,
RoomManagementException, RoomManagementException,
RoomNotFoundException, RoomNotFoundException,
sync_room_metadata,
) )
@@ -61,22 +58,3 @@ def test_delete_room_raises_management_exception(mock_create_livekit_client):
RoomManagement().delete_room("room-abc") RoomManagement().delete_room("room-abc")
mock_api.aclose.assert_awaited_once() mock_api.aclose.assert_awaited_once()
@mock.patch.object(RoomManagement, "update_metadata")
def test_sync_room_metadata_pushes_configuration_and_access_level(mock_update_metadata):
"""The room's configuration and access level are forwarded to LiveKit."""
room = RoomFactory.build(
access_level=RoomAccessLevel.RESTRICTED,
configuration={"everyone_can_mute": True},
)
sync_room_metadata(room)
mock_update_metadata.assert_called_once_with(
room_name=str(room.id),
metadata={
"configuration": {"everyone_can_mute": True},
"access_level": RoomAccessLevel.RESTRICTED,
},
)
@@ -16,17 +16,8 @@ import responses
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
from rest_framework.test import APIClient from rest_framework.test import APIClient
from core.analytics import AnalyticsEvent
from core.factories import ApplicationFactory, RoomFactory, UserFactory from core.factories import ApplicationFactory, RoomFactory, UserFactory
from core.models import ( from core.models import ApplicationScope, RoleChoices, Room, RoomAccessLevel, User
Application,
ApplicationScope,
RoleChoices,
Room,
RoomAccessLevel,
User,
)
from core.services.room_management import RoomManagement
pytestmark = pytest.mark.django_db pytestmark = pytest.mark.django_db
@@ -889,509 +880,6 @@ def test_api_rooms_create_public_access_level_when_default_is_public(settings):
assert response.data["access_level"] == RoomAccessLevel.PUBLIC assert response.data["access_level"] == RoomAccessLevel.PUBLIC
@mock.patch("core.external_api.viewsets.analytics.capture")
def test_api_rooms_create_tracks_analytics(mock_capture):
"""Creating a room should emit a ROOM_CREATED analytics event."""
user = UserFactory()
token = generate_test_token(user, [ApplicationScope.ROOMS_CREATE])
application = Application.objects.get()
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.post(
"/external-api/v1.0/rooms/",
{"access_level": RoomAccessLevel.RESTRICTED},
format="json",
)
assert response.status_code == 201
mock_capture.assert_called_once()
captured_user, event, properties = mock_capture.call_args[0]
assert captured_user == user
assert event == AnalyticsEvent.ROOM_CREATED
assert properties == {
"room_id": response.data["id"],
"access_level": RoomAccessLevel.RESTRICTED,
"client_id": str(application.client_id),
"external_api": True,
"auth_method": "ApplicationJWTAuthentication",
"$set": {"email": user.email},
}
def test_api_rooms_update_requires_authentication():
"""Updating a room without authentication should return 401."""
room = RoomFactory(users=[(UserFactory(), RoleChoices.OWNER)])
client = APIClient()
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{"access_level": RoomAccessLevel.RESTRICTED},
format="json",
)
assert response.status_code == 401
def test_api_rooms_update_requires_scope():
"""Updating a room requires the ROOMS_UPDATE scope."""
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
# Token without ROOMS_UPDATE scope
token = generate_test_token(user, [ApplicationScope.ROOMS_RETRIEVE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{"access_level": RoomAccessLevel.RESTRICTED},
format="json",
)
assert response.status_code == 403
assert (
"insufficient permissions. required scope: rooms:update"
in str(response.data).lower()
)
def test_api_rooms_update_no_scope():
"""Updating a room without any scope should return 403."""
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
token = generate_test_token(user, [])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{"access_level": RoomAccessLevel.RESTRICTED},
format="json",
)
assert response.status_code == 403
assert "insufficient permissions." in str(response.data).lower()
@mock.patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_owner_success(mock_update_metadata, settings):
"""An owner should be able to update the access level and the configuration."""
settings.APPLICATION_BASE_URL = "http://your-application.com"
user = UserFactory()
room = RoomFactory(
users=[(user, RoleChoices.OWNER)],
access_level=RoomAccessLevel.TRUSTED,
configuration={},
)
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{
"access_level": RoomAccessLevel.RESTRICTED,
"configuration": {"everyone_can_mute": True},
},
format="json",
)
assert response.status_code == 200
assert response.data["id"] == str(room.id)
assert response.data["access_level"] == RoomAccessLevel.RESTRICTED
assert response.data["configuration"] == {"everyone_can_mute": True}
assert response.data["url"] == f"http://your-application.com/{room.slug}"
room.refresh_from_db()
assert room.access_level == RoomAccessLevel.RESTRICTED
assert room.configuration == {"everyone_can_mute": True}
mock_update_metadata.assert_called_once_with(
room_name=str(room.id),
metadata={
"configuration": {"everyone_can_mute": True},
"access_level": RoomAccessLevel.RESTRICTED,
},
)
@mock.patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_replaces_configuration(mock_update_metadata):
"""The configuration is replaced as a whole, it is not merged with the stored one."""
user = UserFactory()
room = RoomFactory(
users=[(user, RoleChoices.OWNER)],
configuration={"can_publish_sources": ["camera"], "everyone_can_mute": True},
)
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{"configuration": {"everyone_can_mute": False}},
format="json",
)
assert response.status_code == 200
# The keys missing from the payload are dropped, not kept.
assert response.data["configuration"] == {"everyone_can_mute": False}
room.refresh_from_db()
assert room.configuration == {"everyone_can_mute": False}
mock_update_metadata.assert_called_once_with(
room_name=str(room.id),
metadata={
"configuration": {"everyone_can_mute": False},
"access_level": room.access_level,
},
)
@mock.patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_administrator_success(mock_update_metadata):
"""An administrator should be able to update a room."""
user = UserFactory()
room = RoomFactory(
users=[(user, RoleChoices.ADMIN)],
access_level=RoomAccessLevel.TRUSTED,
)
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{"access_level": RoomAccessLevel.RESTRICTED},
format="json",
)
assert response.status_code == 200
room.refresh_from_db()
assert room.access_level == RoomAccessLevel.RESTRICTED
mock_update_metadata.assert_called_once()
@mock.patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_put_not_allowed(mock_update_metadata):
"""PUT is not exposed: full replacement is not supported, only PATCH is."""
user = UserFactory()
room = RoomFactory(
users=[(user, RoleChoices.OWNER)],
access_level=RoomAccessLevel.TRUSTED,
configuration={},
)
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.put(
f"/external-api/v1.0/rooms/{room.id}/",
{
"access_level": RoomAccessLevel.RESTRICTED,
"configuration": {"everyone_can_mute": True},
},
format="json",
)
assert response.status_code == 405
room.refresh_from_db()
assert room.access_level == RoomAccessLevel.TRUSTED
assert room.configuration == {}
mock_update_metadata.assert_not_called()
@pytest.mark.parametrize("role", [RoleChoices.MEMBER, None])
@mock.patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_without_privileges(mock_update_metadata, role):
"""Members and users without any role should not be able to update a room."""
user = UserFactory()
users = [(user, role)] if role else []
room = RoomFactory(users=users, access_level=RoomAccessLevel.TRUSTED)
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{"access_level": RoomAccessLevel.RESTRICTED},
format="json",
)
assert response.status_code == 403
room.refresh_from_db()
assert room.access_level == RoomAccessLevel.TRUSTED
mock_update_metadata.assert_not_called()
@mock.patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_readonly_enforcement(mock_update_metadata):
"""Read-only fields provided on update should be ignored, the slug stays immutable."""
user = UserFactory()
room = RoomFactory(
users=[(user, RoleChoices.OWNER)],
access_level=RoomAccessLevel.TRUSTED,
)
expected_id, expected_name = str(room.id), room.name
expected_slug, expected_pin_code = room.slug, room.pin_code
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{
"id": str(uuid.uuid4()),
"name": "fake-name",
"slug": "fake-slug",
"pin_code": "000000",
"access_level": RoomAccessLevel.RESTRICTED,
},
format="json",
)
assert response.status_code == 200
assert response.data["id"] == expected_id
assert response.data["name"] == expected_name
assert response.data["slug"] == expected_slug
room.refresh_from_db()
assert str(room.id) == expected_id
assert room.name == expected_name
assert room.slug == expected_slug
assert room.pin_code == expected_pin_code
# The one writable field in the payload was applied
assert room.access_level == RoomAccessLevel.RESTRICTED
mock_update_metadata.assert_called_once()
@mock.patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_rejects_invalid_configuration(mock_update_metadata):
"""Updating a room with unsupported configuration keys should fail."""
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.OWNER)], configuration={})
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{"configuration": {"unsupported_flag": True}},
format="json",
)
assert response.status_code == 400
assert "extra inputs are not permitted" in str(response.data).lower()
room.refresh_from_db()
assert room.configuration == {}
mock_update_metadata.assert_not_called()
@pytest.mark.parametrize(
"invalid_configuration",
[
{"can_publish_sources": ["invalid-source"]},
{"everyone_can_mute": "invalid-value"},
],
)
@mock.patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_rejects_invalid_configuration_values(
mock_update_metadata, invalid_configuration
):
"""Updating a room with invalid configuration values should fail."""
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.OWNER)], configuration={})
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{"configuration": invalid_configuration},
format="json",
)
assert response.status_code == 400
room.refresh_from_db()
assert room.configuration == {}
mock_update_metadata.assert_not_called()
@mock.patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_public_access_disabled_by_default(mock_update_metadata):
"""Switching a room to public should be disabled for the external API by default."""
user = UserFactory()
room = RoomFactory(
users=[(user, RoleChoices.OWNER)],
access_level=RoomAccessLevel.TRUSTED,
)
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{"access_level": RoomAccessLevel.PUBLIC},
format="json",
)
assert response.status_code == 400
assert "public rooms are disabled" in str(response.data).lower()
room.refresh_from_db()
assert room.access_level == RoomAccessLevel.TRUSTED
mock_update_metadata.assert_not_called()
@mock.patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_public_access_enabled_with_settings(
mock_update_metadata, settings
):
"""Switching a room to public should be allowed when explicitly enabled."""
settings.EXTERNAL_API_ALLOW_PUBLIC_ACCESS = True
user = UserFactory()
room = RoomFactory(
users=[(user, RoleChoices.OWNER)],
access_level=RoomAccessLevel.TRUSTED,
)
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{"access_level": RoomAccessLevel.PUBLIC},
format="json",
)
assert response.status_code == 200
assert response.data["access_level"] == RoomAccessLevel.PUBLIC
room.refresh_from_db()
assert room.access_level == RoomAccessLevel.PUBLIC
mock_update_metadata.assert_called_once()
@mock.patch("core.external_api.viewsets.analytics.capture")
@mock.patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_unchanged_skips_livekit_sync(
mock_update_metadata, mock_capture
):
"""An update that changes nothing should not sync metadata nor report changes."""
user = UserFactory()
room = RoomFactory(
users=[(user, RoleChoices.OWNER)],
access_level=RoomAccessLevel.TRUSTED,
configuration={"everyone_can_mute": True},
)
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{
"access_level": RoomAccessLevel.TRUSTED,
"configuration": {"everyone_can_mute": True},
},
format="json",
)
assert response.status_code == 200
mock_update_metadata.assert_not_called()
# The event is still emitted for auditing, but reports an empty delta.
_, _, properties = mock_capture.call_args[0]
assert properties["updated_fields"] == []
@mock.patch("core.external_api.viewsets.analytics.capture")
@mock.patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_tracks_analytics(mock_update_metadata, mock_capture):
"""Updating a room should emit a ROOM_UPDATED analytics event."""
user = UserFactory()
room = RoomFactory(
users=[(user, RoleChoices.OWNER)],
access_level=RoomAccessLevel.TRUSTED,
configuration={},
)
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
application = Application.objects.get()
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{
"access_level": RoomAccessLevel.RESTRICTED,
"configuration": {"everyone_can_mute": True},
},
format="json",
)
assert response.status_code == 200
mock_capture.assert_called_once()
captured_user, event, properties = mock_capture.call_args[0]
assert captured_user == user
assert event == AnalyticsEvent.ROOM_UPDATED
assert properties == {
"room_id": str(room.pk),
"access_level": RoomAccessLevel.RESTRICTED,
"updated_fields": ["access_level", "configuration"],
"previous_access_level": RoomAccessLevel.TRUSTED,
"client_id": str(application.client_id),
"external_api": True,
"auth_method": "ApplicationJWTAuthentication",
"$set": {"email": user.email},
}
mock_update_metadata.assert_called_once()
def test_api_rooms_response_no_url(settings): def test_api_rooms_response_no_url(settings):
"""Response should not include url field when APPLICATION_BASE_URL is None.""" """Response should not include url field when APPLICATION_BASE_URL is None."""
settings.APPLICATION_BASE_URL = None settings.APPLICATION_BASE_URL = None
@@ -2009,106 +1497,6 @@ def test_resource_server_denies_access_with_insufficient_scopes(settings):
assert response.status_code == 403 assert response.status_code == 403
@responses.activate
@mock.patch.object(RoomManagement, "update_metadata")
def test_resource_server_updates_room_with_prefixed_scope(
mock_update_metadata, settings
):
"""A resource server token carrying the prefixed update scope should be accepted."""
user = UserFactory(sub="very-specific-sub")
room = RoomFactory(
users=[(user, RoleChoices.OWNER)],
access_level=RoomAccessLevel.TRUSTED,
)
settings.OIDC_RS_CLIENT_ID = "some_client_id"
settings.OIDC_RS_CLIENT_SECRET = "some_client_secret"
settings.OIDC_RS_SCOPES_PREFIX = "lasuite_meet"
settings.OIDC_OP_URL = "https://oidc.example.com"
settings.OIDC_VERIFY_SSL = False
settings.OIDC_TIMEOUT = 5
settings.OIDC_PROXY = None
settings.OIDC_OP_JWKS_ENDPOINT = "https://oidc.example.com/jwks"
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
responses.add(
responses.POST,
"https://oidc.example.com/introspect",
json={
"iss": "https://oidc.example.com",
"aud": "some_client_id", # settings.OIDC_RS_CLIENT_ID
"sub": "very-specific-sub",
"client_id": "some_service_provider",
"scope": "openid lasuite_meet lasuite_meet:rooms:update",
"active": True,
},
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION="Bearer some_token")
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{"access_level": RoomAccessLevel.RESTRICTED},
format="json",
)
assert response.status_code == 200
room.refresh_from_db()
assert room.access_level == RoomAccessLevel.RESTRICTED
mock_update_metadata.assert_called_once()
@responses.activate
def test_resource_server_denies_room_update_without_update_scope(settings):
"""A resource server token without the update scope should be denied."""
user = UserFactory(sub="very-specific-sub")
room = RoomFactory(
users=[(user, RoleChoices.OWNER)],
access_level=RoomAccessLevel.TRUSTED,
)
settings.OIDC_RS_CLIENT_ID = "some_client_id"
settings.OIDC_RS_CLIENT_SECRET = "some_client_secret"
settings.OIDC_RS_SCOPES_PREFIX = "lasuite_meet"
settings.OIDC_OP_URL = "https://oidc.example.com"
settings.OIDC_VERIFY_SSL = False
settings.OIDC_TIMEOUT = 5
settings.OIDC_PROXY = None
settings.OIDC_OP_JWKS_ENDPOINT = "https://oidc.example.com/jwks"
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
responses.add(
responses.POST,
"https://oidc.example.com/introspect",
json={
"iss": "https://oidc.example.com",
"aud": "some_client_id", # settings.OIDC_RS_CLIENT_ID
"sub": "very-specific-sub",
"client_id": "some_service_provider",
"scope": "openid lasuite_meet lasuite_meet:rooms:retrieve",
"active": True,
},
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION="Bearer some_token")
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{"access_level": RoomAccessLevel.RESTRICTED},
format="json",
)
assert response.status_code == 403
room.refresh_from_db()
assert room.access_level == RoomAccessLevel.TRUSTED
# ============================== # ==============================
# Addons # Addons
# ============================== # ==============================
@@ -2160,32 +1548,6 @@ def test_api_rooms_create_with_valid_addons_token():
assert room.get_role(user) == RoleChoices.OWNER assert room.get_role(user) == RoleChoices.OWNER
@mock.patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_with_valid_addons_token(mock_update_metadata):
"""Updating a room with a valid addons token should succeed."""
user = UserFactory()
room = RoomFactory(
users=[(user, RoleChoices.OWNER)],
access_level=RoomAccessLevel.TRUSTED,
)
token = generate_addons_test_token(user, [ApplicationScope.ROOMS_UPDATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{"access_level": RoomAccessLevel.RESTRICTED},
format="json",
)
assert response.status_code == 200
room.refresh_from_db()
assert room.access_level == RoomAccessLevel.RESTRICTED
mock_update_metadata.assert_called_once()
def test_api_rooms_addons_token_inactive_user(): def test_api_rooms_addons_token_inactive_user():
"""Addons token for an inactive user should return 401.""" """Addons token for an inactive user should return 401."""
user = UserFactory(is_active=False) user = UserFactory(is_active=False)
+3
View File
@@ -512,3 +512,6 @@ def build_telephony_config():
"default_country": country, "default_country": country,
"international_phone_number": international, "international_phone_number": international,
} }
CACHE_SCAN_ITERSIZE = 500
+9
View File
@@ -856,6 +856,15 @@ class Base(Configuration):
) )
# Lobby configurations # Lobby configurations
PRESENCE_KEY_PREFIX = values.Value(
"room_presence", environ_name="PRESENCE_KEY_PREFIX", environ_prefix=None
)
PRESENCE_CACHE_TIMEOUT = values.PositiveIntegerValue(
3600, environ_name="PRESENCE_CACHE_TIMEOUT", environ_prefix=None
)
PRESENCE_CLEAR_ON_PARTICIPANT_LEFT = values.BooleanValue(
True, environ_name="PRESENCE_CLEAR_ON_PARTICIPANT_LEFT", environ_prefix=None
)
LOBBY_KEY_PREFIX = values.Value( LOBBY_KEY_PREFIX = values.Value(
"room_lobby", environ_name="LOBBY_KEY_PREFIX", environ_prefix=None "room_lobby", environ_name="LOBBY_KEY_PREFIX", environ_prefix=None
) )
+1 -1
View File
@@ -7,7 +7,7 @@ build-backend = "uv_build"
[project] [project]
name = "meet" name = "meet"
version = "1.28.0" version = "1.29.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }] authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [ classifiers = [
"Development Status :: 5 - Production/Stable", "Development Status :: 5 - Production/Stable",
+1 -1
View File
@@ -1187,7 +1187,7 @@ wheels = [
[[package]] [[package]]
name = "meet" name = "meet"
version = "1.28.0" version = "1.29.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "aiohttp" }, { name = "aiohttp" },
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "meet", "name": "meet",
"version": "1.28.0", "version": "1.29.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "meet", "name": "meet",
"version": "1.28.0", "version": "1.29.0",
"dependencies": { "dependencies": {
"@fontsource-variable/atkinson-hyperlegible-next": "5.3.0", "@fontsource-variable/atkinson-hyperlegible-next": "5.3.0",
"@fontsource-variable/lexend": "5.2.11", "@fontsource-variable/lexend": "5.2.11",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "meet", "name": "meet",
"private": true, "private": true,
"version": "1.28.0", "version": "1.29.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "panda codegen && vite", "dev": "panda codegen && vite",
@@ -0,0 +1,119 @@
import { memo } from 'react'
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
import { styled } from '@/styled-system/jsx'
import { cva } from '@/styled-system/css'
import { ParticipantTile } from '@/features/participantTile/components/ParticipantTile'
import { getTrackKey } from '@/features/layout/utils/trackSelection'
type OneToOneFocusLayoutProps = {
mainTrack?: TrackReferenceOrPlaceholder
thumbnailTrack?: TrackReferenceOrPlaceholder
disableTileControls?: boolean
/** Controls thumbnail dimensions 'pip' for small PiP window, 'room' for the main viewport. */
context?: 'pip' | 'room'
}
/**
* Focus layout for 1-to-1 calls: one main tile filling the area (letterboxed)
* with an optional thumbnail overlay at the bottom-right.
*
* Shared between PiP and the main room pass `disableTileControls` in PiP
* where hover controls should be hidden.
*/
export const OneToOneFocusLayout = memo(
({
mainTrack,
thumbnailTrack,
disableTileControls,
context = 'room',
}: OneToOneFocusLayoutProps) => {
return (
<FocusContainer>
{mainTrack && (
<MainSlot>
<ParticipantTile
key={getTrackKey(mainTrack)}
trackRef={mainTrack}
disableTileControls={disableTileControls}
/>
</MainSlot>
)}
{thumbnailTrack && (
<Thumbnail context={context}>
<ParticipantTile
key={getTrackKey(thumbnailTrack)}
trackRef={thumbnailTrack}
disableTileControls={disableTileControls}
/>
</Thumbnail>
)}
</FocusContainer>
)
}
)
OneToOneFocusLayout.displayName = 'OneToOneFocusLayout'
const FocusContainer = styled('div', {
base: {
position: 'relative',
width: '100%',
height: '100%',
borderRadius: '8px',
overflow: 'hidden',
backgroundColor: 'primaryDark.100',
boxSizing: 'border-box',
},
})
const MainSlot = styled('div', {
base: {
width: '100%',
height: '100%',
borderRadius: '8px',
overflow: 'hidden',
'& .lk-participant-tile': {
width: '100%',
height: '100%',
},
'& .lk-participant-media-video': {
objectFit: 'contain',
},
},
})
const Thumbnail = styled(
'div',
cva({
base: {
position: 'absolute',
right: '1.25rem',
bottom: '1.25rem',
aspectRatio: '16 / 9',
borderRadius: '8px',
overflow: 'hidden',
boxShadow: 'md',
zIndex: 2,
'& .lk-participant-tile': {
width: '100%',
height: '100%',
},
},
variants: {
context: {
pip: {
width: '42%',
maxWidth: '220px',
minWidth: '140px',
},
room: {
width: '20%',
maxWidth: '320px',
minWidth: '180px',
},
},
},
defaultVariants: {
context: 'room',
},
})
)
@@ -12,7 +12,8 @@ import {
import { Track } from 'livekit-client' import { Track } from 'livekit-client'
import { useSnapshot } from 'valtio' import { useSnapshot } from 'valtio'
import { clearPinnedTrack, layoutStore, setPinnedTrack } from '@/stores/layout' import { clearPinnedTrack, layoutStore, setPinnedTrack } from '@/stores/layout'
import { useEffect, useRef } from 'react' import { useEffect, useMemo, useRef } from 'react'
import { OneToOneFocusLayout } from '@/features/layout/components/OneToOneFocusLayout'
export const StageLayout = () => { export const StageLayout = () => {
const lastAutoFocusedScreenShareTrack = const lastAutoFocusedScreenShareTrack =
@@ -36,6 +37,29 @@ export const StageLayout = () => {
(track) => !isEqualTrackRef(track, pinnedTrackRef) (track) => !isEqualTrackRef(track, pinnedTrackRef)
) )
const cameraTracks = useMemo(
() => tracks.filter((t) => t.source === Track.Source.Camera),
[tracks]
)
const isOneToOne =
!pinnedTrackRef &&
screenShareTracks.length === 0 &&
cameraTracks.length <= 2
const oneToOneMainTrack = useMemo(() => {
if (!isOneToOne) return undefined
const remote = cameraTracks.find((t) => !t.participant?.isLocal)
const local = cameraTracks.find((t) => t.participant?.isLocal)
return remote ?? local
}, [isOneToOne, cameraTracks])
const oneToOneThumbnailTrack = useMemo(() => {
if (!isOneToOne) return undefined
const local = cameraTracks.find((t) => t.participant?.isLocal)
return oneToOneMainTrack === local ? undefined : local
}, [isOneToOne, cameraTracks, oneToOneMainTrack])
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
// Code duplicated from LiveKit; this warning will be addressed in the refactoring. // Code duplicated from LiveKit; this warning will be addressed in the refactoring.
useEffect(() => { useEffect(() => {
@@ -87,7 +111,12 @@ export const StageLayout = () => {
return ( return (
<> <>
{!pinnedTrackRef ? ( {isOneToOne ? (
<OneToOneFocusLayout
mainTrack={oneToOneMainTrack}
thumbnailTrack={oneToOneThumbnailTrack}
/>
) : !pinnedTrackRef ? (
<div className="lk-grid-layout-wrapper" style={{ height: 'auto' }}> <div className="lk-grid-layout-wrapper" style={{ height: 'auto' }}>
<GridLayout tracks={tracks} style={{ padding: 0 }}> <GridLayout tracks={tracks} style={{ padding: 0 }}>
<ParticipantTile /> <ParticipantTile />
@@ -1,61 +1,31 @@
import { useCallback, useEffect, useMemo, useState } from 'react' import { useMemo } from 'react'
import { useRoomContext } from '@livekit/components-react'
import { RoomEvent } from 'livekit-client'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData' import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner' import { useCanManageLobby } from '@/features/rooms/livekit/hooks/useCanManageLobby'
import { useEnterRoom } from '../api/enterRoom' import { useEnterRoom } from '../api/enterRoom'
import { import {
useListWaitingParticipants, useListWaitingParticipants,
type WaitingParticipant, type WaitingParticipant,
} from '../../participants/api/listWaitingParticipants' } from '../../participants/api/listWaitingParticipants'
import { decodeNotificationDataReceived } from '@/features/notifications/utils'
import { NotificationType } from '@/features/notifications/NotificationType'
import { reportError } from '@/features/analytics/telemetry' import { reportError } from '@/features/analytics/telemetry'
export const POLL_INTERVAL_MS = 1000
export const useWaitingParticipants = () => { export const useWaitingParticipants = () => {
const [listEnabled, setListEnabled] = useState(true)
const roomData = useRoomData() const roomData = useRoomData()
const roomId = roomData?.id || '' // FIXME - bad practice const roomId = roomData?.id || '' // FIXME - bad practice
const room = useRoomContext() const canManageLobby = useCanManageLobby()
const isAdminOrOwner = useIsAdminOrOwner()
const handleDataReceived = useCallback((payload: Uint8Array) => {
const notification = decodeNotificationDataReceived(payload)
if (notification?.type === NotificationType.ParticipantWaiting) {
setListEnabled(true)
}
}, [])
useEffect(() => {
if (isAdminOrOwner) {
room.on(RoomEvent.DataReceived, handleDataReceived)
}
return () => {
room.off(RoomEvent.DataReceived, handleDataReceived)
}
}, [isAdminOrOwner, room, handleDataReceived])
const { data: waitingData, refetch: refetchWaiting } = const { data: waitingData, refetch: refetchWaiting } =
useListWaitingParticipants(roomId, { useListWaitingParticipants(roomId, {
retry: false, retry: false,
enabled: listEnabled && isAdminOrOwner, enabled: false,
refetchInterval: POLL_INTERVAL_MS,
refetchIntervalInBackground: true,
}) })
const waitingParticipants = useMemo( const waitingParticipants = useMemo(
() => waitingData?.participants || [], () => (canManageLobby ? waitingData?.participants || [] : []),
[waitingData] [waitingData, canManageLobby]
) )
useEffect(() => {
if (!waitingParticipants.length) setListEnabled(false)
}, [waitingParticipants])
const { mutateAsync: enterRoom } = useEnterRoom() const { mutateAsync: enterRoom } = useEnterRoom()
const handleParticipantEntry = async ( const handleParticipantEntry = async (
@@ -74,8 +44,6 @@ export const useWaitingParticipants = () => {
allowEntry: boolean allowEntry: boolean
): Promise<void> => { ): Promise<void> => {
try { try {
setListEnabled(false)
await Promise.all( await Promise.all(
waitingParticipants.map((participant) => waitingParticipants.map((participant) =>
enterRoom({ enterRoom({
@@ -89,7 +57,6 @@ export const useWaitingParticipants = () => {
await refetchWaiting() await refetchWaiting()
} catch (e) { } catch (e) {
reportError('generic_failure', e) reportError('generic_failure', e)
setListEnabled(true)
} }
} }
@@ -1,86 +0,0 @@
import { memo } from 'react'
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
import { styled } from '@/styled-system/jsx'
import { ParticipantTile } from '@/features/participantTile/components/ParticipantTile'
import { getTrackKey } from '../../utils/pipTrackSelection'
type PipFocusLayoutProps = {
mainTrack?: TrackReferenceOrPlaceholder
thumbnailTrack?: TrackReferenceOrPlaceholder
}
/**
* Focus layout used when 1-2 tracks are visible in the PiP window.
*
* The main tile is letterboxed (object-fit: contain) so the camera is
* never stretched to a non-video aspect and leaves dark padding
* above/below when the window shape doesn't match the source.
* The thumbnail keeps the usual cover fill.
*/
export const PipFocusLayout = memo(
({ mainTrack, thumbnailTrack }: PipFocusLayoutProps) => {
return (
<FocusContainer>
{mainTrack && (
<MainSlot>
<ParticipantTile
key={getTrackKey(mainTrack)}
trackRef={mainTrack}
disableTileControls
/>
</MainSlot>
)}
{thumbnailTrack && (
<Thumbnail>
<ParticipantTile
key={getTrackKey(thumbnailTrack)}
trackRef={thumbnailTrack}
disableTileControls
/>
</Thumbnail>
)}
</FocusContainer>
)
}
)
PipFocusLayout.displayName = 'PipFocusLayout'
const FocusContainer = styled('div', {
base: {
position: 'relative',
width: '100%',
height: '100%',
borderRadius: '8px',
overflow: 'hidden',
backgroundColor: 'primaryDark.100',
boxSizing: 'border-box',
},
})
const MainSlot = styled('div', {
base: {
width: '100%',
height: '100%',
borderRadius: '8px',
overflow: 'hidden',
'& .lk-participant-media-video': {
objectFit: 'contain',
},
},
})
const Thumbnail = styled('div', {
base: {
position: 'absolute',
right: '1.25rem',
bottom: '1.25rem',
width: '42%',
maxWidth: '220px',
minWidth: '140px',
aspectRatio: '16 / 9',
borderRadius: '8px',
overflow: 'hidden',
boxShadow: 'md',
zIndex: 2,
},
})
@@ -4,7 +4,7 @@ import { styled } from '@/styled-system/jsx'
import { ParticipantTile } from '@/features/participantTile/components/ParticipantTile' import { ParticipantTile } from '@/features/participantTile/components/ParticipantTile'
import { usePipElementSize } from '../../hooks/usePipElementSize' import { usePipElementSize } from '../../hooks/usePipElementSize'
import { computePipGridLayout } from '../../utils/pipGrid' import { computePipGridLayout } from '../../utils/pipGrid'
import { getTrackKey } from '../../utils/pipTrackSelection' import { getTrackKey } from '@/features/layout/utils/trackSelection'
type PipGridLayoutProps = { type PipGridLayoutProps = {
tracks: TrackReferenceOrPlaceholder[] tracks: TrackReferenceOrPlaceholder[]
@@ -2,7 +2,7 @@ import { memo } from 'react'
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core' import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
import { styled } from '@/styled-system/jsx' import { styled } from '@/styled-system/jsx'
import { ParticipantTile } from '@/features/participantTile/components/ParticipantTile' import { ParticipantTile } from '@/features/participantTile/components/ParticipantTile'
import { getTrackKey } from '../../utils/pipTrackSelection' import { getTrackKey } from '@/features/layout/utils/trackSelection'
type PipScreenShareLayoutProps = { type PipScreenShareLayoutProps = {
screenShareTrack: TrackReferenceOrPlaceholder screenShareTrack: TrackReferenceOrPlaceholder
@@ -2,7 +2,7 @@ import React, { useMemo } from 'react'
import { usePagination, useTracks } from '@livekit/components-react' import { usePagination, useTracks } from '@livekit/components-react'
import { RoomEvent, Track } from 'livekit-client' import { RoomEvent, Track } from 'livekit-client'
import { styled } from '@/styled-system/jsx' import { styled } from '@/styled-system/jsx'
import { PipFocusLayout } from './PipFocusLayout' import { OneToOneFocusLayout } from '@/features/layout/components/OneToOneFocusLayout'
import { PipGridLayout } from './PipGridLayout' import { PipGridLayout } from './PipGridLayout'
import { PipPagination } from './PipPagination' import { PipPagination } from './PipPagination'
import { PipScreenShareLayout } from './PipScreenShareLayout' import { PipScreenShareLayout } from './PipScreenShareLayout'
@@ -59,9 +59,11 @@ export const PipStage = () => {
if (cameraTracks.length <= 1) { if (cameraTracks.length <= 1) {
return ( return (
<StageFrame> <StageFrame>
<PipFocusLayout <OneToOneFocusLayout
mainTrack={screenShareTrack} mainTrack={screenShareTrack}
thumbnailTrack={cameraTracks[0]} thumbnailTrack={cameraTracks[0]}
disableTileControls
context="pip"
/> />
</StageFrame> </StageFrame>
) )
@@ -99,7 +101,12 @@ export const PipStage = () => {
return ( return (
<StageFrame> <StageFrame>
<PipFocusLayout mainTrack={mainTrack} thumbnailTrack={thumbnailTrack} /> <OneToOneFocusLayout
mainTrack={mainTrack}
thumbnailTrack={thumbnailTrack}
disableTileControls
context="pip"
/>
</StageFrame> </StageFrame>
) )
} }
@@ -43,6 +43,7 @@ import { useSnapshot } from 'valtio'
import { userPreferencesStore } from '@/stores/userPreferences' import { userPreferencesStore } from '@/stores/userPreferences'
import { userStore } from '@/stores/user' import { userStore } from '@/stores/user'
import { WatchMediaDeviceErrors } from './WatchMediaDeviceErrors' import { WatchMediaDeviceErrors } from './WatchMediaDeviceErrors'
import { VOICE_AUDIO_CONSTRAINTS } from '@/features/rooms/livekit/utils/constants'
export const Conference = ({ export const Conference = ({
roomId, roomId,
@@ -115,6 +116,7 @@ export const Conference = ({
}, },
audioCaptureDefaults: { audioCaptureDefaults: {
deviceId: userConfig.audioDeviceId ?? undefined, deviceId: userConfig.audioDeviceId ?? undefined,
...VOICE_AUDIO_CONSTRAINTS,
}, },
audioOutput: { audioOutput: {
deviceId: userConfig.audioOutputDeviceId ?? undefined, deviceId: userConfig.audioOutputDeviceId ?? undefined,
@@ -0,0 +1,114 @@
import { useCallback, useEffect } from 'react'
import { useConnectionState, useRoomContext } from '@livekit/components-react'
import { ConnectionState, RoomEvent } from 'livekit-client'
import { useCanManageLobby } from '@/features/rooms/livekit/hooks/useCanManageLobby'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { useListWaitingParticipants } from '@/features/participants/api/listWaitingParticipants'
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
import { decodeNotificationDataReceived } from '@/features/notifications/utils'
import { NotificationType } from '@/features/notifications'
import { usePrevious } from '@/hooks/usePrevious'
import { keys } from '@/api/queryKeys'
import { queryClient } from '@/api/queryClient'
import { ApiError } from '@/api/ApiError'
export const POLL_INTERVAL_MS = 1000
export const LAZY_POLL_INTERVAL_MS = 10_000
export const LobbyProvider = () => {
const room = useRoomContext()
const canManageLobby = useCanManageLobby()
const roomData = useRoomData()
const { isParticipantsOpen } = useSidePanel()
const isConnected = useConnectionState(room) === ConnectionState.Connected
const roomId = roomData?.id || '' // FIXME - bad practice
const { error: waitingError, refetch: refetchWaiting } =
useListWaitingParticipants(roomId, {
retry: false,
enabled: canManageLobby && isConnected && !!roomId,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchInterval: (query) => {
if (!query.state.data?.participants?.length) return false
if (isParticipantsOpen) return POLL_INTERVAL_MS
return LAZY_POLL_INTERVAL_MS
},
refetchIntervalInBackground: true,
})
// Triggers: each one-shot, idempotent, deduped by React Query if
// concurrent. The interval takes over whenever a fetch finds waiters.
const fetchIfManager = useCallback(() => {
if (canManageLobby) refetchWaiting()
}, [canManageLobby, refetchWaiting])
// 1. Connection established (join or reconnect)
useEffect(() => {
room.on(RoomEvent.Connected, fetchIfManager)
room.on(RoomEvent.Reconnected, fetchIfManager)
return () => {
room.off(RoomEvent.Connected, fetchIfManager)
room.off(RoomEvent.Reconnected, fetchIfManager)
}
}, [room, fetchIfManager])
// 2. Someone started waiting (LiveKit broadcast).
const handleDataReceived = useCallback(
(payload: Uint8Array) => {
const notification = decodeNotificationDataReceived(payload)
if (notification?.type === NotificationType.ParticipantWaiting) {
fetchIfManager()
}
},
[fetchIfManager]
)
useEffect(() => {
if (canManageLobby) {
room.on(RoomEvent.DataReceived, handleDataReceived)
}
return () => {
room.off(RoomEvent.DataReceived, handleDataReceived)
}
}, [canManageLobby, room, handleDataReceived])
// 3. Rights regained.
const prevCanManageLobby = usePrevious(canManageLobby)
useEffect(() => {
if (!prevCanManageLobby && canManageLobby && isConnected) {
fetchIfManager()
}
}, [
prevCanManageLobby,
canManageLobby,
isParticipantsOpen,
fetchIfManager,
isConnected,
])
const clearWaitingList = useCallback(() => {
const queryKey = [keys.waitingParticipants, roomId]
queryClient.cancelQueries({ queryKey })
queryClient.setQueryData(queryKey, { participants: [] })
}, [roomId])
// Rights lost mid-meeting (covers trusted -> restricted/public).
useEffect(() => {
if (prevCanManageLobby && !canManageLobby) clearWaitingList()
}, [prevCanManageLobby, canManageLobby, clearWaitingList])
useEffect(() => {
if (
waitingError instanceof ApiError &&
[401, 403].includes(waitingError.statusCode)
) {
clearWaitingList()
}
}, [waitingError, clearWaitingList])
return null
}
@@ -0,0 +1,17 @@
import { useUser } from '@/features/auth/api/useUser'
import { ApiAccessLevel } from '@/features/rooms/api/ApiRoom'
import { useIsAdminOrOwner } from './useIsAdminOrOwner'
import { useRoomData } from './useRoomData'
export const useCanManageLobby = () => {
const isAdminOrOwner = useIsAdminOrOwner()
const { isLoggedIn } = useUser()
const roomData = useRoomData()
return (
(isAdminOrOwner ||
(isLoggedIn === true &&
roomData?.access_level === ApiAccessLevel.TRUSTED)) &&
roomData?.access_level !== ApiAccessLevel.PUBLIC
)
}
@@ -18,6 +18,7 @@ import {
noteDeviceReady, noteDeviceReady,
onMediaPermissionError, onMediaPermissionError,
} from '../utils/mediaPermissions' } from '../utils/mediaPermissions'
import { VOICE_AUDIO_CONSTRAINTS } from '../utils/constants'
import { import {
saveAudioInputDeviceId, saveAudioInputDeviceId,
saveAudioInputEnabled, saveAudioInputEnabled,
@@ -27,16 +28,6 @@ import {
} from '@/stores/userChoices' } from '@/stores/userChoices'
import { useSyncTrackDeviceId } from './useSyncTrackDeviceId' import { useSyncTrackDeviceId } from './useSyncTrackDeviceId'
const VOICE_AUDIO_CONSTRAINTS = {
noiseSuppression: true,
echoCancellation: true,
autoGainControl: true,
voiceIsolation: false,
sampleRate: 48000,
channelCount: 1,
sampleSize: 16,
} as const
// Module-level: effect dependencies, must be referentially stable. // Module-level: effect dependencies, must be referentially stable.
const disableAudio = () => saveAudioInputEnabled(false) const disableAudio = () => saveAudioInputEnabled(false)
const disableVideo = () => saveVideoInputEnabled(false) const disableVideo = () => saveVideoInputEnabled(false)
@@ -31,6 +31,7 @@ import { PinAnnouncer } from '@/features/layout/components/PinAnnouncer'
import { ChatProvider } from '@/features/chat/components/ChatProvider' import { ChatProvider } from '@/features/chat/components/ChatProvider'
import { SyncDevicePreferences } from '@/features/rooms/livekit/components/SyncDevicePreferences' import { SyncDevicePreferences } from '@/features/rooms/livekit/components/SyncDevicePreferences'
import { RoomSilentMicDetector } from '@/features/rooms/components/SilentMicDetector' import { RoomSilentMicDetector } from '@/features/rooms/components/SilentMicDetector'
import { LobbyProvider } from '@/features/rooms/components/LobbyProvider'
/** /**
* @public * @public
@@ -120,6 +121,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
<RoomSilentMicDetector /> <RoomSilentMicDetector />
<MediaStateObserver /> <MediaStateObserver />
<ChatProvider /> <ChatProvider />
<LobbyProvider />
<VideoResolutionSubscription /> <VideoResolutionSubscription />
<div <div
className="lk-video-conference" className="lk-video-conference"
@@ -0,0 +1,9 @@
export const VOICE_AUDIO_CONSTRAINTS = {
noiseSuppression: true,
echoCancellation: true,
autoGainControl: true,
voiceIsolation: false,
sampleRate: 48000,
channelCount: 1,
sampleSize: 16,
} as const
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "mail_mjml", "name": "mail_mjml",
"version": "1.28.0", "version": "1.29.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "mail_mjml", "name": "mail_mjml",
"version": "1.28.0", "version": "1.29.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@html-to/text-cli": "0.6.1", "@html-to/text-cli": "0.6.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "mail_mjml", "name": "mail_mjml",
"version": "1.28.0", "version": "1.29.0",
"description": "An util to generate html and text django's templates from mjml templates", "description": "An util to generate html and text django's templates from mjml templates",
"type": "module", "type": "module",
"dependencies": { "dependencies": {
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "sdk", "name": "sdk",
"version": "1.28.0", "version": "1.29.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "sdk", "name": "sdk",
"version": "1.28.0", "version": "1.29.0",
"license": "ISC", "license": "ISC",
"workspaces": [ "workspaces": [
"./library", "./library",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "sdk", "name": "sdk",
"version": "1.28.0", "version": "1.29.0",
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"description": "", "description": "",
+1 -1
View File
@@ -1,7 +1,7 @@
[project] [project]
name = "summary" name = "summary"
version = "1.28.0" version = "1.29.0"
dependencies = [ dependencies = [
"fastapi[standard]>=0.105.0", "fastapi[standard]>=0.105.0",
"uvicorn>=0.24.0", "uvicorn>=0.24.0",