Compare commits

...

107 Commits

Author SHA1 Message Date
leo 765625c6f0 wip: replace created_at by started_at. may want to revert 2026-03-26 00:30:12 +01:00
leo e64b2901f9 Changed diarization (dict) to Transcription object 2026-03-25 17:49:23 +01:00
leo 7dc7da9109 plug user assign + replace recording_date, recording_time by recording_datetime 2026-03-25 17:14:52 +01:00
leo 1889fe6505 Add user assignment 2026-03-25 15:20:14 +01:00
leo bf54feac9a fix Dockerfile 2026-03-25 14:29:36 +01:00
leo f08ae06c5c fix lint-agents install 2026-03-25 14:23:04 +01:00
leo d05a4da470 fix tests for linting 2026-03-25 14:16:07 +01:00
leo b16062dcc7 fix linting 2026-03-25 11:55:57 +01:00
leo 665cb0edde fix conditions for transcription 2026-03-25 11:46:15 +01:00
leo 12851d940e fix frontend transcribe flag 2026-03-25 11:46:15 +01:00
leo f82f9dacd6 fix test 2026-03-25 11:46:15 +01:00
leo 96bd8cc06e enable metadata collection by default 2026-03-25 11:46:15 +01:00
leo 8261b8cff8 add tests 2026-03-25 11:46:15 +01:00
leo 18d77bc491 fix bug 2026-03-25 11:46:15 +01:00
leo 7c1624cfaa modify metadata recording conditions 2026-03-25 11:46:15 +01:00
leo 1b02bfcc63 changes to Dockerfile and compose 2026-03-25 11:46:15 +01:00
leo b350ff5650 fix bug 2026-03-25 11:46:15 +01:00
leo 399ea0f2d0 update Dockerfile and compose 2026-03-25 11:46:12 +01:00
leo d894215f48 fix async bug 2026-03-25 11:44:52 +01:00
leo 9914a87387 update docstrings 2026-03-25 11:44:52 +01:00
leo a671054186 add exception for missing conf 2026-03-25 11:44:52 +01:00
leo fef4745c77 add Recording type to variables 2026-03-25 11:44:52 +01:00
leo ca8220aa1a store dispatch id instead of search 2026-03-25 11:44:52 +01:00
leo eb3923cde3 consistently name metadata collector 2026-03-25 11:44:52 +01:00
leo c274c005ce consistently name metadata collector 2026-03-25 11:44:52 +01:00
leo 5a0344ff08 clean up 2026-03-25 11:44:52 +01:00
lebaudantoine 74cf4bf538 wip align env variable in metadata agent 2026-03-25 11:44:52 +01:00
lebaudantoine 78124dd589 wip create a temporary service to trigger the metadata agent 2026-03-25 11:44:52 +01:00
lebaudantoine a67daf1b58 wip persist metadata in the right folder with the right name 2026-03-25 11:44:52 +01:00
lebaudantoine 2aaa9b6999 wip lint the metadata agent 2026-03-25 11:44:52 +01:00
lebaudantoine 62dd62235f wip start when requested the metadata agent 2026-03-25 11:44:52 +01:00
lebaudantoine 37eabc6d8b wip create timestamp on utc timezone 2026-03-25 11:44:52 +01:00
lebaudantoine a4d741c3a8 wip rename to_dict to serialize 2026-03-25 11:44:52 +01:00
lebaudantoine cbf7842cc5 wip track chat message 2026-03-25 11:44:52 +01:00
lebaudantoine 9c5b60a19e wip listen to participant rename event 2026-03-25 11:44:52 +01:00
lebaudantoine 4d682f9715 wip rename participants_seen 2026-03-25 11:44:52 +01:00
lebaudantoine 5019ed5c9e wip temporary deploy the metadata agetn 2026-03-25 11:44:52 +01:00
lebaudantoine 7e41d54a3e wip introduce metadata agent 2026-03-25 11:44:40 +01:00
lebaudantoine d15e09997f wip align script naming 2026-03-25 11:43:59 +01:00
lebaudantoine 108db2e3e5 📌(backend) pin brevo-python to v2.x.x. in renovate
The SDK introduced breaking changes in newer versions. Lock the
dependency to v2.x.x to maintain compatibility.
2026-03-25 11:18:58 +01:00
renovate[bot] 73496406e8 ⬆️(dependencies) update python dependencies 2026-03-25 11:18:57 +01:00
lebaudantoine c5c96369c8 🔊(backend) remove email addresses from invitation failure logs
Email addresses are PII and should not appear in technical or
error logs.

Sanitize logging to avoid exposing sensitive user data when
invitation sending fails.
2026-03-25 09:52:43 +01:00
lebaudantoine e9f90e95b1 🔒️(backend) fix email disclosure in room invitation endpoint
Prevent invited participants from seeing each other's email
addresses when sending room invitations.

Ensure invitations are sent with proper isolation to avoid
mass PII disclosure.

This mitigates risks of email harvesting, spam, and phishing
through the platform.
2026-03-25 09:52:43 +01:00
lebaudantoine f57fbf2d35 🔖(minor) bump release to 1.12.0 2026-03-24 23:37:41 +01:00
Martin Weinelt 920f4558fc 🐛(backend): fix module inclusion with uv-build
After migrating to uv-build only the module matching the project name was
included in sdist/wheel packages. Without a src layout additional modules
need to be tracked manually to ship them in built packages.
2026-03-24 16:14:31 +01:00
lebaudantoine 9df901b9d6 📝(backend) clarify trailing slash requirement in API Swagger doc
Specify that POST routes require a trailing slash to avoid
confusion and incorrect usage from API consumers.
2026-03-24 15:31:58 +01:00
dependabot[bot] c09c440631 ⬆️️️(frontend) bump dompurify from 3.3.1 to 3.3.2 in /src/frontend
Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.3.1 to 3.3.2.
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.3.1...3.3.2)

---
updated-dependencies:
- dependency-name: dompurify
  dependency-version: 3.3.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-23 18:23:18 +01:00
dependabot[bot] cd7ce77074 ⬆️️️(frontend) bump hono from 4.12.2 to 4.12.7 in /src/frontend
Bumps [hono](https://github.com/honojs/hono) from 4.12.2 to 4.12.7.
- [Release notes](https://github.com/honojs/hono/releases)
- [Commits](https://github.com/honojs/hono/compare/v4.12.2...v4.12.7)

---
updated-dependencies:
- dependency-name: hono
  dependency-version: 4.12.7
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-23 18:08:38 +01:00
dependabot[bot] 6d3c26419d ⬆️️️(frontend) bump undici from 6.23.0 to 6.24.1 in /src/frontend
Bumps [undici](https://github.com/nodejs/undici) from 6.23.0 to 6.24.1.
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v6.23.0...v6.24.1)

---
updated-dependencies:
- dependency-name: undici
  dependency-version: 6.24.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-23 17:59:51 +01:00
dependabot[bot] 9dbc38984e ⬆️ Bump flatted from 3.3.1 to 3.4.2 in /src/frontend
Bumps [flatted](https://github.com/WebReflection/flatted) from 3.3.1 to 3.4.2.
- [Commits](https://github.com/WebReflection/flatted/compare/v3.3.1...v3.4.2)

---
updated-dependencies:
- dependency-name: flatted
  dependency-version: 3.4.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-23 17:50:57 +01:00
Michel-Marie MAUDET 1bd5a294e4 🐛(frontend) fix device selection not applying during conference
Await was missing, leading to inconsistent behavior between select component.
Michel-Marie fixed it.
2026-03-23 16:46:52 +01:00
Michel-Marie MAUDET 4d98ed4977 ♻️(backend) make SESSION_ENGINE configurable via environment variable
Make SESSION_ENGINE configurable through environment variable,
following the same values.Value() pattern already used for
SESSION_COOKIE_AGE. This enables OIDC backchannel-logout by
allowing users to set SESSION_ENGINE to db backend.

Closes #1037
2026-03-23 13:39:36 +01:00
Cyril bf32c073c6 💄(frontend) show OS-specific shortcut in participant tile hint
Participant tile hint uses formatShortcutLabel so Mac users see ⌘ not Ctrl.
2026-03-23 09:13:53 +01:00
lebaudantoine 4f2c4bfaf9 ️(summary) improve accessibility of transcription download link
Update the link label to use explicit text "Download your recording"
instead of generic "following this link."

This ensures blind users understand the purpose of the link
and the behavior of opening a new window.
2026-03-20 10:17:48 +01:00
lebaudantoine dacf705329 🍱(frontend) update logo to the latest version
Replace the previous logo with the most up-to-date brand
assets to ensure consistency across the application and emails.
2026-03-20 09:54:12 +01:00
lebaudantoine 8296738347 ️(backend) improve logo access in screen recording email notification
Update the logo alternative text to include the brand name instead
of a generic "logo image" description.

Use a more descriptive value such as "Logo LaSuite Meet"
to better convey the content to screen reader users.

It closes #1092
2026-03-20 09:54:08 +01:00
Florent Chehab 04be495351 💄(custom-background) add upload indicator with preview
When uploading an image, depending on the available network
there might be a bit of wait while the image is being uploaded.
In this commit we add a preview (grayscale + spinner) to have
UI feedback that the upload is in progress.
2026-03-19 17:27:51 +01:00
Florent Chehab 43185605eb 💄(spinner) enforce spinner height
For some reason the ProgressBar adds a bit of height to the spinner
which makes it hard to center.
2026-03-19 17:27:21 +01:00
Florent Chehab cf3fb208e2 🐛(frontend) auto-select new custom background when not logged in
When not logged in and selecting a new custom personal background
the constant id was causing the new custom background not to be auto
selected.
2026-03-19 17:27:21 +01:00
Florent Chehab 4ca230eb12 🐛(frontend) disable personal custom background while deleting
Prevent users selecting a personal custom background while deleting one.
2026-03-19 17:27:20 +01:00
lebaudantoine 4b5e0cb2a3 ️(frontend) improve button descriptions for More tools actions #1184
The "Transcribe" and "Record" buttons had unclear and misleading
descriptions, both using the verb "record," which caused confusion,
especially for screen reader users.

Update descriptions to clearly reflect each action:
- Transcribe: generate a written transcript of the conversation
- Record: save the meeting as a video

This improves accessibility (RGAA 11.9) and reduces the risk of
users triggering the wrong action.

Closes #1173
2026-03-19 14:48:02 +01:00
lebaudantoine 45f374610f ️(frontend) fix more tools heading hierarchy
Side panel title is an H1, but the hierarchy skips directly to H3.
Fix the heading structure. It closes #1178.
2026-03-19 11:56:16 +01:00
lebaudantoine b419a2bfd2 ️(frontend) fix sidepanel accessibility aria-label
The aria-label only announced the presence of a sidepanel without
including its title.

Append the sidepanel title to improve accessibility and context
for screen readers.

Closes #1176.
2026-03-19 11:21:40 +01:00
lebaudantoine ee8d96bee7 🔖(minor) bump release to 1.11.0 2026-03-19 00:31:32 +01:00
lebaudantoine c65ff2d75d 🩹(frontend) disable subtitle settings when feature is unavailable
Hide or disable settings related to the subtitle feature when
the feature flag is not enabled.
2026-03-18 23:25:20 +01:00
Cyril ea1c90d8ca (feat) add default color option for captions
Add "Default" option (white text on black background) for font and background.
2026-03-18 20:44:25 +01:00
Cyril 3e963e3e6d ♻️(refactor) apply caption color customization to subtitles
Use captionFontColor and captionBackgroundColor in Transcription component.
2026-03-18 20:44:25 +01:00
Cyril 82769128a1 (feat) add caption font color and background settings
Select font color and background color in Accessibility > Captions.
2026-03-18 20:44:25 +01:00
Cyril 05f4ce6b2e 🌐(i18n) add captions color settings translations
EN, FR, DE, NL for Accessibility > Captions > Font color / Background color.
2026-03-18 20:44:25 +01:00
Cyril 508984ecfa ️(frontend) add caption font color and background to access store
Persist captionFontColor and captionBackgroundColor in user preferences.
2026-03-18 20:44:25 +01:00
lebaudantoine a05507f73d 👷(ci) run summary test suite
Configure the CI to run the summary tests' suite.
2026-03-18 20:19:52 +01:00
lebaudantoine a27def119c (summary) add tests for the task endpoint
Ensure the API correctly validates and accepts the parameters
required to register a task in the queue.
2026-03-18 20:19:52 +01:00
lebaudantoine fce94f38ce 🧑‍💻(summary) add make command to run summary test suite
Introduce a dedicated make target to execute tests for the
summary component of the stack.
2026-03-18 20:19:52 +01:00
lebaudantoine 83bd9c5ce3 (summary) add minimal test suite for health endpoint
Based on cameldev's work, introduce basic tests to ensure the
health endpoint responds correctly.
2026-03-18 20:19:52 +01:00
Florent Chehab bfbfade99a 🐛(frontend) prevent black background image
Prevent showing a black background when the image is not
accessible anymore.

This happens after the user logs out or logs in.
Or the auth is revoked.
2026-03-18 18:49:59 +01:00
Florent Chehab 16daf7b8d3 (frontend) add custom virtual background feature
Add a custom virtual background feature.

If the backend supports uploading files, backgrounds are stored
in the backend for the user.
Otherwise, only one background image can be selected.
2026-03-18 18:49:59 +01:00
lebaudantoine fcad79d662 📌(agents) unpin OpenSSL and related dependencies
The base image now includes OpenSSL 3.5.5, which resolves
CVE-2025-15467.

Remove explicit pinning of OpenSSL and its dependencies.
2026-03-18 11:34:44 +01:00
lebaudantoine 2424ce17ec 👷(ci) re-enable Trivy scan in CI for image builds
Restore Trivy scanning jobs for built images after the situation
was clarified, ensuring vulnerability checks are active again.
2026-03-18 10:26:23 +01:00
camillem 4715905334 docs: fix typo and markdown 2026-03-18 10:24:09 +01:00
renovate[bot] 7c05aedcfe ⬆️(dependencies) update PyJWT to v2.12.0 [SECURITY] 2026-03-17 18:33:39 +01:00
lebaudantoine 7347fc7c86 🚨(doc) fix changelog linting
Merge an outside contribution, didn't notice it broke the changelog fix it.
2026-03-17 16:45:04 +01:00
Hadrien Blanc ada7d9a666 🐛(frontend) fix dimension mismatch in BackgroundCustomProcessor
The getImageData call was using PROCESSING_WIDTH for both dimensions
instead of PROCESSING_WIDTH and PROCESSING_HEIGHT. This caused the
source image data to be 256x256 instead of the expected 256x144, leading
to buffer overflow when writing to the segmentation mask and potential
visual artifacts in Firefox background effects.
2026-03-17 16:41:40 +01:00
lebaudantoine 0cb3fb8e3c 🔧(ci) explicitly set Docker Hub CI permissions to read-only
Define read-only permissions in the workflow to clarify the
expected access level and follow the principle of least
privilege.
2026-03-15 16:53:07 +01:00
lebaudantoine dcb788b57b 🔒️(backend) avoid information exposure through exception messages
Sanitize error handling to prevent leaking internal details when
invalid or malicious requests are sent to the API.

Return generic error responses to reduce the risk of information
disclosure during probing attempts.
2026-03-13 17:33:55 +01:00
lebaudantoine 73bcb9d598 🐛(backend) fix unescaped dot in regex pattern
The dot before (?P<extension>...) was not escaped and matched any
character instead of a literal period.

Escape it to align with MEDIA_STORAGE_URL_PATTERN, which correctly
uses \. for the file extension separator.
2026-03-13 16:19:36 +01:00
lebaudantoine f3e90c3999 ♻️(backend) clarify storage hook message for intentionally ignored req
Update the response message to explicitly state that certain
notifications are ignored on purpose, avoiding confusion
during debugging or log inspection.
2026-03-13 15:42:30 +01:00
lebaudantoine cb4ed3c9d7 🩹(backend) ignore non-recording uploads in storage webhook handler
With the introduction of background file uploads, a misconfigured
MinIO webhook could trigger the storage hook endpoint for unrelated
files.

While the dev setup now filters events via the MinIO lifecycle
configuration, add a safeguard at the application level.

Enforce a stricter filepath regex when parsing storage hook events
and ignore files outside the recording output directory.

Return a clean 200 response to acknowledge the webhook while
avoiding unnecessary processing.
2026-03-13 15:34:02 +01:00
lebaudantoine f8b0746e73 ♻️(backend) factorize regex used in the S3 event parser
Avoid duplicating patterns that match the same logical unit by
sharing common parts between the two regexes.

This logical factorization improves maintainability and ensures
consistent parsing behavior across S3 event handlers.
2026-03-13 15:34:02 +01:00
lebaudantoine d8ad7a743e ♻️(backend) align recording file regex with MEDIA_URL-based pattern
Initially I avoided coupling the recording path regex with the
setting that provides the Django static URL. However the new
file viewset already relies on it, and Drive does as well.

For consistency, update the recording regex to use the same
STATIC_URL-based approach.

While the coupling may not feel ideal, having two different
regex strategies for similar file paths would be worse.
2026-03-13 15:34:02 +01:00
lebaudantoine 0ba445895c ♻️(backend) merge room name regex and centralize patterns in enums
Combine the regex used in Drive with the one from the recording
feature and centralize them in the enums module.

Although the module name suggests only enums, it also hosts
shared constants used across the codebase.
2026-03-13 15:34:02 +01:00
Florent Chehab 3b719ab9ba (settings) disable file upload by default & max count
Add FILE_UPLOAD_ENABLED setting (default to False, to avoid
a breaking change). Also adds a max_count_by_user sub setting
to restrict the number of uploaded files per user.
2026-03-12 14:55:33 +01:00
Florent Chehab 8887e811d3 🔨(tilt) improve tilt stack stability
* Increase the backoff limit on jobs
* Add redis dependency for livekit
2026-03-12 14:41:58 +01:00
Florent Chehab cb9e994749 ️(helm) reduce initialDelaySeconds and add periods seconds
InitialDelaySeconds was set to 30s which caused the tilt stack
startup to be very slow. In this commit I split the initial delay
and the period arg.
5s seems to be relevant given the nature of the app.
2026-03-12 14:41:57 +01:00
Florent Chehab 1841533d2c 🔨(makefile) add default to meet namespace
Avoids resources being deployed in the wrong namespace
depending on user config.
2026-03-12 14:41:40 +01:00
lebaudantoine 7da99f2116 🔐(backend) avoids revealing the inactive status of an application
Authenticate the application secret before checking whether the
application is inactive.

This avoids revealing the inactive status of an application when
an incorrect secret is provided, preventing an authentication
state oracle and client_id enumeration.
2026-03-12 13:40:11 +01:00
lebaudantoine b0af5e7f35 🧪(backend) add failing test for client_id enumeration issue
Currently the inactive status is revealed before verifying the
secret, creating an authentication state oracle.

Introduce a failing test to capture the issue before
applying the fix.
2026-03-12 13:39:44 +01:00
lebaudantoine dd6bb0ed3e 🔧(backend) trigger webhook only for recording file uploads
With the introduction of file background uploads, only trigger the
webhook for files related to recordings.

Avoid firing the "recording saved" event for other file uploads,
preventing unnecessary queries and false triggers.
2026-03-12 13:33:39 +01:00
lebaudantoine 1306f0bcfe (backend) expose is_active field for Application in Django admin
Add minimal admin support to allow administrators to mark an
application account as inactive if it becomes rogue or compromised.

This capability was missing when the application concept was
initially introduced in the backend.
2026-03-12 13:27:08 +01:00
lebaudantoine ca4494c09e ♻️(backend) align Application model field with is_active convention
Most models in the project use `is_active` rather than `active`.
The Application model was not aligned with this convention.

Rename the field and add a migration to standardize the naming.
Update related tests accordingly.

This change should not introduce breaking changes for external
applications.
2026-03-12 13:26:37 +01:00
lebaudantoine f57db0acc8 ♻️(backend) refactor uploaded file's key format
Remove the user-provided filename while preserving the extension,
and switch to the format `uuid.extension`.

This is more natural than the previous `uuid/.extension` format
and avoids confusion.

Update related tests accordingly.
2026-03-12 10:39:59 +01:00
Florent Chehab 342f992556 🔨(python-env) migrate meet main app to UV
Migrate main meet app to use UV for dependancy management.
Also optimized the backend image build sequence for faster rebuilds
when dependencies don't change.
Also removed compiled django translations files are they are done in the build
process now.

Changes inspired by drive repo.
2026-03-12 10:25:35 +01:00
lebaudantoine d0cf3974ad 🚸(frontend) allow downloading recordings with "failed to stop" status
When support manually marks a recording as "failed to stop," enable
users to download the recording from the frontend.

This ensures users can recover their recordings even if the
automatic stop process fails.
2026-03-11 19:41:45 +01:00
lebaudantoine 0f3eb35c83 🩹(backend) add Django admin action to update recording status manually
Making the status read-only improved security and enforced least
privilege, but in production some recordings could not be properly
stopped, leaving them in an active state.

Introduce an admin action to allow support staff to mark recordings
as "failed to stop" or update their status when necessary.
2026-03-11 19:41:45 +01:00
lebaudantoine d15fb37932 📝(doc) fix changelog formatting
Wrap lines that were excessively long to improve readability.
2026-03-11 19:19:00 +01:00
lebaudantoine 051f33bb1e (backend) add tests for request-entry throttling in the lobby system
Cover throttling behavior for both authenticated users and
anonymous participants identified via the lobby cookie.
2026-03-11 18:07:39 +01:00
lebaudantoine 566eacc8fe (backend) add authenticated user rate throttling on request-entry
Throttle the request-entry endpoint for authenticated users also
to guard against accidental hammering from buggy clients.

Authenticated users are throttled via RequestEntryUserRateThrottle.
Anonymous users are throttled using the lobby participant cookie
through RequestEntryAnonRateThrottle.
2026-03-11 18:07:39 +01:00
lebaudantoine d19023a1ba 🐛(backend) refactor lobby throttling to use participant id instead of IP
use the lobby participant cookie ID as the throttle cache key
rather than the client IP address.

This avoids penalising multiple users behind the same NAT or proxy
and aligns throttling with how LobbyService identifies participants.

This bug was spotted in production where users from ministries behind
NAT was blocked by throttling while using visio.

If no cookie is present yet, skip throttling for the request. The
cookie will be set on the first response and throttling will apply
from subsequent requests.

This throttle is intended to protect against accidental hammering
from buggy clients, not as a security control against DoS attacks.

This is not a security measure, we should use a WAF.
2026-03-11 18:07:39 +01:00
132 changed files with 6887 additions and 1165 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ __pycache__
**/__pycache__
**/*.pyc
venv
.venv
**/.venv
# System-specific files
.DS_Store
+48 -33
View File
@@ -12,6 +12,9 @@ on:
branches:
- 'main'
permissions:
contents: read
env:
DOCKER_USER: 1001:127
DOCKER_CONTAINER_REGISTRY_HOSTNAME: docker.io
@@ -20,6 +23,8 @@ env:
jobs:
build-and-push-backend:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
-
name: Checkout repository
@@ -43,12 +48,12 @@ jobs:
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
# -
# name: Run trivy scan
# uses: numerique-gouv/action-trivy-cache@main
# with:
# docker-build-args: '--target backend-production -f Dockerfile'
# docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend:${{ github.sha }}'
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '--target backend-production -f Dockerfile'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend:${{ github.sha }}'
-
name: Build and push
uses: docker/build-push-action@v6
@@ -63,6 +68,8 @@ jobs:
build-and-push-frontend-generic:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
-
name: Checkout repository
@@ -86,12 +93,12 @@ jobs:
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
# -
# name: Run trivy scan
# uses: numerique-gouv/action-trivy-cache@main
# with:
# docker-build-args: '-f src/frontend/Dockerfile --target frontend-production'
# docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend:${{ github.sha }}'
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '-f src/frontend/Dockerfile --target frontend-production'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend:${{ github.sha }}'
-
name: Build and push
uses: docker/build-push-action@v6
@@ -107,6 +114,8 @@ jobs:
build-and-push-frontend-dinum:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
-
name: Checkout repository
@@ -130,12 +139,12 @@ jobs:
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
# -
# name: Run trivy scan
# uses: numerique-gouv/action-trivy-cache@main
# with:
# docker-build-args: '-f docker/dinum-frontend/Dockerfile --target frontend-production'
# docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend-dinum:${{ github.sha }}'
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '-f docker/dinum-frontend/Dockerfile --target frontend-production'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend-dinum:${{ github.sha }}'
-
name: Build and push
uses: docker/build-push-action@v6
@@ -151,6 +160,8 @@ jobs:
build-and-push-summary:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
-
name: Checkout repository
@@ -174,13 +185,13 @@ jobs:
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
# -
# name: Run trivy scan
# uses: numerique-gouv/action-trivy-cache@main
# continue-on-error: true
# with:
# docker-build-args: '-f src/summary/Dockerfile --target production'
# docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-summary:${{ github.sha }}'
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
continue-on-error: true
with:
docker-build-args: '-f src/summary/Dockerfile --target production'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-summary:${{ github.sha }}'
docker-context: './src/summary'
-
name: Build and push
@@ -197,6 +208,8 @@ jobs:
build-and-push-agents:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
-
name: Checkout repository
@@ -220,14 +233,14 @@ jobs:
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
# -
# name: Run trivy scan
# uses: numerique-gouv/action-trivy-cache@main
# continue-on-error: true
# with:
# docker-build-args: '-f src/agents/Dockerfile --target production'
# docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-agents:${{ github.sha }}'
# docker-context: './src/agents'
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
continue-on-error: true
with:
docker-build-args: '-f src/agents/Dockerfile --target production'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-agents:${{ github.sha }}'
docker-context: './src/agents'
-
name: Build and push
uses: docker/build-push-action@v6
@@ -242,6 +255,8 @@ jobs:
labels: ${{ steps.meta.outputs.labels }}
notify-argocd:
permissions:
contents: read
needs:
- build-and-push-frontend-generic
- build-and-push-frontend-dinum
+54 -12
View File
@@ -124,15 +124,17 @@ jobs:
uses: actions/setup-python@v6
with:
python-version: "3.13"
cache: "pip"
- name: Install development dependencies
run: pip install --user .[dev]
- name: Install uv
uses: astral-sh/setup-uv@v7
- name: Install the project
run: uv sync --locked --all-extras
- name: Check code formatting with ruff
run: ~/.local/bin/ruff format . --diff
run: uv run ruff format . --diff
- name: Lint code with ruff
run: ~/.local/bin/ruff check .
run: uv run ruff check .
- name: Lint code with pylint
run: ~/.local/bin/pylint meet demo core
run: uv run pylint meet demo core
lint-agents:
runs-on: ubuntu-latest
@@ -279,10 +281,10 @@ jobs:
uses: actions/setup-python@v6
with:
python-version: "3.13"
cache: "pip"
- name: Install development dependencies
run: pip install --user .[dev]
- name: Install uv
uses: astral-sh/setup-uv@v7
- name: Install the dependencies
run: uv sync --locked --all-extras
- name: Install gettext (required to compile messages)
run: |
@@ -290,10 +292,50 @@ jobs:
sudo apt-get install -y gettext
- name: Generate a MO file from strings extracted from the project
run: python manage.py compilemessages
run: uv run python manage.py compilemessages
- name: Run tests
run: ~/.local/bin/pytest -n 2
run: uv run pytest -n 2
test-summary:
runs-on: ubuntu-latest
permissions:
contents: read
defaults:
run:
working-directory: src/summary
env:
APP_API_TOKEN: "test-api-token"
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"
WHISPERX_BASE_URL: "https://configure-your-url.com"
WHISPERX_ASR_MODEL: "large-v2"
WHISPERX_API_KEY: "test-whisperx-secret"
WHISPERX_DEFAULT_LANGUAGE: "fr"
LLM_BASE_URL: "https://configure-your-url.com"
LLM_API_KEY: "test-llm-secret"
LLM_MODEL: "test-llm-model"
WEBHOOK_API_TOKEN: "test-webhook-secret"
WEBHOOK_URL: "https://configure-your-url.com"
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Install Python
uses: actions/setup-python@v6
with:
python-version: "3.13"
cache: "pip"
- name: Install development dependencies
run: pip install --user .[dev]
- name: Run summary tests
run: ~/.local/bin/pytest
lint-front:
runs-on: ubuntu-latest
+1
View File
@@ -31,6 +31,7 @@ MANIFEST
# Translations # Translations
*.pot
*.mo
# Environments
.env
+51 -2
View File
@@ -8,24 +8,73 @@ and this project adheres to
## [Unreleased]
### Changed
- ⬆️(dependencies) update python dependencies
### Fixed
- 🔒️(backend) fix email disclosure in room invitation endpoint #1200
## [1.12.0] - 2026-03-24
### Changed
- ♻️(backend) configurable SESSION_ENGINE #1038 #1154
- ♿️(frontend) fix sidepanel accessibility aria-label #1182
- ♿️(frontend) fix more tools heading hierarchy #1181
- ♿️(fronted) improve button descriptions for More tools actions #1184
- 💄(spinner) enforce spinner height #1183
- 💄(custom-background) add upload indicator with preview #1183
- ♿️(backend) improve logo accessibility in recording email notification #1092
- ♿️(summary) improve accessibility of transcription download link #1187
- 💄(frontend) show OS-specific shortcut in participant tile hint #1193
- ⬆️(frontend) bump flatted from 3.3.1 to 3.4.2 in /src/frontend #1188
- ⬆️(frontend) bump undici from 6.23.0 to 6.24.1 in /src/frontend
- ⬆️(frontend) bump hono from 4.12.2 to 4.12.7 in /src/frontend
- ⬆️(frontend) bump dompurify from 3.3.1 to 3.3.2 in /src/frontend
### Fixed
- 🐛(frontend) disable personal custom background while deleting #1183
- 🐛(frontend) auto-select new custom background when not logged in #1183
- 🐛(frontend) fix device selection not applying during conference #1156
## [1.11.0] - 2026-03-19
### Added
- ✨(helm) support celery with our Django backend #1124
- ✨(helm) support ingress for custom background image #1124
- ✨(backend) add authenticated user rate throttling on request-entry #1129
- ✨(backend) expose `is_active` field for Application in Django admin #1133
- ✨(file-upload) disable by default & limit count by user #1141
- ✨(frontend) custom background #1067
### Changed
- ♿️(frontend) Caption text size setting for accessibility #1062
- ♿️(frontend) sync html lang attribute with i18n for screen readers #1111
- ♿️(frontend) improve MoreLink a11y and UX on home page #1112
- ♿(frontend) improve chat toast a11y for screen readers #1109
- ♿(frontend) improve ui and aria labels for help article links #1108
-(frontend) improve chat toast a11y for screen readers #1109
-(frontend) improve ui and aria labels for help article links #1108
- 🌐(frontend) improve German translation #1125
- 🔨(python-env) migrate meet main app to UV #1120
- ♻️(backend) align Application model field with `is_active` convention #1133
- 🔐(backend) avoids revealing the inactive status of an application #1135
- ⚡️(helm) reduce initialDelaySeconds and add periods seconds #1139
- 🔒️(backend) avoid information exposure through exception messages #1144
- ⬆️(dependencies) update PyJWT to v2.12.0 [SECURITY] #1151
- 📌(agents) unpin OpenSSL and related dependencies #1167
- ♿️(frontend) add caption font and background color customization #1122
### Fixed
- 🐛(frontend) fix hand icon and queue position alignment and position #1119
- 🩹(backend) add page_size to pagination for room endpoints #1131
- 🐛(backend) refactor lobby throttling to use participant id #1129
- 🩹(backend) ignore non-recording uploads in storage webhook handler #1142
- 🐛(frontend) fix dimension mismatch in BackgroundCustomProcessor #1116
## [1.10.0] - 2026-03-05
+39 -23
View File
@@ -13,14 +13,28 @@ RUN apk update && \
# ---- Back-end builder image ----
FROM base AS back-builder
WORKDIR /builder
# Copy required python dependencies
COPY ./src/backend /builder
ENV UV_COMPILE_BYTECODE=1
ENV UV_LINK_MODE=copy
RUN mkdir /install && \
pip install --prefix=/install .
# Disable Python downloads, because we want to use the system interpreter
# across both images. If using a managed Python version, it needs to be
# copied from the build image into the final image;
ENV UV_PYTHON_DOWNLOADS=0
# install uv
COPY --from=ghcr.io/astral-sh/uv:0.10.9 /uv /uvx /bin/
WORKDIR /app
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=src/backend/uv.lock,target=uv.lock \
--mount=type=bind,source=src/backend/pyproject.toml,target=pyproject.toml \
uv sync --locked --no-install-project --no-dev
COPY src/backend /app
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-dev
# ---- mails ----
FROM node:20 AS mail-builder
@@ -30,7 +44,7 @@ COPY ./src/mail /mail/app
WORKDIR /mail/app
RUN yarn install --frozen-lockfile && \
yarn build
yarn build
# ---- static link collector ----
@@ -42,17 +56,17 @@ RUN apk add \
libmagic \
rdfind
# Copy installed python dependencies
COPY --from=back-builder /install /usr/local
# Copy Meet application (see .dockerignore)
COPY ./src/backend /app/
WORKDIR /app
# Copy the application from the builder
COPY --from=back-builder /app /app
ENV PATH="/app/.venv/bin:$PATH"
# collectstatic
RUN DJANGO_CONFIGURATION=Build DJANGO_JWT_PRIVATE_SIGNING_KEY=Dummy \
python manage.py collectstatic --noinput
python manage.py collectstatic --noinput
# Replace duplicated file by a symlink to decrease the overall size of the
# final image
@@ -81,14 +95,17 @@ COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
# docker user (see entrypoint).
RUN chmod g=u /etc/passwd
# Copy installed python dependencies
COPY --from=back-builder /install /usr/local
# Copy Meet application (see .dockerignore)
COPY ./src/backend /app/
# Copy the application from the builder
COPY --from=back-builder /app /app
WORKDIR /app
ENV PATH="/app/.venv/bin:$PATH"
# Generate compiled translation messages
RUN DJANGO_CONFIGURATION=Build \
python manage.py compilemessages --ignore=".venv/**/*"
# We wrap commands run in this container by the following entrypoint that
# creates a user on-the-fly with the container user ID (see USER) and root group
# ID.
@@ -103,10 +120,9 @@ USER root:root
# Install psql
RUN apk add postgresql-client
# Uninstall Meet and re-install it in editable mode along with development
# dependencies
RUN pip uninstall -y meet
RUN pip install -e .[dev]
# Install development dependencies
RUN --mount=from=ghcr.io/astral-sh/uv:0.10.9,source=/uv,target=/bin/uv \
uv sync --all-extras --locked
# Restore the un-privileged user running the application
ARG DOCKER_USER
@@ -115,7 +131,7 @@ USER ${DOCKER_USER}
# Target database host (e.g. database engine following docker compose services
# name) & port
ENV DB_HOST=postgresql \
DB_PORT=5432
DB_PORT=5432
# Run django development server
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
+15 -4
View File
@@ -125,10 +125,15 @@ run-summary: ## start only the summary application and all needed services
@$(COMPOSE) up --force-recreate -d celery-summary-summarize
.PHONY: run-summary
run-agents: ## start the LiveKit agents (metadata collector)
@$(COMPOSE) up --force-recreate -d metadata-collector-dev
.PHONY: run-agents
run:
run: ## start the wsgi (production) and development server
@$(MAKE) run-backend
@$(MAKE) run-summary
@$(MAKE) run-agents
@$(COMPOSE) up --force-recreate -d frontend
.PHONY: run
@@ -191,6 +196,7 @@ lint-pylint: ## lint back-end python sources with pylint only on changed files f
test: ## run project tests
@$(MAKE) test-back-parallel
@$(MAKE) test-summary
.PHONY: test
test-back: ## run back-end tests
@@ -203,6 +209,11 @@ test-back-parallel: ## run all back-end tests in parallel
bin/pytest -n auto $${args:-${1}}
.PHONY: test-back-parallel
test-summary: ## run summary tests
@args="$(filter-out $@,$(MAKECMDGOALS))" && \
bin/pytest-summary $${args:-${1}}
.PHONY: test-summary
makemigrations: ## run django makemigrations for the Meet project.
@echo "$(BOLD)Running makemigrations$(RESET)"
@$(COMPOSE) up -d postgresql
@@ -223,7 +234,7 @@ superuser: ## Create an admin superuser with password "admin"
.PHONY: superuser
back-i18n-compile: ## compile the gettext files
@$(MANAGE) compilemessages --ignore="venv/**/*"
@$(MANAGE) compilemessages --ignore=".venv/**/*"
.PHONY: back-i18n-compile
back-i18n-generate: ## create the .pot files used for i18n
@@ -354,13 +365,13 @@ install-external-secrets: ## install the kubernetes secrets from Vaultwarden
.PHONY: build-k8s-cluster
start-tilt: ## start the kubernetes cluster using kind
tilt up -f ./bin/Tiltfile
tilt up --namespace=meet -f ./bin/Tiltfile
.PHONY: build-k8s-cluster
start-tilt-keycloak: ## start the kubernetes cluster using kind, without Pro Connect for authentication, use keycloak
DEV_ENV=dev-keycloak tilt up -f ./bin/Tiltfile
DEV_ENV=dev-keycloak tilt up --namespace=meet -f ./bin/Tiltfile
.PHONY: build-k8s-cluster
start-tilt-dinum: ## start the kubernetes cluster using kind, without Pro Connect for authentication, but with DINUM styles
DEV_ENV=dev-dinum tilt up -f ./bin/Tiltfile
DEV_ENV=dev-dinum tilt up --namespace=meet -f ./bin/Tiltfile
.PHONY: build-k8s-cluster
+1
View File
@@ -103,6 +103,7 @@ k8s_resource('meet-celery-backend', resource_deps=['redis'])
k8s_resource('meet-celery-summarize', resource_deps=['redis'])
k8s_resource('meet-celery-transcribe', resource_deps=['redis'])
k8s_resource('meet-backend-migrate', resource_deps=['meet-backend'])
k8s_resource('livekit-livekit-server', resource_deps=['redis'])
k8s_resource('livekit-livekit-server-test-connection', resource_deps=['livekit-livekit-server'])
k8s_resource('keycloak', resource_deps=['kc-postgresql'])
k8s_resource('meet-backend-createsuperuser', resource_deps=['meet-backend-migrate'])
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
_dc_run \
app-summary-dev \
python -m pytest "$@"
+26 -1
View File
@@ -58,7 +58,7 @@ services:
/usr/bin/mc admin config set meet notify_webhook:meet-webhook endpoint='http://app-dev:8000/api/v1.0/recordings/storage-hook/' auth_token='Bearer password' &&
/usr/bin/mc admin service restart meet --wait --json &&
sleep 15 &&
/usr/bin/mc event add meet/meet-media-storage arn:minio:sqs::meet-webhook:webhook --event put &&
/usr/bin/mc event add meet/meet-media-storage arn:minio:sqs::meet-webhook:webhook --event put --prefix "recordings" &&
exit 0;"
app-dev:
@@ -80,6 +80,7 @@ services:
volumes:
- ./src/backend:/app
- ./data/static:/data/static
- /app/.venv
depends_on:
- postgresql
- mailcatcher
@@ -105,6 +106,7 @@ services:
volumes:
- ./src/backend:/app
- ./data/static:/data/static
- /app/.venv
depends_on:
- app-dev
@@ -244,6 +246,29 @@ services:
depends_on:
- redis
metadata-collector-dev:
build:
context: ./src/agents
command: ["python", "metadata_collector.py", "dev"]
environment:
- 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_STORAGE_BUCKET_NAME=meet-media-storage
- AWS_S3_SECURE_ACCESS=False
volumes:
- ./src/agents:/app
depends_on:
- livekit
- minio
develop:
watch:
- action: rebuild
path: ./src/agents
redis-summary:
image: redis
ports:
+3 -4
View File
@@ -61,11 +61,10 @@ services:
`docker compose up -d`
```
Your keycloak instance is now available on https://doc.yourdomain.tld
Your keycloak instance is now available on https://id.yourdomain.tld
> [!CAUTION]
> Version of the images are set to latest, you should pin it to the desired version to avoid unwanted upgrades when pulling latest image. You can find available versions on [Keycloak registry](https://quay.io/repository/keycloak/keycloak?tab=tags).
```
## Creating an OIDC Client for Meet Application
@@ -76,7 +75,7 @@ Your keycloak instance is now available on https://doc.yourdomain.tld
3. Enter the name of the realm - `meet`.
4. Click "Create".
#### Step 2: Create a New Client
### Step 2: Create a New Client
1. Navigate to the "Clients" tab.
2. Click on the "Create client" button.
@@ -86,7 +85,7 @@ Your keycloak instance is now available on https://doc.yourdomain.tld
1. Set the "Web Origins" to the URL of your meet application - e.g. `https://meet.example.com`.
1. Click "Save".
#### Step 3: Get Client Credentials
### Step 3: Get Client Credentials
1. Go to the "Credentials" tab.
2. Copy the client ID (`meet` in this example) and the client secret.
+1 -1
View File
@@ -71,7 +71,7 @@ backend:
# Extra volume to manage our local custom CA and avoid to set ssl_verify: false
extraVolumeMounts:
- name: certs
mountPath: /usr/local/lib/python3.12/site-packages/certifi/cacert.pem
mountPath: /app/.venv/lib/python3.13/site-packages/certifi/cacert.pem
subPath: cacert.pem
# Extra volume to manage our local custom CA and avoid to set ssl_verify: false
+1
View File
@@ -190,6 +190,7 @@ paths:
'403':
$ref: '#/components/responses/ForbiddenError'
/rooms/:
post:
tags:
- Rooms
+1
View File
@@ -113,6 +113,7 @@ paths:
'403':
$ref: '#/components/responses/ForbiddenError'
/rooms/:
post:
tags:
- Rooms
+5 -1
View File
@@ -27,7 +27,8 @@ 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
MEDIA_BASE_URL=http://localhost:8083
MEDIA_BASE_URL=http://localhost:3000
FILE_UPLOAD_ENABLED=True
# OIDC
OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/certs
@@ -70,6 +71,9 @@ RECORDING_DOWNLOAD_BASE_URL=http://localhost:3000/recording
# Telephony
ROOM_TELEPHONY_ENABLED=True
# Metadata
METADATA_COLLECTOR_ENABLED=True
FRONTEND_USE_FRENCH_GOV_FOOTER=False
FRONTEND_USE_PROCONNECT_BUTTON=False
+6
View File
@@ -36,6 +36,12 @@
"matchPackageNames": ["django"],
"allowedVersions": "<6.0.0"
},
{
"groupName": "allowed brevo versions",
"matchManagers": ["pep621"],
"matchPackageNames": ["brevo-python"],
"allowedVersions": "<3.0.0"
},
{
"enabled": false,
"groupName": "ignored js dependencies",
+16 -7
View File
@@ -4,8 +4,6 @@ FROM python:3.13-slim AS base
RUN apt-get update && apt-get install -y \
libglib2.0-0 \
libgobject-2.0-0 \
"openssl=3.5.4-1~deb13u2" \
"libssl3t64=3.5.4-1~deb13u2" \
&& rm -rf /var/lib/apt/lists/*
FROM base AS builder
@@ -17,19 +15,30 @@ COPY pyproject.toml .
RUN mkdir /install && \
pip install --prefix=/install .
FROM base AS development
WORKDIR /app
COPY pyproject.toml .
RUN pip install --no-cache-dir ".[dev]"
COPY . .
CMD ["python", "metadata_collector.py", "dev"]
FROM base AS production
WORKDIR /app
COPY --from=builder /install /usr/local
# Remove pip to reduce attack surface in production
RUN pip uninstall -y pip
# Un-privileged user running the application
ARG DOCKER_USER
USER ${DOCKER_USER}
# Un-privileged user running the application
COPY --from=builder /install /usr/local
COPY ./*.py /app/
COPY . .
CMD ["python", "multi-user-transcriber.py", "start"]
CMD ["python", "multi_user_transcriber.py", "start"]
+5
View File
@@ -0,0 +1,5 @@
"""Storage parsers specific exceptions."""
class MissingConfigError(Exception):
"""Raised when a variable is not set in configuration."""
+384
View File
@@ -0,0 +1,384 @@
"""Metadata agent that extracts metadata from active room."""
import asyncio
import json
import logging
import os
from dataclasses import asdict, dataclass
from datetime import datetime, timezone
from io import BytesIO
from typing import List, Optional
from dotenv import load_dotenv
from livekit import api, rtc
from livekit.agents import (
Agent,
AgentServer,
AgentSession,
AutoSubscribe,
JobContext,
JobProcess,
JobRequest,
RoomInputOptions,
RoomIO,
RoomOutputOptions,
WorkerPermissions,
cli,
utils,
)
from livekit.plugins import silero
from minio import Minio
from minio.error import S3Error
from exceptions import MissingConfigError
load_dotenv()
logger = logging.getLogger("metadata-collector")
AGENT_NAME = os.getenv("METADATA_COLLECTOR_AGENT_NAME", "metadata-collector")
def prewarm(proc: JobProcess):
"""Preload voice activity detection model."""
proc.userdata["vad"] = silero.VAD.load()
server = AgentServer(
permissions=WorkerPermissions(
can_publish=False,
can_publish_data=False,
can_subscribe=True,
hidden=True,
),
)
server.setup_fnc = prewarm
@dataclass
class MetadataEvent:
"""A single timestamped event recorded during a meeting."""
participant_id: str
type: str
timestamp: datetime
data: Optional[str] = None
def serialize(self) -> dict:
"""Return a JSON-serializable dictionary representation of the event."""
data = asdict(self)
data["timestamp"] = self.timestamp.isoformat()
return data
class VADAgent(Agent):
"""Agent that monitors voice activity for a specific participant."""
def __init__(self, participant_identity: str, events: List):
"""Initialize with a participant identity and shared events list."""
super().__init__(
instructions="not-needed",
)
self.participant_identity = participant_identity
self.events = events
async def on_enter(self) -> None:
"""Initialize VAD monitoring for this participant."""
@self.session.on("user_state_changed")
def on_user_state(event):
timestamp = datetime.now(timezone.utc)
if event.new_state == "speaking":
event = MetadataEvent(
participant_id=self.participant_identity,
type="speech_start",
timestamp=timestamp,
)
self.events.append(event)
elif event.old_state == "speaking":
event = MetadataEvent(
participant_id=self.participant_identity,
type="speech_end",
timestamp=timestamp,
)
self.events.append(event)
class MetadataCollector:
"""Collect meeting events across all participants in a room.
Creates one AgentSession per participant to capture VAD events
(speech start/end), and listens for connection, disconnection,
and chat events. Persists all collected events as JSON to S3
on shutdown.
"""
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.ctx = ctx
self._sessions: dict[str, AgentSession] = {}
self._tasks: set[asyncio.Task] = set()
output_folder = os.getenv("AWS_S3_OUTPUT_FOLDER", "metadata")
self.output_filename = f"{output_folder}/{recording_id}-metadata.json"
# Storage for events
self.events = []
self.participants = {}
logger.info("MetadataCollector initialized")
def start(self):
"""Start listening for room-level events."""
self.ctx.room.on("participant_disconnected", self.on_participant_disconnected)
self.ctx.room.on("participant_name_changed", self.on_participant_name_changed)
self.ctx.room.register_text_stream_handler("lk.chat", self.handle_chat_stream)
logger.info("Started listening for participant events")
async def on_chat_message_received(
self, reader: rtc.TextStreamReader, participant_identity: str
):
"""Read a complete chat message and record it as an event."""
full_text = await reader.read_all()
logger.info(
"Received chat message from %s: '%s'", participant_identity, full_text
)
self.events.append(
MetadataEvent(
participant_id=participant_identity,
type="chat_received",
timestamp=datetime.now(timezone.utc),
data=full_text,
)
)
def handle_chat_stream(self, reader, participant_identity):
"""Schedule async processing of an incoming chat stream."""
task = asyncio.create_task(
self.on_chat_message_received(reader, participant_identity)
)
self._tasks.add(task)
task.add_done_callback(lambda _: self._tasks.remove(task))
def save(self):
"""Serialize collected events and upload as JSON to S3."""
logger.info("Persisting metadata...")
participants = []
for k, v in self.participants.items():
participants.append({"participantId": k, "name": v})
sorted_events = sorted(self.events, key=lambda e: e.timestamp)
payload = {
"events": [event.serialize() for event in sorted_events],
"participants": participants,
}
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",
)
logger.info(
"Uploaded speaker meeting metadata",
)
except S3Error:
logger.exception(
"Failed to upload meeting metadata",
)
async def aclose(self):
"""Close all sessions and cleanup resources."""
logger.info("Closing all VAD monitoring sessions…")
await utils.aio.cancel_and_wait(*self._tasks)
await asyncio.gather(
*[self._close_session(session) for session in self._sessions.values()],
return_exceptions=True,
)
self.ctx.room.off("participant_disconnected", self.on_participant_disconnected)
self.ctx.room.off("participant_name_changed", self.on_participant_name_changed)
logger.info("All VAD sessions closed")
self.save()
async def on_participant_entrypoint(
self, ctx: JobContext, participant: rtc.RemoteParticipant
):
"""Handle new participant by starting a VAD monitoring session."""
if participant.identity in self._sessions:
logger.debug("Session already exists for %s", participant.identity)
return
self.events.append(
MetadataEvent(
participant_id=participant.identity,
type="participant_connected",
timestamp=datetime.now(timezone.utc),
)
)
self.participants[participant.identity] = participant.name
logger.info("New participant connected: %s", participant.identity)
try:
session = await self._start_session(participant)
self._sessions[participant.identity] = session
except Exception:
logger.exception("Failed to start session for %s", participant.identity)
def on_participant_disconnected(self, participant: rtc.RemoteParticipant):
"""Handle participant disconnection by closing VAD monitoring."""
self.events.append(
MetadataEvent(
participant_id=participant.identity,
type="participant_disconnected",
timestamp=datetime.now(timezone.utc),
)
)
session = self._sessions.pop(participant.identity, None)
if session is None:
logger.debug("No session found for %s", participant.identity)
return
logger.info("Participant disconnected: %s", participant.identity)
task = asyncio.create_task(self._close_session(session))
self._tasks.add(task)
def on_close_done(_):
self._tasks.discard(task)
logger.info(
"VAD session closed for %s (remaining sessions: %d)",
participant.identity,
len(self._sessions),
)
task.add_done_callback(on_close_done)
def on_participant_name_changed(self, participant: rtc.RemoteParticipant):
"""Update stored participant name when it changes."""
logger.info("Participant's name changed: %s", participant.identity)
self.participants[participant.identity] = participant.name
async def _start_session(self, participant: rtc.RemoteParticipant) -> AgentSession:
"""Create and start VAD monitoring session for participant."""
if participant.identity in self._sessions:
return self._sessions[participant.identity]
# Create session with VAD only - no STT, LLM, or TTS
session = AgentSession(
vad=self.ctx.proc.userdata["vad"],
turn_detection="vad",
user_away_timeout=30.0,
)
# Set up room IO to receive audio from this specific participant
room_io = RoomIO(
agent_session=session,
room=self.ctx.room,
participant=participant,
input_options=RoomInputOptions(
audio_enabled=True,
text_enabled=False,
),
output_options=RoomOutputOptions(
audio_enabled=False,
transcription_enabled=False,
),
)
await room_io.start()
await session.start(
agent=VADAgent(
participant_identity=participant.identity, events=self.events
)
)
return session
async def _close_session(self, session: AgentSession) -> None:
"""Close and cleanup VAD monitoring session."""
try:
await session.drain()
await session.aclose()
except Exception:
logger.exception("Error closing session")
async def handle_job_request(job_req: JobRequest) -> None:
"""Accept or reject the job request based on agent presence in the room."""
room_name = job_req.room.name
recording_id = job_req.job.metadata
agent_identity = f"{AGENT_NAME}-{room_name}"
async with api.LiveKitAPI() as lk:
try:
resp = await lk.room.list_participants(
list=api.ListParticipantsRequest(room=room_name)
)
already_present = any(
p.kind == rtc.ParticipantKind.PARTICIPANT_KIND_AGENT
and p.identity == agent_identity
for p in resp.participants
)
if already_present:
logger.info("Agent already in the room '%s' — reject", room_name)
await job_req.reject()
else:
logger.info(
"Accept job for '%s' — identity=%s", room_name, agent_identity
)
await job_req.accept(identity=agent_identity, metadata=recording_id)
except Exception:
logger.exception("Error treating the job for '%s'", room_name)
await job_req.reject()
@server.rtc_session(agent_name=AGENT_NAME, on_request=handle_job_request)
async def entrypoint(ctx: JobContext):
"""Initialize and run the metadata collector."""
logger.info("Starting metadata agent in room: %s", ctx.room.name)
recording_id = ctx.job.metadata
metadata_collector = MetadataCollector(ctx, recording_id)
metadata_collector.start()
ctx.add_participant_entrypoint(metadata_collector.on_participant_entrypoint)
await ctx.connect(auto_subscribe=AutoSubscribe.AUDIO_ONLY)
async def cleanup():
logger.info("Shutting down metadata collector...")
await metadata_collector.aclose()
ctx.add_shutdown_callback(cleanup)
if __name__ == "__main__":
cli.run_app(server)
+11 -7
View File
@@ -1,22 +1,26 @@
[project]
name = "agents"
version = "1.10.0"
version = "1.12.0"
requires-python = ">=3.12"
dependencies = [
"livekit-agents==1.3.10",
"livekit-plugins-deepgram==1.3.10",
"livekit-plugins-silero==1.3.10",
"livekit-agents==1.4.5",
"livekit-plugins-deepgram==1.4.5",
"livekit-plugins-silero==1.4.5",
"livekit-plugins-kyutai-lasuite==0.0.6",
"python-dotenv==1.2.1",
"protobuf==6.33.5"
"python-dotenv==1.2.2",
"protobuf==6.33.5",
"minio==7.2.15"
]
[project.optional-dependencies]
dev = [
"ruff==0.14.4",
"ruff==0.15.6",
]
[tool.setuptools]
py-modules = ["multi_user_transcriber", "metadata_collector", "exceptions"]
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
+35 -2
View File
@@ -197,6 +197,38 @@ def resend_notification(modeladmin, request, queryset): # pylint: disable=unuse
)
@admin.action(description=_("Mark selected recordings as 'Failed to Stop'"))
def mark_as_failed_to_stop(modeladmin, request, queryset):
"""Force selected recordings status to failed_to_stop."""
eligible_statuses = [
models.RecordingStatusChoices.ACTIVE,
models.RecordingStatusChoices.INITIATED,
models.RecordingStatusChoices.STOPPED,
]
eligible = queryset.filter(status__in=eligible_statuses)
skipped = queryset.exclude(status__in=eligible_statuses).count()
updated = eligible.update(status=models.RecordingStatusChoices.FAILED_TO_STOP)
if updated > 0:
modeladmin.message_user(
request,
_("%(count)s recording(s) successfully marked as 'Failed to Stop'.")
% {"count": updated},
level=messages.SUCCESS,
)
if skipped > 0:
modeladmin.message_user(
request,
_("Skipped %(count)s recording(s) with an ineligible status.")
% {"count": skipped},
level=messages.WARNING,
)
@admin.register(models.Recording)
class RecordingAdmin(admin.ModelAdmin):
"""Recording admin interface declaration."""
@@ -224,7 +256,7 @@ class RecordingAdmin(admin.ModelAdmin):
"updated_at",
"worker_id",
)
actions = [resend_notification]
actions = [resend_notification, mark_as_failed_to_stop]
def get_queryset(self, request):
"""Optimize queries by prefetching related access and user data to avoid N+1 queries."""
@@ -276,7 +308,7 @@ class ApplicationAdmin(admin.ModelAdmin):
form = ApplicationAdminForm
list_display = ("id", "name", "client_id", "get_scopes_display")
list_display = ("id", "name", "client_id", "get_scopes_display", "is_active")
fields = [
"name",
"id",
@@ -285,6 +317,7 @@ class ApplicationAdmin(admin.ModelAdmin):
"scopes",
"client_id",
"client_secret",
"is_active",
]
readonly_fields = ["id", "created_at", "updated_at"]
inlines = [ApplicationDomainInline]
+15
View File
@@ -43,6 +43,21 @@ def get_frontend_configuration(request):
"expiration_days": settings.RECORDING_EXPIRATION_DAYS,
"max_duration": settings.RECORDING_MAX_DURATION,
},
"background_image": {
"upload_is_enabled": settings.FILE_UPLOAD_ENABLED,
"max_count_by_user": settings.FILE_UPLOAD_RESTRICTIONS["background_image"][
"max_count_by_user"
],
"max_size": settings.FILE_UPLOAD_RESTRICTIONS["background_image"][
"max_size"
],
"allowed_extensions": settings.FILE_UPLOAD_RESTRICTIONS["background_image"][
"allowed_extensions"
],
"allowed_mimetypes": settings.FILE_UPLOAD_RESTRICTIONS["background_image"][
"allowed_mimetypes"
],
},
"telephony": {
"enabled": settings.ROOM_TELEPHONY_ENABLED,
"phone_number": settings.ROOM_TELEPHONY_PHONE_NUMBER
+1
View File
@@ -13,6 +13,7 @@ class FeatureFlag:
"recording": "RECORDING_ENABLE",
"storage_event": "RECORDING_STORAGE_EVENT_ENABLE",
"subtitle": "ROOM_SUBTITLE_ENABLED",
"file_upload": "FILE_UPLOAD_ENABLED",
}
@classmethod
+8
View File
@@ -1,5 +1,6 @@
"""Permission handlers for the Meet core app."""
from django.conf import settings
from django.http import Http404
from rest_framework import permissions
@@ -116,6 +117,13 @@ class FilePermission(IsAuthenticated):
Handling soft deletions specificities
"""
def has_permission(self, request, view):
"""Allow access only to authenticated users."""
if not settings.FILE_UPLOAD_ENABLED:
raise Http404
return super().has_permission(request, view)
def has_object_permission(self, request, view, obj):
"""
Return a 404 on deleted files or if the user is not the owner
+50 -1
View File
@@ -1,7 +1,9 @@
"""Throttling modules for the API."""
from django.conf import settings
from lasuite.drf.throttling import MonitoredThrottleMixin
from rest_framework.throttling import AnonRateThrottle
from rest_framework.throttling import AnonRateThrottle, UserRateThrottle
from sentry_sdk import capture_message
@@ -14,11 +16,58 @@ class MonitoredAnonRateThrottle(MonitoredThrottleMixin, AnonRateThrottle):
"""Throttle for the monitored scoped rate throttle."""
class MonitoredUserRateThrottle(MonitoredThrottleMixin, UserRateThrottle):
"""Throttle for the monitored scoped rate throttle."""
class RequestEntryAuthenticatedUserRateThrottle(MonitoredUserRateThrottle):
"""Throttle authenticated user requesting room entry"""
scope = "request_entry"
def get_cache_key(self, request, view):
"""Use the authenticated user ID as the throttle cache key."""
if request.user and not request.user.is_authenticated:
return None # Defer to RequestEntryAnonRateThrottle for anonymous users.
return super().get_cache_key(request, view)
class RequestEntryAnonRateThrottle(MonitoredAnonRateThrottle):
"""Throttle Anonymous user requesting room entry"""
scope = "request_entry"
def get_cache_key(self, request, view):
"""Use the lobby participant cookie ID as the throttle cache key.
Only throttle if a cookie is already set. If no cookie exists yet,
return None to skip throttling — the cookie will be set on the first
response, and throttling will apply from the second request onward.
Keying on the cookie rather than the IP address prevents penalising
multiple users behind the same NAT/proxy, and is consistent with how
LobbyService identifies participants.
Note: as per DRF documentation, application-level throttling is not a
security measure against brute-force or DoS attacks. This throttle exists
solely to guard against accidental hammering from buggy clients.
"""
if request.user and request.user.is_authenticated:
return None # Only throttle unauthenticated requests.
participant_id = request.COOKIES.get(settings.LOBBY_COOKIE_NAME)
if participant_id is None:
return None # No throttling for cookieless requests
return self.cache_format % {
"scope": self.scope,
"ident": participant_id,
}
class CreationCallbackAnonRateThrottle(MonitoredAnonRateThrottle):
"""Throttle Anonymous user requesting room generation callback"""
+53 -20
View File
@@ -1,7 +1,6 @@
"""API endpoints"""
# pylint: disable=too-many-lines
import re
import uuid
from logging import getLogger
from urllib.parse import unquote, urlparse
@@ -12,6 +11,7 @@ from django.db.models import Q
from django.http import Http404
from django.shortcuts import get_object_or_404
from django.utils.text import slugify
from django.utils.translation import gettext_lazy as _
from django_filters import rest_framework as django_filters
from rest_framework import (
@@ -33,15 +33,21 @@ from rest_framework import (
from core import enums, models, utils
from core.api.filters import ListFileFilter
from core.enums import MEDIA_STORAGE_URL_PATTERN
from core.recording.enums import FileExtension
from core.recording.event.authentication import StorageEventAuthentication
from core.recording.event.exceptions import (
InvalidBucketError,
InvalidFilepathError,
InvalidFileTypeError,
ParsingEventDataError,
)
from core.recording.event.notification import notification_service
from core.recording.event.parsers import get_parser
from core.recording.services.metadata_collector import (
MetadataCollectorException,
MetadataCollectorService,
)
from core.recording.worker.exceptions import (
RecordingStartError,
RecordingStopError,
@@ -78,17 +84,6 @@ from .feature_flag import FeatureFlag
logger = getLogger(__name__)
FILE_FOLDER = settings.FILE_UPLOAD_PATH
UUID_REGEX = (
r"[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}"
)
FILE_EXT_REGEX = r"[\d\w]+"
MEDIA_STORAGE_URL_PATTERN = re.compile(
f"{settings.MEDIA_URL:s}"
rf"(?P<key>{FILE_FOLDER:s}/(?P<pk>{UUID_REGEX:s})/\.{FILE_EXT_REGEX:s})$"
)
class NestedGenericViewSet(viewsets.GenericViewSet):
"""
A generic Viewset aims to be used in a nested route context.
@@ -345,6 +340,16 @@ class RoomViewSet(
status=drf_status.HTTP_500_INTERNAL_SERVER_ERROR,
)
if settings.METADATA_COLLECTOR_ENABLED and (
recording.mode == models.RecordingModeChoices.TRANSCRIPT
or recording.options.get("transcribe", False)
):
try:
MetadataCollectorService().start(recording)
logger.info("Started MetadataCollectorService")
except MetadataCollectorException:
logger.warning("Failed to start MetadataCollectorService")
return drf_response.Response(
{"message": f"Recording successfully started for room {room.slug}"},
status=drf_status.HTTP_201_CREATED,
@@ -393,7 +398,10 @@ class RoomViewSet(
methods=["post"],
url_path="request-entry",
permission_classes=[],
throttle_classes=[throttling.RequestEntryAnonRateThrottle],
throttle_classes=[
throttling.RequestEntryAuthenticatedUserRateThrottle,
throttling.RequestEntryAnonRateThrottle,
],
)
def request_entry(self, request, pk=None): # pylint: disable=unused-argument
"""Request entry to a room"""
@@ -494,9 +502,7 @@ class RoomViewSet(
if status_code == drf_status.HTTP_500_INTERNAL_SERVER_ERROR:
raise e
return drf_response.Response(
{"status": "error", "message": str(e)}, status=status_code
)
return drf_response.Response({"status": "error"}, status=status_code)
@decorators.action(
detail=False,
@@ -763,14 +769,19 @@ class RecordingViewSet(
recording_id = parser.get_recording_id(request.data)
except ParsingEventDataError as e:
raise drf_exceptions.PermissionDenied(f"Invalid request data: {e}") from e
raise drf_exceptions.PermissionDenied("Invalid request data.") from e
except InvalidBucketError as e:
raise drf_exceptions.PermissionDenied("Invalid bucket specified") from e
raise drf_exceptions.PermissionDenied("Invalid bucket specified.") from e
except InvalidFileTypeError as e:
except InvalidFilepathError:
return drf_response.Response(
{"message": f"Ignore this file type, {e}"},
{"message": "Notification ignored."},
)
except InvalidFileTypeError:
return drf_response.Response(
{"message": "Notification ignored."},
)
try:
@@ -970,6 +981,26 @@ class FileViewSet(
def perform_create(self, serializer):
"""Set the current user as creator of the newly created file."""
if settings.FILE_UPLOAD_APPLY_RESTRICTIONS:
file_type = serializer.validated_data["type"]
config_for_file_type = settings.FILE_UPLOAD_RESTRICTIONS[file_type]
count = models.File.objects.filter(
creator=self.request.user,
deleted_at__isnull=True,
type=file_type,
).count()
if count >= config_for_file_type["max_count_by_user"]:
logger.info(
"create_item: user reached max files per user for type %s",
file_type,
)
raise serializers.PermissionDenied(
_("You have reached the maximum number of files for this type.")
)
serializer.save(creator=self.request.user)
def perform_destroy(self, instance):
@@ -977,6 +1008,7 @@ class FileViewSet(
instance.soft_delete()
@decorators.action(detail=True, methods=["post"], url_path="upload-ended")
@FeatureFlag.require("file_upload")
def upload_ended(self, request, *args, **kwargs):
"""
Check the actual uploaded file and mark it as ready.
@@ -1159,6 +1191,7 @@ class FileViewSet(
return url_params, request.user.id, file
@decorators.action(detail=False, methods=["get"], url_path="media-auth")
@FeatureFlag.require("file_upload")
def media_auth(self, request, *args, **kwargs):
"""
This view is used by an Nginx subrequest to control access to an file's
+7 -1
View File
@@ -14,9 +14,15 @@ FILE_EXT_REGEX = r"[a-zA-Z0-9]{1,10}"
# pylint: disable=line-too-long
RECORDING_STORAGE_URL_PATTERN = re.compile(
f"/media/{settings.RECORDING_OUTPUT_FOLDER}/(?P<recording_id>{UUID_REGEX:s}).(?P<extension>{FILE_EXT_REGEX:s})"
rf"{settings.MEDIA_URL:s}{settings.RECORDING_OUTPUT_FOLDER}/(?P<recording_id>{UUID_REGEX:s})\.(?P<extension>{FILE_EXT_REGEX:s})"
)
MEDIA_STORAGE_URL_PATTERN = re.compile(
f"{settings.MEDIA_URL:s}"
rf"(?P<key>{settings.FILE_UPLOAD_PATH:s}/(?P<pk>{UUID_REGEX:s})\.{FILE_EXT_REGEX:s})$"
)
# Django sets `LANGUAGES` by default with all supported languages. We can use it for
# the choice of languages which should not be limited to the few languages active in
# the app.
@@ -203,7 +203,7 @@ class ApplicationJWTAuthentication(BaseJWTAuthentication):
logger.warning("Application not found: %s", client_id)
raise exceptions.AuthenticationFailed("Application not found.") from e
if not application.active:
if not application.is_active:
logger.warning(
"Inactive application attempted authentication: %s", client_id
)
+3 -3
View File
@@ -61,12 +61,12 @@ class ApplicationViewSet(viewsets.ViewSet):
except models.Application.DoesNotExist as e:
raise drf_exceptions.AuthenticationFailed("Invalid credentials") from e
if not application.active:
raise drf_exceptions.AuthenticationFailed("Application is inactive")
if not check_password(client_secret, application.client_secret):
raise drf_exceptions.AuthenticationFailed("Invalid credentials")
if not application.is_active:
raise drf_exceptions.AuthenticationFailed("Application is inactive")
email = serializer.validated_data["scope"]
try:
validate_email(email)
+1 -1
View File
@@ -129,7 +129,7 @@ class ApplicationFactory(factory.django.DjangoModelFactory):
model = models.Application
name = factory.Faker("company")
active = True
is_active = True
client_id = factory.LazyFunction(utils.generate_client_id)
client_secret = factory.LazyFunction(utils.generate_client_secret)
scopes = []
@@ -0,0 +1,18 @@
# Generated by Django 5.2.12 on 2026-03-11 14:39
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('core', '0017_file'),
]
operations = [
migrations.RenameField(
model_name='application',
old_name='active',
new_name='is_active',
),
]
+2 -2
View File
@@ -759,7 +759,7 @@ class Application(BaseModel):
verbose_name=_("Application name"),
help_text=_("Descriptive name for this application."),
)
active = models.BooleanField(default=True)
is_active = models.BooleanField(default=True)
client_id = models.CharField(
max_length=100, unique=True, default=utils.generate_client_id
)
@@ -952,7 +952,7 @@ class File(BaseModel):
_, extension = splitext(self.filename)
# We store only the extension in the storage system to avoid
# leaking Personal Information in logs, etc.
return f"{self.key_base}/{extension!s}"
return f"{self.key_base}{extension!s}"
def get_abilities(self, user):
"""
@@ -1,6 +1,7 @@
"""Service to notify external services when a new recording is ready."""
import logging
import os
import smtplib
from django.conf import settings
@@ -150,22 +151,23 @@ class NotificationService:
.first()
)
# TODO: change how we get metadata_filename
output_folder = os.getenv("AWS_S3_OUTPUT_FOLDER", "metadata")
metadata_filename = f"{output_folder}/{recording.id}-metadata.json"
if not owner_access:
logger.error("No owner found for recording %s", recording.id)
return False
payload = {
"owner_id": str(owner_access.user.id),
"filename": recording.key,
"recording_filename": recording.key,
"metadata_filename": metadata_filename,
"email": owner_access.user.email,
"sub": owner_access.user.sub,
"room": recording.room.name,
"language": recording.options.get("language"),
"recording_date": recording.created_at.astimezone(
owner_access.user.timezone
).strftime("%Y-%m-%d"),
"recording_time": recording.created_at.astimezone(
owner_access.user.timezone
).strftime("%H:%M"),
"worker_id": recording.worker_id,
"owner_timezone": str(owner_access.user.timezone),
"download_link": f"{get_recording_download_base_url()}/{recording.id}",
"context_language": owner_access.user.language,
}
+3 -1
View File
@@ -9,6 +9,8 @@ from typing import Any, Dict, Optional, Protocol
from django.conf import settings
from django.utils.module_loading import import_string
from core.enums import FILE_EXT_REGEX, UUID_REGEX
from .exceptions import (
InvalidBucketError,
InvalidFilepathError,
@@ -86,7 +88,7 @@ class MinioParser:
# pylint: disable=line-too-long
self._filepath_regex = re.compile(
r"(?P<url_encoded_folder_path>(?:[^%]+%2F)+)?(?P<recording_id>[0-9a-fA-F\-]{36})\.(?P<extension>[a-zA-Z0-9]+)"
rf"(?P<url_encoded_folder_path>(?:[^%]+%2F)+)?{settings.RECORDING_OUTPUT_FOLDER}%2F(?P<recording_id>{UUID_REGEX})\.(?P<extension>{FILE_EXT_REGEX})"
)
@staticmethod
@@ -0,0 +1,91 @@
"""Meeting metadata collection service."""
from logging import getLogger
from django.conf import settings
from asgiref.sync import async_to_sync, sync_to_async
from livekit.protocol.agent_dispatch import (
CreateAgentDispatchRequest,
)
from core import utils
from core.models import Recording
logger = getLogger(__name__)
class MetadataCollectorException(Exception):
"""Generic exception in the metadata collector."""
class MetadataCollectorService:
"""Service for dispatching and managing the metadata collector agent."""
@async_to_sync
async def start(self, recording: Recording):
"""Explicitly dispatch the metadata collector agent to a room."""
lkapi = utils.create_livekit_client()
room_id = str(recording.room.id)
try:
response = await lkapi.agent_dispatch.create_dispatch(
CreateAgentDispatchRequest(
agent_name=settings.METADATA_COLLECTOR_AGENT_NAME,
room=room_id,
metadata=str(recording.id),
)
)
except Exception as e:
logger.exception(
"Failed to create metadata collector agent for room %s", room_id
)
raise MetadataCollectorException(
"Failed to create metadata collector agent"
) from e
finally:
await lkapi.aclose()
dispatch_id = getattr(response, "id", None)
if not dispatch_id:
logger.error("LiveKit response missing dispatch ID for room %s", room_id)
raise MetadataCollectorException(
f"LiveKit did not return a dispatch_id for room {room_id}"
)
recording.options["metadata_collector_dispatch_id"] = dispatch_id
await sync_to_async(recording.save)(update_fields=["options"])
return dispatch_id
@async_to_sync
async def stop(self, recording: Recording):
"""Stop and delete the agent dispatch associated to the room."""
room_id = str(recording.room.id)
dispatch_id = recording.options.get("metadata_collector_dispatch_id")
lkapi = utils.create_livekit_client()
try:
if not dispatch_id:
logger.warning(
"No metadata collector dispatch ID stored for room %s", room_id
)
return None
await lkapi.agent_dispatch.delete_dispatch(
dispatch_id=str(dispatch_id), room_name=room_id
)
except Exception as e:
logger.exception(
"Failed to stop metadata collector agent dispatch for room %s",
room_id,
)
raise MetadataCollectorException(
f"Failed to stop metadata collector agent for room {room_id}"
) from e
finally:
await lkapi.aclose()
@@ -7,6 +7,7 @@ from logging import getLogger
from livekit import api
from core import models, utils
from core.models import Recording
logger = getLogger(__name__)
@@ -19,7 +20,7 @@ class RecordingEventsService:
"""Handles recording-related LiveKit webhook events."""
@staticmethod
def handle_update(recording, egress_status):
def handle_update(recording: Recording, egress_status):
"""Handle egress status updates and sync recording state to room metadata."""
room_name = str(recording.room.id)
@@ -40,7 +41,7 @@ class RecordingEventsService:
logger.exception("Failed to update room's metadata: %s", e)
@staticmethod
def handle_limit_reached(recording):
def handle_limit_reached(recording: Recording):
"""Stop recording and notify participants when limit is reached."""
recording.status = models.RecordingStatusChoices.STOPPED
+13 -10
View File
@@ -4,7 +4,7 @@ import smtplib
from logging import getLogger
from django.conf import settings
from django.core.mail import send_mail
from django.core.mail import EmailMultiAlternatives
from django.template.loader import render_to_string
from django.utils.translation import get_language, override
from django.utils.translation import gettext_lazy as _
@@ -45,15 +45,18 @@ class InvitationService:
)
) # Force translation
email = EmailMultiAlternatives(
subject=subject,
body=msg_plain,
from_email=settings.EMAIL_FROM,
to=[],
bcc=emails,
)
email.attach_alternative(msg_html, "text/html")
try:
send_mail(
subject,
msg_plain,
settings.EMAIL_FROM,
emails,
html_message=msg_html,
fail_silently=False,
)
email.send()
except smtplib.SMTPException as e:
logger.error("invitation to %s was not sent: %s", emails, e)
logger.error("invitations were not sent: %s", e)
raise InvitationError("Could not send invitation") from e
+13 -1
View File
@@ -12,6 +12,10 @@ from django.conf import settings
from livekit import api
from core import models, utils
from core.recording.services.metadata_collector import (
MetadataCollectorException,
MetadataCollectorService,
)
from core.recording.services.recording_events import (
RecordingEventsError,
RecordingEventsService,
@@ -158,7 +162,7 @@ class LiveKitEventsService:
"""Handle 'egress_ended' event."""
try:
recording = models.Recording.objects.get(
recording = models.Recording.objects.select_related("room").get(
worker_id=data.egress_info.egress_id
)
except models.Recording.DoesNotExist as err:
@@ -174,6 +178,14 @@ class LiveKitEventsService:
except utils.MetadataUpdateException as e:
logger.exception("Failed to update room's metadata: %s", e)
if settings.METADATA_COLLECTOR_ENABLED and recording.options.get(
"metadata_collector_dispatch_id"
):
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
@@ -118,7 +118,7 @@ def test_api_files_create_file_authenticated_success():
assert policy_parsed.scheme == "http"
assert policy_parsed.netloc == "localhost:9000"
assert policy_parsed.path == f"/meet-media-storage/files/{file.id!s}/.png"
assert policy_parsed.path == f"/meet-media-storage/files/{file.id!s}.png"
query_params = parse_qs(policy_parsed.query)
@@ -174,6 +174,26 @@ def test_api_files_create_file_authenticated_extension_case_insensitive():
assert file.title == "file"
def test_api_files_create_file_disabled(settings):
"""
Creating a file is denied if file upload is disabled
"""
settings.FILE_UPLOAD_ENABLED = False
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/files/",
{
"type": FileTypeChoices.BACKGROUND_IMAGE,
"filename": "file.JPG",
},
format="json",
)
assert response.status_code == 404
assert not File.objects.exists()
def test_api_files_create_file_authenticated_not_checking_extension(settings):
"""
Creating a file with an extension not allowed should not fail when restrictions are disabled.
@@ -239,6 +259,48 @@ def test_api_files_create_file_authenticated_hidden_file_but_checking_extension_
assert response.json() == {"filename": ["This file extension is not allowed."]}
def test_api_files_create_file_too_many(
settings,
):
"""
Creating a file is forbidden if above user limit.
"""
settings.FILE_UPLOAD_APPLY_RESTRICTIONS = True
settings.FILE_UPLOAD_RESTRICTIONS = {
"background_image": {
**settings.FILE_UPLOAD_RESTRICTIONS["background_image"],
"max_count_by_user": 1,
},
}
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/files/",
{
"type": FileTypeChoices.BACKGROUND_IMAGE,
"filename": "1.png",
},
)
assert response.status_code == 201
response = client.post(
"/api/v1.0/files/",
{
"type": FileTypeChoices.BACKGROUND_IMAGE,
"filename": "2.png",
},
)
assert response.status_code == 403
assert response.json() == {
"detail": "You have reached the maximum number of files for this type."
}
assert File.objects.count() == 1
def test_api_files_create_force_id_success():
"""It should be possible to force the item ID when creating a item."""
user = factories.UserFactory()
@@ -39,7 +39,7 @@ def test_api_files_update_anonymous_forbidden():
def test_api_files_update_description_and_title():
"""
Test the description and title of an file can be updated.
Test the description and title of a file can be updated.
"""
user = factories.UserFactory()
@@ -32,7 +32,7 @@ def valid_minio_event():
"s3": {
"bucket": {"name": "test-bucket"},
"object": {
"key": "recording%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
"key": "recordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
"contentType": "audio/ogg",
},
}
@@ -51,7 +51,7 @@ def test_parse_valid_event(minio_parser, valid_minio_event):
"""Test parsing a valid Minio event."""
event = minio_parser.parse(valid_minio_event)
assert isinstance(event, StorageEvent)
assert event.filepath == "recording%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg"
assert event.filepath == "recordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg"
assert event.filetype == "audio/ogg"
assert event.bucket_name == "test-bucket"
assert event.metadata is None
@@ -130,11 +130,13 @@ def test_validate_invalid_filetype(minio_parser):
"invalid_filepath",
[
"invalid_filepath", # totally invalid string
"recording/46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
"recording/46d1a121-2426-484d-8fb3-09b5d886f7a8", # missing extension
"recordings/46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
"recordings/46d1a121-2426-484d-8fb3-09b5d886f7a8", # missing extension
"46d1a121-2426-484d-8fb3-09b5d886f7a8", # missing url_encoded_folder_path and extension
"", # empty string
"recording%2F46d1a1212426484d8fb309b5d886f7a8.ogg",
"46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg", # no folder at all
"uploads%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg", # wrong folder name
"folder%2Fuploads%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg", # nested but no recordings/
],
)
def test_validate_invalid_filepath(invalid_filepath, minio_parser):
@@ -152,7 +154,7 @@ def test_validate_invalid_filepath(invalid_filepath, minio_parser):
def test_validate_valid_event(minio_parser):
"""Test validation with valid event data."""
event = StorageEvent(
filepath="recording%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
filepath="recordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
filetype="audio/ogg",
bucket_name="test-bucket",
metadata=None,
@@ -170,7 +172,7 @@ def test_get_recording_id_success(minio_parser, valid_minio_event):
def test_validate_filepath_with_folder(minio_parser):
"""Test validation of filepath with folder structure."""
event = StorageEvent(
filepath="parent_folder%2Ffolder%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
filepath="parent_folder%2Frecordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
filetype="audio/ogg",
bucket_name="test-bucket",
metadata=None,
@@ -219,7 +221,7 @@ def test_validate_custom_filetypes():
parser = MinioParser(bucket_name="test-bucket", allowed_filetypes={"audio/mp3"})
event = StorageEvent(
filepath="parent_folder%2Ffolder%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
filepath="parent_folder%2Frecordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
filetype="audio/mp3",
bucket_name="test-bucket",
metadata=None,
@@ -14,6 +14,7 @@ from ...factories import RecordingFactory
from ...models import Recording, RecordingStatusChoices
from ...recording.event.exceptions import (
InvalidBucketError,
InvalidFilepathError,
InvalidFileTypeError,
ParsingEventDataError,
)
@@ -94,7 +95,7 @@ def test_save_recording_parsing_error(recording_settings, mock_get_parser, clien
)
assert response.status_code == 403
assert response.json() == {"detail": "Invalid request data: Error message"}
assert response.json() == {"detail": "Invalid request data."}
def test_save_recording_bucket_error(recording_settings, mock_get_parser, client):
@@ -111,7 +112,7 @@ def test_save_recording_bucket_error(recording_settings, mock_get_parser, client
)
assert response.status_code == 403
assert response.json() == {"detail": "Invalid bucket specified"}
assert response.json() == {"detail": "Invalid bucket specified."}
def test_save_recording_filetype_error(recording_settings, mock_get_parser):
@@ -132,7 +133,28 @@ def test_save_recording_filetype_error(recording_settings, mock_get_parser):
)
assert response.status_code == 200
assert response.json() == {"message": "Ignore this file type, unsupported '.json'"}
assert response.json() == {"message": "Notification ignored."}
def test_save_recording_filepath_error(recording_settings, mock_get_parser):
"""Test handling of unsupported filepath in recording event data."""
mock_parser = mock.Mock()
mock_parser.get_recording_id.side_effect = InvalidFilepathError(
"Invalid filepath structure: parent/folder/recording.jpeg"
)
mock_get_parser.return_value = mock_parser
client = APIClient()
response = client.post(
"/api/v1.0/recordings/storage-hook/",
{"recording_data": "valid-data"},
HTTP_AUTHORIZATION="Bearer testAuthToken",
)
assert response.status_code == 200
assert response.json() == {"message": "Notification ignored."}
def test_save_recording_unknown_recording(recording_settings, mock_get_parser, client):
@@ -240,10 +240,9 @@ def test_api_rooms_invite_error(mock_invite_to_room):
mock_invite_to_room.assert_called_once()
@mock.patch("core.services.invitation.send_mail")
def test_api_rooms_invite_success(mock_send_mail, settings):
@mock.patch("core.services.invitation.EmailMultiAlternatives")
def test_api_rooms_invite_success(mock_email_class, settings):
"""Test privileged users should successfully send invitation emails."""
settings.EMAIL_BRAND_NAME = "ACME"
settings.EMAIL_LOGO_IMG = "https://acme.com/logo"
settings.EMAIL_APP_BASE_URL = "https://acme.com"
@@ -255,7 +254,6 @@ def test_api_rooms_invite_success(mock_send_mail, settings):
user = UserFactory()
room.accesses.create(user=user, role=random.choice(["administrator", "owner"]))
client.force_login(user)
data = {"emails": ["fabien@yopmail.com", "gerald@yopmail.com"]}
@@ -269,26 +267,38 @@ def test_api_rooms_invite_success(mock_send_mail, settings):
assert response.status_code == 200
assert response.json() == {"status": "success", "message": "invitations sent"}
mock_send_mail.assert_called_once()
mock_email_class.assert_called_once()
subject, body, sender, recipients = mock_send_mail.call_args[0]
# Check constructor arguments
call_kwargs = mock_email_class.call_args[1] # EmailMultiAlternatives(**kwargs)
assert (
subject == f"Video call in progress: {user.email} is waiting for you to connect"
assert call_kwargs["subject"] == (
f"Video call in progress: {user.email} is waiting for you to connect"
)
assert call_kwargs["from_email"] == "notifications@acme.com"
assert call_kwargs["to"] == []
assert sorted(call_kwargs["bcc"]) == sorted(
["fabien@yopmail.com", "gerald@yopmail.com"]
)
# Verify email contains expected content
# Check plain text body
plain_body = call_kwargs["body"]
required_content = [
"ACME", # Brand name
"https://acme.com/logo", # Logo URL
f"https://acme.com/{room.slug}", # Room url
f"acme.com/{room.slug}", # Room link
"ACME",
"https://acme.com/logo",
f"https://acme.com/{room.slug}",
f"acme.com/{room.slug}",
]
for content in required_content:
assert content in body
assert content in plain_body
assert sender == "notifications@acme.com"
# Check HTML alternative was attached
mock_instance = mock_email_class.return_value
mock_instance.attach_alternative.assert_called_once()
html_body, mimetype = mock_instance.attach_alternative.call_args[0]
assert mimetype == "text/html"
for content in required_content:
assert content in html_body
# Verify all owners received the email (order-independent comparison)
assert sorted(recipients) == sorted(["fabien@yopmail.com", "gerald@yopmail.com"])
# Check send was called
mock_instance.send.assert_called_once()
@@ -631,3 +631,109 @@ def test_list_waiting_participants_empty(settings):
assert response.status_code == 200
assert response.json() == {"participants": []}
@mock.patch.object(utils, "notify_participants", return_value=None)
@mock.patch.object(
utils, "generate_livekit_config", return_value={"token": "test-token"}
)
def test_request_entry_throttling_anonymous_without_cookie(
mock_notify_participants, mock_generate_livekit_config, settings
):
"""Anonymous users without a cookie should not be throttled."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
client = APIClient()
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["request_entry"] = "1/minute"
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user"},
)
assert response.status_code == 200
assert response.cookies.get("mocked-cookie") is not None
client.cookies.clear() # Simulate a new cookieless request
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user"},
)
assert response.status_code == 200
@mock.patch.object(utils, "notify_participants", return_value=None)
@mock.patch.object(
utils, "generate_livekit_config", return_value={"token": "test-token"}
)
def test_request_entry_throttling_anonymous_with_cookie(
mock_notify_participants, mock_generate_livekit_config, settings
):
"""Anonymous users with a cookie should be throttled after exceeding the rate limit."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
client = APIClient()
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["request_entry"] = "2/minute"
participant_id = str(uuid.uuid4())
client.cookies.load({"mocked-cookie": participant_id})
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user"},
)
assert response.status_code == 200
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user"},
)
assert response.status_code == 200
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user"},
)
assert response.status_code == 429
@mock.patch.object(utils, "notify_participants", return_value=None)
@mock.patch.object(
utils, "generate_livekit_config", return_value={"token": "test-token"}
)
def test_request_entry_throttling_authenticated_user(
mock_notify_participants, mock_generate_livekit_config, settings
):
"""Authenticated users should be throttled."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
user = UserFactory()
client = APIClient()
client.force_login(user)
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["request_entry"] = "2/minute"
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user"},
)
assert response.status_code == 200
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user"},
)
assert response.status_code == 200
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user"},
)
assert response.status_code == 429
@@ -277,6 +277,7 @@ def test_start_recording_options_transcribe_valid_true(
):
"""Should accept transcribe with any valid pydantic true values."""
settings.RECORDING_ENABLE = True
settings.METADATA_COLLECTOR_ENABLED = False
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
@@ -487,6 +488,87 @@ def test_start_recording_options_original_mode_omitted(
assert recording.options == {}
def test_start_recording_calls_metadata_collector_start(
settings, mock_worker_service_factory, mock_worker_manager
):
"""Should call MetadataCollectorService.start when conditions are met."""
settings.RECORDING_ENABLE = True
settings.METADATA_COLLECTOR_ENABLED = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
with mock.patch(
"core.api.viewsets.MetadataCollectorService"
) as mock_collector_class:
mock_collector = mock.Mock()
mock_collector_class.return_value = mock_collector
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording", "options": {"transcribe": True}},
format="json",
)
assert response.status_code == 201
recording = Recording.objects.get(room=room)
mock_collector.start.assert_called_once_with(recording)
@pytest.mark.parametrize(
"metadata_enabled,transcribe",
[
(False, True),
(False, False),
(True, False),
(True, None),
],
)
def test_start_recording_does_not_call_metadata_collector_start_when_conditions_not_met(
settings,
mock_worker_service_factory,
mock_worker_manager,
metadata_enabled,
transcribe,
):
"""Should not call MetadataCollectorService.start when conditions are not met."""
settings.RECORDING_ENABLE = True
settings.METADATA_COLLECTOR_ENABLED = metadata_enabled
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
options = {}
if transcribe is not None:
options["transcribe"] = transcribe
with mock.patch(
"core.api.viewsets.MetadataCollectorService"
) as mock_collector_class:
mock_collector = mock.Mock()
mock_collector_class.return_value = mock_collector
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording", "options": options}
if options
else {"mode": "screen_recording"},
format="json",
)
assert response.status_code == 201
mock_collector.start.assert_not_called()
@pytest.mark.parametrize("value", ["invalid_mode", "foo", 123, "SCREEN_RECORDING"])
def test_start_recording_options_original_mode_invalid(settings, value):
"""Should reject invalid recording mode values for original_mode."""
@@ -77,7 +77,6 @@ def test_missing_auth_header(client, serialized_event_data, mock_livekit_config)
assert response.status_code == 401
assert response.json() == {
"status": "error",
"message": "Authorization header missing",
}
@@ -91,7 +90,7 @@ def test_invalid_payload(client, auth_token, mock_livekit_config):
)
assert response.status_code == 400
assert response.json() == {"status": "error", "message": "Invalid webhook payload"}
assert response.json() == {"status": "error"}
def test_unknown_event_type(client, mock_livekit_config):
@@ -116,7 +115,6 @@ def test_unknown_event_type(client, mock_livekit_config):
assert response.status_code == 422
assert response.json() == {
"status": "error",
"message": "Unknown webhook type: unknown_event_type",
}
@@ -269,6 +269,63 @@ def test_handle_egress_ended_recording_not_limit_reached(
assert recording.status == "stopped"
@mock.patch("core.services.livekit_events.MetadataCollectorService")
@mock.patch("core.utils.update_room_metadata")
def test_handle_egress_ended_calls_metadata_collector_stop_when_conditions_are_met(
mock_update_room_metadata, mock_collector_class, service, settings
):
"""Should call MetadataCollectorService.stop when it exists."""
settings.METADATA_COLLECTOR_ENABLED = True
recording = RecordingFactory(
worker_id="worker-1",
status="active",
options={"metadata_collector_dispatch_id": "dispatch-123"},
)
mock_data = mock.MagicMock()
mock_data.egress_info.egress_id = recording.worker_id
mock_data.egress_info.status = EgressStatus.EGRESS_COMPLETE
mock_collector = mock.Mock()
mock_collector_class.return_value = mock_collector
service._handle_egress_ended(mock_data)
mock_collector.stop.assert_called_once_with(recording)
@pytest.mark.parametrize(
"metadata_enabled,options",
[
(True, {}),
(False, {}),
],
)
@mock.patch("core.services.livekit_events.MetadataCollectorService")
@mock.patch("core.utils.update_room_metadata")
def test_handle_egress_ended_does_not_call_metadata_collector_stop_when_conditions_not_met(
_, mock_collector_class, metadata_enabled, options, service, settings
): # pylint: disable=too-many-arguments,too-many-positional-arguments
"""Should not call MetadataCollectorService.stop when it does not exist."""
settings.METADATA_COLLECTOR_ENABLED = metadata_enabled
recording = RecordingFactory(
worker_id="worker-1",
status="active",
options=options,
)
mock_data = mock.MagicMock()
mock_data.egress_info.egress_id = recording.worker_id
mock_data.egress_info.status = EgressStatus.EGRESS_COMPLETE
mock_collector = mock.Mock()
mock_collector_class.return_value = mock_collector
service._handle_egress_ended(mock_data)
mock_collector.stop.assert_not_called()
@mock.patch.object(LobbyService, "clear_room_cache")
@mock.patch.object(TelephonyService, "delete_dispatch_rule")
def test_handle_room_finished_clears_cache_and_deletes_dispatch_rule(
@@ -904,7 +904,7 @@ def test_api_rooms_token_unknown_application(settings):
def test_api_rooms_token_inactive_application(settings):
"""Token for inactive application should be rejected."""
application = ApplicationFactory(active=False)
application = ApplicationFactory(is_active=False)
now = datetime.now(timezone.utc)
payload = {
@@ -23,7 +23,7 @@ def test_api_applications_generate_token_success(settings):
"""Valid credentials should return a JWT token."""
UserFactory(email="User.Family@example.com")
application = ApplicationFactory(
active=True,
is_active=True,
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
)
@@ -79,7 +79,7 @@ def test_api_applications_generate_token_invalid_client_id():
def test_api_applications_generate_token_invalid_client_secret():
"""Invalid client_secret should return 401."""
user = UserFactory(email="user@example.com")
application = ApplicationFactory(active=True)
application = ApplicationFactory(is_active=True)
client = APIClient()
response = client.post(
@@ -100,7 +100,7 @@ def test_api_applications_generate_token_invalid_client_secret():
def test_api_applications_generate_token_inactive_application():
"""Inactive application should return 401."""
user = UserFactory(email="user@example.com")
application = ApplicationFactory(active=False)
application = ApplicationFactory(is_active=False)
plain_secret = "test-secret-123"
application.client_secret = plain_secret
@@ -122,9 +122,31 @@ def test_api_applications_generate_token_inactive_application():
assert "Application is inactive" in str(response.data)
def test_api_applications_generate_token_inactive_application_wrong_secret():
"""An inactive application with a wrong secret should return 401."""
user = UserFactory(email="user@example.com")
application = ApplicationFactory(is_active=False)
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
{
"client_id": application.client_id,
"client_secret": "wrong-secret",
"grant_type": "client_credentials",
"scope": user.email,
},
format="json",
)
assert response.status_code == 401
assert "Invalid credentials" in str(response.data)
assert "inactive" not in str(response.data).lower()
def test_api_applications_generate_token_invalid_email_format():
"""Invalid email format should return 400."""
application = ApplicationFactory(active=True)
application = ApplicationFactory(is_active=True)
plain_secret = "test-secret-123"
application.client_secret = plain_secret
@@ -149,7 +171,7 @@ def test_api_applications_generate_token_invalid_email_format():
def test_api_applications_generate_token_domain_not_authorized():
"""Application without domain authorization should return 403."""
user = UserFactory(email="user@denied.com")
application = ApplicationFactory(active=True)
application = ApplicationFactory(is_active=True)
ApplicationDomainFactory(application=application, domain="allowed.com")
plain_secret = "test-secret-123"
@@ -176,7 +198,7 @@ def test_api_applications_generate_token_domain_authorized():
"""Application with domain authorization should succeed."""
user = UserFactory(email="user@allowed.com")
application = ApplicationFactory(
active=True,
is_active=True,
scopes=[ApplicationScope.ROOMS_LIST],
)
ApplicationDomainFactory(application=application, domain="allowed.com")
@@ -203,7 +225,7 @@ def test_api_applications_generate_token_domain_authorized():
def test_api_applications_generate_token_user_not_found():
"""Non-existent user should return 404."""
application = ApplicationFactory(active=True)
application = ApplicationFactory(is_active=True)
plain_secret = "test-secret-123"
application.client_secret = plain_secret
@@ -231,7 +253,7 @@ def test_api_applications_token_payload_structure(settings):
user = UserFactory(email="user@example.com")
application = ApplicationFactory(
active=True,
is_active=True,
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
)
@@ -284,7 +306,7 @@ def test_api_applications_token_new_user(settings):
assert len(User.objects.all()) == 0
application = ApplicationFactory(
active=True,
is_active=True,
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
)
@@ -342,7 +364,7 @@ def test_api_applications_token_existing_user(settings):
assert len(User.objects.all()) == 1
application = ApplicationFactory(
active=True,
is_active=True,
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
)
@@ -41,7 +41,7 @@ def test_models_application_name_maxlength():
def test_models_application_active_default():
"""An application should be active by default."""
application = Application.objects.create(name="Test App")
assert application.active is True
assert application.is_active is True
def test_models_application_scopes_default():
Binary file not shown.
+115 -96
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-02-26 17:34+0000\n"
"POT-Creation-Date: 2026-03-12 13:46+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -29,11 +29,11 @@ msgstr "Berechtigungen"
msgid "Important dates"
msgstr "Wichtige Daten"
#: core/admin.py:132 core/admin.py:243
#: core/admin.py:132 core/admin.py:275
msgid "No owner"
msgstr "Kein Eigentümer"
#: core/admin.py:135 core/admin.py:246
#: core/admin.py:135 core/admin.py:278
msgid "Multiple owners"
msgstr "Mehrere Eigentümer"
@@ -61,117 +61,136 @@ msgstr "Benachrichtigungen für %(count)s Aufnahme(n) erfolgreich gesendet."
msgid "Skipped %(count)s expired recording(s)."
msgstr "%(count)s abgelaufene Aufnahme(n) übersprungen."
#: core/admin.py:309
#: core/admin.py:200
msgid "Mark selected recordings as 'Failed to Stop'"
msgstr "Ausgewählte Aufnahmen als Fehler beim Stoppen markieren"
#: core/admin.py:218
#, python-format
msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'."
msgstr "%(count)s Aufnahme(n) erfolgreich als Fehler beim Stoppen markiert."
#: core/admin.py:226
#, fuzzy, python-format
#| msgid "Skipped %(count)s expired recording(s)."
msgid "Skipped %(count)s recording(s) with an ineligible status."
msgstr "%(count)s abgelaufene Aufnahme(n) übersprungen."
#: core/admin.py:342
msgid "No scopes"
msgstr "Keine Scopes"
#: core/admin.py:311
#: core/admin.py:344
msgid "Scopes"
msgstr "Scopes"
#: core/api/filters.py:24
#: core/api/filters.py:25
msgid "Creator is me"
msgstr "Ersteller bin ich"
#: core/api/serializers.py:84
#: core/api/serializers.py:88
msgid "You must be administrator or owner of a room to add accesses to it."
msgstr ""
"Sie müssen Administrator oder Eigentümer eines Raums sein, um Zugriffe "
"hinzuzufügen."
#: core/api/serializers.py:443
#: core/api/serializers.py:516
msgid "This file extension is not allowed."
msgstr "Diese Dateiendung ist nicht erlaubt."
#: core/models.py:35
#: core/api/serializers.py:533
msgid "You have reached the maximum number of files for this type."
msgstr "Sie haben die maximale Anzahl an Dateien dieses Typs erreicht."
#: core/models.py:37
msgid "Member"
msgstr "Mitglied"
#: core/models.py:36
#: core/models.py:38
msgid "Administrator"
msgstr "Administrator"
#: core/models.py:37
#: core/models.py:39
msgid "Owner"
msgstr "Eigentümer"
#: core/models.py:53
#: core/models.py:55
msgid "Initiated"
msgstr "Gestartet"
#: core/models.py:54
#: core/models.py:56
msgid "Active"
msgstr "Aktiv"
#: core/models.py:55
#: core/models.py:57
msgid "Stopped"
msgstr "Beendet"
#: core/models.py:56
#: core/models.py:58
msgid "Saved"
msgstr "Gespeichert"
#: core/models.py:57
#: core/models.py:59
msgid "Aborted"
msgstr "Abgebrochen"
#: core/models.py:58
#: core/models.py:60
msgid "Failed to Start"
msgstr "Start fehlgeschlagen"
#: core/models.py:59
#: core/models.py:61
msgid "Failed to Stop"
msgstr "Stopp fehlgeschlagen"
#: core/models.py:60
#: core/models.py:62
msgid "Notification succeeded"
msgstr "Benachrichtigung erfolgreich"
#: core/models.py:87
#: core/models.py:89
msgid "SCREEN_RECORDING"
msgstr "BILDSCHIRMAUFZEICHNUNG"
#: core/models.py:88
#: core/models.py:90
msgid "TRANSCRIPT"
msgstr "TRANSKRIPT"
#: core/models.py:94
#: core/models.py:96
msgid "Public Access"
msgstr "Öffentlicher Zugriff"
#: core/models.py:95
#: core/models.py:97
msgid "Trusted Access"
msgstr "Vertrauenswürdiger Zugriff"
#: core/models.py:96
#: core/models.py:98
msgid "Restricted Access"
msgstr "Eingeschränkter Zugriff"
#: core/models.py:108
#: core/models.py:110
msgid "id"
msgstr "ID"
#: core/models.py:109
#: core/models.py:111
msgid "primary key for the record as UUID"
msgstr "Primärschlüssel des Eintrags als UUID"
#: core/models.py:115
#: core/models.py:117
msgid "created on"
msgstr "erstellt am"
#: core/models.py:116
#: core/models.py:118
msgid "date and time at which a record was created"
msgstr "Datum und Uhrzeit der Erstellung eines Eintrags"
#: core/models.py:121
#: core/models.py:123
msgid "updated on"
msgstr "aktualisiert am"
#: core/models.py:122
#: core/models.py:124
msgid "date and time at which a record was last updated"
msgstr "Datum und Uhrzeit der letzten Aktualisierung eines Eintrags"
#: core/models.py:142
#: core/models.py:144
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
@@ -179,11 +198,11 @@ msgstr ""
"Geben Sie einen gültigen Sub ein. Dieser Wert darf nur Buchstaben, Zahlen "
"und die Zeichen @/./+/-/_ enthalten."
#: core/models.py:148
#: core/models.py:150
msgid "sub"
msgstr "Sub"
#: core/models.py:150
#: core/models.py:152
msgid ""
"Optional for pending users; required upon account activation. 255 characters "
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
@@ -191,55 +210,55 @@ msgstr ""
"Optional für ausstehende Benutzer; erforderlich nach Kontoaktivierung. "
"Maximal 255 Zeichen. Nur Buchstaben, Zahlen und @/./+/-/_ Zeichen erlaubt."
#: core/models.py:159
#: core/models.py:161
msgid "identity email address"
msgstr "Identitäts-E-Mail-Adresse"
#: core/models.py:164
#: core/models.py:166
msgid "admin email address"
msgstr "Administrator-E-Mail-Adresse"
#: core/models.py:166
#: core/models.py:168
msgid "full name"
msgstr "Vollständiger Name"
#: core/models.py:168
#: core/models.py:170
msgid "short name"
msgstr "Kurzname"
#: core/models.py:174
#: core/models.py:176
msgid "language"
msgstr "Sprache"
#: core/models.py:175
#: core/models.py:177
msgid "The language in which the user wants to see the interface."
msgstr "Die Sprache, in der der Benutzer die Oberfläche sehen möchte."
#: core/models.py:181
#: core/models.py:183
msgid "The timezone in which the user wants to see times."
msgstr "Die Zeitzone, in der der Benutzer die Zeiten sehen möchte."
#: core/models.py:184
#: core/models.py:186
msgid "device"
msgstr "Gerät"
#: core/models.py:186
#: core/models.py:188
msgid "Whether the user is a device or a real user."
msgstr "Ob es sich um ein Gerät oder einen echten Benutzer handelt."
#: core/models.py:189
#: core/models.py:191
msgid "staff status"
msgstr "Mitarbeiterstatus"
#: core/models.py:191
#: core/models.py:193
msgid "Whether the user can log into this admin site."
msgstr "Ob der Benutzer sich bei dieser Admin-Seite anmelden kann."
#: core/models.py:194
#: core/models.py:196
msgid "active"
msgstr "aktiv"
#: core/models.py:197
#: core/models.py:199
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
@@ -247,66 +266,66 @@ msgstr ""
"Ob dieser Benutzer als aktiv behandelt werden soll. Deaktivieren Sie dies "
"anstelle des Löschens des Kontos."
#: core/models.py:210
#: core/models.py:212
msgid "user"
msgstr "Benutzer"
#: core/models.py:211
#: core/models.py:213
msgid "users"
msgstr "Benutzer"
#: core/models.py:270
#: core/models.py:272
msgid "Resource"
msgstr "Ressource"
#: core/models.py:271
#: core/models.py:273
msgid "Resources"
msgstr "Ressourcen"
#: core/models.py:329
#: core/models.py:331
msgid "Resource access"
msgstr "Ressourcenzugriff"
#: core/models.py:330
#: core/models.py:332
msgid "Resource accesses"
msgstr "Ressourcenzugriffe"
#: core/models.py:336
#: core/models.py:338
msgid "Resource access with this User and Resource already exists."
msgstr ""
"Ein Ressourcenzugriff mit diesem Benutzer und dieser Ressource existiert "
"bereits."
#: core/models.py:392
#: core/models.py:394
msgid "Visio room configuration"
msgstr "Visio-Raumkonfiguration"
#: core/models.py:393
#: core/models.py:395
msgid "Values for Visio parameters to configure the room."
msgstr "Werte für Visio-Parameter zur Konfiguration des Raums."
#: core/models.py:400
#: core/models.py:402
msgid "Room PIN code"
msgstr "PIN-Code für den Raum"
#: core/models.py:401
#: core/models.py:403
msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr ""
"Eindeutiger n-stelliger Code, der diesen Raum im Telephonmodus identifiziert."
#: core/models.py:407 core/models.py:561
#: core/models.py:409 core/models.py:563
msgid "Room"
msgstr "Raum"
#: core/models.py:408
#: core/models.py:410
msgid "Rooms"
msgstr "Räume"
#: core/models.py:572
#: core/models.py:574
msgid "Worker ID"
msgstr "Worker-ID"
#: core/models.py:574
#: core/models.py:576
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
@@ -315,120 +334,120 @@ msgstr ""
"erhalten, auch wenn der Worker stoppt, was ein einfaches Nachverfolgen "
"ermöglicht."
#: core/models.py:582
#: core/models.py:584
msgid "Recording mode"
msgstr "Aufzeichnungsmodus"
#: core/models.py:583
#: core/models.py:585
msgid "Defines the mode of recording being called."
msgstr "Definiert den aufgerufenen Aufzeichnungsmodus."
#: core/models.py:588 core/models.py:589
#: core/models.py:590 core/models.py:591
msgid "Recording options"
msgstr "Aufnahmeoptionen"
#: core/models.py:595
#: core/models.py:597
msgid "Recording"
msgstr "Aufzeichnung"
#: core/models.py:596
#: core/models.py:598
msgid "Recordings"
msgstr "Aufzeichnungen"
#: core/models.py:704
#: core/models.py:706
msgid "Recording/user relation"
msgstr "Beziehung Aufzeichnung/Benutzer"
#: core/models.py:705
#: core/models.py:707
msgid "Recording/user relations"
msgstr "Beziehungen Aufzeichnung/Benutzer"
#: core/models.py:711
#: core/models.py:713
msgid "This user is already in this recording."
msgstr "Dieser Benutzer ist bereits Teil dieser Aufzeichnung."
#: core/models.py:717
#: core/models.py:719
msgid "This team is already in this recording."
msgstr "Dieses Team ist bereits Teil dieser Aufzeichnung."
#: core/models.py:723
#: core/models.py:725
msgid "Either user or team must be set, not both."
msgstr "Entweder Benutzer oder Team muss festgelegt werden, nicht beides."
#: core/models.py:740
#: core/models.py:742
msgid "Create rooms"
msgstr "Räume erstellen"
#: core/models.py:741
#: core/models.py:743
msgid "List rooms"
msgstr "Räume auflisten"
#: core/models.py:742
#: core/models.py:744
msgid "Retrieve room details"
msgstr "Raumdetails abrufen"
#: core/models.py:743
#: core/models.py:745
msgid "Update rooms"
msgstr "Räume aktualisieren"
#: core/models.py:744
#: core/models.py:746
msgid "Delete rooms"
msgstr "Räume löschen"
#: core/models.py:757
#: core/models.py:759
msgid "Application name"
msgstr "Anwendungsname"
#: core/models.py:758
#: core/models.py:760
msgid "Descriptive name for this application."
msgstr "Beschreibender Name für diese Anwendung."
#: core/models.py:768
#: core/models.py:770
msgid "Hashed on Save. Copy it now if this is a new secret."
msgstr ""
"Beim Speichern gehasht. Jetzt kopieren, wenn dies ein neues Geheimnis ist."
#: core/models.py:779
#: core/models.py:781
msgid "Application"
msgstr "Anwendung"
#: core/models.py:780
#: core/models.py:782
msgid "Applications"
msgstr "Anwendungen"
#: core/models.py:803
#: core/models.py:805
msgid "Enter a valid domain"
msgstr "Geben Sie eine gültige Domain ein"
#: core/models.py:806
#: core/models.py:808
msgid "Domain"
msgstr "Domain"
#: core/models.py:807
#: core/models.py:809
msgid "Email domain this application can act on behalf of."
msgstr "E-Mail-Domain, im Namen der diese Anwendung handeln kann."
#: core/models.py:819
#: core/models.py:821
msgid "Application domain"
msgstr "Anwendungsdomain"
#: core/models.py:820
#: core/models.py:822
msgid "Application domains"
msgstr "Anwendungsdomains"
#: core/models.py:838
#: core/models.py:840
msgid "Pending"
msgstr "Ausstehend"
#: core/models.py:846
#: core/models.py:848
msgid "Ready"
msgstr "Bereit"
#: core/models.py:852
#: core/models.py:854
msgid "Background image"
msgstr "Hintergrundbild"
#: core/models.py:864
#: core/models.py:866
msgid "title"
msgstr "Titel"
@@ -445,11 +464,11 @@ msgstr "Datei"
msgid "Files"
msgstr "Dateien"
#: core/models.py:970
#: core/models.py:1000
msgid "This file is already hard deleted."
msgstr "Diese Datei wurde bereits endgültig gelöscht."
#: core/models.py:980
#: core/models.py:1010
#, fuzzy
#| msgid "To hard delete a file, it must first be soft deleted."
msgid "To hard delete a file, it must first be soft deleted."
@@ -585,18 +604,18 @@ msgstr ""
" Wenn Sie Fragen haben oder Unterstützung benötigen, wenden Sie sich bitte "
"an unser Support-Team unter %(support_email)s. "
#: meet/settings.py:224
#: meet/settings.py:223
msgid "English"
msgstr "Englisch"
#: meet/settings.py:225
#: meet/settings.py:224
msgid "French"
msgstr "Französisch"
#: meet/settings.py:226
#: meet/settings.py:225
msgid "Dutch"
msgstr "Niederländisch"
#: meet/settings.py:227
#: meet/settings.py:226
msgid "German"
msgstr "Deutsch"
Binary file not shown.
+115 -96
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-02-26 17:26+0000\n"
"POT-Creation-Date: 2026-03-12 13:46+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -29,11 +29,11 @@ msgstr "Permissions"
msgid "Important dates"
msgstr "Important dates"
#: core/admin.py:132 core/admin.py:243
#: core/admin.py:132 core/admin.py:275
msgid "No owner"
msgstr "No owner"
#: core/admin.py:135 core/admin.py:246
#: core/admin.py:135 core/admin.py:278
msgid "Multiple owners"
msgstr "Multiple owners"
@@ -61,115 +61,134 @@ msgstr "Successfully sent notifications for %(count)s recording(s)."
msgid "Skipped %(count)s expired recording(s)."
msgstr "Skipped %(count)s expired recording(s)."
#: core/admin.py:309
#: core/admin.py:200
msgid "Mark selected recordings as 'Failed to Stop'"
msgstr "Mark selected recordings as 'Failed to Stop'"
#: core/admin.py:218
#, python-format
msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'."
msgstr "%(count)s recording(s) successfully marked as 'Failed to Stop'."
#: core/admin.py:226
#, fuzzy, python-format
#| msgid "Skipped %(count)s expired recording(s)."
msgid "Skipped %(count)s recording(s) with an ineligible status."
msgstr "Skipped %(count)s expired recording(s)."
#: core/admin.py:342
msgid "No scopes"
msgstr "No scopes"
#: core/admin.py:311
#: core/admin.py:344
msgid "Scopes"
msgstr "Scopes"
#: core/api/filters.py:24
#: core/api/filters.py:25
msgid "Creator is me"
msgstr "Creator is me"
#: core/api/serializers.py:84
#: core/api/serializers.py:88
msgid "You must be administrator or owner of a room to add accesses to it."
msgstr "You must be administrator or owner of a room to add accesses to it."
#: core/api/serializers.py:443
#: core/api/serializers.py:516
msgid "This file extension is not allowed."
msgstr "This file extension is not allowed."
#: core/models.py:35
#: core/api/serializers.py:533
msgid "You have reached the maximum number of files for this type."
msgstr "You have reached the maximum number of files for this type."
#: core/models.py:37
msgid "Member"
msgstr "Member"
#: core/models.py:36
#: core/models.py:38
msgid "Administrator"
msgstr "Administrator"
#: core/models.py:37
#: core/models.py:39
msgid "Owner"
msgstr "Owner"
#: core/models.py:53
#: core/models.py:55
msgid "Initiated"
msgstr "Initiated"
#: core/models.py:54
#: core/models.py:56
msgid "Active"
msgstr "Active"
#: core/models.py:55
#: core/models.py:57
msgid "Stopped"
msgstr "Stopped"
#: core/models.py:56
#: core/models.py:58
msgid "Saved"
msgstr "Saved"
#: core/models.py:57
#: core/models.py:59
msgid "Aborted"
msgstr "Aborted"
#: core/models.py:58
#: core/models.py:60
msgid "Failed to Start"
msgstr "Failed to Start"
#: core/models.py:59
#: core/models.py:61
msgid "Failed to Stop"
msgstr "Failed to Stop"
#: core/models.py:60
#: core/models.py:62
msgid "Notification succeeded"
msgstr "Notification succeeded"
#: core/models.py:87
#: core/models.py:89
msgid "SCREEN_RECORDING"
msgstr "SCREEN_RECORDING"
#: core/models.py:88
#: core/models.py:90
msgid "TRANSCRIPT"
msgstr "TRANSCRIPT"
#: core/models.py:94
#: core/models.py:96
msgid "Public Access"
msgstr "Public Access"
#: core/models.py:95
#: core/models.py:97
msgid "Trusted Access"
msgstr "Trusted Access"
#: core/models.py:96
#: core/models.py:98
msgid "Restricted Access"
msgstr "Restricted Access"
#: core/models.py:108
#: core/models.py:110
msgid "id"
msgstr "id"
#: core/models.py:109
#: core/models.py:111
msgid "primary key for the record as UUID"
msgstr "primary key for the record as UUID"
#: core/models.py:115
#: core/models.py:117
msgid "created on"
msgstr "created on"
#: core/models.py:116
#: core/models.py:118
msgid "date and time at which a record was created"
msgstr "date and time at which a record was created"
#: core/models.py:121
#: core/models.py:123
msgid "updated on"
msgstr "updated on"
#: core/models.py:122
#: core/models.py:124
msgid "date and time at which a record was last updated"
msgstr "date and time at which a record was last updated"
#: core/models.py:142
#: core/models.py:144
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
@@ -177,11 +196,11 @@ msgstr ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
#: core/models.py:148
#: core/models.py:150
msgid "sub"
msgstr "sub"
#: core/models.py:150
#: core/models.py:152
msgid ""
"Optional for pending users; required upon account activation. 255 characters "
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
@@ -189,55 +208,55 @@ msgstr ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
#: core/models.py:159
#: core/models.py:161
msgid "identity email address"
msgstr "identity email address"
#: core/models.py:164
#: core/models.py:166
msgid "admin email address"
msgstr "admin email address"
#: core/models.py:166
#: core/models.py:168
msgid "full name"
msgstr "full name"
#: core/models.py:168
#: core/models.py:170
msgid "short name"
msgstr "short name"
#: core/models.py:174
#: core/models.py:176
msgid "language"
msgstr "language"
#: core/models.py:175
#: core/models.py:177
msgid "The language in which the user wants to see the interface."
msgstr "The language in which the user wants to see the interface."
#: core/models.py:181
#: core/models.py:183
msgid "The timezone in which the user wants to see times."
msgstr "The timezone in which the user wants to see times."
#: core/models.py:184
#: core/models.py:186
msgid "device"
msgstr "device"
#: core/models.py:186
#: core/models.py:188
msgid "Whether the user is a device or a real user."
msgstr "Whether the user is a device or a real user."
#: core/models.py:189
#: core/models.py:191
msgid "staff status"
msgstr "staff status"
#: core/models.py:191
#: core/models.py:193
msgid "Whether the user can log into this admin site."
msgstr "Whether the user can log into this admin site."
#: core/models.py:194
#: core/models.py:196
msgid "active"
msgstr "active"
#: core/models.py:197
#: core/models.py:199
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
@@ -245,63 +264,63 @@ msgstr ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
#: core/models.py:210
#: core/models.py:212
msgid "user"
msgstr "user"
#: core/models.py:211
#: core/models.py:213
msgid "users"
msgstr "users"
#: core/models.py:270
#: core/models.py:272
msgid "Resource"
msgstr "Resource"
#: core/models.py:271
#: core/models.py:273
msgid "Resources"
msgstr "Resources"
#: core/models.py:329
#: core/models.py:331
msgid "Resource access"
msgstr "Resource access"
#: core/models.py:330
#: core/models.py:332
msgid "Resource accesses"
msgstr "Resource accesses"
#: core/models.py:336
#: core/models.py:338
msgid "Resource access with this User and Resource already exists."
msgstr "Resource access with this User and Resource already exists."
#: core/models.py:392
#: core/models.py:394
msgid "Visio room configuration"
msgstr "Visio room configuration"
#: core/models.py:393
#: core/models.py:395
msgid "Values for Visio parameters to configure the room."
msgstr "Values for Visio parameters to configure the room."
#: core/models.py:400
#: core/models.py:402
msgid "Room PIN code"
msgstr "Room PIN code"
#: core/models.py:401
#: core/models.py:403
msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr "Unique n-digit code that identifies this room in telephony mode."
#: core/models.py:407 core/models.py:561
#: core/models.py:409 core/models.py:563
msgid "Room"
msgstr "Room"
#: core/models.py:408
#: core/models.py:410
msgid "Rooms"
msgstr "Rooms"
#: core/models.py:572
#: core/models.py:574
msgid "Worker ID"
msgstr "Worker ID"
#: core/models.py:574
#: core/models.py:576
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
@@ -309,125 +328,125 @@ msgstr ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
#: core/models.py:582
#: core/models.py:584
msgid "Recording mode"
msgstr "Recording mode"
#: core/models.py:583
#: core/models.py:585
msgid "Defines the mode of recording being called."
msgstr "Defines the mode of recording being called."
#: core/models.py:588 core/models.py:589
#: core/models.py:590 core/models.py:591
msgid "Recording options"
msgstr "Recording options"
#: core/models.py:595
#: core/models.py:597
msgid "Recording"
msgstr "Recording"
#: core/models.py:596
#: core/models.py:598
msgid "Recordings"
msgstr "Recordings"
#: core/models.py:704
#: core/models.py:706
msgid "Recording/user relation"
msgstr "Recording/user relation"
#: core/models.py:705
#: core/models.py:707
msgid "Recording/user relations"
msgstr "Recording/user relations"
#: core/models.py:711
#: core/models.py:713
msgid "This user is already in this recording."
msgstr "This user is already in this recording."
#: core/models.py:717
#: core/models.py:719
msgid "This team is already in this recording."
msgstr "This team is already in this recording."
#: core/models.py:723
#: core/models.py:725
msgid "Either user or team must be set, not both."
msgstr "Either user or team must be set, not both."
#: core/models.py:740
#: core/models.py:742
#, fuzzy
#| msgid "created on"
msgid "Create rooms"
msgstr "Create rooms"
#: core/models.py:741
#: core/models.py:743
msgid "List rooms"
msgstr "List rooms"
#: core/models.py:742
#: core/models.py:744
msgid "Retrieve room details"
msgstr "Retrieve room details"
#: core/models.py:743
#: core/models.py:745
#, fuzzy
#| msgid "updated on"
msgid "Update rooms"
msgstr "Update rooms"
#: core/models.py:744
#: core/models.py:746
msgid "Delete rooms"
msgstr "Delete rooms"
#: core/models.py:757
#: core/models.py:759
msgid "Application name"
msgstr "Application name"
#: core/models.py:758
#: core/models.py:760
msgid "Descriptive name for this application."
msgstr "Descriptive name for this application."
#: core/models.py:768
#: core/models.py:770
msgid "Hashed on Save. Copy it now if this is a new secret."
msgstr "Hashed on Save. Copy it now if this is a new secret."
#: core/models.py:779
#: core/models.py:781
msgid "Application"
msgstr "Application"
#: core/models.py:780
#: core/models.py:782
msgid "Applications"
msgstr "Applications"
#: core/models.py:803
#: core/models.py:805
msgid "Enter a valid domain"
msgstr "Enter a valid domain"
#: core/models.py:806
#: core/models.py:808
msgid "Domain"
msgstr "Domain"
#: core/models.py:807
#: core/models.py:809
msgid "Email domain this application can act on behalf of."
msgstr "Email domain this application can act on behalf of."
#: core/models.py:819
#: core/models.py:821
msgid "Application domain"
msgstr "Application domain"
#: core/models.py:820
#: core/models.py:822
msgid "Application domains"
msgstr "Application domains"
#: core/models.py:838
#: core/models.py:840
#, fuzzy
#| msgid "Recording"
msgid "Pending"
msgstr "Pending"
#: core/models.py:846
#: core/models.py:848
msgid "Ready"
msgstr "Ready"
#: core/models.py:852
#: core/models.py:854
msgid "Background image"
msgstr "Background image"
#: core/models.py:864
#: core/models.py:866
msgid "title"
msgstr "title"
@@ -443,13 +462,13 @@ msgstr "File"
msgid "Files"
msgstr "Files"
#: core/models.py:970
#: core/models.py:1000
#, fuzzy
#| msgid "This user is already in this recording."
msgid "This file is already hard deleted."
msgstr "This file is already hard deleted."
#: core/models.py:980
#: core/models.py:1010
msgid "To hard delete a file, it must first be soft deleted."
msgstr "To hard delete a file, it must first be soft deleted."
@@ -581,18 +600,18 @@ msgstr ""
" If you have any questions or need assistance, please contact our support "
"team at %(support_email)s. "
#: meet/settings.py:224
#: meet/settings.py:223
msgid "English"
msgstr "English"
#: meet/settings.py:225
#: meet/settings.py:224
msgid "French"
msgstr "French"
#: meet/settings.py:226
#: meet/settings.py:225
msgid "Dutch"
msgstr "Dutch"
#: meet/settings.py:227
#: meet/settings.py:226
msgid "German"
msgstr "German"
Binary file not shown.
+121 -98
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-02-26 17:26+0000\n"
"POT-Creation-Date: 2026-03-12 13:46+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: antoine.lebaud@mail.numerique.gouv.fr\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -29,11 +29,11 @@ msgstr "Permissions"
msgid "Important dates"
msgstr "Dates importantes"
#: core/admin.py:132 core/admin.py:243
#: core/admin.py:132 core/admin.py:275
msgid "No owner"
msgstr "Pas de propriétaire"
#: core/admin.py:135 core/admin.py:246
#: core/admin.py:135 core/admin.py:278
msgid "Multiple owners"
msgstr "Plusieurs propriétaires"
@@ -61,119 +61,139 @@ msgstr "Notifications envoyées avec succès pour %(count)s enregistrement(s)."
msgid "Skipped %(count)s expired recording(s)."
msgstr "%(count)s enregistrement(s) expiré(s) ignoré(s)."
#: core/admin.py:309
#: core/admin.py:200
msgid "Mark selected recordings as 'Failed to Stop'"
msgstr "Marquer les enregistrements sélectionnés comme « Échec darrêt »"
#: core/admin.py:218
#, python-format
msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'."
msgstr ""
"%(count)s enregistrement(s) marqué(s) avec succès comme « Échec darrêt »."
#: core/admin.py:226
#, fuzzy, python-format
#| msgid "Skipped %(count)s expired recording(s)."
msgid "Skipped %(count)s recording(s) with an ineligible status."
msgstr "%(count)s enregistrement(s) avec un statut inéligible ignoré(s)."
#: core/admin.py:342
msgid "No scopes"
msgstr "Aucun scopes"
#: core/admin.py:311
#: core/admin.py:344
msgid "Scopes"
msgstr "Scopes"
#: core/api/filters.py:24
#: core/api/filters.py:25
msgid "Creator is me"
msgstr "Je suis le créateur"
#: core/api/serializers.py:84
#: core/api/serializers.py:88
msgid "You must be administrator or owner of a room to add accesses to it."
msgstr ""
"Vous devez être administrateur ou propriétaire d'une salle pour y ajouter "
"des accès."
#: core/api/serializers.py:443
#: core/api/serializers.py:516
msgid "This file extension is not allowed."
msgstr "Cette extension n'est pas autorisée"
#: core/models.py:35
#: core/api/serializers.py:533
msgid "You have reached the maximum number of files for this type."
msgstr "Vous avez atteint le nombre maximum de fichiers de ce type"
#: core/models.py:37
msgid "Member"
msgstr "Membre"
#: core/models.py:36
#: core/models.py:38
msgid "Administrator"
msgstr "Administrateur"
#: core/models.py:37
#: core/models.py:39
msgid "Owner"
msgstr "Propriétaire"
#: core/models.py:53
#: core/models.py:55
msgid "Initiated"
msgstr "Initié"
#: core/models.py:54
#: core/models.py:56
msgid "Active"
msgstr "Actif"
#: core/models.py:55
#: core/models.py:57
msgid "Stopped"
msgstr "Arrêté"
#: core/models.py:56
#: core/models.py:58
msgid "Saved"
msgstr "Enregistré"
#: core/models.py:57
#: core/models.py:59
msgid "Aborted"
msgstr "Abandonné"
#: core/models.py:58
#: core/models.py:60
msgid "Failed to Start"
msgstr "Échec au démarrage"
#: core/models.py:59
#: core/models.py:61
msgid "Failed to Stop"
msgstr "Échec à l'arrêt"
#: core/models.py:60
#: core/models.py:62
msgid "Notification succeeded"
msgstr "Notification réussie"
#: core/models.py:87
#: core/models.py:89
msgid "SCREEN_RECORDING"
msgstr "ENREGISTREMENT_ÉCRAN"
#: core/models.py:88
#: core/models.py:90
msgid "TRANSCRIPT"
msgstr "TRANSCRIPTION"
#: core/models.py:94
#: core/models.py:96
msgid "Public Access"
msgstr "Accès public"
#: core/models.py:95
#: core/models.py:97
msgid "Trusted Access"
msgstr "Accès de confiance"
#: core/models.py:96
#: core/models.py:98
msgid "Restricted Access"
msgstr "Accès restreint"
#: core/models.py:108
#: core/models.py:110
msgid "id"
msgstr "id"
#: core/models.py:109
#: core/models.py:111
msgid "primary key for the record as UUID"
msgstr "clé primaire pour l'enregistrement sous forme d'UUID"
#: core/models.py:115
#: core/models.py:117
msgid "created on"
msgstr "créé le"
#: core/models.py:116
#: core/models.py:118
msgid "date and time at which a record was created"
msgstr "date et heure auxquelles un enregistrement a été créé"
#: core/models.py:121
#: core/models.py:123
msgid "updated on"
msgstr "mis à jour le"
#: core/models.py:122
#: core/models.py:124
msgid "date and time at which a record was last updated"
msgstr ""
"date et heure auxquelles un enregistrement a été mis à jour pour la dernière "
"fois"
#: core/models.py:142
#: core/models.py:144
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
@@ -181,11 +201,11 @@ msgstr ""
"Entrez un sub valide. Cette valeur ne peut contenir que des lettres, des "
"chiffres et les caractères @/./+/-/_."
#: core/models.py:148
#: core/models.py:150
msgid "sub"
msgstr "sub"
#: core/models.py:150
#: core/models.py:152
msgid ""
"Optional for pending users; required upon account activation. 255 characters "
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
@@ -193,55 +213,55 @@ msgstr ""
"Optionnel pour les utilisateurs en attente ; requis lors de l'activation du "
"compte. 255 caractères maximum. Lettres, chiffres et @/./+/-/_ uniquement."
#: core/models.py:159
#: core/models.py:161
msgid "identity email address"
msgstr "adresse e-mail d'identité"
#: core/models.py:164
#: core/models.py:166
msgid "admin email address"
msgstr "adresse e-mail d'administrateur"
#: core/models.py:166
#: core/models.py:168
msgid "full name"
msgstr "nom complet"
#: core/models.py:168
#: core/models.py:170
msgid "short name"
msgstr "nom court"
#: core/models.py:174
#: core/models.py:176
msgid "language"
msgstr "langue"
#: core/models.py:175
#: core/models.py:177
msgid "The language in which the user wants to see the interface."
msgstr "La langue dans laquelle l'utilisateur souhaite voir l'interface."
#: core/models.py:181
#: core/models.py:183
msgid "The timezone in which the user wants to see times."
msgstr "Le fuseau horaire dans lequel l'utilisateur souhaite voir les heures."
#: core/models.py:184
#: core/models.py:186
msgid "device"
msgstr "appareil"
#: core/models.py:186
#: core/models.py:188
msgid "Whether the user is a device or a real user."
msgstr "Si l'utilisateur est un appareil ou un utilisateur réel."
#: core/models.py:189
#: core/models.py:191
msgid "staff status"
msgstr "statut du personnel"
#: core/models.py:191
#: core/models.py:193
msgid "Whether the user can log into this admin site."
msgstr "Si l'utilisateur peut se connecter à ce site d'administration."
#: core/models.py:194
#: core/models.py:196
msgid "active"
msgstr "actif"
#: core/models.py:197
#: core/models.py:199
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
@@ -249,65 +269,65 @@ msgstr ""
"Si cet utilisateur doit être traité comme actif. Désélectionnez cette option "
"au lieu de supprimer des comptes."
#: core/models.py:210
#: core/models.py:212
msgid "user"
msgstr "utilisateur"
#: core/models.py:211
#: core/models.py:213
msgid "users"
msgstr "utilisateurs"
#: core/models.py:270
#: core/models.py:272
msgid "Resource"
msgstr "Ressource"
#: core/models.py:271
#: core/models.py:273
msgid "Resources"
msgstr "Ressources"
#: core/models.py:329
#: core/models.py:331
msgid "Resource access"
msgstr "Accès aux ressources"
#: core/models.py:330
#: core/models.py:332
msgid "Resource accesses"
msgstr "Accès aux ressources"
#: core/models.py:336
#: core/models.py:338
msgid "Resource access with this User and Resource already exists."
msgstr ""
"L'accès à la ressource avec cet utilisateur et cette ressource existe déjà."
#: core/models.py:392
#: core/models.py:394
msgid "Visio room configuration"
msgstr "Configuration de la salle de visioconférence"
#: core/models.py:393
#: core/models.py:395
msgid "Values for Visio parameters to configure the room."
msgstr "Valeurs des paramètres de visioconférence pour configurer la salle."
#: core/models.py:400
#: core/models.py:402
msgid "Room PIN code"
msgstr "Code PIN de la salle"
#: core/models.py:401
#: core/models.py:403
msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr ""
"Code unique à n chiffres qui identifie cette salle en mode téléphonique."
#: core/models.py:407 core/models.py:561
#: core/models.py:409 core/models.py:563
msgid "Room"
msgstr "Salle"
#: core/models.py:408
#: core/models.py:410
msgid "Rooms"
msgstr "Salles"
#: core/models.py:572
#: core/models.py:574
msgid "Worker ID"
msgstr "ID du Worker"
#: core/models.py:574
#: core/models.py:576
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
@@ -315,127 +335,128 @@ msgstr ""
"Entrez un identifiant pour l'enregistrement du Worker. Cet identifiant est "
"conservé même lorsque le Worker s'arrête, permettant un suivi facile."
#: core/models.py:582
#: core/models.py:584
msgid "Recording mode"
msgstr "Mode d'enregistrement"
#: core/models.py:583
#: core/models.py:585
msgid "Defines the mode of recording being called."
msgstr "Définit le mode d'enregistrement appelé."
#: core/models.py:588 core/models.py:589
#: core/models.py:590 core/models.py:591
msgid "Recording options"
msgstr "Options d'enregistrement"
#: core/models.py:595
#: core/models.py:597
msgid "Recording"
msgstr "Enregistrement"
#: core/models.py:596
#: core/models.py:598
msgid "Recordings"
msgstr "Enregistrements"
#: core/models.py:704
#: core/models.py:706
msgid "Recording/user relation"
msgstr "Relation enregistrement/utilisateur"
#: core/models.py:705
#: core/models.py:707
msgid "Recording/user relations"
msgstr "Relations enregistrement/utilisateur"
#: core/models.py:711
#: core/models.py:713
msgid "This user is already in this recording."
msgstr "Cet utilisateur est déjà dans cet enregistrement."
#: core/models.py:717
#: core/models.py:719
msgid "This team is already in this recording."
msgstr "Cette équipe est déjà dans cet enregistrement."
#: core/models.py:723
#: core/models.py:725
msgid "Either user or team must be set, not both."
msgstr "Soit l'utilisateur, soit l'équipe doit être défini, pas les deux."
#: core/models.py:740
#: core/models.py:742
msgid "Create rooms"
msgstr "Créer des salles"
#: core/models.py:741
#: core/models.py:743
msgid "List rooms"
msgstr "Lister les salles"
#: core/models.py:742
#: core/models.py:744
msgid "Retrieve room details"
msgstr "Afficher les détails dune salle"
#: core/models.py:743
#: core/models.py:745
msgid "Update rooms"
msgstr "Mettre à jour les salles"
#: core/models.py:744
#: core/models.py:746
msgid "Delete rooms"
msgstr "Supprimer les salles"
#: core/models.py:757
#: core/models.py:759
msgid "Application name"
msgstr "Nom de lapplication"
#: core/models.py:758
#: core/models.py:760
msgid "Descriptive name for this application."
msgstr "Nom descriptif de cette application."
#: core/models.py:768
#: core/models.py:770
msgid "Hashed on Save. Copy it now if this is a new secret."
msgstr ""
"Haché lors de lenregistrement. Copiez-le maintenant sil sagit dun "
"nouveau secret."
#: core/models.py:779
#: core/models.py:781
msgid "Application"
msgstr "Application"
#: core/models.py:780
#: core/models.py:782
msgid "Applications"
msgstr "Applications"
#: core/models.py:803
#: core/models.py:805
msgid "Enter a valid domain"
msgstr "Saisissez un domaine valide"
#: core/models.py:806
#: core/models.py:808
msgid "Domain"
msgstr "Domaine"
#: core/models.py:807
#: core/models.py:809
msgid "Email domain this application can act on behalf of."
msgstr "Domaine de messagerie au nom duquel cette application peut agir."
#: core/models.py:819
#: core/models.py:821
msgid "Application domain"
msgstr "Domaine dapplication"
#: core/models.py:820
#: core/models.py:822
msgid "Application domains"
msgstr "Domaines dapplication"
#: core/models.py:838
#: core/models.py:840
msgid "Pending"
msgstr "En attente"
#: core/models.py:846
#: core/models.py:848
msgid "Ready"
msgstr "Prêt"
#: core/models.py:852
#: core/models.py:854
msgid "Background image"
msgstr "Image de fond"
#: core/models.py:864
#: core/models.py:866
msgid "title"
msgstr "Titre"
#: core/models.py:890
msgid "Malware detection info when the analysis status is unsafe."
msgstr "Information concernant la détection de Malware cand le statut n'est pas sain"
msgstr ""
"Information concernant la détection de Malware cand le statut n'est pas sain"
#: core/models.py:895
msgid "File"
@@ -445,15 +466,17 @@ msgstr "Fichier"
msgid "Files"
msgstr "Fichiers"
#: core/models.py:970
#: core/models.py:1000
#, fuzzy
#| msgid "This user is already in this recording."
msgid "This file is already hard deleted."
msgstr "Ce fichier a été supprimé."
#: core/models.py:980
#: core/models.py:1010
msgid "To hard delete a file, it must first be soft deleted."
msgstr "Pour supprimer définitivement un fichier il doit d'abord avoir été marqué comme supprimé (soft delete)"
msgstr ""
"Pour supprimer définitivement un fichier il doit d'abord avoir été marqué "
"comme supprimé (soft delete)"
#: core/recording/event/notification.py:116
msgid "Your recording is ready"
@@ -583,18 +606,18 @@ msgstr ""
" Si vous avez des questions ou besoin d'assistance, veuillez contacter notre "
"équipe d'assistance à %(support_email)s. "
#: meet/settings.py:224
#: meet/settings.py:223
msgid "English"
msgstr "Anglais"
#: meet/settings.py:225
#: meet/settings.py:224
msgid "French"
msgstr "Français"
#: meet/settings.py:226
#: meet/settings.py:225
msgid "Dutch"
msgstr "Néerlandais"
#: meet/settings.py:227
#: meet/settings.py:226
msgid "German"
msgstr "Allemand"
Binary file not shown.
+115 -96
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-02-26 17:34+0000\n"
"POT-Creation-Date: 2026-03-12 13:46+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -29,11 +29,11 @@ msgstr "Rechten"
msgid "Important dates"
msgstr "Belangrijke datums"
#: core/admin.py:132 core/admin.py:243
#: core/admin.py:132 core/admin.py:275
msgid "No owner"
msgstr "Geen eigenaar"
#: core/admin.py:135 core/admin.py:246
#: core/admin.py:135 core/admin.py:278
msgid "Multiple owners"
msgstr "Meerdere eigenaren"
@@ -61,116 +61,135 @@ msgstr "Meldingen succesvol verzonden voor %(count)s opname(n)."
msgid "Skipped %(count)s expired recording(s)."
msgstr "%(count)s verlopen opname(n) overgeslagen."
#: core/admin.py:309
#: core/admin.py:200
msgid "Mark selected recordings as 'Failed to Stop'"
msgstr "Geselecteerde opnames markeren als 'Mislukt bij stoppen'"
#: core/admin.py:218
#, python-format
msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'."
msgstr "%(count)s opname(s) succesvol gemarkeerd als 'Mislukt bij stoppen'."
#: core/admin.py:226
#, fuzzy, python-format
#| msgid "Skipped %(count)s expired recording(s)."
msgid "Skipped %(count)s recording(s) with an ineligible status."
msgstr "%(count)s opname(s) met een niet-toegestane status overgeslagen."
#: core/admin.py:342
msgid "No scopes"
msgstr "Geen scopes"
#: core/admin.py:311
#: core/admin.py:344
msgid "Scopes"
msgstr "Scopes"
#: core/api/filters.py:24
#: core/api/filters.py:25
msgid "Creator is me"
msgstr "Maker ben ik"
#: core/api/serializers.py:84
#: core/api/serializers.py:88
msgid "You must be administrator or owner of a room to add accesses to it."
msgstr ""
"Je moet beheerder of eigenaar van een ruimte zijn om toegang toe te voegen."
#: core/api/serializers.py:443
#: core/api/serializers.py:516
msgid "This file extension is not allowed."
msgstr "Deze bestandsextensie is niet toegestaan."
#: core/models.py:35
#: core/api/serializers.py:533
msgid "You have reached the maximum number of files for this type."
msgstr "Het maximale aantal bestanden voor dit type is bereikt."
#: core/models.py:37
msgid "Member"
msgstr "Lid"
#: core/models.py:36
#: core/models.py:38
msgid "Administrator"
msgstr "Beheerder"
#: core/models.py:37
#: core/models.py:39
msgid "Owner"
msgstr "Eigenaar"
#: core/models.py:53
#: core/models.py:55
msgid "Initiated"
msgstr "Gestart"
#: core/models.py:54
#: core/models.py:56
msgid "Active"
msgstr "Actief"
#: core/models.py:55
#: core/models.py:57
msgid "Stopped"
msgstr "Gestopt"
#: core/models.py:56
#: core/models.py:58
msgid "Saved"
msgstr "Opgeslagen"
#: core/models.py:57
#: core/models.py:59
msgid "Aborted"
msgstr "Afgebroken"
#: core/models.py:58
#: core/models.py:60
msgid "Failed to Start"
msgstr "Starten mislukt"
#: core/models.py:59
#: core/models.py:61
msgid "Failed to Stop"
msgstr "Stoppen mislukt"
#: core/models.py:60
#: core/models.py:62
msgid "Notification succeeded"
msgstr "Notificatie geslaagd"
#: core/models.py:87
#: core/models.py:89
msgid "SCREEN_RECORDING"
msgstr "SCHERM_OPNAME"
#: core/models.py:88
#: core/models.py:90
msgid "TRANSCRIPT"
msgstr "TRANSCRIPT"
#: core/models.py:94
#: core/models.py:96
msgid "Public Access"
msgstr "Openbare toegang"
#: core/models.py:95
#: core/models.py:97
msgid "Trusted Access"
msgstr "Vertrouwde toegang"
#: core/models.py:96
#: core/models.py:98
msgid "Restricted Access"
msgstr "Beperkte toegang"
#: core/models.py:108
#: core/models.py:110
msgid "id"
msgstr "id"
#: core/models.py:109
#: core/models.py:111
msgid "primary key for the record as UUID"
msgstr "primaire sleutel voor het record als UUID"
#: core/models.py:115
#: core/models.py:117
msgid "created on"
msgstr "aangemaakt op"
#: core/models.py:116
#: core/models.py:118
msgid "date and time at which a record was created"
msgstr "datum en tijd waarop een record werd aangemaakt"
#: core/models.py:121
#: core/models.py:123
msgid "updated on"
msgstr "bijgewerkt op"
#: core/models.py:122
#: core/models.py:124
msgid "date and time at which a record was last updated"
msgstr "datum en tijd waarop een record voor het laatst werd bijgewerkt"
#: core/models.py:142
#: core/models.py:144
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
@@ -178,11 +197,11 @@ msgstr ""
"Voer een geldige sub in. Deze waarde mag alleen letters, cijfers en @/./+/-/"
"_ tekens bevatten."
#: core/models.py:148
#: core/models.py:150
msgid "sub"
msgstr "sub"
#: core/models.py:150
#: core/models.py:152
msgid ""
"Optional for pending users; required upon account activation. 255 characters "
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
@@ -190,55 +209,55 @@ msgstr ""
"Optioneel voor gebruikers in afwachting; vereist bij accountactivering. "
"Maximum 255 tekens. Alleen letters, cijfers en @/./+/-/_ toegestaan."
#: core/models.py:159
#: core/models.py:161
msgid "identity email address"
msgstr "identiteit e-mailadres"
#: core/models.py:164
#: core/models.py:166
msgid "admin email address"
msgstr "beheerder e-mailadres"
#: core/models.py:166
#: core/models.py:168
msgid "full name"
msgstr "volledige naam"
#: core/models.py:168
#: core/models.py:170
msgid "short name"
msgstr "korte naam"
#: core/models.py:174
#: core/models.py:176
msgid "language"
msgstr "taal"
#: core/models.py:175
#: core/models.py:177
msgid "The language in which the user wants to see the interface."
msgstr "De taal waarin de gebruiker de interface wil zien."
#: core/models.py:181
#: core/models.py:183
msgid "The timezone in which the user wants to see times."
msgstr "De tijdzone waarin de gebruiker tijden wil zien."
#: core/models.py:184
#: core/models.py:186
msgid "device"
msgstr "apparaat"
#: core/models.py:186
#: core/models.py:188
msgid "Whether the user is a device or a real user."
msgstr "Of de gebruiker een apparaat is of een echte gebruiker."
#: core/models.py:189
#: core/models.py:191
msgid "staff status"
msgstr "personeelsstatus"
#: core/models.py:191
#: core/models.py:193
msgid "Whether the user can log into this admin site."
msgstr "Of de gebruiker kan inloggen op deze beheersite."
#: core/models.py:194
#: core/models.py:196
msgid "active"
msgstr "actief"
#: core/models.py:197
#: core/models.py:199
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
@@ -246,64 +265,64 @@ msgstr ""
"Of deze gebruiker als actief moet worden behandeld. Deselecteer dit in "
"plaats van accounts te verwijderen."
#: core/models.py:210
#: core/models.py:212
msgid "user"
msgstr "gebruiker"
#: core/models.py:211
#: core/models.py:213
msgid "users"
msgstr "gebruikers"
#: core/models.py:270
#: core/models.py:272
msgid "Resource"
msgstr "Bron"
#: core/models.py:271
#: core/models.py:273
msgid "Resources"
msgstr "Bronnen"
#: core/models.py:329
#: core/models.py:331
msgid "Resource access"
msgstr "Brontoegang"
#: core/models.py:330
#: core/models.py:332
msgid "Resource accesses"
msgstr "Brontoegangsrechten"
#: core/models.py:336
#: core/models.py:338
msgid "Resource access with this User and Resource already exists."
msgstr "Brontoegang met deze gebruiker en bron bestaat al."
#: core/models.py:392
#: core/models.py:394
msgid "Visio room configuration"
msgstr "Visio-ruimteconfiguratie"
#: core/models.py:393
#: core/models.py:395
msgid "Values for Visio parameters to configure the room."
msgstr "Waarden voor Visio-parameters om de ruimte te configureren."
#: core/models.py:400
#: core/models.py:402
msgid "Room PIN code"
msgstr "Pincode van de kamer"
#: core/models.py:401
#: core/models.py:403
msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr ""
"Unieke n-cijferige code die deze kamer identificeert in telefonie-modus."
#: core/models.py:407 core/models.py:561
#: core/models.py:409 core/models.py:563
msgid "Room"
msgstr "Ruimte"
#: core/models.py:408
#: core/models.py:410
msgid "Rooms"
msgstr "Ruimtes"
#: core/models.py:572
#: core/models.py:574
msgid "Worker ID"
msgstr "Worker ID"
#: core/models.py:574
#: core/models.py:576
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
@@ -311,120 +330,120 @@ msgstr ""
"Voer een identificatie in voor de worker-opname. Deze ID blijft behouden, "
"zelfs wanneer de worker stopt, waardoor eenvoudige tracking mogelijk is."
#: core/models.py:582
#: core/models.py:584
msgid "Recording mode"
msgstr "Opnamemodus"
#: core/models.py:583
#: core/models.py:585
msgid "Defines the mode of recording being called."
msgstr "Definieert de modus van opname die wordt aangeroepen."
#: core/models.py:588 core/models.py:589
#: core/models.py:590 core/models.py:591
msgid "Recording options"
msgstr "Opnameopties"
#: core/models.py:595
#: core/models.py:597
msgid "Recording"
msgstr "Opname"
#: core/models.py:596
#: core/models.py:598
msgid "Recordings"
msgstr "Opnames"
#: core/models.py:704
#: core/models.py:706
msgid "Recording/user relation"
msgstr "Opname/gebruiker-relatie"
#: core/models.py:705
#: core/models.py:707
msgid "Recording/user relations"
msgstr "Opname/gebruiker-relaties"
#: core/models.py:711
#: core/models.py:713
msgid "This user is already in this recording."
msgstr "Deze gebruiker is al in deze opname."
#: core/models.py:717
#: core/models.py:719
msgid "This team is already in this recording."
msgstr "Dit team is al in deze opname."
#: core/models.py:723
#: core/models.py:725
msgid "Either user or team must be set, not both."
msgstr "Ofwel gebruiker of team moet worden ingesteld, niet beide."
#: core/models.py:740
#: core/models.py:742
msgid "Create rooms"
msgstr "Ruimtes aanmaken"
#: core/models.py:741
#: core/models.py:743
msgid "List rooms"
msgstr "Ruimtes weergeven"
#: core/models.py:742
#: core/models.py:744
msgid "Retrieve room details"
msgstr "Details van een ruimte ophalen"
#: core/models.py:743
#: core/models.py:745
msgid "Update rooms"
msgstr "Ruimtes bijwerken"
#: core/models.py:744
#: core/models.py:746
msgid "Delete rooms"
msgstr "Ruimtes verwijderen"
#: core/models.py:757
#: core/models.py:759
msgid "Application name"
msgstr "Naam van de applicatie"
#: core/models.py:758
#: core/models.py:760
msgid "Descriptive name for this application."
msgstr "Beschrijvende naam voor deze applicatie."
#: core/models.py:768
#: core/models.py:770
msgid "Hashed on Save. Copy it now if this is a new secret."
msgstr ""
"Wordt gehasht bij het opslaan. Kopieer het nu als dit een nieuw geheim is."
#: core/models.py:779
#: core/models.py:781
msgid "Application"
msgstr "Applicatie"
#: core/models.py:780
#: core/models.py:782
msgid "Applications"
msgstr "Applicaties"
#: core/models.py:803
#: core/models.py:805
msgid "Enter a valid domain"
msgstr "Voer een geldig domein in"
#: core/models.py:806
#: core/models.py:808
msgid "Domain"
msgstr "Domein"
#: core/models.py:807
#: core/models.py:809
msgid "Email domain this application can act on behalf of."
msgstr "E-maildomein namens welke deze applicatie kan handelen."
#: core/models.py:819
#: core/models.py:821
msgid "Application domain"
msgstr "Applicatiedomein"
#: core/models.py:820
#: core/models.py:822
msgid "Application domains"
msgstr "Applicatiedomeinen"
#: core/models.py:838
#: core/models.py:840
msgid "Pending"
msgstr "In afwachting"
#: core/models.py:846
#: core/models.py:848
msgid "Ready"
msgstr "Klaar"
#: core/models.py:852
#: core/models.py:854
msgid "Background image"
msgstr "Achtergrondafbeelding"
#: core/models.py:864
#: core/models.py:866
msgid "title"
msgstr "Titel"
@@ -440,11 +459,11 @@ msgstr "Bestand"
msgid "Files"
msgstr "Bestanden"
#: core/models.py:970
#: core/models.py:1000
msgid "This file is already hard deleted."
msgstr "Dit bestand is al definitief verwijderd."
#: core/models.py:980
#: core/models.py:1010
#, fuzzy
#| msgid "To hard delete a file, it must first be soft deleted."
msgid "To hard delete a file, it must first be soft deleted."
@@ -580,18 +599,18 @@ msgstr ""
" Als je vragen hebt of hulp nodig hebt, neem dan contact op met ons support "
"team via %(support_email)s. "
#: meet/settings.py:224
#: meet/settings.py:223
msgid "English"
msgstr "Engels"
#: meet/settings.py:225
#: meet/settings.py:224
msgid "French"
msgstr "Frans"
#: meet/settings.py:226
#: meet/settings.py:225
msgid "Dutch"
msgstr "Nederlands"
#: meet/settings.py:227
#: meet/settings.py:226
msgid "German"
msgstr "Duits"
+24 -1
View File
@@ -176,6 +176,13 @@ class Base(Configuration):
environ_prefix=None,
)
FILE_UPLOAD_ENABLED = values.BooleanValue(
# False to avoid a breaking change for now
default=False,
environ_name="FILE_UPLOAD_ENABLED",
environ_prefix=None,
)
FILE_UPLOAD_PATH = values.Value(
"files", environ_name="FILE_UPLOAD_PATH", environ_prefix=None
)
@@ -188,6 +195,7 @@ class Base(Configuration):
{
"background_image": {
"max_size": 2 * MB,
"max_count_by_user": 10,
"allowed_extensions": [".jpeg", ".jpg", ".png"],
"allowed_mimetypes": ["image/jpeg", "image/png"],
},
@@ -444,7 +452,11 @@ class Base(Configuration):
CELERY_BROKER_TRANSPORT_OPTIONS = values.DictValue({}, environ_prefix=None)
# Session
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
SESSION_ENGINE = values.Value(
default="django.contrib.sessions.backends.cache",
environ_name="SESSION_ENGINE",
environ_prefix=None,
)
SESSION_CACHE_ALIAS = "default"
SESSION_COOKIE_AGE = values.PositiveIntegerValue(
default=60 * 60 * 12, environ_name="SESSION_COOKIE_AGE", environ_prefix=None
@@ -796,6 +808,16 @@ class Base(Configuration):
environ_prefix=None,
)
# Metadata collector settings
METADATA_COLLECTOR_ENABLED = values.BooleanValue(
False, environ_name="METADATA_COLLECTOR_ENABLED", environ_prefix=None
)
METADATA_COLLECTOR_AGENT_NAME = values.Value(
"metadata-collector",
environ_name="METADATA_COLLECTOR_AGENT_NAME",
environ_prefix=None,
)
# External Applications
APPLICATION_CLIENT_ID_LENGTH = values.PositiveIntegerValue(
40,
@@ -973,6 +995,7 @@ class Test(Base):
APPLICATION_JWT_AUDIENCE = "Test inc."
CELERY_TASK_ALWAYS_EAGER = True
FILE_UPLOAD_ENABLED = True
def __init__(self):
# pylint: disable=invalid-name
+31 -26
View File
@@ -2,12 +2,12 @@
# Meet package
#
[build-system]
requires = ["setuptools"]
build-backend = "setuptools.build_meta"
requires = ["uv_build>=0.10.9,<0.11.0"]
build-backend = "uv_build"
[project]
name = "meet"
version = "1.10.0"
version = "1.12.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -21,20 +21,19 @@ classifiers = [
]
description = "A simple video and phone conferencing tool, powered by LiveKit"
keywords = ["Django", "Contacts", "Templates", "RBAC"]
license = { file = "LICENSE" }
readme = "README.md"
license = "MIT"
requires-python = ">=3.13"
dependencies = [
"boto3==1.42.49",
"boto3==1.42.68",
"Brotli==1.2.0",
"brevo-python==1.2.0",
"celery[redis]==5.6.2",
"dj-database-url==3.1.0",
"dj-database-url==3.1.2",
"django-configurations==2.5.1",
"django-cors-headers==4.9.0",
"django-countries==8.2.0",
"django-filter==25.2",
"django-lasuite[all]==0.0.24",
"django-lasuite[all]==0.0.25",
"django-parler==2.3",
"redis==5.2.1",
"django-redis==6.0.0",
@@ -44,21 +43,21 @@ dependencies = [
"django==5.2.12",
"djangorestframework==3.16.1",
"drf_spectacular==0.29.0",
"dockerflow==2026.1.26",
"dockerflow==2026.3.4",
"easy_thumbnails==2.10.1",
"factory_boy==3.3.3",
"gunicorn==25.1.0",
"jsonschema==4.26.0",
"markdown==3.10.2",
"nested-multipart-parser==1.6.0",
"psycopg[binary]==3.3.2",
"pydantic==2.12.4",
"PyJWT==2.11.0",
"psycopg[binary]==3.3.3",
"pydantic==2.12.5",
"PyJWT==2.12.1",
"python-frontmatter==1.1.0",
"python-magic==0.4.27",
"requests==2.32.5",
"sentry-sdk==2.53.0",
"whitenoise==6.11.0",
"sentry-sdk==2.54.0",
"whitenoise==6.12.0",
"mozilla-django-oidc==5.0.2",
"livekit-api==1.1.0",
"aiohttp==3.13.3",
@@ -70,14 +69,14 @@ dependencies = [
"Homepage" = "https://github.com/suitenumerique/meet"
"Repository" = "https://github.com/suitenumerique/meet"
[project.optional-dependencies]
[dependency-groups]
dev = [
"django-extensions==4.1",
"drf-spectacular-sidecar==2026.1.1",
"drf-spectacular-sidecar==2026.3.1",
"freezegun==1.5.5",
"ipdb==0.13.13",
"ipython==9.10.0",
"pyfakefs==6.1.1",
"ipython==9.11.0",
"pyfakefs==6.1.5",
"pylint-django==2.7.0",
"pylint<4.0.0",
"pytest-cov==7.0.0",
@@ -85,17 +84,23 @@ dev = [
"pytest==9.0.2",
"pytest-icdiff==0.9",
"pytest-xdist==3.8.0",
"responses==0.25.8",
"ruff==0.15.1",
"responses==0.26.0",
"ruff==0.15.6",
"types-requests==2.32.4.20260107",
]
[tool.setuptools]
packages = { find = { where = ["."], exclude = ["tests"] } }
zip-safe = true
[tool.distutils.bdist_wheel]
universal = true
[tool.uv.build-backend]
module-name = [
"core",
"demo",
"meet"
]
module-root = ""
source-exclude = [
"**/tests/**",
"**/test_*.py",
"**/tests.py",
]
[tool.ruff]
exclude = [
+2365
View File
File diff suppressed because it is too large Load Diff
+19 -15
View File
@@ -1,12 +1,12 @@
{
"name": "meet",
"version": "1.10.0",
"version": "1.12.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "meet",
"version": "1.10.0",
"version": "1.12.0",
"dependencies": {
"@fontsource-variable/material-symbols-outlined": "5.2.34",
"@fontsource/material-icons-outlined": "5.2.6",
@@ -6269,10 +6269,13 @@
}
},
"node_modules/dompurify": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz",
"integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==",
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz",
"integrity": "sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==",
"license": "(MPL-2.0 OR Apache-2.0)",
"engines": {
"node": ">=20"
},
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
@@ -7158,10 +7161,11 @@
}
},
"node_modules/flatted": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz",
"integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==",
"dev": true
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
"dev": true,
"license": "ISC"
},
"node_modules/for-each": {
"version": "0.3.3",
@@ -7628,9 +7632,9 @@
"integrity": "sha512-Rf4YVNYpKjZ6ASAmibcwTNciQ5Co5Ztq6iZPEykHpkoflnD/K5ryE/rHehFsTm4NJj8nKDhbi3eKBWGogmNnkg=="
},
"node_modules/hono": {
"version": "4.12.2",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.2.tgz",
"integrity": "sha512-gJnaDHXKDayjt8ue0n8Gs0A007yKXj4Xzb8+cNjZeYsSzzwKc0Lr+OZgYwVfB0pHfUs17EPoLvrOsEaJ9mj+Tg==",
"version": "4.12.8",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.8.tgz",
"integrity": "sha512-VJCEvtrezO1IAR+kqEYnxUOoStaQPGrCmX3j4wDTNOcD1uRPFpGlwQUIW8niPuvHXaTUxeOUl5MMDGrl+tmO9A==",
"dev": true,
"license": "MIT",
"engines": {
@@ -11107,9 +11111,9 @@
}
},
"node_modules/undici": {
"version": "6.23.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz",
"integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==",
"version": "6.24.1",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz",
"integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==",
"license": "MIT",
"engines": {
"node": ">=18.17"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "1.10.0",
"version": "1.12.0",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 8.9 KiB

+13 -1
View File
@@ -15,7 +15,19 @@ export const fetchApi = async <T = Record<string, unknown>>(
...options?.headers,
},
})
const result = await response.json()
let result: T
if (response.status === 204) {
result = undefined as T
} else {
const contentType = response.headers.get('content-type') ?? ''
if (!contentType.includes('application/json')) {
result = undefined as T
} else {
result = (await response.json()) as T
}
}
if (!response.ok) {
throw new ApiError(response.status, result)
}
+1
View File
@@ -5,4 +5,5 @@ export const keys = {
requestEntry: 'requestEntry',
waitingParticipants: 'waitingParticipants',
roomCreationCallback: 'roomCreationCallback',
files: 'files',
}
+7
View File
@@ -30,6 +30,13 @@ export interface ApiConfig {
expiration_days?: number
max_duration?: number
}
background_image: {
upload_is_enabled: boolean
max_size: number
max_count_by_user: number
allowed_extensions: string[]
allowed_mimetypes: string[]
}
subtitle: {
enabled: boolean
}
@@ -0,0 +1,93 @@
import { fetchApi } from '@/api/fetchApi'
import { useMutation } from '@tanstack/react-query'
import { ApiFileItem } from '@/features/files/api/types.ts'
import { keys } from '@/api/queryKeys.ts'
import { queryClient } from '@/api/queryClient.ts'
/**
* Upload a file, using XHR so we can report on progress through a handler.
*
* @param url The URL to PUT the file to.
* @param file The file to upload.
* @param progressHandler A handler that receives progress updates as a single integer `0 <= x <= 100`.
*/
export const uploadFile = (
url: string,
file: File,
progressHandler: (progress: number) => void
) =>
new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.open('PUT', url)
xhr.setRequestHeader('X-amz-acl', 'private')
xhr.setRequestHeader('Content-Type', file.type)
xhr.addEventListener('error', reject)
xhr.addEventListener('abort', reject)
xhr.addEventListener('readystatechange', () => {
if (xhr.readyState === 4) {
if (xhr.status === 200) {
// Make sure to always set the progress to 100% when the upload is done.
// Because 'progress' event listener is not called when the file size is 0.
progressHandler(100)
return resolve(true)
}
reject(new Error(`Failed to perform the upload on ${url}.`))
}
})
xhr.upload.addEventListener('progress', (progressEvent) => {
if (progressEvent.lengthComputable) {
progressHandler(
Math.floor((progressEvent.loaded / progressEvent.total) * 100)
)
}
})
xhr.send(file)
})
/**
* Asynchronously creates a new file and uploads it to the server.
*
* @param {object} params - The parameters for the file creation and upload process.
* @param {File} params.file - The file object to be uploaded.
* @param {function} params.onProgress - A callback function that receives the upload progress as a number (0 to 100).
* @returns {Promise<ApiFileItem>} A promise that resolves when the file has been successfully uploaded and the server process is completed.
*/
export const createFile = async ({
file,
onProgress,
}: {
file: File
onProgress: (progress: number) => void
}): Promise<ApiFileItem> => {
const res = await fetchApi<ApiFileItem>(`/files/`, {
method: 'POST',
body: JSON.stringify({ filename: file.name, type: 'background_image' }),
})
if (res.upload_state !== 'pending') {
throw new Error('State should be pending right after creation')
}
const policy = res.policy
await uploadFile(policy, file, onProgress)
const createdFile = await fetchApi<ApiFileItem>(
`/files/${res.id}/upload-ended/`,
{
method: 'POST',
}
)
// We invalidate the files query to make sure the new file is immediately available.
await queryClient.invalidateQueries({
queryKey: [keys.files],
})
return createdFile
}
export const useCreateFile = () => {
return useMutation({
mutationFn: createFile,
})
}
@@ -0,0 +1,33 @@
import { fetchApi } from '@/api/fetchApi'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { keys } from '@/api/queryKeys.ts'
/**
* Deletes a file specified by its unique identifier.
*
* @param {Object} params - The parameters required for deleting the file.
* @param {string} params.fileId - The unique identifier of the file to be deleted.
* @returns {Promise<void>} A promise that resolves when the file is successfully deleted.
*/
export const deleteFile = async ({
fileId,
}: {
fileId: string
}): Promise<void> => {
await fetchApi<void>(`/files/${fileId}/`, {
method: 'DELETE',
})
}
export const useDeleteFile = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: deleteFile,
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: [keys.files],
})
},
})
}
@@ -0,0 +1,70 @@
import { fetchApi } from '@/api/fetchApi'
import { keepPreviousData, useQuery } from '@tanstack/react-query'
import { keys } from '@/api/queryKeys'
import {
ApiFileItem,
ApiFileType,
ApiFileUploadState,
} from '@/features/files/api/types.ts'
import { useUser } from '@/features/auth'
import { useConfig } from '@/api/useConfig.ts'
type ListFilesResponse = {
count: number
next: string | null
previous: string | null
results: ApiFileItem[]
}
type ListFilesFilters = {
is_creator_me?: boolean
type?: ApiFileType
upload_state?: ApiFileUploadState
is_deleted?: boolean
}
export type ListFilesParams = {
filters?: ListFilesFilters
pagination: {
page: number
pageSize: number
}
}
export const listMyFiles = async ({
filters = {},
pagination: { page, pageSize },
}: ListFilesParams): Promise<ListFilesResponse> => {
const query = new URLSearchParams()
query.append('page', page.toString())
query.append('page_size', pageSize.toString())
if (filters?.is_creator_me ?? true) {
query.append('is_creator_me', 'true')
}
if (filters?.type) {
query.append('type', filters.type)
}
if (filters?.upload_state) {
query.append('upload_state', filters.upload_state)
}
if (typeof filters?.is_deleted === 'boolean') {
query.append('is_deleted', filters.is_deleted ? 'true' : 'false')
}
return fetchApi<ListFilesResponse>(`/files?${query.toString()}`, {
method: 'GET',
})
}
export const useListMyFiles = (params: Parameters<typeof listMyFiles>[0]) => {
const { isLoggedIn } = useUser()
const { data: appConfig } = useConfig()
return useQuery({
queryKey: [keys.files, params],
queryFn: () => listMyFiles(params),
refetchOnMount: 'always',
placeholderData: keepPreviousData,
enabled:
isLoggedIn && appConfig?.background_image?.upload_is_enabled === true,
})
}
@@ -0,0 +1,34 @@
export type ApiFileCreator = {
id: string // UUID
full_name: string | null
short_name: string | null
}
export type ApiFileType = 'background_image'
export type ApiFileUploadState = 'pending' | 'ready'
export type ApiFileItem = {
id: string // UUID
created_at: string // ISO datetime string
updated_at: string // ISO datetime string
title: string
type: ApiFileType
creator: ApiFileCreator
deleted_at: string | null
hard_deleted_at: string | null
filename: string
upload_state: ApiFileUploadState
mimetype: string // e.g. "image/png"
size: number // file size in bytes
description: string | null
} & (
| {
upload_state: 'ready'
url: string
}
| {
upload_state: 'pending'
policy: string
url: null
}
)
@@ -72,7 +72,8 @@ export const RecordingDownload = () => {
if (
data.status !== RecordingStatus.Saved &&
data.status !== RecordingStatus.NotificationSucceed
data.status !== RecordingStatus.NotificationSucceed &&
data.status !== RecordingStatus.FailedToStop
) {
return <ErrorScreen title={t('unsaved.title')} body={t('unsaved.body')} />
}
@@ -215,8 +215,8 @@ export const Conference = ({
audio={userConfig.audioEnabled}
video={
userConfig.videoEnabled && {
processor: BackgroundProcessorFactory.deserializeProcessor(
userConfig.processorSerialized
processor: BackgroundProcessorFactory.fromProcessorConfig(
userConfig.processorConfig
),
}
}
@@ -4,15 +4,15 @@ import { css } from '@/styled-system/css'
import { Screen } from '@/layout/Screen'
import { useEffect, useMemo, useRef, useState } from 'react'
import {
createLocalVideoTrack,
createLocalAudioTrack,
createLocalVideoTrack,
LocalAudioTrack,
LocalVideoTrack,
Track,
} from 'livekit-client'
import { H } from '@/primitives/H'
import { Field } from '@/primitives/Field'
import { Button, Dialog, Text, Form } from '@/primitives'
import { Button, Dialog, Form, Text } from '@/primitives'
import { VStack } from '@/styled-system/jsx'
import { Heading } from 'react-aria-components'
import { RiImageCircleAiFill } from '@remixicon/react'
@@ -44,8 +44,7 @@ const onError = (e: Error) => console.error('ERROR', e)
const Effects = ({
videoTrack,
onSubmit,
}: Pick<EffectsConfigurationProps, 'videoTrack' | 'onSubmit'>) => {
}: Pick<EffectsConfigurationProps, 'videoTrack'>) => {
const { t } = useTranslation('rooms', { keyPrefix: 'join.effects' })
const [isDialogOpen, setIsDialogOpen] = useState(false)
const openDialog = () => setIsDialogOpen(true)
@@ -81,7 +80,7 @@ const Effects = ({
>
{t('subTitle')}
</Text>
<EffectsConfiguration videoTrack={videoTrack} onSubmit={onSubmit} />
<EffectsConfiguration videoTrack={videoTrack} />
</Dialog>
<Button
variant="whiteCircle"
@@ -111,7 +110,7 @@ export const Join = ({
audioDeviceId,
audioOutputDeviceId,
videoDeviceId,
processorSerialized,
processorConfig,
username,
},
saveAudioInputEnabled,
@@ -120,7 +119,6 @@ export const Join = ({
saveAudioInputDeviceId,
saveVideoInputDeviceId,
saveUsername,
saveProcessorSerialized,
} = usePersistentUserChoices()
const initialUserChoices = useRef<LocalUserChoices | null>(null)
@@ -132,7 +130,7 @@ export const Join = ({
audioDeviceId,
audioOutputDeviceId,
videoDeviceId,
processorSerialized,
processorConfig,
username,
}
}
@@ -146,8 +144,8 @@ export const Join = ({
video: !!initialUserChoices.current &&
initialUserChoices.current?.videoEnabled && {
deviceId: initialUserChoices.current.videoDeviceId,
processor: BackgroundProcessorFactory.deserializeProcessor(
initialUserChoices.current.processorSerialized
processor: BackgroundProcessorFactory.fromProcessorConfig(
initialUserChoices.current.processorConfig
),
},
},
@@ -186,9 +184,7 @@ export const Join = ({
const track = await createLocalVideoTrack({
deviceId: { exact: videoDeviceId },
processor:
BackgroundProcessorFactory.deserializeProcessor(
processorSerialized
),
BackgroundProcessorFactory.fromProcessorConfig(processorConfig),
})
setDynamicVideoTrack(track)
} catch (error) {
@@ -207,7 +203,7 @@ export const Join = ({
}, [
videoEnabled,
videoDeviceId,
processorSerialized,
processorConfig,
previewVideoTrack,
dynamicVideoTrack,
])
@@ -690,12 +686,7 @@ export const Join = ({
zIndex: '1',
})}
>
<Effects
videoTrack={videoTrack}
onSubmit={(processor) =>
saveProcessorSerialized(processor?.serialize())
}
/>
<Effects videoTrack={videoTrack} />
</div>
</div>
</div>
@@ -31,6 +31,8 @@ import { FullScreenShareWarning } from './FullScreenShareWarning'
import { ParticipantName } from './ParticipantName'
import { getParticipantName } from '@/features/rooms/utils/getParticipantName'
import { useTranslation } from 'react-i18next'
import { getShortcutDescriptorById } from '@/features/shortcuts/catalog'
import { formatShortcutLabel } from '@/features/shortcuts/formatLabels'
import { KeyboardShortcutHint } from './KeyboardShortcutHint'
export function TrackRefContextIfNeeded(
@@ -237,7 +239,13 @@ export const ParticipantTile: (
)}
</ParticipantContextIfNeeded>
</TrackRefContextIfNeeded>
<KeyboardShortcutHint>{t('toolbarHint')}</KeyboardShortcutHint>
<KeyboardShortcutHint>
{t('toolbarHint', {
shortcut: formatShortcutLabel(
getShortcutDescriptorById('open-shortcuts')?.shortcut
),
})}
</KeyboardShortcutHint>
</div>
)
})
@@ -149,11 +149,12 @@ export const SidePanel = () => {
activeSubPanelId,
} = useSidePanel()
const { t } = useTranslation('rooms', { keyPrefix: 'sidePanel' })
const title = t(`heading.${activeSubPanelId || activePanelId}`)
return (
<StyledSidePanel
title={t(`heading.${activeSubPanelId || activePanelId}`)}
ariaLabel={t('ariaLabel')}
title={title}
ariaLabel={t('ariaLabel', { title })}
onClose={() => {
layoutStore.activePanelId = null
layoutStore.activeSubPanelId = null
@@ -65,7 +65,7 @@ const ToolButton = ({
<div>
<Text
margin={false}
as="h3"
as="h2"
className={css({
display: 'flex',
gap: 0.25,
@@ -11,11 +11,7 @@ import {
TIMEOUT_TICK,
timerWorkerScript,
} from './TimerWorker'
import {
BackgroundProcessorInterface,
BackgroundOptions,
ProcessorType,
} from '.'
import { BackgroundProcessorInterface, ProcessorConfig, ProcessorType } from '.'
const PROCESSING_WIDTH = 256
const PROCESSING_HEIGHT = 144
@@ -33,7 +29,7 @@ const DEFAULT_BLUR = '10'
* MediaStreamTrackProcessor.
*/
export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
options: BackgroundOptions
options: ProcessorConfig
name: string
processedTrack?: MediaStreamTrack | undefined
@@ -64,15 +60,10 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
type: ProcessorType
virtualBackgroundImage?: HTMLImageElement
constructor(opts: BackgroundOptions) {
constructor(opts: ProcessorConfig) {
this.name = 'blur'
this.options = opts
if (this.options.blurRadius) {
this.type = ProcessorType.BLUR
} else {
this.type = ProcessorType.VIRTUAL
}
this.type = opts.type
}
static get isSupported() {
@@ -107,6 +98,12 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
}
_initVirtualBackgroundImage() {
if (this.options.type !== 'virtual') {
throw new Error(
'Virtual background is only supported for virtual background'
)
}
const needsUpdate =
this.options.imagePath &&
this.virtualBackgroundImage &&
@@ -118,7 +115,7 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
}
}
async update(opts: BackgroundOptions): Promise<void> {
async update(opts: ProcessorConfig): Promise<void> {
this.options = opts
this._initVirtualBackgroundImage()
}
@@ -187,7 +184,7 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
0,
0,
PROCESSING_WIDTH,
PROCESSING_WIDTH
PROCESSING_HEIGHT
)
}
@@ -212,6 +209,9 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
* TODO: future improvement with WebGL.
*/
async blur() {
if (this.options.type !== 'blur') {
throw new Error('Blurring is only supported for blur background')
}
const mask = this.imageSegmenterResult!.categoryMask!.getAsUint8Array()
for (let i = 0; i < mask.length; ++i) {
this.segmentationMask!.data[i * 4 + 3] = 255 - mask[i]
@@ -293,7 +293,7 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
await this.sizeSource()
await this.segment()
if (this.options.blurRadius) {
if (this.options.type === 'blur') {
await this.blur()
} else {
await this.drawVirtualBackground()
@@ -354,15 +354,4 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
this.timerWorker?.terminate()
this.imageSegmenter?.close()
}
clone() {
return new BackgroundCustomProcessor(this.options)
}
serialize() {
return {
type: this.type,
options: this.options,
}
}
}
@@ -332,15 +332,4 @@ export class FaceLandmarksProcessor implements TrackProcessor<Track.Kind> {
this.timerWorker?.terminate()
this.faceLandmarker?.close()
}
clone() {
return new FaceLandmarksProcessor(this.options)
}
serialize() {
return {
type: this.type,
options: this.options,
}
}
}
@@ -1,28 +1,23 @@
import { ProcessorOptions, Track } from 'livekit-client'
import {
BackgroundBlur,
BackgroundTransformer,
ProcessorWrapper,
VirtualBackground,
} from '@livekit/track-processors'
import {
BackgroundOptions,
BackgroundProcessorInterface,
ProcessorType,
} from '.'
import { ProcessorConfig, BackgroundProcessorInterface, ProcessorType } from '.'
export class UnifiedBackgroundTrackProcessor implements BackgroundProcessorInterface {
processor: ProcessorWrapper<BackgroundOptions>
opts: BackgroundOptions
processor: ProcessorWrapper<{ imagePath?: string; blurRadius?: number }>
opts: ProcessorConfig
processorType: ProcessorType
constructor(opts: BackgroundOptions) {
constructor(opts: ProcessorConfig) {
this.opts = opts
if (opts.imagePath) {
if (opts.type === 'virtual') {
this.processorType = ProcessorType.VIRTUAL
this.processor = VirtualBackground(opts.imagePath)
} else if (opts.blurRadius !== undefined) {
} else if (opts.type === 'blur') {
this.processorType = ProcessorType.BLUR
this.processor = BackgroundBlur(opts.blurRadius)
} else {
@@ -44,24 +39,23 @@ export class UnifiedBackgroundTrackProcessor implements BackgroundProcessorInter
return this.processor.destroy()
}
async update(opts: BackgroundOptions): Promise<void> {
const newProcessorType = opts.imagePath
? ProcessorType.VIRTUAL
: ProcessorType.BLUR
async update(opts: ProcessorConfig): Promise<void> {
this.opts = opts
const newProcessorType =
opts.type === 'virtual' ? ProcessorType.VIRTUAL : ProcessorType.BLUR
let processedOpts = opts
if (newProcessorType !== this.processorType) {
this.processorType = newProcessorType
if (newProcessorType === ProcessorType.VIRTUAL) {
this.processor.name = 'virtual-background'
processedOpts = { ...opts, blurRadius: undefined }
} else {
this.processor.name = 'background-blur'
processedOpts = { ...opts, imagePath: undefined }
}
}
await this.processor.updateTransformerOptions(processedOpts)
this.opts = processedOpts
await this.processor.updateTransformerOptions(
opts as { imagePath?: string; blurRadius?: number }
)
}
get name() {
@@ -73,17 +67,6 @@ export class UnifiedBackgroundTrackProcessor implements BackgroundProcessorInter
}
get options() {
return (this.processor.transformer as BackgroundTransformer).options
}
clone() {
return new UnifiedBackgroundTrackProcessor(this.options || this.opts)
}
serialize() {
return {
type: this.processorType,
options: this.options,
}
return this.opts
}
}
@@ -2,23 +2,7 @@ import { ProcessorWrapper } from '@livekit/track-processors'
import { Track, TrackProcessor } from 'livekit-client'
import { BackgroundCustomProcessor } from './BackgroundCustomProcessor'
import { UnifiedBackgroundTrackProcessor } from './UnifiedBackgroundTrackProcessor'
export type BackgroundOptions = {
blurRadius?: number
imagePath?: string
}
export interface ProcessorSerialized {
type: ProcessorType
options: BackgroundOptions
}
export interface BackgroundProcessorInterface extends TrackProcessor<Track.Kind> {
update(opts: BackgroundOptions): Promise<void>
options: BackgroundOptions
clone(): BackgroundProcessorInterface
serialize(): ProcessorSerialized
}
import { FaceLandmarksOptions } from './FaceLandmarksProcessor'
export enum ProcessorType {
BLUR = 'blur',
@@ -26,6 +10,16 @@ export enum ProcessorType {
FACE_LANDMARKS = 'faceLandmarks',
}
export type ProcessorConfig =
| { type: ProcessorType.BLUR; blurRadius: number }
| { type: ProcessorType.VIRTUAL; imagePath: string; fileId?: string }
| ({ type: ProcessorType.FACE_LANDMARKS } & FaceLandmarksOptions)
export interface BackgroundProcessorInterface extends TrackProcessor<Track.Kind> {
update(opts: ProcessorConfig): Promise<void>
options: ProcessorConfig
}
export class BackgroundProcessorFactory {
static hasModernApiSupport() {
return ProcessorWrapper.hasModernApiSupport
@@ -36,28 +30,27 @@ export class BackgroundProcessorFactory {
}
static getProcessor(
type: ProcessorType,
opts: BackgroundOptions
config: ProcessorConfig
): BackgroundProcessorInterface | undefined {
const isBlur = type === ProcessorType.BLUR
const isVirtual = type === ProcessorType.VIRTUAL
const isBlur = config.type === ProcessorType.BLUR
const isVirtual = config.type === ProcessorType.VIRTUAL
if (!isBlur && !isVirtual) return undefined
if (ProcessorWrapper.isSupported) {
return new UnifiedBackgroundTrackProcessor(opts)
return new UnifiedBackgroundTrackProcessor(config)
}
if (BackgroundCustomProcessor.isSupported) {
return new BackgroundCustomProcessor(opts)
return new BackgroundCustomProcessor(config)
}
return undefined
}
static deserializeProcessor(data?: ProcessorSerialized) {
if (data?.type) {
return BackgroundProcessorFactory.getProcessor(data?.type, data?.options)
static fromProcessorConfig(data?: ProcessorConfig) {
if (data) {
return BackgroundProcessorFactory.getProcessor(data)
}
return undefined
}
@@ -69,10 +69,10 @@ const SelectDevicePermissions = <T extends string | number>({
iconComponent={iconComponent}
placeholder={items.length === 0 ? t('loading') : t('select')}
selectedKey={selectedKey}
onSelectionChange={(key) => {
onSelectionChange={async (key) => {
if (key === selectedKey) return
await setActiveMediaDevice(key as string)
onSubmit?.(key as string)
setActiveMediaDevice(key as string)
}}
{...props}
/>
@@ -82,8 +82,8 @@ export const VideoDeviceControl = ({
*
* See https://github.com/numerique-gouv/meet/pull/309#issuecomment-2622404121
*/
const processor = BackgroundProcessorFactory.deserializeProcessor(
userChoices.processorSerialized
const processor = BackgroundProcessorFactory.fromProcessorConfig(
userChoices.processorConfig
)
const toggle = trackProps.toggle as (
@@ -2,14 +2,12 @@ import { useLocalParticipant } from '@livekit/components-react'
import { LocalVideoTrack } from 'livekit-client'
import { css } from '@/styled-system/css'
import { EffectsConfiguration } from './EffectsConfiguration'
import { usePersistentUserChoices } from '../../hooks/usePersistentUserChoices'
import { useCanPublishTrack } from '@/features/rooms/livekit/hooks/useCanPublishTrack'
import { TrackSource } from '@livekit/protocol'
export const Effects = () => {
const { cameraTrack } = useLocalParticipant()
const localCameraTrack = cameraTrack?.track as LocalVideoTrack
const { saveProcessorSerialized } = usePersistentUserChoices()
const canPublishCamera = useCanPublishTrack(TrackSource.CAMERA)
@@ -24,9 +22,6 @@ export const Effects = () => {
isDisabled={!canPublishCamera}
videoTrack={localCameraTrack}
layout="vertical"
onSubmit={(processor) =>
saveProcessorSerialized(processor?.serialize())
}
/>
</div>
)
File diff suppressed because it is too large Load Diff
@@ -27,7 +27,7 @@ export const FunnyEffects = ({
showFrench: false,
}
}
return processor.serialize().options
return { ...processor.options }
}
const options = getOptions()
@@ -1,7 +1,7 @@
import { useSnapshot } from 'valtio'
import { userChoicesStore } from '@/stores/userChoices'
import type { VideoResolution } from '@/stores/userChoices'
import { ProcessorSerialized } from '@/features/rooms/livekit/components/blur'
import { ProcessorConfig } from '@/features/rooms/livekit/components/blur'
import type { VideoQuality } from 'livekit-client'
export function usePersistentUserChoices() {
@@ -36,10 +36,8 @@ export function usePersistentUserChoices() {
saveNoiseReductionEnabled: (enabled: boolean) => {
userChoicesStore.noiseReductionEnabled = enabled
},
saveProcessorSerialized: (
processorSerialized: ProcessorSerialized | undefined
) => {
userChoicesStore.processorSerialized = processorSerialized
saveProcessorConfig: (processorConfig: ProcessorConfig | undefined) => {
userChoicesStore.processorConfig = processorConfig
},
}
}
@@ -31,7 +31,7 @@ export const VideoTab = ({ id }: VideoTabProps) => {
const {
userChoices: {
videoDeviceId,
processorSerialized,
processorConfig,
videoPublishResolution,
videoSubscribeQuality,
},
@@ -78,7 +78,7 @@ export const VideoTab = ({ id }: VideoTabProps) => {
resolution: VideoPresets[key].resolution,
deviceId: { exact: videoDeviceId },
processor:
BackgroundProcessorFactory.deserializeProcessor(processorSerialized),
BackgroundProcessorFactory.fromProcessorConfig(processorConfig),
})
}
}
@@ -6,8 +6,11 @@ import { useSnapshot } from 'valtio'
import {
accessibilityStore,
type CaptionTextSize,
type CaptionColor,
CAPTION_TEXT_SIZE_OPTIONS,
CAPTION_COLOR_OPTIONS,
} from '@/stores/accessibility'
import { useAreSubtitlesAvailable } from '../hooks/useAreSubtitlesAvailable'
export const CaptionsSettings = () => {
const { t } = useTranslation('settings', {
@@ -24,6 +27,27 @@ export const CaptionsSettings = () => {
[t]
)
const captionFontColorItems = useMemo(
() =>
CAPTION_COLOR_OPTIONS.map((color) => ({
value: color,
label: t(`fontColor.options.${color}`),
})),
[t]
)
const captionBackgroundColorItems = useMemo(
() =>
CAPTION_COLOR_OPTIONS.map((color) => ({
value: color,
label: t(`backgroundColor.options.${color}`),
})),
[t]
)
const areSubtitlesAvailable = useAreSubtitlesAvailable()
if (!areSubtitlesAvailable) return null
return (
<li>
<H
@@ -34,16 +58,44 @@ export const CaptionsSettings = () => {
>
{t('heading')}
</H>
<Field
type="select"
label={t('textSize.label')}
items={captionTextSizeItems}
selectedKey={snap.captionTextSize}
onSelectionChange={(key) => {
accessibilityStore.captionTextSize = key as CaptionTextSize
}}
wrapperProps={{ noMargin: true, fullWidth: true }}
/>
<div
className={css({
display: 'flex',
flexDirection: 'column',
gap: '0.75rem',
})}
>
<Field
type="select"
label={t('textSize.label')}
items={captionTextSizeItems}
selectedKey={snap.captionTextSize}
onSelectionChange={(key) => {
accessibilityStore.captionTextSize = key as CaptionTextSize
}}
wrapperProps={{ noMargin: true, fullWidth: true }}
/>
<Field
type="select"
label={t('fontColor.label')}
items={captionFontColorItems}
selectedKey={snap.captionFontColor}
onSelectionChange={(key) => {
accessibilityStore.captionFontColor = key as CaptionColor
}}
wrapperProps={{ noMargin: true, fullWidth: true }}
/>
<Field
type="select"
label={t('backgroundColor.label')}
items={captionBackgroundColorItems}
selectedKey={snap.captionBackgroundColor}
onSelectionChange={(key) => {
accessibilityStore.captionBackgroundColor = key as CaptionColor
}}
wrapperProps={{ noMargin: true, fullWidth: true }}
/>
</div>
</li>
)
}
@@ -12,6 +12,8 @@ import { useSnapshot } from 'valtio'
import {
accessibilityStore,
CAPTION_TEXT_SIZE_OPTIONS,
CAPTION_FONT_COLOR_VALUES,
CAPTION_BACKGROUND_COLOR_VALUES,
type CaptionTextSize,
} from '@/stores/accessibility'
@@ -92,10 +94,14 @@ const useTranscriptionState = () => {
}
const Transcription = ({ row }: { row: TranscriptionRow }) => {
const { captionTextSize } = useSnapshot(accessibilityStore)
const { captionTextSize, captionFontColor, captionBackgroundColor } =
useSnapshot(accessibilityStore)
const participantColor = getParticipantColor(row.participant)
const participantName = getParticipantName(row.participant)
const { fontSize, lineHeight } = CAPTION_FONT_SIZES[captionTextSize]
const fontColor = CAPTION_FONT_COLOR_VALUES[captionFontColor]
const backgroundColor =
CAPTION_BACKGROUND_COLOR_VALUES[captionBackgroundColor]
const getDisplayText = (row: TranscriptionRow): string => {
return row.segments
@@ -128,9 +134,9 @@ const Transcription = ({ row }: { row: TranscriptionRow }) => {
/>
<div
className={css({
color: 'white',
width: '100%',
})}
style={{ color: fontColor }}
>
<Text variant="h3" margin={false}>
{participantName}
@@ -138,8 +144,10 @@ const Transcription = ({ row }: { row: TranscriptionRow }) => {
<p
className={css({
fontWeight: '400',
borderRadius: '4px',
padding: '0.125rem 0.25rem',
})}
style={{ fontSize, lineHeight }}
style={{ fontSize, lineHeight, backgroundColor }}
>
{displayText}
</p>
+37 -14
View File
@@ -273,17 +273,40 @@
"title": "Virtueller Hintergrund",
"selectedLabel": "Hintergrund angewendet:",
"apply": "Ersetze deinen Hintergrund:",
"descriptions": {
"0": "Gerilltes Holzmöbel",
"1": "Besprechungsraum",
"2": "Loft mit schwarzer Glaswand",
"3": "Esszimmer",
"4": "Holzregale",
"5": "Holztreppe",
"6": "Graue Bibliothek",
"7": "Kaffeetheke"
"personal": {
"title": "Meine Hintergründe",
"selectFileTooltip": "Wähle ein Bild aus, das als persönlicher Hintergrund verwendet werden soll",
"notLoggedInWarning": "Du bist nicht angemeldet, der persönliche Hintergrund wird nicht von einem Meeting zum anderen gespeichert.",
"warningUploadDisabled": "Persönliche Hintergründe werden derzeit nicht von einem Meeting zum anderen gespeichert.",
"uploadLimitReached": "Du kannst keine weiteren persönlichen Hintergründe hinzufügen.",
"uploadInProgress": "Datei wird hochgeladen…",
"errors": {
"close": "Schließen",
"file_too_large": {
"title": "Datei zu groß",
"description": "Die Datei ist zu groß. Bitte wähle eine Datei mit weniger als {{maxSize, number}} MB."
},
"invalid_file_type": {
"title": "Ungültiger Dateityp",
"description": "Der Dateityp wird nicht unterstützt. Bitte wähle eine {{allowedExtension}}-Datei."
}
}
},
"presets": {
"title": "Vorschläge",
"descriptions": {
"0": "Gerilltes Holzmöbel",
"1": "Besprechungsraum",
"2": "Loft mit schwarzer Glaswand",
"3": "Esszimmer",
"4": "Holzregale",
"5": "Holztreppe",
"6": "Graue Bibliothek",
"7": "Kaffeetheke"
}
}
},
"faceLandmarks": {
"title": "Visuelle Effekte",
"glasses": {
@@ -297,7 +320,7 @@
}
},
"sidePanel": {
"ariaLabel": "Seitenleiste",
"ariaLabel": "Seitenleiste - {{title}}",
"backToTools": "Zurück zu den Meeting-Tools",
"heading": {
"participants": "Teilnehmende",
@@ -332,11 +355,11 @@
"tools": {
"transcript": {
"title": "Transkribieren",
"body": "Das Gespräch aufzeichnen."
"body": "Wandelt Meetings in Text um."
},
"screenRecording": {
"title": "Aufzeichnen",
"body": "Das Meeting aufzeichnen."
"title": "Aufnehmen",
"body": "Speichert Meetings als Video."
}
}
},
@@ -597,7 +620,7 @@
},
"participantTileFocus": {
"containerLabel": "Optionen für {{name}}",
"toolbarHint": "Ctrl+Shift+/: Direkt auf die Tastenkürzel zugreifen.",
"toolbarHint": "{{shortcut}}: Direkt auf die Tastenkürzel zugreifen.",
"pin": {
"enable": "Anheften",
"disable": "Lösen"
+28
View File
@@ -126,6 +126,34 @@
"medium": "Mittel",
"large": "Groß"
}
},
"fontColor": {
"label": "Schriftfarbe",
"options": {
"default": "Standard",
"white": "Weiß",
"black": "Schwarz",
"blue": "Blau",
"green": "Grün",
"red": "Rot",
"yellow": "Gelb",
"cyan": "Cyan",
"magenta": "Magenta"
}
},
"backgroundColor": {
"label": "Hintergrundfarbe",
"options": {
"default": "Standard",
"white": "Weiß",
"black": "Schwarz",
"blue": "Blau",
"green": "Grün",
"red": "Rot",
"yellow": "Gelb",
"cyan": "Cyan",
"magenta": "Magenta"
}
}
}
},
+36 -14
View File
@@ -270,18 +270,40 @@
}
},
"virtual": {
"title": "Virtual background",
"title": "Virtual backgrounds",
"selectedLabel": "Background applied:",
"apply": "Replace your background:",
"descriptions": {
"0": "Fluted wooden furniture",
"1": "Meeting room",
"2": "Loft with black glass partition",
"3": "Dining room",
"4": "Wooden shelves",
"5": "Wooden staircase",
"6": "Gray library",
"7": "Coffee counter"
"personal": {
"title": "My backgrounds",
"selectFileTooltip": "Select an image file to use as a personal background",
"notLoggedInWarning": "You are not logged-in, personal backgrounds won't be saved from one meeting to the other.",
"warningUploadDisabled": "Personal backgrounds are currently not saved from one meeting to the other.",
"uploadLimitReached": "You cannot upload more personal backgrounds.",
"uploadInProgress": "Image is being uploaded…",
"errors": {
"close": "Close",
"file_too_large": {
"title": "File too large",
"description": "The file is too large. Please choose a file smaller than {{maxSize, number}} MB."
},
"invalid_file_type": {
"title": "Invalid file type",
"description": "The file type is not supported. Please choose a {{allowedExtension, list(type: 'disjunction')}} file."
}
}
},
"presets": {
"title": "Suggestions",
"descriptions": {
"0": "Fluted wooden furniture",
"1": "Meeting room",
"2": "Loft with black glass partition",
"3": "Dining room",
"4": "Wooden shelves",
"5": "Wooden staircase",
"6": "Gray library",
"7": "Coffee counter"
}
}
},
"faceLandmarks": {
@@ -297,7 +319,7 @@
}
},
"sidePanel": {
"ariaLabel": "Sidepanel",
"ariaLabel": "Sidepanel - {{title}}",
"backToTools": "Back to meeting tools",
"heading": {
"participants": "Participants",
@@ -332,11 +354,11 @@
"tools": {
"transcript": {
"title": "Transcribe",
"body": "Record the conversation."
"body": "Turn meetings into text."
},
"screenRecording": {
"title": "Record",
"body": "Record the meeting."
"body": "Save meetings as video."
}
}
},
@@ -597,7 +619,7 @@
},
"participantTileFocus": {
"containerLabel": "Options for {{name}}",
"toolbarHint": "Ctrl+Shift+/: access shortcuts directly.",
"toolbarHint": "{{shortcut}}: access shortcuts directly.",
"pin": {
"enable": "Pin",
"disable": "Unpin"
+28
View File
@@ -126,6 +126,34 @@
"medium": "Medium",
"large": "Large"
}
},
"fontColor": {
"label": "Font color",
"options": {
"default": "Default",
"white": "White",
"black": "Black",
"blue": "Blue",
"green": "Green",
"red": "Red",
"yellow": "Yellow",
"cyan": "Cyan",
"magenta": "Magenta"
}
},
"backgroundColor": {
"label": "Background color",
"options": {
"default": "Default",
"white": "White",
"black": "Black",
"blue": "Blue",
"green": "Green",
"red": "Red",
"yellow": "Yellow",
"cyan": "Cyan",
"magenta": "Magenta"
}
}
}
},
+36 -14
View File
@@ -270,18 +270,40 @@
}
},
"virtual": {
"title": "Arrière-plan virtuel",
"title": "Arrière-plans virtuels",
"selectedLabel": "Arrière-plan appliqué :",
"apply": "Remplacer votre arrière plan :",
"descriptions": {
"0": "Meuble cannelé en bois",
"1": "Salle de réunion",
"2": "Loft avec verrière noire",
"3": "Salle à manger",
"4": "Étagères en bois",
"5": "Escalier en bois",
"6": "Bibliothèque grise",
"7": "Comptoir de café"
"personal": {
"title": "Mes arrière-plans",
"selectFileTooltip": "Sélectionnez une image à utiliser comme arrière-plan",
"notLoggedInWarning": "Vous n'êtes pas connecté, l'arrière-plan personnel ne sera pas sauvegardé d'une réunion à l'autre.",
"warningUploadDisabled": "Les arrière-plans personnels ne sont actuellement pas sauvegardés d'une réunion à l'autre.",
"uploadLimitReached": "Vous ne pouvez pas ajouter plus d'arrière-plans personnels.",
"uploadInProgress": "Image en cours d'envoi…",
"errors": {
"close": "Fermer",
"file_too_large": {
"title": "Fichier trop volumineux",
"description": "Le fichier est trop volumineux. Veuillez choisir un fichier de moins de {{maxSize, number}} Mo."
},
"invalid_file_type": {
"title": "Type de fichier non valide",
"description": "Le type de fichier n'est pas pris en charge. Veuillez choisir un fichier {{allowedExtension}}."
}
}
},
"presets": {
"title": "Suggestions",
"descriptions": {
"0": "Meuble cannelé en bois",
"1": "Salle de réunion",
"2": "Loft avec verrière noire",
"3": "Salle à manger",
"4": "Étagères en bois",
"5": "Escalier en bois",
"6": "Bibliothèque grise",
"7": "Comptoir de café"
}
}
},
"faceLandmarks": {
@@ -297,7 +319,7 @@
}
},
"sidePanel": {
"ariaLabel": "Panneau latéral",
"ariaLabel": "Panneau latéral - {{title}}",
"backToTools": "Retour aux outils de réunion",
"heading": {
"participants": "Participants",
@@ -332,11 +354,11 @@
"tools": {
"transcript": {
"title": "Transcrire",
"body": "Enregistrer la conversation."
"body": "Transcrire la réunion."
},
"screenRecording": {
"title": "Enregistrer",
"body": "Enregistrer la réunion."
"body": "Enregistrer la réunion en vidéo."
}
}
},
@@ -597,7 +619,7 @@
},
"participantTileFocus": {
"containerLabel": "Options pour {{name}}",
"toolbarHint": "Ctrl+Shift+/ : accéder directement aux raccourcis.",
"toolbarHint": "{{shortcut}} : accéder directement aux raccourcis.",
"pin": {
"enable": "Épingler",
"disable": "Annuler l'épinglage"
+28
View File
@@ -126,6 +126,34 @@
"medium": "Moyen",
"large": "Grand"
}
},
"fontColor": {
"label": "Couleur du texte",
"options": {
"default": "Par défaut",
"white": "Blanc",
"black": "Noir",
"blue": "Bleu",
"green": "Vert",
"red": "Rouge",
"yellow": "Jaune",
"cyan": "Cyan",
"magenta": "Magenta"
}
},
"backgroundColor": {
"label": "Couleur de fond",
"options": {
"default": "Par défaut",
"white": "Blanc",
"black": "Noir",
"blue": "Bleu",
"green": "Vert",
"red": "Rouge",
"yellow": "Jaune",
"cyan": "Cyan",
"magenta": "Magenta"
}
}
}
},
+35 -13
View File
@@ -273,15 +273,37 @@
"title": "Virtuele achtergrond",
"selectedLabel": "Achtergrond toegepast:",
"apply": "Vervang je achtergrond:",
"descriptions": {
"0": "Geprofileerd houten meubel",
"1": "Vergaderruimte",
"2": "Loft met zwarte glaswand",
"3": "Eetkamer",
"4": "Houten planken",
"5": "Houten trap",
"6": "Grijze bibliotheek",
"7": "Koffiebar"
"personal": {
"title": "Mijn achtergronden",
"selectFileTooltip": "Selecteer een afbeeldingsbestand om te gebruiken als persoonlijke achtergrond",
"notLoggedInWarning": "U bent niet ingelogd, persoonlijke achtergronden worden niet opgeslagen van de ene vergadering naar de andere.",
"warningUploadDisabled": "Persoonlijke achtergronden worden momenteel niet opgeslagen van de ene vergadering naar de andere.",
"uploadLimitReached": "U kunt geen persoonlijke achtergronden meer uploaden.",
"uploadInProgress": "Afbeelding wordt geüpload…",
"errors": {
"close": "Sluiten",
"file_too_large": {
"title": "Bestand te groot",
"description": "Het bestand is te groot. Kies een bestand kleiner dan {{maxSize, number}} MB."
},
"invalid_file_type": {
"title": "Ongeldig bestandstype",
"description": "Het bestandstype wordt niet ondersteund. Kies een {{allowedExtension, list(type: 'disjunction')}} bestand."
}
}
},
"presets": {
"title": "Suggesties",
"descriptions": {
"0": "Geprofileerd houten meubel",
"1": "Vergaderruimte",
"2": "Loft met zwarte glaswand",
"3": "Eetkamer",
"4": "Houten planken",
"5": "Houten trap",
"6": "Grijze bibliotheek",
"7": "Koffiebar"
}
}
},
"faceLandmarks": {
@@ -297,7 +319,7 @@
}
},
"sidePanel": {
"ariaLabel": "Zijbalk",
"ariaLabel": "Zijbalk - {{title}}",
"backToTools": "Terug naar vergadertools",
"heading": {
"participants": "Deelnemers",
@@ -332,11 +354,11 @@
"tools": {
"transcript": {
"title": "Transcriberen",
"body": "Het gesprek opnemen."
"body": "Zet vergaderingen om in tekst."
},
"screenRecording": {
"title": "Opnemen",
"body": "De vergadering opnemen."
"body": "Sla vergaderingen op als video."
}
}
},
@@ -597,7 +619,7 @@
},
"participantTileFocus": {
"containerLabel": "Opties voor {{name}}",
"toolbarHint": "Ctrl+Shift+/: direct toegang tot de sneltoetsen.",
"toolbarHint": "{{shortcut}}: direct toegang tot de sneltoetsen.",
"pin": {
"enable": "Pinnen",
"disable": "Losmaken"
+28
View File
@@ -126,6 +126,34 @@
"medium": "Gemiddeld",
"large": "Groot"
}
},
"fontColor": {
"label": "Tekstkleur",
"options": {
"default": "Standaard",
"white": "Wit",
"black": "Zwart",
"blue": "Blauw",
"green": "Groen",
"red": "Rood",
"yellow": "Geel",
"cyan": "Cyaan",
"magenta": "Magenta"
}
},
"backgroundColor": {
"label": "Achtergrondkleur",
"options": {
"default": "Standaard",
"white": "Wit",
"black": "Zwart",
"blue": "Blauw",
"green": "Groen",
"red": "Rood",
"yellow": "Geel",
"cyan": "Cyaan",
"magenta": "Magenta"
}
}
}
},
+5 -1
View File
@@ -20,7 +20,11 @@ export const Spinner = ({
const r = 14 - strokeWidth
const c = 2 * r * Math.PI
return (
<ProgressBar aria-label="Loading..." value={30}>
<ProgressBar
aria-label="Loading..."
value={30}
style={{ height: size, width: size }}
>
{({ percentage }) => (
<div
className={css({

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