mirror of
https://github.com/suitenumerique/meet.git
synced 2026-09-30 22:48:35 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 387269d1d2 |
@@ -0,0 +1,28 @@
|
||||
---
|
||||
name: 🐛 Bug Report
|
||||
about: If something is not working as expected 🤔.
|
||||
|
||||
---
|
||||
|
||||
## Bug Report
|
||||
|
||||
**Problematic behavior**
|
||||
A clear and concise description of the behavior.
|
||||
|
||||
**Expected behavior/code**
|
||||
A clear and concise description of what you expected to happen (or code).
|
||||
|
||||
**Steps to Reproduce**
|
||||
1. Do this...
|
||||
2. Then this...
|
||||
3. And then the bug happens!
|
||||
|
||||
**Environment**
|
||||
- Meet version:
|
||||
- Platform:
|
||||
|
||||
**Possible Solution**
|
||||
<!--- Only if you have suggestions on a fix for the bug -->
|
||||
|
||||
**Additional context/Screenshots**
|
||||
Add any other context about the problem here. If applicable, add screenshots to help explain.
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
name: ✨ Feature Request
|
||||
about: I have a suggestion (and may want to build it 💪)!
|
||||
|
||||
---
|
||||
|
||||
## Feature Request
|
||||
|
||||
**Is your feature request related to a problem or unsupported use case? Please describe.**
|
||||
A clear and concise description of what the problem is. For example: I need to do some task and I have an issue...
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen. Add any considered drawbacks.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Discovery, Documentation, Adoption, Migration Strategy**
|
||||
If you can, explain how users will be able to use this and possibly write out a version the docs (if applicable).
|
||||
Maybe a screenshot or design?
|
||||
|
||||
**Do you want to work on it through a Pull Request?**
|
||||
<!-- Make sure to coordinate with us before you spend too much time working on an implementation! -->
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
name: 🤗 Support Question
|
||||
about: If you have a question 💬, or something was not clear from the docs!
|
||||
|
||||
---
|
||||
|
||||
<!-- ^ Click "Preview" for a nicer view! ^
|
||||
We primarily use GitHub as an issue tracker. If however you're encountering an issue not covered in the docs, we may be able to help! -->
|
||||
|
||||
---
|
||||
|
||||
Please make sure you have read our [main Readme](https://github.com/numerique-gouv/meet).
|
||||
|
||||
Also make sure it was not already answered in [an open or close issue](https://github.com/numerique-gouv/meet/issues).
|
||||
|
||||
If your question was not covered, and you feel like it should be, fire away! We'd love to improve our docs! 👌
|
||||
|
||||
**Topic**
|
||||
What's the general area of your question: for example, docker setup, database schema, search functionality,...
|
||||
|
||||
**Question**
|
||||
Try to be as specific as possible so we can help you as best we can. Please be patient 🙏
|
||||
@@ -0,0 +1,11 @@
|
||||
## Purpose
|
||||
|
||||
Description...
|
||||
|
||||
|
||||
## Proposal
|
||||
|
||||
Description...
|
||||
|
||||
- [] item 1...
|
||||
- [] item 2...
|
||||
+29
-18
@@ -226,9 +226,8 @@ jobs:
|
||||
REDIS_URL: redis://localhost:6379/1
|
||||
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
|
||||
AWS_S3_ENDPOINT_URL: http://localhost:9000
|
||||
AWS_S3_ACCESS_KEY_ID: meet-access-key
|
||||
AWS_S3_SECRET_ACCESS_KEY: meet-secret-access-key
|
||||
AWS_S3_REGION_NAME: local
|
||||
AWS_S3_ACCESS_KEY_ID: meet
|
||||
AWS_S3_SECRET_ACCESS_KEY: password
|
||||
OIDC_RS_CLIENT_ID: meet
|
||||
OIDC_RS_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly
|
||||
OIDC_OP_INTROSPECTION_ENDPOINT: https://oidc.example.com/introspect
|
||||
@@ -251,22 +250,34 @@ jobs:
|
||||
path: "src/backend/core/templates/mail"
|
||||
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
|
||||
|
||||
# Creates the access key and the bucket on startup
|
||||
- name: Start Garage
|
||||
- name: Start MinIO
|
||||
run: |
|
||||
docker run -d --name garage \
|
||||
docker pull quay.io/minio/minio
|
||||
docker run -d --name minio \
|
||||
-p 9000:9000 \
|
||||
-v "${GITHUB_WORKSPACE}/docker/files/etc/garage/garage.toml:/etc/garage.toml:ro" \
|
||||
-e "GARAGE_RPC_SECRET=$(openssl rand -hex 32)" \
|
||||
-e "GARAGE_DEFAULT_ACCESS_KEY=meet-access-key" \
|
||||
-e "GARAGE_DEFAULT_SECRET_KEY=meet-secret-access-key" \
|
||||
-e "GARAGE_DEFAULT_BUCKET=meet-media-storage" \
|
||||
dxflrs/garage:v2.4.1 \
|
||||
/garage server --single-node --default-bucket
|
||||
-e "MINIO_ACCESS_KEY=meet" \
|
||||
-e "MINIO_SECRET_KEY=password" \
|
||||
-v /data/media:/data \
|
||||
quay.io/minio/minio server --console-address :9001 /data
|
||||
|
||||
- name: Wait for Garage to be ready
|
||||
# Tool to wait for a service to be ready
|
||||
- name: Install Dockerize
|
||||
run: |
|
||||
timeout 30 sh -c 'until docker exec garage /garage health; do sleep 1; done'
|
||||
curl --proto "=https" --proto-redir "=https" --tlsv1.2 -sSLf \
|
||||
https://github.com/jwilder/dockerize/releases/download/v0.8.0/dockerize-linux-amd64-v0.8.0.tar.gz |
|
||||
sudo tar -C /usr/local/bin -xzv
|
||||
|
||||
- name: Wait for MinIO to be ready
|
||||
run: |
|
||||
dockerize -wait tcp://localhost:9000 -timeout 10s
|
||||
|
||||
- name: Configure MinIO
|
||||
run: |
|
||||
MINIO=$(docker ps | grep minio/minio | sed -E 's/.*\s+([a-zA-Z0-9_-]+)$/\1/')
|
||||
docker exec ${MINIO} sh -c \
|
||||
"mc alias set meet http://localhost:9000 meet password && \
|
||||
mc alias ls && \
|
||||
mc mb meet/meet-media-storage"
|
||||
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
@@ -299,9 +310,9 @@ jobs:
|
||||
env:
|
||||
AUTHORIZED_TENANTS: '[{"id": "test-tenant", "api_key": "test-api-token", "webhook_url": "https://example.com/webhook", "webhook_api_key": "test-webhook-api-key"}]'
|
||||
AWS_STORAGE_BUCKET_NAME: "http://meet-media-storage"
|
||||
AWS_S3_ENDPOINT_URL: "garage:9000"
|
||||
AWS_S3_ACCESS_KEY_ID: "meet-access-key"
|
||||
AWS_S3_SECRET_ACCESS_KEY: "meet-secret-access-key"
|
||||
AWS_S3_ENDPOINT_URL: "minio:9000"
|
||||
AWS_S3_ACCESS_KEY_ID: "meet"
|
||||
AWS_S3_SECRET_ACCESS_KEY: "password"
|
||||
WHISPERX_BASE_URL: "https://configure-your-url.com"
|
||||
WHISPERX_ASR_MODEL: "large-v2"
|
||||
WHISPERX_API_KEY: "test-whisperx-secret"
|
||||
|
||||
+1
-30
@@ -10,35 +10,6 @@ and this project adheres to
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(backend) purge rooms inactive for a configurable period
|
||||
- 🔨(makefile) add targets to list and download files stored in Garage
|
||||
|
||||
### Changed
|
||||
|
||||
- ⬆️(backend) update python dependencies
|
||||
- ⬆️(summary) update python dependencies
|
||||
- ⬆️(agents) update python dependencies
|
||||
- ♻️(agents) replace the minio client by boto3
|
||||
- 🔧(compose) replace MinIO by Garage for local development
|
||||
- 🔧(helm) point media services to Garage by default
|
||||
- 💥(backend) replace recording encoding options with a profile model
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🔒️(backend) fix critical and high CVEs in PyJWT
|
||||
- ⚡️(frontend) disable posthog-js periodic feature flag reloads
|
||||
|
||||
## [1.32.1] - 2026-09-25
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🔒️(backend) fix CVE-2026-73228 and CVE-2026-73229 in drf
|
||||
- 🔒️(agent) fix CRITICAL CVE-2026-63072 / CVE-2026-63073 in libssl3t64
|
||||
|
||||
## [1.32.0] - 2026-09-25
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(backend) make the LiveKit default video codec configurable
|
||||
- 🔧(dev) add support for Bureautix workstations
|
||||
- ✨(frontend) add screen share zoom controls #1498
|
||||
@@ -57,7 +28,7 @@ and this project adheres to
|
||||
- ⬆️(frontend) upgrade posthog-js from 1.414.0 to 1.418.10
|
||||
- ⬆️(addons) upgrade i18next from 26.3.6 to 26.4.0
|
||||
- ⬆️(frontend) upgrade humanize-duration from 3.33.2 to 3.34.1
|
||||
- ⬆️(addons) upgrade i18next from 26.4.0 to 26.4.2
|
||||
- ⬆️(addons) upgrade i18next from 26.4.0 to 26.4.1
|
||||
- 🔖(helm) release chart 0.0.28
|
||||
- ♻️(backend) decouple recording event handling from LiveKit egress statuses
|
||||
|
||||
|
||||
@@ -69,22 +69,6 @@ LINT_SUMMARY = echo 'lint:ruff-format started…' && $(LINT_RUFF_FORMAT)
|
||||
# -- Frontend
|
||||
PATH_FRONT = ./src/frontend
|
||||
|
||||
# -- Storage
|
||||
GARAGE_BUCKET = meet-media-storage
|
||||
STORAGE_FOLDERS = recordings transcripts summaries
|
||||
STORAGE_DIRS = $(addprefix data/,$(STORAGE_FOLDERS))
|
||||
COMPOSE_RUN_AWS = $(COMPOSE_RUN) --user $(DOCKER_USER)
|
||||
AWS_CLI = garage-cors --endpoint-url=http://garage:9000
|
||||
# Extensions listed in each folder (skips the Egress manifests in recordings/)
|
||||
recordings_EXTENSIONS = mp4 ogg
|
||||
transcripts_EXTENSIONS = json
|
||||
summaries_EXTENSIONS = txt
|
||||
# $(1): folder. Lists its objects with a known extension, most recent first
|
||||
storage_list = s3api list-objects-v2 --bucket $(GARAGE_BUCKET) \
|
||||
--prefix $(1)/
|
||||
storage_query = reverse(sort_by(Contents[?$(foreach ext,$($(1)_EXTENSIONS), \
|
||||
ends_with(Key, `".$(ext)"`) ||) `false`] || `[]`, &LastModified))
|
||||
|
||||
# ==============================================================================
|
||||
# RULES
|
||||
|
||||
@@ -93,9 +77,6 @@ default: help
|
||||
data/media:
|
||||
@mkdir -p data/media
|
||||
|
||||
$(STORAGE_DIRS):
|
||||
@mkdir -p $@
|
||||
|
||||
data/static:
|
||||
@mkdir -p data/static
|
||||
|
||||
@@ -104,7 +85,6 @@ data/static:
|
||||
create-env-files: ## Copy the dist env files to env files
|
||||
create-env-files: \
|
||||
env.d/development/common \
|
||||
env.d/development/garage \
|
||||
env.d/development/crowdin \
|
||||
env.d/development/postgresql \
|
||||
env.d/development/kc_postgresql \
|
||||
@@ -337,38 +317,12 @@ env.d/development/summary:
|
||||
env.d/development/kube-secret:
|
||||
cp -n env.d/development/kube-secret.dist env.d/development/kube-secret
|
||||
|
||||
env.d/development/garage:
|
||||
sed "s/^GARAGE_RPC_SECRET=.*/GARAGE_RPC_SECRET=$$(openssl rand -hex 32)/" \
|
||||
env.d/development/garage.dist > env.d/development/garage
|
||||
|
||||
env.d/development/multi_user_transcriber:
|
||||
cp -n env.d/development/multi_user_transcriber.dist env.d/development/multi_user_transcriber
|
||||
|
||||
env.d/development/metadata_collector:
|
||||
cp -n env.d/development/metadata_collector.dist env.d/development/metadata_collector
|
||||
|
||||
# -- Storage
|
||||
|
||||
recordings-download-latest: ## download the latest recording from Garage into data/recordings
|
||||
transcripts-download-latest: ## download the latest transcript from Garage into data/transcripts
|
||||
summaries-download-latest: ## download the latest summary from Garage into data/summaries
|
||||
$(STORAGE_FOLDERS:%=%-download-latest): %-download-latest: data/%
|
||||
@key=$$($(COMPOSE_RUN_AWS) -T $(AWS_CLI) $(call storage_list,$*) \
|
||||
--query '$(call storage_query,$*)[0].Key' --output text) && \
|
||||
if [ "$$key" = "None" ]; then echo "No $* found"; exit 1; fi && \
|
||||
$(COMPOSE_RUN_AWS) --volume $(CURDIR)/data/$*:/aws/data/$* \
|
||||
$(AWS_CLI) s3 cp "s3://$(GARAGE_BUCKET)/$$key" data/$*/
|
||||
.PHONY: $(STORAGE_FOLDERS:%=%-download-latest)
|
||||
|
||||
recordings-list: ## list recordings stored in Garage, most recent first
|
||||
transcripts-list: ## list transcripts stored in Garage, most recent first
|
||||
summaries-list: ## list summaries stored in Garage, most recent first
|
||||
$(STORAGE_FOLDERS:%=%-list): %-list:
|
||||
@$(COMPOSE_RUN_AWS) $(AWS_CLI) $(call storage_list,$*) \
|
||||
--query '$(call storage_query,$*)[].{Date: LastModified, Key: Key, "Size (bytes)": Size}' \
|
||||
--output table
|
||||
.PHONY: $(STORAGE_FOLDERS:%=%-list)
|
||||
|
||||
# -- Internationalization
|
||||
|
||||
env.d/development/crowdin:
|
||||
|
||||
-159
@@ -16,165 +16,6 @@ the following command inside your docker container:
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Purging inactive rooms
|
||||
|
||||
Rooms now keep track of the last time they were started (`last_started_at`), fed by LiveKit's `room_started` webhook. A new `purge_inactive_rooms` management command permanently deletes the rooms that have not been started for `ROOM_INACTIVITY_DELETION_DAYS` days. See [the room purge documentation](docs/features/room-purge.md).
|
||||
|
||||
- The feature is **disabled by default**: nothing is deleted unless you set `ROOM_INACTIVITY_DELETION_DAYS`.
|
||||
- The migration marks every existing room as started at the time of the upgrade, so no existing room can be purged before a full inactivity period has elapsed after upgrading.
|
||||
- Rooms holding a saved recording their users may still access are kept: any saved recording, or, when `RECORDING_EXPIRATION_DAYS` is set, a saved recording created within that window.
|
||||
- Inactivity is measured from LiveKit's `room_started` webhook: if it is not delivered to your backend, rooms in daily use look inactive and get purged.
|
||||
- When a room is purged, all it's configuration and access rights are also deleted. Its slug becomes available again and can be reused when a meeting is created from that same URL.
|
||||
|
||||
* With `ALLOW_UNREGISTERED_ROOMS=false`, only an authenticated user can navigate to a previously existing link after the room has been purged. Doing so recreates the room in the database with a fresh configuration, with that user associated with it and granted admin rights.
|
||||
* With `ALLOW_UNREGISTERED_ROOMS=true`, any user can reopen the purged room by navigating to the same URL. In that case, the room is created dynamically and no corresponding room entry is persisted in the database.
|
||||
|
||||
### Local development: MinIO replaced by Garage
|
||||
|
||||
The development stacks now use [Garage](https://garagehq.deuxfleurs.fr/) instead of MinIO as S3 storage. Garage keeps its own format in `data/media/meta` and `data/media/data` and cannot read what MinIO left there, so local recordings and files will be lost.
|
||||
|
||||
To migrate a local environment:
|
||||
|
||||
1. Stop the stack and remove its containers, including the former `minio` one: `docker compose down --remove-orphans`
|
||||
2. Optionally reclaim the space used by MinIO: `rm -rf data/media && make data/media`
|
||||
3. In your `env.d/development/*` files, replace `minio:9000` by `garage:9000`, the `meet` / `password` credentials by `meet-access-key` / `meet-secret-access-key`, and add `AWS_S3_REGION_NAME=local` (or delete these files and run `make create-env-files`)
|
||||
4. Run `make create-env-files` to generate `env.d/development/garage`, which holds a random RPC secret for Garage.
|
||||
5. Rebuild the images, since the summary and agent images now install boto3 instead of minio
|
||||
|
||||
### Summary service and metadata collector: boto3 replaces the minio client
|
||||
|
||||
The summary service and the metadata collector agent now talk to S3 through boto3 instead of the minio client, with the same settings.
|
||||
Requests are now signed for `AWS_S3_REGION_NAME` as-is. When it is not set, the region is no longer looked up from the bucket: boto3 falls back to `AWS_DEFAULT_REGION`, then to `us-east-1`. If you left `AWS_S3_REGION_NAME` unset, set it to your provider's region before upgrading, or providers that check the signing region will reject the transcripts, summaries and meeting metadata uploads, as well as their signed URLs.
|
||||
|
||||
Also:
|
||||
- Signed URLs to transcripts and summaries are now always path-style (`<endpoint>/<bucket>/<key>`), whereas the minio client used virtual-hosted-style URLs
|
||||
- The metadata collector now accepts `AWS_S3_ENDPOINT_URL` with or without a scheme, like the summary service: the scheme always follows `AWS_S3_SECURE_ACCESS`.
|
||||
|
||||
### Helm chart: media services default to Garage
|
||||
|
||||
The `meet` chart now defaults `serviceMedia.host` and `serviceMediaFiles.host` to `garage.meet.svc.cluster.local`, and the `upstream-vhost` annotation of `ingressMedia` and `ingressMediaFiles` to `garage.meet.svc.cluster.local:9000`. If you relied on the former `minio.meet.svc.cluster.local` defaults, set these values explicitly to your S3 service before upgrading, or recordings and files stop being served under `/media`.
|
||||
### 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`
|
||||
keep their names and meaning. The keyframe interval now defaults to `0` (unset,
|
||||
encoder's choice) instead of `4.0`.
|
||||
|
||||
#### If you never set `RECORDING_ENCODING_ENABLED=True`
|
||||
|
||||
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.
|
||||
Video output is therefore unchanged.
|
||||
|
||||
Audio and keyframing may not be. 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`. **If you set either of them while the
|
||||
feature was disabled, they had no effect and now do**; check them before upgrading.
|
||||
|
||||
If you never set them, no action is required: 128 kbps AAC is what the preset used,
|
||||
and the keyframe interval now defaults to `0`, which leaves the field unset so the
|
||||
encoder keeps picking it as before. Set `RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=4.0`
|
||||
if you want fixed 4-second keyframes (the value the setting defaulted to while it
|
||||
was gated behind `RECORDING_ENCODING_ENABLED`).
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
Both maps are validated at startup and a malformed one raises a `ValueError`:
|
||||
|
||||
- every entry of `RECORDING_ENCODING_AVAILABLE_RESOLUTIONS` must declare `width` and
|
||||
`height`, and every entry of `RECORDING_ENCODING_AVAILABLE_PROFILES` an `fps` and a
|
||||
`kbps` map;
|
||||
- every profile 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.
|
||||
|
||||
#### Breaking: custom worker services must accept `encoding_options`
|
||||
|
||||
Only concerns deployments pointing `RECORDING_WORKER_CLASSES` at their own worker
|
||||
class. The shipped `VideoCompositeEgressService` and `AudioCompositeEgressService`
|
||||
are already updated.
|
||||
|
||||
The `WorkerService` protocol's `start()` takes a third argument, and the mediator
|
||||
now always passes it as a keyword when the recording carries no per-recording encoding:
|
||||
|
||||
```python
|
||||
# before
|
||||
def start(self, room_id: str, recording_id: str) -> str: ...
|
||||
|
||||
# now
|
||||
def start(
|
||||
self,
|
||||
room_id: str,
|
||||
recording_id: str,
|
||||
encoding_options: Optional[Dict[str, Any]] = None,
|
||||
) -> str: ...
|
||||
```
|
||||
|
||||
#### 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. It
|
||||
falls back to `RECORDING_ENCODING_DEFAULT_PROFILE`) 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
|
||||
|
||||
### Removing S3 storage-event webhooks for recordings
|
||||
|
||||
+2
-2
@@ -104,8 +104,8 @@ k8s_yaml(secret_yaml_generic(
|
||||
|
||||
k8s_yaml(local('cd ../src/helm && helmfile -n meet -e ${DEV_ENV:-dev-keycloak} template .'))
|
||||
|
||||
k8s_resource('garage-cors', resource_deps=['garage'])
|
||||
k8s_resource('meet-backend', resource_deps=['postgresql', 'garage-cors', 'redis', 'livekit-livekit-server'])
|
||||
k8s_resource('minio-bucket', resource_deps=['minio'])
|
||||
k8s_resource('meet-backend', resource_deps=['postgresql', 'minio', 'redis', 'livekit-livekit-server'])
|
||||
k8s_resource('meet-celery-backend', resource_deps=['redis'])
|
||||
k8s_resource('meet-celery-summarize', resource_deps=['redis'])
|
||||
k8s_resource('meet-celery-summary-backend', resource_deps=['redis'])
|
||||
|
||||
+23
-31
@@ -15,44 +15,36 @@ services:
|
||||
ports:
|
||||
- "1081:1080"
|
||||
|
||||
garage:
|
||||
minio:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
image: dxflrs/garage:v2.4.1
|
||||
command: /garage server --single-node --default-bucket
|
||||
env_file:
|
||||
- env.d/development/garage
|
||||
image: quay.io/minio/minio
|
||||
environment:
|
||||
- GARAGE_DEFAULT_ACCESS_KEY=meet-access-key
|
||||
- GARAGE_DEFAULT_SECRET_KEY=meet-secret-access-key
|
||||
- GARAGE_DEFAULT_BUCKET=meet-media-storage
|
||||
- MINIO_ROOT_USER=meet
|
||||
- MINIO_ROOT_PASSWORD=password
|
||||
ports:
|
||||
- '127.0.0.1:9000:9000'
|
||||
- '9000:9000'
|
||||
- '9001:9001'
|
||||
healthcheck:
|
||||
test: [ "CMD", "/garage", "health" ]
|
||||
test: [ "CMD", "mc", "ready", "local" ]
|
||||
interval: 1s
|
||||
timeout: 20s
|
||||
retries: 300
|
||||
entrypoint: ""
|
||||
command: minio server --console-address :9001 /data
|
||||
volumes:
|
||||
- ./docker/files/etc/garage/garage.toml:/etc/garage.toml:ro
|
||||
- ./data/media:/var/lib/garage
|
||||
- ./data/media:/data
|
||||
|
||||
# Garage denies cross-origin requests by default: allow the frontend to upload files
|
||||
garage-cors:
|
||||
image: amazon/aws-cli:2.37.1
|
||||
environment:
|
||||
- AWS_ACCESS_KEY_ID=meet-access-key
|
||||
- AWS_SECRET_ACCESS_KEY=meet-secret-access-key
|
||||
- AWS_DEFAULT_REGION=local
|
||||
createbuckets:
|
||||
image: quay.io/minio/mc
|
||||
depends_on:
|
||||
garage:
|
||||
minio:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
command:
|
||||
- s3api
|
||||
- put-bucket-cors
|
||||
- --endpoint-url=http://garage:9000
|
||||
- --bucket=meet-media-storage
|
||||
- '--cors-configuration={"CORSRules": [{"AllowedOrigins": ["http://localhost:3000"], "AllowedMethods": ["GET", "HEAD", "PUT"], "AllowedHeaders": ["*"], "ExposeHeaders": ["ETag"]}]}'
|
||||
entrypoint: >
|
||||
sh -c "
|
||||
/usr/bin/mc alias set meet http://minio:9000 meet password && \
|
||||
/usr/bin/mc mb meet/meet-media-storage && \
|
||||
exit 0;"
|
||||
|
||||
app-dev:
|
||||
build:
|
||||
@@ -78,7 +70,7 @@ services:
|
||||
- postgresql
|
||||
- mailcatcher
|
||||
- redis
|
||||
- garage-cors
|
||||
- createbuckets
|
||||
extra_hosts:
|
||||
- "127.0.0.1.nip.io:host-gateway"
|
||||
networks:
|
||||
@@ -118,7 +110,7 @@ services:
|
||||
- postgresql
|
||||
- redis
|
||||
- livekit
|
||||
- garage
|
||||
- minio
|
||||
|
||||
celery:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
@@ -252,7 +244,7 @@ services:
|
||||
- /app/.venv
|
||||
depends_on:
|
||||
- livekit
|
||||
- garage
|
||||
- minio
|
||||
develop:
|
||||
watch:
|
||||
- action: rebuild
|
||||
@@ -305,7 +297,7 @@ services:
|
||||
depends_on:
|
||||
- redis-summary
|
||||
- app-summary-dev
|
||||
- garage
|
||||
- minio
|
||||
develop:
|
||||
watch:
|
||||
- action: rebuild
|
||||
@@ -325,7 +317,7 @@ services:
|
||||
depends_on:
|
||||
- redis-summary
|
||||
- app-summary-dev
|
||||
- garage
|
||||
- minio
|
||||
develop:
|
||||
watch:
|
||||
- action: rebuild
|
||||
|
||||
+1
-1
@@ -163,7 +163,7 @@ in
|
||||
REDIS_URL = "redis://127.0.0.1:6379/1";
|
||||
CELERY_BROKER_URL = "redis://127.0.0.1:6379/0";
|
||||
|
||||
# S3 / Garage
|
||||
# S3 / MinIO
|
||||
AWS_S3_ENDPOINT_URL = "http://127.0.0.1:9000";
|
||||
|
||||
# OIDC
|
||||
|
||||
@@ -19,9 +19,7 @@ services:
|
||||
<<: *keep-id
|
||||
celery-dev:
|
||||
<<: *keep-id
|
||||
garage:
|
||||
<<: *keep-id
|
||||
garage-cors:
|
||||
minio:
|
||||
<<: *keep-id
|
||||
node:
|
||||
<<: *keep-id
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
# Garage configuration for local development only: single node, no replication.
|
||||
# See https://garagehq.deuxfleurs.fr/documentation/reference-manual/configuration/
|
||||
metadata_dir = "/var/lib/garage/meta"
|
||||
data_dir = "/var/lib/garage/data"
|
||||
db_engine = "lmdb"
|
||||
|
||||
replication_factor = 1
|
||||
|
||||
rpc_bind_addr = "127.0.0.1:3901"
|
||||
rpc_public_addr = "127.0.0.1:3901"
|
||||
|
||||
[s3_api]
|
||||
api_bind_addr = "[::]:9000"
|
||||
# Clients must sign their requests for this region (AWS_S3_REGION_NAME)
|
||||
s3_region = "local"
|
||||
@@ -17,9 +17,9 @@ server {
|
||||
proxy_set_header X-Amz-Date $authDate;
|
||||
proxy_set_header X-Amz-Content-SHA256 $authContentSha256;
|
||||
|
||||
# Get resource from Garage
|
||||
proxy_pass http://garage:9000/meet-media-storage/;
|
||||
proxy_set_header Host garage:9000;
|
||||
# Get resource from Minio
|
||||
proxy_pass http://minio:9000/meet-media-storage/;
|
||||
proxy_set_header Host minio:9000;
|
||||
# To use with ds_proxy
|
||||
# proxy_pass http://ds-proxy:4444/upstream/meet-media-storage/;
|
||||
# proxy_set_header Host ds-proxy:4444;
|
||||
|
||||
@@ -13,7 +13,7 @@ These components rely on a few key services:
|
||||
|
||||
- PostgreSQL for storing data (users, rooms, recordings)
|
||||
- Redis for caching and inter-service communication
|
||||
- Garage for storing files (room recordings)
|
||||
- MinIO for storing files (room recordings)
|
||||
- Celery workers for meeting transcript (optional, required for AI beta features)
|
||||
|
||||
We provide two stack options for getting Visio up and running for development:
|
||||
|
||||
+34
-41
@@ -93,13 +93,13 @@ sequenceDiagram
|
||||
| **RECORDING_WORKER_CLASSES** | Dict | `{ "screen_recording": "core.recording.worker.services.VideoCompositeEgressService", "transcript": "core.recording.worker.services.AudioCompositeEgressService" }` | Maps recording types to their worker service classes. |
|
||||
| **RECORDING_EXPIRATION_DAYS** | Integer | `None` | Number of days before recordings expire. Should match bucket lifecycle policy. Set to `None` for no expiration. |
|
||||
| **RECORDING_MAX_DURATION** | Integer | `None` | Maximum duration of a recording in milliseconds. Must be synced with the LiveKit Egress configuration. Set to None for unlimited duration. When the maximum duration is reached, the recording is automatically stopped and saved, and the user is prompted in the frontend with an alert message. |
|
||||
| **RECORDING_CUSTOM_ENCODING_ENABLED** | Boolean | `False` | Whether the start-recording API accepts a per-recording `encoding` object (resolution/profile) that overrides the default. When `False`, the API rejects per-recording `encoding`; when `True`, clients may pick from the available resolutions/profiles. The default encoding below is applied regardless of this flag. See [Tuning recording encoding](#tuning-recording-encoding). |
|
||||
| **RECORDING_ENCODING_AVAILABLE_RESOLUTIONS** | Dict | `{"540p": {"width": 960, "height": 540}, "720p": {"width": 1280, "height": 720}, "1080p": {"width": 1920, "height": 1080}}` | Maps a resolution name to its `{"width", "height"}` in pixels. Both the default encoding and the per-recording start-recording API pick from these keys. |
|
||||
| **RECORDING_ENCODING_AVAILABLE_PROFILES** | Dict | `{"full": {"fps": 30, "kbps": {…}}, …}` | Maps a profile name to `{"fps", "kbps": {resolution: video_bitrate_kbps}}`. Every profile must define a bitrate for each available resolution (validated at startup). |
|
||||
| **RECORDING_ENCODING_DEFAULT_RESOLUTION** | String | `"720p"` | Resolution used by the default encoding. When set, must be a key of `RECORDING_ENCODING_AVAILABLE_RESOLUTIONS`. Leave unset (together with, or instead of, the default profile) to disable the custom default encoding and fall back to LiveKit's built-in preset (a startup warning is emitted). |
|
||||
| **RECORDING_ENCODING_DEFAULT_PROFILE** | String | `"full"` | Profile used by the default encoding. When set, must be a key of `RECORDING_ENCODING_AVAILABLE_PROFILES`. Leave unset (together with, or instead of, the default resolution) to disable the custom default encoding and fall back to LiveKit's built-in preset (a startup warning is emitted). |
|
||||
| **RECORDING_ENCODING_AUDIO_BITRATE_KBPS** | Integer | `128` | AAC audio bitrate in kbps used in the default encoding. |
|
||||
| **RECORDING_ENCODING_KEY_FRAME_INTERVAL_S** | Float | `0.0` | Keyframe interval in seconds. Drives seek granularity in the recorded MP4 (a player can only seek to keyframe boundaries). Larger values give the encoder slightly more bits for non-keyframe content at a fixed bitrate. `0` leaves the field unset, letting the encoder pick; `4.0` is a standard VOD value. |
|
||||
| **RECORDING_ENCODING_ENABLED** | Boolean | `False` | When `False`, LiveKit Egress uses its built-in `H264_720P_30` preset. When `True`, the `RECORDING_ENCODING_*` values below are sent to LiveKit as advanced `EncodingOptions`. See [Tuning recording encoding](#tuning-recording-encoding). |
|
||||
| **RECORDING_ENCODING_WIDTH** | Integer | `1280` | Recording video width in pixels. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
|
||||
| **RECORDING_ENCODING_HEIGHT** | Integer | `720` | Recording video height in pixels. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
|
||||
| **RECORDING_ENCODING_FRAMERATE** | Integer | `30` | Recording video framerate (fps). Directly impacts egress worker CPU (roughly linear). Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
|
||||
| **RECORDING_ENCODING_VIDEO_BITRATE_KBPS** | Integer | `3000` | H.264 MAIN video bitrate in kbps. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
|
||||
| **RECORDING_ENCODING_AUDIO_BITRATE_KBPS** | Integer | `128` | AAC audio bitrate in kbps. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
|
||||
| **RECORDING_ENCODING_KEY_FRAME_INTERVAL_S** | Float | `4.0` | Keyframe interval in seconds. Drives seek granularity in the recorded MP4 (a player can only seek to keyframe boundaries). Larger values give the encoder slightly more bits for non-keyframe content at a fixed bitrate. `4.0` is a standard VOD value. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
|
||||
|
||||
|
||||
> [!NOTE]
|
||||
@@ -130,59 +130,52 @@ This allows you to verify which recordings are in progress, troubleshoot egress
|
||||
|
||||
## Tuning recording encoding
|
||||
|
||||
Every video recording is encoded from a default resolved from `RECORDING_ENCODING_DEFAULT_PROFILE` + `RECORDING_ENCODING_DEFAULT_RESOLUTION` and passed to LiveKit as advanced `EncodingOptions`. The shipped defaults (`full` profile) match LiveKit's built-in `H264_720P_30` preset. For a one-hour meeting that produces a file of roughly **1.4 GB**, which is often heavier than necessary for talking-head content and screen sharing; lowering the default profile/resolution shrinks it. If either default is left unset, no custom default encoding is built: a warning is logged at startup and LiveKit's built-in preset is used instead.
|
||||
By default, LiveKit Egress records with the built-in `H264_720P_30` preset: 1280×720 at 30 fps, 3000 kbps H.264 MAIN video and 128 kbps AAC audio. For a one-hour meeting this produces a file of roughly **1.4 GB**, which is often heavier than necessary for talking-head content and screen sharing.
|
||||
|
||||
Encoding is chosen from two maps: `RECORDING_ENCODING_AVAILABLE_RESOLUTIONS` (`resolution → {"width", "height"}`) and `RECORDING_ENCODING_AVAILABLE_PROFILES` (`profile → {"fps", "kbps": {resolution: video_bitrate_kbps}}`):
|
||||
|
||||
- **Default**: `RECORDING_ENCODING_DEFAULT_PROFILE` + `RECORDING_ENCODING_DEFAULT_RESOLUTION` set the encoding used by every recording that doesn't override it. Leave either unset to fall back to LiveKit's built-in preset (a startup warning is emitted).
|
||||
- **Per recording (opt-in)**: set `RECORDING_CUSTOM_ENCODING_ENABLED=True` to let clients override the default per recording. The start-recording API then accepts an `encoding` object selecting a `resolution` (required) and `profile` (optional): a resolution-only request keeps `RECORDING_ENCODING_DEFAULT_PROFILE` for fps and bitrate, so clients can only pick from pre-defined values. When `RECORDING_CUSTOM_ENCODING_ENABLED=False`, the API rejects any per-recording `encoding` and the default is used.
|
||||
|
||||
The resolved values are passed straight through LiveKit's `EncodingOptions.advanced` to the GStreamer pipeline (`x264enc` for video, `faac` for audio), so there are no hidden conversions — what the profile/resolution resolve to is what the encoder receives.
|
||||
The `RECORDING_ENCODING_*` settings let operators override this preset without modifying the source. Values are passed straight through LiveKit's `EncodingOptions.advanced` to the GStreamer pipeline (`x264enc` for video, `faac` for audio), so there are no hidden conversions — what you set is what the encoder receives.
|
||||
|
||||
### How values map to GStreamer
|
||||
|
||||
| Resolved value | GStreamer element | Property |
|
||||
| ----------------------------------------- | ----------------- | ---------------------------------- |
|
||||
| resolution `width` / `height` | capsfilter | `video/x-raw,width=W,height=H` |
|
||||
| profile `fps` | capsfilter | `framerate=F/1` |
|
||||
| profile `kbps[resolution]` | `x264enc` | `bitrate=kbps` (kilobits) |
|
||||
| `RECORDING_ENCODING_KEY_FRAME_INTERVAL_S` | `x264enc` | `key-int-max = interval × fps` |
|
||||
| `RECORDING_ENCODING_AUDIO_BITRATE_KBPS` | `faac` | `bitrate = kbps × 1000` (bits) |
|
||||
| Setting | GStreamer element | Property |
|
||||
| ------------------------------------- | ----------------- | ---------------------------------- |
|
||||
| `RECORDING_ENCODING_WIDTH/HEIGHT` | capsfilter | `video/x-raw,width=W,height=H` |
|
||||
| `RECORDING_ENCODING_FRAMERATE` | capsfilter | `framerate=F/1` |
|
||||
| `RECORDING_ENCODING_VIDEO_BITRATE_KBPS` | `x264enc` | `bitrate=kbps` (kilobits) |
|
||||
| `RECORDING_ENCODING_KEY_FRAME_INTERVAL_S` | `x264enc` | `key-int-max = interval × fps` |
|
||||
| `RECORDING_ENCODING_AUDIO_BITRATE_KBPS` | `faac` | `bitrate = kbps × 1000` (bits) |
|
||||
|
||||
The H.264 profile is fixed to MAIN and the x264 `speed-preset` to `veryfast` by LiveKit (real-time constraint) — lowering the framerate is therefore the main lever to save CPU, while lowering the bitrate is the main lever to shrink the output file.
|
||||
|
||||
### Built-in profiles
|
||||
### Reference profiles
|
||||
|
||||
The default `RECORDING_ENCODING_AVAILABLE_PROFILES` ship four profiles. Framerate is fixed per profile; video bitrate (kbps) scales with resolution so quality stays consistent across sizes. File size scales roughly with `framerate × bitrate`, and so does egress CPU cost.
|
||||
Rough 30-minute file-size estimates assume video + audio bitrate multiplied by duration. Actual sizes vary with content (static talking heads compress better than heavy screen motion). Egress CPU figures are indicative, measured on a single Ryzen laptop core saturated by the default preset (= 100 %); scaling is roughly linear with `framerate × bitrate` but the absolute numbers depend on the host hardware.
|
||||
|
||||
| Profile | FPS | 540p (kbps) | 720p (kbps) | 1080p (kbps) | Suitable for |
|
||||
| --------------- | --- | ----------- | ----------- | ------------ | -------------------------------------------------- |
|
||||
| `talking_heads` | 15 | 400 | 700 | 1200 | Talking-head dominant meetings + occasional slides |
|
||||
| `text` | 15 | 600 | 1000 | 1800 | Frequent dense screen sharing (decks, IDE, docs) |
|
||||
| `mixed` | 20 | 900 | 1500 | 2500 | Mixed content, moderate motion |
|
||||
| `full` | 30 | 2000 | 3000 | 4500 | Highest fidelity; closest to the LiveKit default preset |
|
||||
| Profile | Resolution | FPS | Video (kbps) | Audio (kbps) | Keyframe (s) | ~ size / 30 min | Egress CPU (vs. default) | Suitable for |
|
||||
| ---------------------- | ---------- | --- | ------------ | ------------ | ------------ | --------------- | ------------------------ | --------------------------------------------------- |
|
||||
| Default (preset) | 1280×720 | 30 | 3000 | 128 | 4 | **~690 MB** | 100 % | Unchanged LiveKit behaviour |
|
||||
| Balanced | 1280×720 | 20 | 1000 | 96 | 4 | ~240 MB | ~67 % | Mixed content, moderate motion |
|
||||
| **Low CPU / small file** | 1280×720 | 15 | 600 | 64 | 4 | **~150 MB** | ~50 % | Talking-head dominant meetings + occasional slides ★ |
|
||||
| Slide-heavy | 1280×720 | 15 | 900 | 64 | 4 | ~210 MB | ~55 % | Frequent dense screen sharing (decks, IDE, docs) |
|
||||
| Minimum CPU | 960×540 | 15 | 500 | 64 | 4 | ~125 MB | ~30 % | Voice-first meetings, readable text not required |
|
||||
| Audio-heavy fallback | 1280×720 | 10 | 400 | 96 | 4 | ~110 MB | ~35 % | Long webinars, low motion |
|
||||
|
||||
To pick a profile per recording (requires `RECORDING_CUSTOM_ENCODING_ENABLED=True`), the client sends it in the start-recording request:
|
||||
★ Recommended starting point for typical LaSuite Meet usage.
|
||||
|
||||
```json
|
||||
{
|
||||
"mode": "screen_recording",
|
||||
"options": {"encoding": {"resolution": "720p", "profile": "talking_heads"}}
|
||||
}
|
||||
```
|
||||
|
||||
To change the default encoding applied to every recording:
|
||||
Environment variables for the **Low CPU / small file** profile:
|
||||
|
||||
```bash
|
||||
RECORDING_ENCODING_DEFAULT_RESOLUTION=720p
|
||||
RECORDING_ENCODING_DEFAULT_PROFILE=talking_heads
|
||||
RECORDING_ENCODING_ENABLED=True
|
||||
RECORDING_ENCODING_WIDTH=1280
|
||||
RECORDING_ENCODING_HEIGHT=720
|
||||
RECORDING_ENCODING_FRAMERATE=15
|
||||
RECORDING_ENCODING_VIDEO_BITRATE_KBPS=600
|
||||
RECORDING_ENCODING_AUDIO_BITRATE_KBPS=64
|
||||
RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=4.0
|
||||
```
|
||||
|
||||
### Caveats
|
||||
|
||||
- **Screen-share readability — think bits/frame, not bitrate**: at 720p, text legibility starts to break down below ~40 kbits/frame (= `bitrate ÷ framerate`). The `talking_heads` profile (700 kbps × 15 fps) sits just above that threshold, comfortable for talking heads with occasional slide sharing. The same bitrate at 30 fps would only deliver ~23 kbits/frame and visibly blur dense slides — which is why **lowering framerate is a more screen-share-friendly lever than lowering bitrate**. For deck-heavy or IDE-share meetings, prefer the **`text`** profile (1000 kbps × 15 fps ≈ 67 kbits/frame).
|
||||
- **Screen-share readability — think bits/frame, not bitrate**: at 720p, text legibility starts to break down below ~40 kbits/frame (= `bitrate ÷ framerate`). The recommended preset (600 kbps × 15 fps) sits at exactly that threshold, comfortable for talking heads with occasional slide sharing. The same 600 kbps at 30 fps would only deliver 20 kbits/frame and visibly blur dense slides — which is why **lowering framerate is a more screen-share-friendly lever than lowering bitrate**. For deck-heavy or IDE-share meetings, prefer the **Slide-heavy** profile (900 kbps × 15 fps ≈ 60 kbits/frame).
|
||||
- **Motion handling**: the `veryfast` x264 preset is set by LiveKit and cannot be overridden here. Low-bitrate settings will therefore show more artefacts on fast motion than an offline re-encode with a slower preset would. This is the other reason FPS reduction is the safer tuning lever for meeting recordings.
|
||||
- **Audio**: AAC at 64 kbps stereo is transparent for voice but starts to compress music noticeably. Keep 128 kbps if you expect music playback in meetings.
|
||||
- **Codec choice**: H.264 MAIN is hardcoded on purpose. Switching to HEVC or VP9 would increase egress CPU cost 2×–5×, defeating the goal of this tuning.
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
# Room purge
|
||||
|
||||
Rooms pile up over time and most of them are only used once. The `purge_inactive_rooms` management command permanently deletes the rooms that have not been started for a configurable number of days. It is disabled by default.
|
||||
|
||||
## How it works
|
||||
|
||||
Each time LiveKit tells the backend that a room has started (`room_started` webhook), the backend records the date on the room (`last_started_at`).
|
||||
A room is inactive when:
|
||||
|
||||
- it was last started more than `ROOM_INACTIVITY_DELETION_DAYS` days ago, or
|
||||
- it was never started and was created more than `ROOM_INACTIVITY_DELETION_DAYS` days ago.
|
||||
|
||||
Rooms that existed before this feature was deployed are considered started on the day of the release, so none of them can be purged before a full inactivity period has elapsed.
|
||||
|
||||
The command is meant to run once a day. The Helm chart schedules it in `backend.cronjobs` (`purge-inactive-rooms`, 01:00); it does nothing until `ROOM_INACTIVITY_DELETION_DAYS` is set.
|
||||
|
||||
```bash
|
||||
python manage.py purge_inactive_rooms # delete the inactive rooms
|
||||
python manage.py purge_inactive_rooms --dry-run # only list the rooms that would be deleted
|
||||
```
|
||||
|
||||
## Rooms that are kept
|
||||
|
||||
A recording can only be reached through its room. An inactive room is kept as long as it holds a saved recording its users may still access:
|
||||
|
||||
- with `RECORDING_EXPIRATION_DAYS` set, a saved recording created less than that many days ago,
|
||||
- with `RECORDING_EXPIRATION_DAYS` unset, any saved recording.
|
||||
|
||||
## What happens to a purged room
|
||||
|
||||
The room is deleted from the database, along with its accesses, its telephony PIN code, and the recording entries it still holds — the expired ones and those that were never saved, since any other recording would have protected the room — together with their own accesses.
|
||||
|
||||
The recording **files in the bucket are left untouched**: the backend never deletes anything from the storage, it only drops the database entries pointing at it. Removing the files is the job of the bucket lifecycle policy, which should match `RECORDING_EXPIRATION_DAYS` (see the [recording documentation](recording.md)). When the two do not match, the purge leaves objects behind: they become unreachable, since serving a recording requires its database entry, but they keep costing storage.
|
||||
|
||||
⚠️ When a room is purged, all it's configuration and access rights are also deleted. Its slug becomes available again
|
||||
and can be reused when a meeting is created from that same URL.
|
||||
|
||||
* With `ALLOW_UNREGISTERED_ROOMS=false`, only an authenticated user can navigate to a previously existing link after the room has been purged. Doing so recreates the room in the database with a fresh configuration, with that user associated with it and granted admin rights.
|
||||
|
||||
* With `ALLOW_UNREGISTERED_ROOMS=true`, any user can reopen the purged room by navigating to the same URL. In that case, the room is created dynamically and no corresponding room entry is persisted in the database.
|
||||
@@ -42,7 +42,7 @@ sequenceDiagram
|
||||
participant Backend as Backend API
|
||||
participant Summary as Summary Service
|
||||
participant Celery as Celery Workers (transcribe-queue)
|
||||
participant S3 as S3 (Object Storage)
|
||||
participant MinIO as MinIO (Object Storage)
|
||||
participant STT as WhisperX API
|
||||
participant Docs as LaSuite Docs
|
||||
|
||||
@@ -50,7 +50,7 @@ sequenceDiagram
|
||||
Note right of Backend: Payload contains 7 params: owner_id, filename, email, sub, room, recording_date, recording_time
|
||||
|
||||
Summary->>Celery: Register task (transcribe-queue)
|
||||
Celery->>S3: Fetch audio file
|
||||
Celery->>MinIO: Fetch audio file
|
||||
Celery->>STT: Transcribe audio (WhisperX)
|
||||
STT-->>Celery: Segmented transcript
|
||||
|
||||
@@ -72,12 +72,11 @@ sequenceDiagram
|
||||
| celery_result_backend | String | `"redis://redis/0"` | Celery result backend URL. |
|
||||
| celery_max_retries | Integer | `1` | Maximum number of retries for Celery tasks. |
|
||||
| transcribe_queue | String | `"transcribe-queue"` | Name of the Celery queue for transcription tasks. |
|
||||
| aws_storage_bucket_name | String | — | Name of the S3 bucket used for storing recordings. |
|
||||
| aws_s3_endpoint_url | String | — | Endpoint URL of the S3 storage. |
|
||||
| aws_s3_access_key_id | String | — | Access key for S3. |
|
||||
| aws_s3_secret_access_key | Secret | — | Secret key for S3. |
|
||||
| aws_s3_secure_access | Boolean | `True` | Use HTTPS for S3 requests. |
|
||||
| aws_s3_region_name | String | — | Region used to sign S3 requests, passed as-is to boto3. |
|
||||
| aws_storage_bucket_name | String | — | Name of the S3/MinIO bucket used for storing recordings. |
|
||||
| aws_s3_endpoint_url | String | — | Endpoint URL of the S3/MinIO storage. |
|
||||
| aws_s3_access_key_id | String | — | Access key for S3/MinIO. |
|
||||
| aws_s3_secret_access_key | Secret | — | Secret key for S3/MinIO. |
|
||||
| aws_s3_secure_access | Boolean | `True` | Use HTTPS for S3/MinIO requests. |
|
||||
| whisperx_api_key | Secret | — | API key for accessing WhisperX. |
|
||||
| whisperx_base_url | String | `"https://api.whisperx.com/v1"` | Base URL for the WhisperX API. |
|
||||
| whisperx_asr_model | String | `"whisper-1"` | ASR model used for transcription. |
|
||||
|
||||
@@ -14,7 +14,7 @@ All services are required to run the minimalist instance of LaSuite Meet. Click
|
||||
| **OIDC Provider** | User authentication | [Keycloak setup](../examples/compose/keycloak/README.md) |
|
||||
| **SMTP Service** | Email notifications | - |
|
||||
|
||||
> [!NOTE] Some advanced features, as Recording and transcription, require additional services (S3-compatible object storage, email). See `/features` folder for details.
|
||||
> [!NOTE] Some advanced features, as Recording and transcription, require additional services (MinIO, email). See `/features` folder for details.
|
||||
|
||||
|
||||
## Software Requirements
|
||||
|
||||
@@ -403,7 +403,6 @@ These are the environmental options available on meet backend.
|
||||
| LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND | Firefox-only connection warmup: pre-calls WebSocket endpoint (expecting 401) to initialize cache, resolving proxy/network connectivity issues. | false |
|
||||
| RESOURCE_DEFAULT_ACCESS_LEVEL | Default resource access level for rooms | public |
|
||||
| ALLOW_UNREGISTERED_ROOMS | Allow usage of unregistered rooms | true |
|
||||
| ROOM_INACTIVITY_DELETION_DAYS | Days without being started after which a room is purged. Unset to never purge | |
|
||||
| RECORDING_ENABLE | Record meeting option | false |
|
||||
| RECORDING_OUTPUT_FOLDER | Folder to store meetings | recordings |
|
||||
| RECORDING_WORKER_CLASSES | Worker classes for recording | {"screen_recording": "core.recording.worker.services.VideoCompositeEgressService","transcript": "core.recording.worker.services.AudioCompositeEgressService"} |
|
||||
|
||||
@@ -24,10 +24,9 @@ MEET_BASE_URL="http://localhost:8072"
|
||||
# Media
|
||||
STORAGES_STATICFILES_BACKEND=django.contrib.staticfiles.storage.StaticFilesStorage
|
||||
AWS_S3_DOMAIN_REPLACE=http://localhost:9000
|
||||
AWS_S3_ENDPOINT_URL=http://garage:9000
|
||||
AWS_S3_ACCESS_KEY_ID=meet-access-key
|
||||
AWS_S3_SECRET_ACCESS_KEY=meet-secret-access-key
|
||||
AWS_S3_REGION_NAME=local
|
||||
AWS_S3_ENDPOINT_URL=http://minio:9000
|
||||
AWS_S3_ACCESS_KEY_ID=meet
|
||||
AWS_S3_SECRET_ACCESS_KEY=password
|
||||
MEDIA_BASE_URL=http://localhost:3000
|
||||
FILE_UPLOAD_ENABLED=True
|
||||
|
||||
@@ -64,39 +63,23 @@ ALLOW_UNREGISTERED_ROOMS=False
|
||||
|
||||
# Recording
|
||||
RECORDING_ENABLE=True
|
||||
SUMMARY_SERVICE_VERSION=2
|
||||
SUMMARY_SERVICE_ENDPOINT=http://app-summary-dev:8000/api/v2/async-jobs/transcribe
|
||||
SUMMARY_SERVICE_ENDPOINT=http://app-summary-dev:8000/api/v2/async-jobs/transcribe/
|
||||
SUMMARY_SERVICE_API_TOKEN=password
|
||||
SUMMARY_SERVICE_WEBHOOK_API_TOKEN=webhook-password
|
||||
RECORDING_DOWNLOAD_BASE_URL=http://localhost:3000/recording
|
||||
|
||||
|
||||
# Recording encoding (LiveKit Egress advanced options).
|
||||
# Every video recording is encoded with parameters (height, width, fps, kbps) derived
|
||||
# from the pair (profile, resolution) and passed to LiveKit as advanced EncodingOptions.
|
||||
# Choose the available resolutions and profiles that default settings and users can
|
||||
# pick from. They must be defined as a single-line dict literal (parsed with
|
||||
# ast.literal_eval: double-quoted keys, no trailing comma, no outer quotes).
|
||||
# Every profile must define a kbps entry for exactly the keys of
|
||||
# RECORDING_ENCODING_AVAILABLE_RESOLUTIONS (validated at startup).
|
||||
# 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}}}
|
||||
|
||||
|
||||
# Choose the default named resolution and profile to use by default. These values must
|
||||
# be keys of RECORDING_ENCODING_AVAILABLE_RESOLUTIONS and RECORDING_ENCODING_AVAILABLE_PROFILES.
|
||||
# RECORDING_ENCODING_DEFAULT_RESOLUTION=720p
|
||||
# RECORDING_ENCODING_DEFAULT_PROFILE=full
|
||||
|
||||
# Default encoding values independant of resolution/profile
|
||||
# When RECORDING_ENCODING_ENABLED is False (default), LiveKit uses its built-in
|
||||
# H264_720P_30 preset (1280x720, 30fps, 3000 kbps). Enable and tune to reduce
|
||||
# file size and CPU load on the egress worker.
|
||||
# RECORDING_ENCODING_ENABLED=False
|
||||
# RECORDING_ENCODING_WIDTH=1280
|
||||
# RECORDING_ENCODING_HEIGHT=720
|
||||
# RECORDING_ENCODING_FRAMERATE=30
|
||||
# RECORDING_ENCODING_VIDEO_BITRATE_KBPS=3000
|
||||
# RECORDING_ENCODING_AUDIO_BITRATE_KBPS=128
|
||||
# RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=4.0
|
||||
|
||||
# Set to True to let the start-recording API override that default per recording
|
||||
# with an `encoding` object, e.g. {"resolution": "720p", "profile": "talking_heads"}.
|
||||
# RECORDING_CUSTOM_ENCODING_ENABLED=False
|
||||
|
||||
|
||||
# Telephony
|
||||
ROOM_TELEPHONY_ENABLED=True
|
||||
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
# Filled with a random value by `make create-env-files`
|
||||
GARAGE_RPC_SECRET=
|
||||
@@ -2,9 +2,8 @@ LIVEKIT_URL=ws://livekit:7880
|
||||
LIVEKIT_API_KEY=devkey
|
||||
LIVEKIT_API_SECRET=secret
|
||||
|
||||
AWS_S3_ENDPOINT_URL=garage:9000
|
||||
AWS_S3_ACCESS_KEY_ID=meet-access-key
|
||||
AWS_S3_SECRET_ACCESS_KEY=meet-secret-access-key
|
||||
AWS_S3_REGION_NAME=local
|
||||
AWS_S3_ENDPOINT_URL=minio:9000
|
||||
AWS_S3_ACCESS_KEY_ID=meet
|
||||
AWS_S3_SECRET_ACCESS_KEY=password
|
||||
AWS_STORAGE_BUCKET_NAME=meet-media-storage
|
||||
AWS_S3_SECURE_ACCESS=False
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
AWS_S3_ENDPOINT_URL=garage:9000
|
||||
AWS_S3_ACCESS_KEY_ID=meet-access-key
|
||||
AWS_S3_SECRET_ACCESS_KEY=meet-secret-access-key
|
||||
AWS_S3_REGION_NAME=local
|
||||
AWS_S3_ENDPOINT_URL=minio:9000
|
||||
AWS_S3_ACCESS_KEY_ID=meet
|
||||
AWS_S3_SECRET_ACCESS_KEY=password
|
||||
|
||||
LIVEKIT_URL=ws://livekit:7880
|
||||
LIVEKIT_API_KEY=devkey
|
||||
|
||||
@@ -2,12 +2,11 @@ APP_NAME="meet-app-summary-dev"
|
||||
APP_API_TOKEN="password"
|
||||
|
||||
AWS_STORAGE_BUCKET_NAME="meet-media-storage"
|
||||
AWS_S3_ENDPOINT_URL="garage:9000"
|
||||
AWS_S3_ENDPOINT_URL="minio:9000"
|
||||
AWS_S3_SECURE_ACCESS=false
|
||||
|
||||
AWS_S3_ACCESS_KEY_ID="meet-access-key"
|
||||
AWS_S3_SECRET_ACCESS_KEY="meet-secret-access-key"
|
||||
AWS_S3_REGION_NAME="local"
|
||||
AWS_S3_ACCESS_KEY_ID="meet"
|
||||
AWS_S3_SECRET_ACCESS_KEY="password"
|
||||
|
||||
WHISPERX_BASE_URL="https://configure-your-url.com"
|
||||
WHISPERX_ASR_MODEL="large-v2"
|
||||
|
||||
Generated
+4
-4
@@ -10,7 +10,7 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-js": "3.50.0",
|
||||
"i18next": "26.4.2",
|
||||
"i18next": "26.4.1",
|
||||
"i18next-browser-languagedetector": "8.2.1",
|
||||
"regenerator-runtime": "0.14.1"
|
||||
},
|
||||
@@ -9367,9 +9367,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "26.4.2",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.2.tgz",
|
||||
"integrity": "sha512-RX+R0VLg13IbvRuJSxnqykUFS9vQZTl8wYpWPCIUDWVrSGjsQywB5Y+pjzrkboxGAuYfJZVH1InFTdgBdxq6ug==",
|
||||
"version": "26.4.1",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.1.tgz",
|
||||
"integrity": "sha512-9YbX5E6gd1H+yaOSX3izCsPj5iWXyH7X4oC+iuHHJJw8AeHglzOK5SJf+1CHxaYKYigcea+8jvzSxqYx46YvyA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"core-js": "3.50.0",
|
||||
"i18next": "26.4.2",
|
||||
"i18next": "26.4.1",
|
||||
"i18next-browser-languagedetector": "8.2.1",
|
||||
"regenerator-runtime": "0.14.1"
|
||||
},
|
||||
|
||||
@@ -5,7 +5,6 @@ RUN sed -i "s|^URIs: http://|URIs: https://|" /etc/apt/sources.list.d/debian.sou
|
||||
&& apt-get update && apt-get install -y --no-install-recommends \
|
||||
libglib2.0-0 \
|
||||
libgobject-2.0-0 \
|
||||
libssl3t64 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
||||
|
||||
@@ -6,11 +6,9 @@ import logging
|
||||
import os
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime, timezone
|
||||
from io import BytesIO
|
||||
from typing import List, Optional
|
||||
|
||||
import boto3
|
||||
from botocore.config import Config
|
||||
from botocore.exceptions import BotoCoreError, ClientError
|
||||
from dotenv import load_dotenv
|
||||
from livekit import api, rtc
|
||||
from livekit.agents import (
|
||||
@@ -30,6 +28,8 @@ from livekit.agents import (
|
||||
room_io as lk_room_io,
|
||||
)
|
||||
from livekit.plugins import silero
|
||||
from minio import Minio
|
||||
from minio.error import S3Error
|
||||
|
||||
from exceptions import MissingConfigError
|
||||
from observability import configure_sentry, set_job_context
|
||||
@@ -59,30 +59,6 @@ server = AgentServer(
|
||||
server.setup_fnc = prewarm
|
||||
|
||||
|
||||
def create_s3_client():
|
||||
"""Create an S3 client for the configured endpoint and region.
|
||||
|
||||
The endpoint may be given with or without a scheme: the scheme always
|
||||
follows AWS_S3_SECURE_ACCESS.
|
||||
"""
|
||||
endpoint = (
|
||||
os.getenv("AWS_S3_ENDPOINT_URL", "")
|
||||
.removeprefix("https://")
|
||||
.removeprefix("http://")
|
||||
.rstrip("/")
|
||||
)
|
||||
secure = os.getenv("AWS_S3_SECURE_ACCESS", "False").lower() == "true"
|
||||
|
||||
return boto3.client(
|
||||
"s3",
|
||||
endpoint_url=f"{'https' if secure else 'http'}://{endpoint}",
|
||||
aws_access_key_id=os.getenv("AWS_S3_ACCESS_KEY_ID"),
|
||||
aws_secret_access_key=os.getenv("AWS_S3_SECRET_ACCESS_KEY"),
|
||||
region_name=os.getenv("AWS_S3_REGION_NAME"),
|
||||
config=Config(signature_version="s3v4", s3={"addressing_style": "path"}),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MetadataEvent:
|
||||
"""A single timestamped event recorded during a meeting."""
|
||||
@@ -145,13 +121,18 @@ class MetadataCollector:
|
||||
|
||||
def __init__(self, ctx: JobContext, recording_id: str):
|
||||
"""Initialize metadata agent."""
|
||||
self.minio_client = Minio(
|
||||
endpoint=os.getenv("AWS_S3_ENDPOINT_URL"),
|
||||
access_key=os.getenv("AWS_S3_ACCESS_KEY_ID"),
|
||||
secret_key=os.getenv("AWS_S3_SECRET_ACCESS_KEY"),
|
||||
secure=os.getenv("AWS_S3_SECURE_ACCESS", "False").lower() == "true",
|
||||
)
|
||||
|
||||
if (bucket_name := os.getenv("AWS_STORAGE_BUCKET_NAME")) is not None:
|
||||
self.bucket_name = bucket_name
|
||||
else:
|
||||
raise MissingConfigError
|
||||
|
||||
self.s3_client = create_s3_client()
|
||||
|
||||
self.ctx = ctx
|
||||
self._sessions: dict[str, AgentSession] = {}
|
||||
self._tasks: set[asyncio.Task] = set()
|
||||
@@ -220,18 +201,20 @@ class MetadataCollector:
|
||||
}
|
||||
|
||||
data = json.dumps(payload, indent=2).encode("utf-8")
|
||||
stream = BytesIO(data)
|
||||
|
||||
try:
|
||||
self.s3_client.put_object(
|
||||
Bucket=self.bucket_name,
|
||||
Key=self.output_filename,
|
||||
Body=data,
|
||||
ContentType="application/json",
|
||||
self.minio_client.put_object(
|
||||
self.bucket_name,
|
||||
self.output_filename,
|
||||
stream,
|
||||
length=len(data),
|
||||
content_type="application/json",
|
||||
)
|
||||
logger.info(
|
||||
"Uploaded speaker meeting metadata",
|
||||
)
|
||||
except (BotoCoreError, ClientError):
|
||||
except S3Error:
|
||||
logger.exception(
|
||||
"Failed to upload meeting metadata",
|
||||
)
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
|
||||
[project]
|
||||
name = "agents"
|
||||
version = "1.32.1"
|
||||
version = "1.31.0"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"livekit-agents==1.7.0",
|
||||
"livekit-plugins-deepgram==1.7.0",
|
||||
"livekit-plugins-silero==1.7.0",
|
||||
"livekit-agents==1.6.7",
|
||||
"livekit-plugins-deepgram==1.6.7",
|
||||
"livekit-plugins-silero==1.6.7",
|
||||
"livekit-plugins-kyutai-lasuite==0.0.6",
|
||||
"boto3==1.43.56",
|
||||
"python-dotenv==1.2.3",
|
||||
"protobuf==7.36.0",
|
||||
"sentry-sdk==2.68.1",
|
||||
"python-dotenv==1.2.2",
|
||||
"protobuf==6.33.6",
|
||||
"minio==7.2.20",
|
||||
"sentry-sdk==2.66.1",
|
||||
"websockets==17.1",
|
||||
"httpx==0.28.1",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"ruff==0.16.4",
|
||||
"ruff==0.16.0",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
|
||||
Generated
+856
-861
File diff suppressed because it is too large
Load Diff
@@ -279,16 +279,9 @@ class RoomAdmin(admin.ModelAdmin):
|
||||
|
||||
inlines = (ResourceAccessInline,)
|
||||
search_fields = ["name", "slug", "=id"]
|
||||
list_display = [
|
||||
"name",
|
||||
"slug",
|
||||
"access_level",
|
||||
"get_owner",
|
||||
"created_at",
|
||||
"last_started_at",
|
||||
]
|
||||
list_filter = ["access_level", "created_at", "last_started_at"]
|
||||
readonly_fields = ["id", "created_at", "updated_at", "last_started_at"]
|
||||
list_display = ["name", "slug", "access_level", "get_owner", "created_at"]
|
||||
list_filter = ["access_level", "created_at"]
|
||||
readonly_fields = ["id", "created_at", "updated_at"]
|
||||
|
||||
def get_queryset(self, request):
|
||||
"""Optimize queries by prefetching related access and user data to avoid N+1 queries."""
|
||||
|
||||
@@ -76,6 +76,9 @@ def get_frontend_configuration(request):
|
||||
"authenticated_users_can_edit_display_name": (
|
||||
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME
|
||||
),
|
||||
"encryption": {
|
||||
"is_enabled": settings.ENCRYPTION_ENABLED,
|
||||
},
|
||||
}
|
||||
frontend_configuration.update(settings.FRONTEND_CONFIGURATION)
|
||||
return Response(frontend_configuration)
|
||||
|
||||
@@ -13,12 +13,7 @@ from django.core.exceptions import SuspiciousOperation
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from django_pydantic_field.rest_framework import SchemaField
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
Field,
|
||||
field_serializer,
|
||||
field_validator,
|
||||
)
|
||||
from pydantic import BaseModel, Field, field_serializer
|
||||
from pydantic import ValidationError as PydanticValidationError
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
@@ -43,6 +38,7 @@ class UserSerializer(serializers.ModelSerializer):
|
||||
"short_name",
|
||||
"timezone",
|
||||
"language",
|
||||
"default_encryption_mode",
|
||||
"default_room_access_level",
|
||||
"default_room_configuration",
|
||||
]
|
||||
@@ -58,6 +54,14 @@ class UserSerializer(serializers.ModelSerializer):
|
||||
raise serializers.ValidationError(e.errors()) from e
|
||||
return value
|
||||
|
||||
def validate_default_encryption_mode(self, value):
|
||||
"""Reject a non-none default when the server has encryption disabled."""
|
||||
if value != models.EncryptionMode.NONE and not settings.ENCRYPTION_ENABLED:
|
||||
raise serializers.ValidationError(
|
||||
_("End-to-end encryption is disabled on this server.")
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
class UserLightSerializer(serializers.ModelSerializer):
|
||||
"""Serialize users with limited fields."""
|
||||
@@ -99,6 +103,7 @@ class ResourceAccessSerializerMixin:
|
||||
raise PermissionDenied(
|
||||
"Only owners of a room can assign other users as owners."
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
def validate_resource(self, resource):
|
||||
@@ -153,7 +158,15 @@ class RoomSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = models.Room
|
||||
fields = ["id", "name", "slug", "configuration", "access_level", "pin_code"]
|
||||
fields = [
|
||||
"id",
|
||||
"name",
|
||||
"slug",
|
||||
"configuration",
|
||||
"access_level",
|
||||
"pin_code",
|
||||
"encryption_mode",
|
||||
]
|
||||
read_only_fields = ["id", "slug", "pin_code"]
|
||||
|
||||
def validate_configuration(self, value):
|
||||
@@ -166,6 +179,32 @@ class RoomSerializer(serializers.ModelSerializer):
|
||||
raise serializers.ValidationError(e.errors()) from e
|
||||
return value
|
||||
|
||||
def validate_encryption_mode(self, value):
|
||||
"""Encryption mode is part of the link's semantics (the passphrase
|
||||
lives in the URL hash for `basic` rooms) so it cannot be changed once
|
||||
the room exists."""
|
||||
instance = self.instance
|
||||
if instance and instance.encryption_mode != value:
|
||||
raise serializers.ValidationError(
|
||||
"Encryption mode cannot be changed after room creation."
|
||||
)
|
||||
return value
|
||||
|
||||
def validate_access_level(self, value):
|
||||
"""Encrypted rooms must stay restricted — the lobby is the only way
|
||||
to enforce per-participant admission, and basic encryption relies on
|
||||
the host vetting each joiner before they receive the in-URL key."""
|
||||
instance = self.instance
|
||||
if (
|
||||
instance
|
||||
and instance.encryption_mode != models.EncryptionMode.NONE
|
||||
and value != models.RoomAccessLevel.RESTRICTED
|
||||
):
|
||||
raise serializers.ValidationError(
|
||||
"Encrypted rooms require restricted access level."
|
||||
)
|
||||
return value
|
||||
|
||||
def to_representation(self, instance):
|
||||
"""
|
||||
Add users only for administrator users.
|
||||
@@ -202,12 +241,14 @@ class RoomSerializer(serializers.ModelSerializer):
|
||||
if should_access_room:
|
||||
room_id = f"{instance.id!s}"
|
||||
username = request.query_params.get("username", None)
|
||||
|
||||
output["livekit"] = utils.generate_livekit_config(
|
||||
room_id=room_id,
|
||||
user=request.user,
|
||||
username=username,
|
||||
configuration=output["configuration"],
|
||||
role=role,
|
||||
encryption_mode=instance.encryption_mode,
|
||||
)
|
||||
else:
|
||||
del output["pin_code"]
|
||||
@@ -249,49 +290,6 @@ class BaseValidationOnlySerializer(serializers.Serializer):
|
||||
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 fps and kbps. When `None`,
|
||||
`settings.RECORDING_ENCODING_DEFAULT_PROFILE` applies.
|
||||
"""
|
||||
|
||||
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):
|
||||
"""Configuration options for recording.
|
||||
|
||||
@@ -312,7 +310,7 @@ class RecordingOptions(BaseModel):
|
||||
transcribe: bool | None = None
|
||||
collect_metadata: bool | None = None
|
||||
original_mode: Literal["screen_recording", "transcript"] | None = None
|
||||
encoding: EncodingConfig | None = None
|
||||
|
||||
model_config = {"extra": "forbid"}
|
||||
|
||||
|
||||
@@ -335,22 +333,6 @@ class StartRecordingSerializer(BaseValidationOnlySerializer):
|
||||
help_text="Recording options",
|
||||
)
|
||||
|
||||
def validate_options(self, value: RecordingOptions):
|
||||
"""Validate that custom encoding is enabled if encoding options are passed."""
|
||||
if (
|
||||
value is not None
|
||||
and value.encoding is not None
|
||||
and not settings.RECORDING_CUSTOM_ENCODING_ENABLED
|
||||
):
|
||||
# Per-recording encoding selection is gated by
|
||||
# RECORDING_CUSTOM_ENCODING_ENABLED. When disabled, recordings use
|
||||
# encoding defined by RECORDING_ENCODING_DEFAULT_RESOLUTION
|
||||
# and RECORDING_ENCODING_DEFAULT_PROFILE.
|
||||
raise serializers.ValidationError(
|
||||
"Per-recording encoding selection is disabled."
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
class RequestEntrySerializer(BaseValidationOnlySerializer):
|
||||
"""Validate request entry data."""
|
||||
|
||||
@@ -55,7 +55,6 @@ from core.recording.worker.exceptions import (
|
||||
RecordingStopError,
|
||||
)
|
||||
from core.recording.worker.factories import (
|
||||
build_encoding_options,
|
||||
get_worker_service,
|
||||
)
|
||||
from core.recording.worker.mediator import (
|
||||
@@ -250,6 +249,18 @@ class RoomViewSet(
|
||||
Apply the user's default room preferences (access level and configuration)
|
||||
unless the request explicitly provides its own values.
|
||||
"""
|
||||
encryption_mode = serializer.validated_data.get(
|
||||
"encryption_mode", models.EncryptionMode.NONE
|
||||
)
|
||||
|
||||
if (
|
||||
encryption_mode != models.EncryptionMode.NONE
|
||||
and not settings.ENCRYPTION_ENABLED
|
||||
):
|
||||
raise drf_exceptions.ValidationError(
|
||||
{"encryption_mode": "Encryption is not enabled on this server."}
|
||||
)
|
||||
|
||||
user = self.request.user
|
||||
save_kwargs = {}
|
||||
|
||||
@@ -314,22 +325,19 @@ class RoomViewSet(
|
||||
"""Start recording a room."""
|
||||
|
||||
serializer = serializers.StartRecordingSerializer(data=request.data)
|
||||
|
||||
if not serializer.is_valid():
|
||||
return drf_response.Response(
|
||||
{"detail": "Invalid request."}, status=drf_status.HTTP_400_BAD_REQUEST
|
||||
{"detail": "Invalid request."},
|
||||
status=drf_status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
mode = serializer.validated_data["mode"]
|
||||
options = serializer.validated_data.get("options")
|
||||
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"] = build_encoding_options(
|
||||
options.encoding.resolution, options.encoding.profile
|
||||
if room.is_encrypted:
|
||||
raise drf_exceptions.ValidationError(
|
||||
{"detail": "Recording is unavailable in encrypted rooms."}
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -337,7 +345,7 @@ class RoomViewSet(
|
||||
recording = models.Recording.objects.create(
|
||||
room=room,
|
||||
mode=mode,
|
||||
options=options_data,
|
||||
options=options.model_dump(exclude_none=True) if options else {},
|
||||
)
|
||||
models.RecordingAccess.objects.create(
|
||||
user=self.request.user,
|
||||
@@ -654,6 +662,11 @@ class RoomViewSet(
|
||||
|
||||
room = self.get_object()
|
||||
|
||||
if room.is_encrypted:
|
||||
raise drf_exceptions.ValidationError(
|
||||
{"detail": "Subtitles are unavailable in encrypted rooms."}
|
||||
)
|
||||
|
||||
try:
|
||||
SubtitleService().start_subtitle(room)
|
||||
except SubtitleException:
|
||||
|
||||
@@ -5,6 +5,7 @@ Core application enums declaration
|
||||
import re
|
||||
|
||||
from django.conf import global_settings, settings
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
UUID_REGEX = (
|
||||
@@ -32,3 +33,14 @@ ALL_LANGUAGES = getattr(
|
||||
"ALL_LANGUAGES",
|
||||
[(language, _(name)) for language, name in global_settings.LANGUAGES],
|
||||
)
|
||||
|
||||
|
||||
class EncryptionMode(models.TextChoices):
|
||||
"""Encryption mode for a room.
|
||||
|
||||
Kept as an enum (not a boolean) so future modes — e.g. a vault-managed
|
||||
per-user key flow — can be added without another schema migration.
|
||||
"""
|
||||
|
||||
NONE = "none", _("No encryption")
|
||||
BASIC = "basic", _("Passphrase-in-URL encryption")
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
"""Purge inactive rooms."""
|
||||
|
||||
from datetime import timedelta
|
||||
from itertools import batched
|
||||
from logging import getLogger
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db.models import Exists, OuterRef, Q
|
||||
from django.utils import timezone
|
||||
|
||||
from core.models import Recording, RecordingStatusChoices, Room
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
CHUNK_SIZE = 500
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""
|
||||
Delete rooms that have not been started for ROOM_INACTIVITY_DELETION_DAYS days:
|
||||
- rooms which were last started before that period
|
||||
- rooms never started and created before that period
|
||||
|
||||
Rooms holding a saved recording that has not expired are kept.
|
||||
"""
|
||||
|
||||
help = "Purge inactive rooms"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="List the rooms that would be purged without deleting them",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
"""Browse inactive rooms and delete them chunk by chunk."""
|
||||
|
||||
if not settings.ROOM_INACTIVITY_DELETION_DAYS:
|
||||
self.stdout.write(
|
||||
"Purging inactive rooms is disabled "
|
||||
"(ROOM_INACTIVITY_DELETION_DAYS is not set)."
|
||||
)
|
||||
return
|
||||
|
||||
now = timezone.now()
|
||||
inactive_rooms = self.get_inactive_rooms(now)
|
||||
|
||||
inactive_count = inactive_rooms.count()
|
||||
if not inactive_count:
|
||||
self.stdout.write("No inactive room to purge.")
|
||||
return
|
||||
|
||||
if options["dry_run"]:
|
||||
self.stdout.write(
|
||||
f"[dry-run] {inactive_count} inactive room(s) would be purged:"
|
||||
)
|
||||
names = inactive_rooms.values_list("name", flat=True)
|
||||
for name in names.iterator(chunk_size=CHUNK_SIZE):
|
||||
self.stdout.write(f"- {name}")
|
||||
return
|
||||
|
||||
purged_count = 0
|
||||
rooms = inactive_rooms.values_list("pk", "slug").iterator(chunk_size=CHUNK_SIZE)
|
||||
for chunk in batched(rooms, CHUNK_SIZE, strict=False):
|
||||
for room_id, slug in chunk:
|
||||
logger.info("Purging inactive room %s (%s)", room_id, slug)
|
||||
|
||||
_, deleted_by_model = inactive_rooms.filter(
|
||||
pk__in=[room_id for room_id, _ in chunk]
|
||||
).delete()
|
||||
purged_count += deleted_by_model.get("core.Room", 0)
|
||||
|
||||
self.stdout.write(f"Purged {purged_count} inactive room(s).")
|
||||
|
||||
@staticmethod
|
||||
def get_inactive_rooms(now):
|
||||
"""Return the rooms inactive for too long that no recording protects."""
|
||||
|
||||
threshold = now - timedelta(days=settings.ROOM_INACTIVITY_DELETION_DAYS)
|
||||
is_inactive = Q(last_started_at__lt=threshold) | Q(
|
||||
last_started_at__isnull=True, created_at__lt=threshold
|
||||
)
|
||||
|
||||
protected_recordings = Recording.objects.filter(
|
||||
room=OuterRef("pk"), status__in=RecordingStatusChoices.saved_statuses()
|
||||
)
|
||||
if settings.RECORDING_EXPIRATION_DAYS:
|
||||
protected_recordings = protected_recordings.filter(
|
||||
created_at__gte=now - timedelta(days=settings.RECORDING_EXPIRATION_DAYS)
|
||||
)
|
||||
|
||||
return Room.objects.filter(is_inactive, ~Exists(protected_recordings))
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Add Room.encryption_mode and User.default_encryption_mode (enum-based).
|
||||
|
||||
We store the mode as an enum (CharField with choices) rather than a boolean
|
||||
so a future "advanced" mode (per-user vault keys, etc.) can be added without
|
||||
a schema migration.
|
||||
"""
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("core", "0023_alter_recording_status"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="room",
|
||||
name="encryption_mode",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("none", "No encryption"),
|
||||
("basic", "Passphrase-in-URL encryption"),
|
||||
],
|
||||
default="none",
|
||||
help_text="End-to-end encryption mode for this room.",
|
||||
max_length=20,
|
||||
verbose_name="Encryption mode",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="default_encryption_mode",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("none", "No encryption"),
|
||||
("basic", "Passphrase-in-URL encryption"),
|
||||
],
|
||||
default="none",
|
||||
help_text="Encryption mode pre-selected when this user creates a new meeting.",
|
||||
max_length=20,
|
||||
verbose_name="Default encryption mode",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,18 +0,0 @@
|
||||
from django.db import migrations, models
|
||||
import django.utils.timezone
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0023_alter_recording_status'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='room',
|
||||
name='last_started_at',
|
||||
field=models.DateTimeField(blank=True, default=django.utils.timezone.now, editable=False, help_text='date and time at which the room was last started', null=True, verbose_name='last started at'),
|
||||
preserve_default=False,
|
||||
),
|
||||
]
|
||||
+79
-22
@@ -26,6 +26,7 @@ from lasuite.tools.email import get_domain_from_email
|
||||
from timezone_field import TimeZoneField
|
||||
|
||||
from . import fields, utils
|
||||
from .enums import EncryptionMode
|
||||
from .recording.enums import FileExtension
|
||||
from .validators import sub_validator
|
||||
|
||||
@@ -87,17 +88,6 @@ class RecordingStatusChoices(models.TextChoices):
|
||||
cls.FAILED_TO_STOP,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def saved_statuses(cls):
|
||||
"""Return the statuses of a recording whose file users can access."""
|
||||
|
||||
return {
|
||||
cls.NOTIFICATION_SUCCEEDED,
|
||||
cls.SAVED,
|
||||
cls.EXTERNAL_PROCESS_SUCCESSFUL,
|
||||
cls.EXTERNAL_PROCESS_FAILED,
|
||||
}
|
||||
|
||||
|
||||
class RecordingModeChoices(models.TextChoices):
|
||||
"""Recording mode choices."""
|
||||
@@ -227,6 +217,15 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
"Unselect this instead of deleting accounts."
|
||||
),
|
||||
)
|
||||
default_encryption_mode = models.CharField(
|
||||
_("Default encryption mode"),
|
||||
max_length=20,
|
||||
choices=EncryptionMode.choices,
|
||||
default=EncryptionMode.NONE,
|
||||
help_text=_(
|
||||
"Encryption mode pre-selected when this user creates a new meeting."
|
||||
),
|
||||
)
|
||||
|
||||
objects = auth_models.UserManager()
|
||||
|
||||
@@ -422,6 +421,13 @@ class Room(Resource):
|
||||
choices=RoomAccessLevel.choices,
|
||||
default=settings.RESOURCE_DEFAULT_ACCESS_LEVEL,
|
||||
)
|
||||
encryption_mode = models.CharField(
|
||||
max_length=20,
|
||||
choices=EncryptionMode.choices,
|
||||
default=EncryptionMode.NONE,
|
||||
verbose_name=_("Encryption mode"),
|
||||
help_text=_("End-to-end encryption mode for this room."),
|
||||
)
|
||||
# Public configuration exposed to any room participant via the API
|
||||
configuration = models.JSONField(
|
||||
blank=True,
|
||||
@@ -437,13 +443,6 @@ class Room(Resource):
|
||||
verbose_name=_("Room PIN code"),
|
||||
help_text=_("Unique n-digit code that identifies this room in telephony mode."),
|
||||
)
|
||||
last_started_at = models.DateTimeField(
|
||||
verbose_name=_("last started at"),
|
||||
help_text=_("date and time at which the room was last started"),
|
||||
blank=True,
|
||||
null=True,
|
||||
editable=False,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = "meet_room"
|
||||
@@ -455,20 +454,68 @@ class Room(Resource):
|
||||
return capfirst(self.name)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Generate a unique n-digit pin code for new rooms."""
|
||||
"""Restrict new encrypted rooms and allocate PINs for unencrypted rooms.
|
||||
|
||||
# Roomkit devices also join by PIN, so a PIN is needed as soon as
|
||||
# either integration is enabled.
|
||||
Skip PIN allocation for encrypted rooms — the SIP gateway will
|
||||
always reject calls to them (no way to derive the key), and the
|
||||
PIN namespace is finite (10**length): no point burning slots that
|
||||
can never be dialed.
|
||||
|
||||
Also run `clean()` so the encryption invariants are enforced on
|
||||
every save path (ORM, admin, shell), not only via the DRF
|
||||
serializer.
|
||||
"""
|
||||
# Override both explicit access levels and user defaults on creation.
|
||||
# Updates remain subject to clean() instead of being silently normalized.
|
||||
if self._state.adding and self.is_encrypted:
|
||||
self.access_level = RoomAccessLevel.RESTRICTED
|
||||
self.clean()
|
||||
if (
|
||||
(settings.ROOM_TELEPHONY_ENABLED or settings.ROOMKIT_ENABLED)
|
||||
and not self.pk
|
||||
and not self.pin_code
|
||||
and self.encryption_mode == EncryptionMode.NONE
|
||||
):
|
||||
self.pin_code = self.generate_unique_pin_code(
|
||||
length=settings.ROOM_TELEPHONY_PIN_LENGTH
|
||||
)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def clean(self):
|
||||
"""Enforce encryption-mode invariants outside DRF.
|
||||
|
||||
Two rules:
|
||||
- `encryption_mode` is set at creation and never mutated afterwards
|
||||
(the URL-hash passphrase encodes assumptions about it).
|
||||
- An encrypted room must be at the RESTRICTED access level so the
|
||||
host vets joiners before they ever see the in-URL key.
|
||||
"""
|
||||
super().clean()
|
||||
if self.pk is not None:
|
||||
previous = Room.objects.filter(pk=self.pk).only("encryption_mode").first()
|
||||
if (
|
||||
previous is not None
|
||||
and previous.encryption_mode != self.encryption_mode
|
||||
):
|
||||
raise ValidationError(
|
||||
{
|
||||
"encryption_mode": _(
|
||||
"Encryption mode cannot be changed after room creation."
|
||||
)
|
||||
}
|
||||
)
|
||||
if (
|
||||
self.encryption_mode != EncryptionMode.NONE
|
||||
and self.access_level != RoomAccessLevel.RESTRICTED
|
||||
):
|
||||
raise ValidationError(
|
||||
{
|
||||
"access_level": _(
|
||||
"Encrypted rooms must use the 'restricted' access level."
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
def clean_fields(self, exclude=None):
|
||||
"""
|
||||
Automatically generate the slug from the name and make sure it does not look like a UUID.
|
||||
@@ -491,6 +538,11 @@ class Room(Resource):
|
||||
"""Check if a room is public"""
|
||||
return self.access_level == RoomAccessLevel.PUBLIC
|
||||
|
||||
@property
|
||||
def is_encrypted(self):
|
||||
"""Convenience: any non-none encryption mode counts as encrypted."""
|
||||
return self.encryption_mode != EncryptionMode.NONE
|
||||
|
||||
@staticmethod
|
||||
def generate_unique_pin_code(length):
|
||||
"""Generate a unique n-digit PIN code"""
|
||||
@@ -700,7 +752,12 @@ class Recording(BaseModel):
|
||||
@property
|
||||
def is_saved(self) -> bool:
|
||||
"""Check if the recording is in a saved state."""
|
||||
return self.status in RecordingStatusChoices.saved_statuses()
|
||||
return self.status in {
|
||||
RecordingStatusChoices.NOTIFICATION_SUCCEEDED,
|
||||
RecordingStatusChoices.SAVED,
|
||||
RecordingStatusChoices.EXTERNAL_PROCESS_SUCCESSFUL,
|
||||
RecordingStatusChoices.EXTERNAL_PROCESS_FAILED,
|
||||
}
|
||||
|
||||
@property
|
||||
def extension(self):
|
||||
|
||||
@@ -22,46 +22,6 @@ _RECORDING_AUDIO_CODEC = livekit_api.AudioCodec.AAC
|
||||
_RECORDING_AUDIO_FREQUENCY_HZ = 48000
|
||||
|
||||
|
||||
def build_encoding_options(resolution, profile=None):
|
||||
"""Assemble the LiveKit ``EncodingOptions`` kwargs for a resolution/profile.
|
||||
|
||||
Single source of truth shared by the default encoding
|
||||
(``WorkerServiceConfig.from_settings``) and the per-recording encoding
|
||||
persisted by the start-recording API, so both paths always produce the
|
||||
same shape.
|
||||
|
||||
The profile-independent fields (audio bitrate, keyframe interval and the
|
||||
pinned codec / frequency constants) are always included.
|
||||
|
||||
An omitted profile falls back to RECORDING_ENCODING_DEFAULT_PROFILE.
|
||||
Framerate and bitrate are left to LiveKit only when the operator
|
||||
declared no default profile at all.
|
||||
"""
|
||||
profile = profile or settings.RECORDING_ENCODING_DEFAULT_PROFILE
|
||||
|
||||
options: Dict[str, Any] = {
|
||||
"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,
|
||||
}
|
||||
|
||||
if resolution:
|
||||
resolution_config = settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS[
|
||||
resolution
|
||||
]
|
||||
options["width"] = resolution_config["width"]
|
||||
options["height"] = resolution_config["height"]
|
||||
|
||||
if resolution and profile:
|
||||
profile_config = settings.RECORDING_ENCODING_AVAILABLE_PROFILES[profile]
|
||||
options["framerate"] = profile_config["fps"]
|
||||
options["video_bitrate"] = profile_config["kbps"][resolution]
|
||||
|
||||
return options
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WorkerServiceConfig:
|
||||
"""Declare Worker Service common configurations"""
|
||||
@@ -78,16 +38,22 @@ class WorkerServiceConfig:
|
||||
|
||||
logger.debug("Loading WorkerServiceConfig from settings.")
|
||||
|
||||
# The default encoding is resolved from the default profile/resolution and
|
||||
# applied to every recording that carries no per-recording encoding.
|
||||
# When either default is missing, we leave this as None so LiveKit falls
|
||||
# back to its built-in preset.
|
||||
resolution = settings.RECORDING_ENCODING_DEFAULT_RESOLUTION
|
||||
profile = settings.RECORDING_ENCODING_DEFAULT_PROFILE
|
||||
|
||||
encoding_options: Optional[Dict[str, Any]] = None
|
||||
if resolution and profile:
|
||||
encoding_options = build_encoding_options(resolution, profile)
|
||||
if settings.RECORDING_ENCODING_ENABLED:
|
||||
# Single source of truth for the EncodingOptions kwargs:
|
||||
# operator-tunable values live in Django settings, codec / frequency
|
||||
# are pinned constants. The services layer only unpacks this dict.
|
||||
encoding_options = {
|
||||
"width": settings.RECORDING_ENCODING_WIDTH,
|
||||
"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(
|
||||
output_folder=settings.RECORDING_OUTPUT_FOLDER,
|
||||
@@ -112,12 +78,7 @@ class WorkerService(Protocol):
|
||||
def __init__(self, config: WorkerServiceConfig):
|
||||
"""Initialize the service with the given configuration."""
|
||||
|
||||
def start(
|
||||
self,
|
||||
room_id: str,
|
||||
recording_id: str,
|
||||
encoding_options: Optional[Dict[str, Any]] = None,
|
||||
) -> str:
|
||||
def start(self, room_id: str, recording_id: str) -> str:
|
||||
"""Start a recording for a specified room."""
|
||||
|
||||
def stop(self, worker_id: str) -> str:
|
||||
|
||||
@@ -51,11 +51,8 @@ class WorkerServiceMediator:
|
||||
raise RecordingStartError()
|
||||
|
||||
room_name = str(recording.room.id)
|
||||
encoding_options = (recording.options.get("encoding") or {}).get("resolved")
|
||||
try:
|
||||
worker_id = self._worker_service.start(
|
||||
room_name, recording.id, encoding_options=encoding_options
|
||||
)
|
||||
worker_id = self._worker_service.start(room_name, recording.id)
|
||||
except (WorkerRequestError, WorkerConnectionError, WorkerResponseError) as e:
|
||||
logger.exception(
|
||||
"Failed to start recording for room %s: %s", recording.room.slug, e
|
||||
|
||||
@@ -9,7 +9,7 @@ from livekit import api as livekit_api
|
||||
|
||||
from ... import utils
|
||||
from ..enums import FileExtension
|
||||
from .exceptions import WorkerConnectionError, WorkerRequestError, WorkerResponseError
|
||||
from .exceptions import WorkerConnectionError, WorkerResponseError
|
||||
from .factories import WorkerServiceConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -108,33 +108,28 @@ class BaseEgressService:
|
||||
self._log_egress_error(response, "failed to stop")
|
||||
return "FAILED_TO_STOP"
|
||||
|
||||
def start(self, room_name, recording_id, encoding_options=None):
|
||||
def start(self, room_name, recording_id):
|
||||
"""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
|
||||
its specific egress type (e.g. audio_only, streaming output).
|
||||
"""
|
||||
raise NotImplementedError("Subclass must implement this method.")
|
||||
|
||||
def _resolve_encoding_options(self, encoding_options):
|
||||
"""Build a LiveKit EncodingOptions from a resolved kwargs dict, or None.
|
||||
|
||||
``encoding_options`` is the per-recording dict persisted by the API in
|
||||
``recording.options["encoding"]["resolved"]``; it falls back to the
|
||||
default encoding carried by the service config.
|
||||
def _build_encoding_options(self):
|
||||
"""Build a LiveKit EncodingOptions from the service config, or None.
|
||||
|
||||
When None is returned, the caller should omit the `advanced` field so
|
||||
LiveKit Egress falls back to its built-in preset (H264_720P_30).
|
||||
|
||||
The full EncodingOptions kwargs (operator-tunable values + pinned
|
||||
codec / frequency constants) are assembled in `WorkerServiceConfig`,
|
||||
so this method is a thin protobuf adapter.
|
||||
"""
|
||||
encoding_options = encoding_options or self._config.encoding_options
|
||||
if not encoding_options:
|
||||
opts = self._config.encoding_options
|
||||
if not opts:
|
||||
return None
|
||||
|
||||
try:
|
||||
return livekit_api.EncodingOptions(**encoding_options)
|
||||
except (TypeError, ValueError) as e:
|
||||
# Protobuf raises TypeError on a wrongly typed value (e.g. a float
|
||||
# framerate) and ValueError on an unknown field or an out-of-range int.
|
||||
raise WorkerRequestError(f"Invalid encoding options: {e}") from e
|
||||
return livekit_api.EncodingOptions(**opts)
|
||||
|
||||
|
||||
class VideoCompositeEgressService(BaseEgressService):
|
||||
@@ -142,7 +137,7 @@ class VideoCompositeEgressService(BaseEgressService):
|
||||
|
||||
hrid = "video-recording-composite-livekit-egress"
|
||||
|
||||
def start(self, room_name, recording_id, encoding_options=None):
|
||||
def start(self, room_name, recording_id):
|
||||
"""Start the video composite egress process for a recording."""
|
||||
|
||||
# Save room's recording as a mp4 video file.
|
||||
@@ -163,7 +158,7 @@ class VideoCompositeEgressService(BaseEgressService):
|
||||
"layout": "speaker-light",
|
||||
}
|
||||
|
||||
advanced = self._resolve_encoding_options(encoding_options)
|
||||
advanced = self._build_encoding_options()
|
||||
if advanced is not None:
|
||||
request_kwargs["advanced"] = advanced
|
||||
|
||||
@@ -182,13 +177,8 @@ class AudioCompositeEgressService(BaseEgressService):
|
||||
|
||||
hrid = "audio-recording-composite-livekit-egress"
|
||||
|
||||
def start(self, room_name, recording_id, encoding_options=None):
|
||||
"""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.
|
||||
"""
|
||||
def start(self, room_name, recording_id):
|
||||
"""Start the audio composite egress process for a recording."""
|
||||
|
||||
# Save room's recording as an ogg audio file.
|
||||
file_type = livekit_api.EncodedFileType.OGG
|
||||
|
||||
@@ -8,7 +8,6 @@ from enum import Enum
|
||||
from logging import getLogger
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
|
||||
from livekit import api
|
||||
|
||||
@@ -271,20 +270,14 @@ class LiveKitEventsService:
|
||||
)
|
||||
raise ActionFailedError("Failed to process room started event") from e
|
||||
|
||||
room_updated_count = models.Room.objects.filter(pk=room_id).update(
|
||||
last_started_at=timezone.now()
|
||||
)
|
||||
if not room_updated_count:
|
||||
raise ActionFailedError(f"Room with ID {room_id} does not exist")
|
||||
|
||||
if settings.ROOM_TELEPHONY_ENABLED or settings.ROOMKIT_ENABLED:
|
||||
try:
|
||||
room = models.Room.objects.get(pk=room_id)
|
||||
except models.Room.DoesNotExist as err:
|
||||
raise ActionFailedError(
|
||||
f"Room with ID {room_id} does not exist"
|
||||
) from err
|
||||
try:
|
||||
room = models.Room.objects.get(id=room_id)
|
||||
except models.Room.DoesNotExist as err:
|
||||
raise ActionFailedError(f"Room with ID {room_id} does not exist") from err
|
||||
|
||||
if (
|
||||
settings.ROOM_TELEPHONY_ENABLED or settings.ROOMKIT_ENABLED
|
||||
) and not room.is_encrypted:
|
||||
try:
|
||||
self.sip_management.ensure_dispatch_rule(room)
|
||||
except SIPException as e:
|
||||
|
||||
@@ -48,8 +48,11 @@ class LobbyParticipant:
|
||||
color: str
|
||||
id: str
|
||||
entered_at: str
|
||||
# Whether the user signed in (e.g. via ProConnect). Surfaced to admins so
|
||||
# they can decide whether to accept self-declared identities.
|
||||
is_authenticated: bool = False
|
||||
|
||||
def to_dict(self) -> Dict[str, str]:
|
||||
def to_dict(self) -> Dict[str, object]:
|
||||
"""Serialize the participant object to a dict representation."""
|
||||
return {
|
||||
"status": self.status.value,
|
||||
@@ -57,6 +60,7 @@ class LobbyParticipant:
|
||||
"id": self.id,
|
||||
"color": self.color,
|
||||
"entered_at": self.entered_at,
|
||||
"is_authenticated": self.is_authenticated,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -72,6 +76,7 @@ class LobbyParticipant:
|
||||
id=data["id"],
|
||||
color=data["color"],
|
||||
entered_at=data["entered_at"],
|
||||
is_authenticated=bool(data.get("is_authenticated", False)),
|
||||
)
|
||||
except (KeyError, ValueError) as e:
|
||||
logger.exception("Error creating Participant from dict:")
|
||||
@@ -144,7 +149,7 @@ class LobbyService:
|
||||
key=settings.LOBBY_COOKIE_NAME,
|
||||
value=participant_id,
|
||||
httponly=True,
|
||||
secure=True,
|
||||
secure=not settings.DEBUG,
|
||||
samesite="Lax",
|
||||
)
|
||||
|
||||
@@ -208,6 +213,7 @@ class LobbyService:
|
||||
id=participant_id,
|
||||
color=utils.generate_color(participant_id),
|
||||
entered_at=timezone.now().isoformat(),
|
||||
is_authenticated=request.user.is_authenticated,
|
||||
)
|
||||
else:
|
||||
participant.status = LobbyParticipantStatus.ACCEPTED
|
||||
@@ -220,19 +226,24 @@ class LobbyService:
|
||||
configuration=room.configuration,
|
||||
participant_id=participant_id,
|
||||
role=user_role,
|
||||
encryption_mode=room.encryption_mode,
|
||||
)
|
||||
return participant, livekit_config
|
||||
|
||||
livekit_config = None
|
||||
|
||||
if participant is None:
|
||||
participant = self.enter(room.id, participant_id, username)
|
||||
participant = self.enter(
|
||||
room.id,
|
||||
participant_id,
|
||||
username,
|
||||
is_authenticated=request.user.is_authenticated,
|
||||
)
|
||||
|
||||
elif participant.status == LobbyParticipantStatus.WAITING:
|
||||
self.refresh_waiting_status(room.id, participant_id)
|
||||
|
||||
elif participant.status == LobbyParticipantStatus.ACCEPTED:
|
||||
# wrongly named, contains access token to join a room
|
||||
livekit_config = utils.generate_livekit_config(
|
||||
room_id=room_id,
|
||||
user=request.user,
|
||||
@@ -241,6 +252,7 @@ class LobbyService:
|
||||
configuration=room.configuration,
|
||||
participant_id=participant_id,
|
||||
role=user_role,
|
||||
encryption_mode=room.encryption_mode,
|
||||
)
|
||||
|
||||
return participant, livekit_config
|
||||
@@ -258,7 +270,11 @@ class LobbyService:
|
||||
self._index_touch(room_id)
|
||||
|
||||
def enter(
|
||||
self, room_id: UUID, participant_id: str, username: str
|
||||
self,
|
||||
room_id: UUID,
|
||||
participant_id: str,
|
||||
username: str,
|
||||
is_authenticated: bool = False,
|
||||
) -> LobbyParticipant:
|
||||
"""Add participant to waiting lobby."""
|
||||
|
||||
@@ -270,6 +286,7 @@ class LobbyService:
|
||||
id=participant_id,
|
||||
color=color,
|
||||
entered_at=timezone.now().isoformat(),
|
||||
is_authenticated=is_authenticated,
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
@@ -1,239 +0,0 @@
|
||||
"""Tests for the purge_inactive_rooms management command."""
|
||||
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from io import StringIO
|
||||
from unittest import mock
|
||||
|
||||
from django.core.management import call_command
|
||||
from django.utils import timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from core import factories, models
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
COMMAND_MODULE = "core.management.commands.purge_inactive_rooms"
|
||||
|
||||
BEFORE_PERIOD = timedelta(days=366)
|
||||
WITHIN_PERIOD = timedelta(days=364)
|
||||
|
||||
|
||||
@pytest.fixture(name="purge_enabled", autouse=True)
|
||||
def fixture_purge_enabled(settings):
|
||||
"""Enable the purge of the rooms inactive for a year."""
|
||||
settings.ROOM_INACTIVITY_DELETION_DAYS = 365
|
||||
settings.RECORDING_EXPIRATION_DAYS = 30
|
||||
|
||||
|
||||
def create_at(date, factory, **kwargs):
|
||||
"""Build an object with the factory as if it was created at the given date."""
|
||||
with mock.patch("django.utils.timezone.now", return_value=date):
|
||||
return factory(**kwargs)
|
||||
|
||||
|
||||
def call_purge(*args):
|
||||
"""Run the purge command and return what it wrote on stdout."""
|
||||
out = StringIO()
|
||||
call_command("purge_inactive_rooms", *args, stdout=out)
|
||||
return out.getvalue()
|
||||
|
||||
|
||||
def room_exists(room):
|
||||
"""Tell whether the room is still in database."""
|
||||
return models.Room.objects.filter(pk=room.pk).exists()
|
||||
|
||||
|
||||
def test_purge_inactive_rooms_disabled(settings):
|
||||
"""Should delete nothing when no inactivity period is configured."""
|
||||
settings.ROOM_INACTIVITY_DELETION_DAYS = None
|
||||
room = create_at(timezone.now() - BEFORE_PERIOD, factories.RoomFactory)
|
||||
|
||||
assert "disabled" in call_purge()
|
||||
|
||||
assert room_exists(room)
|
||||
|
||||
|
||||
def test_purge_inactive_rooms_without_recording_expiration(settings):
|
||||
"""Should purge when recordings never expire, keeping rooms with a saved one."""
|
||||
settings.RECORDING_EXPIRATION_DAYS = None
|
||||
long_ago = timezone.now() - BEFORE_PERIOD
|
||||
room = create_at(long_ago, factories.RoomFactory)
|
||||
room_with_recording = create_at(long_ago, factories.RoomFactory)
|
||||
create_at(
|
||||
long_ago,
|
||||
factories.RecordingFactory,
|
||||
room=room_with_recording,
|
||||
status=models.RecordingStatusChoices.SAVED,
|
||||
)
|
||||
|
||||
assert call_purge() == "Purged 1 inactive room(s).\n"
|
||||
|
||||
assert not room_exists(room)
|
||||
assert room_exists(room_with_recording)
|
||||
|
||||
|
||||
def test_purge_inactive_rooms_without_recording_expiration_not_saved(settings):
|
||||
"""Should delete a room whose recordings were never saved when none expire."""
|
||||
settings.RECORDING_EXPIRATION_DAYS = None
|
||||
room = create_at(timezone.now() - BEFORE_PERIOD, factories.RoomFactory)
|
||||
factories.RecordingFactory(room=room, status=models.RecordingStatusChoices.FAILED)
|
||||
|
||||
call_purge()
|
||||
|
||||
assert not room_exists(room)
|
||||
|
||||
|
||||
def test_purge_inactive_rooms_started_before_period(caplog):
|
||||
"""Should delete a room that was last started before the inactivity period."""
|
||||
now = timezone.now()
|
||||
room = create_at(
|
||||
now - timedelta(days=800),
|
||||
factories.RoomFactory,
|
||||
last_started_at=now - BEFORE_PERIOD,
|
||||
)
|
||||
|
||||
with caplog.at_level(logging.INFO, logger=COMMAND_MODULE):
|
||||
output = call_purge()
|
||||
|
||||
assert output == "Purged 1 inactive room(s).\n"
|
||||
assert not room_exists(room)
|
||||
assert f"Purging inactive room {room.pk} ({room.slug})" in caplog.text
|
||||
|
||||
|
||||
def test_purge_inactive_rooms_never_started_created_before_period():
|
||||
"""Should delete a room that was never started and created before the period."""
|
||||
room = create_at(timezone.now() - BEFORE_PERIOD, factories.RoomFactory)
|
||||
|
||||
call_purge()
|
||||
|
||||
assert not room_exists(room)
|
||||
|
||||
|
||||
def test_purge_inactive_rooms_started_within_period():
|
||||
"""Should keep a room created long ago that was started within the period."""
|
||||
now = timezone.now()
|
||||
room = create_at(
|
||||
now - timedelta(days=800),
|
||||
factories.RoomFactory,
|
||||
last_started_at=now - WITHIN_PERIOD,
|
||||
)
|
||||
|
||||
assert call_purge() == "No inactive room to purge.\n"
|
||||
|
||||
assert room_exists(room)
|
||||
|
||||
|
||||
def test_purge_inactive_rooms_never_started_created_within_period():
|
||||
"""Should keep a room that was never started but created within the period."""
|
||||
room = create_at(timezone.now() - WITHIN_PERIOD, factories.RoomFactory)
|
||||
|
||||
assert call_purge() == "No inactive room to purge.\n"
|
||||
|
||||
assert room_exists(room)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"status", sorted(models.RecordingStatusChoices.saved_statuses())
|
||||
)
|
||||
def test_purge_inactive_rooms_recording_not_expired(settings, status):
|
||||
"""Should keep a room holding a saved recording that has not expired yet."""
|
||||
settings.RECORDING_EXPIRATION_DAYS = 400
|
||||
long_ago = timezone.now() - BEFORE_PERIOD
|
||||
room = create_at(long_ago, factories.RoomFactory)
|
||||
create_at(long_ago, factories.RecordingFactory, room=room, status=status)
|
||||
|
||||
call_purge()
|
||||
|
||||
assert room_exists(room)
|
||||
|
||||
|
||||
def test_purge_inactive_rooms_recording_expired(settings):
|
||||
"""Should delete a room along with its recordings when they all have expired."""
|
||||
settings.RECORDING_EXPIRATION_DAYS = 30
|
||||
long_ago = timezone.now() - BEFORE_PERIOD
|
||||
room = create_at(long_ago, factories.RoomFactory)
|
||||
recording = create_at(
|
||||
long_ago,
|
||||
factories.RecordingFactory,
|
||||
room=room,
|
||||
status=models.RecordingStatusChoices.SAVED,
|
||||
)
|
||||
|
||||
call_purge()
|
||||
|
||||
assert not room_exists(room)
|
||||
assert not models.Recording.objects.filter(pk=recording.pk).exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"status",
|
||||
[
|
||||
status
|
||||
for status in models.RecordingStatusChoices
|
||||
if status not in models.RecordingStatusChoices.saved_statuses()
|
||||
],
|
||||
)
|
||||
def test_purge_inactive_rooms_recording_not_saved(status):
|
||||
"""Should delete a room whose recordings were never saved, even unexpired."""
|
||||
room = create_at(timezone.now() - BEFORE_PERIOD, factories.RoomFactory)
|
||||
factories.RecordingFactory(room=room, status=status)
|
||||
|
||||
call_purge()
|
||||
|
||||
assert not room_exists(room)
|
||||
|
||||
|
||||
def test_purge_inactive_rooms_recording_saved_among_others():
|
||||
"""Should keep a room holding a saved recording next to a failed one."""
|
||||
room = create_at(timezone.now() - BEFORE_PERIOD, factories.RoomFactory)
|
||||
factories.RecordingFactory(room=room, status=models.RecordingStatusChoices.FAILED)
|
||||
factories.RecordingFactory(room=room, status=models.RecordingStatusChoices.SAVED)
|
||||
|
||||
call_purge()
|
||||
|
||||
assert room_exists(room)
|
||||
|
||||
|
||||
def test_purge_inactive_rooms_deletes_accesses_and_resource():
|
||||
"""Should delete the last owner access and the resource of a purged room."""
|
||||
room = create_at(timezone.now() - BEFORE_PERIOD, factories.RoomFactory)
|
||||
access = factories.UserResourceAccessFactory(
|
||||
resource=room, role=models.RoleChoices.OWNER
|
||||
)
|
||||
|
||||
call_purge()
|
||||
|
||||
assert not room_exists(room)
|
||||
assert not models.Resource.objects.filter(pk=room.pk).exists()
|
||||
assert not models.ResourceAccess.objects.filter(pk=access.pk).exists()
|
||||
assert models.User.objects.filter(pk=access.user.pk).exists()
|
||||
|
||||
|
||||
def test_purge_inactive_rooms_dry_run():
|
||||
"""Should list the inactive rooms by name without deleting them on a dry run."""
|
||||
long_ago = timezone.now() - BEFORE_PERIOD
|
||||
rooms = [
|
||||
create_at(long_ago, factories.RoomFactory, name=name)
|
||||
for name in ("Alpha room", "Beta room")
|
||||
]
|
||||
factories.RoomFactory(name="Recent room")
|
||||
|
||||
assert call_purge("--dry-run") == (
|
||||
"[dry-run] 2 inactive room(s) would be purged:\n- Alpha room\n- Beta room\n"
|
||||
)
|
||||
|
||||
assert all(room_exists(room) for room in rooms)
|
||||
|
||||
|
||||
def test_purge_inactive_rooms_several_chunks():
|
||||
"""Should delete every inactive room when they span several chunks."""
|
||||
long_ago = timezone.now() - BEFORE_PERIOD
|
||||
rooms = [create_at(long_ago, factories.RoomFactory) for _ in range(5)]
|
||||
|
||||
with mock.patch(f"{COMMAND_MODULE}.CHUNK_SIZE", 2):
|
||||
output = call_purge()
|
||||
|
||||
assert output == "Purged 5 inactive room(s).\n"
|
||||
assert not any(room_exists(room) for room in rooms)
|
||||
@@ -117,14 +117,14 @@ def test_api_files_create_file_authenticated_success():
|
||||
policy_parsed = urlparse(policy)
|
||||
|
||||
assert policy_parsed.scheme == "http"
|
||||
assert policy_parsed.netloc in ["garage:9000", "localhost:9000"]
|
||||
assert policy_parsed.netloc in ["minio:9000", "localhost:9000"]
|
||||
assert policy_parsed.path == f"/meet-media-storage/tmp/files/{file.id!s}.png"
|
||||
|
||||
query_params = parse_qs(policy_parsed.query)
|
||||
|
||||
assert query_params.pop("X-Amz-Algorithm") == ["AWS4-HMAC-SHA256"]
|
||||
assert query_params.pop("X-Amz-Credential") == [
|
||||
f"meet-access-key/{now.strftime('%Y%m%d')}/local/s3/aws4_request"
|
||||
f"meet/{now.strftime('%Y%m%d')}/us-east-1/s3/aws4_request"
|
||||
]
|
||||
assert query_params.pop("X-Amz-Date") == [now.strftime("%Y%m%dT%H%M%SZ")]
|
||||
assert query_params.pop("X-Amz-Expires") == ["60"]
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
"""Tests for the per-recording encoding resolution in BaseEgressService."""
|
||||
|
||||
# pylint: disable=protected-access,redefined-outer-name,unused-argument,no-member
|
||||
|
||||
from unittest.mock import Mock
|
||||
|
||||
from django.conf import settings
|
||||
from django.test import override_settings
|
||||
|
||||
import pytest
|
||||
from livekit import api as livekit_api
|
||||
from pydantic import ValidationError as PydanticValidationError
|
||||
|
||||
from core.api.serializers import EncodingConfig
|
||||
from core.recording.worker.exceptions import WorkerRequestError
|
||||
from core.recording.worker.factories import build_encoding_options
|
||||
from core.recording.worker.services import VideoCompositeEgressService
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
# --- build_encoding_options ---
|
||||
|
||||
|
||||
def test_build_options_without_profile_uses_default_profile():
|
||||
"""A resolution-only config should fall back to the default profile.
|
||||
|
||||
Left unset, framerate and video_bitrate take LiveKit's own EncodingOptions
|
||||
defaults. The profile-independent fields (audio bitrate, keyframe interval,
|
||||
codec/frequency pins) are always present, matching the default encoding.
|
||||
"""
|
||||
default_profile = settings.RECORDING_ENCODING_AVAILABLE_PROFILES[
|
||||
settings.RECORDING_ENCODING_DEFAULT_PROFILE
|
||||
]
|
||||
|
||||
resolved = build_encoding_options("540p")
|
||||
|
||||
assert resolved == {
|
||||
"audio_bitrate": settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS,
|
||||
"key_frame_interval": settings.RECORDING_ENCODING_KEY_FRAME_INTERVAL_S,
|
||||
"video_codec": livekit_api.VideoCodec.H264_MAIN,
|
||||
"audio_codec": livekit_api.AudioCodec.AAC,
|
||||
"audio_frequency": 48000,
|
||||
"width": 960,
|
||||
"height": 540,
|
||||
"framerate": default_profile["fps"],
|
||||
"video_bitrate": default_profile["kbps"]["540p"],
|
||||
}
|
||||
|
||||
|
||||
@override_settings(RECORDING_ENCODING_DEFAULT_PROFILE="")
|
||||
def test_build_options_omits_profile_fields_without_default_profile():
|
||||
"""With no default profile declared, framerate/bitrate are left to LiveKit."""
|
||||
resolved = build_encoding_options("720p", None)
|
||||
|
||||
assert resolved == {
|
||||
"audio_bitrate": settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS,
|
||||
"key_frame_interval": settings.RECORDING_ENCODING_KEY_FRAME_INTERVAL_S,
|
||||
"video_codec": livekit_api.VideoCodec.H264_MAIN,
|
||||
"audio_codec": livekit_api.AudioCodec.AAC,
|
||||
"audio_frequency": 48000,
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
}
|
||||
assert "framerate" not in resolved
|
||||
assert "video_bitrate" not in resolved
|
||||
|
||||
|
||||
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(
|
||||
"encoding_options",
|
||||
[
|
||||
{"framerate": 29.97},
|
||||
{"width": "1280"},
|
||||
{"unknown_field": 1},
|
||||
],
|
||||
)
|
||||
def test_resolve_options_invalid_raises_worker_request_error(service, encoding_options):
|
||||
"""Malformed encoding options should surface as a WorkerRequestError."""
|
||||
with pytest.raises(WorkerRequestError):
|
||||
service._resolve_encoding_options(encoding_options)
|
||||
|
||||
|
||||
@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."""
|
||||
resolution_config = settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS[resolution]
|
||||
expected_width = resolution_config["width"]
|
||||
expected_height = resolution_config["height"]
|
||||
profile_config = settings.RECORDING_ENCODING_AVAILABLE_PROFILES[profile]
|
||||
expected_fps = profile_config["fps"]
|
||||
expected_bitrate = profile_config["kbps"][resolution]
|
||||
|
||||
resolved = build_encoding_options(resolution, profile)
|
||||
result = service._resolve_encoding_options(resolved)
|
||||
|
||||
assert result.width == expected_width
|
||||
assert result.height == expected_height
|
||||
assert result.framerate == expected_fps
|
||||
assert result.video_bitrate == expected_bitrate
|
||||
# Profile-independent fields match the default encoding, never dropped.
|
||||
assert result.audio_bitrate == settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS
|
||||
assert result.video_codec == livekit_api.VideoCodec.H264_MAIN
|
||||
assert result.audio_codec == livekit_api.AudioCodec.AAC
|
||||
assert result.audio_frequency == 48000
|
||||
|
||||
|
||||
def test_resolve_options_none_profile_uses_default_profile(service):
|
||||
"""A missing profile should resolve to the default profile's fps/bitrate."""
|
||||
default_profile = settings.RECORDING_ENCODING_AVAILABLE_PROFILES[
|
||||
settings.RECORDING_ENCODING_DEFAULT_PROFILE
|
||||
]
|
||||
|
||||
resolved = build_encoding_options("720p", None)
|
||||
result = service._resolve_encoding_options(resolved)
|
||||
|
||||
assert result.width == 1280
|
||||
assert result.height == 720
|
||||
assert result.framerate == default_profile["fps"]
|
||||
assert result.video_bitrate == default_profile["kbps"]["720p"]
|
||||
assert result.audio_bitrate == settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS
|
||||
assert result.video_codec == livekit_api.VideoCodec.H264_MAIN
|
||||
|
||||
|
||||
@override_settings(RECORDING_ENCODING_DEFAULT_PROFILE="")
|
||||
def test_resolve_options_passes_zero_when_no_default_profile(service):
|
||||
"""With no default profile, fps/bitrate reach LiveKit unset (protobuf 0).
|
||||
|
||||
The pinned codec / audio fields are still applied.
|
||||
"""
|
||||
resolved = build_encoding_options("720p", None)
|
||||
result = service._resolve_encoding_options(resolved)
|
||||
|
||||
assert result.width == 1280
|
||||
assert result.height == 720
|
||||
assert result.framerate == 0
|
||||
assert result.video_bitrate == 0
|
||||
assert result.audio_bitrate == settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS
|
||||
assert result.video_codec == livekit_api.VideoCodec.H264_MAIN
|
||||
assert result.audio_codec == livekit_api.AudioCodec.AAC
|
||||
@@ -40,16 +40,6 @@ def test_settings():
|
||||
"AWS_S3_SECRET_ACCESS_KEY": "test_secret",
|
||||
"AWS_S3_REGION_NAME": "test-region",
|
||||
"AWS_STORAGE_BUCKET_NAME": "test-bucket",
|
||||
"RECORDING_ENCODING_AVAILABLE_RESOLUTIONS": {
|
||||
"720p": {"width": 1280, "height": 720}
|
||||
},
|
||||
"RECORDING_ENCODING_AVAILABLE_PROFILES": {
|
||||
"full": {"fps": 30, "kbps": {"720p": 3000}}
|
||||
},
|
||||
"RECORDING_ENCODING_DEFAULT_RESOLUTION": "720p",
|
||||
"RECORDING_ENCODING_DEFAULT_PROFILE": "full",
|
||||
"RECORDING_ENCODING_AUDIO_BITRATE_KBPS": 128,
|
||||
"RECORDING_ENCODING_KEY_FRAME_INTERVAL_S": 4.0,
|
||||
}
|
||||
|
||||
# Use override_settings to properly patch Django settings
|
||||
@@ -76,18 +66,8 @@ def test_config_initialization(default_config):
|
||||
"bucket": "test-bucket",
|
||||
"force_path_style": True,
|
||||
}
|
||||
# The default encoding is always resolved from the default profile/resolution.
|
||||
assert default_config.encoding_options == {
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"framerate": 30,
|
||||
"video_bitrate": 3000,
|
||||
"audio_bitrate": 128,
|
||||
"key_frame_interval": 4.0,
|
||||
"video_codec": livekit_api_codec.VideoCodec.H264_MAIN,
|
||||
"audio_codec": livekit_api_codec.AudioCodec.AAC,
|
||||
"audio_frequency": 48000,
|
||||
}
|
||||
# Encoding override is opt-in; disabled by default.
|
||||
assert default_config.encoding_options is None
|
||||
|
||||
|
||||
def test_config_immutability(default_config):
|
||||
@@ -96,7 +76,6 @@ def test_config_immutability(default_config):
|
||||
default_config.output_folder = "new/path"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("custom_encoding_enabled", [True, False])
|
||||
@override_settings(
|
||||
RECORDING_OUTPUT_FOLDER="/test/output",
|
||||
LIVEKIT_CONFIGURATION={"server": "test.example.com"},
|
||||
@@ -105,25 +84,23 @@ def test_config_immutability(default_config):
|
||||
AWS_S3_SECRET_ACCESS_KEY="test_secret",
|
||||
AWS_S3_REGION_NAME="test-region",
|
||||
AWS_STORAGE_BUCKET_NAME="test-bucket",
|
||||
RECORDING_ENCODING_AVAILABLE_RESOLUTIONS={"720p": {"width": 1280, "height": 720}},
|
||||
RECORDING_ENCODING_AVAILABLE_PROFILES={"low": {"fps": 15, "kbps": {"720p": 600}}},
|
||||
RECORDING_ENCODING_DEFAULT_RESOLUTION="720p",
|
||||
RECORDING_ENCODING_DEFAULT_PROFILE="low",
|
||||
RECORDING_ENCODING_ENABLED=True,
|
||||
RECORDING_ENCODING_WIDTH=1280,
|
||||
RECORDING_ENCODING_HEIGHT=720,
|
||||
RECORDING_ENCODING_FRAMERATE=15,
|
||||
RECORDING_ENCODING_VIDEO_BITRATE_KBPS=600,
|
||||
RECORDING_ENCODING_AUDIO_BITRATE_KBPS=64,
|
||||
RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=10.0,
|
||||
)
|
||||
def test_config_encoding_options_default(custom_encoding_enabled):
|
||||
"""The default encoding is always resolved from the default profile/resolution.
|
||||
def test_config_encoding_options_enabled():
|
||||
"""When RECORDING_ENCODING_ENABLED is True, encoding options are populated.
|
||||
|
||||
The default fallback resolves the default profile/resolution and mixes those
|
||||
operator-tunable values with pinned codec / frequency constants. This works
|
||||
regardless of RECORDING_CUSTOM_ENCODING_ENABLED, which only gates the
|
||||
per-recording API, so both toggle states produce the same default.
|
||||
The dict mixes operator-tunable values from settings with pinned codec /
|
||||
frequency constants, so the services layer can simply unpack it.
|
||||
"""
|
||||
|
||||
with override_settings(RECORDING_CUSTOM_ENCODING_ENABLED=custom_encoding_enabled):
|
||||
WorkerServiceConfig.from_settings.cache_clear()
|
||||
config = WorkerServiceConfig.from_settings()
|
||||
WorkerServiceConfig.from_settings.cache_clear()
|
||||
config = WorkerServiceConfig.from_settings()
|
||||
|
||||
assert config.encoding_options == {
|
||||
"width": 1280,
|
||||
@@ -138,27 +115,6 @@ def test_config_encoding_options_default(custom_encoding_enabled):
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("default_resolution", "default_profile"),
|
||||
[("", "full"), ("720p", ""), ("", "")],
|
||||
)
|
||||
def test_config_encoding_options_none_when_default_missing(
|
||||
test_settings, default_resolution, default_profile
|
||||
):
|
||||
"""A missing default resolution/profile leaves encoding_options None.
|
||||
|
||||
The service then omits the `advanced` field so LiveKit uses its built-in preset.
|
||||
"""
|
||||
with override_settings(
|
||||
RECORDING_ENCODING_DEFAULT_RESOLUTION=default_resolution,
|
||||
RECORDING_ENCODING_DEFAULT_PROFILE=default_profile,
|
||||
):
|
||||
WorkerServiceConfig.from_settings.cache_clear()
|
||||
config = WorkerServiceConfig.from_settings()
|
||||
|
||||
assert config.encoding_options is None
|
||||
|
||||
|
||||
@override_settings(
|
||||
RECORDING_OUTPUT_FOLDER="/test/output",
|
||||
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
|
||||
expected_room_name = str(mock_recording.room.id)
|
||||
mock_worker_service.start.assert_called_once_with(
|
||||
expected_room_name, mock_recording.id, encoding_options=None
|
||||
expected_room_name, mock_recording.id
|
||||
)
|
||||
|
||||
# Verify recording updates
|
||||
@@ -64,38 +64,6 @@ def test_start_recording_success(mock_update_metadata, mediator, mock_worker_ser
|
||||
)
|
||||
|
||||
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_start_recording_passes_resolved_encoding(
|
||||
mock_update_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(
|
||||
"error_class", [WorkerRequestError, WorkerConnectionError, WorkerResponseError]
|
||||
)
|
||||
|
||||
@@ -312,3 +312,34 @@ def test_api_rooms_create_authenticated_blank_user_default_access_level():
|
||||
assert response.status_code == 201
|
||||
room = Room.objects.get()
|
||||
assert room.access_level == settings.RESOURCE_DEFAULT_ACCESS_LEVEL
|
||||
|
||||
|
||||
@pytest.mark.parametrize("encryption_mode", ["none", "basic"])
|
||||
@pytest.mark.parametrize(
|
||||
"user_default", [None, RoomAccessLevel.PUBLIC, RoomAccessLevel.TRUSTED]
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"requested_access", [None, RoomAccessLevel.PUBLIC, RoomAccessLevel.TRUSTED]
|
||||
)
|
||||
def test_api_rooms_create_encryption_access_precedence(
|
||||
settings, encryption_mode, user_default, requested_access
|
||||
):
|
||||
"""Encryption overrides request and user access defaults only for encrypted rooms."""
|
||||
settings.ENCRYPTION_ENABLED = True
|
||||
user = UserFactory(default_room_access_level=user_default)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
data = {"name": "New room", "encryption_mode": encryption_mode}
|
||||
if requested_access is not None:
|
||||
data["access_level"] = requested_access
|
||||
|
||||
response = client.post("/api/v1.0/rooms/", data)
|
||||
|
||||
assert response.status_code == 201
|
||||
expected_access = (
|
||||
RoomAccessLevel.RESTRICTED
|
||||
if encryption_mode == "basic"
|
||||
else requested_access or user_default or settings.RESOURCE_DEFAULT_ACCESS_LEVEL
|
||||
)
|
||||
assert response.json()["access_level"] == expected_access
|
||||
assert Room.objects.get().access_level == expected_access
|
||||
|
||||
@@ -62,6 +62,7 @@ def test_request_entry_anonymous(settings):
|
||||
"status": "waiting",
|
||||
"color": "mocked-color",
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
"is_authenticated": False,
|
||||
"livekit": None,
|
||||
}
|
||||
|
||||
@@ -75,9 +76,12 @@ def test_request_entry_anonymous(settings):
|
||||
|
||||
|
||||
@freeze_time("2025-01-01 10:00:00")
|
||||
def test_request_entry_authenticated_user(settings):
|
||||
@pytest.mark.parametrize("encryption_mode", ["none", "basic"])
|
||||
def test_request_entry_authenticated_user(settings, encryption_mode):
|
||||
"""Authenticated users should be allowed to request entry."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
room = RoomFactory(
|
||||
access_level=RoomAccessLevel.RESTRICTED, encryption_mode=encryption_mode
|
||||
)
|
||||
user = UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -113,6 +117,7 @@ def test_request_entry_authenticated_user(settings):
|
||||
"status": "waiting",
|
||||
"color": "mocked-color",
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
"is_authenticated": True,
|
||||
"livekit": None,
|
||||
}
|
||||
|
||||
@@ -189,6 +194,7 @@ def test_request_entry_with_existing_participants(settings):
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
"status": "waiting",
|
||||
"color": "mocked-color",
|
||||
"is_authenticated": False,
|
||||
"livekit": None,
|
||||
}
|
||||
|
||||
@@ -243,6 +249,7 @@ def test_request_entry_public_room(settings):
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
"status": "accepted",
|
||||
"color": "mocked-color",
|
||||
"is_authenticated": False,
|
||||
"livekit": {"token": "test-token"},
|
||||
}
|
||||
|
||||
@@ -297,6 +304,7 @@ def test_request_entry_authenticated_user_public_room(settings):
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
"status": "accepted",
|
||||
"color": "mocked-color",
|
||||
"is_authenticated": True,
|
||||
"livekit": {"token": "test-token"},
|
||||
}
|
||||
|
||||
@@ -354,6 +362,7 @@ def test_request_entry_waiting_participant_public_room(settings):
|
||||
"status": "accepted",
|
||||
"color": "#123456",
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
"is_authenticated": False,
|
||||
"livekit": {"token": "test-token"},
|
||||
}
|
||||
|
||||
@@ -623,6 +632,7 @@ def test_list_waiting_participants_success(settings):
|
||||
"username": "user2",
|
||||
"status": "waiting",
|
||||
"color": "#654321",
|
||||
"is_authenticated": False,
|
||||
"entered_at": "2025-01-01T10:05:00+00:00",
|
||||
},
|
||||
{
|
||||
@@ -630,6 +640,7 @@ def test_list_waiting_participants_success(settings):
|
||||
"username": "user1",
|
||||
"status": "waiting",
|
||||
"color": "#123456",
|
||||
"is_authenticated": False,
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
},
|
||||
]
|
||||
|
||||
@@ -7,7 +7,6 @@ from unittest import mock
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.test.utils import override_settings
|
||||
from django.utils import timezone
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
@@ -34,6 +33,7 @@ def test_api_rooms_retrieve_anonymous_private_pk():
|
||||
"id": str(room.id),
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
"encryption_mode": room.encryption_mode,
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ def test_api_rooms_retrieve_anonymous_trusted_pk():
|
||||
"id": str(room.id),
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
"encryption_mode": room.encryption_mode,
|
||||
}
|
||||
|
||||
|
||||
@@ -71,6 +72,7 @@ def test_api_rooms_retrieve_anonymous_private_pk_no_dashes():
|
||||
"id": str(room.id),
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
"encryption_mode": room.encryption_mode,
|
||||
}
|
||||
|
||||
|
||||
@@ -87,6 +89,7 @@ def test_api_rooms_retrieve_anonymous_private_slug():
|
||||
"id": str(room.id),
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
"encryption_mode": room.encryption_mode,
|
||||
}
|
||||
|
||||
|
||||
@@ -103,6 +106,7 @@ def test_api_rooms_retrieve_anonymous_private_slug_not_normalized():
|
||||
"id": str(room.id),
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
"encryption_mode": room.encryption_mode,
|
||||
}
|
||||
|
||||
|
||||
@@ -218,6 +222,7 @@ def test_api_rooms_retrieve_anonymous_public(mock_token):
|
||||
"name": room.name,
|
||||
"pin_code": room.pin_code,
|
||||
"slug": room.slug,
|
||||
"encryption_mode": room.encryption_mode,
|
||||
}
|
||||
|
||||
mock_token.assert_called_once()
|
||||
@@ -264,6 +269,7 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
|
||||
"name": room.name,
|
||||
"pin_code": room.pin_code,
|
||||
"slug": room.slug,
|
||||
"encryption_mode": room.encryption_mode,
|
||||
}
|
||||
|
||||
mock_token.assert_called_once_with(
|
||||
@@ -274,6 +280,7 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
|
||||
sources=["camera"],
|
||||
role=None,
|
||||
participant_id=None,
|
||||
encryption_mode="none",
|
||||
)
|
||||
|
||||
|
||||
@@ -315,6 +322,7 @@ def test_api_rooms_retrieve_authenticated_trusted(mock_token):
|
||||
"name": room.name,
|
||||
"pin_code": room.pin_code,
|
||||
"slug": room.slug,
|
||||
"encryption_mode": room.encryption_mode,
|
||||
}
|
||||
|
||||
mock_token.assert_called_once_with(
|
||||
@@ -325,6 +333,7 @@ def test_api_rooms_retrieve_authenticated_trusted(mock_token):
|
||||
sources=None,
|
||||
role=None,
|
||||
participant_id=None,
|
||||
encryption_mode="none",
|
||||
)
|
||||
|
||||
|
||||
@@ -350,6 +359,7 @@ def test_api_rooms_retrieve_authenticated():
|
||||
"id": str(room.id),
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
"encryption_mode": room.encryption_mode,
|
||||
}
|
||||
|
||||
|
||||
@@ -401,6 +411,7 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
|
||||
"name": room.name,
|
||||
"pin_code": room.pin_code,
|
||||
"slug": room.slug,
|
||||
"encryption_mode": room.encryption_mode,
|
||||
}
|
||||
|
||||
mock_token.assert_called_once_with(
|
||||
@@ -411,6 +422,7 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
|
||||
sources=["camera"],
|
||||
role=str(RoleChoices.MEMBER),
|
||||
participant_id=None,
|
||||
encryption_mode="none",
|
||||
)
|
||||
|
||||
|
||||
@@ -462,6 +474,7 @@ def test_api_rooms_retrieve_administrators(
|
||||
"short_name": other_user_access.user.short_name,
|
||||
"timezone": "UTC",
|
||||
"language": other_user_access.user.language,
|
||||
"default_encryption_mode": "none",
|
||||
},
|
||||
"resource": str(room.id),
|
||||
"role": other_user_access.role,
|
||||
@@ -477,6 +490,7 @@ def test_api_rooms_retrieve_administrators(
|
||||
"short_name": user_access.user.short_name,
|
||||
"timezone": "UTC",
|
||||
"language": user_access.user.language,
|
||||
"default_encryption_mode": "none",
|
||||
},
|
||||
"resource": str(room.id),
|
||||
"role": user_access.role,
|
||||
@@ -497,6 +511,7 @@ def test_api_rooms_retrieve_administrators(
|
||||
"name": room.name,
|
||||
"pin_code": room.pin_code,
|
||||
"slug": room.slug,
|
||||
"encryption_mode": room.encryption_mode,
|
||||
}
|
||||
|
||||
mock_token.assert_called_once_with(
|
||||
@@ -507,21 +522,25 @@ def test_api_rooms_retrieve_administrators(
|
||||
sources=None,
|
||||
role=str(user_access.role),
|
||||
participant_id=None,
|
||||
encryption_mode="none",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("access_level", RoomAccessLevel)
|
||||
@pytest.mark.parametrize("role", [None, *RoleChoices])
|
||||
def test_api_rooms_retrieve_last_started_at_not_exposed(role, access_level):
|
||||
"""Should not expose when the room was last started, whoever the requester is."""
|
||||
room = RoomFactory(access_level=access_level, last_started_at=timezone.now())
|
||||
@pytest.mark.parametrize("encryption_mode", ["none", "basic"])
|
||||
@mock.patch("core.utils.generate_token", return_value="test-token")
|
||||
def test_api_rooms_retrieve_custom_username(mock_token, encryption_mode, settings):
|
||||
"""Encryption does not override the participant's requested display name."""
|
||||
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = True
|
||||
user = UserFactory(full_name="Profile Name")
|
||||
room = RoomFactory(
|
||||
access_level=RoomAccessLevel.RESTRICTED, encryption_mode=encryption_mode
|
||||
)
|
||||
UserResourceAccessFactory(resource=room, user=user, role="owner")
|
||||
client = APIClient()
|
||||
user = UserFactory()
|
||||
if role is not None:
|
||||
UserResourceAccessFactory(resource=room, user=user, role=role)
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(f"/api/v1.0/rooms/{room.id!s}/")
|
||||
response = client.get(f"/api/v1.0/rooms/{room.id}/", {"username": "Custom Name"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "last_started_at" not in response.json()
|
||||
assert mock_token.call_args.kwargs["username"] == "Custom Name"
|
||||
assert mock_token.call_args.kwargs["encryption_mode"] == encryption_mode
|
||||
|
||||
@@ -2,12 +2,11 @@
|
||||
Test rooms API endpoints in the Meet core app: start recording.
|
||||
"""
|
||||
|
||||
# pylint: disable=redefined-outer-name,unused-argument,no-member
|
||||
# pylint: disable=redefined-outer-name,unused-argument
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from livekit import api as livekit_api
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from ...factories import RoomFactory, UserFactory
|
||||
@@ -471,224 +470,6 @@ def test_start_recording_options_unknown_field_rejected(settings):
|
||||
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
|
||||
settings.RECORDING_CUSTOM_ENCODING_ENABLED = 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_options_encoding_rejected_when_custom_encoding_disabled(
|
||||
settings, mock_worker_service_factory, mock_worker_manager
|
||||
):
|
||||
"""Per-recording encoding is rejected when RECORDING_CUSTOM_ENCODING_ENABLED is off."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
settings.RECORDING_CUSTOM_ENCODING_ENABLED = False
|
||||
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 == 400
|
||||
assert not Recording.objects.filter(room=room).exists()
|
||||
|
||||
|
||||
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
|
||||
settings.RECORDING_CUSTOM_ENCODING_ENABLED = 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": {
|
||||
"audio_bitrate": settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS,
|
||||
"key_frame_interval": settings.RECORDING_ENCODING_KEY_FRAME_INTERVAL_S,
|
||||
"video_codec": livekit_api.VideoCodec.H264_MAIN,
|
||||
"audio_codec": livekit_api.AudioCodec.AAC,
|
||||
"audio_frequency": 48000,
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"framerate": 15,
|
||||
"video_bitrate": 700,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_start_recording_resolution_only_uses_default_profile(
|
||||
settings, mock_worker_service_factory, mock_worker_manager
|
||||
):
|
||||
"""An encoding without a profile should resolve the default profile."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
settings.RECORDING_CUSTOM_ENCODING_ENABLED = True
|
||||
settings.RECORDING_ENCODING_DEFAULT_PROFILE = "talking_heads"
|
||||
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": "540p"}}},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
recording = Recording.objects.get(room=room)
|
||||
resolved = recording.options["encoding"]["resolved"]
|
||||
assert resolved["width"] == 960
|
||||
assert resolved["height"] == 540
|
||||
assert resolved["framerate"] == 15
|
||||
assert resolved["video_bitrate"] == 400
|
||||
# The requested payload is persisted as sent: no profile was asked for.
|
||||
assert "profile" not in recording.options["encoding"]
|
||||
|
||||
|
||||
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
|
||||
settings.RECORDING_CUSTOM_ENCODING_ENABLED = 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.services.room_management.RoomManagement.update_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
|
||||
settings.RECORDING_CUSTOM_ENCODING_ENABLED = 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
|
||||
settings.RECORDING_CUSTOM_ENCODING_ENABLED = 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])
|
||||
def test_start_recording_options_invalid_transcribe_type(settings, value):
|
||||
"""Should reject non-boolean transcribe values."""
|
||||
|
||||
@@ -3,11 +3,8 @@ Test rooms API endpoints in the Meet core app: update.
|
||||
"""
|
||||
|
||||
import random
|
||||
from datetime import timedelta
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
@@ -228,39 +225,6 @@ def test_api_rooms_update_administrators_name_only(mock_update_metadata):
|
||||
mock_update_metadata.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method", ["put", "patch"])
|
||||
def test_api_rooms_update_last_started_at_ignored(method):
|
||||
"""Should ignore a "last_started_at" value sent by a client.
|
||||
|
||||
The field is only ever written by the LiveKit "room_started" webhook: it is not
|
||||
declared on the serializer and is "editable=False" on the model. A client must
|
||||
not be able to keep a room alive by postponing its last start date.
|
||||
"""
|
||||
user = UserFactory()
|
||||
last_started_at = timezone.now() - timedelta(days=30)
|
||||
room = RoomFactory(
|
||||
name="Old name",
|
||||
last_started_at=last_started_at,
|
||||
users=[(user, random.choice(["administrator", "owner"]))],
|
||||
)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = getattr(client, method)(
|
||||
f"/api/v1.0/rooms/{room.id!s}/",
|
||||
{"name": "New name", "last_started_at": timezone.now().isoformat()},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "last_started_at" not in response.json()
|
||||
|
||||
room.refresh_from_db()
|
||||
# The rest of the payload was applied, so the request was not simply rejected
|
||||
assert room.name == "New name"
|
||||
assert room.last_started_at == last_started_at
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"configuration",
|
||||
[
|
||||
@@ -446,3 +410,24 @@ def test_api_rooms_update_livekit_sync_failure(mock_update_metadata, exception):
|
||||
"configuration": {"can_publish_sources": ["camera"]},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"access_level", [RoomAccessLevel.PUBLIC, RoomAccessLevel.TRUSTED]
|
||||
)
|
||||
def test_api_rooms_update_encrypted_access_rejected(access_level):
|
||||
"""API updates cannot change an encrypted room away from restricted access."""
|
||||
room = RoomFactory(encryption_mode="basic")
|
||||
user = UserFactory()
|
||||
room.accesses.create(user=user, role="owner")
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/rooms/{room.id}/", {"access_level": access_level}
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "access_level" in response.json()
|
||||
room.refresh_from_db()
|
||||
assert room.access_level == RoomAccessLevel.RESTRICTED
|
||||
|
||||
@@ -5,16 +5,12 @@ Test LiveKitEvents service.
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import timedelta
|
||||
from unittest import mock
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
import pytest
|
||||
from livekit.api import EgressStatus
|
||||
|
||||
from core.factories import RecordingFactory, RoomFactory
|
||||
from core.models import Room
|
||||
from core.recording.enums import RecordingWorkerEvent
|
||||
from core.recording.services.recording_events import RecordingEventsService
|
||||
from core.services.livekit_events import (
|
||||
@@ -700,82 +696,6 @@ def test_handle_room_started_skips_dispatch_rule_when_telephony_disabled(
|
||||
mock_ensure_dispatch_rule.assert_not_called()
|
||||
|
||||
|
||||
def test_handle_room_started_records_access(service, settings):
|
||||
"""Should record the access on a room that is started for the first time."""
|
||||
settings.ROOM_TELEPHONY_ENABLED = False
|
||||
settings.ROOMKIT_ENABLED = False
|
||||
room = RoomFactory()
|
||||
other_room = RoomFactory()
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.room.name = str(room.id)
|
||||
|
||||
now = timezone.now()
|
||||
with mock.patch("django.utils.timezone.now", return_value=now):
|
||||
service._handle_room_started(mock_data)
|
||||
|
||||
room.refresh_from_db()
|
||||
assert room.last_started_at == now
|
||||
|
||||
other_room.refresh_from_db()
|
||||
assert other_room.last_started_at is None
|
||||
|
||||
|
||||
def test_handle_room_started_overwrites_previous_access(service, settings):
|
||||
"""Should overwrite the previous access each time the room is started again."""
|
||||
settings.ROOM_TELEPHONY_ENABLED = False
|
||||
settings.ROOMKIT_ENABLED = False
|
||||
now = timezone.now()
|
||||
room = RoomFactory(last_started_at=now - timedelta(days=30))
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.room.name = str(room.id)
|
||||
|
||||
with mock.patch("django.utils.timezone.now", return_value=now):
|
||||
service._handle_room_started(mock_data)
|
||||
|
||||
room.refresh_from_db()
|
||||
assert room.last_started_at == now
|
||||
|
||||
|
||||
def test_handle_room_started_only_updates_access(service, settings):
|
||||
"""Should leave the slug and the update date untouched when recording the access."""
|
||||
settings.ROOM_TELEPHONY_ENABLED = False
|
||||
settings.ROOMKIT_ENABLED = False
|
||||
room = RoomFactory()
|
||||
Room.objects.filter(pk=room.pk).update(slug="𓆑")
|
||||
room.refresh_from_db()
|
||||
updated_at = room.updated_at
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.room.name = str(room.id)
|
||||
|
||||
service._handle_room_started(mock_data)
|
||||
|
||||
room.refresh_from_db()
|
||||
assert room.last_started_at is not None
|
||||
assert room.slug == "𓆑"
|
||||
assert room.updated_at == updated_at
|
||||
|
||||
|
||||
@mock.patch.object(
|
||||
SIPManagement,
|
||||
"ensure_dispatch_rule",
|
||||
side_effect=SIPException("Test error"),
|
||||
)
|
||||
def test_handle_room_started_records_access_when_dispatch_rule_creation_fails(
|
||||
mock_ensure_dispatch_rule, service, settings
|
||||
):
|
||||
"""Should still record the access when ensuring the dispatch rule fails."""
|
||||
settings.ROOM_TELEPHONY_ENABLED = True
|
||||
room = RoomFactory()
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.room.name = str(room.id)
|
||||
|
||||
with pytest.raises(ActionFailedError):
|
||||
service._handle_room_started(mock_data)
|
||||
|
||||
room.refresh_from_db()
|
||||
assert room.last_started_at is not None
|
||||
|
||||
|
||||
def test_handle_room_started_raises_error_for_invalid_room_name(service):
|
||||
"""Should raise ActionFailedError when room name format is invalid when room starts."""
|
||||
mock_data = mock.MagicMock()
|
||||
|
||||
@@ -303,6 +303,7 @@ def test_request_entry_public_room(
|
||||
configuration=room.configuration,
|
||||
participant_id="test-participant-id",
|
||||
role=None,
|
||||
encryption_mode="none",
|
||||
)
|
||||
|
||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
||||
@@ -342,6 +343,7 @@ def test_request_entry_trusted_room(
|
||||
configuration=room.configuration,
|
||||
participant_id="test-participant-id",
|
||||
role=None,
|
||||
encryption_mode="none",
|
||||
)
|
||||
|
||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
||||
@@ -374,7 +376,12 @@ def test_request_entry_new_participant(
|
||||
|
||||
assert participant == participant_data
|
||||
assert livekit_config is None
|
||||
mock_enter.assert_called_once_with(room.id, participant_id, username)
|
||||
mock_enter.assert_called_once_with(
|
||||
room.id,
|
||||
participant_id,
|
||||
username,
|
||||
is_authenticated=request.user.is_authenticated,
|
||||
)
|
||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
||||
|
||||
|
||||
@@ -442,6 +449,7 @@ def test_request_entry_accepted_participant(
|
||||
configuration=room.configuration,
|
||||
participant_id="test-participant-id",
|
||||
role=None,
|
||||
encryption_mode="none",
|
||||
)
|
||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
||||
|
||||
@@ -483,6 +491,7 @@ def test_request_entry_participant_with_role(
|
||||
configuration=room.configuration,
|
||||
participant_id="test-participant-id",
|
||||
role="administrator",
|
||||
encryption_mode="none",
|
||||
)
|
||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
||||
|
||||
@@ -886,6 +895,7 @@ def test_update_participant_status_success(mock_cache, lobby_service, participan
|
||||
"id": participant_id,
|
||||
"color": "#123456",
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
"is_authenticated": False,
|
||||
}
|
||||
mock_cache.set.assert_called_once_with(
|
||||
"mocked_cache_key", expected_data, timeout=60
|
||||
|
||||
@@ -127,6 +127,7 @@ def test_api_users_retrieve_me_authenticated(settings):
|
||||
"short_name": user.short_name,
|
||||
"language": user.language,
|
||||
"timezone": "UTC",
|
||||
"default_encryption_mode": "none",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -92,12 +92,6 @@ def test_models_rooms_access_level_default():
|
||||
assert room.access_level == RoomAccessLevel.PUBLIC
|
||||
|
||||
|
||||
def test_models_rooms_last_started_at_default():
|
||||
"""Should have no last access date until the room is started."""
|
||||
room = Room.objects.create(name="room")
|
||||
assert room.last_started_at is None
|
||||
|
||||
|
||||
# Access rights methods
|
||||
|
||||
|
||||
@@ -366,3 +360,35 @@ def test_pin_generation_upper_bound(mock_randbelow, settings):
|
||||
|
||||
# Assert called with the right exclusive upper bound, 10^5
|
||||
mock_randbelow.assert_called_with(100000)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("access_level", [None, *RoomAccessLevel.values])
|
||||
@pytest.mark.parametrize("use_manager", [False, True])
|
||||
def test_models_encrypted_room_creation_is_restricted(access_level, use_manager):
|
||||
"""Both save and manager creation normalize encrypted rooms before validation."""
|
||||
fields = {"name": "Encrypted room", "encryption_mode": "basic"}
|
||||
if access_level is not None:
|
||||
fields["access_level"] = access_level
|
||||
if use_manager:
|
||||
room = Room.objects.create(**fields)
|
||||
else:
|
||||
room = Room(**fields)
|
||||
# A caller may assign the primary key before the first save.
|
||||
room.pk = room.id
|
||||
room.save()
|
||||
room.refresh_from_db()
|
||||
assert room.access_level == RoomAccessLevel.RESTRICTED
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"access_level", [RoomAccessLevel.PUBLIC, RoomAccessLevel.TRUSTED]
|
||||
)
|
||||
def test_models_encrypted_room_access_update_rejected(access_level):
|
||||
"""Existing encrypted rooms reject incompatible access instead of normalizing it."""
|
||||
room = Room.objects.create(name="Encrypted room", encryption_mode="basic")
|
||||
room.access_level = access_level
|
||||
with pytest.raises(ValidationError) as excinfo:
|
||||
room.save()
|
||||
assert "access_level" in excinfo.value.message_dict
|
||||
room.refresh_from_db()
|
||||
assert room.access_level == RoomAccessLevel.RESTRICTED
|
||||
|
||||
@@ -57,21 +57,35 @@ def test_generate_token_authenticated_fallback_user_representation():
|
||||
assert claims["name"] == str(user)
|
||||
|
||||
|
||||
def test_generate_token_explicit_username_overrides_default():
|
||||
@pytest.mark.parametrize("encryption_mode", ["none", "basic"])
|
||||
def test_generate_token_explicit_username_overrides_default(encryption_mode):
|
||||
"""An explicitly provided username should take precedence over the full name."""
|
||||
user = UserFactory(full_name="Jane Doe")
|
||||
|
||||
token = generate_token(room="my-room", user=user, username="Custom Name")
|
||||
token = generate_token(
|
||||
room="my-room",
|
||||
user=user,
|
||||
username="Custom Name",
|
||||
encryption_mode=encryption_mode,
|
||||
)
|
||||
|
||||
claims = decode_token(token)
|
||||
assert claims["name"] == "Custom Name"
|
||||
|
||||
|
||||
def test_authenticated_username_ignored_when_editing_disabled(settings):
|
||||
@pytest.mark.parametrize("encryption_mode", ["none", "basic"])
|
||||
def test_authenticated_username_ignored_when_editing_disabled(
|
||||
settings, encryption_mode
|
||||
):
|
||||
"""With editing disabled, an authenticated user's username is ignored."""
|
||||
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = False
|
||||
user = UserFactory(full_name="Jane Doe")
|
||||
token = generate_token(room="my-room", user=user, username="Custom Name")
|
||||
token = generate_token(
|
||||
room="my-room",
|
||||
user=user,
|
||||
username="Custom Name",
|
||||
encryption_mode=encryption_mode,
|
||||
)
|
||||
claims = decode_token(token)
|
||||
assert claims["name"] == "Jane Doe"
|
||||
|
||||
|
||||
@@ -34,6 +34,9 @@ from livekit.api import ( # pylint: disable=E0611
|
||||
TwirpError,
|
||||
VideoGrants,
|
||||
)
|
||||
from livekit.protocol.room import RoomConfiguration # pylint: disable=E0611
|
||||
|
||||
from core.enums import EncryptionMode
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -69,6 +72,7 @@ def generate_token( # noqa: PLR0917
|
||||
role: Optional[str] = None,
|
||||
participant_id: Optional[str] = None,
|
||||
ttl: Optional[timedelta] = None,
|
||||
encryption_mode: str = "none",
|
||||
) -> str:
|
||||
"""Generate a LiveKit access token for a user in a specific room.
|
||||
|
||||
@@ -91,10 +95,8 @@ def generate_token( # noqa: PLR0917
|
||||
"""
|
||||
|
||||
is_admin_or_owner = role in ("owner", "administrator")
|
||||
if is_admin_or_owner:
|
||||
sources = settings.LIVEKIT_DEFAULT_SOURCES
|
||||
|
||||
if sources is None:
|
||||
if is_admin_or_owner or sources is None:
|
||||
sources = settings.LIVEKIT_DEFAULT_SOURCES
|
||||
|
||||
video_grants = VideoGrants(
|
||||
@@ -141,6 +143,14 @@ def generate_token( # noqa: PLR0917
|
||||
if ttl is not None:
|
||||
token = token.with_ttl(ttl)
|
||||
|
||||
if encryption_mode != EncryptionMode.NONE:
|
||||
token = token.with_room_config(
|
||||
RoomConfiguration(
|
||||
name=room,
|
||||
metadata=json.dumps({"encryption_mode": encryption_mode}),
|
||||
)
|
||||
)
|
||||
|
||||
return token.to_jwt()
|
||||
|
||||
|
||||
@@ -152,6 +162,7 @@ def generate_livekit_config( # noqa: PLR0917
|
||||
color: Optional[str] = None,
|
||||
configuration: Optional[dict] = None,
|
||||
participant_id: Optional[str] = None,
|
||||
encryption_mode: str = "none",
|
||||
) -> dict:
|
||||
"""Generate LiveKit configuration for room access.
|
||||
|
||||
@@ -184,6 +195,7 @@ def generate_livekit_config( # noqa: PLR0917
|
||||
sources=sources,
|
||||
role=role,
|
||||
participant_id=participant_id,
|
||||
encryption_mode=encryption_mode,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
+24
-194
@@ -23,8 +23,6 @@ import dj_database_url
|
||||
import sentry_sdk
|
||||
from configurations import Configuration, values
|
||||
from lasuite.configuration.values import SecretFileValue
|
||||
from pydantic import BaseModel, PositiveInt, TypeAdapter
|
||||
from pydantic import ValidationError as PydanticValidationError
|
||||
from sentry_sdk.integrations.django import DjangoIntegration
|
||||
from sentry_sdk.integrations.logging import ignore_logger
|
||||
|
||||
@@ -67,27 +65,6 @@ class VideoCodecValue(values.Value):
|
||||
return codec
|
||||
|
||||
|
||||
class ResolutionSpec(BaseModel):
|
||||
"""An value of RECORDING_ENCODING_AVAILABLE_RESOLUTIONS."""
|
||||
|
||||
width: PositiveInt
|
||||
height: PositiveInt
|
||||
|
||||
|
||||
class ProfileSpec(BaseModel):
|
||||
"""An value of RECORDING_ENCODING_AVAILABLE_PROFILES.
|
||||
|
||||
`kbps` maps each resolution key to its video bitrate.
|
||||
"""
|
||||
|
||||
fps: PositiveInt
|
||||
kbps: dict[str, PositiveInt]
|
||||
|
||||
|
||||
RESOLUTION_MAP_ADAPTER = TypeAdapter(dict[str, ResolutionSpec])
|
||||
PROFILE_MAP_ADAPTER = TypeAdapter(dict[str, ProfileSpec])
|
||||
|
||||
|
||||
class Base(Configuration):
|
||||
"""
|
||||
This is the base configuration every configuration (aka environment) should inherit from. It
|
||||
@@ -753,9 +730,6 @@ class Base(Configuration):
|
||||
ALLOW_UNREGISTERED_ROOMS = values.BooleanValue(
|
||||
True, environ_name="ALLOW_UNREGISTERED_ROOMS", environ_prefix=None
|
||||
)
|
||||
ROOM_INACTIVITY_DELETION_DAYS = values.PositiveIntegerValue(
|
||||
None, environ_name="ROOM_INACTIVITY_DELETION_DAYS", environ_prefix=None
|
||||
)
|
||||
# if provided, treat as suspicious (possible privilege escalation attempt).
|
||||
PARTICIPANT_FORBIDDEN_PERMISSION_FIELDS = values.ListValue(
|
||||
["hidden", "recorder", "agent"],
|
||||
@@ -798,81 +772,35 @@ class Base(Configuration):
|
||||
# These settings affect screen recordings handled by VideoCompositeEgressService;
|
||||
# they are silently ignored by AudioCompositeEgressService (audio-only transcript
|
||||
# recordings), whose request never carries advanced EncodingOptions.
|
||||
#
|
||||
# A default encoding is applied to every recording: it is resolved from the default
|
||||
# profile and resolution below and passed to LiveKit as EncodingOptions (advanced),
|
||||
# replacing LiveKit's built-in H264_720P_30 preset. Lowering framerate and bitrate
|
||||
# reduces output file size and CPU load on the egress worker. If either
|
||||
# RECORDING_ENCODING_DEFAULT_RESOLUTION or RECORDING_ENCODING_DEFAULT_PROFILE is
|
||||
# unset, no default encoding is built (a startup warning is emitted) and LiveKit's
|
||||
# built-in preset is used instead.
|
||||
#
|
||||
# RECORDING_CUSTOM_ENCODING_ENABLED gates whether the start-recording API lets a
|
||||
# client override that default per recording (via an `encoding` object selecting a
|
||||
# resolution/profile). When False, the API rejects per-recording `encoding` and
|
||||
# every recording uses the default; when True, clients may pick from the
|
||||
# available resolutions/profiles below.
|
||||
RECORDING_CUSTOM_ENCODING_ENABLED = values.BooleanValue(
|
||||
False, environ_name="RECORDING_CUSTOM_ENCODING_ENABLED", environ_prefix=None
|
||||
# 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).
|
||||
# When enabled, the values below are passed to LiveKit as EncodingOptions
|
||||
# (advanced) and replace the preset. Lowering framerate and bitrate reduces
|
||||
# output file size and CPU load on the egress worker.
|
||||
RECORDING_ENCODING_ENABLED = values.BooleanValue(
|
||||
False, environ_name="RECORDING_ENCODING_ENABLED", environ_prefix=None
|
||||
)
|
||||
|
||||
# Map resolution string -> {"width", "height"} in pixels.
|
||||
RECORDING_ENCODING_AVAILABLE_RESOLUTIONS = values.DictValue(
|
||||
{
|
||||
"540p": {"width": 960, "height": 540},
|
||||
"720p": {"width": 1280, "height": 720},
|
||||
"1080p": {"width": 1920, "height": 1080},
|
||||
},
|
||||
environ_name="RECORDING_ENCODING_AVAILABLE_RESOLUTIONS",
|
||||
RECORDING_ENCODING_WIDTH = values.PositiveIntegerValue(
|
||||
1280, environ_name="RECORDING_ENCODING_WIDTH", environ_prefix=None
|
||||
)
|
||||
RECORDING_ENCODING_HEIGHT = values.PositiveIntegerValue(
|
||||
720, environ_name="RECORDING_ENCODING_HEIGHT", environ_prefix=None
|
||||
)
|
||||
RECORDING_ENCODING_FRAMERATE = values.PositiveIntegerValue(
|
||||
30, environ_name="RECORDING_ENCODING_FRAMERATE", environ_prefix=None
|
||||
)
|
||||
RECORDING_ENCODING_VIDEO_BITRATE_KBPS = values.PositiveIntegerValue(
|
||||
3000,
|
||||
environ_name="RECORDING_ENCODING_VIDEO_BITRATE_KBPS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# Map profile string -> {"fps", "kbps": {resolution: video_bitrate_kbps}}.
|
||||
# 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(
|
||||
128,
|
||||
environ_name="RECORDING_ENCODING_AUDIO_BITRATE_KBPS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
RECORDING_ENCODING_KEY_FRAME_INTERVAL_S = values.FloatValue(
|
||||
0.0,
|
||||
4.0,
|
||||
environ_name="RECORDING_ENCODING_KEY_FRAME_INTERVAL_S",
|
||||
environ_prefix=None,
|
||||
)
|
||||
@@ -880,7 +808,6 @@ class Base(Configuration):
|
||||
SUMMARY_SERVICE_VERSION = values.PositiveIntegerValue(
|
||||
1, environ_name="SUMMARY_SERVICE_VERSION", environ_prefix=None
|
||||
)
|
||||
|
||||
SUMMARY_SERVICE_ENDPOINT = values.Value(
|
||||
None, environ_name="SUMMARY_SERVICE_ENDPOINT", environ_prefix=None
|
||||
)
|
||||
@@ -1051,6 +978,10 @@ class Base(Configuration):
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
ENCRYPTION_ENABLED = values.BooleanValue(
|
||||
False, environ_name="ENCRYPTION_ENABLED", environ_prefix=None
|
||||
)
|
||||
|
||||
# External Applications
|
||||
APPLICATION_ENABLED = values.BooleanValue(
|
||||
False, environ_name="APPLICATION_ENABLED", environ_prefix=None
|
||||
@@ -1295,86 +1226,6 @@ class Base(Configuration):
|
||||
},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _check_recording_encoding_maps(cls):
|
||||
"""Ensure the per-recording encoding maps are well-formed and consistent.
|
||||
|
||||
Each entry of RECORDING_ENCODING_AVAILABLE_RESOLUTIONS must declare a width and
|
||||
a height, each entry of RECORDING_ENCODING_AVAILABLE_PROFILES an fps and a kbps
|
||||
map, and every profile must define a bitrate for each declared resolution.
|
||||
|
||||
The default profile / resolution feed the default encoding. When either is
|
||||
missing, no custom default encoding can be built: a warning is emitted and
|
||||
recordings fall back to LiveKit's built-in preset. When both are set, they
|
||||
must reference keys that actually exist in the maps above.
|
||||
"""
|
||||
resolutions = set(cls.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS)
|
||||
profiles = set(cls.RECORDING_ENCODING_AVAILABLE_PROFILES)
|
||||
|
||||
for name, adapter in (
|
||||
("RECORDING_ENCODING_AVAILABLE_RESOLUTIONS", RESOLUTION_MAP_ADAPTER),
|
||||
("RECORDING_ENCODING_AVAILABLE_PROFILES", PROFILE_MAP_ADAPTER),
|
||||
):
|
||||
try:
|
||||
adapter.validate_python(getattr(cls, name), strict=True)
|
||||
except PydanticValidationError as exc:
|
||||
raise ValueError(f"{name} is malformed: {exc}") from exc
|
||||
|
||||
for (
|
||||
profile,
|
||||
profile_config,
|
||||
) in cls.RECORDING_ENCODING_AVAILABLE_PROFILES.items(): # pylint: disable=no-member
|
||||
profile_resolutions = set(profile_config["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}"
|
||||
)
|
||||
|
||||
# Check that default resolutions and profiles are actually defined
|
||||
if (
|
||||
cls.RECORDING_ENCODING_DEFAULT_RESOLUTION
|
||||
and cls.RECORDING_ENCODING_DEFAULT_RESOLUTION not in resolutions
|
||||
):
|
||||
raise ValueError(
|
||||
"RECORDING_ENCODING_DEFAULT_RESOLUTION "
|
||||
f"'{cls.RECORDING_ENCODING_DEFAULT_RESOLUTION}' is not a key of "
|
||||
f"RECORDING_ENCODING_AVAILABLE_RESOLUTIONS ({sorted(resolutions)})."
|
||||
)
|
||||
if (
|
||||
cls.RECORDING_ENCODING_DEFAULT_PROFILE
|
||||
and cls.RECORDING_ENCODING_DEFAULT_PROFILE not in profiles
|
||||
):
|
||||
raise ValueError(
|
||||
"RECORDING_ENCODING_DEFAULT_PROFILE "
|
||||
f"'{cls.RECORDING_ENCODING_DEFAULT_PROFILE}' is not a key of "
|
||||
f"RECORDING_ENCODING_AVAILABLE_PROFILES ({sorted(profiles)})."
|
||||
)
|
||||
|
||||
missing = [
|
||||
name
|
||||
for name, value in (
|
||||
(
|
||||
"RECORDING_ENCODING_DEFAULT_RESOLUTION",
|
||||
cls.RECORDING_ENCODING_DEFAULT_RESOLUTION,
|
||||
),
|
||||
(
|
||||
"RECORDING_ENCODING_DEFAULT_PROFILE",
|
||||
cls.RECORDING_ENCODING_DEFAULT_PROFILE,
|
||||
),
|
||||
)
|
||||
if not value
|
||||
]
|
||||
if missing:
|
||||
warnings.warn(
|
||||
f"{' and '.join(missing)} not set; recordings will use LiveKit's "
|
||||
"built-in encoding preset instead of a custom default encoding.",
|
||||
UserWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def post_setup(cls):
|
||||
"""Post setup configuration.
|
||||
@@ -1388,8 +1239,6 @@ class Base(Configuration):
|
||||
"FILE_UPLOAD_TMP_PATH cannot be the same as FILE_UPLOAD_PATH"
|
||||
)
|
||||
|
||||
cls._check_recording_encoding_maps()
|
||||
|
||||
if (
|
||||
cls.SUMMARY_SERVICE_VERSION == 1
|
||||
and cls.SUMMARY_SERVICE_ENDPOINT is not None
|
||||
@@ -1404,25 +1253,6 @@ class Base(Configuration):
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
if cls.ROOM_INACTIVITY_DELETION_DAYS:
|
||||
if not cls.RECORDING_EXPIRATION_DAYS:
|
||||
warnings.warn(
|
||||
"ROOM_INACTIVITY_DELETION_DAYS is set but "
|
||||
"RECORDING_EXPIRATION_DAYS is not. Recordings never expire, so "
|
||||
"inactive rooms holding a saved recording will never be purged.",
|
||||
UserWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
elif cls.RECORDING_EXPIRATION_DAYS >= cls.ROOM_INACTIVITY_DELETION_DAYS:
|
||||
warnings.warn(
|
||||
"RECORDING_EXPIRATION_DAYS is greater than or equal to "
|
||||
"ROOM_INACTIVITY_DELETION_DAYS. Inactive rooms holding a saved "
|
||||
"recording will be kept past the inactivity period, until their "
|
||||
"recordings expire.",
|
||||
UserWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
# The SENTRY_DSN setting should be available to activate sentry for an environment
|
||||
if cls.SENTRY_DSN is not None:
|
||||
sentry_sdk.init(
|
||||
|
||||
+16
-16
@@ -2,12 +2,12 @@
|
||||
# Meet package
|
||||
#
|
||||
[build-system]
|
||||
requires = ["uv_build>=0.12.6,<0.13.0"]
|
||||
requires = ["uv_build>=0.11.16,<0.12.0"]
|
||||
build-backend = "uv_build"
|
||||
|
||||
[project]
|
||||
name = "meet"
|
||||
version = "1.32.1"
|
||||
version = "1.31.0"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
@@ -24,7 +24,7 @@ keywords = ["Django", "Contacts", "Templates", "RBAC"]
|
||||
license = "MIT"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"boto3==1.43.80",
|
||||
"boto3==1.43.56",
|
||||
"Brotli==1.2.0",
|
||||
"brevo-python==1.2.0",
|
||||
"celery[redis]==5.6.3",
|
||||
@@ -33,7 +33,7 @@ dependencies = [
|
||||
"django-cors-headers==4.9.0",
|
||||
"django-countries==9.0.0",
|
||||
"django-filter==26.1",
|
||||
"django-lasuite[all]==0.0.29",
|
||||
"django-lasuite[all]==0.0.27",
|
||||
"django-parler==2.4",
|
||||
"redis==5.2.1",
|
||||
"django-redis==7.0.0",
|
||||
@@ -41,30 +41,30 @@ dependencies = [
|
||||
"django-timezone-field>=5.1",
|
||||
"django-pydantic-field==0.5.4",
|
||||
"django==5.2.16",
|
||||
"djangorestframework==3.18.0",
|
||||
"djangorestframework==3.17.1",
|
||||
"drf_spectacular==0.30.0",
|
||||
"dockerflow==2026.3.4",
|
||||
"easy_thumbnails==2.10.1",
|
||||
"factory_boy==3.3.3",
|
||||
"gunicorn==26.2.0",
|
||||
"gunicorn==26.0.0",
|
||||
"jsonschema==4.26.0",
|
||||
"markdown==3.10.3",
|
||||
"markdown==3.10.2",
|
||||
"nested-multipart-parser==1.6.0",
|
||||
"posthog==7.44.0",
|
||||
"posthog==7.29.0",
|
||||
"psycopg[binary]==3.3.4",
|
||||
"pydantic==2.13.4",
|
||||
"PyJWT==2.14.0",
|
||||
"PyJWT==2.13.0",
|
||||
"python-frontmatter==1.3.0",
|
||||
"python-magic==0.4.27",
|
||||
"requests==2.34.2",
|
||||
"sentry-sdk==2.68.1",
|
||||
"sentry-sdk==2.66.1",
|
||||
"whitenoise==6.12.0",
|
||||
"mozilla-django-oidc==5.0.2",
|
||||
"livekit-api==1.2.0",
|
||||
"aiohttp==3.14.3",
|
||||
"urllib3==2.7.0",
|
||||
"phonenumbers==9.0.37",
|
||||
"cryptography==50.0.1", # CVE-2026-69247
|
||||
"phonenumbers==9.0.34",
|
||||
"cryptography==50.0.0", # CVE-2026-69247
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
@@ -76,20 +76,20 @@ dependencies = [
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"django-extensions==4.1",
|
||||
"drf-spectacular-sidecar==2026.8.1",
|
||||
"drf-spectacular-sidecar==2026.7.1",
|
||||
"freezegun==1.5.5",
|
||||
"ipdb==0.13.13",
|
||||
"ipython==9.16.1",
|
||||
"ipython==9.15.0",
|
||||
"pyfakefs==6.2.0",
|
||||
"pylint-django==2.8.0",
|
||||
"pylint<4.0.0",
|
||||
"pytest-cov==7.1.0",
|
||||
"pytest-django==4.14.0",
|
||||
"pytest-django==4.12.0",
|
||||
"pytest==9.1.1",
|
||||
"pytest-icdiff==0.9",
|
||||
"pytest-xdist==3.8.0",
|
||||
"responses==0.26.2",
|
||||
"ruff==0.16.4",
|
||||
"ruff==0.16.0",
|
||||
"types-requests==2.33.0.20260712",
|
||||
]
|
||||
|
||||
|
||||
Generated
+537
-754
File diff suppressed because it is too large
Load Diff
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"version": "1.32.1",
|
||||
"version": "1.31.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "meet",
|
||||
"version": "1.32.1",
|
||||
"version": "1.31.0",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/atkinson-hyperlegible-next": "5.3.0",
|
||||
"@fontsource-variable/lexend": "5.3.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"private": true,
|
||||
"version": "1.32.1",
|
||||
"version": "1.31.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "panda codegen && vite",
|
||||
|
||||
@@ -41,7 +41,6 @@ export const useAnalytics = ({
|
||||
api_host: host,
|
||||
flags_api_host: flags_api_host,
|
||||
person_profiles: 'always',
|
||||
remote_config_refresh_interval_ms: 0,
|
||||
capture_pageview: 'history_change',
|
||||
capture_pageleave: true,
|
||||
capture_exceptions: {
|
||||
|
||||
@@ -24,11 +24,10 @@ _summaryEnvVars: &summaryEnvVars
|
||||
APP_NAME: summary-microservice
|
||||
APP_API_TOKEN: password
|
||||
AWS_STORAGE_BUCKET_NAME: meet-media-storage
|
||||
AWS_S3_ENDPOINT_URL: http://garage.meet.svc.cluster.local:9000/
|
||||
AWS_S3_ACCESS_KEY_ID: meet-access-key
|
||||
AWS_S3_SECRET_ACCESS_KEY: meet-secret-access-key
|
||||
AWS_S3_ENDPOINT_URL: http://minio.meet.svc.cluster.local:9000/
|
||||
AWS_S3_ACCESS_KEY_ID: meet
|
||||
AWS_S3_SECRET_ACCESS_KEY: password
|
||||
AWS_S3_SECURE_ACCESS: False
|
||||
AWS_S3_REGION_NAME: local
|
||||
AUTHORIZED_TENANTS: >
|
||||
[
|
||||
{
|
||||
@@ -162,9 +161,9 @@ backend:
|
||||
FRONTEND_TRANSCRIPTION_DESTINATION: "https://docs.numerique.gouv.fr"
|
||||
FRONTEND_IS_SILENT_LOGIN_ENABLED: False
|
||||
# S3 Storage
|
||||
AWS_S3_ENDPOINT_URL: http://garage.meet.svc.cluster.local:9000
|
||||
AWS_S3_ACCESS_KEY_ID: meet-access-key
|
||||
AWS_S3_SECRET_ACCESS_KEY: meet-secret-access-key
|
||||
AWS_S3_ENDPOINT_URL: http://minio.meet.svc.cluster.local:9000
|
||||
AWS_S3_ACCESS_KEY_ID: meet
|
||||
AWS_S3_SECRET_ACCESS_KEY: password
|
||||
AWS_STORAGE_BUCKET_NAME: meet-media-storage
|
||||
# Telephony
|
||||
ROOM_TELEPHONY_ENABLED: True
|
||||
@@ -181,7 +180,7 @@ backend:
|
||||
# Custom Background
|
||||
AWS_S3_REGION_NAME: local
|
||||
AWS_S3_SIGNATURE_VERSION: s3v4
|
||||
AWS_S3_DOMAIN_REPLACE: https://garage.127.0.0.1.nip.io
|
||||
AWS_S3_DOMAIN_REPLACE: https://minio.127.0.0.1.nip.io
|
||||
MEDIA_BASE_URL: https://meet.127.0.0.1.nip.io
|
||||
FILE_UPLOAD_ENABLED: True
|
||||
CELERY_ENABLED: True
|
||||
@@ -265,11 +264,11 @@ ingressMedia:
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/auth-url: https://meet.127.0.0.1.nip.io/api/v1.0/recordings/media-auth/
|
||||
nginx.ingress.kubernetes.io/auth-response-headers: "Authorization, X-Amz-Date, X-Amz-Content-SHA256"
|
||||
nginx.ingress.kubernetes.io/upstream-vhost: garage.meet.svc.cluster.local:9000
|
||||
nginx.ingress.kubernetes.io/upstream-vhost: minio.meet.svc.cluster.local:9000
|
||||
nginx.ingress.kubernetes.io/rewrite-target: /meet-media-storage/$1
|
||||
|
||||
serviceMedia:
|
||||
host: garage.meet.svc.cluster.local
|
||||
host: minio.meet.svc.cluster.local
|
||||
port: 9000
|
||||
|
||||
# ---- Extra ingress/service for background file uploads ------------
|
||||
@@ -281,11 +280,11 @@ ingressMediaFiles:
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/auth-url: https://meet.127.0.0.1.nip.io/api/v1.0/files/media-auth/
|
||||
nginx.ingress.kubernetes.io/auth-response-headers: "Authorization, X-Amz-Date, X-Amz-Content-SHA256"
|
||||
nginx.ingress.kubernetes.io/upstream-vhost: garage.meet.svc.cluster.local:9000
|
||||
nginx.ingress.kubernetes.io/upstream-vhost: minio.meet.svc.cluster.local:9000
|
||||
nginx.ingress.kubernetes.io/rewrite-target: /meet-media-storage/files/$1
|
||||
|
||||
serviceMediaFiles:
|
||||
host: garage.meet.svc.cluster.local
|
||||
host: minio.meet.svc.cluster.local
|
||||
port: 9000
|
||||
|
||||
# ---- STT Orchestration Microservice Components --------------------
|
||||
@@ -363,11 +362,10 @@ agentMetadata:
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
ENABLE_SILERO_VAD: "false"
|
||||
AWS_S3_ENDPOINT_URL: garage.meet.svc.cluster.local:9000
|
||||
AWS_S3_ACCESS_KEY_ID: meet-access-key
|
||||
AWS_S3_SECRET_ACCESS_KEY: meet-secret-access-key
|
||||
AWS_S3_ENDPOINT_URL: minio.meet.svc.cluster.local:9000
|
||||
AWS_S3_ACCESS_KEY_ID: meet
|
||||
AWS_S3_SECRET_ACCESS_KEY: password
|
||||
AWS_S3_SECURE_ACCESS: False
|
||||
AWS_S3_REGION_NAME: local
|
||||
AWS_STORAGE_BUCKET_NAME: meet-media-storage
|
||||
AWS_S3_OUTPUT_FOLDER: metadata
|
||||
|
||||
|
||||
@@ -16,11 +16,11 @@ egress:
|
||||
address: redis-master:6379
|
||||
password: pass
|
||||
s3:
|
||||
access_key: meet-access-key
|
||||
secret: meet-secret-access-key
|
||||
access_key: meet
|
||||
secret: password
|
||||
region: local
|
||||
bucket: meet-media-storage
|
||||
endpoint: http://garage:9000
|
||||
endpoint: http://minio:9000
|
||||
force_path_style: true
|
||||
|
||||
loadBalancer:
|
||||
|
||||
@@ -19,11 +19,11 @@ egress:
|
||||
address: redis-master:6379
|
||||
password: pass
|
||||
s3:
|
||||
access_key: meet-access-key
|
||||
secret: meet-secret-access-key
|
||||
access_key: meet
|
||||
secret: password
|
||||
region: local
|
||||
bucket: meet-media-storage
|
||||
endpoint: http://garage:9000
|
||||
endpoint: http://minio:9000
|
||||
force_path_style: true
|
||||
|
||||
loadBalancer:
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: garage
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/proxy-body-size: 10m
|
||||
spec:
|
||||
rules:
|
||||
- host: "garage.127.0.0.1.nip.io"
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: garage
|
||||
port:
|
||||
number: 9000
|
||||
tls:
|
||||
- hosts:
|
||||
- garage.127.0.0.1.nip.io
|
||||
secretName: meet-tls
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: garage
|
||||
namespace: {{ .Release.Namespace | quote }}
|
||||
spec:
|
||||
ports:
|
||||
- name: client
|
||||
port: 9000
|
||||
protocol: TCP
|
||||
targetPort: 9000
|
||||
selector:
|
||||
app.kubernetes.io/instance: extra
|
||||
app.kubernetes.io/name: garage
|
||||
type: ClusterIP
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: garage-config
|
||||
namespace: {{ .Release.Namespace | quote }}
|
||||
data:
|
||||
garage.toml: |
|
||||
metadata_dir = "/var/lib/garage/meta"
|
||||
data_dir = "/var/lib/garage/data"
|
||||
db_engine = "lmdb"
|
||||
|
||||
replication_factor = 1
|
||||
|
||||
rpc_bind_addr = "127.0.0.1:3901"
|
||||
rpc_public_addr = "127.0.0.1:3901"
|
||||
|
||||
[s3_api]
|
||||
api_bind_addr = "[::]:9000"
|
||||
# Clients must sign their requests for this region (AWS_S3_REGION_NAME)
|
||||
s3_region = "local"
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: garage-dev
|
||||
namespace: {{ .Release.Namespace | quote }}
|
||||
type: Opaque
|
||||
data:
|
||||
GARAGE_RPC_SECRET: {{ printf "%s/garage-rpc-secret" .Release.Namespace | sha256sum | b64enc }}
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: garage
|
||||
namespace: {{ .Release.Namespace | quote }}
|
||||
labels:
|
||||
app.kubernetes.io/instance: extra
|
||||
app.kubernetes.io/name: garage
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/instance: extra
|
||||
app.kubernetes.io/name: garage
|
||||
replicas: 1
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/instance: extra
|
||||
app.kubernetes.io/name: garage
|
||||
spec:
|
||||
containers:
|
||||
- name: garage
|
||||
command:
|
||||
- /garage
|
||||
- server
|
||||
- --single-node
|
||||
- --default-bucket
|
||||
env:
|
||||
- name: GARAGE_RPC_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: garage-dev
|
||||
key: GARAGE_RPC_SECRET
|
||||
- name: GARAGE_DEFAULT_ACCESS_KEY
|
||||
value: meet-access-key
|
||||
- name: GARAGE_DEFAULT_SECRET_KEY
|
||||
value: meet-secret-access-key
|
||||
- name: GARAGE_DEFAULT_BUCKET
|
||||
value: meet-media-storage
|
||||
image: "dxflrs/garage:v2.4.1"
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- containerPort: 9000
|
||||
name: client
|
||||
readinessProbe:
|
||||
exec:
|
||||
command:
|
||||
- /garage
|
||||
- health
|
||||
volumeMounts:
|
||||
- mountPath: /etc/garage.toml
|
||||
name: config
|
||||
subPath: garage.toml
|
||||
- mountPath: /var/lib/garage
|
||||
name: data
|
||||
volumes:
|
||||
- name: config
|
||||
configMap:
|
||||
name: garage-config
|
||||
- name: data
|
||||
emptyDir:
|
||||
---
|
||||
# Garage denies cross-origin requests by default: allow the frontend to upload
|
||||
# files straight to the bucket
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: garage-cors
|
||||
spec:
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: aws-cli
|
||||
image: amazon/aws-cli:2.37.1
|
||||
env:
|
||||
- name: AWS_ACCESS_KEY_ID
|
||||
value: meet-access-key
|
||||
- name: AWS_SECRET_ACCESS_KEY
|
||||
value: meet-secret-access-key
|
||||
- name: AWS_DEFAULT_REGION
|
||||
value: local
|
||||
- name: AWS_ENDPOINT_URL
|
||||
value: http://garage:9000
|
||||
- name: BUCKET
|
||||
value: meet-media-storage
|
||||
command:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- |
|
||||
deadline=$(($(date +%s) + 300))
|
||||
until aws s3api head-bucket --bucket="$BUCKET" --cli-connect-timeout=5; do
|
||||
if [ "$(date +%s)" -ge "$deadline" ]; then
|
||||
echo "Bucket $BUCKET still unavailable on $AWS_ENDPOINT_URL" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Waiting for bucket $BUCKET on $AWS_ENDPOINT_URL"
|
||||
sleep 5
|
||||
done
|
||||
exec aws s3api put-bucket-cors --bucket="$BUCKET" \
|
||||
--cors-configuration='{"CORSRules": [{"AllowedOrigins": ["https://meet.127.0.0.1.nip.io"], "AllowedMethods": ["GET", "HEAD", "PUT"], "AllowedHeaders": ["*"], "ExposeHeaders": ["ETag"]}]}'
|
||||
restartPolicy: Never
|
||||
backoffLimit: 3
|
||||
@@ -0,0 +1,115 @@
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: minio
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/proxy-body-size: 10m
|
||||
spec:
|
||||
rules:
|
||||
- host: "minio.127.0.0.1.nip.io"
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: minio
|
||||
port:
|
||||
number: 9000
|
||||
tls:
|
||||
- hosts:
|
||||
- minio.127.0.0.1.nip.io
|
||||
secretName: meet-tls
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: minio
|
||||
namespace: {{ .Release.Namespace | quote }}
|
||||
spec:
|
||||
ports:
|
||||
- name: client
|
||||
port: 9000
|
||||
protocol: TCP
|
||||
targetPort: 9000
|
||||
- name: console
|
||||
port: 9001
|
||||
protocol: TCP
|
||||
targetPort: 9001
|
||||
selector:
|
||||
app.kubernetes.io/instance: extra
|
||||
app.kubernetes.io/name: minio
|
||||
type: ClusterIP
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: minio
|
||||
namespace: {{ .Release.Namespace | quote }}
|
||||
labels:
|
||||
app.kubernetes.io/instance: extra
|
||||
app.kubernetes.io/name: minio
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/instance: extra
|
||||
app.kubernetes.io/name: minio
|
||||
replicas: 1
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/instance: extra
|
||||
app.kubernetes.io/name: minio
|
||||
spec:
|
||||
containers:
|
||||
- name: minio
|
||||
command:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- |
|
||||
minio server --console-address :9001 /data
|
||||
env:
|
||||
- name: MINIO_ROOT_USER
|
||||
value: meet
|
||||
- name: MINIO_ROOT_PASSWORD
|
||||
value: password
|
||||
image: "quay.io/minio/minio"
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- containerPort: 9000
|
||||
name: client
|
||||
- containerPort: 9001
|
||||
name: console
|
||||
volumeMounts:
|
||||
- mountPath: /data
|
||||
name: data
|
||||
- mountPath: /etc/ssl/certs/mkcert-ca.pem
|
||||
name: mkcert
|
||||
subPath: rootCA.pem
|
||||
volumes:
|
||||
- name: data
|
||||
emptyDir:
|
||||
- name: mkcert
|
||||
secret:
|
||||
secretName: mkcert
|
||||
---
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: minio-bucket
|
||||
spec:
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: mc
|
||||
image: quay.io/minio/mc
|
||||
command:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- |
|
||||
/usr/bin/mc alias set meet http://minio:9000 meet password && \
|
||||
/usr/bin/mc mb meet/meet-media-storage && \
|
||||
exit 0
|
||||
restartPolicy: Never
|
||||
backoffLimit: 3
|
||||
@@ -45,10 +45,10 @@
|
||||
| `ingressMedia.tls.additional[].hosts[]` | Hosts for additional TLS config | |
|
||||
| `ingressMedia.annotations.nginx.ingress.kubernetes.io/auth-url` | | `https://meet.example.com/api/v1.0/recordings/media-auth/` |
|
||||
| `ingressMedia.annotations.nginx.ingress.kubernetes.io/auth-response-headers` | | `Authorization, X-Amz-Date, X-Amz-Content-SHA256` |
|
||||
| `ingressMedia.annotations.nginx.ingress.kubernetes.io/upstream-vhost` | | `garage.meet.svc.cluster.local:9000` |
|
||||
| `ingressMedia.annotations.nginx.ingress.kubernetes.io/upstream-vhost` | | `minio.meet.svc.cluster.local:9000` |
|
||||
| `ingressMedia.annotations.nginx.ingress.kubernetes.io/configuration-snippet` | | `add_header Content-Security-Policy "default-src 'none'" always;
|
||||
` |
|
||||
| `serviceMedia.host` | | `garage.meet.svc.cluster.local` |
|
||||
| `serviceMedia.host` | | `minio.meet.svc.cluster.local` |
|
||||
| `serviceMedia.port` | | `9000` |
|
||||
| `serviceMedia.annotations` | | `{}` |
|
||||
|
||||
|
||||
@@ -128,7 +128,7 @@ ingressMedia:
|
||||
nginx.ingress.kubernetes.io/use-regex: "true"
|
||||
nginx.ingress.kubernetes.io/auth-url: https://meet.example.com/api/v1.0/recordings/media-auth/
|
||||
nginx.ingress.kubernetes.io/auth-response-headers: "Authorization, X-Amz-Date, X-Amz-Content-SHA256"
|
||||
nginx.ingress.kubernetes.io/upstream-vhost: garage.meet.svc.cluster.local:9000
|
||||
nginx.ingress.kubernetes.io/upstream-vhost: minio.meet.svc.cluster.local:9000
|
||||
nginx.ingress.kubernetes.io/configuration-snippet: |
|
||||
add_header Content-Security-Policy "default-src 'none'" always;
|
||||
|
||||
@@ -136,7 +136,7 @@ ingressMedia:
|
||||
## @param serviceMedia.port
|
||||
## @param serviceMedia.annotations
|
||||
serviceMedia:
|
||||
host: garage.meet.svc.cluster.local
|
||||
host: minio.meet.svc.cluster.local
|
||||
port: 9000
|
||||
annotations: {}
|
||||
|
||||
@@ -171,7 +171,7 @@ ingressMediaFiles:
|
||||
nginx.ingress.kubernetes.io/use-regex: "true"
|
||||
nginx.ingress.kubernetes.io/auth-url: https://meet.example.com/api/v1.0/files/media-auth/
|
||||
nginx.ingress.kubernetes.io/auth-response-headers: "Authorization, X-Amz-Date, X-Amz-Content-SHA256"
|
||||
nginx.ingress.kubernetes.io/upstream-vhost: garage.meet.svc.cluster.local:9000
|
||||
nginx.ingress.kubernetes.io/upstream-vhost: minio.meet.svc.cluster.local:9000
|
||||
nginx.ingress.kubernetes.io/configuration-snippet: |
|
||||
add_header Content-Security-Policy "default-src 'none'" always;
|
||||
add_header Content-Disposition "attachment";
|
||||
@@ -180,7 +180,7 @@ ingressMediaFiles:
|
||||
## @param serviceMediaFiles.port
|
||||
## @param serviceMediaFiles.annotations
|
||||
serviceMediaFiles:
|
||||
host: garage.meet.svc.cluster.local
|
||||
host: minio.meet.svc.cluster.local
|
||||
port: 9000
|
||||
annotations: {}
|
||||
|
||||
@@ -289,9 +289,6 @@ backend:
|
||||
## @param backend.cronjobs[1].name Name of the CronJob
|
||||
## @param backend.cronjobs[1].schedule Schedule in cron format
|
||||
## @param backend.cronjobs[1].command The bash command to execute in the CronJob
|
||||
## @param backend.cronjobs[2].name Name of the CronJob
|
||||
## @param backend.cronjobs[2].schedule Schedule in cron format
|
||||
## @param backend.cronjobs[2].command The bash command to execute in the CronJob
|
||||
|
||||
cronjobs:
|
||||
- name: clean-pending-files
|
||||
@@ -306,12 +303,6 @@ backend:
|
||||
- "/bin/sh"
|
||||
- "-c"
|
||||
- "python manage.py purge_deleted_files"
|
||||
- name: purge-inactive-rooms
|
||||
schedule: "0 1 * * *"
|
||||
command:
|
||||
- "/bin/sh"
|
||||
- "-c"
|
||||
- "python manage.py purge_inactive_rooms"
|
||||
|
||||
## @param backend.mergeDuplicateUsers.command backend merge_duplicate_users command
|
||||
## @param backend.mergeDuplicateUsers.restartPolicy backend merge_duplicate_users job restart policy
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mail_mjml",
|
||||
"version": "1.32.1",
|
||||
"version": "1.31.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mail_mjml",
|
||||
"version": "1.32.1",
|
||||
"version": "1.31.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@html-to/text-cli": "0.6.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mail_mjml",
|
||||
"version": "1.32.1",
|
||||
"version": "1.31.0",
|
||||
"description": "An util to generate html and text django's templates from mjml templates",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sdk",
|
||||
"version": "1.32.1",
|
||||
"version": "1.31.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sdk",
|
||||
"version": "1.32.1",
|
||||
"version": "1.31.0",
|
||||
"license": "ISC",
|
||||
"workspaces": [
|
||||
"./library",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sdk",
|
||||
"version": "1.32.1",
|
||||
"version": "1.31.0",
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
[project]
|
||||
name = "summary"
|
||||
version = "1.32.1"
|
||||
version = "1.31.0"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"fastapi[standard]>=0.105.0",
|
||||
@@ -10,18 +10,18 @@ dependencies = [
|
||||
"pydantic-settings>=2.1.0",
|
||||
"celery==5.6.3",
|
||||
"redis==5.2.1",
|
||||
"boto3==1.43.56",
|
||||
"dockerflow==2026.3.4",
|
||||
"openai==3.3.1",
|
||||
"posthog==7.44.0",
|
||||
"minio==7.2.20",
|
||||
"openai==2.48.0",
|
||||
"posthog==7.29.0",
|
||||
"requests==2.34.2",
|
||||
"sentry-sdk[fastapi, celery]==2.68.1",
|
||||
"sentry-sdk[fastapi, celery]==2.66.1",
|
||||
"langfuse==4.14.1"
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"ruff==0.16.4",
|
||||
"ruff==0.16.0",
|
||||
"pytest==9.1.1",
|
||||
"responses>=0.25.8",
|
||||
]
|
||||
|
||||
@@ -77,7 +77,7 @@ class Settings(BaseSettings):
|
||||
summarize_queue_v2: str = "summarize-queue-v2"
|
||||
call_webhook_queue_v2: str = "call-webhook-queue-v2"
|
||||
|
||||
# S3 settings
|
||||
# Minio settings
|
||||
aws_storage_bucket_name: str
|
||||
aws_s3_endpoint_url: str
|
||||
aws_s3_access_key_id: str
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""File service to encapsulate files' manipulations."""
|
||||
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -8,13 +9,11 @@ import tempfile
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from functools import cached_property
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import boto3
|
||||
import requests
|
||||
from botocore.config import Config
|
||||
from minio import Minio
|
||||
|
||||
from summary.core.config import get_settings
|
||||
from summary.core.shared_models import WhisperXResponse
|
||||
@@ -267,44 +266,30 @@ class FileServiceException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _build_s3_client():
|
||||
"""Build an S3 client for the configured endpoint and region.
|
||||
|
||||
The endpoint may be given with or without a scheme: the scheme always
|
||||
follows `aws_s3_secure_access`.
|
||||
"""
|
||||
endpoint = (
|
||||
settings.aws_s3_endpoint_url.removeprefix("https://")
|
||||
.removeprefix("http://")
|
||||
.rstrip("/")
|
||||
)
|
||||
scheme = "https" if settings.aws_s3_secure_access else "http"
|
||||
|
||||
return boto3.client(
|
||||
"s3",
|
||||
endpoint_url=f"{scheme}://{endpoint}",
|
||||
aws_access_key_id=settings.aws_s3_access_key_id,
|
||||
aws_secret_access_key=settings.aws_s3_secret_access_key.get_secret_value(),
|
||||
region_name=settings.aws_s3_region_name,
|
||||
config=Config(signature_version="s3v4", s3={"addressing_style": "path"}),
|
||||
)
|
||||
|
||||
|
||||
class FileService:
|
||||
"""Service for downloading and preparing files from S3 storage."""
|
||||
"""Service for downloading and preparing files from MinIO storage."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize FileService with its configuration."""
|
||||
"""Initialize FileService with MinIO client and configuration."""
|
||||
endpoint = (
|
||||
settings.aws_s3_endpoint_url.removeprefix("https://")
|
||||
.removeprefix("http://")
|
||||
.rstrip("/")
|
||||
)
|
||||
|
||||
self._minio_client = Minio(
|
||||
endpoint,
|
||||
access_key=settings.aws_s3_access_key_id,
|
||||
secret_key=settings.aws_s3_secret_access_key.get_secret_value(),
|
||||
secure=settings.aws_s3_secure_access,
|
||||
region=settings.aws_s3_region_name,
|
||||
)
|
||||
|
||||
self._bucket_name = settings.aws_storage_bucket_name
|
||||
self._stream_chunk_size = 32 * 1024
|
||||
|
||||
self._max_duration_seconds = settings.recording_max_duration
|
||||
|
||||
@cached_property
|
||||
def _s3_client(self):
|
||||
"""S3 client, created on first use."""
|
||||
return _build_s3_client()
|
||||
|
||||
def _download_from_cloud_storage_url(self, cloud_storage_url: str) -> Path:
|
||||
"""Download file from a cloud storage URL to local temporary file."""
|
||||
logger.info(
|
||||
@@ -383,7 +368,7 @@ class FileService:
|
||||
):
|
||||
"""Download and prepare audio file for processing.
|
||||
|
||||
Downloads file from S3 or an external cloud URL, validates duration,
|
||||
Downloads file from MinIO or an external cloud URL, validates duration,
|
||||
and yields an open file handle with metadata. Automatically cleans up
|
||||
temporary files when the context exits.
|
||||
"""
|
||||
@@ -431,13 +416,16 @@ class FileService:
|
||||
logger.warning("Failed to remove temporary file %s: %s", path, e)
|
||||
|
||||
def store_transcript(self, *, transcript: WhisperXResponse, job_id: str) -> None:
|
||||
"""Store transcript in S3."""
|
||||
"""Store transcript in MinIO."""
|
||||
logger.info("Storing transcript for job id %s", job_id)
|
||||
transcript_path = f"{settings.aws_transcript_path}/{job_id}.json"
|
||||
logger.debug("Transcript path: %s", transcript_path)
|
||||
data = transcript.model_dump_json().encode()
|
||||
self._s3_client.put_object(
|
||||
Bucket=self._bucket_name, Key=transcript_path, Body=data
|
||||
self._minio_client.put_object(
|
||||
self._bucket_name,
|
||||
transcript_path,
|
||||
io.BytesIO(data),
|
||||
length=len(data),
|
||||
)
|
||||
logger.info("Transcript stored successfully for job id %s", job_id)
|
||||
|
||||
@@ -445,20 +433,21 @@ class FileService:
|
||||
"""Get signed URL for transcript file."""
|
||||
transcript_path = f"{settings.aws_transcript_path}/{job_id}.json"
|
||||
logger.debug("Transcript path: %s", transcript_path)
|
||||
return self._s3_client.generate_presigned_url(
|
||||
"get_object",
|
||||
Params={"Bucket": self._bucket_name, "Key": transcript_path},
|
||||
ExpiresIn=int(timedelta(hours=24).total_seconds()),
|
||||
return self._minio_client.presigned_get_object(
|
||||
self._bucket_name, transcript_path, expires=timedelta(hours=24)
|
||||
)
|
||||
|
||||
def store_summary(self, *, summary: str, job_id: str) -> None:
|
||||
"""Store summary in S3."""
|
||||
"""Store summary in MinIO."""
|
||||
logger.info("Storing summary for job id %s", job_id)
|
||||
summary_path = f"{settings.aws_summary_path}/{job_id}.txt"
|
||||
logger.debug("Summary path: %s", summary_path)
|
||||
data = summary.encode()
|
||||
self._s3_client.put_object(
|
||||
Bucket=self._bucket_name, Key=summary_path, Body=data
|
||||
self._minio_client.put_object(
|
||||
self._bucket_name,
|
||||
summary_path,
|
||||
io.BytesIO(data),
|
||||
length=len(data),
|
||||
)
|
||||
logger.info("Summary stored successfully for job id %s", job_id)
|
||||
|
||||
@@ -466,8 +455,6 @@ class FileService:
|
||||
"""Get signed URL for summary file."""
|
||||
summary_path = f"{settings.aws_summary_path}/{job_id}.txt"
|
||||
logger.debug("Summary path: %s", summary_path)
|
||||
return self._s3_client.generate_presigned_url(
|
||||
"get_object",
|
||||
Params={"Bucket": self._bucket_name, "Key": summary_path},
|
||||
ExpiresIn=int(timedelta(hours=24).total_seconds()),
|
||||
return self._minio_client.presigned_get_object(
|
||||
self._bucket_name, summary_path, expires=timedelta(hours=24)
|
||||
)
|
||||
|
||||
@@ -1,22 +1,17 @@
|
||||
"""Unit tests for the file service."""
|
||||
|
||||
import json
|
||||
from collections.abc import Callable, Iterator
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import pytest
|
||||
from botocore.stub import Stubber
|
||||
|
||||
from summary.core import file_service
|
||||
from summary.core.file_service import (
|
||||
FileService,
|
||||
MediaInfo,
|
||||
extract_audio_from_media,
|
||||
get_media_info,
|
||||
)
|
||||
from summary.core.shared_models import WhisperXResponse
|
||||
|
||||
BASE_PATH = Path(__file__).parent.parent / "assets"
|
||||
|
||||
@@ -29,46 +24,6 @@ MEDIA_INFO_SAMPLE_VISIO = MediaInfo(
|
||||
)
|
||||
|
||||
|
||||
S3Settings = Callable[..., None]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def s3_settings(monkeypatch: pytest.MonkeyPatch) -> S3Settings:
|
||||
"""Configure the S3 settings read by the file service.
|
||||
|
||||
The returned function overrides some of them on top of the current ones.
|
||||
The (frozen) settings are replaced by a copy, restored after the test.
|
||||
"""
|
||||
|
||||
def override(**values) -> None:
|
||||
monkeypatch.setattr(
|
||||
file_service, "settings", file_service.settings.model_copy(update=values)
|
||||
)
|
||||
|
||||
override(
|
||||
aws_s3_endpoint_url="garage:9000",
|
||||
aws_s3_secure_access=False,
|
||||
aws_s3_region_name="fr-par",
|
||||
aws_storage_bucket_name="meet-media-storage",
|
||||
)
|
||||
return override
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def s3_stubber(
|
||||
monkeypatch: pytest.MonkeyPatch, s3_settings: S3Settings
|
||||
) -> Iterator[Stubber]:
|
||||
"""Stub the S3 client built by the file service.
|
||||
|
||||
An unexpected S3 call fails the test instead of reaching the network.
|
||||
"""
|
||||
stubber = Stubber(file_service._build_s3_client())
|
||||
stubber.activate()
|
||||
monkeypatch.setattr(file_service, "_build_s3_client", lambda: stubber.client)
|
||||
yield stubber
|
||||
stubber.assert_no_pending_responses()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"media_info",
|
||||
[
|
||||
@@ -194,92 +149,3 @@ def test_extract_audio_from_video():
|
||||
assert path.name.endswith(".m4a")
|
||||
finally:
|
||||
path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("endpoint_url", "secure_access", "expected_endpoint_url"),
|
||||
[
|
||||
("garage:9000", False, "http://garage:9000"),
|
||||
("http://garage:9000/", False, "http://garage:9000"),
|
||||
("s3.example.com", True, "https://s3.example.com"),
|
||||
("http://s3.example.com", True, "https://s3.example.com"),
|
||||
],
|
||||
)
|
||||
def test_s3_client_endpoint_follows_secure_access(
|
||||
s3_settings: S3Settings,
|
||||
endpoint_url: str,
|
||||
secure_access: bool,
|
||||
expected_endpoint_url: str,
|
||||
) -> None:
|
||||
"""The endpoint scheme is taken from aws_s3_secure_access, not from the URL."""
|
||||
s3_settings(aws_s3_endpoint_url=endpoint_url, aws_s3_secure_access=secure_access)
|
||||
|
||||
assert FileService()._s3_client.meta.endpoint_url == expected_endpoint_url
|
||||
|
||||
|
||||
@pytest.mark.parametrize("region_name", ["fr-par", None])
|
||||
def test_s3_client_region_is_passed_as_is(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
s3_settings: S3Settings,
|
||||
region_name: str | None,
|
||||
) -> None:
|
||||
"""The configured region goes straight to boto3, even when it is unset."""
|
||||
s3_settings(aws_s3_region_name=region_name)
|
||||
boto3_client = Mock()
|
||||
monkeypatch.setattr(file_service.boto3, "client", boto3_client)
|
||||
|
||||
assert FileService()._s3_client is boto3_client.return_value
|
||||
|
||||
boto3_client.assert_called_once()
|
||||
assert boto3_client.call_args.kwargs["region_name"] == region_name
|
||||
|
||||
|
||||
def test_store_transcript(s3_stubber: Stubber) -> None:
|
||||
"""The transcript is stored as JSON under the transcripts path."""
|
||||
transcript = WhisperXResponse(segments=())
|
||||
s3_stubber.add_response(
|
||||
"put_object",
|
||||
{},
|
||||
{
|
||||
"Bucket": "meet-media-storage",
|
||||
"Key": "transcripts/job-1.json",
|
||||
"Body": transcript.model_dump_json().encode(),
|
||||
},
|
||||
)
|
||||
|
||||
FileService().store_transcript(transcript=transcript, job_id="job-1")
|
||||
|
||||
|
||||
def test_store_summary(s3_stubber: Stubber) -> None:
|
||||
"""The summary is stored as text under the summaries path."""
|
||||
s3_stubber.add_response(
|
||||
"put_object",
|
||||
{},
|
||||
{
|
||||
"Bucket": "meet-media-storage",
|
||||
"Key": "summaries/job-1.txt",
|
||||
"Body": b"The summary",
|
||||
},
|
||||
)
|
||||
|
||||
FileService().store_summary(summary="The summary", job_id="job-1")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("method", "expected_path"),
|
||||
[
|
||||
("get_transcript_signed_url", "/meet-media-storage/transcripts/job-1.json"),
|
||||
("get_summary_signed_url", "/meet-media-storage/summaries/job-1.txt"),
|
||||
],
|
||||
)
|
||||
def test_signed_urls(s3_stubber: Stubber, method: str, expected_path: str) -> None:
|
||||
"""Signed URLs are path-style, SigV4-signed for the region, valid for a day."""
|
||||
url = urlparse(getattr(FileService(), method)("job-1"))
|
||||
query = parse_qs(url.query)
|
||||
|
||||
assert url.scheme == "http"
|
||||
assert url.netloc == "garage:9000"
|
||||
assert url.path == expected_path
|
||||
assert query["X-Amz-Algorithm"] == ["AWS4-HMAC-SHA256"]
|
||||
assert "/fr-par/s3/aws4_request" in query["X-Amz-Credential"][0]
|
||||
assert query["X-Amz-Expires"] == ["86400"]
|
||||
|
||||
Generated
+577
-579
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user