Compare commits

..

58 Commits

Author SHA1 Message Date
leo 8017b03436 wip 2026-09-29 15:30:01 +02:00
leo 1a728cf049 wip 2026-09-29 15:11:08 +02:00
leo 4071984f8e ⬆️(dependencies) update python dependencies
Update python dependencies.

Co-Authored-By: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-29 11:47:32 +02:00
lebaudantoine 2ae602606c ⚡️(frontend) disable posthog-js periodic feature flag reloads
Since posthog-js 1.356.0, feature flags are reloaded every 5
minutes by default while the tab is visible. Meet sessions are
long-lived visible tabs, so this multiplied the number of `/flags`
requests we send.

Restore the pre-1.356.0 behavior: only (re)load flags on init and
on identity
2026-09-29 11:14:05 +02:00
briquet f28389b624 👷(helm) run the inactive rooms purge command as cronjob
Schedule `purge_inactive_rooms` every night along with the other
housekeeping commands.
2026-09-29 11:08:38 +02:00
briquet cbb8740f41 📝(docs) document the inactive rooms purge
Add a basic documentation of the purge_inactive_rooms command
2026-09-29 11:08:38 +02:00
briquet b4b9fe54fb ♻️(backend) add command to purge inactive rooms
Add a `purge_inactive_rooms` management command which permanently
deletes the rooms that were not started for
`ROOM_INACTIVITY_DELETION_DAYS` days. Rooms never started are aged from
their creation date.

Important points :
- The setting is unset by default, which disables the purge.
- Rooms holding a recording their users may still access are kept,
- It refuses to run while unregistered rooms are allowed, as the link
  of a purged room would turn into a public unregistered room,
2026-09-29 11:08:38 +02:00
briquet 88685d613a 🧐(backend) track room last start datetime from livekit webhook
Rooms pile up in database with no way to tell the ones still in use from
the abandoned ones. Record on the room the last time LiveKit reported it
as started, in a new `last_started_at` field which stays NULL until the
first time the room is started on a LiveKit node.

The migration stamps existing rooms with the migration time to avoid
deleting preexisting rooms.
2026-09-29 11:08:38 +02:00
briquet 6cde1b4461 🔨(dev) add Makefile targets to list and download files from Garage
Garage has no web console, so inspecting recordings, transcripts and
summaries locally meant writing aws-cli commands by hand.

For each of recordings, transcripts and summaries:

- `make <folder>-list` lists the files from the most recent, and
- `make <folder>-download-latest` downloads the latest one into
data/<folder>.

Only files with the folder's expected extensions are kept, so the Egress
manifests stored next to recordings are skipped.
2026-09-29 10:52:49 +02:00
briquet 68fe3f96fc 🔧(helm) point media services to Garage by default
The media ingresses and their ExternalName services defaulted to the
MinIO service of the development stack, which is now Garage.
Self-hosted relying on these defaults must set serviceMedia*.host and the
upstream-vhost annotations explicitly, see UPGRADE.md.
2026-09-29 10:52:49 +02:00
briquet 3f9942a61d 🔧(compose) replace MinIO by Garage for local development
The development stacks now run Garage instead of MinIO which is
deprecated.

Garage is a bit stricter than MinIO:
- key IDs and secrets must be at least 8 and 16 characters long
- requests must be signed for its region, so every development env now sets
  AWS_S3_REGION_NAME=local;
- cross-origin requests are denied unless the bucket CORS rules allow
  them, so a one-shot aws-cli container allows the frontend origin to
  upload files straight to the bucket.
2026-09-29 10:52:49 +02:00
briquet 62a2515c6b ✅(summary) test the S3 FileClient service
Ensure that the new combination of boto + Garage work well
together and honor the region and secure parameters.
2026-09-29 10:52:49 +02:00
briquet fa9b30c6bf ♻️(agents) replace the minio client by boto3
Switch from the minio client to the boto3 python client, in the metadata
collector agent and the summary service. AWS_S3_REGION_NAME is passed
as-is to boto3, the region is not looked up from the bucket.
2026-09-29 10:52:49 +02:00
lebaudantoine 4d9ee4e9c5 🔧(devx) call summary v2 without trailing slash from dev backend
In the dev stack, configure the backend to call the summary service
using its v2 API by default.

Also strip the trailing `/` from the task endpoint, which was
triggering a redirect on every call.
2026-09-29 10:40:28 +02:00
lebaudantoine 72cd5a8f18 🔥(github) remove local GitHub PR and issue templates
The organization now provides default GitHub templates for pull
requests and issues at the org level, so individual repositories no
longer need to duplicate the same Markdown files.

Delete the local templates so this project uses the organization
defaults, reducing the amount of code we maintain and centralizing
the practice across repositories.
2026-09-28 17:03:41 +02:00
lebaudantoine 21dc63b8ce 🔖(patch) bump release to 1.32.1 2026-09-25 16:47:35 +02:00
lebaudantoine 8d5d42cfdb 🔒️(agent) fix CRITICAL CVE-2026-63072 / CVE-2026-63073 in libssl3t64
Address the following CVEs reported by Trivy on the LiveKit agent
image against `libssl3t64` 3.5.7-1~deb13u2:

* CVE-2026-63073 — CRITICAL (CVSS 9.8)
* CVE-2026-63072

Bump `libssl3t64` to the patched version to pick up both fixes.
2026-09-25 16:40:39 +02:00
lebaudantoine 2480a76b62 🔒️(backend) fix CVE-2026-73228 and CVE-2026-73229 in drf
Address the following MEDIUM severity CVEs reported against
`djangorestframework` 3.17.1:

* CVE-2026-73228 (CVSS 5.3)
* CVE-2026-73229 (CVSS 4.3)

Bump `djangorestframework` to the patched version to pick up the
fixes.
2026-09-25 16:40:39 +02:00
lebaudantoine 31c8f3ec06 🔖(minor) bump release to 1.32.0 2026-09-25 15:18:15 +02:00
snyk-bot a8c4aee0c2 ⬆️(addons) upgrade i18next from 26.4.0 to 26.4.2
Snyk has created this PR to upgrade i18next from 26.4.1 to 26.4.2.

See this package in npm:
i18next

See this project in Snyk:
https://app.eu.snyk.io/org/lasuite-dinum-default/project/af693e79-8c43-4c09-ab65-60580515c9e8?utm_source=github&utm_medium=referral&page=upgrade-pr
2026-09-25 11:56:59 +02:00
Lebaud Antoine 9a2ad63524 🔥(backend) remove unused API viewset and permission helpers
Dead code elements spotted by @briquet.
2026-09-24 23:08:00 +02:00
lebaudantoine 67f9e54784 🔒️(frontend) fix HIGH CVE-2026-93990 in libexpat
Bump `libexpat` from 2.8.4-r0 to 2.8.5-r0 to address the following
HIGH severity CVE, reported by Trivy on the frontend image
(alpine 3.24.1):

* CVE-2026-93990 — expat: XML injection via malformed UTF-16
  input.

  https://avd.aquasec.com/nvd/cve-2026-93990
2026-09-24 18:05:32 +02:00
lebaudantoine 5d3255ddbc 🔇(backend) drop warning log when room metadata is updated
Most call sites of `update_metadata` already wrap the call in a
try/except that logs the failure at info level.

Remove the warning log inside `update_metadata` itself to avoid
redundant logs, without losing any information.
2026-09-24 18:05:32 +02:00
leo d89b01b681 🐛(recording) handle FAILED and ABORTED LiveKit egresses
`EGRESS_ABORTED` and `EGRESS_FAILED` events were previously ignored, leaving
recordings indefinitely in `ACTIVE` state and potentially blocking subsequent
recordings with 409 errors. Add handling and logging for failed and aborted
egresses, discarding failed recordings while preserving the existing behavior
for savable recordings.

Rename `handle_complete` to `handle_savable` to reflect that it handles both
`EGRESS_COMPLETE` and `EGRESS_LIMIT_REACHED`.

Slight refactor to separate LiveKit event handling from recording concerns as
part of a general separation concern to allow for future SFU swapping.

NB:
- FAILED recordings are currently discarded although exploitable media files
may exist
- There is a theoretical hole: if stop observes EGRESS_FAILED before the
egress_ended webhook is processed, the recording is immediately marked as
FAILED. Since only ACTIVE and STOPPED recordings are savable, the webhook
then skips the failure notification and LiveKit error log. In that rare race
condition, participants may therefore not see the failure toast. We accept
this trade-off for now, as this should be very infrequent.
- Another theoretical hole: There is a short race window where the user
clicks stop while the limit-reached status is being processed. Since the user
explicitly requested the stop, we consider skipping the limit notification
acceptable and do not handle this case.

fix(recording): log aborted worker events at info level
2026-09-24 18:05:32 +02:00
briquet 660c0ed684 🔒️(backend) upgrade base image to python:3.13.15-alpine3.24
Fixes an XML injection attack in libexpat (CVE-2026-93990)
2026-09-24 17:59:24 +02:00
lebaudantoine 8d980192c8 🚸(frontend) inform user that recording waits until a track is published
When a user starts a recording or a transcription while no track is
published yet, the recording stays in a "starting" state until an
appropriate track is available.

Show an explicit message on start explaining that the recording
will remain in "starting" state until a track of the required type
is published. The expected track type depends on the recording
type (audio-only vs. audio + video).

This situation was generating a lot of support requests, with users
asking why the recording did not actually start.
2026-09-24 00:24:40 +02:00
Ovgodd de1f7158f5 📝(changelog) shorten the reception resolution entry
The changelog check fails at 80 columns. Dropping "the" keeps
the reception resolution entry under that limit.
2026-09-22 16:21:00 +02:00
Ovgodd 6e17c6533c ♿️(frontend) use i18n strings for screen share wheel zoom shortcuts
Use i18n strings for displaying screen share zoom shortcuts in the UI controls.
2026-09-22 16:21:00 +02:00
Ovgodd 27e32c0370 ♿️(frontend) add keyboard navigation to screen share zoom toolbar
Arrows move between controls instead of panning, w/ en/fr/nl/de hint update.
2026-09-22 16:21:00 +02:00
Cyril 6d4403d4fa ♻️(frontend) refactor screen share zoom pan with useMove
use react-aria useMove for pan, zoom/pan refs for DOM updates,
2026-09-22 16:21:00 +02:00
Cyril 37ae308825 ♿️(frontend) add wheel zoom shortcut hints to screen share controls
Show Ctrl/Cmd+scroll zoom shortcut in tooltips, aria, SR hints, w/ en/fr/nl/de.
2026-09-22 16:21:00 +02:00
Cyril 16fd2dc4e8 💄(frontend) improve screen share zoom toolbar sizing and containment
Slightly enlarge toolbar controls while clipping hover states
inside the pill, so buttons no longer overflow the bar.
2026-09-22 16:21:00 +02:00
Cyril 9791a8a3b2 💄(frontend) use distinct expand/collapse icons for fullscreen actions
Replace fullscreen icons with expand-diagonal-line and collapse-diagonal-line
2026-09-22 16:21:00 +02:00
Cyril 5b0dece79b 🌐(frontend) add i18n keys for screen share zoom controls
English and French labels, SR announcements and pan navigation hint.
2026-09-22 16:21:00 +02:00
Cyril 96135a0263 ✨(frontend) add zoomable screen share video component
Wraps VideoTrack with zoom/pan, keyboard nav and screen reader announcements.
2026-09-22 16:21:00 +02:00
Cyril ce2e2a4d64 ✨(frontend) add ScreenShareZoomControls toolbar component
Bottom-right toolbar with zoom, fit-to-window and fullscreen buttons.
2026-09-22 16:21:00 +02:00
Cyril e5dc9c2f15 ✨(frontend) add useScreenShareZoom hook for zoom and pan
Manages zoom level, pan offset, wheel zoom, drag-to-pan and keyboard panBy.
2026-09-22 16:21:00 +02:00
kaelvar e97eab9b5e 🐛(frontend) apply the saved reception resolution when joining a meeting
`VideoResolutionSubscription` applied the saved reception resolution on
`RoomEvent.TrackPublished`. livekit-client does not raise that event for cameras
that were already sending when the local participant joined, so a user who had
chosen Low definition still received High definition from everyone already in
the meeting, and Low definition only from whoever joined after them. Nothing in
the UI showed the discrepancy: the setting kept displaying Low definition.

Apply the preference to the publications we already know about when the effect
runs, and keep listening on `TrackPublished` — which stays the earliest point to
cap a camera that starts after us — plus `TrackSubscribed`, which is the first
event raised for the cameras that were already sending.

That initial pass also covers a change of preference mid-call, which
`VideoTab.updateExistingRemoteVideoQuality` was doing separately. Removed, it is
now the same code path for joining and for changing the setting.

The three entry points overlap on purpose; the `publication.videoQuality` guard
makes the repeats free. It reads as High definition when nothing was ever
requested, so the default case costs no signal round trip either.

Fixes #1606.
2026-09-22 15:07:47 +02:00
lebaudantoine 436b3dc9df 🔖(helm) release chart 0.0.28 2026-09-22 13:43:04 +02:00
briquet 6ea2a85810 🚑️(summary) serve health endpoints with the dockerflow router
The dockerflow package ships with a FastAPI router to serve healthcheck
endpoints. A specific Redis test was activated to check the broker.
2026-09-22 12:00:20 +02:00
briquet 3d1ea88e8f ✅(backend) test the dockerflow health endpoints
Test kubernetes probes liveness/readiness endpoints
2026-09-22 12:00:20 +02:00
briquet beb74af574 🔊(backend) change the dockerflow logger level to WARNING
We want the liveness/readiness failure reason to show explicitly in logs
2026-09-22 12:00:20 +02:00
briquet c785b4a627 ♻️(backend) serve the dockerflow views early in the middleware stack
`DockerflowMiddleware` serves the endpoints `/__heartbeat__`, `/__lbheartbeat__
that we use for the kubernetes probes. Sitting at the bottom of
MIDDLEWARE, every Kubernetes probe traversed all middlewares  which is not
efficient.
2026-09-22 12:00:20 +02:00
briquet a9a4246abb 🐛(helm) swap liveness and readiness probes
The backend and summary probes had the two Dockerflow endpoints the wrong way
round. `/__lbheartbeat__` returns an unconditional 200 as soon as the server is
up and touches no dependency, while `/__heartbeat__` runs the Dockerflow checks
and answers 500 when one of them errors.

Wired as they were, a database error made `/__heartbeat__` fail on every
backend pod at once, restarting them all. Since a restart cannot fix a
database outage, it's better to use these check on the readiness probe
and start routing traffic when the database is reachable.

- Probe liveness on `/__lbheartbeat__` and readiness on `/__heartbeat__`
- Add a startup probe on `/__lbheartbeat__`, polled every 5s with a
  `failureThreshold` of 12, leaving the pod a minute to boot
- Drop `initialDelaySeconds` from liveness and readiness, now that the startup
  probe holds them off until the server answers
- Set `timeoutSeconds` to 5s on every probe, up from the 1s Kubernetes default
- Set the readiness `failureThreshold` to 3
2026-09-22 12:00:20 +02:00
briquet 3cf7f60eaa 🐛(helm) render periodSeconds and failureThreshold on probes
The `meet.probes.abstract` helper was missing `periodSeconds` block which means
Kubernetes fell back to its 10s default instead of the chart value.
It's now possible to configure the  `failureThreshold` and `successThreshold`.
2026-09-22 12:00:20 +02:00
snyk-bot bf215f1513 ⬆️(addons) upgrade i18next from 26.4.0 to 26.4.1
Snyk has created this PR to upgrade i18next from 26.4.0 to 26.4.1.

See this package in npm:
i18next

See this project in Snyk:
https://app.eu.snyk.io/org/lasuite-dinum-default/project/af693e79-8c43-4c09-ab65-60580515c9e8?utm_source=github&utm_medium=referral&page=upgrade-pr
2026-09-22 10:43:55 +02:00
lebaudantoine 75836fc817 ⬆️(devx) update the MinIO image on the Tilt stack
Bump the MinIO image used by the Tilt dev stack to a more recent
version, since the previous public image we relied on was removed.
2026-09-19 20:54:42 +02:00
briquet 1affe65b4a 🔧(dev) configure bureautix proxy for image builds
Builds through the Docker API of the Podman service receive none of the
proxy variables in their RUN steps and fail systematically because of
the proxy rejection. Plain HTTP connections are also rejected by the
proxy with a HTTP 405 method error.

- Passes http_proxy, https_proxy and no_proxy from the shell as build args
- Make the Debian mirror of the agents image a build argument and
  override it for bureautix to force https usage
2026-09-18 16:10:31 +02:00
briquet c34ecbd3ef 🔧(dev) remove the unused bin/compose wrapper
Nothing in the repository nor the CI calls it
2026-09-18 15:17:58 +02:00
briquet 78960d9769 🔧(dev) use a dedicated folder for docker compose overrides
Move extra compose files to docker/compose.d folder
2026-09-18 15:17:51 +02:00
briquet 41d07d36ee 🔧(dev) use port 8081 for keycloak admin on bureautix workstations
The Bureautix workstation proxy listens on 8080, which collides with
Keycloak's published admin port.
2026-09-18 15:17:44 +02:00
Briquet f7386e1741 🔧(dev) add a devenv shell for nix-based workstations
Add the minimal requirements to build and run the project locally on NixOS

- `devenv update` update devenv using NixOS 26.05 stable repositories
- `devenv shell` activates the devenv
- `devenv --profile <profile>` shell uses additional packages when
  activated (profile=agent|summary|k8s)
2026-09-18 15:17:36 +02:00
briquet f1da04eb27 🔧(podman) pin the livekit rtc section for local usage
Pin the LiveKit rtc section: the browser reaches the server through
podman's published ports on loopback while egress and the agents reach
it over the podman network, and those two have no address in common.

`advertise_internal_ip` keeps the container's own interface address as a
host candidate alongside the node_ip one, so LiveKit offers both and ICE
picks whichever works. Without it egress only ever sees 127.0.0.1, which
is the egress container itself, and its peer connection timeouts

`use_external_ip` is turned off since it would advertise the STUN-discovered
public IP, which no local peer can hairpin to.
2026-09-18 15:17:29 +02:00
briquet 2970420b84 🔧(podman) make the dev stack work with rootless podman
Rootless podman maps container UID 0 to the host user and every other
container UID to a subuid that owns nothing in the worktree, so the usual
DOCKER_USER=$(id -u):$(id -g) makes every bind mount effectively
read-only.

Add a compose.podman.yml to override the compose.yml and set
`userns_mode: keep-id` in order to map the host user to the same UID and UID
inside the container. The merge of the docker compose file is now done with
the COMPOSE_FILE environment variable
2026-09-18 15:17:21 +02:00
tanguy chenier 771f58c0aa 🐛(frontend) play the waiting room notification sound on every arrival
The waiting room has its own sound in notifications.mp3, and nothing could play
it: the sprite is named "waiting" while triggerNotificationSound passes a
NotificationType, and howler returns without playing when the sprite id is
unknown. ParticipantWaiting was also absent from the sound settings, so the
check on the store would have refused it first. The toast borrowed the
participant joined sound instead.

The sound was tied to the waiting list going from empty to non empty, so a
second person arriving while someone was still waiting was silent, which is the
case the issue describes.

Name the sprite after the notification type, register the type in the settings,
and sound every arrival, detected on the participant ids so that an admission
and an arrival between two refreshes do not cancel each other out.

closes #1705
2026-09-17 17:06:06 +02:00
briquet b159b20695 🐛(backend) read the Sentry release from pyproject.toml
get_release() read the version from a version.json file  but nothing generates
it during the CI Docker image build, therefore the release reported to Sentry
was always "NA".

Read the version from pyproject.toml instead, which is bumped at each
release and copied into the image.
2026-09-16 15:54:40 +02:00
leo 226d004838 ✅(agents) fix subtitle test
Result of test for display of subtitles was depending on local
env config, potentially failing. Fix this by overriding settings.
2026-09-16 14:20:44 +02:00
lebaudantoine b723b7bb62 🐛(frontend) fix file permissions in the Docker image
Incorrect file permissions in the frontend Docker image caused
problems when running the project, and were surfaced by @briquet
while setting it up with Podman.

Adjust the ownership and permissions applied during the build so
the image works cleanly under Docker and Podman alike.
2026-09-15 00:01:22 +02:00
147 changed files with 7173 additions and 2893 deletions
-28
View File
@@ -1,28 +0,0 @@
---
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.
-23
View File
@@ -1,23 +0,0 @@
---
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! -->
@@ -1,22 +0,0 @@
---
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 🙏
-11
View File
@@ -1,11 +0,0 @@
## Purpose
Description...
## Proposal
Description...
- [] item 1...
- [] item 2...
+18 -29
View File
@@ -226,8 +226,9 @@ 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
AWS_S3_SECRET_ACCESS_KEY: password
AWS_S3_ACCESS_KEY_ID: meet-access-key
AWS_S3_SECRET_ACCESS_KEY: meet-secret-access-key
AWS_S3_REGION_NAME: local
OIDC_RS_CLIENT_ID: meet
OIDC_RS_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly
OIDC_OP_INTROSPECTION_ENDPOINT: https://oidc.example.com/introspect
@@ -250,34 +251,22 @@ jobs:
path: "src/backend/core/templates/mail"
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
- name: Start MinIO
# Creates the access key and the bucket on startup
- name: Start Garage
run: |
docker pull quay.io/minio/minio
docker run -d --name minio \
docker run -d --name garage \
-p 9000:9000 \
-e "MINIO_ACCESS_KEY=meet" \
-e "MINIO_SECRET_KEY=password" \
-v /data/media:/data \
quay.io/minio/minio server --console-address :9001 /data
-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
# Tool to wait for a service to be ready
- name: Install Dockerize
- name: Wait for Garage to be ready
run: |
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"
timeout 30 sh -c 'until docker exec garage /garage health; do sleep 1; done'
- name: Install Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
@@ -310,9 +299,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: "minio:9000"
AWS_S3_ACCESS_KEY_ID: "meet"
AWS_S3_SECRET_ACCESS_KEY: "password"
AWS_S3_ENDPOINT_URL: "garage:9000"
AWS_S3_ACCESS_KEY_ID: "meet-access-key"
AWS_S3_SECRET_ACCESS_KEY: "meet-secret-access-key"
WHISPERX_BASE_URL: "https://configure-your-url.com"
WHISPERX_ASR_MODEL: "large-v2"
WHISPERX_API_KEY: "test-whisperx-secret"
+3
View File
@@ -86,3 +86,6 @@ docker/livekit/rootCA.pem
# Frontend rollup-plugin-visualizer
/src/frontend/rollup-plugin-visualizer/*
# NixOS
.devenv
+46
View File
@@ -8,12 +8,41 @@ and this project adheres to
## [Unreleased]
### Changed
- ⬆️(backend) update python dependencies
- ⬆️(summary) update python dependencies
- ⬆️(agents) update python dependencies
### Fixed
- ⚡️(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
- ✨(backend) purge rooms inactive for a configurable period
- 🔧(dev) add support for Bureautix workstations
- ✨(frontend) add screen share zoom controls #1498
- 🔨(makefile) add targets to list and download files stored in Garage
- ✨(backend) add room soft-deletion to the external API
- ✨(backend) answer 410 Gone when accessing a soft-deleted room
### Changed
- 🔥(backend) remove unused API viewset and permission helpers
- 🔊(backend) pin the dockerflow logger level to WARNING
- 🚑️(summary) serve health endpoints with the dockerflow router
- ♻️(backend) serve the dockerflow views early in the middleware stack
- 📈(frontend) include LiveKit SIDs in the connection analytics event
- 🔇(backend) silence expected 401 warnings on /me
- 🔇(backend) silence noisy request summary info logs
@@ -22,13 +51,30 @@ 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
- 🔖(helm) release chart 0.0.28
- ♻️(backend) decouple recording event handling from LiveKit egress statuses
- ♻️(agents) replace the minio client by boto3
- 🔧(compose) replace MinIO by Garage for local development
- 🔧(helm) point media services to Garage by default
- ♻️(backend) soft delete rooms instead of hard-delete
### Fixed
- 🐛(helm) probe liveness on __lbheartbeat__ and readiness on __heartbeat__
- 🐛(helm) render periodSeconds and failureThreshold on probes
- 🐛(backend) report the app release to Sentry instead of "NA"
- 🐛(frontend) play the waiting room notification sound on every arrival
- 🐛(frontend) apply saved reception resolution when joining a meeting #1714
- 🐛(backend) acknowledge unknown LiveKit webhook events instead of 422
- 🔒️(backend) enforce display name setting on rename API
- 🔒️(backend) reject inactive users in resource server backend
- 🐛(frontend) fix file permissions in the Docker image
- 🚸(frontend) inform user that recording waits until a track is published
- 🔒(backend) upgrade base image to python:3.13.5-alpine3.24
- 🐛(backend) handle failed and aborted egresses
- 🩹(frontend) notify participants when a recording fails or is aborted
- 🔒️(frontend) fix HIGH CVE-2026-93990 in libexpat
## [1.31.0] - 2026-09-08
+1 -1
View File
@@ -1,7 +1,7 @@
# Django Meet
# ---- base image to inherit from ----
FROM python:3.13.5-alpine3.21 AS base
FROM python:3.13.15-alpine3.24 AS base
# Upgrade pip to its latest release to speed up dependencies installation
RUN python -m pip install --upgrade pip
+48 -7
View File
@@ -36,14 +36,9 @@ DB_PORT = 5432
# -- Docker
# Get the current user ID to use for docker run and docker exec commands
ifneq ($(findstring podman,$(DOCKER_HOST)),)
DOCKER_UID = 0
DOCKER_GID = 0
else
DOCKER_UID = $(shell id -u)
DOCKER_GID = $(shell id -g)
endif
DOCKER_USER ?= $(DOCKER_UID):$(DOCKER_GID)
DOCKER_USER = $(DOCKER_UID):$(DOCKER_GID)
COMPOSE = DOCKER_USER=$(DOCKER_USER) docker compose
COMPOSE_EXEC = $(COMPOSE) exec
COMPOSE_EXEC_APP = $(COMPOSE_EXEC) app-dev
@@ -74,6 +69,22 @@ 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
@@ -82,6 +93,9 @@ default: help
data/media:
@mkdir -p data/media
$(STORAGE_DIRS):
@mkdir -p $@
data/static:
@mkdir -p data/static
@@ -90,6 +104,7 @@ 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 \
@@ -297,7 +312,7 @@ shell: ## connect to database shell
# -- Database
dbshell: ## connect to database shell
docker compose exec app-dev python manage.py dbshell
@$(COMPOSE_EXEC_APP) python manage.py dbshell
.PHONY: dbshell
resetdb: FLUSH_ARGS ?=
@@ -322,12 +337,38 @@ 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:
+38
View File
@@ -16,6 +16,44 @@ 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`.
## v1.30.0
### Removing S3 storage-event webhooks for recordings
+2 -2
View File
@@ -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('minio-bucket', resource_deps=['minio'])
k8s_resource('meet-backend', resource_deps=['postgresql', 'minio', 'redis', 'livekit-livekit-server'])
k8s_resource('garage-cors', resource_deps=['garage'])
k8s_resource('meet-backend', resource_deps=['postgresql', 'garage-cors', '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'])
+3 -7
View File
@@ -5,7 +5,7 @@ set -eo pipefail
REPO_DIR="$(cd "$( dirname "${BASH_SOURCE[0]}" )/.." && pwd)"
UNSET_USER=0
COMPOSE_FILE="${REPO_DIR}/compose.yml"
export COMPOSE_FILE="${COMPOSE_FILE:-${REPO_DIR}/compose.yml}"
COMPOSE_PROJECT="meet"
@@ -25,11 +25,8 @@ function _set_user() {
return
fi
# USER_ID = USER_ID or the engine-appropriate default if USER_ID is not set.
case "${DOCKER_HOST:-}" in
*podman*) USER_ID=${USER_ID:-0} ;;
*) USER_ID=${USER_ID:-$(id -u)} ;;
esac
# USER_ID = USER_ID or `id -u` if USER_ID is not set
USER_ID=${USER_ID:-$(id -u)}
echo "🙋(user) ID: ${USER_ID}"
}
@@ -45,7 +42,6 @@ function _docker_compose() {
echo "🐳(compose) project: '${COMPOSE_PROJECT}' file: '${COMPOSE_FILE}'"
docker compose \
-p "${COMPOSE_PROJECT}" \
-f "${COMPOSE_FILE}" \
--project-directory "${REPO_DIR}" \
"$@"
}
-6
View File
@@ -1,6 +0,0 @@
#!/usr/bin/env bash
# shellcheck source=bin/_config.sh
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
_docker_compose "$@"
+31 -19
View File
@@ -15,32 +15,44 @@ services:
ports:
- "1081:1080"
minio:
garage:
user: ${DOCKER_USER:-1000}
image: quay.io/minio/minio
image: dxflrs/garage:v2.4.1
command: /garage server --single-node --default-bucket
env_file:
- env.d/development/garage
environment:
- MINIO_ROOT_USER=meet
- MINIO_ROOT_PASSWORD=password
- GARAGE_DEFAULT_ACCESS_KEY=meet-access-key
- GARAGE_DEFAULT_SECRET_KEY=meet-secret-access-key
- GARAGE_DEFAULT_BUCKET=meet-media-storage
ports:
- '9000:9000'
- '9001:9001'
- '127.0.0.1:9000:9000'
healthcheck:
test: [ "CMD", "mc", "ready", "local" ]
test: [ "CMD", "/garage", "health" ]
interval: 1s
timeout: 20s
retries: 300
entrypoint: ""
command: minio server --console-address :9001 /data
volumes:
- ./data/media:/data
- ./docker/files/etc/garage/garage.toml:/etc/garage.toml:ro
- ./data/media:/var/lib/garage
createbuckets:
image: quay.io/minio/mc
# 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
depends_on:
minio:
garage:
condition: service_healthy
restart: true
entrypoint: ["/bin/sh", "-c", "mc alias set meet http://minio:9000 meet password && mc mb --ignore-existing meet/meet-media-storage"]
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"]}]}'
app-dev:
build:
@@ -66,7 +78,7 @@ services:
- postgresql
- mailcatcher
- redis
- createbuckets
- garage-cors
extra_hosts:
- "127.0.0.1.nip.io:host-gateway"
networks:
@@ -106,7 +118,7 @@ services:
- postgresql
- redis
- livekit
- minio
- garage
celery:
user: ${DOCKER_USER:-1000}
@@ -240,7 +252,7 @@ services:
- /app/.venv
depends_on:
- livekit
- minio
- garage
develop:
watch:
- action: rebuild
@@ -293,7 +305,7 @@ services:
depends_on:
- redis-summary
- app-summary-dev
- minio
- garage
develop:
watch:
- action: rebuild
@@ -313,7 +325,7 @@ services:
depends_on:
- redis-summary
- app-summary-dev
- minio
- garage
develop:
watch:
- action: rebuild
+47
View File
@@ -0,0 +1,47 @@
{
"nodes": {
"devenv": {
"locked": {
"dir": "src/modules",
"lastModified": 1778705847,
"narHash": "sha256-EQnZCy7r4VMO6KDoytxHBa0mFbM1D9g1kaDfs/s0YZA=",
"ref": "refs/tags/v2.1.2",
"rev": "ea3d94ac9d6bf6a1313773170122ca4e2ef5a0be",
"revCount": 6569,
"type": "git",
"url": "https://github.com/cachix/devenv"
},
"original": {
"dir": "src/modules",
"ref": "refs/tags/v2.1.2",
"type": "git",
"url": "https://github.com/cachix/devenv"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1789542786,
"narHash": "sha256-ajQuqcxnj6RYnwCjA/9FTCnrdR2+BDzstE8UembXf60=",
"ref": "nixos-26.05",
"rev": "4c7870105e7f1fdf9c48688c8d7efc21abf0688a",
"shallow": true,
"type": "git",
"url": "https://github.com/NixOS/nixpkgs"
},
"original": {
"ref": "nixos-26.05",
"shallow": true,
"type": "git",
"url": "https://github.com/NixOS/nixpkgs"
}
},
"root": {
"inputs": {
"devenv": "devenv",
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}
+265
View File
@@ -0,0 +1,265 @@
# =============================================================================
# devenv.nix — La Suite Meet ("Visio") developer environment
# =============================================================================
{
pkgs,
lib,
config,
...
}:
let
python = pkgs.python313;
nodejs = pkgs.nodejs_22;
backendDir = "src/backend";
agentsDir = "src/agents";
summaryDir = "src/summary";
frontendDir = "src/frontend";
readDotEnv =
file:
let
lines = lib.splitString "\n" (builtins.readFile file);
unquote =
v:
let
len = builtins.stringLength v;
in
if len >= 2 && lib.hasPrefix "\"" v && lib.hasSuffix "\"" v then
builtins.substring 1 (len - 2) v
else if len >= 2 && lib.hasPrefix "'" v && lib.hasSuffix "'" v then
builtins.substring 1 (len - 2) v
else
v;
parseLine =
line:
let
m = builtins.match "[ \t]*([A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(.*)" line;
in
if m == null then null else { name = builtins.elemAt m 0; value = unquote (builtins.elemAt m 1); };
in
builtins.listToAttrs (builtins.filter (x: x != null) (map parseLine lines));
# Reuse existing .env
dotEnv =
(readDotEnv ./env.d/development/common.dist)
// (readDotEnv ./env.d/development/postgresql.dist);
sharedEnv = builtins.removeAttrs dotEnv [ "PYTHONPATH" ]; # only makes sense inside the backend container.
in
{
options.meet = {
agents.enable = lib.mkEnableOption "tooling for the LiveKit agents in src/agents";
summary.enable = lib.mkEnableOption "tooling for the summary service in src/summary";
k8s.enable = lib.mkEnableOption "Kubernetes dev utilities";
};
config = {
# Profile can be activated with devenv --profile <profile> shell
profiles = {
agents.module = {
meet.agents.enable = true;
};
summary.module = {
meet.summary.enable = true;
};
k8s.module = {
meet.k8s.enable = true;
};
};
languages.python = {
enable = true;
package = python;
directory = backendDir;
manylinux.enable = pkgs.stdenv.hostPlatform.isLinux;
libraries = [
"${config.devenv.dotfile}/profile"
pkgs.file
pkgs.zlib
pkgs.libffi
pkgs.openssl
];
uv.enable = true;
uv.sync.enable = false;
venv.enable = false;
lsp.enable = true;
};
languages.javascript = {
enable = true;
package = nodejs;
directory = frontendDir;
npm.enable = true;
yarn.enable = true;
corepack.enable = false;
};
languages.typescript.enable = false;
languages.nix.enable = true;
packages =
with pkgs;
[
gnumake
file
shared-mime-info
gettext
postgresql_16
git
curl
jq
podman
podman-compose
docker-client
]
# -- LiveKit agents
++ lib.optionals config.meet.agents.enable [
glib
portaudio
livekit-cli
]
# -- summary service
++ lib.optionals config.meet.summary.enable [
redis
]
# -- Kubernetes tools
++ lib.optionals config.meet.k8s.enable [
kubectl
kubernetes-helm
helmfile
tilt
kind
mkcert
];
env = sharedEnv // {
UV_LINK_MODE = "copy";
PYTHONDONTWRITEBYTECODE = "1";
PYTHONUNBUFFERED = "1";
UV_PROJECT_ENVIRONMENT = lib.mkForce ".venv";
COMPOSE_PROJECT_NAME = "meet";
DJANGO_DATA_DIR = "${config.devenv.root}/data";
# Database / Pgsql
DB_HOST = "127.0.0.1";
DB_PORT = "15432";
PGHOST = "127.0.0.1";
PGPORT = "15432";
PGDATABASE = sharedEnv.DB_NAME;
PGUSER = sharedEnv.DB_USER;
PGPASSWORD = sharedEnv.DB_PASSWORD;
REDIS_URL = "redis://127.0.0.1:6379/1";
CELERY_BROKER_URL = "redis://127.0.0.1:6379/0";
# S3 / Garage
AWS_S3_ENDPOINT_URL = "http://127.0.0.1:9000";
# OIDC
OIDC_OP_JWKS_ENDPOINT = "http://localhost:8083/realms/meet/protocol/openid-connect/certs";
OIDC_OP_TOKEN_ENDPOINT = "http://localhost:8083/realms/meet/protocol/openid-connect/token";
OIDC_OP_USER_ENDPOINT = "http://localhost:8083/realms/meet/protocol/openid-connect/userinfo";
OIDC_OP_INTROSPECTION_ENDPOINT = "http://localhost:8083/realms/meet/protocol/openid-connect/token/introspect";
# summary service
SUMMARY_SERVICE_ENDPOINT = "http://127.0.0.1:8001/api/v2/async-jobs/transcribe/";
SUMMARY_SERVICE_VERSION = "2";
# Mail
DJANGO_EMAIL_HOST = "127.0.0.1";
};
scripts = {
meet-venv = {
description = "Create/refresh meet uv virtualenvs for backend, agents and summary";
exec = ''
set -euo pipefail
cd "$DEVENV_ROOT"
echo "==> ${backendDir} (uv sync --locked, dependency-groups)"
( cd "${backendDir}" && uv sync --locked --all-groups )
echo "==> ${agentsDir} (uv sync --locked --all-extras)"
( cd "${agentsDir}" && uv sync --locked --all-extras )
echo "==> ${summaryDir} (uv sync --locked --all-extras)"
( cd "${summaryDir}" && uv sync --locked --all-extras )
echo
echo "Synced the following virtualenvs successfully:"
echo " ${backendDir}/.venv"
echo " ${agentsDir}/.venv"
echo " ${summaryDir}/.venv"
'';
};
};
enterShell = ''
# Make podman socket accessible in order to launch regular docker commands.
# Set MEET_PODMAN_SOCKET=0 to keep the DOCKER_HOST of the calling environment.
case "''${MEET_PODMAN_SOCKET:-1}" in
0|false|no|off) ;;
*)
_rundir="''${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
export DOCKER_HOST="unix://$_rundir/podman/podman.sock"
unset _rundir
;;
esac
# Compose files to merge
_compose_dir="${config.devenv.root}/docker/compose.d"
_compose_files="${config.devenv.root}/compose.yml"
export DOCKER_USER="$(id -u):$(id -g)"
case "''${DOCKER_HOST:-}" in
*podman*)
_compose_files="$_compose_files:$_compose_dir/compose.podman.yml"
# Build images with Podman/Buildah rather than BuildKit. `docker
# compose build` otherwise has buildx boot a moby/buildkit container,
# and that container lands in its own network namespace with neither
# the proxy in its environment nor any route to it.
# Buildah has neither problem: base images are resolved by the Podman systemd
# service, which inherits the proxy from its systemd socket activated unit, and
# RUN steps execute in the *host* network namespace
export DOCKER_BUILDKIT=0
export COMPOSE_BAKE=false
;;
esac
# Apply Bureautix override
if [ -n "''${http_proxy:-}" ]; then
_compose_files="$_compose_files:$_compose_dir/compose.bureautix.yml"
fi
export COMPOSE_FILE="$_compose_files"
unset _compose_dir _compose_files
# Make binaries accessible
for _d in \
"$DEVENV_ROOT/${backendDir}/.venv/bin" \
"$DEVENV_ROOT/${agentsDir}/.venv/bin" \
"$DEVENV_ROOT/${summaryDir}/.venv/bin" \
"$DEVENV_ROOT/${frontendDir}/node_modules/.bin"
do
[ -d "$_d" ] && export PATH="$_d:$PATH"
done
unset _d
'';
};
}
+5
View File
@@ -0,0 +1,5 @@
inputs:
nixpkgs:
url: git+https://github.com/NixOS/nixpkgs?ref=nixos-26.05&shallow=1
devenv:
url: git+https://github.com/cachix/devenv?ref=refs/tags/v2.1.2&dir=src/modules
+48
View File
@@ -0,0 +1,48 @@
# Bureautix proxy overrides
#
# Builds submitted through the Docker API of the Podman service get none of
# the proxy variables in their RUN steps. We need to pass the http_proxy explicitely
# otherwise all connections fail during the build.
x-proxy-vars: &proxy-vars
http_proxy: ${http_proxy:-}
https_proxy: ${https_proxy:-}
no_proxy: ${no_proxy:-}
services:
app:
build:
args:
<<: *proxy-vars
app-dev:
build:
args:
<<: *proxy-vars
frontend:
build:
args:
<<: *proxy-vars
metadata-collector-dev:
build:
args:
<<: *proxy-vars
multi-user-transcriber-dev:
build:
args:
<<: *proxy-vars
app-summary-dev:
build:
args:
<<: *proxy-vars
celery-summary-transcribe:
build:
args:
<<: *proxy-vars
celery-summary-summarize:
build:
args:
<<: *proxy-vars
# The local proxy listens on 8080 and collides with Keycloak's published admin port.
keycloak:
ports: !override
- "8081:8080"
+35
View File
@@ -0,0 +1,35 @@
# Rootless Podman override for compose.yml.
#
# Rootless Podman maps container UID 0 to the host user and every other
# container UID to a subuid that owns nothing in the worktree. Meet compose.ymlruns
# its containers as DOCKER_USER=$(id -u):$(id -g), which would land on such a
# subuid and make every bind mount effectively read-only.
#
# `userns_mode: keep-id` maps the host user to the same UID and GID inside the
# container instead, so DOCKER_USER keeps its Docker value and files written
# through a bind mount are owned by the host user on both sides.
#
# Only the services that mount the worktree and run as DOCKER_USER are listed.
x-keep-id: &keep-id
userns_mode: keep-id
services:
app-dev:
<<: *keep-id
celery-dev:
<<: *keep-id
garage:
<<: *keep-id
garage-cors:
<<: *keep-id
node:
<<: *keep-id
crowdin:
<<: *keep-id
metadata-collector-dev:
<<: *keep-id
multi-user-transcriber-dev:
<<: *keep-id
app-summary-dev:
<<: *keep-id
+2 -1
View File
@@ -57,7 +57,8 @@ RUN npx webpack --mode production
FROM nginxinc/nginx-unprivileged:1.30.4-alpine3.24 AS frontend-production
USER root
RUN apk del curl
RUN apk upgrade --no-cache libexpat && \
apk del curl
USER nginx
USER nginx
+15
View File
@@ -0,0 +1,15 @@
# 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"
+3 -3
View File
@@ -17,9 +17,9 @@ server {
proxy_set_header X-Amz-Date $authDate;
proxy_set_header X-Amz-Content-SHA256 $authContentSha256;
# Get resource from Minio
proxy_pass http://minio:9000/meet-media-storage/;
proxy_set_header Host minio:9000;
# Get resource from Garage
proxy_pass http://garage:9000/meet-media-storage/;
proxy_set_header Host garage:9000;
# To use with ds_proxy
# proxy_pass http://ds-proxy:4444/upstream/meet-media-storage/;
# proxy_set_header Host ds-proxy:4444;
@@ -21,3 +21,10 @@ turn:
- 192.168.0.0/16
- 172.16.0.0/12
rtc:
node_ip: 127.0.0.1
advertise_internal_ip: true
udp_port: 7882
tcp_port: 7881
use_external_ip: false
+1 -1
View File
@@ -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
- MinIO for storing files (room recordings)
- Garage 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:
+40
View File
@@ -0,0 +1,40 @@
# 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.
+8 -7
View File
@@ -42,7 +42,7 @@ sequenceDiagram
participant Backend as Backend API
participant Summary as Summary Service
participant Celery as Celery Workers (transcribe-queue)
participant MinIO as MinIO (Object Storage)
participant S3 as S3 (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->>MinIO: Fetch audio file
Celery->>S3: Fetch audio file
Celery->>STT: Transcribe audio (WhisperX)
STT-->>Celery: Segmented transcript
@@ -72,11 +72,12 @@ 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/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. |
| 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. |
| 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. |
+1 -1
View File
@@ -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 (MinIO, email). See `/features` folder for details.
> [!NOTE] Some advanced features, as Recording and transcription, require additional services (S3-compatible object storage, email). See `/features` folder for details.
## Software Requirements
+1
View File
@@ -403,6 +403,7 @@ 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"} |
+6 -4
View File
@@ -24,9 +24,10 @@ 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://minio:9000
AWS_S3_ACCESS_KEY_ID=meet
AWS_S3_SECRET_ACCESS_KEY=password
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
MEDIA_BASE_URL=http://localhost:3000
FILE_UPLOAD_ENABLED=True
@@ -63,7 +64,8 @@ ALLOW_UNREGISTERED_ROOMS=False
# Recording
RECORDING_ENABLE=True
SUMMARY_SERVICE_ENDPOINT=http://app-summary-dev:8000/api/v2/async-jobs/transcribe/
SUMMARY_SERVICE_VERSION=2
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
+2
View File
@@ -0,0 +1,2 @@
# Filled with a random value by `make create-env-files`
GARAGE_RPC_SECRET=
+4 -3
View File
@@ -2,8 +2,9 @@ LIVEKIT_URL=ws://livekit:7880
LIVEKIT_API_KEY=devkey
LIVEKIT_API_SECRET=secret
AWS_S3_ENDPOINT_URL=minio:9000
AWS_S3_ACCESS_KEY_ID=meet
AWS_S3_SECRET_ACCESS_KEY=password
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_STORAGE_BUCKET_NAME=meet-media-storage
AWS_S3_SECURE_ACCESS=False
@@ -1,6 +1,7 @@
AWS_S3_ENDPOINT_URL=minio:9000
AWS_S3_ACCESS_KEY_ID=meet
AWS_S3_SECRET_ACCESS_KEY=password
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
LIVEKIT_URL=ws://livekit:7880
LIVEKIT_API_KEY=devkey
+4 -3
View File
@@ -2,11 +2,12 @@ APP_NAME="meet-app-summary-dev"
APP_API_TOKEN="password"
AWS_STORAGE_BUCKET_NAME="meet-media-storage"
AWS_S3_ENDPOINT_URL="minio:9000"
AWS_S3_ENDPOINT_URL="garage:9000"
AWS_S3_SECURE_ACCESS=false
AWS_S3_ACCESS_KEY_ID="meet"
AWS_S3_SECRET_ACCESS_KEY="password"
AWS_S3_ACCESS_KEY_ID="meet-access-key"
AWS_S3_SECRET_ACCESS_KEY="meet-secret-access-key"
AWS_S3_REGION_NAME="local"
WHISPERX_BASE_URL="https://configure-your-url.com"
WHISPERX_ASR_MODEL="large-v2"
+4 -4
View File
@@ -10,7 +10,7 @@
"license": "MIT",
"dependencies": {
"core-js": "3.50.0",
"i18next": "26.4.0",
"i18next": "26.4.2",
"i18next-browser-languagedetector": "8.2.1",
"regenerator-runtime": "0.14.1"
},
@@ -9367,9 +9367,9 @@
}
},
"node_modules/i18next": {
"version": "26.4.0",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.0.tgz",
"integrity": "sha512-rsmK5bFqsD1AetSFSIa43wtNR4WpvvH4p0tLEsTxkC7QTrfdFm06nbQ95bh8Og4wwaCnUEcm9DVYL2cgxitiQg==",
"version": "26.4.2",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.2.tgz",
"integrity": "sha512-RX+R0VLg13IbvRuJSxnqykUFS9vQZTl8wYpWPCIUDWVrSGjsQywB5Y+pjzrkboxGAuYfJZVH1InFTdgBdxq6ug==",
"funding": [
{
"type": "individual",
+1 -1
View File
@@ -27,7 +27,7 @@
},
"dependencies": {
"core-js": "3.50.0",
"i18next": "26.4.0",
"i18next": "26.4.2",
"i18next-browser-languagedetector": "8.2.1",
"regenerator-runtime": "0.14.1"
},
+4 -2
View File
@@ -1,9 +1,11 @@
FROM python:3.14.6-slim AS base
# Install system dependencies required by LiveKit
RUN apt-get update && apt-get install -y \
# Install system dependencies required by LiveKit, fetching packages over HTTPS only for Bureautix proxy
RUN sed -i "s|^URIs: http://|URIs: https://|" /etc/apt/sources.list.d/debian.sources \
&& apt-get update && apt-get install -y --no-install-recommends \
libglib2.0-0 \
libgobject-2.0-0 \
libssl3t64 \
&& rm -rf /var/lib/apt/lists/*
+35 -18
View File
@@ -6,9 +6,11 @@ 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 (
@@ -28,8 +30,6 @@ 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,6 +59,30 @@ 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."""
@@ -121,18 +145,13 @@ 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()
@@ -201,20 +220,18 @@ class MetadataCollector:
}
data = json.dumps(payload, indent=2).encode("utf-8")
stream = BytesIO(data)
try:
self.minio_client.put_object(
self.bucket_name,
self.output_filename,
stream,
length=len(data),
content_type="application/json",
self.s3_client.put_object(
Bucket=self.bucket_name,
Key=self.output_filename,
Body=data,
ContentType="application/json",
)
logger.info(
"Uploaded speaker meeting metadata",
)
except S3Error:
except (BotoCoreError, ClientError):
logger.exception(
"Failed to upload meeting metadata",
)
+9 -9
View File
@@ -1,24 +1,24 @@
[project]
name = "agents"
version = "1.31.0"
version = "1.32.1"
requires-python = ">=3.12"
dependencies = [
"livekit-agents==1.6.7",
"livekit-plugins-deepgram==1.6.7",
"livekit-plugins-silero==1.6.7",
"livekit-agents==1.7.0",
"livekit-plugins-deepgram==1.7.0",
"livekit-plugins-silero==1.7.0",
"livekit-plugins-kyutai-lasuite==0.0.6",
"python-dotenv==1.2.2",
"protobuf==6.33.6",
"minio==7.2.20",
"sentry-sdk==2.66.1",
"boto3==1.43.56",
"python-dotenv==1.2.3",
"protobuf==7.36.0",
"sentry-sdk==2.68.1",
"websockets==17.1",
"httpx==0.28.1",
]
[project.optional-dependencies]
dev = [
"ruff==0.16.0",
"ruff==0.16.4",
]
[tool.uv]
+859 -854
View File
File diff suppressed because it is too large Load Diff
+17 -3
View File
@@ -279,9 +279,23 @@ class RoomAdmin(admin.ModelAdmin):
inlines = (ResourceAccessInline,)
search_fields = ["name", "slug", "=id"]
list_display = ["name", "slug", "access_level", "get_owner", "created_at"]
list_filter = ["access_level", "created_at"]
readonly_fields = ["id", "created_at", "updated_at"]
list_display = [
"name",
"slug",
"access_level",
"get_owner",
"created_at",
"deleted_at",
]
list_filter = ["access_level", "created_at", "deleted_at", "last_started_at"]
readonly_fields = [
"id",
"created_at",
"updated_at",
"deleted_at",
"last_started_at",
]
actions = []
def get_queryset(self, request):
"""Optimize queries by prefetching related access and user data to avoid N+1 queries."""
+1
View File
@@ -9,6 +9,7 @@ class AnalyticsEvent(StrEnum):
# Rooms
ROOM_CREATED = "room_created"
ROOM_UPDATED = "room_updated"
ROOM_DELETED = "room_deleted"
# Roomkit (meeting-room SIP devices)
ROOMKIT_JOINED = "roomkit_joined"
+31
View File
@@ -0,0 +1,31 @@
"""Exceptions and guards shared by the API endpoints."""
from django.utils.translation import gettext_lazy as _
from rest_framework import exceptions, status
from core import models
class RoomGone(exceptions.APIException):
"""Raised when the requested room has been soft deleted."""
status_code = status.HTTP_410_GONE
default_detail = _("This room has been deleted.")
default_code = "room_deleted"
def ensure_room_not_deleted(resource):
"""Raise a 410 Gone if the resource is a soft-deleted room.
Accepts a room or its parent resource, as referenced by accesses. Call it
after permissions are checked, so a deleted room is only revealed to users
who would have been granted access to it.
"""
if isinstance(resource, models.Room):
room = resource
else:
room = getattr(resource, "room", None)
if room is not None and room.is_deleted:
raise RoomGone()
-13
View File
@@ -12,10 +12,6 @@ from ..services.participants_management import (
ParticipantsManagementException,
)
ACTION_FOR_METHOD_TO_PERMISSION = {
"versions_detail": {"DELETE": "versions_destroy", "GET": "versions_retrieve"}
}
class IsAuthenticated(permissions.BasePermission):
"""
@@ -27,15 +23,6 @@ class IsAuthenticated(permissions.BasePermission):
return bool(request.auth) or request.user.is_authenticated
class IsAuthenticatedOrSafe(IsAuthenticated):
"""Allows access to authenticated users (or anonymous users but only on safe methods)."""
def has_permission(self, request, view):
if request.method in permissions.SAFE_METHODS:
return True
return super().has_permission(request, view)
class IsSelf(IsAuthenticated):
"""
Allows access only to authenticated users. Alternative method checking the presence
+2
View File
@@ -20,6 +20,7 @@ from rest_framework.exceptions import PermissionDenied
from timezone_field.rest_framework import TimeZoneSerializerField
from core import models, utils
from core.api.exceptions import ensure_room_not_deleted
logger = logging.getLogger(__name__)
@@ -108,6 +109,7 @@ class ResourceAccessSerializerMixin:
_("You must be administrator or owner of a room to add accesses to it.")
)
ensure_room_not_deleted(resource)
return resource
+23 -58
View File
@@ -42,6 +42,7 @@ from rest_framework.settings import api_settings
from core import analytics, enums, models, utils
from core.api import throttling
from core.api.exceptions import ensure_room_not_deleted
from core.api.filters import ListFileFilter
from core.enums import MEDIA_STORAGE_URL_PATTERN
from core.recording.enums import FileExtension
@@ -75,7 +76,7 @@ from core.services.participants_management import (
ParticipantsManagementException,
)
from core.services.room_creation import RoomCreation
from core.services.room_management import RoomManagement
from core.services.room_management import RoomManagement, RoomManagementException
from core.services.room_roles import (
RoomRoleError,
RoomRoleService,
@@ -95,60 +96,6 @@ from .feature_flag import FeatureFlag
logger = getLogger(__name__)
class NestedGenericViewSet(viewsets.GenericViewSet):
"""
A generic Viewset aims to be used in a nested route context.
e.g: `/api/v1.0/resource_1/<resource_1_pk>/resource_2/<resource_2_pk>/`
It allows to define all url kwargs and lookup fields to perform the lookup.
"""
lookup_fields: list[str] = ["pk"]
lookup_url_kwargs: list[str] = []
def __getattribute__(self, file):
"""
This method is overridden to allow to get the last lookup field or lookup url kwarg
when accessing the `lookup_field` or `lookup_url_kwarg` attribute. This is useful
to keep compatibility with all methods used by the parent class `GenericViewSet`.
"""
if file in ["lookup_field", "lookup_url_kwarg"]:
return getattr(self, file + "s", [None])[-1]
return super().__getattribute__(file)
def get_queryset(self):
"""
Get the list of files for this view.
`lookup_fields` attribute is enumerated here to perform the nested lookup.
"""
queryset = super().get_queryset()
# The last lookup field is removed to perform the nested lookup as it corresponds
# to the object pk, it is used within get_object method.
lookup_url_kwargs = (
self.lookup_url_kwargs[:-1]
if self.lookup_url_kwargs
else self.lookup_fields[:-1]
)
filter_kwargs = {}
for index, lookup_url_kwarg in enumerate(lookup_url_kwargs):
if lookup_url_kwarg not in self.kwargs:
raise KeyError(
f"Expected view {self.__class__.__name__} to be called with a URL "
f'keyword argument named "{lookup_url_kwarg}". Fix your URL conf, or '
"set the `.lookup_fields` attribute on the view correctly."
)
filter_kwargs.update(
{self.lookup_fields[index]: self.kwargs[lookup_url_kwarg]}
)
return queryset.filter(**filter_kwargs)
class SerializerPerActionMixin:
"""
A mixin to allow to define serializer classes for each action.
@@ -242,10 +189,9 @@ class RoomViewSet(
filter_kwargs = {"pk": self.kwargs["pk"]}
except ValueError:
filter_kwargs = {"slug": slugify(self.kwargs["pk"])}
queryset = self.filter_queryset(self.get_queryset())
obj = get_object_or_404(queryset, **filter_kwargs)
# May raise a permission denied
obj = get_object_or_404(models.Room.all_objects, **filter_kwargs)
self.check_object_permissions(self.request, obj)
ensure_room_not_deleted(obj)
return obj
def retrieve(self, request, *args, **kwargs):
@@ -297,6 +243,18 @@ class RoomViewSet(
serializer = self.get_serializer(queryset, many=True)
return drf_response.Response(serializer.data)
def perform_destroy(self, instance):
"""Soft delete the room and close its LiveKit room.
The room and its recordings are kept in database for traceability.
"""
try:
RoomManagement.soft_delete(instance)
except RoomManagementException as e:
raise drf_exceptions.APIException(
"Could not delete the room, please try again."
) from e
def perform_create(self, serializer):
"""Set the current user as owner of the newly created room.
@@ -981,6 +939,13 @@ class ResourceAccessViewSet(
return queryset
def get_object(self):
"""Accesses to a soft-deleted room can be read but no longer modified."""
access = super().get_object()
if self.request.method not in drf_permissions.SAFE_METHODS:
ensure_room_not_deleted(access.resource)
return access
class RecordingViewSet(
mixins.DestroyModelMixin,
+30 -2
View File
@@ -22,11 +22,13 @@ from rest_framework import (
from rest_framework import (
status as drf_status,
)
from rest_framework.generics import get_object_or_404
from core import analytics, api, models
from core.api.exceptions import ensure_room_not_deleted
from core.api.feature_flag import FeatureFlag
from core.services.jwt_token import JwtTokenService
from core.services.room_management import RoomManagement
from core.services.room_management import RoomManagement, RoomManagementException
from ..services.provisional_user_service import (
ProvisionalUserCreationDisabledError,
@@ -142,6 +144,7 @@ class ApplicationViewSet(viewsets.ViewSet):
class RoomViewSet(
mixins.CreateModelMixin,
mixins.DestroyModelMixin,
mixins.RetrieveModelMixin,
mixins.ListModelMixin,
mixins.UpdateModelMixin,
@@ -159,9 +162,13 @@ class RoomViewSet(
- create: Create a new room owned by the user (requires 'rooms:create' scope)
- partial_update: Update a room's access level and configuration, for
administrators and owners only (requires 'rooms:update' scope)
- destroy: Soft delete a room and close its LiveKit room, for owners only
(requires 'rooms:delete' scope)
Detail operations on a soft-deleted room answer 410 Gone.
"""
http_method_names = ["get", "post", "patch", "head", "options"]
http_method_names = ["get", "post", "patch", "delete", "head", "options"]
authentication_classes = [
authentication.ApplicationJWTAuthentication,
@@ -176,6 +183,17 @@ class RoomViewSet(
queryset = models.Room.objects.all()
serializer_class = serializers.RoomSerializer
def get_object(self):
"""Get the room, answer 410 if it has been deleted.
Permissions are checked first so a deleted room is only revealed to
users who would have been granted access to it.
"""
room = get_object_or_404(models.Room.all_objects, pk=self.kwargs["pk"])
self.check_object_permissions(self.request, room)
ensure_room_not_deleted(room)
return room
def list(self, request, *args, **kwargs):
"""Limit listed rooms to the ones related to the authenticated user."""
@@ -239,6 +257,16 @@ class RoomViewSet(
self._track_room_event(room, analytics.AnalyticsEvent.ROOM_CREATED)
def perform_destroy(self, instance):
"""Soft delete the room, close its LiveKit room, then log and track it."""
try:
RoomManagement.soft_delete(instance)
except RoomManagementException as e:
raise drf_exceptions.APIException(
"Could not delete the room, please try again."
) from e
self._track_room_event(instance, analytics.AnalyticsEvent.ROOM_DELETED)
def perform_update(self, serializer: serializers.RoomSerializer):
"""Persist the room update, sync it to LiveKit, then log and track it."""
@@ -0,0 +1,94 @@
"""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,18 @@
# Generated by Django 5.2.16 on 2026-09-23 16:49
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0022_user_default_room_access_level_and_more'),
]
operations = [
migrations.AlterField(
model_name='recording',
name='status',
field=models.CharField(choices=[('initiated', 'Initiated'), ('active', 'Active'), ('stopped', 'Stopped'), ('saved', 'Saved'), ('aborted', 'Aborted'), ('failed', 'Failed'), ('failed_to_start', 'Failed to Start'), ('failed_to_stop', 'Failed to Stop'), ('notification_succeeded', 'Notification succeeded'), ('external_process_successful', 'External process successful'), ('external_process_failed', 'External process failed')], default='initiated', max_length=50),
),
]
@@ -0,0 +1,18 @@
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,
),
]
@@ -0,0 +1,29 @@
# Generated by Django 5.2.14 on 2026-09-16 10:00
import django.db.models.manager
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0024_room_last_started_at'),
]
operations = [
migrations.AddField(
model_name='room',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AlterModelOptions(
name='room',
options={'default_manager_name': 'all_objects', 'ordering': ('name',), 'verbose_name': 'Room', 'verbose_name_plural': 'Rooms'},
),
migrations.AlterModelManagers(
name='room',
managers=[
('all_objects', django.db.models.manager.Manager()),
],
),
]
+71 -10
View File
@@ -58,6 +58,7 @@ class RecordingStatusChoices(models.TextChoices):
STOPPED = "stopped", _("Stopped")
SAVED = "saved", _("Saved")
ABORTED = "aborted", _("Aborted")
FAILED = "failed", _("Failed")
FAILED_TO_START = "failed_to_start", _("Failed to Start")
FAILED_TO_STOP = "failed_to_stop", _("Failed to Stop")
NOTIFICATION_SUCCEEDED = "notification_succeeded", _("Notification succeeded")
@@ -79,6 +80,7 @@ class RecordingStatusChoices(models.TextChoices):
cls.STOPPED,
cls.SAVED,
cls.ABORTED,
cls.FAILED,
cls.EXTERNAL_PROCESS_SUCCESSFUL,
cls.EXTERNAL_PROCESS_FAILED,
cls.FAILED_TO_START,
@@ -86,9 +88,15 @@ class RecordingStatusChoices(models.TextChoices):
}
@classmethod
def is_unsuccessful(cls, status):
"""Determine if the recording status represents an unsuccessful state."""
return status in {cls.ABORTED, cls.FAILED_TO_START, cls.FAILED_TO_STOP}
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):
@@ -398,6 +406,33 @@ class ResourceAccess(BaseModel):
return super().delete(*args, **kwargs)
class RoomQuerySet(models.QuerySet):
"""QuerySet exposing the room lifecycle filters."""
def active(self):
"""Rooms that have not been soft deleted."""
return self.filter(deleted_at__isnull=True)
def deleted(self):
"""Rooms that have been soft deleted."""
return self.filter(deleted_at__isnull=False)
class RoomManager(models.Manager.from_queryset(RoomQuerySet)):
"""Manager hiding soft-deleted rooms, exposed as ``Room.objects``.
It is deliberately not the model's default manager: Django relies on
``_default_manager`` for unique validation, which must see deleted rooms as
they keep holding their slug and pin code. Forward relations (e.g.
``recording.room``) go through the base manager and still resolve deleted
rooms, which keeps recordings and their notifications working.
"""
def get_queryset(self):
"""Exclude soft-deleted rooms."""
return super().get_queryset().active()
class Room(Resource):
"""Model for one room"""
@@ -421,6 +456,7 @@ class Room(Resource):
verbose_name=_("Visio room configuration"),
help_text=_("Values for Visio parameters to configure the room."),
)
deleted_at = models.DateTimeField(null=True, blank=True)
pin_code = models.CharField(
max_length=None,
unique=True,
@@ -429,9 +465,21 @@ 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,
)
# Managers
objects = RoomManager()
all_objects = models.Manager.from_queryset(RoomQuerySet)()
class Meta:
db_table = "meet_room"
default_manager_name = "all_objects"
ordering = ("name",)
verbose_name = _("Room")
verbose_name_plural = _("Rooms")
@@ -454,6 +502,23 @@ class Room(Resource):
)
super().save(*args, **kwargs)
@property
def is_deleted(self):
"""Whether the room has been soft deleted."""
return self.deleted_at is not None
def soft_delete(self):
"""Soft delete the room.
The room is hidden from the default manager making it impossible to
list, join or update.
"""
if self.deleted_at:
raise RuntimeError("This room is already deleted.")
self.deleted_at = timezone.now()
self.save(update_fields=["deleted_at"])
def clean_fields(self, exclude=None):
"""
Automatically generate the slug from the name and make sure it does not look like a UUID.
@@ -489,7 +554,7 @@ class Room(Resource):
for _ in range(settings.ROOM_TELEPHONY_PIN_MAX_RETRIES):
pin_code = str(secrets.randbelow(max_value)).zfill(length)
if not Room.objects.filter(pin_code=pin_code).exists():
if not Room.all_objects.filter(pin_code=pin_code).exists():
return pin_code
# Log a warning as a temporary measure until backend observability is implemented.
@@ -582,6 +647,7 @@ class Recording(BaseModel):
4. NOTIFICATION_SUCCEEDED: External service has been notified of this recording
Error States:
- FAILED: Egress failed mid-recording
- FAILED_TO_START: Worker failed to initialize recording
- FAILED_TO_STOP: Worker failed during stop operation
- ABORTED: Recording was terminated before completion
@@ -684,12 +750,7 @@ class Recording(BaseModel):
@property
def is_saved(self) -> bool:
"""Check if the recording is in a saved state."""
return self.status in {
RecordingStatusChoices.NOTIFICATION_SUCCEEDED,
RecordingStatusChoices.SAVED,
RecordingStatusChoices.EXTERNAL_PROCESS_SUCCESSFUL,
RecordingStatusChoices.EXTERNAL_PROCESS_FAILED,
}
return self.status in RecordingStatusChoices.saved_statuses()
@property
def extension(self):
+47
View File
@@ -8,3 +8,50 @@ class FileExtension(Enum):
OGG = "ogg"
MP4 = "mp4"
class RecordingWorkerEvent(Enum):
"""Lifecycle events a recording worker reports about a recording.
It is intended to be free of SFU-specific vocabulary.
"""
# The worker accepted the request but is not recording yet.
STARTING = "starting"
# The worker is recording.
STARTED = "started"
# The worker stopped recording and is flushing the media file.
SAVING = "saving"
# The recording ended, its media file is available.
COMPLETED = "completed"
# The recording ended on its configured limit, its media file is available.
LIMIT_REACHED = "limit reached"
# The worker stopped before it ever started recording, there is no media file.
ABORTED = "aborted"
# The worker hit a runtime error once recording had started; its media file
# may be available.
FAILED = "failed"
@classmethod
def is_terminal(cls, event):
"""Determine if the event ends the recording's lifecycle (successful or not)."""
return event in TERMINAL_EVENTS
SUCCESSFUL_EVENTS = frozenset(
{
RecordingWorkerEvent.COMPLETED,
RecordingWorkerEvent.LIMIT_REACHED,
}
)
UNSUCCESSFUL_EVENTS = frozenset(
{
RecordingWorkerEvent.ABORTED,
RecordingWorkerEvent.FAILED,
}
)
TERMINAL_EVENTS = SUCCESSFUL_EVENTS | UNSUCCESSFUL_EVENTS
@@ -1,13 +1,13 @@
"""Recording-related LiveKit Events Service"""
# pylint: disable=no-member
"""Recording-related Events Service"""
from logging import getLogger
from livekit import api
from core import models, utils
from core.models import Recording
from core.recording.enums import (
UNSUCCESSFUL_EVENTS,
RecordingWorkerEvent,
)
from core.recording.event.notification import notification_service
from core.services.room_management import (
RoomManagement,
@@ -26,22 +26,110 @@ class RecordingNotSavableError(Exception):
"""Recording cannot be saved because it is either in an error state or has already been saved"""
# Notification sent to the room's participants, per event and recording mode.
NOTIFICATION_PREFIXES = {
models.RecordingModeChoices.SCREEN_RECORDING: "screenRecording",
models.RecordingModeChoices.TRANSCRIPT: "transcription",
}
NOTIFICATION_SUFFIXES = {
RecordingWorkerEvent.LIMIT_REACHED: "LimitReached",
RecordingWorkerEvent.FAILED: "Failed",
RecordingWorkerEvent.ABORTED: "Aborted",
}
def get_notification_type(recording_mode, event):
"""Generate corresponding notification type string."""
try:
return f"{NOTIFICATION_PREFIXES[recording_mode]}{NOTIFICATION_SUFFIXES[event]}"
except KeyError:
return None
# Recording status in the room's metadata, per event.
ROOM_METADATA_RECORDING_STATUSES = {
RecordingWorkerEvent.STARTED: "started",
RecordingWorkerEvent.SAVING: "saving",
}
class RecordingEventsService:
"""Handles recording-related LiveKit webhook events."""
"""Handles recording-related worker events.
Two entry points: `handle_update` for the events a running recording
reports, and `handle_terminal_event` for the one ending it.
"""
@staticmethod
def handle_update(recording: Recording, egress_status):
"""Handle egress status updates and sync recording state to room metadata."""
def log_worker_error(recording, event, error=None, error_code=None):
"""Log FAILED at error level and expected ABORTED outcomes at info level."""
if event == RecordingWorkerEvent.FAILED:
log = logger.error
elif event == RecordingWorkerEvent.ABORTED:
log = logger.info
else:
return
log(
"Recording worker reported %s for recording %s (room=%s, mode=%s): %s (error_code=%s)",
event.value,
recording.id,
recording.room.id,
recording.mode,
error or "no error reported",
error_code or "no error_code reported",
)
@staticmethod
def _notify_participants(recording: Recording, event: RecordingWorkerEvent):
"""Notify the room's participants that a recording ended on the given event."""
recording_mode = recording.options.get("original_mode", None) or recording.mode
notification_type = get_notification_type(recording_mode, event)
if not notification_type:
logger.warning(
"Could not find notification type for: "
"room=%s, recording_id=%s, mode=%s, event=%s",
recording.room.id,
recording.id,
recording_mode,
event.value,
)
return
try:
utils.notify_participants(
room_name=str(recording.room.id),
notification_data={"type": notification_type},
)
except utils.NotificationError as e:
raise RecordingEventsError(
f"Failed to notify participants in room '{recording.room.id}' about "
f"recording {event.value} (recording_id={recording.id})"
) from e
@staticmethod
def _log_notification_failure(recording, event: RecordingWorkerEvent):
"""Log a participant notification error on an unsuccessful recording."""
logger.exception(
"Failed to notify participants that recording %s %s (room=%s)",
recording.id,
event.value,
recording.room.id,
)
@staticmethod
def handle_update(recording: Recording, event: RecordingWorkerEvent):
"""Handle non-terminal worker events and sync recording state to room metadata.
Terminal events are dispatched through `handle_terminal_event` instead.
"""
room_name = str(recording.room.id)
status_mapping = {
api.EgressStatus.EGRESS_ACTIVE: "started",
api.EgressStatus.EGRESS_ENDING: "saving",
api.EgressStatus.EGRESS_ABORTED: "aborted",
}
recording_status = status_mapping.get(egress_status)
recording_status = ROOM_METADATA_RECORDING_STATUSES.get(event)
if recording_status:
try:
RoomManagement.update_metadata(
@@ -55,42 +143,113 @@ class RecordingEventsService:
except RoomManagementException as e:
logger.exception("Failed to update room's metadata: %s", e)
@staticmethod
def handle_limit_reached(recording: Recording):
def handle_terminal_event(self, recording: Recording, event: RecordingWorkerEvent):
"""Run the appropriate handlers for a terminal event, given the recording's state."""
if not RecordingWorkerEvent.is_terminal(event):
logger.warning(
"Ignoring non-terminal event %s dispatched as a terminal event "
"for recording %s.",
event.value,
recording.id,
)
return
if event in UNSUCCESSFUL_EVENTS:
self._flag_unsuccessful_recording(recording, event)
else:
self._save_successful_recording(recording, event)
def _flag_unsuccessful_recording(
self, recording: Recording, event: RecordingWorkerEvent
):
"""Persist the outcome of a recording the worker announced as unsuccessful."""
# Aborted
if event == RecordingWorkerEvent.ABORTED:
if recording.status == models.RecordingStatusChoices.ACTIVE:
self._apply_outcome(recording, event, self._handle_aborted)
return
# Failed
if event == RecordingWorkerEvent.FAILED:
if recording.is_savable():
self._apply_outcome(recording, event, self._handle_failed)
return
logger.error(
"Unsuccessful event %s has no handler; recording %s keeps status '%s'.",
event.value,
recording.id,
recording.status,
)
def _save_successful_recording(
self, recording: Recording, event: RecordingWorkerEvent
):
"""Save a recording whose media file the worker made available."""
# Limit reached
if (
event == RecordingWorkerEvent.LIMIT_REACHED
and recording.status == models.RecordingStatusChoices.ACTIVE
):
self._apply_outcome(recording, event, self._handle_limit_reached)
try:
self._handle_successful(recording)
except RecordingNotSavableError:
logger.warning(
"Recording %s is not savable on a completed recording "
"(already saved or in an error state); ignoring.",
recording.id,
)
def _apply_outcome(
self, recording: Recording, event: RecordingWorkerEvent, handler
):
"""Keep notification failure non-fatal."""
try:
handler(recording)
except RecordingEventsError:
self._log_notification_failure(recording, event)
@classmethod
def _handle_limit_reached(cls, recording: Recording):
"""Stop recording and notify participants when limit is reached."""
recording.status = models.RecordingStatusChoices.STOPPED
recording.save()
notification_mapping = {
models.RecordingModeChoices.SCREEN_RECORDING: "screenRecordingLimitReached",
models.RecordingModeChoices.TRANSCRIPT: "transcriptionLimitReached",
}
cls._notify_participants(recording, RecordingWorkerEvent.LIMIT_REACHED)
notification_type = notification_mapping.get(recording.mode)
if not notification_type:
return
@classmethod
def _handle_failed(cls, recording: Recording):
"""Set recording status to failed, matching the worker event, and notify participants.
try:
utils.notify_participants(
room_name=str(recording.room.id),
notification_data={"type": notification_type},
)
except utils.NotificationError as e:
logger.exception(
"Failed to notify participants about recording limit reached: "
"room=%s, recording_id=%s, mode=%s",
recording.room.id,
recording.id,
recording.mode,
)
raise RecordingEventsError(
f"Failed to notify participants in room '{recording.room.id}' about "
f"recording limit reached (recording_id={recording.id})"
) from e
FAILED: used when an actual runtime/pipeline error occurs after the
recording has started
"""
recording.status = models.RecordingStatusChoices.FAILED
recording.save()
cls._notify_participants(recording, RecordingWorkerEvent.FAILED)
@classmethod
def _handle_aborted(cls, recording: Recording):
"""Set recording status to aborted, matching the worker event, and notify participants.
ABORTED: used when the worker stops before it ever became
active/recording
"""
recording.status = models.RecordingStatusChoices.ABORTED
recording.save()
cls._notify_participants(recording, RecordingWorkerEvent.ABORTED)
@staticmethod
def handle_complete(recording: Recording):
def _handle_successful(recording: Recording):
"""Notify external services and save recording."""
if not recording.is_savable():
+37 -5
View File
@@ -2,6 +2,8 @@
# pylint: disable=no-member
import logging
from asgiref.sync import async_to_sync
from livekit import api as livekit_api
@@ -10,6 +12,8 @@ from ..enums import FileExtension
from .exceptions import WorkerConnectionError, WorkerResponseError
from .factories import WorkerServiceConfig
logger = logging.getLogger(__name__)
class BaseEgressService:
"""Base egress defining common methods to manage and interact with LiveKit egress processes."""
@@ -49,6 +53,22 @@ class BaseEgressService:
finally:
await lkapi.aclose()
@staticmethod
def _log_egress_error(response, event: str):
"""Log the reason LiveKit reported an unsuccessful egress on stop.
Mirrors the logging done in the 'egress_ended' webhook. The
StopEgress response carries the same error fields.
"""
logger.error(
"Egress %s on stop (egress_id=%s, status=%s): %s (error_code=%s)",
event,
response.egress_id,
livekit_api.EgressStatus.Name(response.status),
response.error or "no error reported",
response.error_code or "no error_code reported",
)
def stop(self, worker_id: str) -> str:
"""Stop an ongoing egress worker.
The StopEgressRequest is shared among all types of egress,
@@ -66,14 +86,26 @@ class BaseEgressService:
"LiveKit response is missing the recording status."
)
# To avoid exposing EgressStatus values and coupling with LiveKit outside of this class,
# the response status is mapped to simpler "ABORTED", "STOPPED" or "FAILED_TO_STOP" strings.
if response.status == livekit_api.EgressStatus.EGRESS_ABORTED:
return "ABORTED"
if response.status == livekit_api.EgressStatus.EGRESS_ENDING:
return "STOPPED"
if response.status == livekit_api.EgressStatus.EGRESS_LIMIT_REACHED:
return "STOPPED"
# Cases below should be very infrequent as status changes should be
# received and processed by `handle_ended`, thus `stop` would not
# be called (unless failure and stop are very close in time).
# We therefore accept not to notify the user in this code branch.
# This could be fixed in a future refactoring.
if response.status == livekit_api.EgressStatus.EGRESS_ABORTED:
self._log_egress_error(response, "aborted")
return "ABORTED"
if response.status == livekit_api.EgressStatus.EGRESS_FAILED:
self._log_egress_error(response, "failed")
return "FAILED"
self._log_egress_error(response, "failed to stop")
return "FAILED_TO_STOP"
def start(self, room_name, recording_id):
+85 -34
View File
@@ -8,19 +8,17 @@ from enum import Enum
from logging import getLogger
from django.conf import settings
from django.utils import timezone
from livekit import api
from core import models
from core.recording.enums import RecordingWorkerEvent
from core.recording.services.metadata_collector import (
MetadataCollectorException,
MetadataCollectorService,
)
from core.recording.services.recording_events import (
RecordingEventsError,
RecordingEventsService,
RecordingNotSavableError,
)
from core.recording.services.recording_events import RecordingEventsService
from .lobby import LobbyService
from .presence import PresenceCache
@@ -84,6 +82,30 @@ class LiveKitWebhookEventType(Enum):
INGRESS_ENDED = "ingress_ended"
# LiveKit egress statuses mapped to recording worker event statuses
EGRESS_STATUS_TO_RECORDING_EVENT = {
api.EgressStatus.EGRESS_STARTING: RecordingWorkerEvent.STARTING,
api.EgressStatus.EGRESS_ACTIVE: RecordingWorkerEvent.STARTED,
api.EgressStatus.EGRESS_ENDING: RecordingWorkerEvent.SAVING,
api.EgressStatus.EGRESS_COMPLETE: RecordingWorkerEvent.COMPLETED,
api.EgressStatus.EGRESS_LIMIT_REACHED: RecordingWorkerEvent.LIMIT_REACHED,
api.EgressStatus.EGRESS_ABORTED: RecordingWorkerEvent.ABORTED,
api.EgressStatus.EGRESS_FAILED: RecordingWorkerEvent.FAILED,
}
def to_recording_event(egress_status):
"""Translate a LiveKit egress status into a recording worker event."""
event = EGRESS_STATUS_TO_RECORDING_EVENT.get(egress_status)
if event is None:
logger.warning(
"Unmapped LiveKit egress status '%s', ignoring the event.",
egress_status,
)
return event
class LiveKitEventsService:
"""Service for processing and handling LiveKit webhook events and notifications."""
@@ -173,12 +195,20 @@ class LiveKitEventsService:
f"Recording with worker ID {egress_id} does not exist"
) from err
egress_status = data.egress_info.status
self.recording_events.handle_update(recording, egress_status)
event = to_recording_event(data.egress_info.status)
if event is None:
return
self.recording_events.handle_update(recording, event)
def _handle_egress_ended(self, data):
"""Handle 'egress_ended' event."""
"""Handle 'egress_ended' event.
Egress ended is sent with one of these statuses:
EGRESS_COMPLETE, EGRESS_FAILED, EGRESS_ABORTED, EGRESS_LIMIT_REACHED
"""
# Fetch recording
try:
recording = models.Recording.objects.select_related("room").get(
worker_id=data.egress_info.egress_id
@@ -188,6 +218,17 @@ class LiveKitEventsService:
f"Recording with worker ID {data.egress_info.egress_id} does not exist"
) from err
event = to_recording_event(data.egress_info.status)
# Log if/why the recording failed
self.recording_events.log_worker_error(
recording,
event,
error=data.egress_info.error,
error_code=data.egress_info.error_code,
)
# Update room
try:
room_name = str(recording.room.id)
RoomManagement.update_metadata(
@@ -201,38 +242,17 @@ class LiveKitEventsService:
except RoomManagementException as e:
logger.exception("Failed to update room's metadata: %s", e)
# Stop metadata collector
if recording.options.get("metadata_collector_dispatch_id", None) is not None:
try:
MetadataCollectorService().stop(recording)
except MetadataCollectorException:
logger.warning("Failed to stop the MetadataCollectorService")
if (
data.egress_info.status == api.EgressStatus.EGRESS_LIMIT_REACHED
and recording.status == models.RecordingStatusChoices.ACTIVE
):
try:
self.recording_events.handle_limit_reached(recording)
except RecordingEventsError as e:
raise ActionFailedError(
f"Failed to process limit reached event for recording {recording}"
) from e
if event is None:
return
# Finalize the recording, the egress has uploaded the file to the storage
if data.egress_info.status in [
api.EgressStatus.EGRESS_COMPLETE,
api.EgressStatus.EGRESS_LIMIT_REACHED,
]:
try:
self.recording_events.handle_complete(recording)
except RecordingNotSavableError:
logger.warning(
"Recording %s is not savable on egress complete "
"(already saved or in an error state); ignoring.",
recording.id,
)
# Silently ignoring EGRESS_ABORTED, EGRESS_FAILED
self.recording_events.handle_terminal_event(recording, event)
@staticmethod
def _is_connection_test_room(room_name: str) -> bool:
@@ -252,10 +272,22 @@ class LiveKitEventsService:
raise ActionFailedError("Failed to process room started event") from e
try:
room = models.Room.objects.get(id=room_id)
room = models.Room.all_objects.get(id=room_id)
except models.Room.DoesNotExist as err:
raise ActionFailedError(f"Room with ID {room_id} does not exist") from err
# The block below is intended to fix the issue where long-lived livekit
# tokens allow users who already entered a room to re-create it,
# even if it was closed.
if room.is_deleted:
self._close_deleted_room(room_id)
return
# Update through the queryset to skip the full_clean run by save()
models.Room.all_objects.filter(pk=room.pk).update(
last_started_at=timezone.now()
)
if settings.ROOM_TELEPHONY_ENABLED or settings.ROOMKIT_ENABLED:
try:
self.sip_management.ensure_dispatch_rule(room)
@@ -264,6 +296,25 @@ class LiveKitEventsService:
f"Failed to create sip dispatch rule for room {room_id}"
) from e
@staticmethod
def _close_deleted_room(room_id):
"""Close a LiveKit room recreated after its room was soft deleted.
LiveKit auto-creates a room on join, so a participant still holding a
valid token can bring a deleted room back to life until the token expires.
"""
logger.warning(
"LiveKit room %s started for a deleted room, closing it", room_id
)
try:
RoomManagement.delete_room(str(room_id))
except RoomNotFoundException:
logger.info("LiveKit room %s is already closed", room_id)
except RoomManagementException as e:
raise ActionFailedError(f"Failed to close deleted room {room_id}") from e
def _handle_room_finished(self, data):
"""Handle 'room_finished' event."""
+24 -4
View File
@@ -6,6 +6,8 @@ import json
from logging import getLogger
from typing import Dict, Optional
from django.db import transaction
from asgiref.sync import async_to_sync
from livekit.api import (
DeleteRoomRequest,
@@ -15,6 +17,7 @@ from livekit.api import (
)
from core import utils
from core.models import Room
logger = getLogger(__name__)
@@ -76,10 +79,6 @@ class RoomManagement:
except TwirpError as e:
if e.code == "not_found":
logger.warning(
"Room %s not found in LiveKit, skipping metadata update",
room_name,
)
raise RoomNotFoundException("Room does not exist") from e
logger.exception(
@@ -119,6 +118,27 @@ class RoomManagement:
finally:
await lkapi.aclose()
@classmethod
def soft_delete(cls, room: Room):
"""Soft delete a room and close its LiveKit room.
Raises:
RoomManagementException: the LiveKit room could not be closed.
"""
try:
with transaction.atomic():
room.soft_delete()
try:
cls.delete_room(str(room.id))
except RoomNotFoundException:
logger.info(
"Room %s is not live in LiveKit, nothing to close", room.id
)
except RoomManagementException:
room.deleted_at = None
raise
@classmethod
def sync_room_metadata(cls, room):
"""Push a room's configuration and access level to its LiveKit room metadata.
@@ -0,0 +1,239 @@
"""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 ["minio:9000", "localhost:9000"]
assert policy_parsed.netloc in ["garage: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/{now.strftime('%Y%m%d')}/us-east-1/s3/aws4_request"
f"meet-access-key/{now.strftime('%Y%m%d')}/local/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"]
@@ -2,18 +2,23 @@
Test RecordingEventsService service.
"""
# pylint: disable=redefined-outer-name
# pylint: disable=redefined-outer-name,protected-access
import logging
from unittest import mock
import pytest
from core.factories import RecordingFactory
from core.recording.enums import RecordingWorkerEvent
from core.recording.services.recording_events import (
RecordingEventsError,
RecordingEventsService,
RecordingNotSavableError,
)
from core.services.room_management import (
RoomManagementException,
)
from core.utils import NotificationError
pytestmark = pytest.mark.django_db
@@ -34,10 +39,10 @@ def service():
)
@mock.patch("core.utils.notify_participants")
def test_handle_limit_reached_success(mock_notify, mode, notification_type, service):
"""Test handle_limit_reached stops recording and notifies participants."""
"""Test _handle_limit_reached stops recording and notifies participants."""
recording = RecordingFactory(status="active", mode=mode)
service.handle_limit_reached(recording)
service._handle_limit_reached(recording)
assert recording.status == "stopped"
mock_notify.assert_called_once_with(
@@ -48,13 +53,69 @@ def test_handle_limit_reached_success(mock_notify, mode, notification_type, serv
@pytest.mark.parametrize(
("mode", "notification_type"),
(
("screen_recording", "screenRecordingLimitReached"),
("transcript", "transcriptionLimitReached"),
("screen_recording", "screenRecordingFailed"),
("transcript", "transcriptionFailed"),
),
)
@mock.patch("core.utils.notify_participants")
def test_handle_limit_reached_error(mock_notify, mode, notification_type, service):
"""Test handle_limit_reached raises RecordingEventsError when notification fails."""
def test_handle_failed_success(mock_notify, mode, notification_type, service):
"""Test _handle_failed marks recording as failed and notifies participants."""
recording = RecordingFactory(status="active", mode=mode)
service._handle_failed(recording)
assert recording.status == "failed"
mock_notify.assert_called_once_with(
room_name=str(recording.room.id), notification_data={"type": notification_type}
)
@pytest.mark.parametrize(
("mode", "notification_type"),
(
("screen_recording", "screenRecordingAborted"),
("transcript", "transcriptionAborted"),
),
)
@mock.patch("core.utils.notify_participants")
def test_handle_aborted_success(mock_notify, mode, notification_type, service):
"""Test _handle_aborted marks recording as aborted and notifies participants."""
recording = RecordingFactory(status="active", mode=mode)
service._handle_aborted(recording)
assert recording.status == "aborted"
mock_notify.assert_called_once_with(
room_name=str(recording.room.id), notification_data={"type": notification_type}
)
@pytest.mark.parametrize(
("mode", "notification_prefix"),
(("screen_recording", "screenRecording"), ("transcript", "transcription")),
)
@pytest.mark.parametrize(
("handler", "expected_status", "event", "notification_suffix"),
(
("_handle_limit_reached", "stopped", "limit reached", "LimitReached"),
("_handle_failed", "failed", "failed", "Failed"),
("_handle_aborted", "aborted", "aborted", "Aborted"),
),
)
@mock.patch("core.utils.notify_participants")
def test_handle_event_notification_error( # noqa: PLR0913, PLR0917
mock_notify,
handler,
expected_status,
event,
notification_suffix,
mode,
notification_prefix,
service,
): # pylint: disable=too-many-arguments,too-many-positional-arguments
"""Test handlers raise RecordingEventsError when notifying participants fails,
while still applying the recording status of their event.
"""
mock_notify.side_effect = NotificationError("Error notifying")
@@ -62,14 +123,15 @@ def test_handle_limit_reached_error(mock_notify, mode, notification_type, servic
with pytest.raises(
RecordingEventsError,
match=r"Failed to notify participants in room '.+' "
r"about recording limit reached \(recording_id=.+\)",
match=rf"Failed to notify participants in room '.+' "
rf"about recording {event} \(recording_id=.+\)",
):
service.handle_limit_reached(recording)
getattr(service, handler)(recording)
assert recording.status == "stopped"
assert recording.status == expected_status
mock_notify.assert_called_once_with(
room_name=str(recording.room.id), notification_data={"type": notification_type}
room_name=str(recording.room.id),
notification_data={"type": f"{notification_prefix}{notification_suffix}"},
)
@@ -82,19 +144,19 @@ def test_handle_limit_reached_error(mock_notify, mode, notification_type, servic
"core.recording.services.recording_events.notification_service."
"notify_external_services"
)
def test_handle_complete_saves_recording( # pylint: disable=too-many-arguments, too-many-positional-arguments
def test_handle_successful_saves_recording( # pylint: disable=too-many-arguments, too-many-positional-arguments
mock_notify_external_services,
notify_return_value,
expected_status,
status,
service,
):
"""Test handle_complete notifies external services and saves a savable recording."""
"""Test _handle_successful notifies external services and saves a savable recording."""
mock_notify_external_services.return_value = notify_return_value
recording = RecordingFactory(status=status)
service.handle_complete(recording)
service._handle_successful(recording)
mock_notify_external_services.assert_called_once_with(recording)
@@ -104,23 +166,293 @@ def test_handle_complete_saves_recording( # pylint: disable=too-many-arguments,
@pytest.mark.parametrize(
"status",
["initiated", "saved", "notification_succeeded", "aborted", "failed_to_start"],
[
"initiated",
"saved",
"notification_succeeded",
"aborted",
"failed",
"failed_to_start",
],
)
@mock.patch(
"core.recording.services.recording_events.notification_service."
"notify_external_services"
)
def test_handle_complete_non_savable_recording(
def test_handle_successful_non_savable_recording(
mock_notify_external_services, status, service
):
"""Test handle_complete refuses recordings that are already saved or in error."""
"""Test _handle_successful refuses recordings that are already saved or in error."""
recording = RecordingFactory(status=status)
with pytest.raises(RecordingNotSavableError):
service.handle_complete(recording)
service._handle_successful(recording)
mock_notify_external_services.assert_not_called()
recording.refresh_from_db()
assert recording.status == status
@pytest.mark.parametrize(
("event", "recording_status"),
(
(RecordingWorkerEvent.STARTED, "started"),
(RecordingWorkerEvent.SAVING, "saving"),
),
)
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_update_syncs_room_metadata(
mock_update_metadata, event, recording_status, service
):
"""Test handle_update updates the room's metadata."""
recording = RecordingFactory(status="active")
service.handle_update(recording, event)
mock_update_metadata.assert_called_once_with(
str(recording.room.id), {"recording_status": recording_status}
)
@pytest.mark.parametrize(
"event",
(
RecordingWorkerEvent.STARTING,
RecordingWorkerEvent.COMPLETED,
RecordingWorkerEvent.LIMIT_REACHED,
RecordingWorkerEvent.ABORTED,
RecordingWorkerEvent.FAILED,
),
)
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_update_ignores_events_without_a_metadata_status(
mock_update_metadata, event, service
):
"""Test handle_update doesn't update metadata for events it doesn't match."""
recording = RecordingFactory(status="active")
service.handle_update(recording, event)
mock_update_metadata.assert_not_called()
@pytest.mark.parametrize(
("event", "initial_status", "expected_status", "notification_type"),
(
(RecordingWorkerEvent.LIMIT_REACHED, "active", "saved", "LimitReached"),
(RecordingWorkerEvent.LIMIT_REACHED, "stopped", "saved", None),
(RecordingWorkerEvent.LIMIT_REACHED, "saved", "saved", None),
(RecordingWorkerEvent.ABORTED, "active", "aborted", "Aborted"),
(RecordingWorkerEvent.ABORTED, "failed_to_stop", "failed_to_stop", None),
(RecordingWorkerEvent.FAILED, "active", "failed", "Failed"),
(RecordingWorkerEvent.FAILED, "stopped", "failed", "Failed"),
(RecordingWorkerEvent.FAILED, "aborted", "aborted", None),
(RecordingWorkerEvent.COMPLETED, "active", "saved", None),
(RecordingWorkerEvent.COMPLETED, "saved", "saved", None),
),
)
@mock.patch(
"core.recording.services.recording_events.notification_service."
"notify_external_services"
)
@mock.patch("core.utils.notify_participants")
def test_handle_terminal_event_dispatches_on_event_and_status( # noqa: PLR0913, PLR0917
mock_notify,
mock_notify_external_services,
event,
initial_status,
expected_status,
notification_type,
service,
): # pylint: disable=too-many-arguments,too-many-positional-arguments
"""Test handle_terminal_event chooses the right handler from the event and status."""
mock_notify_external_services.return_value = False
recording = RecordingFactory(status=initial_status, mode="screen_recording")
service.handle_terminal_event(recording, event)
recording.refresh_from_db()
assert recording.status == expected_status
if notification_type is None:
mock_notify.assert_not_called()
else:
mock_notify.assert_called_once_with(
room_name=str(recording.room.id),
notification_data={"type": f"screenRecording{notification_type}"},
)
@pytest.mark.parametrize(
"event",
(
RecordingWorkerEvent.STARTING,
RecordingWorkerEvent.STARTED,
RecordingWorkerEvent.SAVING,
),
)
@mock.patch(
"core.recording.services.recording_events.notification_service."
"notify_external_services"
)
@mock.patch("core.utils.notify_participants")
def test_handle_terminal_event_ignores_non_terminal_events(
mock_notify, mock_notify_external_services, event, service, caplog
):
"""Test handle_terminal_event refuses non-terminal events."""
recording = RecordingFactory(status="active")
with caplog.at_level(logging.WARNING):
service.handle_terminal_event(recording, event)
assert f"Ignoring non-terminal event {event.value}" in caplog.text
mock_notify.assert_not_called()
mock_notify_external_services.assert_not_called()
recording.refresh_from_db()
assert recording.status == "active"
@pytest.mark.parametrize(
("event", "expected_status"),
(
(RecordingWorkerEvent.LIMIT_REACHED, "saved"),
(RecordingWorkerEvent.ABORTED, "aborted"),
(RecordingWorkerEvent.FAILED, "failed"),
),
)
@mock.patch(
"core.recording.services.recording_events.notification_service."
"notify_external_services"
)
@mock.patch("core.utils.notify_participants")
def test_handle_terminal_event_survives_a_notification_failure( # noqa: PLR0913, PLR0917
mock_notify,
mock_notify_external_services,
event,
expected_status,
service,
caplog,
): # pylint: disable=too-many-arguments,too-many-positional-arguments
"""Test handle_terminal_event logs a notification failure instead of raising.
The recording status must still be persisted: participants missing their
notification should not disturb recording.
"""
mock_notify_external_services.return_value = False
mock_notify.side_effect = NotificationError("Error notifying")
recording = RecordingFactory(status="active")
with caplog.at_level(logging.ERROR):
service.handle_terminal_event(recording, event)
assert f"Failed to notify participants that recording {recording.id}" in caplog.text
recording.refresh_from_db()
assert recording.status == expected_status
@pytest.mark.parametrize(
"status",
["failed_to_start", "aborted", "failed", "failed_to_stop", "saved", "initiated"],
)
@mock.patch(
"core.recording.services.recording_events.notification_service."
"notify_external_services"
)
def test_handle_terminal_event_ignores_a_non_savable_recording(
mock_notify_external_services, status, service, caplog
):
"""Test handle_terminal_event handles a redelivered event idempotently.
A terminal event may be redelivered for an already finalized recording;
this must not raise, otherwise the webhook would 500 and be retried.
"""
recording = RecordingFactory(status=status)
with caplog.at_level(logging.WARNING):
service.handle_terminal_event(recording, RecordingWorkerEvent.COMPLETED)
assert f"Recording {recording.id} is not savable" in caplog.text
mock_notify_external_services.assert_not_called()
recording.refresh_from_db()
assert recording.status == status
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_update_survives_a_metadata_failure(
mock_update_metadata, service, caplog
):
"""Test handle_update logs a metadata failure instead of raising."""
mock_update_metadata.side_effect = RoomManagementException("Error updating")
recording = RecordingFactory(status="active")
with caplog.at_level(logging.ERROR):
service.handle_update(recording, RecordingWorkerEvent.SAVING)
assert "Failed to update room's metadata" in caplog.text
@pytest.mark.parametrize(
("event", "expected_level"),
(
(RecordingWorkerEvent.ABORTED, logging.INFO),
(RecordingWorkerEvent.FAILED, logging.ERROR),
),
)
def test_log_worker_error_reports_an_unsuccessful_event(
event, expected_level, service, caplog
):
"""Test log_worker_error records the reason the recording did not succeed."""
recording = RecordingFactory(status="active", mode="screen_recording")
with caplog.at_level(logging.INFO):
service.log_worker_error(
recording, event, error="could not connect to the room", error_code=500
)
assert (
f"Recording worker reported {event.value} for recording {recording.id}"
in caplog.text
)
assert "could not connect to the room" in caplog.text
assert "error_code=500" in caplog.text
worker_logs = [
record
for record in caplog.records
if record.name == "core.recording.services.recording_events"
]
assert [record.levelno for record in worker_logs] == [expected_level]
@pytest.mark.parametrize(
"event",
(
RecordingWorkerEvent.STARTING,
RecordingWorkerEvent.STARTED,
RecordingWorkerEvent.SAVING,
RecordingWorkerEvent.COMPLETED,
RecordingWorkerEvent.LIMIT_REACHED,
None,
),
)
def test_log_worker_error_stays_quiet_on_anything_else(event, service, caplog):
"""Test log_worker_error ignores events other than FAILED and ABORTED."""
recording = RecordingFactory(status="active")
with caplog.at_level(logging.INFO):
service.log_worker_error(recording, event, error="some error", error_code=500)
assert "Recording worker reported" not in caplog.text
@@ -224,6 +224,7 @@ def test_api_recording_retrieve_expired(settings):
RecordingStatusChoices.INITIATED,
RecordingStatusChoices.ACTIVE,
RecordingStatusChoices.SAVED,
RecordingStatusChoices.FAILED,
RecordingStatusChoices.FAILED_TO_START,
RecordingStatusChoices.FAILED_TO_STOP,
RecordingStatusChoices.ABORTED,
@@ -4,6 +4,7 @@ Test worker service classes.
# pylint: disable=protected-access,redefined-outer-name,unused-argument,no-member
import logging
from unittest.mock import AsyncMock, Mock, patch
import pytest
@@ -154,9 +155,9 @@ def test_base_egress_filepath_construction(service, filename, extension, expecte
"response_status,expected_result",
[
(livekit_api.EgressStatus.EGRESS_ABORTED, "ABORTED"),
(livekit_api.EgressStatus.EGRESS_FAILED, "FAILED"),
(livekit_api.EgressStatus.EGRESS_COMPLETE, "FAILED_TO_STOP"),
(livekit_api.EgressStatus.EGRESS_ENDING, "STOPPED"),
(livekit_api.EgressStatus.EGRESS_FAILED, "FAILED_TO_STOP"),
],
)
def test_base_egress_stop_with_status(service, response_status, expected_result):
@@ -175,6 +176,32 @@ def test_base_egress_stop_with_status(service, response_status, expected_result)
assert result == expected_result
@pytest.mark.parametrize(
"response_status,event",
[
(livekit_api.EgressStatus.EGRESS_ABORTED, "aborted"),
(livekit_api.EgressStatus.EGRESS_FAILED, "failed"),
(livekit_api.EgressStatus.EGRESS_COMPLETE, "failed to stop"),
],
)
def test_base_egress_stop_logs_livekit_error(service, response_status, event, caplog):
"""Should log the reason LiveKit reported for an unsuccessful stop."""
mock_response = Mock(
status=response_status,
egress_id="test_worker_id",
error="could not connect to the room",
error_code=500,
)
service._handle_request = Mock(return_value=mock_response)
with caplog.at_level(logging.ERROR):
service.stop("test_worker_id")
assert f"Egress {event} on stop (egress_id=test_worker_id" in caplog.text
assert "could not connect to the room" in caplog.text
assert "error_code=500" in caplog.text
def test_base_egress_stop_missing_status(service):
"""Test stop method when response is missing status"""
# Mock _handle_request with missing status
@@ -112,6 +112,29 @@ def test_api_rooms_create_authenticated_existing_slug():
assert response.json() == {"slug": ["Room with this Slug already exists."]}
def test_api_rooms_create_authenticated_slug_held_by_soft_deleted_room():
"""
A deleted room keeps its slug: creating a room with the same name should
fail validation rather than hit the database constraint.
"""
RoomFactory(name="my room").soft_delete()
user = UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/rooms/",
{
"name": "My Room!",
},
)
assert response.status_code == 400
assert response.json() == {"slug": ["Room with this Slug already exists."]}
assert Room.all_objects.count() == 1
def test_api_rooms_create_authenticated_user_default_access_level():
"""
The user's default room access level should be applied to the new room
@@ -2,11 +2,14 @@
Test rooms API endpoints in the Meet core app: delete.
"""
from unittest import mock
import pytest
from rest_framework.test import APIClient
from ...factories import RoomFactory, UserFactory
from ...models import Room
from ...services.room_management import RoomManagement, RoomNotFoundException
pytestmark = pytest.mark.django_db
@@ -83,10 +86,11 @@ def test_api_rooms_delete_administrators():
assert Room.objects.count() == 1
def test_api_rooms_delete_owners():
@mock.patch.object(RoomManagement, "delete_room")
def test_api_rooms_delete_owners(mock_delete_room):
"""
Authenticated users should be able to delete a room for which they are directly
owner.
owner. The room is soft deleted and its LiveKit room is closed.
"""
user = UserFactory()
room = RoomFactory(users=[(user, "owner")])
@@ -99,4 +103,71 @@ def test_api_rooms_delete_owners():
)
assert response.status_code == 204
mock_delete_room.assert_called_once_with(str(room.id))
assert Room.objects.exists() is False
assert Room.all_objects.get(id=room.id).deleted_at is not None
@mock.patch.object(
RoomManagement,
"delete_room",
side_effect=RoomNotFoundException("Room does not exist"),
)
def test_api_rooms_delete_owners_room_not_live(mock_delete_room):
"""
Deleting a room that is not live in LiveKit should still soft delete it.
"""
user = UserFactory()
room = RoomFactory(users=[(user, "owner")])
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/rooms/{room.id}/",
)
assert response.status_code == 204
mock_delete_room.assert_called_once_with(str(room.id))
assert Room.objects.exists() is False
assert Room.all_objects.get(id=room.id).deleted_at is not None
@mock.patch.object(RoomManagement, "delete_room")
def test_api_rooms_delete_soft_deleted(mock_delete_room):
"""Deleting a room that is already soft deleted should return a 410."""
user = UserFactory()
room = RoomFactory(users=[(user, "owner")])
room.soft_delete()
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/rooms/{room.id}/",
)
assert response.status_code == 410
assert response.json() == {"detail": "This room has been deleted."}
mock_delete_room.assert_not_called()
@mock.patch.object(RoomManagement, "delete_room")
def test_api_rooms_delete_soft_deleted_not_owner(mock_delete_room):
"""
Deleting a soft-deleted room as a non-owner should return a 403,
not revealing that the room has been deleted.
"""
user = UserFactory()
room = RoomFactory(users=[(user, "administrator")])
room.soft_delete()
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/rooms/{room.id}/",
)
assert response.status_code == 403
mock_delete_room.assert_not_called()
@@ -7,6 +7,7 @@ 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
@@ -187,6 +188,28 @@ def test_api_rooms_retrieve_anonymous_unregistered_not_allowed():
assert response.json() == {"detail": "No Room matches the given query."}
@pytest.mark.parametrize("allow_unregistered_rooms", [True, False])
@pytest.mark.parametrize("lookup", ["id", "slug"])
@mock.patch("core.utils.generate_token", return_value="foo")
def test_api_rooms_retrieve_soft_deleted(
mock_token, lookup, allow_unregistered_rooms, settings
):
"""
Retrieving a soft-deleted room should return a 410, and never fall back
to an unregistered room with the same slug.
"""
settings.ALLOW_UNREGISTERED_ROOMS = allow_unregistered_rooms
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
room.soft_delete()
client = APIClient()
response = client.get(f"/api/v1.0/rooms/{getattr(room, lookup)!s}/")
assert response.status_code == 410
assert response.json() == {"detail": "This room has been deleted."}
mock_token.assert_not_called()
@mock.patch("core.utils.generate_token", return_value="foo")
@override_settings(
LIVEKIT_CONFIGURATION={
@@ -507,3 +530,20 @@ def test_api_rooms_retrieve_administrators(
role=str(user_access.role),
participant_id=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())
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}/")
assert response.status_code == 200
assert "last_started_at" not in response.json()
@@ -7,6 +7,7 @@ import uuid
from unittest import mock
from django.conf import settings
from django.test.utils import override_settings
import pytest
from livekit.api import AccessToken, TwirpError, VideoGrants
@@ -117,9 +118,12 @@ def test_start_subtitle_invalid_token():
assert response.json() == {"detail": "Invalid LiveKit token: Not enough segments"}
@override_settings(ROOM_SUBTITLE_ENABLED=False)
def test_start_subtitle_disabled_by_default(mock_livekit_token):
"""Test that subtitle functionality is disabled when feature flag is off."""
settings.ROOM_SUBTITLE_ENABLED = False
room = RoomFactory()
user = UserFactory()
client = APIClient()
@@ -3,8 +3,11 @@ 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
@@ -225,6 +228,39 @@ 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",
[
@@ -3,23 +3,34 @@ Test LiveKitEvents service.
"""
# pylint: disable=W0621,W0613, W0212, E0611
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 (
EGRESS_STATUS_TO_RECORDING_EVENT,
ActionFailedError,
AuthenticationError,
InvalidPayloadError,
LiveKitEventsService,
api,
to_recording_event,
)
from core.services.lobby import LobbyService
from core.services.room_management import RoomManagementException
from core.services.room_management import (
RoomManagementException,
RoomNotFoundException,
)
from core.services.sip_management import (
SIPException,
SIPManagement,
@@ -77,7 +88,7 @@ def test_initialization(
def test_handle_egress_ended_success( # pylint: disable=too-many-arguments, too-many-positional-arguments
mock_update_metadata, mock_notify, mode, notification_type, service
):
"""Should successfully stop recording and notifies all participant."""
"""Should successfully stop recording and notify all participant."""
recording = RecordingFactory(worker_id="worker-1", mode=mode, status="active")
mock_data = mock.MagicMock()
@@ -104,7 +115,6 @@ def test_handle_egress_ended_success( # pylint: disable=too-many-arguments, too
(
(EgressStatus.EGRESS_ACTIVE, "started"),
(EgressStatus.EGRESS_ENDING, "saving"),
(EgressStatus.EGRESS_ABORTED, "aborted"),
),
)
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
@@ -125,29 +135,6 @@ def test_handle_egress_updated_success(
)
@pytest.mark.parametrize(
"egress_status",
(
EgressStatus.EGRESS_FAILED,
EgressStatus.EGRESS_LIMIT_REACHED,
),
)
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_updated_non_handled(
mock_update_metadata, egress_status, service
):
"""Should ignore certain egress status and don't trigger metadata updates."""
recording = RecordingFactory(worker_id="worker-1", status="initiated")
mock_data = mock.MagicMock()
mock_data.egress_info.egress_id = recording.worker_id
mock_data.egress_info.status = egress_status
service._handle_egress_updated(mock_data)
mock_update_metadata.assert_not_called()
@pytest.mark.parametrize(
("mode", "notification_type"),
(
@@ -180,33 +167,38 @@ def test_handle_egress_ended_metadata_update_fails( # pylint: disable=too-many-
assert recording.status == "saved"
@mock.patch(
"core.recording.services.recording_events.notification_service."
"notify_external_services"
)
@mock.patch("core.utils.notify_participants")
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_notification_fails(
mock_update_metadata, mock_notify, service
mock_update_metadata, mock_notify, mock_notify_external_services, service
):
"""Should raise ActionFailedError when notification fails but still stop recording."""
"""Should still stop and save the recording when notifying participants fails."""
mock_notify_external_services.return_value = False
mock_notify.side_effect = NotificationError("Error notifying")
recording = RecordingFactory(worker_id="worker-1", status="active")
mock_data = mock.MagicMock()
mock_data.egress_info.egress_id = recording.worker_id
mock_data.egress_info.status = EgressStatus.EGRESS_LIMIT_REACHED
mock_notify.side_effect = NotificationError("Error notifying")
with pytest.raises(
ActionFailedError,
match=r"Failed to process limit reached event for recording .+",
):
service._handle_egress_ended(mock_data)
recording.refresh_from_db()
assert recording.status == "stopped"
service._handle_egress_ended(mock_data)
mock_notify.assert_called_once_with(
room_name=str(recording.room.id),
notification_data={"type": "screenRecordingLimitReached"},
)
mock_update_metadata.assert_called_once_with(
str(recording.room.id), remove_keys=["recording_mode", "recording_status"]
)
recording.refresh_from_db()
assert recording.status == "saved"
@mock.patch("core.utils.notify_participants")
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
@@ -232,17 +224,25 @@ def test_handle_egress_ended_recording_not_found(
assert recording.status == "active"
@pytest.mark.parametrize(
"egress_status",
(
EgressStatus.EGRESS_FAILED,
EgressStatus.EGRESS_ABORTED,
EgressStatus.EGRESS_LIMIT_REACHED,
),
)
@mock.patch("core.utils.notify_participants")
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_recording_not_active(
mock_update_metadata, mock_notify, service
def test_handle_egress_ended_recording_should_not_be_saved(
mock_update_metadata, mock_notify, egress_status, service
):
"""Should ignore non-active recordings."""
"""Don't update status for recordings that must not be saved."""
recording = RecordingFactory(worker_id="worker-1", status="failed_to_stop")
mock_data = mock.MagicMock()
mock_data.egress_info.egress_id = "worker-1"
mock_data.egress_info.status = EgressStatus.EGRESS_LIMIT_REACHED
mock_data.egress_info.status = egress_status
service._handle_egress_ended(mock_data)
@@ -255,12 +255,24 @@ def test_handle_egress_ended_recording_not_active(
assert recording.status == "failed_to_stop"
@mock.patch(
"core.recording.services.recording_events.notification_service."
"notify_external_services"
)
@mock.patch("core.utils.notify_participants")
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_recording_not_limit_reached(
mock_update_metadata, mock_notify, service
def test_handle_egress_ended_complete_does_not_notify_participants(
mock_update_metadata, mock_notify, mock_notify_external_services, service
):
"""Should ignore egress non-limit-reached statuses."""
"""Shouldn't notify participants on a successful egress.
EGRESS_COMPLETE is the only ended status that notifies no one: limit
reached, aborted and failed egresses each send their own notification.
A stopped recording is simply finalized, which is the nominal flow once
the egress uploaded the file of a user-initiated stop.
"""
mock_notify_external_services.return_value = False
recording = RecordingFactory(worker_id="worker-1", status="stopped")
mock_data = mock.MagicMock()
@@ -273,7 +285,10 @@ def test_handle_egress_ended_recording_not_limit_reached(
mock_update_metadata.assert_called_once_with(
str(recording.room.id), remove_keys=["recording_mode", "recording_status"]
)
assert recording.status == "stopped"
mock_notify_external_services.assert_called_once_with(recording)
recording.refresh_from_db()
assert recording.status == "saved"
@mock.patch("core.services.livekit_events.MetadataCollectorService")
@@ -333,96 +348,163 @@ def test_handle_egress_ended_does_not_call_metadata_collector_stop_when_conditio
mock_collector.stop.assert_not_called()
@pytest.mark.parametrize(
("egress_status", "recording_status", "event", "expected_level"),
(
(EgressStatus.EGRESS_ABORTED, "active", "aborted", logging.INFO),
(EgressStatus.EGRESS_FAILED, "active", "failed", logging.ERROR),
# The synchronous stop may already have persisted the terminal status,
# and the error details exist only in the webhook payload.
(EgressStatus.EGRESS_ABORTED, "aborted", "aborted", logging.INFO),
(EgressStatus.EGRESS_FAILED, "failed", "failed", logging.ERROR),
),
)
@mock.patch("core.utils.notify_participants")
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_logs_livekit_error( # noqa: PLR0913, PLR0917
mock_update_metadata,
mock_notify,
egress_status,
recording_status,
event,
expected_level,
service,
caplog,
): # pylint: disable=too-many-arguments,too-many-positional-arguments
"""Should log the reason LiveKit reported an unsuccessful egress."""
recording = RecordingFactory(worker_id="worker-1", status=recording_status)
mock_data = mock.MagicMock()
mock_data.egress_info.egress_id = recording.worker_id
mock_data.egress_info.status = egress_status
mock_data.egress_info.error = "could not connect to the room"
mock_data.egress_info.error_code = 500
with caplog.at_level(logging.INFO):
service._handle_egress_ended(mock_data)
assert (
f"Recording worker reported {event} for recording {recording.id}" in caplog.text
)
assert "could not connect to the room" in caplog.text
assert "error_code=500" in caplog.text
worker_logs = [
record
for record in caplog.records
if record.name == "core.recording.services.recording_events"
]
assert [record.levelno for record in worker_logs] == [expected_level]
@pytest.mark.parametrize(
"egress_status",
(EgressStatus.EGRESS_COMPLETE, EgressStatus.EGRESS_LIMIT_REACHED),
)
@mock.patch(
"core.recording.services.recording_events.notification_service."
"notify_external_services"
)
@mock.patch("core.utils.notify_participants")
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
@pytest.mark.parametrize(
"egress_status",
[EgressStatus.EGRESS_COMPLETE, EgressStatus.EGRESS_LIMIT_REACHED],
)
@pytest.mark.parametrize(
"notify_return_value, recording_status",
[(True, "notification_succeeded"), (False, "saved")],
)
def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913, PLR0917
def test_handle_egress_ended_does_not_log_error_on_successful_egress( # noqa: PLR0913, PLR0917
mock_update_metadata,
mock_notify,
mock_notify_external_services,
notify_return_value,
recording_status,
egress_status,
service,
caplog,
): # pylint: disable=too-many-arguments,too-many-positional-arguments
"""Should notify external services and save the recording on egress completion
(EGRESS_COMPLETE or EGRESS_LIMIT_REACHED).
"""
mock_notify_external_services.return_value = notify_return_value
"""Shouldn't log an egress error when LiveKit reports a successful egress."""
recording = RecordingFactory(worker_id="worker-1", status="active")
mock_data = mock.MagicMock()
mock_data.egress_info.egress_id = recording.worker_id
mock_data.egress_info.status = egress_status
service._handle_egress_ended(mock_data)
with caplog.at_level(logging.ERROR):
service._handle_egress_ended(mock_data)
mock_notify_external_services.assert_called_once_with(recording)
recording.refresh_from_db()
assert recording.status == recording_status
assert "Recording worker reported" not in caplog.text
@pytest.mark.parametrize(
"egress_status",
[
EgressStatus.EGRESS_STARTING,
EgressStatus.EGRESS_ACTIVE,
EgressStatus.EGRESS_ENDING,
EgressStatus.EGRESS_FAILED,
EgressStatus.EGRESS_ABORTED,
],
)
@mock.patch("core.utils.notify_participants")
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_does_not_save_on_wrong_status(
mock_update_metadata, egress_status, service
def test_handle_egress_ended_logs_livekit_error_before_cleaning_up(
mock_update_metadata, mock_notify, service, caplog
):
"""Shouldn't save on invalid status."""
"""Should log the failure reason even when the cleanup fails afterwards."""
mock_update_metadata.side_effect = RuntimeError("LiveKit is unreachable")
recording = RecordingFactory(worker_id="worker-1", status="active")
mock_data = mock.MagicMock()
mock_data.egress_info.egress_id = recording.worker_id
mock_data.egress_info.status = egress_status
mock_data.egress_info.status = EgressStatus.EGRESS_FAILED
mock_data.egress_info.error = "could not connect to the room"
mock_data.egress_info.error_code = 500
service._handle_egress_ended(mock_data)
with caplog.at_level(logging.ERROR), pytest.raises(RuntimeError):
service._handle_egress_ended(mock_data)
recording.refresh_from_db()
assert recording.status == "active"
assert (
f"Recording worker reported failed for recording {recording.id}" in caplog.text
)
assert "could not connect to the room" in caplog.text
def test_egress_status_mapping_covers_every_livekit_status():
"""Every egress status LiveKit can report must translate to a recording event."""
unmapped = [
name
for name in EgressStatus.keys()
if getattr(EgressStatus, name) not in EGRESS_STATUS_TO_RECORDING_EVENT
]
assert not unmapped
@pytest.mark.parametrize(
"status", ["failed_to_start", "aborted", "failed_to_stop", "saved", "initiated"]
("egress_status", "expected_event"),
(
(EgressStatus.EGRESS_STARTING, RecordingWorkerEvent.STARTING),
(EgressStatus.EGRESS_ACTIVE, RecordingWorkerEvent.STARTED),
(EgressStatus.EGRESS_ENDING, RecordingWorkerEvent.SAVING),
(EgressStatus.EGRESS_COMPLETE, RecordingWorkerEvent.COMPLETED),
(EgressStatus.EGRESS_LIMIT_REACHED, RecordingWorkerEvent.LIMIT_REACHED),
(EgressStatus.EGRESS_ABORTED, RecordingWorkerEvent.ABORTED),
(EgressStatus.EGRESS_FAILED, RecordingWorkerEvent.FAILED),
),
)
def test_to_recording_event_translates_egress_status(egress_status, expected_event):
"""Should translate a LiveKit egress status into a recording worker event."""
assert to_recording_event(egress_status) == expected_event
def test_to_recording_event_returns_none_on_unmapped_status(caplog):
"""Should warn and return None when LiveKit reports an unknown status."""
with caplog.at_level(logging.WARNING):
event = to_recording_event(999)
assert event is None
assert "Unmapped LiveKit egress status" in caplog.text
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_ignores_non_savable_recording(
mock_update_metadata, status, service
):
"""Should handle non-savable recordings idempotently without raising.
def test_handle_egress_updated_ignores_unmapped_status(mock_update_metadata, service):
"""Shouldn't touch the room's metadata when the egress status is unknown."""
'egress_ended' may be redelivered (e.g. for an already-saved recording);
this must not raise, otherwise the webhook would 500 and LiveKit would retry.
"""
recording = RecordingFactory(worker_id="worker-1", status=status)
RecordingFactory(worker_id="worker-1", status="active")
mock_data = mock.MagicMock()
mock_data.egress_info.egress_id = recording.worker_id
mock_data.egress_info.status = EgressStatus.EGRESS_COMPLETE
mock_data.egress_info.egress_id = "worker-1"
mock_data.egress_info.status = 999
service._handle_egress_ended(mock_data)
service._handle_egress_updated(mock_data)
recording.refresh_from_db()
assert recording.status == status
mock_update_metadata.assert_not_called()
@mock.patch.object(LobbyService, "clear_room_cache")
@@ -621,6 +703,82 @@ 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()
@@ -641,6 +799,61 @@ def test_handle_room_started_raises_error_for_nonexistent_room(service):
service._handle_room_started(mock_data)
@mock.patch.object(SIPManagement, "ensure_dispatch_rule")
@mock.patch("core.services.room_management.RoomManagement.delete_room")
def test_handle_room_started_closes_deleted_room(
mock_delete_room, mock_ensure_dispatch_rule, service, settings
):
"""Should close a LiveKit room recreated for a soft-deleted room."""
settings.ROOM_TELEPHONY_ENABLED = True
room = RoomFactory()
room.soft_delete()
mock_data = mock.MagicMock()
mock_data.room.name = str(room.id)
service._handle_room_started(mock_data)
mock_delete_room.assert_called_once_with(str(room.id))
mock_ensure_dispatch_rule.assert_not_called()
@mock.patch(
"core.services.room_management.RoomManagement.delete_room",
side_effect=RoomNotFoundException("Room does not exist"),
)
def test_handle_room_started_ignores_already_closed_deleted_room(
mock_delete_room, service
):
"""Should proceed silently when the deleted room is already closed in LiveKit."""
room = RoomFactory()
room.soft_delete()
mock_data = mock.MagicMock()
mock_data.room.name = str(room.id)
service._handle_room_started(mock_data)
mock_delete_room.assert_called_once_with(str(room.id))
@mock.patch(
"core.services.room_management.RoomManagement.delete_room",
side_effect=RoomManagementException("Could not delete room"),
)
def test_handle_room_started_raises_error_when_closing_deleted_room_fails(
mock_delete_room, service
):
"""Should raise ActionFailedError when the deleted room cannot be closed."""
room = RoomFactory()
room.soft_delete()
mock_data = mock.MagicMock()
mock_data.room.name = str(room.id)
expected_error = f"Failed to close deleted room {room.id}"
with pytest.raises(ActionFailedError, match=expected_error):
service._handle_room_started(mock_data)
@mock.patch.object(
api.WebhookReceiver, "receive", side_effect=Exception("Invalid payload")
)
@@ -6,7 +6,7 @@ import pytest
from livekit.api import TwirpError
from core.factories import RoomFactory
from core.models import RoomAccessLevel
from core.models import Room, RoomAccessLevel
from core.services.room_management import (
RoomManagement,
RoomManagementException,
@@ -62,6 +62,29 @@ def test_delete_room_raises_management_exception(mock_create_livekit_client):
mock_api.aclose.assert_awaited_once()
@pytest.mark.django_db
@mock.patch.object(RoomManagement, "delete_room")
def test_soft_delete_failure_rolls_back_and_can_be_retried(mock_delete_room):
"""A failed soft delete leaves the room untouched, in database and in memory,
so it can be retried."""
room = RoomFactory()
mock_delete_room.side_effect = RoomManagementException("Could not delete room")
with pytest.raises(RoomManagementException):
RoomManagement.soft_delete(room)
assert room.deleted_at is None
assert Room.objects.filter(id=room.id).exists()
mock_delete_room.side_effect = None
RoomManagement.soft_delete(room)
assert mock_delete_room.call_count == 2
assert room.deleted_at is not None
assert Room.all_objects.get(id=room.id).deleted_at is not None
assert Room.objects.filter(id=room.id).exists() is False
@mock.patch.object(RoomManagement, "update_metadata")
def test_sync_room_metadata_pushes_configuration_and_access_level(mock_update_metadata):
"""The room's configuration and access level are forwarded to LiveKit."""
@@ -956,3 +956,99 @@ def test_api_room_user_access_delete_owners_last_owner():
assert response.status_code == 403
assert ResourceAccess.objects.count() == 1
# Soft-deleted rooms
def test_api_room_user_accesses_create_soft_deleted_room():
"""
Owners of a soft-deleted room should not be allowed to add accesses to it.
"""
user = UserFactory()
room = RoomFactory(users=[(user, "owner")])
room.soft_delete()
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/resource-accesses/",
{
"user": str(UserFactory().id),
"resource": str(room.id),
"role": "member",
},
)
assert response.status_code == 410
assert response.json() == {"detail": "This room has been deleted."}
assert ResourceAccess.objects.count() == 1
def test_api_room_user_accesses_create_soft_deleted_room_not_administrator():
"""
Users without privileges on a soft-deleted room should get a 403,
not revealing that the room has been deleted.
"""
user = UserFactory()
room = RoomFactory(users=[(user, "member")])
room.soft_delete()
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/resource-accesses/",
{
"user": str(UserFactory().id),
"resource": str(room.id),
"role": "member",
},
)
assert response.status_code == 403
assert ResourceAccess.objects.count() == 1
def test_api_room_user_accesses_update_soft_deleted_room():
"""
Owners of a soft-deleted room should not be allowed to update its accesses.
"""
user = UserFactory()
room = RoomFactory(users=[(user, "owner")])
access = UserResourceAccessFactory(resource=room, role="member")
room.soft_delete()
client = APIClient()
client.force_login(user)
response = client.patch(
f"/api/v1.0/resource-accesses/{access.id!s}/",
{"role": "administrator"},
format="json",
)
assert response.status_code == 410
access.refresh_from_db()
assert access.role == "member"
def test_api_room_user_access_delete_soft_deleted_room():
"""
Owners of a soft-deleted room should not be allowed to remove its accesses.
"""
user = UserFactory()
room = RoomFactory(users=[(user, "owner")])
access = UserResourceAccessFactory(resource=room, role="member")
room.soft_delete()
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/resource-accesses/{access.id!s}/",
)
assert response.status_code == 410
assert ResourceAccess.objects.filter(id=access.id).exists() is True
+78
View File
@@ -0,0 +1,78 @@
"""Tests for the Dockerflow health endpoints backing the Kubernetes probes."""
from unittest import mock
from django.core import checks
from django.test.utils import override_settings
import pytest
from dockerflow.django.views import django_check_registry
pytestmark = pytest.mark.django_db
@pytest.mark.parametrize("path", ["/__lbheartbeat__", "/__heartbeat__"])
def test_dockerflow_endpoints_are_anonymous(client, path):
"""Both endpoints answer without authentication."""
response = client.get(path)
assert response.status_code == 200
def test_dockerflow_lbheartbeat_is_a_liveness_signal(client, django_assert_num_queries):
"""The load balancer heartbeat answers 200 without touching the database."""
with django_assert_num_queries(0):
response = client.get("/__lbheartbeat__")
assert response.status_code == 200
def test_dockerflow_lbheartbeat_ignores_allowed_hosts(client):
"""The probes are served before ALLOWED_HOSTS is enforced."""
response = client.get("/__lbheartbeat__", headers={"host": "1.2.3.4:8000"})
assert response.status_code == 200
def test_dockerflow_heartbeat_reports_the_configured_checks(client):
"""The heartbeat runs the database, migrations and redis checks."""
with override_settings(DEBUG=True):
response = client.get("/__heartbeat__")
assert response.status_code == 200
payload = response.json()
assert payload["status"] == "ok"
assert payload["details"] == {}
assert payload["checks"]["check_database_connected"] == "ok"
assert payload["checks"]["check_migrations_applied"] == "ok"
def test_dockerflow_heartbeat_is_a_readiness_signal(client):
"""A check reporting an error takes the heartbeat down with a 500."""
def failing_check(**kwargs):
return [checks.Error("Could not connect to database", id="health.E001")]
with mock.patch.object(
django_check_registry, "get_checks", return_value=[failing_check]
):
response = client.get("/__heartbeat__")
assert response.status_code == 500
assert response.json()["status"] == "error"
def test_dockerflow_heartbeat_tolerates_warnings(client):
"""A warning in the check should not affect the probes."""
def warning_check(**kwargs):
return [checks.Warning("Unapplied migration", id="health.W001")]
with mock.patch.object(
django_check_registry, "get_checks", return_value=[warning_check]
):
response = client.get("/__heartbeat__")
assert response.status_code == 200
assert response.json()["status"] == "warning"
@@ -26,7 +26,11 @@ from core.models import (
RoomAccessLevel,
User,
)
from core.services.room_management import RoomManagement
from core.services.room_management import (
RoomManagement,
RoomManagementException,
RoomNotFoundException,
)
pytestmark = pytest.mark.django_db
@@ -563,6 +567,53 @@ def test_api_rooms_retrieve_not_found():
assert "no room matches the given query." in str(response.data).lower()
def test_api_rooms_retrieve_invalid_id():
"""Retrieving a room with a malformed id should return a 404."""
user = UserFactory()
token = generate_test_token(user, [ApplicationScope.ROOMS_RETRIEVE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/external-api/v1.0/rooms/not-a-uuid/")
assert response.status_code == 404
def test_api_rooms_retrieve_soft_deleted():
"""Retrieving a soft-deleted room should return a 410."""
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
room.soft_delete()
token = generate_test_token(user, [ApplicationScope.ROOMS_RETRIEVE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get(f"/external-api/v1.0/rooms/{room.id}/")
assert response.status_code == 410
assert response.json() == {"detail": "This room has been deleted."}
def test_api_rooms_retrieve_soft_deleted_not_member():
"""Retrieving a soft-deleted room without any role on it should return a 403,
not revealing that the room has been deleted."""
user = UserFactory()
room = RoomFactory()
room.soft_delete()
token = generate_test_token(user, [ApplicationScope.ROOMS_RETRIEVE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get(f"/external-api/v1.0/rooms/{room.id}/")
assert response.status_code == 403
def test_api_rooms_create_requires_authentication():
"""Creating rooms without authentication should return 401."""
@@ -1392,6 +1443,204 @@ def test_api_rooms_update_tracks_analytics(mock_update_metadata, mock_capture):
mock_update_metadata.assert_called_once()
@mock.patch.object(RoomManagement, "delete_room")
def test_api_rooms_delete_requires_authentication(mock_delete_room):
"""Deleting a room without authentication should return 401."""
room = RoomFactory(users=[(UserFactory(), RoleChoices.OWNER)])
client = APIClient()
response = client.delete(f"/external-api/v1.0/rooms/{room.id}/")
assert response.status_code == 401
assert Room.objects.filter(id=room.id).exists() is True
mock_delete_room.assert_not_called()
@mock.patch.object(RoomManagement, "delete_room")
def test_api_rooms_delete_requires_scope(mock_delete_room):
"""Deleting a room requires the ROOMS_DELETE scope."""
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
# Token without ROOMS_DELETE scope
token = generate_test_token(
user, [ApplicationScope.ROOMS_RETRIEVE, ApplicationScope.ROOMS_UPDATE]
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.delete(f"/external-api/v1.0/rooms/{room.id}/")
assert response.status_code == 403
assert (
"insufficient permissions. required scope: rooms:delete"
in str(response.data).lower()
)
assert Room.objects.filter(id=room.id).exists() is True
mock_delete_room.assert_not_called()
@pytest.mark.parametrize("role", [RoleChoices.ADMIN, RoleChoices.MEMBER, None])
@mock.patch.object(RoomManagement, "delete_room")
def test_api_rooms_delete_without_ownership(mock_delete_room, role):
"""Only owners should be able to delete a room, administrators included."""
user = UserFactory()
users = [(user, role)] if role else []
room = RoomFactory(users=users)
token = generate_test_token(user, [ApplicationScope.ROOMS_DELETE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.delete(f"/external-api/v1.0/rooms/{room.id}/")
assert response.status_code == 403
assert Room.objects.filter(id=room.id).exists() is True
mock_delete_room.assert_not_called()
@mock.patch.object(RoomManagement, "delete_room")
def test_api_rooms_delete_unknown_room(mock_delete_room):
"""Deleting a room that does not exist should return 404."""
user = UserFactory()
token = generate_test_token(user, [ApplicationScope.ROOMS_DELETE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.delete(f"/external-api/v1.0/rooms/{uuid.uuid4()}/")
assert response.status_code == 404
mock_delete_room.assert_not_called()
@mock.patch("core.external_api.viewsets.analytics.capture")
@mock.patch.object(RoomManagement, "delete_room")
def test_api_rooms_delete_success(mock_delete_room, mock_capture):
"""Owners should be able to delete a room: it is soft deleted, its LiveKit
room is closed and a ROOM_DELETED analytics event is emitted."""
user = UserFactory()
room = RoomFactory(
users=[(user, RoleChoices.OWNER)], access_level=RoomAccessLevel.TRUSTED
)
token = generate_test_token(user, [ApplicationScope.ROOMS_DELETE])
application = Application.objects.get()
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.delete(f"/external-api/v1.0/rooms/{room.id}/")
assert response.status_code == 204
mock_delete_room.assert_called_once_with(str(room.id))
assert Room.objects.filter(id=room.id).exists() is False
assert Room.all_objects.get(id=room.id).deleted_at is not None
mock_capture.assert_called_once()
captured_user, event, properties = mock_capture.call_args[0]
assert captured_user == user
assert event == AnalyticsEvent.ROOM_DELETED
assert properties == {
"room_id": str(room.pk),
"access_level": RoomAccessLevel.TRUSTED,
"client_id": str(application.client_id),
"external_api": True,
"auth_method": "ApplicationJWTAuthentication",
"$set": {"email": user.email},
}
@mock.patch("core.external_api.viewsets.analytics.capture")
@mock.patch.object(
RoomManagement,
"delete_room",
side_effect=RoomManagementException("Could not delete room"),
)
def test_api_rooms_delete_livekit_failure(mock_delete_room, mock_capture):
"""When the LiveKit room can't be closed, the deletion should be rolled back
and no analytics event emitted."""
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
token = generate_test_token(user, [ApplicationScope.ROOMS_DELETE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.delete(f"/external-api/v1.0/rooms/{room.id}/")
assert response.status_code == 500
assert response.json() == {"detail": "Could not delete the room, please try again."}
mock_delete_room.assert_called_once_with(str(room.id))
assert Room.objects.get(id=room.id).deleted_at is None
mock_capture.assert_not_called()
@mock.patch.object(
RoomManagement,
"delete_room",
side_effect=RoomNotFoundException("Room does not exist"),
)
def test_api_rooms_delete_room_not_live(mock_delete_room):
"""Deleting a room that is not live in LiveKit should still soft delete it."""
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
token = generate_test_token(user, [ApplicationScope.ROOMS_DELETE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.delete(f"/external-api/v1.0/rooms/{room.id}/")
assert response.status_code == 204
mock_delete_room.assert_called_once_with(str(room.id))
assert Room.objects.filter(id=room.id).exists() is False
assert Room.all_objects.get(id=room.id).deleted_at is not None
@mock.patch.object(RoomManagement, "delete_room")
def test_api_rooms_delete_soft_deleted(mock_delete_room):
"""Deleting a room that is already soft deleted should return a 410."""
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
room.soft_delete()
token = generate_test_token(user, [ApplicationScope.ROOMS_DELETE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.delete(f"/external-api/v1.0/rooms/{room.id}/")
assert response.status_code == 410
mock_delete_room.assert_not_called()
@mock.patch.object(RoomManagement, "delete_room")
def test_api_rooms_delete_soft_deleted_not_owner(mock_delete_room):
"""Deleting a soft-deleted room as a non-owner should return a 403,
not revealing that the room has been deleted."""
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.ADMIN)])
room.soft_delete()
token = generate_test_token(user, [ApplicationScope.ROOMS_DELETE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.delete(f"/external-api/v1.0/rooms/{room.id}/")
assert response.status_code == 403
mock_delete_room.assert_not_called()
def test_api_rooms_response_no_url(settings):
"""Response should not include url field when APPLICATION_BASE_URL is None."""
settings.APPLICATION_BASE_URL = None
@@ -157,6 +157,7 @@ def test_models_recording_is_savable_normal():
@pytest.mark.parametrize(
"status",
[
RecordingStatusChoices.FAILED,
RecordingStatusChoices.FAILED_TO_STOP,
RecordingStatusChoices.FAILED_TO_START,
RecordingStatusChoices.ABORTED,
@@ -279,6 +280,7 @@ def test_models_recording_is_saved_false_initiated():
@pytest.mark.parametrize(
"status",
[
RecordingStatusChoices.FAILED,
RecordingStatusChoices.FAILED_TO_STOP,
RecordingStatusChoices.FAILED_TO_START,
RecordingStatusChoices.ABORTED,
@@ -92,6 +92,12 @@ 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
@@ -0,0 +1,47 @@
"""Unit tests for the get_release settings helper."""
import re
import pytest
from meet.settings import get_release
@pytest.fixture(name="base_dir")
def fixture_empty_base_dir(tmp_path, monkeypatch):
"""Point get_release at an empty directory."""
monkeypatch.setattr("meet.settings.BASE_DIR", str(tmp_path))
return tmp_path
def test_get_release_reads_project_pyproject():
"""Should return the semantic version of the backend's pyproject.toml."""
assert re.fullmatch(r"\d+\.\d+\.\d+", get_release())
def test_get_release_reads_pyproject_version(base_dir):
"""Should return the version declared in the [project] table."""
(base_dir / "pyproject.toml").write_text(
'[project]\nname = "meet"\nversion = "1.2.3"\n', encoding="utf-8"
)
assert get_release() == "1.2.3"
@pytest.mark.usefixtures("base_dir")
def test_get_release_missing_pyproject():
"""Should fall back to "NA" without a pyproject.toml."""
assert get_release() == "NA"
@pytest.mark.parametrize(
"content",
[
'[project]\nname = "meet"\n', # no version
"[tool.uv]\npackage = true\n", # no [project] table
"[project\nversion = ", # malformed TOML
],
)
def test_get_release_unreadable_version(base_dir, content):
"""Should fall back to "NA" without a readable version in pyproject.toml."""
(base_dir / "pyproject.toml").write_text(content, encoding="utf-8")
assert get_release() == "NA"
+34 -13
View File
@@ -12,7 +12,7 @@ https://docs.djangoproject.com/en/3.1/ref/settings/
# pylint: disable=too-many-lines
import json
import tomllib
import warnings
from os import path
from socket import gethostbyname, gethostname
@@ -37,19 +37,11 @@ GB = 1024 * MB
def get_release():
"""
Get the current release of the application
By release, we mean the release from the version.json file à la Mozilla [1]
(if any). If this file has not been found, it defaults to "NA".
[1]
https://github.com/mozilla-services/Dockerflow/blob/master/docs/version_object.md
"""
# Try to get the current release from the version.json file generated by the
# CI during the Docker image build
try:
with open(path.join(BASE_DIR, "version.json"), encoding="utf8") as version:
return json.load(version)["version"]
except FileNotFoundError:
with open(path.join(BASE_DIR, "pyproject.toml"), "rb") as pyproject:
return tomllib.load(pyproject)["project"]["version"]
except (FileNotFoundError, KeyError, tomllib.TOMLDecodeError):
return "NA" # Default: not available
@@ -295,6 +287,7 @@ class Base(Configuration):
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"dockerflow.django.middleware.DockerflowMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.locale.LocaleMiddleware",
@@ -304,7 +297,6 @@ class Base(Configuration):
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"dockerflow.django.middleware.DockerflowMiddleware",
]
AUTHENTICATION_BACKENDS = [
@@ -738,6 +730,9 @@ 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"],
@@ -1181,6 +1176,13 @@ class Base(Configuration):
environ_prefix="",
)
},
"dockerflow": {
"level": values.Value(
"WARNING",
environ_name="LOGGING_LEVEL_DOCKERFLOW",
environ_prefix="",
)
},
"core": {
"handlers": ["console"],
"level": values.Value(
@@ -1250,6 +1252,25 @@ 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(
+15 -15
View File
@@ -2,12 +2,12 @@
# Meet package
#
[build-system]
requires = ["uv_build>=0.11.16,<0.12.0"]
requires = ["uv_build>=0.12.6,<0.13.0"]
build-backend = "uv_build"
[project]
name = "meet"
version = "1.31.0"
version = "1.32.1"
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.56",
"boto3==1.43.80",
"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.27",
"django-lasuite[all]==0.0.29",
"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.17.1",
"djangorestframework==3.18.0",
"drf_spectacular==0.30.0",
"dockerflow==2026.3.4",
"easy_thumbnails==2.10.1",
"factory_boy==3.3.3",
"gunicorn==26.0.0",
"gunicorn==26.2.0",
"jsonschema==4.26.0",
"markdown==3.10.2",
"markdown==3.10.3",
"nested-multipart-parser==1.6.0",
"posthog==7.29.0",
"posthog==7.44.0",
"psycopg[binary]==3.3.4",
"pydantic==2.13.4",
"PyJWT==2.13.0",
"python-frontmatter==1.3.0",
"python-magic==0.4.27",
"requests==2.34.2",
"sentry-sdk==2.66.1",
"sentry-sdk==2.68.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.34",
"cryptography==50.0.0", # CVE-2026-69247
"phonenumbers==9.0.37",
"cryptography==50.0.1", # CVE-2026-69247
]
[project.urls]
@@ -76,20 +76,20 @@ dependencies = [
[dependency-groups]
dev = [
"django-extensions==4.1",
"drf-spectacular-sidecar==2026.7.1",
"drf-spectacular-sidecar==2026.8.1",
"freezegun==1.5.5",
"ipdb==0.13.13",
"ipython==9.15.0",
"ipython==9.16.1",
"pyfakefs==6.2.0",
"pylint-django==2.8.0",
"pylint<4.0.0",
"pytest-cov==7.1.0",
"pytest-django==4.12.0",
"pytest-django==4.14.0",
"pytest==9.1.1",
"pytest-icdiff==0.9",
"pytest-xdist==3.8.0",
"responses==0.26.2",
"ruff==0.16.0",
"ruff==0.16.4",
"types-requests==2.33.0.20260712",
]
+750 -533
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -45,7 +45,8 @@ RUN npm run build
FROM nginxinc/nginx-unprivileged:1.30.4-alpine3.24 AS frontend-production
USER root
RUN apk del curl
RUN apk upgrade --no-cache libexpat && \
apk del curl
USER nginx
# Un-privileged user running the application
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "meet",
"version": "1.31.0",
"version": "1.32.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "meet",
"version": "1.31.0",
"version": "1.32.1",
"dependencies": {
"@fontsource-variable/atkinson-hyperlegible-next": "5.3.0",
"@fontsource-variable/lexend": "5.3.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "1.31.0",
"version": "1.32.1",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
@@ -41,6 +41,7 @@ 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: {
@@ -96,6 +96,10 @@ export const MainNotificationToast = () => {
case NotificationType.ScreenRecordingStopped:
case NotificationType.TranscriptionLimitReached:
case NotificationType.ScreenRecordingLimitReached:
case NotificationType.TranscriptionFailed:
case NotificationType.ScreenRecordingFailed:
case NotificationType.TranscriptionAborted:
case NotificationType.ScreenRecordingAborted:
toastQueue.add(
{
participant,
@@ -1,5 +1,6 @@
import { Div } from '@/primitives'
import { ToastProvider } from './components/ToastProvider'
import { RecordingWaitingForTracksNotification } from './components/RecordingWaitingForTracksNotification'
import { WaitingParticipantNotification } from './components/WaitingParticipantNotification'
export const NotificationProvider = ({
@@ -12,5 +13,6 @@ export const NotificationProvider = ({
<Div position="absolute" bottom={bottom} right={right} zIndex={1000}>
<ToastProvider />
<WaitingParticipantNotification />
<RecordingWaitingForTracksNotification />
</Div>
)
@@ -10,11 +10,15 @@ export enum NotificationType {
TranscriptionStarted = 'transcriptionStarted',
TranscriptionStopped = 'transcriptionStopped',
TranscriptionLimitReached = 'transcriptionLimitReached',
TranscriptionFailed = 'transcriptionFailed',
TranscriptionAborted = 'transcriptionAborted',
TranscriptionRequested = 'transcriptionRequested',
ScreenRecordingStarted = 'screenRecordingStarted',
ScreenRecordingStopped = 'screenRecordingStopped',
ScreenRecordingRequested = 'screenRecordingRequested',
ScreenRecordingLimitReached = 'screenRecordingLimitReached',
ScreenRecordingFailed = 'screenRecordingFailed',
ScreenRecordingAborted = 'screenRecordingAborted',
RecordingSaving = 'recordingSaving',
PermissionsRemoved = 'permissionsRemoved',
RoleChanged = 'roleChanged',
@@ -0,0 +1,41 @@
import { useTranslation } from 'react-i18next'
import { Text } from '@/primitives'
import { css } from '@/styled-system/css'
import { HStack } from '@/styled-system/jsx'
import { RecordingMode } from '@/features/recording'
import { useRecordingWaitingForTracks } from '@/features/recording/hooks/useRecordingWaitingForTracks'
import { StyledToastContainer } from './StyledToastContainer'
export const RecordingWaitingForTracksNotification = () => {
const { t } = useTranslation('notifications')
const isTranscriptWaiting = useRecordingWaitingForTracks(
RecordingMode.Transcript
)
const isScreenRecordingWaiting = useRecordingWaitingForTracks(
RecordingMode.ScreenRecording
)
if (!isTranscriptWaiting && !isScreenRecordingWaiting) return null
return (
<StyledToastContainer role="status">
<HStack padding={14}>
<Text
margin={false}
className={css({
maxWidth: '22rem',
wordBreak: 'break-word',
overflowWrap: 'break-word',
whiteSpace: 'normal',
})}
>
{t(
isTranscriptWaiting
? 'transcript.waitingForTracks'
: 'screenRecording.waitingForTracks'
)}
</Text>
</HStack>
</StyledToastContainer>
)
}
@@ -22,12 +22,20 @@ export function ToastAnyRecording({ state, ...props }: Readonly<ToastProps>) {
return 'transcript.stopped'
case NotificationType.TranscriptionLimitReached:
return 'transcript.limitReached'
case NotificationType.TranscriptionFailed:
return 'transcript.failed'
case NotificationType.TranscriptionAborted:
return 'transcript.aborted'
case NotificationType.ScreenRecordingStarted:
return 'screenRecording.started'
case NotificationType.ScreenRecordingStopped:
return 'screenRecording.stopped'
case NotificationType.ScreenRecordingLimitReached:
return 'screenRecording.limitReached'
case NotificationType.ScreenRecordingFailed:
return 'screenRecording.failed'
case NotificationType.ScreenRecordingAborted:
return 'screenRecording.aborted'
default:
return
}
@@ -58,6 +58,10 @@ const renderToast = (
case NotificationType.ScreenRecordingStarted:
case NotificationType.ScreenRecordingStopped:
case NotificationType.ScreenRecordingLimitReached:
case NotificationType.TranscriptionFailed:
case NotificationType.ScreenRecordingFailed:
case NotificationType.TranscriptionAborted:
case NotificationType.ScreenRecordingAborted:
return <ToastAnyRecording key={toast.key} toast={toast} state={state} />
case NotificationType.TranscriptionRequested:
@@ -34,6 +34,15 @@ export const WaitingParticipantNotification = () => {
const isParticipantListEmpty = (p?: WaitingParticipant[]) => p?.length == 0
useEffect(() => {
const previousIds = new Set(prevWaitingParticipant?.map(({ id }) => id))
const hasNewWaitingParticipant = waitingParticipants.some(
({ id }) => !previousIds.has(id)
)
if (hasNewWaitingParticipant) {
triggerNotificationSound(NotificationType.ParticipantWaiting)
}
// Show notification when the first participant enters the waiting room
if (
!isParticipantListEmpty(waitingParticipants) &&
@@ -42,8 +51,6 @@ export const WaitingParticipantNotification = () => {
) {
setShowQuickActionsMessage(true)
triggerNotificationSound(NotificationType.ParticipantJoined)
if (timerRef.current !== null) {
clearTimeout(timerRef.current)
}
@@ -11,7 +11,7 @@ export const useNotificationSound = () => {
participantJoined: [0, 1150],
handRaised: [1400, 180],
messageReceived: [1580, 300],
waiting: [2039, 710],
participantWaiting: [2039, 710],
success: [2740, 1304],
},
volume: notificationsSnap.soundNotificationVolume,
@@ -20,6 +20,7 @@ import { Track } from 'livekit-client'
import { ParticipantPlaceholder } from './ParticipantPlaceholder'
import { ParticipantTileFocus } from './participantTileFocus/ParticipantTileFocus'
import { FullScreenShareWarning } from './FullScreenShareWarning'
import { ScreenShareZoomableVideo } from '@/features/rooms/livekit/components/ScreenShareZoomableVideo'
import { useTranslation } from 'react-i18next'
import { getShortcutDescriptorById } from '@/features/shortcuts/catalog'
import { formatShortcutLabel } from '@/features/shortcuts/formatLabels'
@@ -89,6 +90,8 @@ export const ParticipantTile: (
)
const isScreenShare = trackReference.source != Track.Source.Camera
const isRemoteScreenShare =
isScreenShare && !trackReference.participant.isLocal
const [hasKeyboardFocus, setHasKeyboardFocus] = React.useState(false)
const participantColor = getParticipantColor(trackReference.participant)
@@ -98,11 +101,23 @@ export const ParticipantTile: (
})
const participantName = name || identity || 'Unknown'
// tileRef: fullscreen target, and the node the focus overlay listens on.
// setRefs merges it with the forwarded ref on the same node.
const tileRef = React.useRef<HTMLDivElement>(null)
const setRefs = React.useCallback(
(node: HTMLDivElement | null) => {
;(tileRef as React.MutableRefObject<HTMLDivElement | null>).current = node
if (typeof ref === 'function') ref(node)
else if (ref)
(ref as React.MutableRefObject<HTMLDivElement | null>).current = node
},
[ref]
)
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
const interactiveProps = {
...elementProps,
// Ensure the tile is focusable to expose contextual controls to keyboard users.
tabIndex: 0,
'aria-label': t('containerLabel', { name: participantName }),
onFocus: (event: React.FocusEvent<HTMLDivElement>) => {
@@ -120,8 +135,41 @@ export const ParticipantTile: (
},
}
const isVideoTrack =
isTrackReference(trackReference) &&
trackReference.publication.kind === 'video'
let trackMedia: React.ReactNode = null
if (isVideoTrack) {
const videoTrack = (
<VideoTrack
trackRef={trackReference}
onSubscriptionStatusChanged={handleSubscribe}
manageSubscription={autoManageSubscription}
/>
)
// Zoom toolbar stays out of picture-in-picture: that window has its own
// document and the fullscreen API is off. Follow-up PR can restore zoom
// there without the dead fullscreen button.
trackMedia =
isRemoteScreenShare && !disableTileControls ? (
<ScreenShareZoomableVideo tileRef={tileRef}>
{videoTrack}
</ScreenShareZoomableVideo>
) : (
videoTrack
)
} else if (isTrackReference(trackReference)) {
trackMedia = (
<AudioTrack
trackRef={trackReference}
onSubscriptionStatusChanged={handleSubscribe}
/>
)
}
return (
<div ref={ref} style={{ position: 'relative' }} {...interactiveProps}>
<div ref={setRefs} style={{ position: 'relative' }} {...interactiveProps}>
<TrackRefContextIfNeeded trackRef={trackReference}>
<ParticipantContextIfNeeded participant={trackReference.participant}>
{trackReference.participant.isLocal && (
@@ -129,23 +177,7 @@ export const ParticipantTile: (
)}
{children ?? (
<>
{isTrackReference(trackReference) &&
(trackReference.publication?.kind === 'video' ||
trackReference.source === Track.Source.Camera ||
trackReference.source === Track.Source.ScreenShare) ? (
<VideoTrack
trackRef={trackReference}
onSubscriptionStatusChanged={handleSubscribe}
manageSubscription={autoManageSubscription}
/>
) : (
isTrackReference(trackReference) && (
<AudioTrack
trackRef={trackReference}
onSubscriptionStatusChanged={handleSubscribe}
/>
)
)}
{trackMedia}
<div className="lk-participant-placeholder">
<ParticipantPlaceholder
color={participantColor}
@@ -164,6 +196,7 @@ export const ParticipantTile: (
{!disableMetadata && !disableTileControls && (
<ParticipantTileFocus
trackRef={trackReference}
tileRef={tileRef}
hasKeyboardFocus={hasKeyboardFocus}
/>
)}
@@ -7,38 +7,64 @@ import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
import { FocusButton } from './FocusButton'
import { EffectsButton } from './EffectsButton'
import { MuteButton } from './MuteButton'
import { ZoomButton } from './ZoomButton'
const MOUSE_IDLE_TIME = 3000
type FadeOverlayProps = {
children: ReactNode
hasKeyboardFocus: boolean
tileRef: React.RefObject<HTMLDivElement | null>
}
const FadeOverlay = ({ children, hasKeyboardFocus }: FadeOverlayProps) => {
// Pointer-events none so this overlay doesn't block the zoom surface below.
// The tile node still gets the mouse events, so we listen on it directly
// rather than lifting the state up: this keeps mouse moves from re-rendering
// the tile and the video it contains.
const FadeOverlay = ({
children,
hasKeyboardFocus,
tileRef,
}: FadeOverlayProps) => {
const [active, setActive] = useState(false)
const idleTimerRef = useRef<number | null>(null)
const clearIdleTimer = () => {
if (idleTimerRef.current) window.clearTimeout(idleTimerRef.current)
}
useEffect(() => {
const tile = tileRef.current
if (!tile) return
const armIdleTimer = () => {
clearIdleTimer()
idleTimerRef.current = window.setTimeout(() => {
const clearIdleTimer = () => {
if (idleTimerRef.current) window.clearTimeout(idleTimerRef.current)
idleTimerRef.current = null
}
const handleActivity = () => {
setActive(true)
clearIdleTimer()
idleTimerRef.current = window.setTimeout(
() => setActive(false),
MOUSE_IDLE_TIME
)
}
const handleLeave = () => {
clearIdleTimer()
setActive(false)
}, MOUSE_IDLE_TIME)
}
}
const handleActivity = () => {
setActive(true)
armIdleTimer()
}
tile.addEventListener('mouseenter', handleActivity)
tile.addEventListener('mousemove', handleActivity)
tile.addEventListener('mouseleave', handleLeave)
useEffect(() => clearIdleTimer, [])
return () => {
clearIdleTimer()
tile.removeEventListener('mouseenter', handleActivity)
tile.removeEventListener('mousemove', handleActivity)
tile.removeEventListener('mouseleave', handleLeave)
}
}, [tileRef])
const isVisible = hasKeyboardFocus || active
return (
<div
className={css({
@@ -50,15 +76,10 @@ const FadeOverlay = ({ children, hasKeyboardFocus }: FadeOverlayProps) => {
alignItems: 'center',
width: '100%',
height: '100%',
pointerEvents: 'none',
})}
data-visible={isVisible || undefined}
aria-hidden={!isVisible}
onMouseEnter={handleActivity}
onMouseMove={handleActivity}
onMouseLeave={() => {
clearIdleTimer()
setActive(false)
}}
>
{isVisible && children}
</div>
@@ -67,9 +88,11 @@ const FadeOverlay = ({ children, hasKeyboardFocus }: FadeOverlayProps) => {
export const ParticipantTileFocus = ({
trackRef,
tileRef,
hasKeyboardFocus,
}: {
trackRef: TrackReferenceOrPlaceholder
tileRef: React.RefObject<HTMLDivElement | null>
hasKeyboardFocus: boolean
}) => {
const participant = trackRef.participant
@@ -78,7 +101,7 @@ export const ParticipantTileFocus = ({
const canMute = useCanMute(participant)
return (
<FadeOverlay hasKeyboardFocus={hasKeyboardFocus}>
<FadeOverlay hasKeyboardFocus={hasKeyboardFocus} tileRef={tileRef}>
<div
className={css({
backgroundColor: 'primaryDark.50',
@@ -87,6 +110,7 @@ export const ParticipantTileFocus = ({
display: 'flex',
opacity: 0.6,
animation: 'overlayIn 200ms linear 300ms backwards',
pointerEvents: 'auto',
_hover: {
opacity: 0.95,
},
@@ -94,7 +118,7 @@ export const ParticipantTileFocus = ({
>
<HStack gap={0.5} padding={0.5}>
<FocusButton trackRef={trackRef} />
{!isScreenShare ? (
{!isScreenShare && (
<>
{isLocal ? (
<EffectsButton />
@@ -102,8 +126,6 @@ export const ParticipantTileFocus = ({
canMute && <MuteButton participant={participant} />
)}
</>
) : (
!isLocal && <ZoomButton trackRef={trackRef} />
)}
</HStack>
</div>
@@ -1,32 +0,0 @@
import { TrackReferenceOrPlaceholder } from '@livekit/components-core'
import { useTranslation } from 'react-i18next'
import { useFullScreen } from '@/features/rooms/livekit/hooks/useFullScreen'
import { Button } from '@/primitives'
import { RiFullscreenLine } from '@remixicon/react'
export const ZoomButton = ({
trackRef,
}: {
trackRef: TrackReferenceOrPlaceholder
}) => {
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
const { toggleFullScreen, isFullscreenAvailable } = useFullScreen({
trackRef,
})
if (!isFullscreenAvailable) {
return
}
return (
<Button
size="sm"
variant="primaryTextDark"
square
tooltip={t('fullScreen')}
onPress={() => toggleFullScreen()}
>
<RiFullscreenLine />
</Button>
)
}
@@ -0,0 +1,29 @@
import { useEffect, useState } from 'react'
import { useTracks } from '@livekit/components-react'
import { Track } from 'livekit-client'
import { RecordingMode } from '@/features/recording'
import { useRecordingStatuses } from './useRecordingStatuses'
const STARTING_NOTIFICATION_DELAY = 15_000
export const useRecordingWaitingForTracks = (mode: RecordingMode) => {
const { isStarting } = useRecordingStatuses(mode)
const tracks = useTracks(
mode === RecordingMode.Transcript ? [Track.Source.Microphone] : undefined,
{ onlySubscribed: false }
)
const [delayElapsed, setDelayElapsed] = useState(false)
useEffect(() => {
setDelayElapsed(false)
if (!isStarting) return
const timeout = window.setTimeout(
() => setDelayElapsed(true),
STARTING_NOTIFICATION_DELAY
)
return () => window.clearTimeout(timeout)
}, [isStarting, mode])
return isStarting && delayElapsed && tracks.length === 0
}
+3 -2
View File
@@ -9,8 +9,9 @@ export enum RecordingStatus {
Stopped = 'stopped',
Saved = 'saved',
Aborted = 'aborted',
FailedToStart = 'failedToStart',
FailedToStop = 'failedToStop',
Failed = 'failed',
FailedToStart = 'failed_to_start',
FailedToStop = 'failed_to_stop',
NotificationSucceed = 'notification_succeeded',
ExternalProcessSuccessful = 'external_process_successful',
ExternalProcessFailed = 'external_process_failed',
@@ -12,6 +12,8 @@ export enum ApiLobbyStatus {
DENIED = 'denied',
TIMEOUT = 'timeout',
ACCEPTED = 'accepted',
// Client-side only: the room was deleted while waiting
DELETED = 'deleted',
}
export interface ApiRequestEntry {
@@ -85,6 +85,7 @@ export const Conference = ({
const {
status: fetchStatus,
isError: isFetchError,
error: fetchError,
data,
} = useQuery({
queryKey: fetchKey,
@@ -98,6 +99,8 @@ export const Conference = ({
if (error.statusCode == '404') {
createRoom({ slug: roomId, username })
}
// A deleted room can't be recreated, surface the error instead
if (error.statusCode == '410') throw error
}),
retry: false,
})
@@ -198,6 +201,15 @@ export const Conference = ({
}, [apiConfig?.livekit])
const { t } = useTranslation('rooms')
if (fetchError?.statusCode == 410) {
return (
<ErrorScreen
title={t('error.deletedRoom.heading')}
body={t('error.deletedRoom.body')}
/>
)
}
if (isCreateError) {
// this error screen should be replaced by a proper waiting room for anonymous user.
return (
@@ -282,6 +294,7 @@ export const Conference = ({
return
case DisconnectReason.DUPLICATE_IDENTITY:
case DisconnectReason.PARTICIPANT_REMOVED:
case DisconnectReason.ROOM_DELETED:
navigateTo(
'feedback',
{},
@@ -74,7 +74,9 @@ export const Lobby = ({
const { openLoginHint } = useLoginHint()
const handleSubmit = async () => {
const { data } = await refetchRoom()
const { data, error } = await refetchRoom()
if (error?.statusCode == 410) return
if (!data?.livekit) {
// Display a message to inform the user that by logging in, they won't have to wait for room entry approval.
@@ -88,6 +90,22 @@ export const Lobby = ({
enterRoom()
}
if (
status === ApiLobbyStatus.DELETED ||
(isError && error?.statusCode == 410)
) {
return (
<VStack alignItems="center" textAlign="center">
<H lvl={1} margin={false} centered>
{t('deleted.title')}
</H>
<Text as="p" variant="note">
{t('deleted.body')}
</Text>
</VStack>
)
}
switch (status) {
case ApiLobbyStatus.TIMEOUT:
return (
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { keys } from '@/api/queryKeys'
import { ApiError } from '@/api/ApiError'
import {
requestEntry,
ApiLobbyStatus,
@@ -39,10 +40,21 @@ export const useLobby = ({
const { data: waitingData } = useQuery({
queryKey: [keys.requestEntry, roomId],
queryFn: async () => {
const response = await requestEntry({
roomId,
username,
})
let response: ApiRequestEntry
try {
response = await requestEntry({
roomId,
username,
})
} catch (error) {
// The room was deleted while waiting, stop polling
if (error instanceof ApiError && error.statusCode === 410) {
clearWaitingTimeout()
setStatus(ApiLobbyStatus.DELETED)
return { status: ApiLobbyStatus.DELETED }
}
throw error
}
if (response.status === ApiLobbyStatus.ACCEPTED) {
clearWaitingTimeout()
setStatus(ApiLobbyStatus.ACCEPTED)
@@ -0,0 +1,76 @@
import { Button } from '@/primitives'
import { RiCollapseDiagonalLine, RiExpandDiagonalLine } from '@remixicon/react'
import { memo, useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
// Keeps the fullscreen state here rather than on the toolbar, so entering or
// leaving fullscreen does not re-render the zoom controls.
export const ScreenShareFullscreenButton = memo(
({
containerRef,
}: {
containerRef: React.RefObject<HTMLDivElement | null>
}) => {
const { t } = useTranslation('rooms', { keyPrefix: 'screenShareZoom' })
const announce = useScreenReaderAnnounce()
const [isFullscreen, setIsFullscreen] = useState(false)
// Tracks whether this tile's container triggered fullscreen (vs another share's).
const wasThisTileFullscreen = useRef(false)
// Covers Esc and browser UI exits, not just this button.
// Only this tile's instance announces to avoid duplicates with multiple shares.
useEffect(() => {
const onChange = () => {
const isThisTileFullscreen =
document.fullscreenElement === containerRef.current
setIsFullscreen(isThisTileFullscreen)
if (isThisTileFullscreen) {
wasThisTileFullscreen.current = true
announce(t('fullScreenEntered'), 'assertive')
} else if (wasThisTileFullscreen.current) {
wasThisTileFullscreen.current = false
announce(t('fullScreenExited'), 'assertive')
}
}
document.addEventListener('fullscreenchange', onChange)
return () => document.removeEventListener('fullscreenchange', onChange)
}, [announce, t, containerRef])
const toggleFullScreen = useCallback(async () => {
try {
if (document.fullscreenElement === containerRef.current) {
await document.exitFullscreen()
} else {
// Tile container so zoom controls stay visible in fullscreen.
await containerRef.current?.requestFullscreen()
}
} catch (error) {
console.error('Error toggling fullscreen:', error)
}
}, [containerRef])
if (!document.fullscreenEnabled) return null
return (
<Button
size="sm"
variant="primaryTextDark"
square
tooltip={isFullscreen ? t('exitFullScreen') : t('fullScreen')}
aria-label={isFullscreen ? t('exitFullScreen') : t('fullScreen')}
onPress={toggleFullScreen}
>
{isFullscreen ? (
<RiCollapseDiagonalLine size={20} />
) : (
<RiExpandDiagonalLine size={20} />
)}
</Button>
)
}
)
ScreenShareFullscreenButton.displayName = 'ScreenShareFullscreenButton'
@@ -0,0 +1,165 @@
import { css } from '@/styled-system/css'
import { Button } from '@/primitives'
import {
RiFullscreenExitLine,
RiZoomInLine,
RiZoomOutLine,
} from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { Toolbar } from 'react-aria-components'
import { useEffect, useRef } from 'react'
import { isMacintosh } from '@/utils/livekit'
import { srOnly } from '@/styles/a11y'
import { ScreenShareFullscreenButton } from './ScreenShareFullscreenButton'
interface ScreenShareZoomControlsProps {
containerRef: React.RefObject<HTMLDivElement | null>
isZoomed: boolean
zoomPercentage: number
canZoomIn: boolean
canZoomOut: boolean
onZoomIn: () => void
onZoomOut: () => void
onResetZoom: () => void
}
export const ScreenShareZoomControls = ({
containerRef,
isZoomed,
zoomPercentage,
canZoomIn,
canZoomOut,
onZoomIn,
onZoomOut,
onResetZoom,
}: ScreenShareZoomControlsProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'screenShareZoom' })
const zoomInButtonRef = useRef<HTMLButtonElement>(null)
const hadFocusInCollapsibleRef = useRef(false)
// Back at 100 % the collapsible controls are disabled and hidden, which drops
// keyboard focus on the body. Hand it to the zoom in button instead, the only
// control of that group still reachable.
useEffect(() => {
if (isZoomed || !hadFocusInCollapsibleRef.current) return
hadFocusInCollapsibleRef.current = false
zoomInButtonRef.current?.focus()
}, [isZoomed])
const wheelShortcut = t(isMacintosh() ? 'wheelShortcutMac' : 'wheelShortcut')
return (
<div
className={css({
position: 'absolute',
bottom: '12px',
right: '12px',
zIndex: 2,
pointerEvents: 'auto',
})}
>
<Toolbar
aria-label={t('toolbarLabel')}
className={css({
display: 'flex',
alignItems: 'center',
backgroundColor: 'primaryDark.50',
borderRadius: '2rem',
padding: '0.5rem',
opacity: 0.7,
transition: 'opacity 200ms linear',
_hover: {
opacity: 0.95,
},
})}
>
<span className={srOnly}>
{t(isMacintosh() ? 'wheelShortcutHintMac' : 'wheelShortcutHint')}
</span>
{/* Animated wrapper: collapses to 0 when not zoomed. padding/margin
trick keeps overflow:hidden from clipping focus rings. */}
<div
className={css({
display: 'flex',
alignItems: 'center',
overflow: 'hidden',
transition: 'max-width 200ms ease-out, opacity 200ms ease-out',
padding: '3px',
margin: '-3px',
})}
style={{
maxWidth: isZoomed ? '12rem' : '0',
opacity: isZoomed ? 1 : 0,
}}
aria-hidden={!isZoomed}
onFocus={() => {
hadFocusInCollapsibleRef.current = true
}}
onBlur={(e) => {
// Disabling a focused button blurs it with no relatedTarget, so the
// flag must survive that case for the effect above to rescue focus.
if (e.relatedTarget) hadFocusInCollapsibleRef.current = false
}}
>
<Button
size="sm"
variant="primaryTextDark"
square
tooltip={t('fitToWindow')}
aria-label={t('fitToWindow')}
isDisabled={!isZoomed}
onPress={onResetZoom}
>
<RiFullscreenExitLine size={20} />
</Button>
<Button
size="sm"
variant="primaryTextDark"
square
tooltip={t('zoomOutWithShortcut', {
shortcut: wheelShortcut,
})}
aria-label={t('zoomOut')}
isDisabled={!isZoomed || !canZoomOut}
onPress={onZoomOut}
>
<RiZoomOutLine size={20} />
</Button>
{/* Visual only - zoom level is announced via useScreenReaderAnnounce. */}
<span
aria-hidden="true"
className={css({
color: 'white',
fontSize: '0.8125rem',
fontWeight: 500,
minWidth: '3.25rem',
textAlign: 'center',
userSelect: 'none',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '0 0.25rem',
whiteSpace: 'nowrap',
})}
>
{zoomPercentage} %
</span>
</div>
<Button
ref={zoomInButtonRef}
size="sm"
variant="primaryTextDark"
square
tooltip={t('zoomInWithShortcut', { shortcut: wheelShortcut })}
aria-label={t('zoomIn')}
isDisabled={!canZoomIn}
onPress={onZoomIn}
>
<RiZoomInLine size={20} />
</Button>
<ScreenShareFullscreenButton containerRef={containerRef} />
</Toolbar>
</div>
)
}

Some files were not shown because too many files have changed in this diff Show More