mirror of
https://github.com/suitenumerique/meet.git
synced 2026-09-02 21:58:29 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f1e0bd7ac5 | |||
| 35d2bae0f0 |
+5
-8
@@ -10,16 +10,14 @@ and this project adheres to
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- ✨(frontend) add 1080p sending resolution option #1660
|
- ✨(backend) add per-recording encoding quality presets to start-recording API
|
||||||
- ✨(backend) add Traefik support via configurable media-auth url header #1649
|
|
||||||
- ✨(backend) update a room's attributes from the external API
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- 🐛(frontend) keep the sending resolution picked while the camera is off #1667
|
|
||||||
|
|
||||||
## [1.30.0] - 2026-09-01
|
## [1.30.0] - 2026-09-01
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- ♿️(frontend) close side panel with Escape key #1507
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- ✨(agent) support Voxtral realtime as inference engine
|
- ✨(agent) support Voxtral realtime as inference engine
|
||||||
@@ -35,7 +33,6 @@ and this project adheres to
|
|||||||
- ⬆️(frontend) upgrade @fontsource/opendyslexic from 5.2.5 to 5.3.0
|
- ⬆️(frontend) upgrade @fontsource/opendyslexic from 5.2.5 to 5.3.0
|
||||||
- ⬆️(addons) upgrade core-js from 3.49.0 to 3.50.0
|
- ⬆️(addons) upgrade core-js from 3.49.0 to 3.50.0
|
||||||
- ♻️(backend) factorize s3 client creation in utils
|
- ♻️(backend) factorize s3 client creation in utils
|
||||||
- ♿️(frontend) close side panel with Escape key #1507
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
|||||||
+86
@@ -16,6 +16,92 @@ the following command inside your docker container:
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Recording encoding settings replaced by a resolution/profile model
|
||||||
|
|
||||||
|
The `RECORDING_ENCODING_*` settings introduced in v1.16.0 exposed raw encoder
|
||||||
|
values (width, height, framerate, bitrate). They are replaced by two named and configurable sets of
|
||||||
|
dimensions, a **resolution** (default: `540p`, `720p`, `1080p`) and a **profile**
|
||||||
|
(default: `talking_heads`, `text`, `mixed`, `full`), which are resolved to the width, height,
|
||||||
|
fps and video bitrate.
|
||||||
|
|
||||||
|
**The following environment variables are no longer read. If they are still set in
|
||||||
|
your deployment they are silently ignored, and your recordings will be encoded with
|
||||||
|
the new defaults instead of your tuned values.**
|
||||||
|
|
||||||
|
| Removed variable | Replaced by |
|
||||||
|
| --------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `RECORDING_ENCODING_ENABLED` | Nothing. A default encoding is now always built (see below). **Not** `RECORDING_CUSTOM_ENCODING_ENABLED`, which gates a different feature. |
|
||||||
|
| `RECORDING_ENCODING_WIDTH` | The `width` of the entry selected by `RECORDING_ENCODING_DEFAULT_RESOLUTION` in `RECORDING_ENCODING_AVAILABLE_RESOLUTIONS`. |
|
||||||
|
| `RECORDING_ENCODING_HEIGHT` | The `height` of that same entry. |
|
||||||
|
| `RECORDING_ENCODING_FRAMERATE` | The `fps` of the profile selected by `RECORDING_ENCODING_DEFAULT_PROFILE` in `RECORDING_ENCODING_AVAILABLE_PROFILES`. |
|
||||||
|
| `RECORDING_ENCODING_VIDEO_BITRATE_KBPS` | That profile's `kbps`. |
|
||||||
|
|
||||||
|
`RECORDING_ENCODING_AUDIO_BITRATE_KBPS` and `RECORDING_ENCODING_KEY_FRAME_INTERVAL_S`
|
||||||
|
are unchanged and keep their values.
|
||||||
|
|
||||||
|
#### If you never set `RECORDING_ENCODING_ENABLED=True`
|
||||||
|
|
||||||
|
No action is required. The shipped defaults (`RECORDING_ENCODING_DEFAULT_PROFILE=full`,
|
||||||
|
`RECORDING_ENCODING_DEFAULT_RESOLUTION=720p`) match LiveKit's built-in
|
||||||
|
`H264_720P_30` preset: 1280×720, 30 fps, 3000 kbps H.264 MAIN, 128 kbps AAC.
|
||||||
|
|
||||||
|
Note that these values are now sent explicitly as advanced `EncodingOptions`
|
||||||
|
rather than relying on LiveKit's preset, so `RECORDING_ENCODING_AUDIO_BITRATE_KBPS`
|
||||||
|
and `RECORDING_ENCODING_KEY_FRAME_INTERVAL_S` now apply to every recording. They
|
||||||
|
previously applied only when `RECORDING_ENCODING_ENABLED` was `True`.
|
||||||
|
|
||||||
|
To keep letting LiveKit pick the encoding instead, set either default to an empty
|
||||||
|
value:
|
||||||
|
|
||||||
|
```
|
||||||
|
RECORDING_ENCODING_DEFAULT_RESOLUTION=
|
||||||
|
RECORDING_ENCODING_DEFAULT_PROFILE=
|
||||||
|
```
|
||||||
|
|
||||||
|
#### If you had tuned `RECORDING_ENCODING_*` values
|
||||||
|
|
||||||
|
Translate your old values into a default resolution and a default profile. Declare your own resolution and/or profile. Both maps are read from the
|
||||||
|
environment as a single-line Python/JSON dict literal (parsed with
|
||||||
|
`ast.literal_eval`, so use double-quoted keys and no trailing commas, and do not
|
||||||
|
add outer quotes in `.env`-style files):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
RECORDING_ENCODING_AVAILABLE_RESOLUTIONS={"540p": {"width": 960, "height": 540}, "720p": {"width": 1280, "height": 720}, "1080p": {"width": 1920, "height": 1080}}
|
||||||
|
RECORDING_ENCODING_AVAILABLE_PROFILES={"my_old_profile": {"fps": 15, "kbps": {"540p": 350, "720p": 600, "1080p": 1100}}}
|
||||||
|
RECORDING_ENCODING_DEFAULT_RESOLUTION=720p
|
||||||
|
RECORDING_ENCODING_DEFAULT_PROFILE=my_old_profile
|
||||||
|
```
|
||||||
|
|
||||||
|
Two constraints are validated at startup and may raise a `ValueError`:
|
||||||
|
|
||||||
|
- every profile in `RECORDING_ENCODING_AVAILABLE_PROFILES` must define a `kbps`
|
||||||
|
entry for **exactly** the keys of `RECORDING_ENCODING_AVAILABLE_RESOLUTIONS`;
|
||||||
|
overriding one of the two maps usually means overriding both;
|
||||||
|
- `RECORDING_ENCODING_DEFAULT_RESOLUTION` and `RECORDING_ENCODING_DEFAULT_PROFILE`,
|
||||||
|
when non-empty, must be keys of their respective map.
|
||||||
|
|
||||||
|
#### Optional: per-recording encoding
|
||||||
|
|
||||||
|
`RECORDING_CUSTOM_ENCODING_ENABLED` (default `False`) toggles whether the
|
||||||
|
start-recording API accepts an `encoding` object
|
||||||
|
(`{"resolution": "720p", "profile": "talking_heads"}`, `profile` optional) that
|
||||||
|
overrides the default for a single recording. It does not enable or disable the
|
||||||
|
default encoding, which is built from the two `RECORDING_ENCODING_DEFAULT_*`
|
||||||
|
settings either way. Leaving it at `False` preserves the previous behaviour, where
|
||||||
|
every recording uses the server-side encoding: requests carrying
|
||||||
|
`options.encoding` are rejected with a `400` before the recording is created, so
|
||||||
|
nothing is persisted and no egress is started.
|
||||||
|
|
||||||
|
Before enabling it:
|
||||||
|
|
||||||
|
- clients can only pick keys you declared; there is no way to send a raw width or bitrate
|
||||||
|
- as of this implementation, the frontend never sends `encoding`
|
||||||
|
- `encoding` is accepted but ignored for `transcript` recordings, whose audio-only
|
||||||
|
egress has no video encoding to configure.
|
||||||
|
|
||||||
|
See [docs/features/recording.md](docs/features/recording.md#tuning-recording-encoding)
|
||||||
|
for the full setting reference, the shipped profile table and the tuning caveats.
|
||||||
|
|
||||||
## v1.30.0
|
## v1.30.0
|
||||||
|
|
||||||
### Removing S3 storage-event webhooks for recordings
|
### Removing S3 storage-event webhooks for recordings
|
||||||
|
|||||||
@@ -213,8 +213,6 @@ services:
|
|||||||
- "7880:7880"
|
- "7880:7880"
|
||||||
- "7881:7881"
|
- "7881:7881"
|
||||||
- "7882:7882/udp"
|
- "7882:7882/udp"
|
||||||
- "3478:3478/udp"
|
|
||||||
- "30000-30100:30000-30100/udp"
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./docker/livekit/config/livekit-server.yaml:/config.yaml
|
- ./docker/livekit/config/livekit-server.yaml:/config.yaml
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -65,7 +65,6 @@ RUN apk update && apk upgrade \
|
|||||||
musl \
|
musl \
|
||||||
musl-utils \
|
musl-utils \
|
||||||
zlib>=1.3.2-r0 \
|
zlib>=1.3.2-r0 \
|
||||||
libexpat>=2.8.4-r0 \
|
|
||||||
&& apk del curl
|
&& apk del curl
|
||||||
|
|
||||||
USER nginx
|
USER nginx
|
||||||
|
|||||||
@@ -8,16 +8,3 @@ webhook:
|
|||||||
api_key: devkey
|
api_key: devkey
|
||||||
urls:
|
urls:
|
||||||
- http://app-dev:8000/api/v1.0/rooms/webhooks-livekit/
|
- http://app-dev:8000/api/v1.0/rooms/webhooks-livekit/
|
||||||
|
|
||||||
turn:
|
|
||||||
enabled: true
|
|
||||||
domain: turn.127.0.0.1.nip.io
|
|
||||||
udp_port: 3478
|
|
||||||
tls_port: 0
|
|
||||||
external_tls: false
|
|
||||||
relay_range_start: 30000
|
|
||||||
relay_range_end: 30100
|
|
||||||
allow_restricted_peer_cidrs:
|
|
||||||
- 192.168.0.0/16
|
|
||||||
- 172.16.0.0/12
|
|
||||||
|
|
||||||
|
|||||||
+1
-76
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -69,17 +69,31 @@ SUMMARY_SERVICE_WEBHOOK_API_TOKEN=webhook-password
|
|||||||
RECORDING_DOWNLOAD_BASE_URL=http://localhost:3000/recording
|
RECORDING_DOWNLOAD_BASE_URL=http://localhost:3000/recording
|
||||||
|
|
||||||
# Recording encoding (LiveKit Egress advanced options).
|
# Recording encoding (LiveKit Egress advanced options).
|
||||||
# When RECORDING_ENCODING_ENABLED is False (default), LiveKit uses its built-in
|
# Encoding is described by a named resolution (width/height) and a named profile
|
||||||
# H264_720P_30 preset (1280x720, 30fps, 3000 kbps). Enable and tune to reduce
|
# (framerate + video bitrate per resolution) instead of raw encoder values. The
|
||||||
# file size and CPU load on the egress worker.
|
# start-recording API accepts a pair per recording, e.g.
|
||||||
# RECORDING_ENCODING_ENABLED=False
|
# options.encoding={"resolution": "720p", "profile": "talking_heads"}; only keys
|
||||||
# RECORDING_ENCODING_WIDTH=1280
|
# declared in the two maps below are accepted, "profile" is optional.
|
||||||
# RECORDING_ENCODING_HEIGHT=720
|
# Both maps are read as a one-line Python dict literal (ast.literal_eval): double
|
||||||
# RECORDING_ENCODING_FRAMERATE=30
|
# quoted keys, no outer quotes, no trailing comma. Every profile must define a
|
||||||
# RECORDING_ENCODING_VIDEO_BITRATE_KBPS=3000
|
# kbps entry for exactly the resolutions of the resolutions map, or startup fails.
|
||||||
|
# RECORDING_ENCODING_AVAILABLE_RESOLUTIONS={"540p": {"width": 960, "height": 540}, "720p": {"width": 1280, "height": 720}, "1080p": {"width": 1920, "height": 1080}}
|
||||||
|
# RECORDING_ENCODING_AVAILABLE_PROFILES={"talking_heads": {"fps": 15, "kbps": {"540p": 400, "720p": 700, "1080p": 1200}}, "text": {"fps": 15, "kbps": {"540p": 600, "720p": 1000, "1080p": 1800}}, "mixed": {"fps": 20, "kbps": {"540p": 900, "720p": 1500, "1080p": 2500}}, "full": {"fps": 30, "kbps": {"540p": 2000, "720p": 3000, "1080p": 4500}}}
|
||||||
|
|
||||||
|
# Defaults for recordings that don't carry an encoding. Must be keys of the maps
|
||||||
|
# above. Declared but not yet read by the recording code at this commit.
|
||||||
|
# RECORDING_ENCODING_DEFAULT_RESOLUTION=720p
|
||||||
|
# RECORDING_ENCODING_DEFAULT_PROFILE=full
|
||||||
|
|
||||||
|
# Applied to every resolved encoding, independent of resolution and profile.
|
||||||
# RECORDING_ENCODING_AUDIO_BITRATE_KBPS=128
|
# RECORDING_ENCODING_AUDIO_BITRATE_KBPS=128
|
||||||
# RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=4.0
|
# RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=4.0
|
||||||
|
|
||||||
|
# Server-wide encoding used when a recording carries no encoding of its own.
|
||||||
|
# Keep False: when True, the worker factory still reads RECORDING_ENCODING_WIDTH,
|
||||||
|
# _HEIGHT, _FRAMERATE and _VIDEO_BITRATE_KBPS, which no longer exist.
|
||||||
|
# RECORDING_ENCODING_ENABLED=False
|
||||||
|
|
||||||
# Telephony
|
# Telephony
|
||||||
ROOM_TELEPHONY_ENABLED=True
|
ROOM_TELEPHONY_ENABLED=True
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -13,7 +13,12 @@ from django.core.exceptions import SuspiciousOperation
|
|||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from django_pydantic_field.rest_framework import SchemaField
|
from django_pydantic_field.rest_framework import SchemaField
|
||||||
from pydantic import BaseModel, Field, field_serializer
|
from pydantic import (
|
||||||
|
BaseModel,
|
||||||
|
Field,
|
||||||
|
field_serializer,
|
||||||
|
field_validator,
|
||||||
|
)
|
||||||
from pydantic import ValidationError as PydanticValidationError
|
from pydantic import ValidationError as PydanticValidationError
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from rest_framework.exceptions import PermissionDenied
|
from rest_framework.exceptions import PermissionDenied
|
||||||
@@ -244,6 +249,49 @@ class BaseValidationOnlySerializer(serializers.Serializer):
|
|||||||
raise NotImplementedError(f"{self.__class__.__name__} is validation-only")
|
raise NotImplementedError(f"{self.__class__.__name__} is validation-only")
|
||||||
|
|
||||||
|
|
||||||
|
class EncodingConfig(BaseModel):
|
||||||
|
"""Configuration options for recording encoding.
|
||||||
|
|
||||||
|
The allowed `resolution` and `profile` values are derived at validation time
|
||||||
|
from ``settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS`` and
|
||||||
|
``settings.RECORDING_ENCODING_AVAILABLE_PROFILES``, so adding a resolution or profile
|
||||||
|
to those maps is enough to make it accepted here.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
resolution: Target video resolution.
|
||||||
|
profile: Encoding profile to balance quality and CPU usage. When `None`,
|
||||||
|
LiveKit default framerate/bitrate are used for the resolution.
|
||||||
|
"""
|
||||||
|
|
||||||
|
resolution: str
|
||||||
|
profile: str | None = None
|
||||||
|
model_config = {"extra": "forbid"}
|
||||||
|
|
||||||
|
@field_validator("resolution")
|
||||||
|
@classmethod
|
||||||
|
def _validate_resolution(cls, value):
|
||||||
|
"""Reject resolutions absent from RECORDING_ENCODING_AVAILABLE_RESOLUTIONS."""
|
||||||
|
allowed = set(settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS)
|
||||||
|
if value not in allowed:
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid resolution '{value}'. Choose from {sorted(allowed)}."
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
@field_validator("profile")
|
||||||
|
@classmethod
|
||||||
|
def _validate_profile(cls, value):
|
||||||
|
"""Reject profiles absent from RECORDING_ENCODING_AVAILABLE_PROFILES."""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
allowed = set(settings.RECORDING_ENCODING_AVAILABLE_PROFILES)
|
||||||
|
if value not in allowed:
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid profile '{value}'. Choose from {sorted(allowed)}."
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
class RecordingOptions(BaseModel):
|
class RecordingOptions(BaseModel):
|
||||||
"""Configuration options for recording.
|
"""Configuration options for recording.
|
||||||
|
|
||||||
@@ -264,7 +312,7 @@ class RecordingOptions(BaseModel):
|
|||||||
transcribe: bool | None = None
|
transcribe: bool | None = None
|
||||||
collect_metadata: bool | None = None
|
collect_metadata: bool | None = None
|
||||||
original_mode: Literal["screen_recording", "transcript"] | None = None
|
original_mode: Literal["screen_recording", "transcript"] | None = None
|
||||||
|
encoding: EncodingConfig | None = None
|
||||||
model_config = {"extra": "forbid"}
|
model_config = {"extra": "forbid"}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ from core.recording.worker.factories import (
|
|||||||
from core.recording.worker.mediator import (
|
from core.recording.worker.mediator import (
|
||||||
WorkerServiceMediator,
|
WorkerServiceMediator,
|
||||||
)
|
)
|
||||||
|
from core.recording.worker.services import resolve_encoding_config
|
||||||
from core.services.invitation import InvitationService
|
from core.services.invitation import InvitationService
|
||||||
from core.services.livekit_events import (
|
from core.services.livekit_events import (
|
||||||
LiveKitEventsService,
|
LiveKitEventsService,
|
||||||
@@ -75,7 +76,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 RoomManagement
|
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,
|
||||||
@@ -352,7 +357,26 @@ class RoomViewSet(
|
|||||||
):
|
):
|
||||||
return
|
return
|
||||||
|
|
||||||
RoomManagement.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,
|
||||||
@@ -377,12 +401,20 @@ class RoomViewSet(
|
|||||||
options = serializer.validated_data.get("options")
|
options = serializer.validated_data.get("options")
|
||||||
room = self.get_object()
|
room = self.get_object()
|
||||||
|
|
||||||
|
options_data = options.model_dump(exclude_none=True) if options else {}
|
||||||
|
if options is not None and options.encoding is not None:
|
||||||
|
# Persist the resolved encoding (concrete width/height/framerate/
|
||||||
|
# bitrate) alongside the requested resolution/profile for traceability.
|
||||||
|
options_data["encoding"]["resolved"] = resolve_encoding_config(
|
||||||
|
options.encoding
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
recording = models.Recording.objects.create(
|
recording = models.Recording.objects.create(
|
||||||
room=room,
|
room=room,
|
||||||
mode=mode,
|
mode=mode,
|
||||||
options=options.model_dump(exclude_none=True) if options else {},
|
options=options_data,
|
||||||
)
|
)
|
||||||
models.RecordingAccess.objects.create(
|
models.RecordingAccess.objects.create(
|
||||||
user=self.request.user,
|
user=self.request.user,
|
||||||
@@ -1053,10 +1085,9 @@ class RecordingViewSet(
|
|||||||
|
|
||||||
def _auth_get_original_url(self, request):
|
def _auth_get_original_url(self, request):
|
||||||
"""
|
"""
|
||||||
Extracts and parses the original URL from the configured header.
|
Extracts and parses the original URL from the "HTTP_X_ORIGINAL_URL" header.
|
||||||
Raises PermissionDenied if the header is missing.
|
Raises PermissionDenied if the header is missing.
|
||||||
The original url is passed by the reverse proxy in the header named by the
|
The original url is passed by nginx in the "HTTP_X_ORIGINAL_URL" header.
|
||||||
MEDIA_AUTH_ORIGINAL_URL_HEADER setting, which defaults to "HTTP_X_ORIGINAL_URL".
|
|
||||||
See corresponding ingress configuration in Helm chart and read about the
|
See corresponding ingress configuration in Helm chart and read about the
|
||||||
nginx.ingress.kubernetes.io/auth-url annotation to understand how the Nginx ingress
|
nginx.ingress.kubernetes.io/auth-url annotation to understand how the Nginx ingress
|
||||||
is configured to do this.
|
is configured to do this.
|
||||||
@@ -1066,13 +1097,9 @@ class RecordingViewSet(
|
|||||||
reasons.
|
reasons.
|
||||||
"""
|
"""
|
||||||
# Extract the original URL from the request header
|
# Extract the original URL from the request header
|
||||||
original_url = request.META.get(settings.MEDIA_AUTH_ORIGINAL_URL_HEADER)
|
original_url = request.META.get("HTTP_X_ORIGINAL_URL")
|
||||||
if not original_url:
|
if not original_url:
|
||||||
logger.warning(
|
logger.warning("Missing HTTP_X_ORIGINAL_URL header in subrequest")
|
||||||
"Missing %s header in subrequest. Set MEDIA_AUTH_ORIGINAL_URL_HEADER "
|
|
||||||
"to the header your reverse proxy sends.",
|
|
||||||
settings.MEDIA_AUTH_ORIGINAL_URL_HEADER,
|
|
||||||
)
|
|
||||||
raise drf_exceptions.PermissionDenied()
|
raise drf_exceptions.PermissionDenied()
|
||||||
|
|
||||||
logger.debug("Original url: '%s'", original_url)
|
logger.debug("Original url: '%s'", original_url)
|
||||||
@@ -1397,8 +1424,7 @@ class FileViewSet(
|
|||||||
Authorize access based on the original URL of an Nginx subrequest
|
Authorize access based on the original URL of an Nginx subrequest
|
||||||
and user permissions. Returns a dictionary of URL parameters if authorized.
|
and user permissions. Returns a dictionary of URL parameters if authorized.
|
||||||
|
|
||||||
The original url is passed by the reverse proxy in the header named by the
|
The original url is passed by nginx in the "HTTP_X_ORIGINAL_URL" header.
|
||||||
MEDIA_AUTH_ORIGINAL_URL_HEADER setting, which defaults to "HTTP_X_ORIGINAL_URL".
|
|
||||||
See corresponding ingress configuration in Helm chart and read about the
|
See corresponding ingress configuration in Helm chart and read about the
|
||||||
nginx.ingress.kubernetes.io/auth-url annotation to understand how the Nginx ingress
|
nginx.ingress.kubernetes.io/auth-url annotation to understand how the Nginx ingress
|
||||||
is configured to do this.
|
is configured to do this.
|
||||||
@@ -1417,13 +1443,9 @@ class FileViewSet(
|
|||||||
- PermissionDenied if authorization fails.
|
- PermissionDenied if authorization fails.
|
||||||
"""
|
"""
|
||||||
# Extract the original URL from the request header
|
# Extract the original URL from the request header
|
||||||
original_url = request.META.get(settings.MEDIA_AUTH_ORIGINAL_URL_HEADER)
|
original_url = request.META.get("HTTP_X_ORIGINAL_URL")
|
||||||
if not original_url:
|
if not original_url:
|
||||||
logger.warning(
|
logger.warning("Missing HTTP_X_ORIGINAL_URL header in subrequest")
|
||||||
"Missing %s header in subrequest. Set MEDIA_AUTH_ORIGINAL_URL_HEADER "
|
|
||||||
"to the header your reverse proxy sends.",
|
|
||||||
settings.MEDIA_AUTH_ORIGINAL_URL_HEADER,
|
|
||||||
)
|
|
||||||
raise drf_exceptions.PermissionDenied()
|
raise drf_exceptions.PermissionDenied()
|
||||||
|
|
||||||
parsed_url = urlparse(original_url)
|
parsed_url = urlparse(original_url)
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"""External API endpoints"""
|
"""External API endpoints"""
|
||||||
|
|
||||||
import copy
|
|
||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@@ -26,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 RoomManagement
|
|
||||||
|
|
||||||
from ..services.provisional_user_service import (
|
from ..services.provisional_user_service import (
|
||||||
ProvisionalUserCreationDisabledError,
|
ProvisionalUserCreationDisabledError,
|
||||||
@@ -144,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.
|
||||||
@@ -157,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,
|
||||||
@@ -196,39 +189,7 @@ 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):
|
def perform_create(self, serializer):
|
||||||
"""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: serializers.RoomSerializer):
|
|
||||||
"""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()
|
||||||
models.ResourceAccess.objects.create(
|
models.ResourceAccess.objects.create(
|
||||||
@@ -237,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: serializers.RoomSerializer):
|
# 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": copy.deepcopy(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(
|
||||||
RoomManagement.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},
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ 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()
|
||||||
|
|
||||||
|
|
||||||
class UserResourceAccessFactory(factory.django.DjangoModelFactory):
|
class UserResourceAccessFactory(factory.django.DjangoModelFactory):
|
||||||
"""Create fake resource user accesses for testing."""
|
"""Create fake resource user accesses for testing."""
|
||||||
@@ -95,6 +97,8 @@ class RecordingFactory(factory.django.DjangoModelFactory):
|
|||||||
recording=self, user=item[0], role=item[1]
|
recording=self, user=item[0], role=item[1]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
|
||||||
class UserRecordingAccessFactory(factory.django.DjangoModelFactory):
|
class UserRecordingAccessFactory(factory.django.DjangoModelFactory):
|
||||||
"""Create fake recording user accesses for testing."""
|
"""Create fake recording user accesses for testing."""
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ class RecordingEventsService:
|
|||||||
recording_status = status_mapping.get(egress_status)
|
recording_status = status_mapping.get(egress_status)
|
||||||
if recording_status:
|
if recording_status:
|
||||||
try:
|
try:
|
||||||
RoomManagement.update_metadata(
|
RoomManagement().update_metadata(
|
||||||
room_name, {"recording_status": recording_status}
|
room_name, {"recording_status": recording_status}
|
||||||
)
|
)
|
||||||
except RoomNotFoundException:
|
except RoomNotFoundException:
|
||||||
|
|||||||
@@ -22,6 +22,37 @@ _RECORDING_AUDIO_CODEC = livekit_api.AudioCodec.AAC
|
|||||||
_RECORDING_AUDIO_FREQUENCY_HZ = 48000
|
_RECORDING_AUDIO_FREQUENCY_HZ = 48000
|
||||||
|
|
||||||
|
|
||||||
|
def _build_default_encoding_options() -> Optional[Dict[str, Any]]:
|
||||||
|
"""Build the server-wide EncodingOptions kwargs, or None to keep LiveKit's preset.
|
||||||
|
|
||||||
|
Operator-tunable values live in Django settings; the default resolution gives
|
||||||
|
width / height, the default profile gives framerate and video bitrate, while
|
||||||
|
codec and frequency are pinned constants. Either default left empty means we
|
||||||
|
use the livekit defaults.
|
||||||
|
"""
|
||||||
|
|
||||||
|
resolution = settings.RECORDING_ENCODING_DEFAULT_RESOLUTION
|
||||||
|
profile = settings.RECORDING_ENCODING_DEFAULT_PROFILE
|
||||||
|
|
||||||
|
if not resolution or not profile:
|
||||||
|
return None
|
||||||
|
|
||||||
|
dimensions = settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS[resolution]
|
||||||
|
profile_spec = settings.RECORDING_ENCODING_AVAILABLE_PROFILES[profile]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"width": dimensions["width"],
|
||||||
|
"height": dimensions["height"],
|
||||||
|
"framerate": profile_spec["fps"],
|
||||||
|
"video_bitrate": profile_spec["kbps"][resolution],
|
||||||
|
"audio_bitrate": settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS,
|
||||||
|
"key_frame_interval": settings.RECORDING_ENCODING_KEY_FRAME_INTERVAL_S,
|
||||||
|
"video_codec": _RECORDING_VIDEO_CODEC,
|
||||||
|
"audio_codec": _RECORDING_AUDIO_CODEC,
|
||||||
|
"audio_frequency": _RECORDING_AUDIO_FREQUENCY_HZ,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class WorkerServiceConfig:
|
class WorkerServiceConfig:
|
||||||
"""Declare Worker Service common configurations"""
|
"""Declare Worker Service common configurations"""
|
||||||
@@ -38,22 +69,14 @@ class WorkerServiceConfig:
|
|||||||
|
|
||||||
logger.debug("Loading WorkerServiceConfig from settings.")
|
logger.debug("Loading WorkerServiceConfig from settings.")
|
||||||
|
|
||||||
encoding_options: Optional[Dict[str, Any]] = None
|
# Single source of truth for the EncodingOptions kwargs; the services
|
||||||
if settings.RECORDING_ENCODING_ENABLED:
|
# layer only unpacks this dict. Recordings carrying their own encoding
|
||||||
# Single source of truth for the EncodingOptions kwargs:
|
# resolve it per request and bypass this default.
|
||||||
# operator-tunable values live in Django settings, codec / frequency
|
encoding_options: Optional[Dict[str, Any]] = (
|
||||||
# are pinned constants. The services layer only unpacks this dict.
|
_build_default_encoding_options()
|
||||||
encoding_options = {
|
if settings.RECORDING_ENCODING_ENABLED
|
||||||
"width": settings.RECORDING_ENCODING_WIDTH,
|
else None
|
||||||
"height": settings.RECORDING_ENCODING_HEIGHT,
|
)
|
||||||
"framerate": settings.RECORDING_ENCODING_FRAMERATE,
|
|
||||||
"video_bitrate": settings.RECORDING_ENCODING_VIDEO_BITRATE_KBPS,
|
|
||||||
"audio_bitrate": settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS,
|
|
||||||
"key_frame_interval": settings.RECORDING_ENCODING_KEY_FRAME_INTERVAL_S,
|
|
||||||
"video_codec": _RECORDING_VIDEO_CODEC,
|
|
||||||
"audio_codec": _RECORDING_AUDIO_CODEC,
|
|
||||||
"audio_frequency": _RECORDING_AUDIO_FREQUENCY_HZ,
|
|
||||||
}
|
|
||||||
|
|
||||||
return cls(
|
return cls(
|
||||||
output_folder=settings.RECORDING_OUTPUT_FOLDER,
|
output_folder=settings.RECORDING_OUTPUT_FOLDER,
|
||||||
@@ -78,7 +101,12 @@ class WorkerService(Protocol):
|
|||||||
def __init__(self, config: WorkerServiceConfig):
|
def __init__(self, config: WorkerServiceConfig):
|
||||||
"""Initialize the service with the given configuration."""
|
"""Initialize the service with the given configuration."""
|
||||||
|
|
||||||
def start(self, room_id: str, recording_id: str) -> str:
|
def start(
|
||||||
|
self,
|
||||||
|
room_id: str,
|
||||||
|
recording_id: str,
|
||||||
|
encoding_options: Optional[Dict[str, Any]] = None,
|
||||||
|
) -> str:
|
||||||
"""Start a recording for a specified room."""
|
"""Start a recording for a specified room."""
|
||||||
|
|
||||||
def stop(self, worker_id: str) -> str:
|
def stop(self, worker_id: str) -> str:
|
||||||
|
|||||||
@@ -51,8 +51,11 @@ class WorkerServiceMediator:
|
|||||||
raise RecordingStartError()
|
raise RecordingStartError()
|
||||||
|
|
||||||
room_name = str(recording.room.id)
|
room_name = str(recording.room.id)
|
||||||
|
encoding_options = (recording.options.get("encoding") or {}).get("resolved")
|
||||||
try:
|
try:
|
||||||
worker_id = self._worker_service.start(room_name, recording.id)
|
worker_id = self._worker_service.start(
|
||||||
|
room_name, recording.id, encoding_options=encoding_options
|
||||||
|
)
|
||||||
except (WorkerRequestError, WorkerConnectionError, WorkerResponseError) as e:
|
except (WorkerRequestError, WorkerConnectionError, WorkerResponseError) as e:
|
||||||
logger.exception(
|
logger.exception(
|
||||||
"Failed to start recording for room %s: %s", recording.room.slug, e
|
"Failed to start recording for room %s: %s", recording.room.slug, e
|
||||||
@@ -68,7 +71,7 @@ class WorkerServiceMediator:
|
|||||||
mode = recording.options.get("original_mode", None) or recording.mode
|
mode = recording.options.get("original_mode", None) or recording.mode
|
||||||
|
|
||||||
try:
|
try:
|
||||||
RoomManagement.update_metadata(
|
RoomManagement().update_metadata(
|
||||||
room_name, {"recording_mode": mode, "recording_status": "starting"}
|
room_name, {"recording_mode": mode, "recording_status": "starting"}
|
||||||
)
|
)
|
||||||
except RoomNotFoundException:
|
except RoomNotFoundException:
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
# pylint: disable=no-member
|
# pylint: disable=no-member
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
from asgiref.sync import async_to_sync
|
from asgiref.sync import async_to_sync
|
||||||
from livekit import api as livekit_api
|
from livekit import api as livekit_api
|
||||||
|
|
||||||
@@ -11,6 +13,41 @@ from .exceptions import WorkerConnectionError, WorkerResponseError
|
|||||||
from .factories import WorkerServiceConfig
|
from .factories import WorkerServiceConfig
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_encoding_config(encoding_config):
|
||||||
|
"""Resolve a per-recording EncodingConfig to concrete encoding fields.
|
||||||
|
|
||||||
|
Returns a JSON-serializable dict of the LiveKit ``EncodingOptions`` kwargs
|
||||||
|
derived from the request's resolution / profile, or None when no
|
||||||
|
encoding_config is provided. This allows to derive width, height, fps, and
|
||||||
|
bitrate from (resolution, profile).
|
||||||
|
|
||||||
|
Only the fields that can actually be resolved are included: width/height
|
||||||
|
require a resolution, framerate/video_bitrate require both a resolution and a
|
||||||
|
profile.
|
||||||
|
"""
|
||||||
|
if encoding_config is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
resolution = encoding_config.resolution
|
||||||
|
profile = encoding_config.profile
|
||||||
|
|
||||||
|
resolved = {
|
||||||
|
"key_frame_interval": settings.RECORDING_ENCODING_KEY_FRAME_INTERVAL_S,
|
||||||
|
}
|
||||||
|
|
||||||
|
if resolution:
|
||||||
|
dimensions = settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS[resolution]
|
||||||
|
resolved["width"] = dimensions["width"]
|
||||||
|
resolved["height"] = dimensions["height"]
|
||||||
|
|
||||||
|
if resolution and profile:
|
||||||
|
profile_spec = settings.RECORDING_ENCODING_AVAILABLE_PROFILES[profile]
|
||||||
|
resolved["framerate"] = profile_spec["fps"]
|
||||||
|
resolved["video_bitrate"] = profile_spec["kbps"][resolution]
|
||||||
|
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
class BaseEgressService:
|
class BaseEgressService:
|
||||||
"""Base egress defining common methods to manage and interact with LiveKit egress processes."""
|
"""Base egress defining common methods to manage and interact with LiveKit egress processes."""
|
||||||
|
|
||||||
@@ -76,7 +113,7 @@ class BaseEgressService:
|
|||||||
|
|
||||||
return "FAILED_TO_STOP"
|
return "FAILED_TO_STOP"
|
||||||
|
|
||||||
def start(self, room_name, recording_id):
|
def start(self, room_name, recording_id, encoding_options=None):
|
||||||
"""Start the egress process for a recording (not implemented in the base class).
|
"""Start the egress process for a recording (not implemented in the base class).
|
||||||
Each derived class must implement this method, providing the necessary parameters for
|
Each derived class must implement this method, providing the necessary parameters for
|
||||||
its specific egress type (e.g. audio_only, streaming output).
|
its specific egress type (e.g. audio_only, streaming output).
|
||||||
@@ -99,13 +136,24 @@ class BaseEgressService:
|
|||||||
|
|
||||||
return livekit_api.EncodingOptions(**opts)
|
return livekit_api.EncodingOptions(**opts)
|
||||||
|
|
||||||
|
def _resolve_encoding_options(self, encoding_options):
|
||||||
|
"""Build LiveKit EncodingOptions from a resolved per-recording dict, or None.
|
||||||
|
|
||||||
|
``encoding_options`` is the dict persisted by the API in
|
||||||
|
``recording.options["encoding"]["resolved"]``.
|
||||||
|
"""
|
||||||
|
if not encoding_options:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return livekit_api.EncodingOptions(**encoding_options)
|
||||||
|
|
||||||
|
|
||||||
class VideoCompositeEgressService(BaseEgressService):
|
class VideoCompositeEgressService(BaseEgressService):
|
||||||
"""Record multiple participant video and audio tracks into a single output '.mp4' file."""
|
"""Record multiple participant video and audio tracks into a single output '.mp4' file."""
|
||||||
|
|
||||||
hrid = "video-recording-composite-livekit-egress"
|
hrid = "video-recording-composite-livekit-egress"
|
||||||
|
|
||||||
def start(self, room_name, recording_id):
|
def start(self, room_name, recording_id, encoding_options=None):
|
||||||
"""Start the video composite egress process for a recording."""
|
"""Start the video composite egress process for a recording."""
|
||||||
|
|
||||||
# Save room's recording as a mp4 video file.
|
# Save room's recording as a mp4 video file.
|
||||||
@@ -126,7 +174,10 @@ class VideoCompositeEgressService(BaseEgressService):
|
|||||||
"layout": "speaker-light",
|
"layout": "speaker-light",
|
||||||
}
|
}
|
||||||
|
|
||||||
advanced = self._build_encoding_options()
|
advanced = (
|
||||||
|
self._resolve_encoding_options(encoding_options)
|
||||||
|
or self._build_encoding_options()
|
||||||
|
)
|
||||||
if advanced is not None:
|
if advanced is not None:
|
||||||
request_kwargs["advanced"] = advanced
|
request_kwargs["advanced"] = advanced
|
||||||
|
|
||||||
@@ -145,8 +196,13 @@ class AudioCompositeEgressService(BaseEgressService):
|
|||||||
|
|
||||||
hrid = "audio-recording-composite-livekit-egress"
|
hrid = "audio-recording-composite-livekit-egress"
|
||||||
|
|
||||||
def start(self, room_name, recording_id):
|
def start(self, room_name, recording_id, encoding_options=None):
|
||||||
"""Start the audio composite egress process for a recording."""
|
"""Start the audio composite egress process for a recording.
|
||||||
|
|
||||||
|
``encoding_options`` is accepted for signature compatibility with the
|
||||||
|
WorkerService protocol but ignored: audio-only egress has no
|
||||||
|
encoding to configure.
|
||||||
|
"""
|
||||||
|
|
||||||
# Save room's recording as an ogg audio file.
|
# Save room's recording as an ogg audio file.
|
||||||
file_type = livekit_api.EncodedFileType.OGG
|
file_type = livekit_api.EncodedFileType.OGG
|
||||||
|
|||||||
@@ -192,7 +192,7 @@ class LiveKitEventsService:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
room_name = str(recording.room.id)
|
room_name = str(recording.room.id)
|
||||||
RoomManagement.update_metadata(
|
RoomManagement().update_metadata(
|
||||||
room_name, remove_keys=["recording_mode", "recording_status"]
|
room_name, remove_keys=["recording_mode", "recording_status"]
|
||||||
)
|
)
|
||||||
except RoomNotFoundException:
|
except RoomNotFoundException:
|
||||||
|
|||||||
@@ -30,10 +30,9 @@ class RoomNotFoundException(RoomManagementException):
|
|||||||
class RoomManagement:
|
class RoomManagement:
|
||||||
"""Service for managing LiveKit rooms."""
|
"""Service for managing LiveKit rooms."""
|
||||||
|
|
||||||
@classmethod
|
|
||||||
@async_to_sync
|
@async_to_sync
|
||||||
async def update_metadata(
|
async def update_metadata(
|
||||||
cls,
|
self,
|
||||||
room_name: str,
|
room_name: str,
|
||||||
metadata: Optional[Dict] = None,
|
metadata: Optional[Dict] = None,
|
||||||
remove_keys: Optional[list[str]] = None,
|
remove_keys: Optional[list[str]] = None,
|
||||||
@@ -91,9 +90,8 @@ class RoomManagement:
|
|||||||
finally:
|
finally:
|
||||||
await lkapi.aclose()
|
await lkapi.aclose()
|
||||||
|
|
||||||
@classmethod
|
|
||||||
@async_to_sync
|
@async_to_sync
|
||||||
async def delete_room(cls, room_name: str):
|
async def delete_room(self, room_name: str):
|
||||||
"""Delete a LiveKit room and disconnect all participants.
|
"""Delete a LiveKit room and disconnect all participants.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
@@ -118,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()
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def sync_room_metadata(cls, 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:
|
|
||||||
cls.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,
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ def delete_connection_test_room(room_name: str):
|
|||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
RoomManagement.delete_room(room_name)
|
RoomManagement().delete_room(room_name)
|
||||||
except RoomNotFoundException:
|
except RoomNotFoundException:
|
||||||
# Room may already be gone after empty/departure timeout.
|
# Room may already be gone after empty/departure timeout.
|
||||||
logger.info("Connection test room '%s' already gone.", room_name)
|
logger.info("Connection test room '%s' already gone.", room_name)
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ from urllib.parse import quote, urlparse
|
|||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.files.storage import default_storage
|
from django.core.files.storage import default_storage
|
||||||
from django.test import override_settings
|
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -144,59 +143,3 @@ def test_api_files_media_auth_own_file_deleted():
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
@override_settings(MEDIA_AUTH_ORIGINAL_URL_HEADER="HTTP_X_FORWARDED_URI")
|
|
||||||
def test_api_files_media_auth_custom_original_url_header():
|
|
||||||
"""
|
|
||||||
Authorization should honour the configured original-url header.
|
|
||||||
|
|
||||||
Covers the attachment subrequest path, which resolves the header separately
|
|
||||||
from the recording one. Reverse proxies other than nginx-ingress use
|
|
||||||
different headers: Traefik's ForwardAuth sends X-Forwarded-Uri and cannot
|
|
||||||
emit X-Original-URL at all.
|
|
||||||
"""
|
|
||||||
user = factories.UserFactory()
|
|
||||||
|
|
||||||
file = factories.FileFactory(
|
|
||||||
type=models.FileTypeChoices.BACKGROUND_IMAGE,
|
|
||||||
update_upload_state=models.FileUploadStateChoices.READY,
|
|
||||||
creator=user,
|
|
||||||
)
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
default_storage.save(file.file_key, BytesIO(b"my prose"))
|
|
||||||
|
|
||||||
original_url = f"http://localhost/media/{file.file_key:s}"
|
|
||||||
response = client.get(
|
|
||||||
"/api/v1.0/files/media-auth/", HTTP_X_FORWARDED_URI=original_url
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert "AWS4-HMAC-SHA256 Credential=" in response["Authorization"]
|
|
||||||
|
|
||||||
|
|
||||||
@override_settings(MEDIA_AUTH_ORIGINAL_URL_HEADER="HTTP_X_FORWARDED_URI")
|
|
||||||
def test_api_files_media_auth_default_header_ignored_when_reconfigured():
|
|
||||||
"""
|
|
||||||
Only the configured header should be honoured, never a hardcoded fallback.
|
|
||||||
"""
|
|
||||||
user = factories.UserFactory()
|
|
||||||
|
|
||||||
file = factories.FileFactory(
|
|
||||||
type=models.FileTypeChoices.BACKGROUND_IMAGE,
|
|
||||||
update_upload_state=models.FileUploadStateChoices.READY,
|
|
||||||
creator=user,
|
|
||||||
)
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
original_url = f"http://localhost/media/{file.file_key:s}"
|
|
||||||
response = client.get(
|
|
||||||
"/api/v1.0/files/media-auth/", HTTP_X_ORIGINAL_URL=original_url
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 403
|
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ from uuid import uuid4
|
|||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.files.storage import default_storage
|
from django.core.files.storage import default_storage
|
||||||
from django.test import override_settings
|
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -283,63 +282,3 @@ def test_api_recordings_media_auth_success_administrator(mode):
|
|||||||
timeout=1,
|
timeout=1,
|
||||||
)
|
)
|
||||||
assert response.content.decode("utf-8") == "my prose"
|
assert response.content.decode("utf-8") == "my prose"
|
||||||
|
|
||||||
|
|
||||||
def test_api_recordings_media_auth_missing_header():
|
|
||||||
"""
|
|
||||||
Test that a subrequest without the configured original-url header is rejected.
|
|
||||||
"""
|
|
||||||
user = UserFactory()
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
response = client.get("/api/v1.0/recordings/media-auth/")
|
|
||||||
|
|
||||||
assert response.status_code == 403
|
|
||||||
|
|
||||||
|
|
||||||
@override_settings(MEDIA_AUTH_ORIGINAL_URL_HEADER="HTTP_X_FORWARDED_URI")
|
|
||||||
def test_api_recordings_media_auth_custom_original_url_header():
|
|
||||||
"""
|
|
||||||
Test that the header carrying the original URL can be configured.
|
|
||||||
|
|
||||||
Reverse proxies other than nginx-ingress use different headers: Traefik's
|
|
||||||
ForwardAuth sends X-Forwarded-Uri and cannot emit X-Original-URL at all.
|
|
||||||
"""
|
|
||||||
user = UserFactory()
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
original_url = f"http://localhost/media/recordings/{uuid4()!s}.mp4"
|
|
||||||
|
|
||||||
response = client.get(
|
|
||||||
"/api/v1.0/recordings/media-auth/", HTTP_X_FORWARDED_URI=original_url
|
|
||||||
)
|
|
||||||
|
|
||||||
# The header was read and parsed: we get as far as looking the recording up,
|
|
||||||
# rather than being rejected for a missing header.
|
|
||||||
assert response.status_code == 404
|
|
||||||
|
|
||||||
|
|
||||||
@override_settings(MEDIA_AUTH_ORIGINAL_URL_HEADER="HTTP_X_FORWARDED_URI")
|
|
||||||
def test_api_recordings_media_auth_default_header_ignored_when_reconfigured():
|
|
||||||
"""
|
|
||||||
Test that only the configured header is honoured.
|
|
||||||
|
|
||||||
Guards against the header being read from a hardcoded name in parallel with
|
|
||||||
the setting.
|
|
||||||
"""
|
|
||||||
user = UserFactory()
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
original_url = f"http://localhost/media/recordings/{uuid4()!s}.mp4"
|
|
||||||
|
|
||||||
response = client.get(
|
|
||||||
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 403
|
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"""Tests for the per-recording encoding resolution in BaseEgressService."""
|
||||||
|
|
||||||
|
# pylint: disable=protected-access,redefined-outer-name,unused-argument
|
||||||
|
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pydantic import ValidationError as PydanticValidationError
|
||||||
|
|
||||||
|
from core.api.serializers import EncodingConfig
|
||||||
|
from core.recording.worker.services import (
|
||||||
|
VideoCompositeEgressService,
|
||||||
|
resolve_encoding_config,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def make_config():
|
||||||
|
"""Build a minimal WorkerServiceConfig-like mock for service instantiation."""
|
||||||
|
config = Mock()
|
||||||
|
config.bucket_args = {
|
||||||
|
"endpoint": "https://s3.test.com",
|
||||||
|
"access_key": "test_key",
|
||||||
|
"secret": "test_secret",
|
||||||
|
"region": "test-region",
|
||||||
|
"bucket": "test-bucket",
|
||||||
|
"force_path_style": True,
|
||||||
|
}
|
||||||
|
config.encoding_options = None
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def service():
|
||||||
|
"""Return a VideoCompositeEgressService with mocked handle_request."""
|
||||||
|
svc = VideoCompositeEgressService(make_config())
|
||||||
|
svc._handle_request = Mock()
|
||||||
|
return svc
|
||||||
|
|
||||||
|
|
||||||
|
# --- resolve_encoding_config ---
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_config_returns_none_without_config():
|
||||||
|
"""Resolver should return None when no encoding config is provided."""
|
||||||
|
assert resolve_encoding_config(None) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_config_without_profile_omits_profile_fields():
|
||||||
|
"""A resolution-only config should resolve dimensions but no framerate/bitrate."""
|
||||||
|
resolved = resolve_encoding_config(EncodingConfig(resolution="720p"))
|
||||||
|
|
||||||
|
assert resolved == {
|
||||||
|
"key_frame_interval": settings.RECORDING_ENCODING_KEY_FRAME_INTERVAL_S,
|
||||||
|
"width": 1280,
|
||||||
|
"height": 720,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_encoding_config_requires_resolution():
|
||||||
|
"""A profile-only or empty encoding config should be rejected at validation."""
|
||||||
|
with pytest.raises(PydanticValidationError):
|
||||||
|
EncodingConfig(profile="mixed")
|
||||||
|
with pytest.raises(PydanticValidationError):
|
||||||
|
EncodingConfig()
|
||||||
|
|
||||||
|
|
||||||
|
# --- _resolve_encoding_options ---
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("encoding_options", [None, {}])
|
||||||
|
def test_resolve_options_returns_none_when_empty(service, encoding_options):
|
||||||
|
"""Resolver should return None when the resolved dict is empty or missing."""
|
||||||
|
assert service._resolve_encoding_options(encoding_options) is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"resolution",
|
||||||
|
list(settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS),
|
||||||
|
)
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"profile",
|
||||||
|
list(settings.RECORDING_ENCODING_AVAILABLE_PROFILES),
|
||||||
|
)
|
||||||
|
def test_resolve_profile_resolution_combinations(service, profile, resolution):
|
||||||
|
"""Every (profile, resolution) pair should resolve to the values from settings."""
|
||||||
|
dimensions = settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS[resolution]
|
||||||
|
profile_spec = settings.RECORDING_ENCODING_AVAILABLE_PROFILES[profile]
|
||||||
|
|
||||||
|
resolved = resolve_encoding_config(
|
||||||
|
EncodingConfig(resolution=resolution, profile=profile)
|
||||||
|
)
|
||||||
|
result = service._resolve_encoding_options(resolved)
|
||||||
|
|
||||||
|
assert result.width == dimensions["width"]
|
||||||
|
assert result.height == dimensions["height"]
|
||||||
|
assert result.framerate == profile_spec["fps"]
|
||||||
|
assert result.video_bitrate == profile_spec["kbps"][resolution]
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_options_none_profile_uses_livekit_defaults(service):
|
||||||
|
"""Missing profile should pass 0 fps/bitrate (LiveKit protobuf default)."""
|
||||||
|
resolved = resolve_encoding_config(EncodingConfig(resolution="720p"))
|
||||||
|
result = service._resolve_encoding_options(resolved)
|
||||||
|
assert result.width == 1280
|
||||||
|
assert result.height == 720
|
||||||
|
assert result.framerate == 0
|
||||||
|
assert result.video_bitrate == 0
|
||||||
@@ -85,18 +85,22 @@ def test_config_immutability(default_config):
|
|||||||
AWS_S3_REGION_NAME="test-region",
|
AWS_S3_REGION_NAME="test-region",
|
||||||
AWS_STORAGE_BUCKET_NAME="test-bucket",
|
AWS_STORAGE_BUCKET_NAME="test-bucket",
|
||||||
RECORDING_ENCODING_ENABLED=True,
|
RECORDING_ENCODING_ENABLED=True,
|
||||||
RECORDING_ENCODING_WIDTH=1280,
|
RECORDING_ENCODING_AVAILABLE_RESOLUTIONS={
|
||||||
RECORDING_ENCODING_HEIGHT=720,
|
"720p": {"width": 1280, "height": 720},
|
||||||
RECORDING_ENCODING_FRAMERATE=15,
|
},
|
||||||
RECORDING_ENCODING_VIDEO_BITRATE_KBPS=600,
|
RECORDING_ENCODING_AVAILABLE_PROFILES={
|
||||||
|
"talking_heads": {"fps": 15, "kbps": {"720p": 600}},
|
||||||
|
},
|
||||||
|
RECORDING_ENCODING_DEFAULT_RESOLUTION="720p",
|
||||||
|
RECORDING_ENCODING_DEFAULT_PROFILE="talking_heads",
|
||||||
RECORDING_ENCODING_AUDIO_BITRATE_KBPS=64,
|
RECORDING_ENCODING_AUDIO_BITRATE_KBPS=64,
|
||||||
RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=10.0,
|
RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=10.0,
|
||||||
)
|
)
|
||||||
def test_config_encoding_options_enabled():
|
def test_config_encoding_options_enabled():
|
||||||
"""When RECORDING_ENCODING_ENABLED is True, encoding options are populated.
|
"""When RECORDING_ENCODING_ENABLED is True, encoding options are populated.
|
||||||
|
|
||||||
The dict mixes operator-tunable values from settings with pinned codec /
|
The dict mixes values resolved from the default resolution / profile with
|
||||||
frequency constants, so the services layer can simply unpack it.
|
pinned codec / frequency constants, so the services layer can simply unpack it.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
WorkerServiceConfig.from_settings.cache_clear()
|
WorkerServiceConfig.from_settings.cache_clear()
|
||||||
@@ -115,6 +119,27 @@ def test_config_encoding_options_enabled():
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
RECORDING_OUTPUT_FOLDER="/test/output",
|
||||||
|
LIVEKIT_CONFIGURATION={"server": "test.example.com"},
|
||||||
|
AWS_S3_ENDPOINT_URL="https://s3.test.com",
|
||||||
|
AWS_S3_ACCESS_KEY_ID="test_key",
|
||||||
|
AWS_S3_SECRET_ACCESS_KEY="test_secret",
|
||||||
|
AWS_S3_REGION_NAME="test-region",
|
||||||
|
AWS_STORAGE_BUCKET_NAME="test-bucket",
|
||||||
|
RECORDING_ENCODING_ENABLED=True,
|
||||||
|
RECORDING_ENCODING_DEFAULT_RESOLUTION="",
|
||||||
|
RECORDING_ENCODING_DEFAULT_PROFILE="",
|
||||||
|
)
|
||||||
|
def test_config_encoding_options_without_defaults():
|
||||||
|
"""An empty default resolution or profile leaves the encoding to LiveKit."""
|
||||||
|
|
||||||
|
WorkerServiceConfig.from_settings.cache_clear()
|
||||||
|
config = WorkerServiceConfig.from_settings()
|
||||||
|
|
||||||
|
assert config.encoding_options is None
|
||||||
|
|
||||||
|
|
||||||
@override_settings(
|
@override_settings(
|
||||||
RECORDING_OUTPUT_FOLDER="/test/output",
|
RECORDING_OUTPUT_FOLDER="/test/output",
|
||||||
LIVEKIT_CONFIGURATION={"server": "test.example.com"},
|
LIVEKIT_CONFIGURATION={"server": "test.example.com"},
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ def test_start_recording_success(mock_update_metadata, mediator, mock_worker_ser
|
|||||||
# Verify worker service call
|
# Verify worker service call
|
||||||
expected_room_name = str(mock_recording.room.id)
|
expected_room_name = str(mock_recording.room.id)
|
||||||
mock_worker_service.start.assert_called_once_with(
|
mock_worker_service.start.assert_called_once_with(
|
||||||
expected_room_name, mock_recording.id
|
expected_room_name, mock_recording.id, encoding_options=None
|
||||||
)
|
)
|
||||||
|
|
||||||
# Verify recording updates
|
# Verify recording updates
|
||||||
@@ -64,6 +64,38 @@ def test_start_recording_success(mock_update_metadata, mediator, mock_worker_ser
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch("core.utils.update_room_metadata")
|
||||||
|
def test_start_recording_passes_resolved_encoding(
|
||||||
|
mock_update_room_metadata, mediator, mock_worker_service
|
||||||
|
):
|
||||||
|
"""The resolved encoding persisted in recording.options reaches the worker."""
|
||||||
|
mock_worker_service.start.return_value = "test-worker-123"
|
||||||
|
|
||||||
|
resolved = {
|
||||||
|
"key_frame_interval": 4.0,
|
||||||
|
"width": 1280,
|
||||||
|
"height": 720,
|
||||||
|
"framerate": 15,
|
||||||
|
"video_bitrate": 700,
|
||||||
|
}
|
||||||
|
mock_recording = RecordingFactory(
|
||||||
|
status=RecordingStatusChoices.INITIATED,
|
||||||
|
worker_id=None,
|
||||||
|
options={
|
||||||
|
"encoding": {
|
||||||
|
"resolution": "720p",
|
||||||
|
"profile": "talking_heads",
|
||||||
|
"resolved": resolved,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
mediator.start(mock_recording)
|
||||||
|
|
||||||
|
mock_worker_service.start.assert_called_once_with(
|
||||||
|
str(mock_recording.room.id), mock_recording.id, encoding_options=resolved
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"error_class", [WorkerRequestError, WorkerConnectionError, WorkerResponseError]
|
"error_class", [WorkerRequestError, WorkerConnectionError, WorkerResponseError]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -470,6 +470,160 @@ def test_start_recording_options_unknown_field_rejected(settings):
|
|||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_recording_options_encoding_valid(
|
||||||
|
settings, mock_worker_service_factory, mock_worker_manager
|
||||||
|
):
|
||||||
|
"""Should accept a valid encoding configuration."""
|
||||||
|
settings.RECORDING_ENABLE = True
|
||||||
|
room = RoomFactory()
|
||||||
|
user = UserFactory()
|
||||||
|
room.accesses.create(user=user, role="owner")
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||||
|
{
|
||||||
|
"mode": "screen_recording",
|
||||||
|
"options": {"encoding": {"resolution": "720p", "profile": "talking_heads"}},
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_recording_persists_resolved_encoding(
|
||||||
|
settings, mock_worker_service_factory, mock_worker_manager
|
||||||
|
):
|
||||||
|
"""The resolved encoding should be persisted in recording.options alongside
|
||||||
|
the requested resolution/profile for traceability."""
|
||||||
|
settings.RECORDING_ENABLE = True
|
||||||
|
room = RoomFactory()
|
||||||
|
user = UserFactory()
|
||||||
|
room.accesses.create(user=user, role="owner")
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||||
|
{
|
||||||
|
"mode": "screen_recording",
|
||||||
|
"options": {"encoding": {"resolution": "720p", "profile": "talking_heads"}},
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
recording = Recording.objects.get(room=room)
|
||||||
|
assert recording.options["encoding"] == {
|
||||||
|
"resolution": "720p",
|
||||||
|
"profile": "talking_heads",
|
||||||
|
"resolved": {
|
||||||
|
"key_frame_interval": settings.RECORDING_ENCODING_KEY_FRAME_INTERVAL_S,
|
||||||
|
"width": 1280,
|
||||||
|
"height": 720,
|
||||||
|
"framerate": 15,
|
||||||
|
"video_bitrate": 700,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_recording_forwards_resolved_encoding_to_worker(
|
||||||
|
settings, mock_worker_service, mock_worker_service_factory
|
||||||
|
):
|
||||||
|
"""The resolved encoding should passed on to the worker."""
|
||||||
|
settings.RECORDING_ENABLE = True
|
||||||
|
room = RoomFactory()
|
||||||
|
user = UserFactory()
|
||||||
|
room.accesses.create(user=user, role="owner")
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
mock_worker_service.start.return_value = "egress-123"
|
||||||
|
|
||||||
|
with mock.patch("core.utils.update_room_metadata"):
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||||
|
{
|
||||||
|
"mode": "screen_recording",
|
||||||
|
"options": {
|
||||||
|
"encoding": {"resolution": "720p", "profile": "talking_heads"}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
recording = Recording.objects.get(room=room)
|
||||||
|
mock_worker_service.start.assert_called_once_with(
|
||||||
|
str(room.id),
|
||||||
|
recording.id,
|
||||||
|
encoding_options=recording.options["encoding"]["resolved"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_recording_options_encoding_invalid_resolution(settings):
|
||||||
|
"""Should reject invalid encoding resolution values."""
|
||||||
|
settings.RECORDING_ENABLE = True
|
||||||
|
room = RoomFactory()
|
||||||
|
user = UserFactory()
|
||||||
|
room.accesses.create(user=user, role="owner")
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||||
|
{"mode": "screen_recording", "options": {"encoding": {"resolution": "4K"}}},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_recording_options_encoding_unknown_key_rejected(settings):
|
||||||
|
"""Should reject unknown keys in encoding configuration."""
|
||||||
|
settings.RECORDING_ENABLE = True
|
||||||
|
room = RoomFactory()
|
||||||
|
user = UserFactory()
|
||||||
|
room.accesses.create(user=user, role="owner")
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||||
|
{
|
||||||
|
"mode": "screen_recording",
|
||||||
|
"options": {"encoding": {"bitrate": 9000}},
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_recording_options_without_encoding_unchanged(
|
||||||
|
settings, mock_worker_service_factory, mock_worker_manager
|
||||||
|
):
|
||||||
|
"""Requests without encoding should keep existing options behavior."""
|
||||||
|
settings.RECORDING_ENABLE = True
|
||||||
|
room = RoomFactory()
|
||||||
|
user = UserFactory()
|
||||||
|
room.accesses.create(user=user, role="owner")
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||||
|
{"mode": "screen_recording", "options": {"language": "fr"}},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
recording = Recording.objects.get(room=room)
|
||||||
|
assert recording.options == {"language": "fr"}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("value", ["foo", 12])
|
@pytest.mark.parametrize("value", ["foo", 12])
|
||||||
def test_start_recording_options_invalid_transcribe_type(settings, value):
|
def test_start_recording_options_invalid_transcribe_type(settings, value):
|
||||||
"""Should reject non-boolean transcribe values."""
|
"""Should reject non-boolean transcribe values."""
|
||||||
|
|||||||
@@ -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"]))],
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ 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,
|
||||||
@@ -22,7 +20,7 @@ def test_delete_room_calls_livekit(mock_create_livekit_client):
|
|||||||
mock_api.aclose = mock.AsyncMock()
|
mock_api.aclose = mock.AsyncMock()
|
||||||
mock_create_livekit_client.return_value = mock_api
|
mock_create_livekit_client.return_value = mock_api
|
||||||
|
|
||||||
RoomManagement.delete_room("room-abc")
|
RoomManagement().delete_room("room-abc")
|
||||||
|
|
||||||
mock_api.room.delete_room.assert_awaited_once()
|
mock_api.room.delete_room.assert_awaited_once()
|
||||||
request = mock_api.room.delete_room.await_args.args[0]
|
request = mock_api.room.delete_room.await_args.args[0]
|
||||||
@@ -41,7 +39,7 @@ def test_delete_room_raises_not_found(mock_create_livekit_client):
|
|||||||
mock_create_livekit_client.return_value = mock_api
|
mock_create_livekit_client.return_value = mock_api
|
||||||
|
|
||||||
with pytest.raises(RoomNotFoundException):
|
with pytest.raises(RoomNotFoundException):
|
||||||
RoomManagement.delete_room("missing-room")
|
RoomManagement().delete_room("missing-room")
|
||||||
|
|
||||||
mock_api.aclose.assert_awaited_once()
|
mock_api.aclose.assert_awaited_once()
|
||||||
|
|
||||||
@@ -57,25 +55,6 @@ def test_delete_room_raises_management_exception(mock_create_livekit_client):
|
|||||||
mock_create_livekit_client.return_value = mock_api
|
mock_create_livekit_client.return_value = mock_api
|
||||||
|
|
||||||
with pytest.raises(RoomManagementException):
|
with pytest.raises(RoomManagementException):
|
||||||
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},
|
|
||||||
)
|
|
||||||
|
|
||||||
RoomManagement.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)
|
||||||
|
|||||||
@@ -129,15 +129,6 @@ class Base(Configuration):
|
|||||||
MEDIA_BASE_URL = values.Value(
|
MEDIA_BASE_URL = values.Value(
|
||||||
"", environ_name="MEDIA_BASE_URL", environ_prefix=None
|
"", environ_name="MEDIA_BASE_URL", environ_prefix=None
|
||||||
)
|
)
|
||||||
# Header the reverse proxy uses to pass the original request URL to the
|
|
||||||
# media-auth subrequest views. nginx-ingress sends X-Original-URL, which is
|
|
||||||
# the default. Other proxies use different headers -- Traefik's ForwardAuth,
|
|
||||||
# for instance, sends X-Forwarded-Uri and cannot emit X-Original-URL at all.
|
|
||||||
MEDIA_AUTH_ORIGINAL_URL_HEADER = values.Value(
|
|
||||||
default="HTTP_X_ORIGINAL_URL",
|
|
||||||
environ_name="MEDIA_AUTH_ORIGINAL_URL_HEADER",
|
|
||||||
environ_prefix=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
SITE_ID = 1
|
SITE_ID = 1
|
||||||
|
|
||||||
@@ -756,26 +747,63 @@ class Base(Configuration):
|
|||||||
# recordings), whose request never carries advanced EncodingOptions.
|
# recordings), whose request never carries advanced EncodingOptions.
|
||||||
# When disabled, LiveKit falls back to its built-in H264_720P_30 preset
|
# When disabled, LiveKit falls back to its built-in H264_720P_30 preset
|
||||||
# (1280x720, 30 fps, 3000 kbps H.264 MAIN video, 128 kbps AAC audio).
|
# (1280x720, 30 fps, 3000 kbps H.264 MAIN video, 128 kbps AAC audio).
|
||||||
# When enabled, the values below are passed to LiveKit as EncodingOptions
|
# When enabled, the encoding parameters are resolved from the default profile
|
||||||
# (advanced) and replace the preset. Lowering framerate and bitrate reduces
|
# and resolution below and passed to LiveKit as EncodingOptions (advanced),
|
||||||
# output file size and CPU load on the egress worker.
|
# replacing the preset. Lowering framerate and bitrate reduces output file
|
||||||
|
# size and CPU load on the egress worker.
|
||||||
RECORDING_ENCODING_ENABLED = values.BooleanValue(
|
RECORDING_ENCODING_ENABLED = values.BooleanValue(
|
||||||
False, environ_name="RECORDING_ENCODING_ENABLED", environ_prefix=None
|
False, environ_name="RECORDING_ENCODING_ENABLED", environ_prefix=None
|
||||||
)
|
)
|
||||||
RECORDING_ENCODING_WIDTH = values.PositiveIntegerValue(
|
|
||||||
1280, environ_name="RECORDING_ENCODING_WIDTH", environ_prefix=None
|
# Map resolution name -> {"width": ..., "height": ...} in pixels.
|
||||||
)
|
RECORDING_ENCODING_AVAILABLE_RESOLUTIONS = values.DictValue(
|
||||||
RECORDING_ENCODING_HEIGHT = values.PositiveIntegerValue(
|
{
|
||||||
720, environ_name="RECORDING_ENCODING_HEIGHT", environ_prefix=None
|
"540p": {"width": 960, "height": 540},
|
||||||
)
|
"720p": {"width": 1280, "height": 720},
|
||||||
RECORDING_ENCODING_FRAMERATE = values.PositiveIntegerValue(
|
"1080p": {"width": 1920, "height": 1080},
|
||||||
30, environ_name="RECORDING_ENCODING_FRAMERATE", environ_prefix=None
|
},
|
||||||
)
|
environ_name="RECORDING_ENCODING_AVAILABLE_RESOLUTIONS",
|
||||||
RECORDING_ENCODING_VIDEO_BITRATE_KBPS = values.PositiveIntegerValue(
|
|
||||||
3000,
|
|
||||||
environ_name="RECORDING_ENCODING_VIDEO_BITRATE_KBPS",
|
|
||||||
environ_prefix=None,
|
environ_prefix=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Bitrate scales with resolution so quality stays consistent across sizes.
|
||||||
|
RECORDING_ENCODING_AVAILABLE_PROFILES = values.DictValue(
|
||||||
|
{
|
||||||
|
"talking_heads": {
|
||||||
|
"fps": 15,
|
||||||
|
"kbps": {"540p": 400, "720p": 700, "1080p": 1200},
|
||||||
|
},
|
||||||
|
"text": {
|
||||||
|
"fps": 15,
|
||||||
|
"kbps": {"540p": 600, "720p": 1000, "1080p": 1800},
|
||||||
|
},
|
||||||
|
"mixed": {
|
||||||
|
"fps": 20,
|
||||||
|
"kbps": {"540p": 900, "720p": 1500, "1080p": 2500},
|
||||||
|
},
|
||||||
|
"full": {
|
||||||
|
"fps": 30,
|
||||||
|
"kbps": {"540p": 2000, "720p": 3000, "1080p": 4500},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
environ_name="RECORDING_ENCODING_AVAILABLE_PROFILES",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Defaults used when no profile/resolution is specified per recording.
|
||||||
|
# Must be keys of the two dicts above (validated at startup).
|
||||||
|
RECORDING_ENCODING_DEFAULT_PROFILE = values.Value(
|
||||||
|
"full",
|
||||||
|
environ_name="RECORDING_ENCODING_DEFAULT_PROFILE",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
RECORDING_ENCODING_DEFAULT_RESOLUTION = values.Value(
|
||||||
|
"720p",
|
||||||
|
environ_name="RECORDING_ENCODING_DEFAULT_RESOLUTION",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Settings independent of profile/resolution.
|
||||||
RECORDING_ENCODING_AUDIO_BITRATE_KBPS = values.PositiveIntegerValue(
|
RECORDING_ENCODING_AUDIO_BITRATE_KBPS = values.PositiveIntegerValue(
|
||||||
128,
|
128,
|
||||||
environ_name="RECORDING_ENCODING_AUDIO_BITRATE_KBPS",
|
environ_name="RECORDING_ENCODING_AUDIO_BITRATE_KBPS",
|
||||||
@@ -790,6 +818,7 @@ class Base(Configuration):
|
|||||||
SUMMARY_SERVICE_VERSION = values.PositiveIntegerValue(
|
SUMMARY_SERVICE_VERSION = values.PositiveIntegerValue(
|
||||||
1, environ_name="SUMMARY_SERVICE_VERSION", environ_prefix=None
|
1, environ_name="SUMMARY_SERVICE_VERSION", environ_prefix=None
|
||||||
)
|
)
|
||||||
|
|
||||||
SUMMARY_SERVICE_ENDPOINT = values.Value(
|
SUMMARY_SERVICE_ENDPOINT = values.Value(
|
||||||
None, environ_name="SUMMARY_SERVICE_ENDPOINT", environ_prefix=None
|
None, environ_name="SUMMARY_SERVICE_ENDPOINT", environ_prefix=None
|
||||||
)
|
)
|
||||||
@@ -1178,6 +1207,49 @@ class Base(Configuration):
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _check_recording_encoding_maps(cls):
|
||||||
|
"""Ensure the per-recording encoding maps are mutually consistent.
|
||||||
|
|
||||||
|
Every profile in RECORDING_ENCODING_AVAILABLE_PROFILES must declare an fps and
|
||||||
|
a bitrate for each resolution declared in RECORDING_ENCODING_AVAILABLE_RESOLUTIONS,
|
||||||
|
and each non-empty default must name an entry of its map.
|
||||||
|
"""
|
||||||
|
# DictValue resolves to a dict at runtime; pylint sees the descriptor.
|
||||||
|
# pylint: disable=no-member
|
||||||
|
resolutions = set(cls.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS)
|
||||||
|
for profile, spec in cls.RECORDING_ENCODING_AVAILABLE_PROFILES.items():
|
||||||
|
if "fps" not in spec or "kbps" not in spec:
|
||||||
|
raise ValueError(
|
||||||
|
f"Profile '{profile}' in RECORDING_ENCODING_AVAILABLE_PROFILES must "
|
||||||
|
"define both 'fps' and 'kbps'."
|
||||||
|
)
|
||||||
|
|
||||||
|
profile_resolutions = set(spec["kbps"])
|
||||||
|
if profile_resolutions != resolutions:
|
||||||
|
raise ValueError(
|
||||||
|
f"Profile '{profile}' in RECORDING_ENCODING_AVAILABLE_PROFILES must "
|
||||||
|
"define a bitrate for exactly the resolutions in "
|
||||||
|
"RECORDING_ENCODING_AVAILABLE_RESOLUTIONS, mismatch on: "
|
||||||
|
f"{resolutions ^ profile_resolutions}"
|
||||||
|
)
|
||||||
|
|
||||||
|
default_resolution = cls.RECORDING_ENCODING_DEFAULT_RESOLUTION
|
||||||
|
if default_resolution and default_resolution not in resolutions:
|
||||||
|
raise ValueError(
|
||||||
|
f"RECORDING_ENCODING_DEFAULT_RESOLUTION '{default_resolution}' is not a "
|
||||||
|
"key of RECORDING_ENCODING_AVAILABLE_RESOLUTIONS, choose from "
|
||||||
|
f"{sorted(resolutions)}."
|
||||||
|
)
|
||||||
|
|
||||||
|
profiles = set(cls.RECORDING_ENCODING_AVAILABLE_PROFILES)
|
||||||
|
default_profile = cls.RECORDING_ENCODING_DEFAULT_PROFILE
|
||||||
|
if default_profile and default_profile not in profiles:
|
||||||
|
raise ValueError(
|
||||||
|
f"RECORDING_ENCODING_DEFAULT_PROFILE '{default_profile}' is not a key of "
|
||||||
|
f"RECORDING_ENCODING_AVAILABLE_PROFILES, choose from {sorted(profiles)}."
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def post_setup(cls):
|
def post_setup(cls):
|
||||||
"""Post setup configuration.
|
"""Post setup configuration.
|
||||||
@@ -1191,6 +1263,8 @@ class Base(Configuration):
|
|||||||
"FILE_UPLOAD_TMP_PATH cannot be the same as FILE_UPLOAD_PATH"
|
"FILE_UPLOAD_TMP_PATH cannot be the same as FILE_UPLOAD_PATH"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
cls._check_recording_encoding_maps()
|
||||||
|
|
||||||
if (
|
if (
|
||||||
cls.SUMMARY_SERVICE_VERSION == 1
|
cls.SUMMARY_SERVICE_VERSION == 1
|
||||||
and cls.SUMMARY_SERVICE_ENDPOINT is not None
|
and cls.SUMMARY_SERVICE_ENDPOINT is not None
|
||||||
|
|||||||
@@ -53,7 +53,6 @@ RUN apk update && apk upgrade \
|
|||||||
musl \
|
musl \
|
||||||
musl-utils \
|
musl-utils \
|
||||||
zlib>=1.3.2-r0 \
|
zlib>=1.3.2-r0 \
|
||||||
libexpat>=2.8.4-r0 \
|
|
||||||
&& apk del curl
|
&& apk del curl
|
||||||
|
|
||||||
USER nginx
|
USER nginx
|
||||||
|
|||||||
@@ -1,595 +0,0 @@
|
|||||||
import { CSSProperties, useState } from 'react'
|
|
||||||
import { useConnectionState, useRoomContext } from '@livekit/components-react'
|
|
||||||
import { ConnectionState } from 'livekit-client'
|
|
||||||
import { StatsSnapshot, TrackRow, useWebRTCStats } from './useWebRTCStats'
|
|
||||||
import { readRoomConfig } from './roomConfig'
|
|
||||||
import {
|
|
||||||
forceTransport,
|
|
||||||
releaseForcedTransport,
|
|
||||||
Scenario,
|
|
||||||
SCENARIOS,
|
|
||||||
setDownlinkCap,
|
|
||||||
setUplinkCap,
|
|
||||||
stepBitrate,
|
|
||||||
TransportMode,
|
|
||||||
transportModeFromRoute,
|
|
||||||
} from './simulation'
|
|
||||||
|
|
||||||
const SANS =
|
|
||||||
"-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif"
|
|
||||||
const MONO = 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace'
|
|
||||||
|
|
||||||
const COLOR = {
|
|
||||||
text: '#e8e8e8',
|
|
||||||
muted: '#9a9a9a',
|
|
||||||
faint: '#6f6f6f',
|
|
||||||
hairline: '#2c2d31',
|
|
||||||
border: '#3d3e44',
|
|
||||||
surface: '#1b1c1e',
|
|
||||||
chartBg: '#141517',
|
|
||||||
down: '#f6821f',
|
|
||||||
up: '#5a9cf8',
|
|
||||||
ok: '#10b981',
|
|
||||||
bad: '#f05a4a',
|
|
||||||
busy: '#e5a13c',
|
|
||||||
}
|
|
||||||
|
|
||||||
const stateColor = (state: ConnectionState): string => {
|
|
||||||
switch (state) {
|
|
||||||
case ConnectionState.Connected:
|
|
||||||
return COLOR.ok
|
|
||||||
case ConnectionState.Reconnecting:
|
|
||||||
case ConnectionState.SignalReconnecting:
|
|
||||||
return COLOR.busy
|
|
||||||
case ConnectionState.Connecting:
|
|
||||||
return COLOR.up
|
|
||||||
case ConnectionState.Disconnected:
|
|
||||||
return COLOR.bad
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles: Record<string, CSSProperties> = {
|
|
||||||
toggle: {
|
|
||||||
position: 'fixed',
|
|
||||||
bottom: 12,
|
|
||||||
right: 12,
|
|
||||||
zIndex: 9999,
|
|
||||||
fontFamily: SANS,
|
|
||||||
fontSize: 12,
|
|
||||||
lineHeight: 1,
|
|
||||||
padding: '8px 12px',
|
|
||||||
borderRadius: 999,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderStyle: 'solid',
|
|
||||||
borderColor: COLOR.border,
|
|
||||||
background: COLOR.surface,
|
|
||||||
color: COLOR.text,
|
|
||||||
cursor: 'pointer',
|
|
||||||
boxShadow: '0 2px 10px #0006',
|
|
||||||
},
|
|
||||||
panel: {
|
|
||||||
position: 'fixed',
|
|
||||||
bottom: 12,
|
|
||||||
right: 12,
|
|
||||||
zIndex: 9999,
|
|
||||||
width: 460,
|
|
||||||
maxWidth: 'calc(100vw - 24px)',
|
|
||||||
maxHeight: 'calc(100vh - 24px)',
|
|
||||||
overflowY: 'auto',
|
|
||||||
fontFamily: SANS,
|
|
||||||
fontSize: 11,
|
|
||||||
lineHeight: 1.5,
|
|
||||||
color: COLOR.text,
|
|
||||||
background: COLOR.surface,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderStyle: 'solid',
|
|
||||||
borderColor: COLOR.border,
|
|
||||||
borderRadius: 10,
|
|
||||||
padding: 14,
|
|
||||||
boxShadow: '0 10px 34px #00000080',
|
|
||||||
},
|
|
||||||
sectionTitle: {
|
|
||||||
margin: '14px 0 5px',
|
|
||||||
color: COLOR.faint,
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
letterSpacing: 1.2,
|
|
||||||
fontSize: 9,
|
|
||||||
fontWeight: 600,
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
},
|
|
||||||
metricsLine: {
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 16,
|
|
||||||
margin: '0 0 10px',
|
|
||||||
paddingBottom: 10,
|
|
||||||
borderBottom: `1px solid ${COLOR.hairline}`,
|
|
||||||
fontVariantNumeric: 'tabular-nums',
|
|
||||||
},
|
|
||||||
legend: {
|
|
||||||
display: 'flex',
|
|
||||||
gap: 12,
|
|
||||||
marginTop: 3,
|
|
||||||
color: COLOR.faint,
|
|
||||||
fontSize: 10,
|
|
||||||
},
|
|
||||||
list: {
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
maxHeight: '400px',
|
|
||||||
overflowY: 'auto',
|
|
||||||
},
|
|
||||||
configList: { display: 'flex', flexWrap: 'wrap' },
|
|
||||||
configRow: {
|
|
||||||
display: 'flex',
|
|
||||||
gap: 6,
|
|
||||||
flex: '0 0 50%',
|
|
||||||
minWidth: 0,
|
|
||||||
boxSizing: 'border-box',
|
|
||||||
padding: '1px 8px 1px 0',
|
|
||||||
},
|
|
||||||
configLabel: {
|
|
||||||
color: COLOR.muted,
|
|
||||||
flex: '0 0 45%',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
},
|
|
||||||
configValue: {
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 0,
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
},
|
|
||||||
trackRow: {
|
|
||||||
display: 'flex',
|
|
||||||
gap: 8,
|
|
||||||
padding: '3px 0',
|
|
||||||
borderBottom: `1px solid ${COLOR.hairline}`,
|
|
||||||
alignItems: 'baseline',
|
|
||||||
},
|
|
||||||
trackLabel: {
|
|
||||||
flex: '0 0 34%',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
},
|
|
||||||
trackCodec: {
|
|
||||||
flex: '0 0 11%',
|
|
||||||
fontFamily: MONO,
|
|
||||||
fontSize: 10,
|
|
||||||
color: COLOR.muted,
|
|
||||||
},
|
|
||||||
trackKbps: {
|
|
||||||
flex: '0 0 9%',
|
|
||||||
textAlign: 'right',
|
|
||||||
fontVariantNumeric: 'tabular-nums',
|
|
||||||
},
|
|
||||||
trackDetail: {
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 0,
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
},
|
|
||||||
mono: { fontFamily: MONO, fontSize: 10, color: COLOR.muted },
|
|
||||||
button: {
|
|
||||||
fontFamily: SANS,
|
|
||||||
fontSize: 11,
|
|
||||||
padding: '3px 9px',
|
|
||||||
borderRadius: 5,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderStyle: 'solid',
|
|
||||||
borderColor: COLOR.border,
|
|
||||||
background: '#232428',
|
|
||||||
color: COLOR.text,
|
|
||||||
cursor: 'pointer',
|
|
||||||
},
|
|
||||||
buttonActive: { borderColor: COLOR.down, color: COLOR.down },
|
|
||||||
buttonDisabled: { color: COLOR.faint, cursor: 'not-allowed' },
|
|
||||||
row: { display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' },
|
|
||||||
stepperValue: {
|
|
||||||
minWidth: 92,
|
|
||||||
textAlign: 'center',
|
|
||||||
borderWidth: 1,
|
|
||||||
borderStyle: 'solid',
|
|
||||||
borderColor: COLOR.hairline,
|
|
||||||
borderRadius: 4,
|
|
||||||
padding: '2px 6px',
|
|
||||||
background: COLOR.chartBg,
|
|
||||||
fontVariantNumeric: 'tabular-nums',
|
|
||||||
},
|
|
||||||
close: {
|
|
||||||
background: 'none',
|
|
||||||
border: 'none',
|
|
||||||
color: COLOR.faint,
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontSize: 14,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const Sparkline = ({ history }: { history: StatsSnapshot[] }) => {
|
|
||||||
const width = 430
|
|
||||||
const height = 54
|
|
||||||
const max = Math.max(...history.map((s) => Math.max(s.upKbps, s.downKbps)), 1)
|
|
||||||
const points = (pick: (s: StatsSnapshot) => number) =>
|
|
||||||
history
|
|
||||||
.map(
|
|
||||||
(s, i) =>
|
|
||||||
`${((i / Math.max(history.length - 1, 1)) * width).toFixed(1)},${(
|
|
||||||
height -
|
|
||||||
(pick(s) / max) * (height - 6) -
|
|
||||||
3
|
|
||||||
).toFixed(1)}`
|
|
||||||
)
|
|
||||||
.join(' ')
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<svg
|
|
||||||
width={width}
|
|
||||||
height={height}
|
|
||||||
role="img"
|
|
||||||
aria-label="Bandwidth over the last minute"
|
|
||||||
style={{
|
|
||||||
display: 'block',
|
|
||||||
background: COLOR.chartBg,
|
|
||||||
borderRadius: 6,
|
|
||||||
border: `1px solid ${COLOR.hairline}`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{history.length >= 2 && (
|
|
||||||
<>
|
|
||||||
<polyline
|
|
||||||
points={points((s) => s.downKbps)}
|
|
||||||
fill="none"
|
|
||||||
stroke={COLOR.down}
|
|
||||||
strokeWidth="1.5"
|
|
||||||
/>
|
|
||||||
<polyline
|
|
||||||
points={points((s) => s.upKbps)}
|
|
||||||
fill="none"
|
|
||||||
stroke={COLOR.up}
|
|
||||||
strokeWidth="1.5"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</svg>
|
|
||||||
{/* Legend outside the plot: text over the lines was unreadable. */}
|
|
||||||
<div style={styles.legend}>
|
|
||||||
<span>
|
|
||||||
<span style={{ color: COLOR.down }}>●</span> down
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
<span style={{ color: COLOR.up }}>●</span> up
|
|
||||||
</span>
|
|
||||||
<span>last 60s · max {Math.round(max)} kbps</span>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const CapStepper = ({
|
|
||||||
label,
|
|
||||||
hint,
|
|
||||||
valueKbps,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
label: string
|
|
||||||
hint: string
|
|
||||||
valueKbps: number | null
|
|
||||||
onChange: (kbps: number | null) => void
|
|
||||||
}) => (
|
|
||||||
<div style={styles.row}>
|
|
||||||
<span style={{ flex: '0 0 70px' }}>{label}</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
style={styles.button}
|
|
||||||
aria-label={`Decrease ${label} bandwidth`}
|
|
||||||
onClick={() => onChange(stepBitrate(valueKbps, 'decrease'))}
|
|
||||||
>
|
|
||||||
−
|
|
||||||
</button>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
...styles.stepperValue,
|
|
||||||
...(valueKbps !== null ? { color: COLOR.down } : {}),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{valueKbps === null ? 'unlimited' : `≤ ${valueKbps} kbps`}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
style={styles.button}
|
|
||||||
aria-label={`Increase ${label} bandwidth`}
|
|
||||||
onClick={() => onChange(stepBitrate(valueKbps, 'increase'))}
|
|
||||||
>
|
|
||||||
+
|
|
||||||
</button>
|
|
||||||
<span style={styles.mono}>{hint}</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
const TrackList = ({ title, rows }: { title: string; rows: TrackRow[] }) => (
|
|
||||||
<>
|
|
||||||
<div style={styles.sectionTitle}>
|
|
||||||
<span>
|
|
||||||
{title} ({rows.length})
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
{Math.round(rows.reduce((sum, t) => sum + t.kbps, 0))} kbps media
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div style={styles.list}>
|
|
||||||
{rows.map((track) => (
|
|
||||||
<div key={track.key} style={styles.trackRow}>
|
|
||||||
<span style={styles.trackLabel}>{track.label}</span>
|
|
||||||
<span style={styles.trackCodec}>{track.codec ?? '–'}</span>
|
|
||||||
<span style={styles.trackKbps}>{Math.round(track.kbps)}</span>
|
|
||||||
<span style={styles.trackDetail}>
|
|
||||||
{[
|
|
||||||
track.res,
|
|
||||||
track.fps !== undefined && `${Math.round(track.fps)}fps`,
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(' · ')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{rows.length === 0 && <span style={{ color: COLOR.faint }}>none</span>}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
|
|
||||||
const MeetDevtools = () => {
|
|
||||||
const room = useRoomContext()
|
|
||||||
const connState = useConnectionState(room)
|
|
||||||
const [open, setOpen] = useState(false)
|
|
||||||
const [firedScenario, setFiredScenario] = useState<string>()
|
|
||||||
const [uplinkCap, setUplinkCapState] = useState<number | null>(null)
|
|
||||||
const [downlinkCap, setDownlinkCapState] = useState<number | null>(null)
|
|
||||||
const { snapshot, history } = useWebRTCStats(room, open)
|
|
||||||
|
|
||||||
const transportMode: TransportMode = transportModeFromRoute(snapshot?.route)
|
|
||||||
|
|
||||||
const fireScenario = (scenario: Scenario) => {
|
|
||||||
setFiredScenario(scenario.id)
|
|
||||||
scenario.run(room).catch((e) => {
|
|
||||||
console.warn('[MeetDevtools] simulateScenario failed', e)
|
|
||||||
})
|
|
||||||
window.setTimeout(
|
|
||||||
() =>
|
|
||||||
setFiredScenario((current) =>
|
|
||||||
current === scenario.id ? undefined : current
|
|
||||||
),
|
|
||||||
1200
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!open) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
style={styles.toggle}
|
|
||||||
onClick={() => setOpen(true)}
|
|
||||||
aria-label="Open WebRTC devtools"
|
|
||||||
>
|
|
||||||
<span style={{ color: stateColor(connState) }}>●</span> rtc
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const config = readRoomConfig(room)
|
|
||||||
const published = snapshot?.tracks.filter((t) => t.dir === 'up') ?? []
|
|
||||||
const subscribed = snapshot?.tracks.filter((t) => t.dir === 'down') ?? []
|
|
||||||
const turnProtocols = snapshot?.turnProtocols ?? []
|
|
||||||
const transports: Array<{
|
|
||||||
mode: TransportMode
|
|
||||||
label: string
|
|
||||||
requires?: string
|
|
||||||
title: string
|
|
||||||
}> = [
|
|
||||||
{
|
|
||||||
mode: 'auto',
|
|
||||||
label: 'auto (udp)',
|
|
||||||
title:
|
|
||||||
'clears the server-cached transport preference and relay-only policy, then full reconnect',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
mode: 'tcp',
|
|
||||||
label: 'tcp',
|
|
||||||
title: 'force-tcp — server prefers TCP candidates (ICE/TCP)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
mode: 'turn-udp',
|
|
||||||
label: 'turn:udp',
|
|
||||||
requires: 'udp',
|
|
||||||
title:
|
|
||||||
'client-side: iceTransportPolicy relay + full reconnect (no server hook exists)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
mode: 'turn-tcp',
|
|
||||||
label: 'turn:tcp',
|
|
||||||
requires: 'tcp',
|
|
||||||
title: 'force-tcp — lands on TURN/TCP when it is the TCP path',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
mode: 'turn-tls',
|
|
||||||
label: 'turn:tls',
|
|
||||||
requires: 'tls',
|
|
||||||
title: 'force-tls — server switches you to TURN over TLS',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section style={styles.panel} aria-label="WebRTC devtools">
|
|
||||||
<div style={styles.metricsLine}>
|
|
||||||
<span>
|
|
||||||
<span style={{ color: COLOR.down }}>↓</span> {snapshot?.downKbps ?? 0}{' '}
|
|
||||||
kbps
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
<span style={{ color: COLOR.up }}>↑</span> {snapshot?.upKbps ?? 0}{' '}
|
|
||||||
kbps
|
|
||||||
</span>
|
|
||||||
<span style={{ color: COLOR.muted }}>
|
|
||||||
rtt{' '}
|
|
||||||
<span style={{ color: COLOR.text }}>{snapshot?.rttMs ?? '–'}</span> ms
|
|
||||||
</span>
|
|
||||||
<span style={{ color: COLOR.muted }}>
|
|
||||||
jitter{' '}
|
|
||||||
<span style={{ color: COLOR.text }}>{snapshot?.jitterMs ?? '–'}</span>{' '}
|
|
||||||
ms
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
color: stateColor(connState),
|
|
||||||
fontSize: 10,
|
|
||||||
marginLeft: 'auto',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
● {connState}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
style={styles.close}
|
|
||||||
onClick={() => setOpen(false)}
|
|
||||||
aria-label="Close WebRTC devtools"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Sparkline history={history} />
|
|
||||||
|
|
||||||
<div style={styles.sectionTitle}>room configuration (live)</div>
|
|
||||||
<div style={styles.configList}>
|
|
||||||
{config.map((entry) => (
|
|
||||||
<div key={entry.label} style={styles.configRow}>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
color:
|
|
||||||
entry.on === undefined
|
|
||||||
? COLOR.faint
|
|
||||||
: entry.on
|
|
||||||
? COLOR.ok
|
|
||||||
: COLOR.bad,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
●
|
|
||||||
</span>
|
|
||||||
<span style={styles.configLabel}>{entry.label}</span>
|
|
||||||
<span style={styles.configValue} title={entry.value}>
|
|
||||||
{entry.value}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TrackList title="published ↑" rows={published} />
|
|
||||||
<TrackList title="subscribed ↓" rows={subscribed} />
|
|
||||||
|
|
||||||
<div style={styles.sectionTitle}>bandwidth</div>
|
|
||||||
<div style={{ ...styles.list, gap: 4 }}>
|
|
||||||
<CapStepper
|
|
||||||
label="uplink"
|
|
||||||
hint="encoder cap (setParameters)"
|
|
||||||
valueKbps={uplinkCap}
|
|
||||||
onChange={(kbps) => {
|
|
||||||
setUplinkCapState(kbps)
|
|
||||||
void setUplinkCap(room, kbps)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<CapStepper
|
|
||||||
label="downlink"
|
|
||||||
hint="SFU limit (subscriber-bandwidth)"
|
|
||||||
valueKbps={downlinkCap}
|
|
||||||
onChange={(kbps) => {
|
|
||||||
setDownlinkCapState(kbps)
|
|
||||||
void setDownlinkCap(room, kbps).catch((e) =>
|
|
||||||
console.warn('[MeetDevtools] downlink cap failed', e)
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={styles.sectionTitle}>transport</div>
|
|
||||||
<div style={styles.row}>
|
|
||||||
{transports.map(({ mode, label, requires, title }) => {
|
|
||||||
const available = !requires || turnProtocols.includes(requires)
|
|
||||||
const active = transportMode === mode
|
|
||||||
const clickable = available && !active
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={mode}
|
|
||||||
type="button"
|
|
||||||
aria-pressed={active}
|
|
||||||
disabled={!clickable}
|
|
||||||
title={available ? title : `${title} — not configured`}
|
|
||||||
style={{
|
|
||||||
...styles.button,
|
|
||||||
...(clickable || active ? {} : styles.buttonDisabled),
|
|
||||||
...(active ? styles.buttonActive : {}),
|
|
||||||
}}
|
|
||||||
onClick={() => {
|
|
||||||
if (!clickable) return
|
|
||||||
void (
|
|
||||||
mode === 'auto'
|
|
||||||
? releaseForcedTransport(room)
|
|
||||||
: forceTransport(
|
|
||||||
room,
|
|
||||||
mode as 'tcp' | 'turn-udp' | 'turn-tcp' | 'turn-tls'
|
|
||||||
)
|
|
||||||
).catch((e) =>
|
|
||||||
console.warn('[MeetDevtools] transport change failed', e)
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{requires && (
|
|
||||||
<span style={{ color: available ? COLOR.ok : COLOR.faint }}>
|
|
||||||
●{' '}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
<span style={styles.mono}>
|
|
||||||
route:{' '}
|
|
||||||
{snapshot?.route
|
|
||||||
? [
|
|
||||||
snapshot.route.protocol,
|
|
||||||
snapshot.route.type,
|
|
||||||
snapshot.route.relayProtocol &&
|
|
||||||
`relay:${snapshot.route.relayProtocol}`,
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join('·')
|
|
||||||
: '–'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={styles.sectionTitle}>connection scenarios</div>
|
|
||||||
<div style={styles.row}>
|
|
||||||
{SCENARIOS.map((scenario) => (
|
|
||||||
<button
|
|
||||||
key={scenario.id}
|
|
||||||
type="button"
|
|
||||||
title={`${scenario.side}-side simulation — momentary, watch the state dot`}
|
|
||||||
style={{
|
|
||||||
...styles.button,
|
|
||||||
...(firedScenario === scenario.id ? styles.buttonActive : {}),
|
|
||||||
}}
|
|
||||||
onClick={() => fireScenario(scenario)}
|
|
||||||
>
|
|
||||||
{scenario.side === 'server' ? '☁ ' : ''}
|
|
||||||
{scenario.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default MeetDevtools
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import { lazy, Suspense } from 'react'
|
|
||||||
|
|
||||||
const LazyPanel = import.meta.env.DEV
|
|
||||||
? lazy(() => import('./MeetDevtools'))
|
|
||||||
: null
|
|
||||||
|
|
||||||
export const MeetDevtools = () => {
|
|
||||||
if (!LazyPanel) return null
|
|
||||||
return (
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<LazyPanel />
|
|
||||||
</Suspense>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
import { Room } from 'livekit-client'
|
|
||||||
|
|
||||||
export type ConfigEntry = {
|
|
||||||
label: string
|
|
||||||
value: string
|
|
||||||
/** true = feature actively on, false = off, undefined = informational */
|
|
||||||
on?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatBackupCodec = (backup: unknown): string => {
|
|
||||||
if (backup === undefined || backup === true) return 'auto'
|
|
||||||
if (backup === false) return 'off'
|
|
||||||
if (typeof backup === 'object' && backup !== null && 'codec' in backup) {
|
|
||||||
return String((backup as { codec: unknown }).codec)
|
|
||||||
}
|
|
||||||
return String(backup)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const readRoomConfig = (room: Room): ConfigEntry[] => {
|
|
||||||
const options = room.options
|
|
||||||
const publish = options.publishDefaults
|
|
||||||
const adaptive = options.adaptiveStream
|
|
||||||
|
|
||||||
const entries: ConfigEntry[] = [
|
|
||||||
{
|
|
||||||
label: 'adaptiveStream',
|
|
||||||
value:
|
|
||||||
typeof adaptive === 'object'
|
|
||||||
? JSON.stringify(adaptive)
|
|
||||||
: String(!!adaptive),
|
|
||||||
on: !!adaptive,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'dynacast',
|
|
||||||
value: String(!!options.dynacast),
|
|
||||||
on: !!options.dynacast,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'e2ee',
|
|
||||||
value: String(room.isE2EEEnabled),
|
|
||||||
on: room.isE2EEEnabled,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'videoCodec',
|
|
||||||
value: publish?.videoCodec ?? 'default',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'backupCodec',
|
|
||||||
value: formatBackupCodec(publish?.backupCodec),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'simulcast',
|
|
||||||
value: String(publish?.simulcast ?? true),
|
|
||||||
on: publish?.simulcast ?? true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'audio dtx',
|
|
||||||
value: String(publish?.dtx ?? true),
|
|
||||||
on: publish?.dtx ?? true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'audio red',
|
|
||||||
value: String(publish?.red ?? true),
|
|
||||||
on: publish?.red ?? true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'quality (local)',
|
|
||||||
value: room.localParticipant.connectionQuality,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const server = room.serverInfo
|
|
||||||
if (server) {
|
|
||||||
entries.push({
|
|
||||||
label: 'server',
|
|
||||||
value: [
|
|
||||||
server.version && `v${server.version}`,
|
|
||||||
server.region,
|
|
||||||
server.protocol !== undefined && `proto ${server.protocol}`,
|
|
||||||
server.edition !== undefined && `edition ${server.edition}`,
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(' · '),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return entries
|
|
||||||
}
|
|
||||||
@@ -1,192 +0,0 @@
|
|||||||
import { Room, Track } from 'livekit-client'
|
|
||||||
// Transitive dependency of livekit-client (pinned by it); used only to
|
|
||||||
// build the one signal request room.simulateScenario cannot express.
|
|
||||||
import { SimulateScenario } from '@livekit/protocol'
|
|
||||||
|
|
||||||
/** Shared bandwidth ladder for the −/+ steppers; null = unlimited. */
|
|
||||||
export const BITRATE_LADDER_KBPS: Array<number | null> = [
|
|
||||||
null,
|
|
||||||
2000,
|
|
||||||
1000,
|
|
||||||
600,
|
|
||||||
300,
|
|
||||||
150,
|
|
||||||
]
|
|
||||||
|
|
||||||
export const stepBitrate = (
|
|
||||||
current: number | null,
|
|
||||||
direction: 'decrease' | 'increase'
|
|
||||||
): number | null => {
|
|
||||||
const index = BITRATE_LADDER_KBPS.indexOf(current)
|
|
||||||
const safeIndex = index === -1 ? 0 : index
|
|
||||||
const next =
|
|
||||||
direction === 'decrease'
|
|
||||||
? Math.min(safeIndex + 1, BITRATE_LADDER_KBPS.length - 1)
|
|
||||||
: Math.max(safeIndex - 1, 0)
|
|
||||||
return BITRATE_LADDER_KBPS[next]
|
|
||||||
}
|
|
||||||
|
|
||||||
const savedEncodings = new WeakMap<RTCRtpSender, Array<number | undefined>>()
|
|
||||||
|
|
||||||
export const setUplinkCap = async (
|
|
||||||
room: Room,
|
|
||||||
kbps: number | null
|
|
||||||
): Promise<void> => {
|
|
||||||
const senders: RTCRtpSender[] = []
|
|
||||||
room.localParticipant.trackPublications.forEach((pub) => {
|
|
||||||
const track = pub.track
|
|
||||||
if (track?.kind === Track.Kind.Video && track.sender) {
|
|
||||||
senders.push(track.sender)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
for (const sender of senders) {
|
|
||||||
const params = sender.getParameters()
|
|
||||||
if (!params.encodings || params.encodings.length === 0) continue
|
|
||||||
|
|
||||||
if (kbps === null) {
|
|
||||||
const original = savedEncodings.get(sender)
|
|
||||||
params.encodings.forEach((encoding, i) => {
|
|
||||||
encoding.maxBitrate = original?.[i]
|
|
||||||
})
|
|
||||||
savedEncodings.delete(sender)
|
|
||||||
} else {
|
|
||||||
if (!savedEncodings.has(sender)) {
|
|
||||||
savedEncodings.set(
|
|
||||||
sender,
|
|
||||||
params.encodings.map((encoding) => encoding.maxBitrate)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
const activeCount =
|
|
||||||
params.encodings.filter((encoding) => encoding.active !== false)
|
|
||||||
.length || 1
|
|
||||||
// Split the budget across active simulcast layers / SVC encoding.
|
|
||||||
const perEncoding = Math.max(
|
|
||||||
30_000,
|
|
||||||
Math.floor((kbps * 1000) / activeCount)
|
|
||||||
)
|
|
||||||
params.encodings.forEach((encoding) => {
|
|
||||||
encoding.maxBitrate = perEncoding
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await sender.setParameters(params)
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('[MeetDevtools] setParameters failed', e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const setDownlinkCap = (
|
|
||||||
room: Room,
|
|
||||||
kbps: number | null
|
|
||||||
): Promise<void> =>
|
|
||||||
room.simulateScenario('subscriber-bandwidth', kbps === null ? 0 : kbps * 1000)
|
|
||||||
|
|
||||||
export type Scenario = {
|
|
||||||
id: string
|
|
||||||
label: string
|
|
||||||
/** where the simulation happens */
|
|
||||||
side: 'client' | 'server'
|
|
||||||
run: (room: Room) => Promise<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SCENARIOS: Scenario[] = [
|
|
||||||
{
|
|
||||||
id: 'resume',
|
|
||||||
label: 'reconnect (resume)',
|
|
||||||
side: 'client',
|
|
||||||
// Replays a signaling WebSocket loss; media keeps flowing, client resumes.
|
|
||||||
run: (room) => room.simulateScenario('signal-reconnect'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'resume-fail',
|
|
||||||
label: 'reconnect (resume fails)',
|
|
||||||
side: 'client',
|
|
||||||
// Same, but the next resume attempt fails → exercises the retry ladder.
|
|
||||||
run: (room) => room.simulateScenario('resume-reconnect'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'full-reconnect',
|
|
||||||
label: 'full reconnect',
|
|
||||||
side: 'client',
|
|
||||||
// Complete rejoin with brand-new peer connections.
|
|
||||||
run: (room) => room.simulateScenario('full-reconnect'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'migration',
|
|
||||||
label: 'server migration',
|
|
||||||
side: 'server',
|
|
||||||
run: (room) => room.simulateScenario('migration'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'node-failure',
|
|
||||||
label: 'SFU node failure',
|
|
||||||
side: 'server',
|
|
||||||
run: (room) => room.simulateScenario('node-failure'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'server-leave',
|
|
||||||
label: 'server disconnect',
|
|
||||||
side: 'server',
|
|
||||||
// Server-initiated leave: the closest thing to "you got kicked".
|
|
||||||
run: (room) => room.simulateScenario('server-leave'),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export type TransportMode =
|
|
||||||
| 'auto'
|
|
||||||
| 'tcp'
|
|
||||||
| 'turn-udp'
|
|
||||||
| 'turn-tcp'
|
|
||||||
| 'turn-tls'
|
|
||||||
|
|
||||||
const clearServerTransportPreference = (room: Room): Promise<void> =>
|
|
||||||
room.engine.client.sendSimulateScenario(
|
|
||||||
new SimulateScenario({
|
|
||||||
scenario: { case: 'switchCandidateProtocol', value: 0 },
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
const setRelayOnly = (room: Room, relay: boolean) => {
|
|
||||||
room.engine.rtcConfig = {
|
|
||||||
...room.engine.rtcConfig,
|
|
||||||
iceTransportPolicy: relay ? 'relay' : 'all',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 300))
|
|
||||||
|
|
||||||
export const forceTransport = async (
|
|
||||||
room: Room,
|
|
||||||
mode: 'tcp' | 'turn-udp' | 'turn-tcp' | 'turn-tls'
|
|
||||||
): Promise<void> => {
|
|
||||||
if (mode === 'turn-udp') {
|
|
||||||
await clearServerTransportPreference(room)
|
|
||||||
setRelayOnly(room, true)
|
|
||||||
await settle()
|
|
||||||
return room.simulateScenario('full-reconnect')
|
|
||||||
}
|
|
||||||
setRelayOnly(room, false)
|
|
||||||
return room.simulateScenario(mode === 'turn-tls' ? 'force-tls' : 'force-tcp')
|
|
||||||
}
|
|
||||||
|
|
||||||
export const releaseForcedTransport = async (room: Room): Promise<void> => {
|
|
||||||
setRelayOnly(room, false)
|
|
||||||
await clearServerTransportPreference(room)
|
|
||||||
await settle()
|
|
||||||
await room.simulateScenario('full-reconnect')
|
|
||||||
}
|
|
||||||
|
|
||||||
export const transportModeFromRoute = (route?: {
|
|
||||||
protocol?: string
|
|
||||||
relayProtocol?: string
|
|
||||||
}): TransportMode => {
|
|
||||||
// relayProtocol is the client→TURN leg; protocol alone means no relay.
|
|
||||||
if (route?.relayProtocol === 'tls') return 'turn-tls'
|
|
||||||
if (route?.relayProtocol === 'tcp') return 'turn-tcp'
|
|
||||||
if (route?.relayProtocol === 'udp') return 'turn-udp'
|
|
||||||
if (route?.protocol === 'tcp') return 'tcp'
|
|
||||||
return 'auto'
|
|
||||||
}
|
|
||||||
@@ -1,243 +0,0 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
|
||||||
import { Room } from 'livekit-client'
|
|
||||||
|
|
||||||
export type TrackRow = {
|
|
||||||
key: string
|
|
||||||
dir: 'up' | 'down'
|
|
||||||
label: string
|
|
||||||
codec?: string
|
|
||||||
kbps: number
|
|
||||||
fps?: number
|
|
||||||
res?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type StatsSnapshot = {
|
|
||||||
ts: number
|
|
||||||
/** wire totals from transport stats (includes headers, RTCP, FEC) */
|
|
||||||
upKbps: number
|
|
||||||
downKbps: number
|
|
||||||
rttMs?: number
|
|
||||||
/** worst inbound RTP jitter across subscribed tracks */
|
|
||||||
jitterMs?: number
|
|
||||||
availableOutKbps?: number
|
|
||||||
/** selected ICE route of the publisher transport (measured, not assumed) */
|
|
||||||
route?: { protocol?: string; type?: string; relayProtocol?: string }
|
|
||||||
/**
|
|
||||||
* relayProtocol values of gathered relay local candidates — i.e. which
|
|
||||||
* client→TURN transports are actually configured (udp/tcp/tls). Empty
|
|
||||||
* when no TURN server is configured.
|
|
||||||
*/
|
|
||||||
turnProtocols: string[]
|
|
||||||
tracks: TrackRow[]
|
|
||||||
}
|
|
||||||
|
|
||||||
type StatDict = Record<string, unknown>
|
|
||||||
type Counters = Record<string, number>
|
|
||||||
|
|
||||||
const asNumber = (v: unknown): number | undefined =>
|
|
||||||
typeof v === 'number' && Number.isFinite(v) ? v : undefined
|
|
||||||
|
|
||||||
const asString = (v: unknown): string | undefined =>
|
|
||||||
typeof v === 'string' ? v : undefined
|
|
||||||
|
|
||||||
const shortCodec = (mimeType?: string) =>
|
|
||||||
mimeType ? mimeType.replace(/^(audio|video)\//, '') : undefined
|
|
||||||
|
|
||||||
const buildTrackLabels = (room: Room) => {
|
|
||||||
const labels = new Map<string, string>()
|
|
||||||
room.localParticipant.trackPublications.forEach((pub) => {
|
|
||||||
const id = pub.track?.mediaStreamTrack?.id
|
|
||||||
if (id) labels.set(id, `local ${pub.source}`)
|
|
||||||
})
|
|
||||||
room.remoteParticipants.forEach((participant) => {
|
|
||||||
// Keep rows scannable: participant names capped at 10 chars.
|
|
||||||
const rawName = participant.name || participant.identity
|
|
||||||
const name = rawName.length > 10 ? `${rawName.slice(0, 10)}.` : rawName
|
|
||||||
participant.trackPublications.forEach((pub) => {
|
|
||||||
const id = pub.track?.mediaStreamTrack?.id
|
|
||||||
if (id) labels.set(id, `${name} ${pub.source}`)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
return labels
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useWebRTCStats = (
|
|
||||||
room: Room,
|
|
||||||
enabled: boolean,
|
|
||||||
intervalMs = 1000
|
|
||||||
) => {
|
|
||||||
// Single source of truth: the snapshot is just the last history entry.
|
|
||||||
const [history, setHistory] = useState<StatsSnapshot[]>([])
|
|
||||||
const prevRef = useRef(new Map<string, Counters>())
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!enabled) return
|
|
||||||
|
|
||||||
let cancelled = false
|
|
||||||
const prev = prevRef.current
|
|
||||||
|
|
||||||
/** Per-stat counter deltas; returns 0 on the first sighting. */
|
|
||||||
const deltas = (key: string, now: Counters): Counters => {
|
|
||||||
const before = prev.get(key)
|
|
||||||
prev.set(key, now)
|
|
||||||
const out: Counters = {}
|
|
||||||
for (const [name, value] of Object.entries(now)) {
|
|
||||||
out[name] = before?.[name] !== undefined ? value - before[name] : 0
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
const collect = async () => {
|
|
||||||
const reports: Array<{ pc: 'pub' | 'sub'; report: RTCStatsReport }> = []
|
|
||||||
try {
|
|
||||||
// Not public API — see file header.
|
|
||||||
const manager = room.engine?.pcManager
|
|
||||||
const pub = await manager?.publisher?.getStats()
|
|
||||||
if (pub) reports.push({ pc: 'pub', report: pub })
|
|
||||||
const sub = await manager?.subscriber?.getStats()
|
|
||||||
if (sub) reports.push({ pc: 'sub', report: sub })
|
|
||||||
} catch {
|
|
||||||
// Engine not ready or SDK internals changed; panel shows nothing.
|
|
||||||
}
|
|
||||||
if (cancelled || reports.length === 0) return
|
|
||||||
|
|
||||||
const labels = buildTrackLabels(room)
|
|
||||||
const tracks: TrackRow[] = []
|
|
||||||
let upKbps = 0
|
|
||||||
let downKbps = 0
|
|
||||||
let rttMs: number | undefined
|
|
||||||
let jitterMs: number | undefined
|
|
||||||
let availableOutKbps: number | undefined
|
|
||||||
let route: StatsSnapshot['route']
|
|
||||||
const turnProtocols = new Set<string>()
|
|
||||||
|
|
||||||
for (const { pc, report } of reports) {
|
|
||||||
const byId = new Map<string, StatDict>()
|
|
||||||
report.forEach((stat) => byId.set(stat.id as string, stat as StatDict))
|
|
||||||
|
|
||||||
report.forEach((raw) => {
|
|
||||||
const stat = raw as StatDict
|
|
||||||
const type = asString(stat.type)
|
|
||||||
const ts = asNumber(stat.timestamp) ?? Date.now()
|
|
||||||
const key = `${pc}:${asString(stat.id) ?? ''}`
|
|
||||||
|
|
||||||
if (
|
|
||||||
type === 'local-candidate' &&
|
|
||||||
asString(stat.candidateType) === 'relay'
|
|
||||||
) {
|
|
||||||
// Relay candidates are only gathered when a TURN server is
|
|
||||||
// configured and reachable; relayProtocol says how the client
|
|
||||||
// reaches it (udp/tcp/tls).
|
|
||||||
turnProtocols.add(
|
|
||||||
asString(stat.relayProtocol) ?? asString(stat.protocol) ?? 'udp'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === 'transport') {
|
|
||||||
const d = deltas(key, {
|
|
||||||
sent: asNumber(stat.bytesSent) ?? 0,
|
|
||||||
received: asNumber(stat.bytesReceived) ?? 0,
|
|
||||||
ts,
|
|
||||||
})
|
|
||||||
if (d.ts > 0) {
|
|
||||||
upKbps += Math.max(0, (d.sent * 8) / d.ts)
|
|
||||||
downKbps += Math.max(0, (d.received * 8) / d.ts)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === 'candidate-pair' && stat.nominated === true) {
|
|
||||||
const rtt = asNumber(stat.currentRoundTripTime)
|
|
||||||
if (rtt !== undefined) rttMs = Math.round(rtt * 1000)
|
|
||||||
const available = asNumber(stat.availableOutgoingBitrate)
|
|
||||||
if (available !== undefined && pc === 'pub') {
|
|
||||||
availableOutKbps = Math.round(available / 1000)
|
|
||||||
}
|
|
||||||
if (pc === 'pub') {
|
|
||||||
const local = byId.get(asString(stat.localCandidateId) ?? '')
|
|
||||||
route = {
|
|
||||||
protocol: asString(local?.protocol),
|
|
||||||
type: asString(local?.candidateType),
|
|
||||||
relayProtocol: asString(local?.relayProtocol),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === 'outbound-rtp' || type === 'inbound-rtp') {
|
|
||||||
const isUp = type === 'outbound-rtp'
|
|
||||||
|
|
||||||
if (!isUp) {
|
|
||||||
const jitter = asNumber(stat.jitter)
|
|
||||||
if (jitter !== undefined) {
|
|
||||||
const ms = Math.round(jitter * 1000)
|
|
||||||
if (jitterMs === undefined || ms > jitterMs) jitterMs = ms
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const d = deltas(key, {
|
|
||||||
bytes: asNumber(isUp ? stat.bytesSent : stat.bytesReceived) ?? 0,
|
|
||||||
ts,
|
|
||||||
})
|
|
||||||
const kbps = d.ts > 0 ? Math.max(0, (d.bytes * 8) / d.ts) : 0
|
|
||||||
|
|
||||||
// Resolve codec + source track.
|
|
||||||
const codecStat = byId.get(asString(stat.codecId) ?? '')
|
|
||||||
let msTrackId = asString(stat.trackIdentifier)
|
|
||||||
if (!msTrackId && isUp) {
|
|
||||||
const mediaSource = byId.get(asString(stat.mediaSourceId) ?? '')
|
|
||||||
msTrackId = asString(mediaSource?.trackIdentifier)
|
|
||||||
}
|
|
||||||
const rid = asString(stat.rid)
|
|
||||||
const baseLabel =
|
|
||||||
(msTrackId && labels.get(msTrackId)) ??
|
|
||||||
`${asString(stat.kind) ?? 'media'} ssrc ${asNumber(stat.ssrc) ?? '?'}`
|
|
||||||
|
|
||||||
const width = asNumber(stat.frameWidth)
|
|
||||||
const height = asNumber(stat.frameHeight)
|
|
||||||
|
|
||||||
tracks.push({
|
|
||||||
key,
|
|
||||||
dir: isUp ? 'up' : 'down',
|
|
||||||
label: rid ? `${baseLabel} [${rid}]` : baseLabel,
|
|
||||||
codec: shortCodec(asString(codecStat?.mimeType)),
|
|
||||||
kbps,
|
|
||||||
fps: asNumber(stat.framesPerSecond),
|
|
||||||
res: width && height ? `${width}x${height}` : undefined,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stable order (direction, then label): sorting by bitrate would
|
|
||||||
// reshuffle rows on every tick as kbps fluctuates.
|
|
||||||
tracks.sort((a, b) =>
|
|
||||||
a.dir === b.dir
|
|
||||||
? a.label.localeCompare(b.label)
|
|
||||||
: a.dir === 'up'
|
|
||||||
? -1
|
|
||||||
: 1
|
|
||||||
)
|
|
||||||
|
|
||||||
const next: StatsSnapshot = {
|
|
||||||
ts: Date.now(),
|
|
||||||
upKbps: Math.round(upKbps),
|
|
||||||
downKbps: Math.round(downKbps),
|
|
||||||
rttMs,
|
|
||||||
jitterMs,
|
|
||||||
availableOutKbps,
|
|
||||||
route,
|
|
||||||
turnProtocols: Array.from(turnProtocols).sort(),
|
|
||||||
tracks,
|
|
||||||
}
|
|
||||||
setHistory((h) => [...h.slice(-59), next])
|
|
||||||
}
|
|
||||||
|
|
||||||
void collect()
|
|
||||||
const id = window.setInterval(() => void collect(), intervalMs)
|
|
||||||
return () => {
|
|
||||||
cancelled = true
|
|
||||||
window.clearInterval(id)
|
|
||||||
}
|
|
||||||
}, [room, enabled, intervalMs])
|
|
||||||
|
|
||||||
return { snapshot: history[history.length - 1], history }
|
|
||||||
}
|
|
||||||
@@ -43,7 +43,6 @@ 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 { MeetDevtools } from '@/features/devtools'
|
|
||||||
import { VOICE_AUDIO_CONSTRAINTS } from '@/features/rooms/livekit/utils/constants'
|
import { VOICE_AUDIO_CONSTRAINTS } from '@/features/rooms/livekit/utils/constants'
|
||||||
|
|
||||||
export const Conference = ({
|
export const Conference = ({
|
||||||
@@ -299,7 +298,6 @@ export const Conference = ({
|
|||||||
<VideoConference />
|
<VideoConference />
|
||||||
{!isMobile && <InviteDialog mode={mode} />}
|
{!isMobile && <InviteDialog mode={mode} />}
|
||||||
<PictureInPictureConference />
|
<PictureInPictureConference />
|
||||||
<MeetDevtools />
|
|
||||||
</LiveKitRoom>
|
</LiveKitRoom>
|
||||||
</Screen>
|
</Screen>
|
||||||
</QueryAware>
|
</QueryAware>
|
||||||
|
|||||||
+2
-6
@@ -2,7 +2,7 @@ import { useTranslation } from 'react-i18next'
|
|||||||
import { useTrackToggle, UseTrackToggleProps } from '@livekit/components-react'
|
import { useTrackToggle, UseTrackToggleProps } from '@livekit/components-react'
|
||||||
import { Button, Popover } from '@/primitives'
|
import { Button, Popover } from '@/primitives'
|
||||||
import { RiArrowUpSLine, RiImageCircleAiFill } from '@remixicon/react'
|
import { RiArrowUpSLine, RiImageCircleAiFill } from '@remixicon/react'
|
||||||
import { Track, type VideoCaptureOptions, VideoPresets } from 'livekit-client'
|
import { Track, type VideoCaptureOptions } from 'livekit-client'
|
||||||
|
|
||||||
import { ToggleDevice } from './ToggleDevice'
|
import { ToggleDevice } from './ToggleDevice'
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
@@ -57,8 +57,7 @@ export const VideoDeviceControl = ({
|
|||||||
}: VideoDeviceControlProps) => {
|
}: VideoDeviceControlProps) => {
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
|
||||||
|
|
||||||
const { videoDeviceId, processorConfig, videoPublishResolution } =
|
const { videoDeviceId, processorConfig } = useSnapshot(userChoicesStore)
|
||||||
useSnapshot(userChoicesStore)
|
|
||||||
|
|
||||||
const onChange = React.useCallback(
|
const onChange = React.useCallback(
|
||||||
(enabled: boolean, isUserInitiated: boolean) =>
|
(enabled: boolean, isUserInitiated: boolean) =>
|
||||||
@@ -98,9 +97,6 @@ export const VideoDeviceControl = ({
|
|||||||
|
|
||||||
await toggle(!trackProps.enabled, {
|
await toggle(!trackProps.enabled, {
|
||||||
processor: processor,
|
processor: processor,
|
||||||
...(videoPublishResolution && {
|
|
||||||
resolution: VideoPresets[videoPublishResolution].resolution,
|
|
||||||
}),
|
|
||||||
} as VideoCaptureOptions)
|
} as VideoCaptureOptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import {
|
|||||||
saveVideoPublishResolution,
|
saveVideoPublishResolution,
|
||||||
saveVideoSubscribeQuality,
|
saveVideoSubscribeQuality,
|
||||||
userChoicesStore,
|
userChoicesStore,
|
||||||
VIDEO_RESOLUTIONS,
|
|
||||||
VideoResolution,
|
VideoResolution,
|
||||||
} from '@/stores/userChoices'
|
} from '@/stores/userChoices'
|
||||||
import { RowWrapper } from './layout/RowWrapper'
|
import { RowWrapper } from './layout/RowWrapper'
|
||||||
@@ -33,8 +32,7 @@ const EMPTY_PROPS = {}
|
|||||||
|
|
||||||
export const VideoTab = ({ id }: VideoTabProps) => {
|
export const VideoTab = ({ id }: VideoTabProps) => {
|
||||||
const { t } = useTranslation('settings', { keyPrefix: 'video' })
|
const { t } = useTranslation('settings', { keyPrefix: 'video' })
|
||||||
const room = useRoomContext()
|
const { localParticipant, remoteParticipants } = useRoomContext()
|
||||||
const { localParticipant, remoteParticipants } = room
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
videoDeviceId,
|
videoDeviceId,
|
||||||
@@ -71,15 +69,13 @@ export const VideoTab = ({ id }: VideoTabProps) => {
|
|||||||
isDisabled: true,
|
isDisabled: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleVideoResolutionChange = async (key: VideoResolution) => {
|
const handleVideoResolutionChange = async (key: 'h720' | 'h360' | 'h180') => {
|
||||||
saveVideoPublishResolution(key)
|
const videoPublication = localParticipant.getTrackPublication(
|
||||||
const videoTrack = localParticipant.getTrackPublication(
|
|
||||||
Track.Source.Camera
|
Track.Source.Camera
|
||||||
)?.track
|
)
|
||||||
if (!videoTrack) {
|
const videoTrack = videoPublication?.track
|
||||||
return
|
if (videoTrack) {
|
||||||
}
|
saveVideoPublishResolution(key)
|
||||||
|
|
||||||
await videoTrack.restartTrack({
|
await videoTrack.restartTrack({
|
||||||
resolution: VideoPresets[key].resolution,
|
resolution: VideoPresets[key].resolution,
|
||||||
deviceId: { exact: videoDeviceId },
|
deviceId: { exact: videoDeviceId },
|
||||||
@@ -87,6 +83,7 @@ export const VideoTab = ({ id }: VideoTabProps) => {
|
|||||||
BackgroundProcessorFactory.fromProcessorConfig(processorConfig),
|
BackgroundProcessorFactory.fromProcessorConfig(processorConfig),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates video quality for all existing remote video tracks when user preference changes.
|
* Updates video quality for all existing remote video tracks when user preference changes.
|
||||||
@@ -125,13 +122,20 @@ export const VideoTab = ({ id }: VideoTabProps) => {
|
|||||||
}, [videoDeviceId, videoElement])
|
}, [videoDeviceId, videoElement])
|
||||||
|
|
||||||
const resolutionItems = useMemo(() => {
|
const resolutionItems = useMemo(() => {
|
||||||
const labels: Record<VideoResolution, string> = {
|
return [
|
||||||
h1080: `${t('resolution.publish.items.veryHigh')} (1080p)`,
|
{
|
||||||
h720: `${t('resolution.publish.items.high')} (720p)`,
|
value: 'h720',
|
||||||
h360: `${t('resolution.publish.items.medium')} (360p)`,
|
label: `${t('resolution.publish.items.high')} (720p)`,
|
||||||
h180: `${t('resolution.publish.items.low')} (180p)`,
|
},
|
||||||
}
|
{
|
||||||
return VIDEO_RESOLUTIONS.map((value) => ({ value, label: labels[value] }))
|
value: 'h360',
|
||||||
|
label: `${t('resolution.publish.items.medium')} (360p)`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'h180',
|
||||||
|
label: `${t('resolution.publish.items.low')} (180p)`,
|
||||||
|
},
|
||||||
|
]
|
||||||
}, [t])
|
}, [t])
|
||||||
|
|
||||||
const videoQualityItems = useMemo(() => {
|
const videoQualityItems = useMemo(() => {
|
||||||
|
|||||||
@@ -56,7 +56,6 @@
|
|||||||
"publish": {
|
"publish": {
|
||||||
"label": "Wähle die maximale Auflösung beim Senden",
|
"label": "Wähle die maximale Auflösung beim Senden",
|
||||||
"items": {
|
"items": {
|
||||||
"veryHigh": "Sehr hohe Auflösung",
|
|
||||||
"high": "Hohe Auflösung",
|
"high": "Hohe Auflösung",
|
||||||
"medium": "Mittlere Auflösung",
|
"medium": "Mittlere Auflösung",
|
||||||
"low": "Niedrige Auflösung"
|
"low": "Niedrige Auflösung"
|
||||||
|
|||||||
@@ -56,7 +56,6 @@
|
|||||||
"publish": {
|
"publish": {
|
||||||
"label": "Select your sending resolution (max.)",
|
"label": "Select your sending resolution (max.)",
|
||||||
"items": {
|
"items": {
|
||||||
"veryHigh": "Very high definition",
|
|
||||||
"high": "High definition",
|
"high": "High definition",
|
||||||
"medium": "Standard definition",
|
"medium": "Standard definition",
|
||||||
"low": "Low definition"
|
"low": "Low definition"
|
||||||
|
|||||||
@@ -56,7 +56,6 @@
|
|||||||
"publish": {
|
"publish": {
|
||||||
"label": "Selecciona tu resolución de envío (máx.)",
|
"label": "Selecciona tu resolución de envío (máx.)",
|
||||||
"items": {
|
"items": {
|
||||||
"veryHigh": "Muy alta definición",
|
|
||||||
"high": "Alta definición",
|
"high": "Alta definición",
|
||||||
"medium": "Definición estándar",
|
"medium": "Definición estándar",
|
||||||
"low": "Baja definición"
|
"low": "Baja definición"
|
||||||
|
|||||||
@@ -56,7 +56,6 @@
|
|||||||
"publish": {
|
"publish": {
|
||||||
"label": "Sélectionner votre résolution d'envoi (max.)",
|
"label": "Sélectionner votre résolution d'envoi (max.)",
|
||||||
"items": {
|
"items": {
|
||||||
"veryHigh": "Très haute définition",
|
|
||||||
"high": "Haute définition",
|
"high": "Haute définition",
|
||||||
"medium": "Définition standard",
|
"medium": "Définition standard",
|
||||||
"low": "Basse définition"
|
"low": "Basse définition"
|
||||||
|
|||||||
@@ -56,7 +56,6 @@
|
|||||||
"publish": {
|
"publish": {
|
||||||
"label": "Selecteer uw verzendresolutie (max.)",
|
"label": "Selecteer uw verzendresolutie (max.)",
|
||||||
"items": {
|
"items": {
|
||||||
"veryHigh": "Zeer hoge definitie",
|
|
||||||
"high": "Hoge definitie",
|
"high": "Hoge definitie",
|
||||||
"medium": "Standaarddefinitie",
|
"medium": "Standaarddefinitie",
|
||||||
"low": "Lage definitie"
|
"low": "Lage definitie"
|
||||||
|
|||||||
@@ -10,12 +10,7 @@ import {
|
|||||||
} from '@livekit/components-core'
|
} from '@livekit/components-core'
|
||||||
import { VideoQuality } from 'livekit-client'
|
import { VideoQuality } from 'livekit-client'
|
||||||
|
|
||||||
export const VIDEO_RESOLUTIONS = ['h1080', 'h720', 'h360', 'h180'] as const
|
export type VideoResolution = 'h720' | 'h360' | 'h180'
|
||||||
|
|
||||||
export type VideoResolution = (typeof VIDEO_RESOLUTIONS)[number]
|
|
||||||
|
|
||||||
const isVideoResolution = (value: unknown): value is VideoResolution =>
|
|
||||||
VIDEO_RESOLUTIONS.includes(value as VideoResolution)
|
|
||||||
|
|
||||||
export type LocalUserChoices = Omit<LocalUserChoicesLK, 'username'> & {
|
export type LocalUserChoices = Omit<LocalUserChoicesLK, 'username'> & {
|
||||||
processorConfig?: ProcessorConfig
|
processorConfig?: ProcessorConfig
|
||||||
@@ -26,17 +21,13 @@ export type LocalUserChoices = Omit<LocalUserChoicesLK, 'username'> & {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getUserChoicesState(): LocalUserChoices {
|
function getUserChoicesState(): LocalUserChoices {
|
||||||
const stored: LocalUserChoices = {
|
return {
|
||||||
noiseReductionEnabled: false,
|
noiseReductionEnabled: false,
|
||||||
audioOutputDeviceId: 'default', // Use 'default' to match LiveKit's standard device selection behavior
|
audioOutputDeviceId: 'default', // Use 'default' to match LiveKit's standard device selection behavior
|
||||||
videoPublishResolution: 'h720',
|
videoPublishResolution: 'h720',
|
||||||
videoSubscribeQuality: VideoQuality.HIGH,
|
videoSubscribeQuality: VideoQuality.HIGH,
|
||||||
...loadUserChoices(),
|
...loadUserChoices(),
|
||||||
}
|
}
|
||||||
if (!isVideoResolution(stored.videoPublishResolution)) {
|
|
||||||
stored.videoPublishResolution = 'h720'
|
|
||||||
}
|
|
||||||
return stored
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const userChoicesStore = proxy<LocalUserChoices>(getUserChoicesState())
|
export const userChoicesStore = proxy<LocalUserChoices>(getUserChoicesState())
|
||||||
|
|||||||
Reference in New Issue
Block a user