Compare commits

..

55 Commits

Author SHA1 Message Date
lebaudantoine 68e999a037 🔖(minor) bump release to 1.24.0 2026-07-21 18:01:20 +02:00
lebaudantoine 34103bf326 🩹(agents) announce the update of uv.lock via prepare-release script
Announce it among the other files. Minor issue.
2026-07-21 18:01:20 +02:00
lebaudantoine e273ac9e43 🔖(helm) release chart 0.0.27 2026-07-21 17:26:01 +02:00
Florent Chehab adcdbc0695 🐛(summary) whisper call error handling
* Consider that HTTP 400 errors are due to corrupted
audio files by  default.
* Properly reraise the http error otherwise so that
the retry mechanism actually works.
2026-07-21 12:05:06 +02:00
lebaudantoine 8f9008f1e0 🔖(addons) remove the beta tag
Promote the addon to a stable v1 release.
2026-07-21 10:30:00 +02:00
lebaudantoine 9320a1af0c (addon) show add-in tools when creating meetings in shared calendars
The existing manifest was not exposing the add-in tools when
creating a meeting in a shared calendar. Amend the manifest with the
missing instructions so the add-in shows up in that context as well.

Manifest changes generated with Claude's assistance.
2026-07-21 10:30:00 +02:00
David Wagner e3827970c8 (docs) Fix CSS theming env var name
While here, also mention *where* this env var should be set.
2026-07-20 12:38:07 +02:00
lebaudantoine ab707d866f 🩹(backend) fix the mail-builder step in docker
Upgrading mjml now requires node >=22. I forgot to update the step,
responsible to build the mail template in the production image.
2026-07-13 11:32:46 +02:00
snyk-bot dac0b9c000 ⬆️(frontend) upgrade react-aria related dependencies
Initiated by Snyk.
2026-07-13 11:32:46 +02:00
Cyril 53cc8642eb ️(frontend) fix focus restore when switching side panels
restore trigger focus on panel switch while panel remains open
2026-07-11 16:08:57 +02:00
Cyril c8d9d2fea8 ️(frontend) focus side panel container on open
Focus aside on side panel open so screen readers announce the aria-label
2026-07-11 16:08:57 +02:00
snyk-bot e910b1f0b7 ⬆️(frontend) upgrade posthog-js from 1.387.0 to 1.391.2
Snyk has created this PR to upgrade posthog-js from 1.387.0 to 1.391.2.

See this package in npm:
posthog-js

See this project in Snyk:
https://app.eu.snyk.io/org/lasuite-dinum-default/project/96ea03d8-8d09-493d-86bf-363f274e129e?utm_source=github&utm_medium=referral&page=upgrade-pr
2026-07-11 16:06:18 +02:00
lebaudantoine 581e115c03 ♻️(frontend) inline MediaPipe WASM modules to avoid loading from remote
Copy the MediaPipe WASM modules into the frontend build output and
serve them locally, instead of loading them from the Google CDN at
runtime.

Mediapipe was a transitive dependency, add it explicitly.

Requested by some members of the community.
It closes #1168
2026-07-11 15:57:59 +02:00
lebaudantoine 8118ef0612 🎨(frontend) format vite config file
Apply the project's formatter to the Vite config file to align its
style with the rest of the codebase.
2026-07-11 15:57:59 +02:00
lebaudantoine 85f886d9c5 (frontend) install vite-plugin-static-copy for MediaPipe WASM assets
Add vite-plugin-static-copy as a dependency. It will be used to copy
and serve the WASM modules shipped by the MediaPipe JS dependency
from the frontend build output.
2026-07-11 15:57:59 +02:00
lebaudantoine b66f82e4b7 ♻️(frontend) reorganize JS packages in package.json
Sort and regroup the entries in package.json that were
not in the expected order.
2026-07-11 15:57:59 +02:00
lebaudantoine aabb7d629c ♻️(frontend) inline model weights to avoid loading them from remote
Inline the model weights instead of fetching them from a Google
remote location at runtime. The weights do not update frequently, so
inlining removes an external dependency.
2026-07-11 15:57:59 +02:00
lebaudantoine 1e5f5c4fe9 ♻️(frontend) refactor background processors to use the new API
Update the calls to the background processors imported from
livekit-track-processor so they use the new instantiation method
instead of the previous, now-deprecated one.
2026-07-11 15:57:59 +02:00
lebaudantoine f55fa0c42b 🐛(backend) fix info panel crash for unregistered rooms
The info panel was crashing when opening a room that was not
registered in the database (with ALLOW_UNREGISTERED_ROOMS=true),
because the API response did not include the room slug.

Instead of adding frontend fallbacks, update the unregistered-room
response to include the slug, keeping the API contract consistent
with registered rooms.

Note: this still relies on the unregistered-room response staying
aligned with the registered-room schema. Any future divergence
between the two responses could introduce similar issues.
A refactoring on the backend side is needed.

It closes #1441.
2026-07-10 19:45:56 +02:00
lebaudantoine 2bc5e47c75 🚸(frontend) initialize the join input name with the persisted full name
Prefill the "name" field on the join screen with the full name
persisted in the database, instead of leaving it empty by default.
2026-07-10 19:02:26 +02:00
lebaudantoine 0d5136206f (all) allow forcing SSO display name for authenticated users
Add a new setting that controls whether an authenticated user can
rename or modify their display name, or if they must use the one
returned by the SSO.

When the setting is disabled, only anonymous users can set a display
name freely; authenticated users always use their SSO display name.

Requested by many self-hosters.
2026-07-10 19:02:26 +02:00
lebaudantoine 05a67320b1 🩹(backend) fix the LiveKit token to use full name as display name
Use the user's full name as the display name in the emitted LiveKit
token instead of the email.

Requested by several self-hosters.
2026-07-10 19:02:26 +02:00
lebaudantoine 2fbaa49089 ♻️(frontend) refactor the username storage
Create a new dedicated store for user choices, starting with the
username, to decouple this part from any LiveKit elements. This is
better for tree-shaking, and depending on LiveKit for storing the
username brings no real value.

The refactoring will be continued later for the other user choices
that can be persisted.
2026-07-10 19:02:26 +02:00
lebaudantoine 76542b2235 🔧(tilt) configure Tilt stack for full-name and short-name mapping
Update the Tilt dev stack configuration so that the full-name and
short-name claim mapping works correctly with the current Keycloak
configuration.
2026-07-10 19:02:26 +02:00
lebaudantoine 10231b0333 🩹(backend) identify externally provisioned users to PostHog
In the external API, applications authenticate with a
client_id/secret and can provision users with only an email. In that
flow, users are not identified to PostHog, so the user DB id alone
is not enough to identify them in analytics afterwards.

The issue does not exist in the public API, where users are
authenticated and therefore already identified.

Inspired by @flochehab's approach in summary.
2026-07-10 10:47:04 +02:00
Cyril 9b9e6578ee (frontend) add participant color gradient when camera is off
use participant color radial gradient for video-off placeholder
2026-07-10 10:46:46 +02:00
lebaudantoine 0c81d29ee7 (backend) allow searching the recording admin table by owner email
Add the owner's email to the searchable fields of the recording
admin table. This makes it much more convenient for the support team
to look up recordings by user.
2026-07-10 10:15:57 +02:00
Cyril 839a8f1c71 💄(frontend) add consistent spacing to picture-in-picture tiles
Add padding to StageFrame and bigger gaps so PiP tiles aren't flush with window.
2026-07-10 08:45:53 +02:00
Cyril 40ef420cda (frontend) use focus layout for solo PiP screen sharing
Solo cam in screen share PiP is now a BR thumbnail over shared screen
2026-07-10 08:45:51 +02:00
Cyril d995bd9041 (frontend) prioritize screen share in picture-in-picture layout
Show screen share large with cameras in a top row; match main room focus.
2026-07-10 08:45:51 +02:00
lebaudantoine 8ac4c2409d 💚(mail) update the node version for the mail building step
Broken with the dependencies update.
2026-07-09 16:22:54 +02:00
lebaudantoine e47ad42656 ⬆️(mail) update mjml to v5 and @html-to/text-cli
@html-to/text-cli was containing CVEs reported by the audit.
2026-07-09 16:22:54 +02:00
Florent Chehab 2509452c52 🗑️(settings) deprecate SUMMARY_SERVICE_VERSION=1
Add a deprecation warning when SUMMARY_SERVICE_VERSION=1 and
an endpoint is configured meaning summary service is in use.
2026-07-09 16:14:23 +02:00
Florent Chehab 1022780027 📝(upgrade) add note related to summary v1 removal
Add upgrade notes related to the summary changes in v1.23.0
2026-07-09 15:49:53 +02:00
Florent Chehab 4a1a04f86e 🔖(minor) bump release to 1.23.0 2026-07-08 10:07:09 +02:00
Florent Chehab 8702638cf3 (release) uv lock agents
In release script also run uv lock for agents.
2026-07-08 10:06:53 +02:00
Florent Chehab f88c0307ea 🐛(summary) do not save null emails in analytics
In case the email is not provided we should not update
the analytics email value.
2026-07-08 02:09:30 +02:00
Florent Chehab e7f15b50ff (summary) more precise analytics events
* Specific transcript and summary events
* Improve observability on summary tasks
2026-07-08 02:09:30 +02:00
snyk-bot 75ba2ff146 🔒️(frontend) update docker image to nginx-unprivileged:1.30.3-alpine3.23
The following vulnerabilities are fixed with an upgrade:
- https://snyk.io/vuln/SNYK-ALPINE323-EXPAT-17675120
- https://snyk.io/vuln/SNYK-ALPINE323-EXPAT-17675112
- https://snyk.io/vuln/SNYK-ALPINE323-EXPAT-17675114
- https://snyk.io/vuln/SNYK-ALPINE323-EXPAT-17675124
- https://snyk.io/vuln/SNYK-ALPINE323-EXPAT-17675125
2026-07-08 01:48:59 +02:00
lebaudantoine bbe2a32efc ⬆️(frontend) update the frontend build image to Node 22
Bump the Node.js version used in the frontend build image from
Node 20 to Node 22.
2026-07-08 01:39:20 +02:00
snyk-bot 86fff16eed ⬆️(frontend) upgrade @tanstack/react-query from 5.100.14 to 5.101.0
Snyk has created this PR to upgrade @tanstack/react-query from 5.100.14 to 5.101.0.
2026-07-08 01:02:50 +02:00
snyk-bot 195d2b5006 ⬆️(frontend) upgrade posthog-js from 1.386.5 to 1.387.0
Snyk has created this PR to upgrade posthog-js from 1.386.5 to 1.387.0.
2026-07-08 00:56:41 +02:00
snyk-bot 30cf264276 ⬆️(frontend) upgrade livekit-client from 2.19.0 to 2.19.2
Snyk has created this PR to upgrade livekit-client from 2.19.0 to 2.19.2.
2026-07-08 00:48:27 +02:00
lebaudantoine 945d779b45 ♻️(frontend) wrap MuteEveryoneButton with AdminOrOwnerOnly
Avoids mounting the button's hooks and rendering its logic for users who
are not admin or owner, which previously happened on every re-render
of the parent.
2026-07-07 19:11:41 +02:00
leo d43b335546 (agents) add Sentry instrumentation for agents
Add Sentry observability to the `agents`. Introduce a dedicated
`observability.py` module. Refactor both agents to use the shared
observability layer and extract task helpers into a new `tasks.py` module,
making task execution easier to instrument and maintain.
2026-07-07 14:56:21 +02:00
Florent Chehab e6f0ff5112 🔥(summary) remove call to summary enabled feature flag
We now rely only on the value coming from
the API request. This feature flag should
be handled by the caller.
2026-07-07 12:09:19 +02:00
Florent Chehab 224d6ce358 (backend) use feature flag in call to summary-v2
Summary enabled is now infered directly from
the feature flag analytics backend.
2026-07-07 12:09:19 +02:00
Florent Chehab 20718f2d5a (backend) implement feature flags in Posthog analytics backend
Implement feature flags related functions in
Posthog analytics backend.
We cache the results in django cache to avoid
too frequent calls to Posthog.
Especially for when we are checking multiple features in
the same user request.
2026-07-07 12:09:19 +02:00
Florent Chehab 8edcb6e973 (backend) extend analytics module to support feature flags
Extend the AnalyticsBackend class to support
user feature flags, without a breaking change to the
current implementation.

A flag value is considered to be a bool or a string.
2026-07-07 12:09:19 +02:00
lebaudantoine b00c4e5d6d ♻️(backend) refactor analytics backend from Protocol to abstract class
Switch the analytics backend interface from a typing Protocol to an
abstract base class, so the contract that backends must implement
is explicit and enforced at instantiation time.
2026-07-07 11:51:11 +02:00
Florent Chehab 3c0ba03976 ♻️(backend) refactor Bearer Auth based authentification
Created a shared class for handling Header based authentification.
This avoid duplicating logic and helps with a maintaining a signle
security related peace of code.
2026-07-06 20:53:29 +02:00
Florent Chehab 7fa172d100 (visio) use compatible with summary v2
This commit introduces the compatibility with summary v2.
It tecnically doesn't break the compatibility with v1 as
v1 params are still sent. But we advise people using the
transcribe feature in their own deployments to adapt to the
new v2 API, as this compatibility will be removed in a
future major version.

* RecordingStatusChoices now has
EXTERNAL_PROCESS_SUCCESSFUL
& EXTERNAL_PROCESS_FAILED
Values, which are changed by a new webhook that
can be called by the transcribe service.
This webhook is protected by its own bearer token.
* Title for the document is computed in visio,
* Tests are added / updated accordingly
2026-07-06 20:53:29 +02:00
Florent Chehab ecf8f0fe3f (summary) extend v2 routes to support visio usecase
* Update the v2 create transcribe payload to support
publishing directly to docs and performing automatically
a summary if requested
* Note that title computation is to be handled by the caller
now as it makes more sense and avoids throwing a bunch
of parameters to the endpoint.
* All files are send as signed URL now, we don't read
directly from s3 anymore,
* Docs integration is now explicit in summary settings
* To make analytics work properly accross projects,
we use the user sub as distinct id,
this will require a new posthog project to be deployed
to work properly.
We create a post hog event at the creation request processing,
to make sure feature flags work properly after that.
* User email should now be provided to the API, it's not
mandatory to avoid a breaking change.
* Use a specific user agent for better tracking
Finally note that existing helpers don't always make use of
 the pydantic models, so that's why there
are model_dump in some places. To avoid bigger changes.
2026-07-06 20:53:29 +02:00
Florent Chehab 27da57aff5 💥(summary) remove v1 related code
This commit cleans up most of the code related to v1 route that
is used only by visio.
This is first step before introducing an update to the v2 route.
2026-07-06 20:53:29 +02:00
leo e327a5e35f ⬆️(dependencies) update python dependencies
Update python dependencies.
2026-07-06 20:43:54 +02:00
114 changed files with 5595 additions and 2530 deletions
+1 -2
View File
@@ -82,7 +82,7 @@ jobs:
- name: Install Node.js - name: Install Node.js
uses: actions/setup-node@v6 uses: actions/setup-node@v6
with: with:
node-version: "18" node-version: "22"
- name: Restore the mail templates - name: Restore the mail templates
uses: actions/cache@v5 uses: actions/cache@v5
@@ -305,7 +305,6 @@ jobs:
working-directory: src/summary working-directory: src/summary
env: env:
V1_TENANT_ID: 'test-tenant'
AUTHORIZED_TENANTS: '[{"id": "test-tenant", "api_key": "test-api-token", "webhook_url": "https://example.com/webhook", "webhook_api_key": "test-webhook-api-key"}]' AUTHORIZED_TENANTS: '[{"id": "test-tenant", "api_key": "test-api-token", "webhook_url": "https://example.com/webhook", "webhook_api_key": "test-webhook-api-key"}]'
AWS_STORAGE_BUCKET_NAME: "http://meet-media-storage" AWS_STORAGE_BUCKET_NAME: "http://meet-media-storage"
AWS_S3_ENDPOINT_URL: "minio:9000" AWS_S3_ENDPOINT_URL: "minio:9000"
+50
View File
@@ -8,10 +8,59 @@ and this project adheres to
## [Unreleased] ## [Unreleased]
## [1.24.0] - 2026-07-21
### Added
- ✨(backend) allow searching the recording admin table by owner email
- ✨(frontend) add participant color gradient when camera is off #1490
- ✨(all) allow forcing SSO display name for authenticated users
- (frontend) install vite-plugin-static-copy for MediaPipe WASM assets
- ✨(addon) show add-in tools when creating meetings in shared calendars
### Changed
- 🗑️(settings) deprecate SUMMARY_SERVICE_VERSION=1
- ⬆️(mail) update mjml to v5 and @html-to/text-cli
- 🚸(frontend) initialize the join input name with the persisted full name
- ♻️(frontend) refactor background processors to use the new API
- ♻️(frontend) inline model weights to avoid loading them from remote
- ♻️(frontend) inline MediaPipe WASM modules to avoid loading from remote
- ⬆️(frontend) upgrade posthog-js from 1.387.0 to 1.391.2
- ⬆️(frontend) upgrade react-stately from 3.47.0 to 3.48.0
- ⬆️(frontend) upgrade react-aria from 3.49.0 to 3.50.0
- ⬆️(frontend) upgrade react-aria-components from 1.18.0 to 1.19.0
### Fixed
- 🩹(backend) identify externally provisioned users to PostHog
- 🐛(backend) fix info panel crash for unregistered rooms
- ♿️(frontend) focus side panel container on open #1452
- 🐛(summary) whisper call error handling
## [1.23.0] - 2026-07-08
### Added
- ✨(backend) extend analytics module to support feature flags
- ✨(backend) implement feature flags in Posthog analytics backend
- ✨(agents) report errors to Sentry for all LiveKit agents
### Changed ### Changed
- ⬆️(agents) upgrade to python 3.14 slim - ⬆️(agents) upgrade to python 3.14 slim
- ⬆️(dependencies) update python dependencies - ⬆️(dependencies) update python dependencies
- 💥(summary) remove v1 related code #1362
- ✨(meet) use compatible with summary v2 #1362
- ♻️(backend) refactor analytics backend from Protocol to abstract class
- 🔥(summary) remove call to summary enabled feature flag
- ♻️(frontend) wrap MuteEveryoneButton with AdminOrOwnerOnly
- ⬆️(frontend) upgrade livekit-client from 2.19.0 to 2.19.2
- ⬆️(frontend) upgrade posthog-js from 1.386.5 to 1.387.0
- ⬆️(frontend) upgrade @tanstack/react-query from 5.100.14 to 5.101.0
- ⬆️(frontend) update the frontend build image to Node 22
- 🔒️(frontend) update docker image to nginx-unprivileged:1.30.3-alpine3.23
- ✨(summary) more precise analytics events
### Fixed ### Fixed
@@ -31,6 +80,7 @@ and this project adheres to
- ✨(backend) add fallback to save recordings without S3/MinIO webhooks - ✨(backend) add fallback to save recordings without S3/MinIO webhooks
- 🩹(frontend) enable screen share button in PiP #1458 - 🩹(frontend) enable screen share button in PiP #1458
- 🐛(backend) support unencoded S3 notification object keys #1455 - 🐛(backend) support unencoded S3 notification object keys #1455
- ✨(frontend) prioritize screen share in picture-in-picture layout #1467
### Changed ### Changed
+1 -1
View File
@@ -37,7 +37,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-dev uv sync --locked --no-dev
# ---- mails ---- # ---- mails ----
FROM node:20 AS mail-builder FROM node:22 AS mail-builder
COPY ./src/mail /mail/app COPY ./src/mail /mail/app
+12
View File
@@ -15,3 +15,15 @@ the following command inside your docker container:
(Note : in your development environment, you can `make migrate`.) (Note : in your development environment, you can `make migrate`.)
## [Unreleased] ## [Unreleased]
## v1.23.0
As part of the 1.23.0 release, the legacy `api/v1` implementation has been removed from the _experimental_ Summary service and Meet has been migrated to the new `api/v2`.
**To avoid a breaking change, the Meet backend continues to use the Summary service's v1-compatible API format by default (`SUMMARY_SERVICE_VERSION` setting defaults to `1`).**
If you are deploying both Meet and Summary from this repository, you must configure the Meet backend to use the v2 API by setting the following environment variable `SUMMARY_SERVICE_VERSION=2`.
If you are upgrading only the Meet deployment while keeping an older Summary v1 compatible deployment, no action is required, as the v1-compatible API remains the default.
Note that we plan on removing the legacy `v1` summary compatibility in a future major version. If you have your own implementation for the summary service, we recommend updating its API contract and setting `SUMMARY_SERVICE_VERSION=2`.
+7
View File
@@ -113,6 +113,12 @@ update_python_version "summary"
# Update agents pyproject.toml # Update agents pyproject.toml
update_python_version "agents" update_python_version "agents"
# Run uv lock in agents
print_info "Running uv lock in agents..."
cd "src/agents"
uv lock
cd -
# Update CHANGELOG # Update CHANGELOG
print_info "Updating CHANGELOG..." print_info "Updating CHANGELOG..."
@@ -158,6 +164,7 @@ echo " - src/backend/pyproject.toml"
echo " - src/backend/uv.lock" echo " - src/backend/uv.lock"
echo " - src/summary/pyproject.toml" echo " - src/summary/pyproject.toml"
echo " - src/agents/pyproject.toml" echo " - src/agents/pyproject.toml"
echo " - src/agents/uv.lock"
echo " - CHANGELOG.md" echo " - CHANGELOG.md"
echo "" echo ""
print_warning "Next steps:" print_warning "Next steps:"
+2 -2
View File
@@ -301,7 +301,7 @@ services:
context: ./src/summary context: ./src/summary
dockerfile: Dockerfile dockerfile: Dockerfile
target: production target: production
command: celery -A summary.core.celery_worker worker --pool=solo --loglevel=debug -Q transcribe-queue command: celery -A summary.core.celery_worker worker --pool=solo --loglevel=debug -Q transcribe-queue-v2
env_file: env_file:
- env.d/development/summary - env.d/development/summary
volumes: volumes:
@@ -321,7 +321,7 @@ services:
context: ./src/summary context: ./src/summary
dockerfile: Dockerfile dockerfile: Dockerfile
target: production target: production
command: celery -A summary.core.celery_worker worker --pool=solo --loglevel=debug -Q summarize-queue command: celery -A summary.core.celery_worker worker --pool=solo --loglevel=debug -Q summarize-queue-v2
env_file: env_file:
- env.d/development/summary - env.d/development/summary
volumes: volumes:
+2 -2
View File
@@ -1,5 +1,5 @@
# ---- Front-end image ---- # ---- Front-end image ----
FROM node:20-alpine AS frontend-deps FROM node:22-alpine AS frontend-deps
WORKDIR /home/frontend/ WORKDIR /home/frontend/
@@ -54,7 +54,7 @@ RUN npx webpack --mode production
# ---- Front-end image ---- # ---- Front-end image ----
FROM nginxinc/nginx-unprivileged:alpine3.23 AS frontend-production FROM nginxinc/nginx-unprivileged:1.30.3-alpine3.23 AS frontend-production
USER root USER root
+4 -4
View File
@@ -11,14 +11,14 @@ There are two ways to customize LaSuite Meet:
### How to Use ### How to Use
To use this feature, simply set the `FRONTEND_CSS_URL` environment variable to the URL of your custom CSS file. For example: To use this feature, simply set the `FRONTEND_CUSTOM_CSS_URL` environment variable (of the **backend** service) to the URL of your custom CSS file. For example:
```javascript ```javascript
FRONTEND_CSS_URL=https://example.com/custom-style.css FRONTEND_CUSTOM_CSS_URL=https://example.com/custom-style.css
``` ```
> [!TIP] > [!TIP]
> If you serve your CSS file on the same domain as LaSuite Meet, paths are supported, i.e. `FRONTEND_CSS_URL=/custom/style.css` will load `https://your-domain.com/custom/style.css`. > If you serve your CSS file on the same domain as LaSuite Meet, paths are supported, i.e. `FRONTEND_CUSTOM_CSS_URL=/custom/style.css` will load `https://your-domain.com/custom/style.css`.
Setting this variable makes the app load your CSS at runtime, adding a `<link>` to `<head>` so you can override CSS variables and customize the frontend without rebuilding. Setting this variable makes the app load your CSS at runtime, adding a `<link>` to `<head>` so you can override CSS variables and customize the frontend without rebuilding.
@@ -37,7 +37,7 @@ Let's say you want to change the font of our application to a custom font. You c
} }
``` ```
Then, set the `FRONTEND_CSS_URL` environment variable to the URL of your custom CSS file. Once you've done this, our application will load your custom CSS file and apply the styles, changing the default font to the one you specified. Then, set the `FRONTEND_CUSTOM_CSS_URL` environment variable to the URL of your custom CSS file. Once you've done this, our application will load your custom CSS file and apply the styles, changing the default font to the one you specified.
> [!IMPORTANT] > [!IMPORTANT]
> You can override any CSS token—semantic or palette. See [panda.config.ts](../src/frontend/panda.config.ts) for all defined semantic tokens. > You can override any CSS token—semantic or palette. See [panda.config.ts](../src/frontend/panda.config.ts) for all defined semantic tokens.
+2 -1
View File
@@ -65,8 +65,9 @@ ALLOW_UNREGISTERED_ROOMS=False
RECORDING_ENABLE=True RECORDING_ENABLE=True
RECORDING_STORAGE_EVENT_ENABLE=True RECORDING_STORAGE_EVENT_ENABLE=True
RECORDING_STORAGE_EVENT_TOKEN=password RECORDING_STORAGE_EVENT_TOKEN=password
SUMMARY_SERVICE_ENDPOINT=http://app-summary-dev:8000/api/v1/tasks/ SUMMARY_SERVICE_ENDPOINT=http://app-summary-dev:8000/api/v2/async-jobs/transcribe/
SUMMARY_SERVICE_API_TOKEN=password SUMMARY_SERVICE_API_TOKEN=password
SUMMARY_SERVICE_WEBHOOK_API_TOKEN=webhook-password
RECORDING_DOWNLOAD_BASE_URL=http://localhost:3000/recording RECORDING_DOWNLOAD_BASE_URL=http://localhost:3000/recording
# Recording encoding (LiveKit Egress advanced options). # Recording encoding (LiveKit Egress advanced options).
@@ -9,3 +9,6 @@ DEEPGRAM_API_KEY=
KYUTAI_STT_BASE_URL= KYUTAI_STT_BASE_URL=
KYUTAI_API_KEY= KYUTAI_API_KEY=
SENTRY_DSN=
SENTRY_ENVIRONMENT=
+1
View File
@@ -30,3 +30,4 @@ POSTHOG_ENABLED="False"
# Transcription # Transcription
TRANSCRIPTION_SATISFACTION_FORM_BASE_URL= TRANSCRIPTION_SATISFACTION_FORM_BASE_URL=
AUTHORIZED_TENANTS='[{"id": "meet","api_key": "password","webhook_url": "https://configure-your-url.com/api/v1.0/recordings/external-process-hook/","webhook_api_key": "webhook-password","allowed_push_to_docs": true}]'
+170 -1
View File
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?> <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<OfficeApp xmlns="http://schemas.microsoft.com/office/appforoffice/1.1" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:bt="http://schemas.microsoft.com/office/officeappbasictypes/1.0" xmlns:mailappor="http://schemas.microsoft.com/office/mailappversionoverrides/1.0" xsi:type="MailApp"> <OfficeApp xmlns="http://schemas.microsoft.com/office/appforoffice/1.1" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:bt="http://schemas.microsoft.com/office/officeappbasictypes/1.0" xmlns:mailappor="http://schemas.microsoft.com/office/mailappversionoverrides/1.0" xsi:type="MailApp">
<Id>a025f0f6-757a-4790-97f3-99c66c4a5795</Id> <Id>a025f0f6-757a-4790-97f3-99c66c4a5795</Id>
<Version>0.0.2.0</Version> <Version>1.0.0.0</Version>
<ProviderName>__APP_NAME__</ProviderName> <ProviderName>__APP_NAME__</ProviderName>
<DefaultLocale>fr-FR</DefaultLocale> <DefaultLocale>fr-FR</DefaultLocale>
<DisplayName DefaultValue="__APP_NAME__"/> <DisplayName DefaultValue="__APP_NAME__"/>
@@ -205,5 +205,174 @@
</bt:String> </bt:String>
</bt:LongStrings> </bt:LongStrings>
</Resources> </Resources>
<!-- ─── V1.1 override: required for shared folder / delegate support ─── -->
<VersionOverrides xmlns="http://schemas.microsoft.com/office/mailappversionoverrides/1.1" xsi:type="VersionOverridesV1_1">
<Requirements>
<bt:Sets DefaultMinVersion="1.8">
<bt:Set Name="Mailbox"/>
</bt:Sets>
</Requirements>
<Hosts>
<Host xsi:type="MailHost">
<DesktopFormFactor>
<FunctionFile resid="Commands.Url"/>
<SupportsSharedFolders>true</SupportsSharedFolders>
<!-- ─── Mail: Read ─────────────────────────────────────────── -->
<ExtensionPoint xsi:type="MessageReadCommandSurface">
<OfficeTab id="TabDefault">
<Group id="msgReadGroup">
<Label resid="GroupLabel"/>
<Control xsi:type="Button" id="msgReadOpenPaneButton">
<Label resid="TaskpaneButton.Label"/>
<Supertip>
<Title resid="TaskpaneButton.Label"/>
<Description resid="TaskpaneButton.Tooltip"/>
</Supertip>
<Icon>
<bt:Image size="16" resid="Icon.16x16"/>
<bt:Image size="32" resid="Icon.32x32"/>
<bt:Image size="80" resid="Icon.80x80"/>
</Icon>
<Action xsi:type="ShowTaskpane">
<SourceLocation resid="Taskpane.Url"/>
</Action>
</Control>
</Group>
</OfficeTab>
</ExtensionPoint>
<!-- ─── Mail: Compose ─────────────────────────────────────── -->
<ExtensionPoint xsi:type="MessageComposeCommandSurface">
<OfficeTab id="TabDefault">
<Group id="msgComposeGroup">
<Label resid="GroupLabel"/>
<Control xsi:type="Button" id="msgComposeGenerateLinkButton">
<Label resid="GenerateLink.Label"/>
<Supertip>
<Title resid="GenerateLink.Label"/>
<Description resid="GenerateLink.Tooltip"/>
</Supertip>
<Icon>
<bt:Image size="16" resid="Icon.16x16"/>
<bt:Image size="32" resid="Icon.32x32"/>
<bt:Image size="80" resid="Icon.80x80"/>
</Icon>
<Action xsi:type="ExecuteFunction">
<FunctionName>generateMeetingLinkFromMail</FunctionName>
</Action>
</Control>
<Control xsi:type="Button" id="msgComposeOpenPaneButton">
<Label resid="TaskpaneButton.Label"/>
<Supertip>
<Title resid="TaskpaneButton.Label"/>
<Description resid="TaskpaneButton.Tooltip"/>
</Supertip>
<Icon>
<bt:Image size="16" resid="Settings.16x16"/>
<bt:Image size="32" resid="Settings.32x32"/>
<bt:Image size="80" resid="Settings.80x80"/>
</Icon>
<Action xsi:type="ShowTaskpane">
<SourceLocation resid="Taskpane.Url"/>
</Action>
</Control>
</Group>
</OfficeTab>
</ExtensionPoint>
<!-- ─── Calendar: Compose (New/Edit appointment) ──────────── -->
<ExtensionPoint xsi:type="AppointmentOrganizerCommandSurface">
<OfficeTab id="TabDefault">
<Group id="apptComposeGroup">
<Label resid="GroupLabel"/>
<Control xsi:type="Button" id="apptGenerateLinkButton">
<Label resid="GenerateLink.Label"/>
<Supertip>
<Title resid="GenerateLink.Label"/>
<Description resid="GenerateLink.Tooltip"/>
</Supertip>
<Icon>
<bt:Image size="16" resid="Icon.16x16"/>
<bt:Image size="32" resid="Icon.32x32"/>
<bt:Image size="80" resid="Icon.80x80"/>
</Icon>
<Action xsi:type="ExecuteFunction">
<FunctionName>generateMeetingLinkFromCalendar</FunctionName>
</Action>
</Control>
<Control xsi:type="Button" id="apptOpenSettingsButton">
<Label resid="OpenSettings.Label"/>
<Supertip>
<Title resid="OpenSettings.Label"/>
<Description resid="OpenSettings.Tooltip"/>
</Supertip>
<Icon>
<bt:Image size="16" resid="Settings.16x16"/>
<bt:Image size="32" resid="Settings.32x32"/>
<bt:Image size="80" resid="Settings.80x80"/>
</Icon>
<Action xsi:type="ShowTaskpane">
<SourceLocation resid="Taskpane.Url"/>
</Action>
</Control>
</Group>
</OfficeTab>
</ExtensionPoint>
</DesktopFormFactor>
</Host>
</Hosts>
<Resources>
<bt:Images>
<bt:Image id="Settings.16x16" DefaultValue="https://localhost:3000/addons/outlook/assets/settings-16.png"/>
<bt:Image id="Settings.32x32" DefaultValue="https://localhost:3000/addons/outlook/assets/settings-32.png"/>
<bt:Image id="Settings.80x80" DefaultValue="https://localhost:3000/addons/outlook/assets/settings-80.png"/>
<bt:Image id="Add.16x16" DefaultValue="https://localhost:3000/addons/outlook/assets/add-16.png"/>
<bt:Image id="Add.32x32" DefaultValue="https://localhost:3000/addons/outlook/assets/add-32.png"/>
<bt:Image id="Add.80x80" DefaultValue="https://localhost:3000/addons/outlook/assets/add-80.png"/>
<bt:Image id="Icon.16x16" DefaultValue="https://localhost:3000/addons/outlook/assets/icon-16.png"/>
<bt:Image id="Icon.32x32" DefaultValue="https://localhost:3000/addons/outlook/assets/icon-32.png"/>
<bt:Image id="Icon.80x80" DefaultValue="https://localhost:3000/addons/outlook/assets/icon-80.png"/>
</bt:Images>
<bt:Urls>
<bt:Url id="Commands.Url" DefaultValue="https://localhost:3000/addons/outlook/commands.html"/>
<bt:Url id="Taskpane.Url" DefaultValue="https://localhost:3000/addons/outlook/taskpane.html"/>
</bt:Urls>
<bt:ShortStrings>
<!-- Default (French) -->
<bt:String id="GroupLabel" DefaultValue="__APP_NAME__"/>
<bt:String id="GenerateLink.Label" DefaultValue="Ajouter un lien __APP_NAME__">
<bt:Override Locale="en-US" Value="Add a __APP_NAME__ link"/>
<bt:Override Locale="de-DE" Value="__APP_NAME__-Link hinzufügen"/>
</bt:String>
<bt:String id="TaskpaneButton.Label" DefaultValue="Ouvrir les paramètres">
<bt:Override Locale="en-US" Value="Open settings"/>
<bt:Override Locale="de-DE" Value="Einstellungen öffnen"/>
</bt:String>
<bt:String id="OpenSettings.Label" DefaultValue="Paramètres">
<bt:Override Locale="en-US" Value="Settings"/>
<bt:Override Locale="de-DE" Value="Einstellungen"/>
</bt:String>
</bt:ShortStrings>
<bt:LongStrings>
<bt:String id="GenerateLink.Tooltip" DefaultValue="Génère un lien de réunion __APP_NAME__ et l'insère dans l'événement.">
<bt:Override Locale="de-DE" Value="Generiert einen __APP_NAME__-Besprechungslink und fügt ihn in den Termin ein."/>
<bt:Override Locale="en-US" Value="Generates a __APP_NAME__ meeting link and inserts it into the item."/>
</bt:String>
<bt:String id="TaskpaneButton.Tooltip" DefaultValue="Ouvre les paramètres de connexion __APP_NAME__.">
<bt:Override Locale="de-DE" Value="Öffnet die __APP_NAME__-Verbindungseinstellungen."/>
<bt:Override Locale="en-US" Value="Opens the __APP_NAME__ connection settings."/>
</bt:String>
<bt:String id="OpenSettings.Tooltip" DefaultValue="Ouvre les paramètres de connexion __APP_NAME__.">
<bt:Override Locale="de-DE" Value="Öffnet die __APP_NAME__-Verbindungseinstellungen."/>
<bt:Override Locale="en-US" Value="Opens the __APP_NAME__ connection settings."/>
</bt:String>
</bt:LongStrings>
</Resources>
</VersionOverrides>
</VersionOverrides> </VersionOverrides>
</OfficeApp> </OfficeApp>
@@ -57,8 +57,7 @@
data-i18n="footer.feedback" data-i18n="footer.feedback"
></a> ></a>
<div id="footer-right"> <div id="footer-right">
<span class="version-badge">beta</span> <span class="version-number">1.0.0</span>
<span class="version-number">0.0.2</span>
</div> </div>
</footer> </footer>
</body> </body>
+26 -10
View File
@@ -32,6 +32,8 @@ from minio import Minio
from minio.error import S3Error from minio.error import S3Error
from exceptions import MissingConfigError from exceptions import MissingConfigError
from observability import configure_sentry, set_job_context
from tasks import done_callback
load_dotenv() load_dotenv()
@@ -42,6 +44,7 @@ AGENT_NAME = os.getenv("METADATA_COLLECTOR_AGENT_NAME", "metadata-collector")
def prewarm(proc: JobProcess): def prewarm(proc: JobProcess):
"""Preload voice activity detection model.""" """Preload voice activity detection model."""
configure_sentry(AGENT_NAME)
proc.userdata["vad"] = silero.VAD.load() proc.userdata["vad"] = silero.VAD.load()
@@ -174,7 +177,13 @@ class MetadataCollector:
self.on_chat_message_received(reader, participant_identity) self.on_chat_message_received(reader, participant_identity)
) )
self._tasks.add(task) self._tasks.add(task)
task.add_done_callback(lambda _: self._tasks.remove(task)) task.add_done_callback(
done_callback(
logger,
self._tasks,
f"process chat stream from {participant_identity}",
)
)
def save(self): def save(self):
"""Serialize collected events and upload as JSON to S3.""" """Serialize collected events and upload as JSON to S3."""
@@ -270,16 +279,18 @@ class MetadataCollector:
logger.info("Participant disconnected: %s", participant.identity) logger.info("Participant disconnected: %s", participant.identity)
task = asyncio.create_task(self._close_session(session)) task = asyncio.create_task(self._close_session(session))
self._tasks.add(task) self._tasks.add(task)
task.add_done_callback(
def on_close_done(_): done_callback(
self._tasks.discard(task) logger,
logger.info( self._tasks,
"VAD session closed for %s (remaining sessions: %d)", f"close VAD session for {participant.identity}",
participant.identity, on_success=lambda _: logger.info(
len(self._sessions), "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): def on_participant_name_changed(self, participant: rtc.RemoteParticipant):
"""Update stored participant name when it changes.""" """Update stored participant name when it changes."""
@@ -360,6 +371,8 @@ async def handle_job_request(job_req: JobRequest) -> None:
@server.rtc_session(agent_name=AGENT_NAME, on_request=handle_job_request) @server.rtc_session(agent_name=AGENT_NAME, on_request=handle_job_request)
async def entrypoint(ctx: JobContext): async def entrypoint(ctx: JobContext):
"""Initialize and run the metadata collector.""" """Initialize and run the metadata collector."""
set_job_context(room=ctx.room.name, job_id=ctx.job.id)
logger.info("Starting metadata agent in room: %s", ctx.room.name) logger.info("Starting metadata agent in room: %s", ctx.room.name)
recording_id = ctx.job.metadata recording_id = ctx.job.metadata
metadata_collector = MetadataCollector(ctx, recording_id) metadata_collector = MetadataCollector(ctx, recording_id)
@@ -377,4 +390,7 @@ async def entrypoint(ctx: JobContext):
if __name__ == "__main__": if __name__ == "__main__":
# Initialize Sentry for the worker process. Each job runs in its own
# (forked) process and re-initializes Sentry via prewarm().
configure_sentry(AGENT_NAME)
cli.run_app(server) cli.run_app(server)
+25 -10
View File
@@ -25,6 +25,9 @@ from livekit.agents import (
) )
from livekit.plugins import deepgram, silero from livekit.plugins import deepgram, silero
from observability import configure_sentry, set_job_context
from tasks import done_callback
load_dotenv() load_dotenv()
logger = logging.getLogger("transcriber") logger = logging.getLogger("transcriber")
@@ -99,24 +102,29 @@ class MultiUserTranscriber:
logger.info(f"starting session for {participant.identity}") logger.info(f"starting session for {participant.identity}")
task = asyncio.create_task(self._start_session(participant)) task = asyncio.create_task(self._start_session(participant))
self._tasks.add(task) self._tasks.add(task)
task.add_done_callback(
def on_task_done(task: asyncio.Task): done_callback(
try: logger,
self._sessions[participant.identity] = task.result() self._tasks,
finally: f"start transcription session for {participant.identity}",
self._tasks.discard(task) )
)
task.add_done_callback(on_task_done)
def on_participant_disconnected(self, participant: rtc.RemoteParticipant): def on_participant_disconnected(self, participant: rtc.RemoteParticipant):
"""Handle participant disconnection by closing transcription session.""" """Handle participant disconnection by closing transcription session."""
if (session := self._sessions.pop(participant.identity)) is None: if (session := self._sessions.pop(participant.identity, None)) is None:
return return
logger.info(f"closing session for {participant.identity}") logger.info(f"closing session for {participant.identity}")
task = asyncio.create_task(self._close_session(session)) task = asyncio.create_task(self._close_session(session))
self._tasks.add(task) self._tasks.add(task)
task.add_done_callback(lambda _: self._tasks.discard(task)) task.add_done_callback(
done_callback(
logger,
self._tasks,
f"close transcription session for {participant.identity}",
)
)
async def _start_session(self, participant: rtc.RemoteParticipant) -> AgentSession: async def _start_session(self, participant: rtc.RemoteParticipant) -> AgentSession:
"""Create and start transcription session for participant.""" """Create and start transcription session for participant."""
@@ -139,6 +147,7 @@ class MultiUserTranscriber:
participant_identity=participant.identity, participant_identity=participant.identity,
) )
) )
self._sessions[participant.identity] = session
return session return session
async def _close_session(self, sess: AgentSession) -> None: async def _close_session(self, sess: AgentSession) -> None:
@@ -149,6 +158,8 @@ class MultiUserTranscriber:
async def entrypoint(ctx: JobContext): async def entrypoint(ctx: JobContext):
"""Initialize and run the multi-user transcriber.""" """Initialize and run the multi-user transcriber."""
set_job_context(room=ctx.room.name, job_id=ctx.job.id)
transcriber = MultiUserTranscriber(ctx) transcriber = MultiUserTranscriber(ctx)
transcriber.start() transcriber.start()
@@ -193,11 +204,15 @@ async def handle_transcriber_job_request(job_req: JobRequest) -> None:
def prewarm(proc: JobProcess): def prewarm(proc: JobProcess):
"""Preload voice activity detection model.""" """Preload voice activity detection model."""
configure_sentry(TRANSCRIBER_AGENT_NAME)
if ENABLE_SILERO_VAD: if ENABLE_SILERO_VAD:
proc.userdata["vad"] = silero.VAD.load() proc.userdata["vad"] = silero.VAD.load()
if __name__ == "__main__": if __name__ == "__main__":
# Initialize Sentry for the worker process. Each job runs in its own
# (forked) process and re-initializes Sentry via prewarm().
configure_sentry(TRANSCRIBER_AGENT_NAME)
cli.run_app( cli.run_app(
WorkerOptions( WorkerOptions(
entrypoint_fnc=entrypoint, entrypoint_fnc=entrypoint,
+83
View File
@@ -0,0 +1,83 @@
"""Sentry helpers for the LiveKit agents."""
import logging
import os
import tomllib
from os import path
import sentry_sdk
from sentry_sdk.integrations.logging import LoggingIntegration
logger = logging.getLogger("observability")
BASE_DIR = path.dirname(path.abspath(__file__))
def get_release():
"""Get the current release of the application.
By release, we mean the ``version`` declared in ``pyproject.toml``.
If the file cannot be read or declares no version, it defaults to "NA".
"""
try:
with open(path.join(BASE_DIR, "pyproject.toml"), "rb") as pyproject:
return tomllib.load(pyproject)["project"]["version"]
except (FileNotFoundError, KeyError, tomllib.TOMLDecodeError):
return "NA" # Default: not available
def configure_sentry(agent_name: str) -> None:
"""Initialize Sentry for the current agent process.
No-op if ``SENTRY_DSN`` is not configured. Otherwise (re)initializes Sentry
unconditionally so the calling process gets its own live transport.
Must be called once per process: in the worker entrypoint and again in the
per-job ``prewarm``/``setup_fnc`` hook, because LiveKit runs each job in a
forked process. A forked child inherits the parent's initialized Sentry
client but not its background transport thread (threads do not survive
``fork()``), so it must re-init to get a working transport. For that reason,
do NOT guard this with ``sentry_sdk.is_initialized()``: the child inherits it
as ``True`` and would skip init, silently dropping every event.
Args:
agent_name: Identifier of the agent, attached as a tag to Sentry issues
"""
# Read the DSN at call time so it picks up variables that load_dotenv()
# populated after this module was first imported.
sentry_dsn = os.getenv("SENTRY_DSN")
if not sentry_dsn:
logger.debug("SENTRY_DSN not defined for agent '%s'", agent_name)
return
sentry_sdk.init(
dsn=sentry_dsn,
environment=os.getenv("SENTRY_ENVIRONMENT"),
release=get_release(),
debug=False,
integrations=[
# Capture log records emitted at ERROR and above as Sentry events.
# This covers the agents' explicit logger.exception(...) calls as
# well as asyncio's "Exception in callback" / "Task exception was
# never retrieved" records, so unhandled task failures surface too.
LoggingIntegration(level=logging.INFO, event_level=logging.ERROR),
],
)
sentry_sdk.set_tag("application", "agents")
sentry_sdk.set_tag("agent", agent_name)
logger.info("Sentry initialized for agent '%s' (pid %d)", agent_name, os.getpid())
def set_job_context(*, room: str | None = None, job_id: str | None = None) -> None:
"""Tag the current Sentry scope with the LiveKit job being handled.
Args:
room: Name of the room the job is serving.
job_id: LiveKit job identifier.
"""
scope = sentry_sdk.get_current_scope()
if room is not None:
scope.set_tag("room", room)
if job_id is not None:
scope.set_tag("job_id", job_id)
+4 -3
View File
@@ -1,7 +1,7 @@
[project] [project]
name = "agents" name = "agents"
version = "1.22.0" version = "1.24.0"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
"livekit-agents==1.6.4", "livekit-agents==1.6.4",
@@ -9,8 +9,9 @@ dependencies = [
"livekit-plugins-silero==1.6.4", "livekit-plugins-silero==1.6.4",
"livekit-plugins-kyutai-lasuite==0.0.6", "livekit-plugins-kyutai-lasuite==0.0.6",
"python-dotenv==1.2.2", "python-dotenv==1.2.2",
"protobuf>=6.33.5", "protobuf==6.33.6",
"minio==7.2.20" "minio==7.2.20",
"sentry-sdk==2.60.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]
+42
View File
@@ -0,0 +1,42 @@
"""Helpers for managing asyncio tasks."""
import asyncio
import logging
from collections.abc import Callable
from typing import Any
def done_callback(
logger: logging.Logger,
tasks: set[asyncio.Task],
description: str,
*,
on_success: Callable[[Any], None] | None = None,
) -> Callable[[asyncio.Task], None]:
"""Build a done-callback for a background task.
Meant to be passed to `asyncio.Task.add_done_callback`.
Args:
logger: Logger used to report failures, so records keep the caller's
logger name.
tasks: Set the task was registered in; the task is discarded from it.
description: Human-readable intended action
on_success: Optional callback invoked with the task's result when it
completes without error.
Returns:
A callback suitable for ``task.add_done_callback(...)``.
"""
def _finalize(task: asyncio.Task) -> None:
tasks.discard(task)
if task.cancelled():
return
if (exc := task.exception()) is not None:
logger.exception("failed to %s", description, exc_info=exc)
return
if on_success is not None:
on_success(task.result())
return _finalize
+148 -117
View File
@@ -9,7 +9,7 @@ resolution-markers = [
[[package]] [[package]]
name = "agents" name = "agents"
version = "1.22.0" version = "1.24.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "livekit-agents" }, { name = "livekit-agents" },
@@ -19,6 +19,7 @@ dependencies = [
{ name = "minio" }, { name = "minio" },
{ name = "protobuf" }, { name = "protobuf" },
{ name = "python-dotenv" }, { name = "python-dotenv" },
{ name = "sentry-sdk" },
] ]
[package.optional-dependencies] [package.optional-dependencies]
@@ -33,9 +34,10 @@ requires-dist = [
{ name = "livekit-plugins-kyutai-lasuite", specifier = "==0.0.6" }, { name = "livekit-plugins-kyutai-lasuite", specifier = "==0.0.6" },
{ name = "livekit-plugins-silero", specifier = "==1.6.4" }, { name = "livekit-plugins-silero", specifier = "==1.6.4" },
{ name = "minio", specifier = "==7.2.20" }, { name = "minio", specifier = "==7.2.20" },
{ name = "protobuf", specifier = ">=6.33.5" }, { name = "protobuf", specifier = "==6.33.6" },
{ name = "python-dotenv", specifier = "==1.2.2" }, { name = "python-dotenv", specifier = "==1.2.2" },
{ name = "ruff", marker = "extra == 'dev'", specifier = "==0.15.19" }, { name = "ruff", marker = "extra == 'dev'", specifier = "==0.15.19" },
{ name = "sentry-sdk", specifier = "==2.60.0" },
] ]
provides-extras = ["dev"] provides-extras = ["dev"]
@@ -290,132 +292,148 @@ wheels = [
[[package]] [[package]]
name = "cffi" name = "cffi"
version = "2.0.0" version = "2.1.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "pycparser", marker = "implementation_name != 'PyPy'" }, { name = "pycparser", marker = "implementation_name != 'PyPy'" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } sdist = { url = "https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", size = 531036, upload-time = "2026-07-06T21:34:30.382Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, { url = "https://files.pythonhosted.org/packages/1e/85/990925db5df586ec90beb97529c853497e7f85ba0234830447faf41c3057/cffi-2.1.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f", size = 184829, upload-time = "2026-07-06T21:32:44.324Z" },
{ url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, { url = "https://files.pythonhosted.org/packages/4b/92/e7bb136ad6b5352603732cf907ef862ca103f20f2031c1735a46300c20c9/cffi-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde", size = 184728, upload-time = "2026-07-06T21:32:45.683Z" },
{ url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, { url = "https://files.pythonhosted.org/packages/c3/c0/d1ec30ffb370f748f2fb54425972bfef9871e0132e82fb589c46b6676049/cffi-2.1.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d", size = 214815, upload-time = "2026-07-06T21:32:48.557Z" },
{ url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, { url = "https://files.pythonhosted.org/packages/1b/dc/5620cf930688be01f2d673804291de757a934c90b946dbdc3d84130c2ea4/cffi-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7", size = 222429, upload-time = "2026-07-06T21:32:49.848Z" },
{ url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, { url = "https://files.pythonhosted.org/packages/4b/a4/77b53abbf7a1e0beb9637edbef2a94d15f9c822f591e85d439ffd91519a6/cffi-2.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b", size = 210315, upload-time = "2026-07-06T21:32:51.221Z" },
{ url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, { url = "https://files.pythonhosted.org/packages/58/0c/f528df19cc94b675087324d4760d9e6d5bfae97d6217aa4fac43de4f5fcc/cffi-2.1.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7", size = 208859, upload-time = "2026-07-06T21:32:52.512Z" },
{ url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, { url = "https://files.pythonhosted.org/packages/62/f2/c9522a81c32132799a1972c39f5c5f8b4c8b9f00488a23feaa6c06f07741/cffi-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66", size = 221844, upload-time = "2026-07-06T21:32:53.704Z" },
{ url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, { url = "https://files.pythonhosted.org/packages/6e/28/bd53988b9833e8f8ad539d26f4c07a6b3f6bcb1e9e02e7ca038250b3428d/cffi-2.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe", size = 225287, upload-time = "2026-07-06T21:32:54.907Z" },
{ url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, { url = "https://files.pythonhosted.org/packages/79/99/0d0fd37f055224085f42bbb2c022d002e17dde4a97972822327b07d84101/cffi-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b", size = 223681, upload-time = "2026-07-06T21:32:56.329Z" },
{ url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, { url = "https://files.pythonhosted.org/packages/b0/80/c138990aa2a70b1a269f6e06348729836d733d6f970867943f61d367f8cc/cffi-2.1.0-cp312-cp312-win32.whl", hash = "sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a", size = 175269, upload-time = "2026-07-06T21:32:57.777Z" },
{ url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, { url = "https://files.pythonhosted.org/packages/a8/eb/f636456ff21a83fc13c032b58cc5dde061691546ac79efa284b2989b7982/cffi-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384", size = 185881, upload-time = "2026-07-06T21:32:59.253Z" },
{ url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, { url = "https://files.pythonhosted.org/packages/dd/2c/400ea43e721727dca8a65c4521390e9196757caba4a45643acb2b63271b8/cffi-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6", size = 180088, upload-time = "2026-07-06T21:33:02.278Z" },
{ url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, { url = "https://files.pythonhosted.org/packages/96/88/a996879e2eeccb815f6e3a5967b12a308257412acec882039d386bd2aa7b/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda", size = 194331, upload-time = "2026-07-06T21:33:03.697Z" },
{ url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, { url = "https://files.pythonhosted.org/packages/58/85/7ae00d5c8dd6266f4e944c3db630f3c5c9a98b61d469c714d848b1d8138a/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b", size = 196966, upload-time = "2026-07-06T21:33:05.353Z" },
{ url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, { url = "https://files.pythonhosted.org/packages/8c/e9/45c3a76ad8d43ad9261f4c95436da61128d3ca545d72b9612c0ab5be0b1c/cffi-2.1.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a", size = 184795, upload-time = "2026-07-06T21:33:06.699Z" },
{ url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, { url = "https://files.pythonhosted.org/packages/84/4c/82f132cb4418ee6d953d982b19191e87e2a6372c8a4ce36e50b69d6ade4a/cffi-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea", size = 184746, upload-time = "2026-07-06T21:33:08.071Z" },
{ url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, { url = "https://files.pythonhosted.org/packages/a0/1c/4ed5a0e5bdca6cbc275556de3328dd1b76fd0c11cc13c88fe66d1d8715f2/cffi-2.1.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db", size = 214747, upload-time = "2026-07-06T21:33:09.671Z" },
{ url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, { url = "https://files.pythonhosted.org/packages/3a/a6/e879bb68cc23a2bc9ba8f4b7d8019f0c2694bad2ab6c4a3701d429439f58/cffi-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f", size = 222392, upload-time = "2026-07-06T21:33:10.896Z" },
{ url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, { url = "https://files.pythonhosted.org/packages/88/f6/01890cfd63c08f8eb96a8319b0443690197d240a8bd6346048cf7bde9190/cffi-2.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d", size = 210285, upload-time = "2026-07-06T21:33:12.251Z" },
{ url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, { url = "https://files.pythonhosted.org/packages/a6/cf/2b684132056f438567b61e19d690dd31cd0921ace051e0a458be6074369e/cffi-2.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0", size = 208801, upload-time = "2026-07-06T21:33:13.617Z" },
{ url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, { url = "https://files.pythonhosted.org/packages/6f/08/f2e7d62c460faae0926f2d6e423694aa409ced3bc1fe2927a0a6e5f05416/cffi-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224", size = 221808, upload-time = "2026-07-06T21:33:15.466Z" },
{ url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, { url = "https://files.pythonhosted.org/packages/38/37/04f54b8e63a02f3d908332c9effbf8c366167c6f733ed8a3d4f79b7e2a1e/cffi-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c", size = 225241, upload-time = "2026-07-06T21:33:16.869Z" },
{ url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, { url = "https://files.pythonhosted.org/packages/a9/d6/c72eecca433cd3e681c65ed313ab4835d9d4a379704d0f628a6a05f51c2e/cffi-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a", size = 223588, upload-time = "2026-07-06T21:33:18.239Z" },
{ url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, { url = "https://files.pythonhosted.org/packages/c6/4b/e706f67279140f92939da3475ad610df18bfd52d50f14953a8e5fede71d5/cffi-2.1.0-cp313-cp313-win32.whl", hash = "sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2", size = 175248, upload-time = "2026-07-06T21:33:19.799Z" },
{ url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, { url = "https://files.pythonhosted.org/packages/5a/47/59eb7975cb0e4ef0afa764ea945b29a5bb4537a9f771cb7d6c8a5dd74c95/cffi-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512", size = 185717, upload-time = "2026-07-06T21:33:21.47Z" },
{ url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, { url = "https://files.pythonhosted.org/packages/5a/af/34fee85c48f8d94efc8597bc09470c9dd274c145f1c12e0fbc6ab6d38d74/cffi-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f", size = 180114, upload-time = "2026-07-06T21:33:22.515Z" },
{ url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, { url = "https://files.pythonhosted.org/packages/d8/f0/81478e482afa03f6d18dc8f2afb5edc45b3080853b634b5ed91961be0998/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a", size = 194142, upload-time = "2026-07-06T21:33:23.657Z" },
{ url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, { url = "https://files.pythonhosted.org/packages/7d/95/8de304305cd9204974b0ca051b86d307cafca13aa575a0ef1b44d92c0d8c/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3", size = 196819, upload-time = "2026-07-06T21:33:25.007Z" },
{ url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, { url = "https://files.pythonhosted.org/packages/20/71/7c8372d30e42415602ed9f268f7cfd66f1b855fed881ecd168bcb45dbc0b/cffi-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d", size = 184965, upload-time = "2026-07-06T21:33:26.605Z" },
{ url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, { url = "https://files.pythonhosted.org/packages/d6/5c/584e626835f0375c928176c04137c96927165cb8733cdb3150ec04e5ee5e/cffi-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac", size = 184952, upload-time = "2026-07-06T21:33:27.823Z" },
{ url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, { url = "https://files.pythonhosted.org/packages/2e/d2/065fcae1c73979fac8e054462478d0ff8a29c40cdc2ed7ea5676a061df53/cffi-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6", size = 222353, upload-time = "2026-07-06T21:33:29.178Z" },
{ url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, { url = "https://files.pythonhosted.org/packages/ed/a5/e8bbb1ce5b3ac2f53ad6a10bde44318a5a8d99d4f4a000d44a6e39aeb3e4/cffi-2.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913", size = 210051, upload-time = "2026-07-06T21:33:30.534Z" },
{ url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, { url = "https://files.pythonhosted.org/packages/28/ed/c127d3ac36e899c965e3361357c3befacd6578c03f40125183e41c3b219e/cffi-2.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d", size = 208630, upload-time = "2026-07-06T21:33:31.753Z" },
{ url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, { url = "https://files.pythonhosted.org/packages/cc/d7/97d3136f81db489ec8d1d67748c110d6c994268fd7528014aa9f2b085e4e/cffi-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5", size = 221593, upload-time = "2026-07-06T21:33:33.044Z" },
{ url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, { url = "https://files.pythonhosted.org/packages/d3/27/93195977168ee63aed233a1a0993a2178798654d1f4bddcdd321d6fd3b21/cffi-2.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce", size = 225146, upload-time = "2026-07-06T21:33:34.224Z" },
{ url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, { url = "https://files.pythonhosted.org/packages/b3/c1/6dbd291ee2ae5a50a034aa057207081f545923bbf15dad4511e985aafff5/cffi-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326", size = 223240, upload-time = "2026-07-06T21:33:35.57Z" },
{ url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, { url = "https://files.pythonhosted.org/packages/0f/6f/ade5ce9863a57992a6ea3d0d10d7e29b8749fc127204b3d493d667b2815f/cffi-2.1.0-cp314-cp314-win32.whl", hash = "sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd", size = 177723, upload-time = "2026-07-06T21:33:51.626Z" },
{ url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, { url = "https://files.pythonhosted.org/packages/41/de/92b9eeed4ae4a21d6fd9b2a2c8505cbed573299902ea73981cc13f7ff62c/cffi-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb", size = 187937, upload-time = "2026-07-06T21:33:53.403Z" },
{ url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, { url = "https://files.pythonhosted.org/packages/2e/1a/cc6ae6c2913a03aab8898eee57963cf1035b8df5872ed8b9115fcc7e2be8/cffi-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804", size = 183001, upload-time = "2026-07-06T21:33:54.74Z" },
{ url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, { url = "https://files.pythonhosted.org/packages/14/f0/134c00ce0779ec86dea2aa1aac69339c2741a8045072676763512363a2ea/cffi-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714", size = 188538, upload-time = "2026-07-06T21:33:36.792Z" },
{ url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, { url = "https://files.pythonhosted.org/packages/50/d8/3b86aba791cb610d24e8a3e1b2cd529e71fa15096b04e4d4e360049d4a4c/cffi-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376", size = 188230, upload-time = "2026-07-06T21:33:38.011Z" },
{ url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, { url = "https://files.pythonhosted.org/packages/14/d0/117dcd9209255ad8571fbc8c92ef32593a1d294dcec91ddc4e4db50606f2/cffi-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98", size = 223899, upload-time = "2026-07-06T21:33:39.514Z" },
{ url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, { url = "https://files.pythonhosted.org/packages/b6/3d/f20f8b886b254e3ad10e15cd4186d3aed49f3e6a35ab37aab9f8f25f7c03/cffi-2.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13", size = 211652, upload-time = "2026-07-06T21:33:40.851Z" },
{ url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, { url = "https://files.pythonhosted.org/packages/28/3b/fad54de07260b93ddeef4b96d0131d57ea900675df1d410ae1deee52d7a6/cffi-2.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d", size = 210755, upload-time = "2026-07-06T21:33:42.183Z" },
{ url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, { url = "https://files.pythonhosted.org/packages/cc/82/3d5c705acb7abbba9bbd7d79b8e62e0f25b6120eb7ae6ac49f1b721722fe/cffi-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056", size = 223933, upload-time = "2026-07-06T21:33:43.603Z" },
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, { url = "https://files.pythonhosted.org/packages/6c/d0/47e338384ab6b1004241002fa616301020cea4fc95f283506565d252f276/cffi-2.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4", size = 226749, upload-time = "2026-07-06T21:33:45.046Z" },
{ url = "https://files.pythonhosted.org/packages/70/25/65bd5b58ea4bfdfc15cde02cb5365f89ef8ab8b2adfb8fe5c4bd4233382f/cffi-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94", size = 225703, upload-time = "2026-07-06T21:33:46.374Z" },
{ url = "https://files.pythonhosted.org/packages/dc/78/aa01ac599a8a4322533d45a1f9bc93b338276d2d59dabbe7c6d92a775c81/cffi-2.1.0-cp314-cp314t-win32.whl", hash = "sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76", size = 182857, upload-time = "2026-07-06T21:33:47.696Z" },
{ url = "https://files.pythonhosted.org/packages/b9/26/d00496b22de4d4228f32dde94ad996f350c8aad676d63bcca0743c8dea4d/cffi-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5", size = 194065, upload-time = "2026-07-06T21:33:48.953Z" },
{ url = "https://files.pythonhosted.org/packages/d5/dd/0c7dbf815a579ff005008a2d815a55d6bb047c349eef536d9dc53d3f0a8d/cffi-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8", size = 186404, upload-time = "2026-07-06T21:33:50.309Z" },
{ url = "https://files.pythonhosted.org/packages/55/c7/8c8c50cb11c6750051daf12164098a9a6f027ac4356967fd4d800a07f242/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c", size = 194121, upload-time = "2026-07-06T21:33:56.109Z" },
{ url = "https://files.pythonhosted.org/packages/99/e2/67680bf19a6b60d2bb7ff83baefa2a4c3d2d7dc0f3277034b802e1fc504c/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001", size = 196820, upload-time = "2026-07-06T21:33:57.288Z" },
{ url = "https://files.pythonhosted.org/packages/ed/da/4bbe583a3b3a5c8c60892124fe17f3fa3656523faf0d3484eae90f091853/cffi-2.1.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3", size = 184936, upload-time = "2026-07-06T21:33:58.765Z" },
{ url = "https://files.pythonhosted.org/packages/e5/4b/1f4c36ab273980d7aa75bb126ea4f8971f24a96108acad3a0a084028c57b/cffi-2.1.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc", size = 185045, upload-time = "2026-07-06T21:34:00.085Z" },
{ url = "https://files.pythonhosted.org/packages/ef/c3/ad299dc38f3583f8d916b299f028af418a9ec98bc695fcbebeae7420691c/cffi-2.1.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699", size = 222342, upload-time = "2026-07-06T21:34:01.814Z" },
{ url = "https://files.pythonhosted.org/packages/eb/d8/df4543cc087245044ed02ef3ad8e0a26619d0075ac7a77a12dc81177851b/cffi-2.1.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022", size = 210073, upload-time = "2026-07-06T21:34:03.255Z" },
{ url = "https://files.pythonhosted.org/packages/2c/0e/fac738d73728c6cea2a88a2883dca54892496cbba88a1dc1f2909cb8a6f5/cffi-2.1.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0", size = 208551, upload-time = "2026-07-06T21:34:04.433Z" },
{ url = "https://files.pythonhosted.org/packages/e6/3f/0b04a700dd64f465c93020253a793a82c9b4dff9961f48facd0df945d9b8/cffi-2.1.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1", size = 221649, upload-time = "2026-07-06T21:34:06.157Z" },
{ url = "https://files.pythonhosted.org/packages/5d/7c/b7379a5704c79eda57ce075869ba70a0368d1c850f803b3c0d078d39dcaf/cffi-2.1.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28", size = 225203, upload-time = "2026-07-06T21:34:07.489Z" },
{ url = "https://files.pythonhosted.org/packages/5a/02/d5e6c43ea85c41bda2a184a3418f195fe7cf602967a8d2b94e085b83deef/cffi-2.1.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629", size = 223263, upload-time = "2026-07-06T21:34:08.712Z" },
{ url = "https://files.pythonhosted.org/packages/2c/d8/772b8259bf75749adffb1c546828978381fb516f60cf701f6c83daf60c85/cffi-2.1.0-cp315-cp315-win32.whl", hash = "sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6", size = 177696, upload-time = "2026-07-06T21:34:26.355Z" },
{ url = "https://files.pythonhosted.org/packages/2f/dd/afa2191fc6d57fedd26e5844a2fe2fcc0bbfa00961bbaa5a41e4921e7cca/cffi-2.1.0-cp315-cp315-win_amd64.whl", hash = "sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853", size = 187914, upload-time = "2026-07-06T21:34:27.58Z" },
{ url = "https://files.pythonhosted.org/packages/05/ef/6cd4f8c671517162379dc79cfae5aea9106bc38abb89628d5c16adf6a838/cffi-2.1.0-cp315-cp315-win_arm64.whl", hash = "sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda", size = 183004, upload-time = "2026-07-06T21:34:28.905Z" },
{ url = "https://files.pythonhosted.org/packages/11/b6/12fc55092817a5faa26fb8c40c7f9d662e11a46ee248c137aafc42517d92/cffi-2.1.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc", size = 188378, upload-time = "2026-07-06T21:34:09.926Z" },
{ url = "https://files.pythonhosted.org/packages/8d/2e/cdac88979f295fde5daa69622c7d2111e56e7ceb94f211357fbe452339e4/cffi-2.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca", size = 188319, upload-time = "2026-07-06T21:34:11.101Z" },
{ url = "https://files.pythonhosted.org/packages/e0/27/1d0b408497e41a74795af122d7b603c418c5fed0171450f899afd04e594f/cffi-2.1.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d", size = 223904, upload-time = "2026-07-06T21:34:12.606Z" },
{ url = "https://files.pythonhosted.org/packages/8b/31/e115c985105dd7ffb32444505f18ceb874bb42d992af05d5dced7ecf1980/cffi-2.1.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8", size = 211554, upload-time = "2026-07-06T21:34:13.987Z" },
{ url = "https://files.pythonhosted.org/packages/5a/67/9e6e09409336d9e515c58367e7cfcf4f89df06ad25252675595a58eb59d5/cffi-2.1.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd", size = 210795, upload-time = "2026-07-06T21:34:15.972Z" },
{ url = "https://files.pythonhosted.org/packages/19/e5/d3cc82a4a0be7902af279c04181ad038449c096734464a5ae1de3e1401bd/cffi-2.1.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f", size = 223843, upload-time = "2026-07-06T21:34:17.509Z" },
{ url = "https://files.pythonhosted.org/packages/b9/65/b434abc97ce7cecc2c640fde160507c0ecc7e21544b483ba3325d2e2ea17/cffi-2.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc", size = 226773, upload-time = "2026-07-06T21:34:19.05Z" },
{ url = "https://files.pythonhosted.org/packages/b5/9f/d4dc66ca651eb1145a133314cda721abf13cfac3d28c4a0402263ae6ad75/cffi-2.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9", size = 225719, upload-time = "2026-07-06T21:34:20.576Z" },
{ url = "https://files.pythonhosted.org/packages/68/5a/e536c528bc8057496c360c0978559a2dc45653f89dd6151078aa7d8fca1a/cffi-2.1.0-cp315-cp315t-win32.whl", hash = "sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b", size = 182760, upload-time = "2026-07-06T21:34:22.059Z" },
{ url = "https://files.pythonhosted.org/packages/d3/0b/0ffe8b82d3875bced5fa1e7986a7a46b748262a40ab7f60b475eb9fb1bb3/cffi-2.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5", size = 193769, upload-time = "2026-07-06T21:34:23.589Z" },
{ url = "https://files.pythonhosted.org/packages/a0/17/1073b53b68c9b5ca6914adf5f8bf55aacc2d3be102418c90700160ea8605/cffi-2.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210", size = 186405, upload-time = "2026-07-06T21:34:24.857Z" },
] ]
[[package]] [[package]]
name = "charset-normalizer" name = "charset-normalizer"
version = "3.4.7" version = "3.4.8"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } sdist = { url = "https://files.pythonhosted.org/packages/20/56/10a88e00039537d74bd420f0457c52ab8f58a1af56126e3b9f1b1c8c4724/charset_normalizer-3.4.8.tar.gz", hash = "sha256:d9bf144d6faf12c70d58e47f7512992ae2882b820031d6cef68152deb645bf2d", size = 151790, upload-time = "2026-07-06T15:27:58.477Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, { url = "https://files.pythonhosted.org/packages/59/c2/39de60ef5687662f467bed3d1e6944c67a4f0d057141d0404002b8f405ae/charset_normalizer-3.4.8-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:faac37c4904598daa00cb4c9b32f3b4cc814fb5f145d7a531ceb4a70f2114132", size = 319040, upload-time = "2026-07-06T15:26:15.854Z" },
{ url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, { url = "https://files.pythonhosted.org/packages/a7/57/a9474c3aeaa337c8a330c0dc5df266527d56da3b189c029529f6b08af2a4/charset_normalizer-3.4.8-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f191c19a32dc6cec0fb8079789d786254653a9ce906fcab04ccd2eed07bba233", size = 215541, upload-time = "2026-07-06T15:26:17.265Z" },
{ url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, { url = "https://files.pythonhosted.org/packages/13/a9/be1ff7e81f6e086dced2a7a7a28b789be351d9796084ccaf6136a4ffafb3/charset_normalizer-3.4.8-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05811b76943d477bb90822dedb5c4565cef70148847a59d574e2b35043aeb563", size = 236913, upload-time = "2026-07-06T15:26:18.482Z" },
{ url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, { url = "https://files.pythonhosted.org/packages/8f/75/d8c5eae93da26d463f9ebe46a4937ca44434dc2937a565b92437befb3d94/charset_normalizer-3.4.8-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3868a3e4ec1e40b419e060d063f93eac6f046fa21426c4816421223ae7dc8ab8", size = 232815, upload-time = "2026-07-06T15:26:19.734Z" },
{ url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, { url = "https://files.pythonhosted.org/packages/27/0d/98e301ca944bcca5e6bc312406b579c8a6d81546c1b494afb3a9478495d6/charset_normalizer-3.4.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25f93d194eb6264c64416cabff46a91f6d99b97e7525a1b4f35c77a99e75cc68", size = 223995, upload-time = "2026-07-06T15:26:20.931Z" },
{ url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, { url = "https://files.pythonhosted.org/packages/fa/df/f5222366b76dcb31453a9bd922610c893540d0e729fd390439b0d3e972ee/charset_normalizer-3.4.8-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:ee6a62492f18d432cca031fabd158f400a8c25bf7b9458f50953393a2a23d97a", size = 208522, upload-time = "2026-07-06T15:26:22.214Z" },
{ url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, { url = "https://files.pythonhosted.org/packages/74/52/293220d59d8ddfb8aa56836b33bd6df58e70795d8a102a858c2984480f00/charset_normalizer-3.4.8-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1c16cb4fc35e4b064f5ee78d849f15a550ada1729c3372916672e38f1f01d1d4", size = 219660, upload-time = "2026-07-06T15:26:23.493Z" },
{ url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, { url = "https://files.pythonhosted.org/packages/c3/2c/81a298e66f3d01e61bfc6f7064bbb553b067a9f1d979e5962bf00733069b/charset_normalizer-3.4.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2fbd0edb0426ab28e70fac9d1d4ef549eb5a64a2521f0428c441d75e4387e6c2", size = 218230, upload-time = "2026-07-06T15:26:24.629Z" },
{ url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, { url = "https://files.pythonhosted.org/packages/0d/45/f1dd2328cbc3340705f82072c09bd4c68d6e079191cde05810c1eac77eee/charset_normalizer-3.4.8-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ccb9052771216170015f810b88065fd9e13b1e0b391f92abb9b47e0919a42aad", size = 210006, upload-time = "2026-07-06T15:26:25.837Z" },
{ url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, { url = "https://files.pythonhosted.org/packages/38/63/28697000620e117eb413424caaf60b6f98ddb1b09b2c11f7c0038d9936a7/charset_normalizer-3.4.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3809ba5d3cd02aca0894597f2669a825bdfe2229061515c128b0f4e5533b4ab5", size = 225771, upload-time = "2026-07-06T15:26:27.079Z" },
{ url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, { url = "https://files.pythonhosted.org/packages/12/a7/d5844e315f5f35e7938c415f07a1df144eed1cf993f1b43cc16c980c5b46/charset_normalizer-3.4.8-cp312-cp312-win32.whl", hash = "sha256:de63c31666a049f653ada24e800192e3c019e96bc7d70fb449a000bccf26a36f", size = 150922, upload-time = "2026-07-06T15:26:28.514Z" },
{ url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, { url = "https://files.pythonhosted.org/packages/9b/82/eb8b72f184b1e4986dd9daec15d7f6d9285a6728d2b07b7f04656829f473/charset_normalizer-3.4.8-cp312-cp312-win_amd64.whl", hash = "sha256:14a4bbe066f3fb05c6ba70e9cf9d34614b57a2fd70ea8c27cc30f34155e16a58", size = 162294, upload-time = "2026-07-06T15:26:29.745Z" },
{ url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, { url = "https://files.pythonhosted.org/packages/09/5a/ab810134aa41034a08ffe94c058102016e6ad9bce62f3cdba547b4723385/charset_normalizer-3.4.8-cp312-cp312-win_arm64.whl", hash = "sha256:2b5b0c0dca0a02c3f816f89abf18af3d20416dedbc3d3aa5f3981045f88ae7b0", size = 152409, upload-time = "2026-07-06T15:26:31.034Z" },
{ url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, { url = "https://files.pythonhosted.org/packages/11/49/fe5a8572a70cd9cba79f80af9388ac8c5c914ed4459b956f940244e499a5/charset_normalizer-3.4.8-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:057f8609f7341618c98e5aa9a6109fa116acff2a658497d47ab3325b5e8f2b08", size = 317424, upload-time = "2026-07-06T15:26:32.23Z" },
{ url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, { url = "https://files.pythonhosted.org/packages/e5/59/d71c96616b6825425a876f79f38fa440db30b32cc1166179a839f6259150/charset_normalizer-3.4.8-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c591f9a82adc5b89a039b90df74e43de2b9177fb46771172bed7b80722a70db0", size = 214723, upload-time = "2026-07-06T15:26:33.635Z" },
{ url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, { url = "https://files.pythonhosted.org/packages/c6/35/dc9eeb297f19b7b6ada39709ccb74937e6c51f0947958ae601a977cedd5d/charset_normalizer-3.4.8-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5b56f449132d9adefe55b87635d05177a914ed5d070438a74725e1d77a280002", size = 236200, upload-time = "2026-07-06T15:26:34.99Z" },
{ url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, { url = "https://files.pythonhosted.org/packages/0a/37/6775fe852b4acad8bf7e0575fbe8aa9f41b546e33251acbded3c04a6b0d9/charset_normalizer-3.4.8-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1004c3b5831a301dadfb9e916f38e78e2ff3e08db24a1ad7c354db8ee3dea9c3", size = 231740, upload-time = "2026-07-06T15:26:36.498Z" },
{ url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, { url = "https://files.pythonhosted.org/packages/e4/28/1bcc3f5f3bac81532384adcfcdd9362c7f46a188a19deacc1ddaf7bdaa00/charset_normalizer-3.4.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:60883e22821d17c9e5b4f3ca1ef8074f766e3db28791f851b665929c515635c0", size = 222888, upload-time = "2026-07-06T15:26:38.161Z" },
{ url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, { url = "https://files.pythonhosted.org/packages/47/81/9f3993ca62ef090c58059da641e49e3129e74700a6a3beb58436cdb8d4b9/charset_normalizer-3.4.8-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:21e4dbb942c8a6342e2685f232dd2a7bc73465697bd26ead4f118271d28be383", size = 207649, upload-time = "2026-07-06T15:26:39.628Z" },
{ url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, { url = "https://files.pythonhosted.org/packages/11/f4/679f636bcbdc2d53d06b1f4039be310450dca95a9f76bbf22f09985556e8/charset_normalizer-3.4.8-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:84bcae14c65e645ca66b661339183d32b8c846a17c96e3e81ab3d346e1c498d4", size = 218908, upload-time = "2026-07-06T15:26:40.911Z" },
{ url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, { url = "https://files.pythonhosted.org/packages/7e/44/96e8c81867ba8a45ff893c8e7474c2d6b9633f7aa663da7901d040214d3e/charset_normalizer-3.4.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b4e4d44b8287aa13a25e16e29393d494b0643b24894f7c8266c6f6788dd36337", size = 217096, upload-time = "2026-07-06T15:26:42.148Z" },
{ url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, { url = "https://files.pythonhosted.org/packages/a5/bf/4d53f04f29bdb22601701f4f9f4d038edfb27976c296fcb7400c02736a6e/charset_normalizer-3.4.8-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:f68545d1b267dbfafd5d253b6d1cb161562c4e61ab25b5c4cdb7d9e5923e441e", size = 209355, upload-time = "2026-07-06T15:26:43.557Z" },
{ url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, { url = "https://files.pythonhosted.org/packages/ad/60/92b3f630798d777fa880ad289a3f9f2fc663e4b4beb24783c53318820254/charset_normalizer-3.4.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1936e48214adea74922a20c8ab41b1393ae27cc9e329eb1f0b937d3416824f36", size = 224732, upload-time = "2026-07-06T15:26:44.892Z" },
{ url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, { url = "https://files.pythonhosted.org/packages/bd/85/eafa0a3c7bb6fe9f02f4c7901f02071933cac85ee634197e17280818c6de/charset_normalizer-3.4.8-cp313-cp313-win32.whl", hash = "sha256:1f8e3521860187d597f3867d8466da225b9179ea2833bb26de1bb026144d07c3", size = 150358, upload-time = "2026-07-06T15:26:46.153Z" },
{ url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, { url = "https://files.pythonhosted.org/packages/f5/8c/879fafff7b47bb1166d289f2d2472cb31b9922f9f4ca1f392edf85ec16be/charset_normalizer-3.4.8-cp313-cp313-win_amd64.whl", hash = "sha256:8b654b6f52a0a9a6be38e88f3e1dc68f1093ebeb2abbadafc7c82da0786a34be", size = 161685, upload-time = "2026-07-06T15:26:47.423Z" },
{ url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, { url = "https://files.pythonhosted.org/packages/c3/46/b57c7e778a7b578f28d35fd38544687d4f8d9c019585eebc5ad936073fad/charset_normalizer-3.4.8-cp313-cp313-win_arm64.whl", hash = "sha256:d2d5a250ee26e29468b7607d97479221b069fa8aaf6f929ac84ec0e962e15154", size = 152333, upload-time = "2026-07-06T15:26:48.689Z" },
{ url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, { url = "https://files.pythonhosted.org/packages/1c/bc/0a8540b8cd494951cca1428606373942803f5ffcec40fe798f819c5a8adb/charset_normalizer-3.4.8-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:77e993ecf65f21ab1f82266ff5e84a7de2c879e7d9b8bc006009df83f22a1d5e", size = 316993, upload-time = "2026-07-06T15:26:49.962Z" },
{ url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, { url = "https://files.pythonhosted.org/packages/0e/99/a0868f0a1f0a045fd374d1f2cf7042d8ad5d7fb4dd1f4ac7365e319f7e32/charset_normalizer-3.4.8-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:524939917f17f6de502dfda30b472550965740d7f126659d4c4f8dd1569cce22", size = 215638, upload-time = "2026-07-06T15:26:51.338Z" },
{ url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, { url = "https://files.pythonhosted.org/packages/e8/e9/43c4d09a09b5557cc5fe1d87c9d96f86a3942aec0517d2b5408cef87ca75/charset_normalizer-3.4.8-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a4508989ba8e2ce43ef989453d18188b261546e8188cbdd4ef451fb9e4c3b467", size = 236456, upload-time = "2026-07-06T15:26:52.531Z" },
{ url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, { url = "https://files.pythonhosted.org/packages/e2/67/492ca98b3ab785b736b5da10c1bc233e1c8fec6c0cdb29b482c38bfc52a2/charset_normalizer-3.4.8-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9e44127f7d11eee4548ad2cdf1f4e1b6eaaddd5cb92d15ad65f6ecc9bcf403ab", size = 232253, upload-time = "2026-07-06T15:26:53.838Z" },
{ url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, { url = "https://files.pythonhosted.org/packages/2d/fd/1e6eff58c14f1aace1e26d80defbeaea2d35e075dbe4b611111ee4b47fa8/charset_normalizer-3.4.8-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb90317359f7e67bb6df615999a95e0980877468e617ddce8b6c2f8e7fe60d95", size = 222886, upload-time = "2026-07-06T15:26:55.009Z" },
{ url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, { url = "https://files.pythonhosted.org/packages/40/7a/90056a5326b0c4b9a3f924d337729c344c11542e5bc7191e50410db61587/charset_normalizer-3.4.8-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:35d9e7a9c960520ae89d1f4e305d1c047a74dea2e0f73a0e84f879356c2e8776", size = 206482, upload-time = "2026-07-06T15:26:56.306Z" },
{ url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, { url = "https://files.pythonhosted.org/packages/18/ff/94761d31a33878dbb5008ddbd918615061fcf5c0a612aa3075450e60f628/charset_normalizer-3.4.8-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:92e322b054c7ff886f78feab7360736bb45de2e18cf4a0ee84e8fc5a08d53a19", size = 218929, upload-time = "2026-07-06T15:26:57.422Z" },
{ url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, { url = "https://files.pythonhosted.org/packages/2b/dc/00b9675acd7c4b926b9102ee3f0d1a570ce943901be73b87485001393fe1/charset_normalizer-3.4.8-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:3c0086d97094363556206dc3bcf43f7edcfc043ea7a568a46f45efea74858bd1", size = 218069, upload-time = "2026-07-06T15:26:58.719Z" },
{ url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, { url = "https://files.pythonhosted.org/packages/04/11/94ada5a0482ee4bf688d04be4c7d6fd945d37370d04a95671040dfe2b416/charset_normalizer-3.4.8-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:0752c849b51198267df2aba013c4de3a2955bd014a4fd70828809946c1acbc0c", size = 207146, upload-time = "2026-07-06T15:27:00.058Z" },
{ url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, { url = "https://files.pythonhosted.org/packages/7c/f7/246bd36762207ab4752cd436b64e5d81a1668b15ddea7b5b2d0e8545e727/charset_normalizer-3.4.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2a4707e09eca11e81ece4fced600c5a0a801f568b962244f6f517bc274745fc9", size = 224896, upload-time = "2026-07-06T15:27:01.599Z" },
{ url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, { url = "https://files.pythonhosted.org/packages/6f/f7/3510622d1fbe13b0ebf827c475e40a27e2be427140d792878b63ab6425cc/charset_normalizer-3.4.8-cp314-cp314-win32.whl", hash = "sha256:8ea67f427c073ae3da0923aa55f3715131fa613a61a7f2f8d762bde75eaf00ae", size = 150851, upload-time = "2026-07-06T15:27:02.964Z" },
{ url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, { url = "https://files.pythonhosted.org/packages/32/2b/9ce65dd21672b55cf800cca5f4433afa1586fda1d78731067ec9ec544c62/charset_normalizer-3.4.8-cp314-cp314-win_amd64.whl", hash = "sha256:ff71018850863362e5c7533769d0a9f77715c31af1502d523630ce822922f5c9", size = 162549, upload-time = "2026-07-06T15:27:04.249Z" },
{ url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, { url = "https://files.pythonhosted.org/packages/2f/34/9a5967eed666a88f31a0866884606d9ec3c2cd6091e2ccd7e0b4c4176c35/charset_normalizer-3.4.8-cp314-cp314-win_arm64.whl", hash = "sha256:44464e66f4da2f21dea7145c7693f9f60717ca4794a954dea5bf8c2c932678bd", size = 153079, upload-time = "2026-07-06T15:27:05.608Z" },
{ url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, { url = "https://files.pythonhosted.org/packages/02/4f/aa44cc81d8987f105352c74c0bf919007f8b80e9880d28bcf0393c1a816e/charset_normalizer-3.4.8-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:50a0c2e58ad2c203adb616fef28941b7e13716adbc25e0dfaeec29f5afe6382f", size = 338586, upload-time = "2026-07-06T15:27:06.86Z" },
{ url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, { url = "https://files.pythonhosted.org/packages/1d/2b/b0392e2b235c08ff0623d905c2ee8ac820620544043c1ce92ce0b3d64c55/charset_normalizer-3.4.8-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a1e589fdb95c76f08288bbb346230cdd8994db74903db6637b380f7b5fc9336", size = 222764, upload-time = "2026-07-06T15:27:08.23Z" },
{ url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, { url = "https://files.pythonhosted.org/packages/a2/a1/7d466879190731f5559662c22232646f2ae2dace2323c3e5aefcf78d458a/charset_normalizer-3.4.8-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b3d7c887444c5a7ef0d68d358d81e758a850bc626f8e639e2ca5667153272b20", size = 241331, upload-time = "2026-07-06T15:27:09.512Z" },
{ url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, { url = "https://files.pythonhosted.org/packages/70/17/8b89e797137aa28c8fb0bafbafc243246a7afe21620a13b00e37624ece1d/charset_normalizer-3.4.8-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:65c389b96c0cfff3a3f0458fa1c7ce554a30e23101a88a49f03997afce6a929f", size = 239323, upload-time = "2026-07-06T15:27:10.86Z" },
{ url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, { url = "https://files.pythonhosted.org/packages/d7/98/1c1940730ed22d50983be4e243c722c89d5136d6f073bd840d1128bfddcb/charset_normalizer-3.4.8-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:593403fc47dcdf55e2987b2e3cc2e064127e2b908929f1f18b2e4a4652cbd780", size = 229964, upload-time = "2026-07-06T15:27:12.113Z" },
{ url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, { url = "https://files.pythonhosted.org/packages/53/2d/bb8e81b7ff603d3f77e9a8a5d1ad34fcabbf3c54d300c29d99fba581fa23/charset_normalizer-3.4.8-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:606088e9fa2b7469ab9c42d4da8e05a415622a07714edd2fcd8fed48dda4c853", size = 212405, upload-time = "2026-07-06T15:27:13.447Z" },
{ url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, { url = "https://files.pythonhosted.org/packages/ce/1f/e52a3a53b13da591bb8f21d29e63877268eadf20686b7762351d4b89062c/charset_normalizer-3.4.8-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0317406326fed512f42a1632ad91a96228a7616c06547666a6dd79967f1bd6ca", size = 226918, upload-time = "2026-07-06T15:27:14.89Z" },
{ url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, { url = "https://files.pythonhosted.org/packages/e8/f9/32996d79c57189af9722fe618f46d8a86b7be035ca98887b8d0c3821f141/charset_normalizer-3.4.8-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b67d50ee47e5c57a0064a9cb575b963a7125819dfd1fd094d44d378fff94659b", size = 225113, upload-time = "2026-07-06T15:27:16.125Z" },
{ url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, { url = "https://files.pythonhosted.org/packages/d6/d2/9248c18e695696513774523a794cfb8b677521ce9ad7554d301cb10a9b20/charset_normalizer-3.4.8-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:79e402b869f270140afa5e2b0e2ac100585358d812fe3dd093d424f7a72964e0", size = 214966, upload-time = "2026-07-06T15:27:17.418Z" },
{ url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, { url = "https://files.pythonhosted.org/packages/1e/9d/4b19432d406179a40f924691906ee5b15ac664b408971c973295192444ea/charset_normalizer-3.4.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2970b9f7ab69ec3a0423ec6b6ac718e79fbf4a282c0bc103ef88c1ef50dfa15a", size = 231699, upload-time = "2026-07-06T15:27:19.131Z" },
{ url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, { url = "https://files.pythonhosted.org/packages/be/41/bdbdf71e8c3ccff10ef3cc2bb9467a7fdb3dc94b9a406d1a3c44afd39632/charset_normalizer-3.4.8-cp314-cp314t-win32.whl", hash = "sha256:458c2972a78043b7261c9726670029f15f722e70669bcbe961153a01968f589f", size = 155333, upload-time = "2026-07-06T15:27:20.681Z" },
{ url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, { url = "https://files.pythonhosted.org/packages/bd/f8/e05c69323bd50091ec39f5f885385b884624b0131a6885a0c83a6217ba7a/charset_normalizer-3.4.8-cp314-cp314t-win_amd64.whl", hash = "sha256:0c926329a1df7cd56d7d8349fe354460d20aefd2e394c9e159e479d018b2b359", size = 167378, upload-time = "2026-07-06T15:27:22.042Z" },
{ url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, { url = "https://files.pythonhosted.org/packages/c2/04/cbaf1a2f5e2bbf70760e774380cbf052b10849fc35e770905df31af5cf00/charset_normalizer-3.4.8-cp314-cp314t-win_arm64.whl", hash = "sha256:2232baea80a2b01783679fed4e625ccdb19a974f44c9cf0fba21a777a4c8179c", size = 157782, upload-time = "2026-07-06T15:27:23.312Z" },
{ url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, { url = "https://files.pythonhosted.org/packages/23/52/d5bee5b6ea81882d549b566d2545b044bbcbc33fe5fbe001008a7e745a21/charset_normalizer-3.4.8-py3-none-any.whl", hash = "sha256:b7c1fb310df524e01fbe84d43b7f95aa4f808f8eaa0dafc185f64ba395e37d54", size = 64279, upload-time = "2026-07-06T15:27:57.043Z" },
{ url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" },
{ url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" },
{ url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" },
{ url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" },
{ url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" },
{ url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" },
{ url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" },
{ url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" },
{ url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" },
{ url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" },
{ url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" },
{ url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" },
] ]
[[package]] [[package]]
@@ -1758,6 +1776,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/30/66/9a73695e31eaee04f35d8475998bf8ab354465f9c638936d76111603dcc5/ruff-0.15.19-py3-none-win_arm64.whl", hash = "sha256:6c6b607466e47349332eb1d9be52fb1467423fc07c217341af41cd0f3f0573be", size = 11376779, upload-time = "2026-06-24T01:10:34.465Z" }, { url = "https://files.pythonhosted.org/packages/30/66/9a73695e31eaee04f35d8475998bf8ab354465f9c638936d76111603dcc5/ruff-0.15.19-py3-none-win_arm64.whl", hash = "sha256:6c6b607466e47349332eb1d9be52fb1467423fc07c217341af41cd0f3f0573be", size = 11376779, upload-time = "2026-06-24T01:10:34.465Z" },
] ]
[[package]]
name = "sentry-sdk"
version = "2.60.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/54/a2/2e6c090db384cc515069f4f85542bd5baf6786852073020ea73d4a76d3ea/sentry_sdk-2.60.0.tar.gz", hash = "sha256:0bd25e54e78ca02d0be512529fa644bbbf9e8470d7b26371294012d4ca93c978", size = 452946, upload-time = "2026-05-13T13:34:52.516Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/29/41/f2b800b7f12a05dd48c2a6280d4dd812d1425fc66ed3fe3fd99420c41d1a/sentry_sdk-2.60.0-py3-none-any.whl", hash = "sha256:28a536c03291c8bcb363cf35c611b32738ec118ff64d8d6383b096448ac4c803", size = 475616, upload-time = "2026-05-13T13:34:50.259Z" },
]
[[package]] [[package]]
name = "shellingham" name = "shellingham"
version = "1.5.4" version = "1.5.4"
+10 -3
View File
@@ -11,7 +11,7 @@ from core.recording.event import notification
from . import models from . import models
from .tasks.file import process_file_deletion from .tasks.file import process_file_deletion
from .utils import generate_download_file_url from .utils import generate_download_s3_url
def hard_delete_file(file): def hard_delete_file(file):
@@ -242,7 +242,7 @@ class FileAdmin(admin.ModelAdmin):
"""Return a clickable preview URL for the file.""" """Return a clickable preview URL for the file."""
if not obj.is_ready: if not obj.is_ready:
return "-" return "-"
url = generate_download_file_url(obj, expires_in=60 * 60) url = generate_download_s3_url(obj.key, expires_in=60 * 60)
return format_html( return format_html(
'<a href="{}" target="_blank" rel="noopener noreferrer">Open File</a>', url '<a href="{}" target="_blank" rel="noopener noreferrer">Open File</a>', url
@@ -402,7 +402,14 @@ class RecordingAdmin(admin.ModelAdmin):
"""Recording admin interface declaration.""" """Recording admin interface declaration."""
inlines = (RecordingAccessInline,) inlines = (RecordingAccessInline,)
search_fields = ["status", "=id", "worker_id", "room__slug", "=room__id"] search_fields = [
"status",
"=id",
"worker_id",
"room__slug",
"=room__id",
"accesses__user__email",
]
list_display = ( list_display = (
"id", "id",
"status", "status",
+8
View File
@@ -19,6 +19,7 @@ from django.utils.module_loading import import_string
from .base import AnalyticsBackend, NoOpAnalytics from .base import AnalyticsBackend, NoOpAnalytics
from .events import AnalyticsEvent from .events import AnalyticsEvent
from .user_feature_flags import UserFeatureFlag
__all__ = [ __all__ = [
"get_analytics", "get_analytics",
@@ -26,6 +27,8 @@ __all__ = [
"capture", "capture",
"AnalyticsBackend", "AnalyticsBackend",
"AnalyticsEvent", "AnalyticsEvent",
"is_user_feature_flag_enabled",
"UserFeatureFlag",
] ]
@@ -57,3 +60,8 @@ def capture(
) -> None: ) -> None:
"""Record an event performed by an identified user.""" """Record an event performed by an identified user."""
analytics_instance.capture(user, event, properties) analytics_instance.capture(user, event, properties)
def is_user_feature_flag_enabled(user, feature_name: UserFeatureFlag) -> bool:
"""Check if a feature is enabled at the user level."""
return analytics_instance.is_user_feature_enabled(user, feature_name)
+23 -8
View File
@@ -1,12 +1,14 @@
"""Analytics backend protocol and default no-op implementation.""" """Analytics backend protocol and default no-op implementation."""
from typing import Any, Protocol from abc import ABC, abstractmethod
from typing import Any, Mapping
from ..models import User from ..models import User
from .events import AnalyticsEvent from .events import AnalyticsEvent
from .user_feature_flags import UserFeatureFlag
class AnalyticsBackend(Protocol): class AnalyticsBackend(ABC):
""" """
Interface every analytics backend must implement. Interface every analytics backend must implement.
@@ -17,11 +19,11 @@ class AnalyticsBackend(Protocol):
ANALYTICS_BACKEND_SETTINGS = {"api_key": "...", "host": "..."} ANALYTICS_BACKEND_SETTINGS = {"api_key": "...", "host": "..."}
""" """
def __init__(self, **kwargs: Any) -> None: ... @abstractmethod
def identify(self, user: User, properties: dict[str, Any] | None = None) -> None: def identify(self, user: User, properties: dict[str, Any] | None = None) -> None:
"""Associate traits (email, name, ...) with an identified user.""" """Associate traits (email, name, ...) with an identified user."""
@abstractmethod
def capture( def capture(
self, self,
user: User, user: User,
@@ -30,16 +32,29 @@ class AnalyticsBackend(Protocol):
) -> None: ) -> None:
"""Record an event performed by an identified user.""" """Record an event performed by an identified user."""
@abstractmethod
def shutdown(self) -> None: def shutdown(self) -> None:
"""Flush pending events. Called on process exit.""" """Flush pending events. Called on process exit."""
def get_user_feature_flags(
self,
user: User, # pylint: disable=unused-argument
) -> Mapping[UserFeatureFlag, bool | str | None]:
"""Return a dict of feature flags for the given user."""
# We return an empty dict here by default to avoid a breaking change
# By making this method abstract.
return {}
class NoOpAnalytics: def is_user_feature_enabled(
self, user: User, feature_name: UserFeatureFlag
) -> bool:
"""Check if a feature is enabled at the user level."""
return self.get_user_feature_flags(user).get(feature_name, False) is True
class NoOpAnalytics(AnalyticsBackend):
"""Default backend: silently discards everything.""" """Default backend: silently discards everything."""
def __init__(self, **kwargs: Any) -> None:
"""No-op: accepts and ignores any backend settings kwargs."""
def identify(self, user: User, properties=None) -> None: def identify(self, user: User, properties=None) -> None:
"""No-op: discards identify calls.""" """No-op: discards identify calls."""
+44 -2
View File
@@ -1,17 +1,21 @@
"""PostHog implementation of the analytics backend protocol.""" """PostHog implementation of the analytics backend protocol."""
import logging import logging
from typing import Any from typing import Any, Mapping
from django.core.cache import cache
from posthog import Posthog from posthog import Posthog
from ..models import User from ..models import User
from .base import AnalyticsBackend
from .events import AnalyticsEvent from .events import AnalyticsEvent
from .user_feature_flags import UserFeatureFlag
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class PostHogAnalytics: class PostHogAnalytics(AnalyticsBackend):
"""Send events to PostHog, keyed on the user's primary key (UUID).""" """Send events to PostHog, keyed on the user's primary key (UUID)."""
def __init__( def __init__(
@@ -19,6 +23,8 @@ class PostHogAnalytics:
*, *,
api_key: str, api_key: str,
host: str = "https://eu.i.posthog.com", host: str = "https://eu.i.posthog.com",
feature_flags_cache_ttl: int = 60,
feature_flags_cache_prefix: str = "user_feature_flags:",
**kwargs: Any, **kwargs: Any,
) -> None: ) -> None:
@@ -29,6 +35,8 @@ class PostHogAnalytics:
host=host, host=host,
**kwargs, **kwargs,
) )
self._feature_flags_cache_ttl = feature_flags_cache_ttl
self._feature_flags_cache_prefix = feature_flags_cache_prefix
@staticmethod @staticmethod
def _distinct_id(user: User) -> str | None: def _distinct_id(user: User) -> str | None:
@@ -72,3 +80,37 @@ class PostHogAnalytics:
def shutdown(self) -> None: def shutdown(self) -> None:
"""Flush pending events. Called on process exit.""" """Flush pending events. Called on process exit."""
self._client.shutdown() self._client.shutdown()
def _fetch_user_feature_flags(
self, user: User
) -> Mapping[UserFeatureFlag, bool | str | None]:
"""Compute feature flags for a user."""
distinct_id = self._distinct_id(user)
if distinct_id is None:
return {}
flags = self._client.evaluate_flags(distinct_id)
out: dict[UserFeatureFlag, bool | str | None] = {}
for flag_key in UserFeatureFlag:
out[flag_key] = flags.get_flag(flag_key.value)
return out
def get_user_feature_flags(
self, user: User
) -> Mapping[UserFeatureFlag, bool | str | None]:
"""Get feature flags for a user. Caches the result for a short time."""
distinct_id = self._distinct_id(user)
if distinct_id is None:
return {}
try:
return cache.get_or_set(
f"{self._feature_flags_cache_prefix}{distinct_id}",
default=lambda: self._fetch_user_feature_flags(user),
timeout=self._feature_flags_cache_ttl,
)
except Exception: # pylint: disable=broad-exception-caught
logger.exception("Failed to get feature flags for user %s", user.pk)
return {}
@@ -0,0 +1,9 @@
"""Catalog of all analytics feature flags used by the backend."""
from enum import StrEnum
class UserFeatureFlag(StrEnum):
"""All feature flags configured in the app."""
TRANSCRIPT_SUMMARY_ENABLED = "summary-enabled"
+3
View File
@@ -68,6 +68,9 @@ def get_frontend_configuration(request):
"enable_firefox_proxy_workaround": settings.LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND, "enable_firefox_proxy_workaround": settings.LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND,
"default_sources": settings.LIVEKIT_DEFAULT_SOURCES, "default_sources": settings.LIVEKIT_DEFAULT_SOURCES,
}, },
"authenticated_users_can_edit_display_name": (
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME
),
} }
frontend_configuration.update(settings.FRONTEND_CONFIGURATION) frontend_configuration.update(settings.FRONTEND_CONFIGURATION)
return Response(frontend_configuration) return Response(frontend_configuration)
+10
View File
@@ -563,3 +563,13 @@ class RenameParticipantSerializer(BaseValidationOnlySerializer):
"""Serializer for renaming a participant in a room.""" """Serializer for renaming a participant in a room."""
name = serializers.CharField(min_length=1, max_length=255, allow_blank=False) name = serializers.CharField(min_length=1, max_length=255, allow_blank=False)
class ExternalProcessEventSerializer(BaseValidationOnlySerializer):
"""Validate external process event data."""
job_id = serializers.CharField(required=True)
# We are not strict on purpose on those fields to avoid
# useless bad requests
type = serializers.CharField(required=False, allow_null=True, allow_blank=True)
status = serializers.CharField(required=False, allow_null=True, allow_blank=True)
+62 -1
View File
@@ -39,7 +39,10 @@ from core import analytics, enums, models, utils
from core.api.filters import ListFileFilter from core.api.filters import ListFileFilter
from core.enums import MEDIA_STORAGE_URL_PATTERN from core.enums import MEDIA_STORAGE_URL_PATTERN
from core.recording.enums import FileExtension from core.recording.enums import FileExtension
from core.recording.event.authentication import StorageEventAuthentication from core.recording.event.authentication import (
RecordingProcessWebhookAuthentication,
StorageEventAuthentication,
)
from core.recording.event.exceptions import ( from core.recording.event.exceptions import (
InvalidBucketError, InvalidBucketError,
InvalidFilepathError, InvalidFilepathError,
@@ -89,6 +92,7 @@ from core.services.subtitle import SubtitleException, SubtitleService
from core.tasks.file import process_file_deletion from core.tasks.file import process_file_deletion
from ..authentication.livekit import LiveKitTokenAuthentication from ..authentication.livekit import LiveKitTokenAuthentication
from ..models import RoomAccessLevel
from . import permissions, serializers, throttling from . import permissions, serializers, throttling
from .feature_flag import FeatureFlag from .feature_flag import FeatureFlag
@@ -264,6 +268,9 @@ class RoomViewSet(
username = request.query_params.get("username", None) username = request.query_params.get("username", None)
data = { data = {
"id": None, "id": None,
"slug": slug,
"is_administrable": False,
"access_level": RoomAccessLevel.PUBLIC,
"livekit": { "livekit": {
"url": settings.LIVEKIT_CONFIGURATION["url"], "url": settings.LIVEKIT_CONFIGURATION["url"],
"room": slug, "room": slug,
@@ -999,6 +1006,60 @@ class RecordingViewSet(
{"message": "Event processed."}, {"message": "Event processed."},
) )
@decorators.action(
detail=False,
methods=["post"],
url_path="external-process-hook",
authentication_classes=[RecordingProcessWebhookAuthentication],
serializer_class=serializers.ExternalProcessEventSerializer,
)
def on_external_process_event_received(self, request, pk=None): # pylint: disable=unused-argument
"""Handle incoming external process events for recordings."""
logger.debug("Processing external process event %s", request.data)
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
ok_response = drf_response.Response(
{"message": "Event processed."},
)
validated_data = serializer.validated_data
job_id = validated_data["job_id"]
try:
recording = models.Recording.objects.get(external_process_id=job_id)
except models.Recording.DoesNotExist as e:
logger.warning("No recording found for job_id %s: %s", job_id, e)
return ok_response
if validated_data.get("type") == "transcript":
if validated_data.get("status") == "success":
logger.info(
"External process transcript success received for recording %s",
job_id,
)
recording.status = (
models.RecordingStatusChoices.EXTERNAL_PROCESS_SUCCESSFUL
)
recording.save()
return ok_response
if validated_data.get("status") == "failure":
logger.info(
"External process transcript failure received for recording %s",
job_id,
)
recording.status = models.RecordingStatusChoices.EXTERNAL_PROCESS_FAILED
recording.save()
return ok_response
logger.info(
"No changes to save for external process id %s and payload %s",
job_id,
validated_data,
)
return ok_response
def _auth_get_original_url(self, request): def _auth_get_original_url(self, request):
""" """
Extracts and parses the original URL from the "HTTP_X_ORIGINAL_URL" header. Extracts and parses the original URL from the "HTTP_X_ORIGINAL_URL" header.
@@ -215,5 +215,6 @@ class RoomViewSet(
"client_id": client_id, "client_id": client_id,
"external_api": True, "external_api": True,
"auth_method": auth_method, "auth_method": auth_method,
"$set": {"email": self.request.user.email},
}, },
) )
@@ -0,0 +1,23 @@
# Generated by Django 5.2.14 on 2026-06-22 08:26
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0020_alter_file_upload_state'),
]
operations = [
migrations.AddField(
model_name='recording',
name='external_process_id',
field=models.CharField(blank=True, help_text='ID of the external process associated with the recording.', max_length=255, null=True, unique=True, verbose_name='External Process ID'),
),
migrations.AlterField(
model_name='recording',
name='status',
field=models.CharField(choices=[('initiated', 'Initiated'), ('active', 'Active'), ('stopped', 'Stopped'), ('saved', 'Saved'), ('aborted', 'Aborted'), ('failed_to_start', 'Failed to Start'), ('failed_to_stop', 'Failed to Stop'), ('notification_succeeded', 'Notification succeeded'), ('external_process_successful', 'External process successful'), ('external_process_failed', 'External process failed')], default='initiated', max_length=50),
),
]
+17
View File
@@ -60,6 +60,11 @@ class RecordingStatusChoices(models.TextChoices):
FAILED_TO_START = "failed_to_start", _("Failed to Start") FAILED_TO_START = "failed_to_start", _("Failed to Start")
FAILED_TO_STOP = "failed_to_stop", _("Failed to Stop") FAILED_TO_STOP = "failed_to_stop", _("Failed to Stop")
NOTIFICATION_SUCCEEDED = "notification_succeeded", _("Notification succeeded") NOTIFICATION_SUCCEEDED = "notification_succeeded", _("Notification succeeded")
EXTERNAL_PROCESS_SUCCESSFUL = (
"external_process_successful",
_("External process successful"),
)
EXTERNAL_PROCESS_FAILED = "external_process_failed", _("External process failed")
@classmethod @classmethod
def is_final(cls, status): def is_final(cls, status):
@@ -73,6 +78,8 @@ class RecordingStatusChoices(models.TextChoices):
cls.STOPPED, cls.STOPPED,
cls.SAVED, cls.SAVED,
cls.ABORTED, cls.ABORTED,
cls.EXTERNAL_PROCESS_SUCCESSFUL,
cls.EXTERNAL_PROCESS_FAILED,
cls.FAILED_TO_START, cls.FAILED_TO_START,
cls.FAILED_TO_STOP, cls.FAILED_TO_STOP,
} }
@@ -598,6 +605,14 @@ class Recording(BaseModel):
verbose_name=_("Recording options"), verbose_name=_("Recording options"),
help_text=_("Recording options"), help_text=_("Recording options"),
) )
external_process_id = models.CharField(
max_length=255,
null=True,
blank=True,
unique=True,
verbose_name=_("External Process ID"),
help_text=_("ID of the external process associated with the recording."),
)
class Meta: class Meta:
db_table = "meet_recording" db_table = "meet_recording"
@@ -653,6 +668,8 @@ class Recording(BaseModel):
return self.status in { return self.status in {
RecordingStatusChoices.NOTIFICATION_SUCCEEDED, RecordingStatusChoices.NOTIFICATION_SUCCEEDED,
RecordingStatusChoices.SAVED, RecordingStatusChoices.SAVED,
RecordingStatusChoices.EXTERNAL_PROCESS_SUCCESSFUL,
RecordingStatusChoices.EXTERNAL_PROCESS_FAILED,
} }
@property @property
@@ -14,9 +14,9 @@ logger = logging.getLogger(__name__)
class MachineUser: class MachineUser:
"""Represent a non-interactive system user for automated storage operations.""" """Represent a non-interactive system user for automated storage operations."""
def __init__(self) -> None: def __init__(self, username: str = "storage_event_user") -> None:
self.pk = None self.pk = None
self.username = "storage_event_user" self.username = username
self.is_active = True self.is_active = True
@property @property
@@ -34,33 +34,33 @@ class MachineUser:
return self.username return self.username
class StorageEventAuthentication(BaseAuthentication): class HeaderBasedAuthentication(BaseAuthentication):
"""Authenticate requests using a Bearer token for storage event integration. """Authenticate requests using a header with a secret key."""
This class validates Bearer tokens for storage events that don't map to database users.
It's designed for S3-compatible storage integrations and similar use cases.
Events are submitted when a webhook is configured on some bucket's events.
"""
AUTH_HEADER = "Authorization" AUTH_HEADER = "Authorization"
TOKEN_TYPE = "Bearer" # noqa S105 TOKEN_TYPE = "Bearer" # noqa S105
REALM = ""
IS_ENFORCED_SETTINGS_KEY = None
EXPECTED_TOKEN_SETTINGS_KEY = None
def authenticate(self, request): def authenticate(self, request):
"""Validate the Bearer token from the Authorization header.""" """Validate the Bearer token from the Authorization header."""
if not settings.RECORDING_ENABLE_STORAGE_EVENT_AUTH: if self.IS_ENFORCED_SETTINGS_KEY is not None:
return MachineUser(), None if not getattr(settings, self.IS_ENFORCED_SETTINGS_KEY):
return MachineUser(), None
required_token = settings.RECORDING_STORAGE_EVENT_TOKEN if (
if not required_token: self.EXPECTED_TOKEN_SETTINGS_KEY is None
if settings.RECORDING_ENABLE_STORAGE_EVENT_AUTH: or (required_token := getattr(settings, self.EXPECTED_TOKEN_SETTINGS_KEY))
raise AuthenticationFailed( is None
"Authentication is enabled but token is not configured." ):
) raise AuthenticationFailed(
"Authentication is enabled but token is not configured."
return MachineUser(), None )
auth_header = request.headers.get(self.AUTH_HEADER) auth_header = request.headers.get(self.AUTH_HEADER)
if not auth_header: if not auth_header:
logger.warning( logger.warning(
"Authentication failed: Missing Authorization header (ip: %s)", "Authentication failed: Missing Authorization header (ip: %s)",
@@ -68,15 +68,10 @@ class StorageEventAuthentication(BaseAuthentication):
) )
raise AuthenticationFailed("Authorization header is required") raise AuthenticationFailed("Authorization header is required")
auth_parts = auth_header.split(" ") scheme, _, token = auth_header.partition(" ")
if len(auth_parts) != 2 or auth_parts[0] != self.TOKEN_TYPE: if scheme.lower() != self.TOKEN_TYPE.lower() or not token.strip():
logger.warning( raise AuthenticationFailed("Invalid authorization header format.")
"Authentication failed: Invalid authorization header (ip: %s)", token = token.strip()
request.META.get("REMOTE_ADDR"),
)
raise AuthenticationFailed("Invalid authorization header.")
token = auth_parts[1]
# Use constant-time comparison to prevent timing attacks # Use constant-time comparison to prevent timing attacks
if not secrets.compare_digest(token.encode(), required_token.encode()): if not secrets.compare_digest(token.encode(), required_token.encode()):
@@ -90,4 +85,26 @@ class StorageEventAuthentication(BaseAuthentication):
def authenticate_header(self, request): def authenticate_header(self, request):
"""Return the WWW-Authenticate header value.""" """Return the WWW-Authenticate header value."""
return f"{self.TOKEN_TYPE} realm='Storage event API'" return f"{self.TOKEN_TYPE} realm='{self.REALM}'"
class StorageEventAuthentication(HeaderBasedAuthentication):
"""Authenticate requests using a Bearer token for storage event integration.
This class validates Bearer tokens for storage events that don't map to database users.
It's designed for S3-compatible storage integrations and similar use cases.
Events are submitted when a webhook is configured on some bucket's events.
"""
REALM = "Storage event API"
IS_ENFORCED_SETTINGS_KEY = "RECORDING_ENABLE_STORAGE_EVENT_AUTH"
EXPECTED_TOKEN_SETTINGS_KEY = "RECORDING_STORAGE_EVENT_TOKEN" # noqa S105
class RecordingProcessWebhookAuthentication(HeaderBasedAuthentication):
"""
Custom authentication class for recording process webhook requests.
Validates the API key in the Authorization header.
"""
REALM = "External process webhook API"
EXPECTED_TOKEN_SETTINGS_KEY = "SUMMARY_SERVICE_WEBHOOK_API_TOKEN" # noqa S105
@@ -4,6 +4,7 @@ import asyncio
import logging import logging
import smtplib import smtplib
from datetime import datetime, timezone from datetime import datetime, timezone
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from django.conf import settings from django.conf import settings
from django.core.mail import send_mail from django.core.mail import send_mail
@@ -17,6 +18,8 @@ from asgiref.sync import async_to_sync
from livekit import api as livekit_api from livekit import api as livekit_api
from core import models, utils from core import models, utils
from core.analytics import UserFeatureFlag, is_user_feature_flag_enabled
from core.utils import generate_download_s3_url
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -178,8 +181,49 @@ class NotificationService:
return _ns_to_utc(file_result.started_at), _ns_to_utc(file_result.ended_at) return _ns_to_utc(file_result.started_at), _ns_to_utc(file_result.ended_at)
@staticmethod
def _generate_title(
*,
locale: str,
room: str,
recording_datetime: datetime | None,
owner_timezone: str | None,
) -> str:
"""Generate title from context or return default."""
if recording_datetime is None:
with override(locale):
return _("Transcription")
dt = recording_datetime
if owner_timezone:
try:
dt = recording_datetime.astimezone(ZoneInfo(owner_timezone))
except (KeyError, ZoneInfoNotFoundError):
pass # Keep the original UTC datetime
with override(locale):
translated_template = _(
'Meeting "{room}" on {room_recording_date} at {room_recording_time}'
)
return translated_template.format(
room=room,
room_recording_date=dt.strftime("%Y-%m-%d"),
room_recording_time=dt.strftime("%H:%M"),
)
@staticmethod @staticmethod
def _notify_summary_service(recording: models.Recording): def _notify_summary_service(recording: models.Recording):
if settings.SUMMARY_SERVICE_VERSION == 1:
return NotificationService._notify_summary_service_v1(recording)
if settings.SUMMARY_SERVICE_VERSION == 2:
return NotificationService._notify_summary_service_v2(recording)
raise NotImplementedError(
f"Unknown summary service version: {settings.SUMMARY_SERVICE_VERSION}"
)
@staticmethod
def _notify_summary_service_v1(recording: models.Recording):
"""Notify summary service about a new recording.""" """Notify summary service about a new recording."""
if ( if (
@@ -253,5 +297,120 @@ class NotificationService:
return True return True
@staticmethod
def _notify_summary_service_v2(recording: models.Recording):
"""Notify summary service about a new recording."""
if (
not settings.SUMMARY_SERVICE_ENDPOINT
or not settings.SUMMARY_SERVICE_API_TOKEN
):
logger.error("Summary service not configured")
return False
owner_access = (
models.RecordingAccess.objects.select_related("user")
.filter(
role=models.RoleChoices.OWNER,
recording_id=recording.id,
)
.first()
)
metadata_filename: None | str = None
if settings.METADATA_COLLECTOR_ENABLED and recording.options.get(
"collect_metadata", False
):
output_folder = settings.METADATA_COLLECTOR_OUTPUT_FOLDER
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
started_at, ended_at = async_to_sync(
NotificationService._get_recording_timestamps
)(recording.worker_id)
form_base_url = settings.TRANSCRIPTION_SATISFACTION_FORM_BASE_URL
form_link = (
f"{form_base_url}?room_id={recording.room.id}"
if (form_base_url and metadata_filename is not None)
else None
)
metadata_payload = None
if started_at and ended_at and metadata_filename:
metadata_payload = {
"cloud_storage_url": generate_download_s3_url(
metadata_filename,
expires_in=settings.SUMMARY_SERVICE_CLOUD_STORAGE_SIGNED_URL_EXPIRY_SECONDS,
override_domain=False,
),
"started_at": started_at.isoformat(),
"ended_at": ended_at.isoformat(),
}
payload = {
"user_sub": owner_access.user.sub,
"user_email": owner_access.user.email,
"cloud_storage_url": generate_download_s3_url(
recording.key,
expires_in=settings.SUMMARY_SERVICE_CLOUD_STORAGE_SIGNED_URL_EXPIRY_SECONDS,
override_domain=False,
),
"language": recording.options.get(
"language", get_language().split("-")[0].lower()
),
"context_language": owner_access.user.language,
"push_to_docs_config": {
"user_email": owner_access.user.email,
"title": NotificationService._generate_title(
locale=owner_access.user.language
or recording.options.get("language", get_language()),
room=recording.room.name,
recording_datetime=started_at,
owner_timezone=str(owner_access.user.timezone),
),
"download_link": f"{get_recording_download_base_url()}/{recording.id}",
"form_link": form_link,
"auto_create_summary": is_user_feature_flag_enabled(
owner_access.user, UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
),
},
"metadata": metadata_payload,
}
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {settings.SUMMARY_SERVICE_API_TOKEN}",
}
try:
response = requests.post(
settings.SUMMARY_SERVICE_ENDPOINT,
json=payload,
headers=headers,
timeout=30,
)
response.raise_for_status()
response_json = response.json()
# We do not require a job_id to avoid a breaking change
job_id = response_json.get("job_id")
if not isinstance(job_id, str):
raise ValueError("job_id is not a string")
recording.external_process_id = job_id
recording.save()
except requests.RequestException as exc:
logger.exception(
"Summary service error for recording %s. URL: %s. Exception: %s",
recording.id,
settings.SUMMARY_SERVICE_ENDPOINT,
exc,
)
return False
return True
notification_service = NotificationService() notification_service = NotificationService()
@@ -12,6 +12,7 @@ import pytest
from core.analytics.events import AnalyticsEvent from core.analytics.events import AnalyticsEvent
from core.analytics.posthog import PostHogAnalytics from core.analytics.posthog import PostHogAnalytics
from core.analytics.user_feature_flags import UserFeatureFlag
from core.factories import UserFactory from core.factories import UserFactory
pytestmark = pytest.mark.django_db pytestmark = pytest.mark.django_db
@@ -248,6 +249,60 @@ def test_capture_logs_the_failing_event_name_on_exception(mock_posthog_cls, capl
assert any("PostHog capture failed" in record.message for record in caplog.records) assert any("PostHog capture failed" in record.message for record in caplog.records)
# ==============================
# feature flags
# ==============================
@patch("core.analytics.posthog.Posthog")
def test_compute_feature_flags_returns_all_catalog_entries(mock_posthog_cls):
"""Should map every declared feature flag key to the SDK evaluated value."""
backend = PostHogAnalytics(api_key="test-api-key")
user = UserFactory()
mock_posthog_cls.return_value.evaluate_flags.return_value.get_flag.return_value = (
True
)
flags = backend._fetch_user_feature_flags(user)
assert flags == {UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED: True}
mock_posthog_cls.return_value.evaluate_flags.assert_called_once_with(str(user.pk))
mock_posthog_cls.return_value.evaluate_flags.return_value.get_flag.assert_called_once_with(
UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED.value
)
@patch("core.analytics.posthog.cache.get_or_set")
@patch("core.analytics.posthog.Posthog")
def test_get_feature_flags_uses_cache_get_or_set(
mock_posthog_cls, mock_cache_get_or_set
):
"""Should cache feature flags by user distinct id with configured TTL."""
cached_flags = {UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED: False}
mock_cache_get_or_set.return_value = cached_flags
backend = PostHogAnalytics(api_key="test-api-key", feature_flags_cache_ttl=120)
user = UserFactory()
flags = backend.get_user_feature_flags(user)
assert flags == cached_flags
mock_cache_get_or_set.assert_called_once()
args, kwargs = mock_cache_get_or_set.call_args
assert kwargs["timeout"] == 120
assert args[0] == f"user_feature_flags:{user.pk}"
assert callable(kwargs["default"])
@patch("core.analytics.posthog.Posthog")
def test_get_feature_flags_returns_empty_dict_on_exception(mock_posthog_cls):
"""Should swallow failures and return an empty mapping."""
backend = PostHogAnalytics(api_key="test-api-key")
user = UserFactory()
with patch("core.analytics.posthog.cache.get_or_set", side_effect=RuntimeError):
assert backend.get_user_feature_flags(user) == {}
# ============================== # ==============================
# shutdown # shutdown
# ============================== # ==============================
@@ -136,10 +136,10 @@ def test_authenticate_header():
def test_multiple_spaces_in_auth_header(settings): def test_multiple_spaces_in_auth_header(settings):
"""Test failure when Authorization header contains multiple spaces.""" """Test success when Authorization header contains multiple spaces."""
settings.RECORDING_STORAGE_EVENT_TOKEN = "valid-test-token" settings.RECORDING_STORAGE_EVENT_TOKEN = "valid-test-token"
request = RequestFactory().get("/") request = RequestFactory().get("/")
request.headers = {"Authorization": "Bearer extra-spaces-token"} request.headers = {"Authorization": "Bearer extra-spaces-token"}
with pytest.raises(AuthenticationFailed, match="Invalid authorization header"): header = StorageEventAuthentication().authenticate_header(request)
StorageEventAuthentication().authenticate(request) assert header == "Bearer realm='Storage event API'"
@@ -13,6 +13,7 @@ from django.contrib.sites.models import Site
import pytest import pytest
from core import factories, models from core import factories, models
from core.analytics import UserFeatureFlag
from core.recording.event.notification import NotificationService, notification_service from core.recording.event.notification import NotificationService, notification_service
pytestmark = pytest.mark.django_db pytestmark = pytest.mark.django_db
@@ -243,3 +244,177 @@ def test_notify_user_by_email_smtp_exception(mocked_current_site, caplog):
assert result is False assert result is False
assert mock_send_mail.call_count == 2 assert mock_send_mail.call_count == 2
assert "notification could not be sent:" in caplog.text assert "notification could not be sent:" in caplog.text
@mock.patch("core.recording.event.notification.requests.post")
@mock.patch("core.recording.event.notification.generate_download_s3_url")
@mock.patch.object(
NotificationService, "_get_recording_timestamps", new_callable=mock.AsyncMock
)
def test_notify_summary_service_post_args_with_metadata(
mock_get_recording_timestamps,
mock_generate_download_s3_url,
mock_post,
settings,
):
"""Test summary notification computed request args when metadata is enabled."""
settings.SUMMARY_SERVICE_VERSION = 2
settings.SUMMARY_SERVICE_ENDPOINT = "https://summary.test/api/v2/tasks"
settings.SUMMARY_SERVICE_API_TOKEN = "summary-token"
settings.RECORDING_DOWNLOAD_BASE_URL = "https://app.test/recordings"
settings.SCREEN_RECORDING_BASE_URL = None
settings.METADATA_COLLECTOR_ENABLED = True
settings.METADATA_COLLECTOR_OUTPUT_FOLDER = "recordings-metadata"
recording = factories.RecordingFactory(
room__name="Engineering Sync",
worker_id="egress-1",
options={"collect_metadata": True, "language": "en-us"},
)
owner = factories.UserFactory(
email="owner@test.com",
sub="owner-sub",
language="fr-fr",
timezone="Europe/Paris",
)
factories.UserRecordingAccessFactory(
recording=recording, role=models.RoleChoices.OWNER, user=owner
)
started_at = datetime.datetime(2026, 1, 2, 10, 30, tzinfo=datetime.timezone.utc)
ended_at = datetime.datetime(2026, 1, 2, 11, 45, tzinfo=datetime.timezone.utc)
mock_get_recording_timestamps.return_value = (started_at, ended_at)
mock_generate_download_s3_url.side_effect = [
"https://storage.test/metadata.json",
"https://storage.test/recording.ogg",
]
mock_response = mock.Mock()
mock_response.raise_for_status.return_value = None
mock_response.json.return_value = {"job_id": "job-42"}
mock_post.return_value = mock_response
result = NotificationService._notify_summary_service(recording)
recording.refresh_from_db()
assert result is True
assert recording.external_process_id == "job-42"
metadata_filename = (
f"{settings.METADATA_COLLECTOR_OUTPUT_FOLDER}/{recording.id}-metadata.json"
)
expected_payload = {
"user_sub": owner.sub,
"user_email": owner.email,
"cloud_storage_url": "https://storage.test/recording.ogg",
"language": "en-us",
"context_language": owner.language,
"push_to_docs_config": {
"user_email": owner.email,
"title": 'Réunion "Engineering Sync" du 2026-01-02 à 11:30',
"download_link": f"{settings.RECORDING_DOWNLOAD_BASE_URL}/{recording.id}",
"auto_create_summary": False,
"form_link": None,
},
"metadata": {
"cloud_storage_url": "https://storage.test/metadata.json",
"started_at": started_at.isoformat(),
"ended_at": ended_at.isoformat(),
},
}
expected_headers = {
"Content-Type": "application/json",
"Authorization": "Bearer summary-token",
}
mock_post.assert_called_once_with(
"https://summary.test/api/v2/tasks",
json=expected_payload,
headers=expected_headers,
timeout=30,
)
assert mock_generate_download_s3_url.call_args_list == [
mock.call(metadata_filename, expires_in=60 * 60 * 24, override_domain=False),
mock.call(recording.key, expires_in=60 * 60 * 24, override_domain=False),
]
mock_get_recording_timestamps.assert_awaited_once_with("egress-1")
@mock.patch("core.recording.event.notification.requests.post")
@mock.patch("core.recording.event.notification.generate_download_s3_url")
@mock.patch.object(
NotificationService, "_get_recording_timestamps", new_callable=mock.AsyncMock
)
@pytest.mark.parametrize("auto_create_summary_enabled", [False, True])
def test_notify_summary_service_post_args_without_metadata(
mock_get_recording_timestamps,
mock_generate_download_s3_url,
mock_post,
auto_create_summary_enabled,
settings,
):
"""Test summary notification computed request args when metadata is not available."""
settings.SUMMARY_SERVICE_VERSION = 2
settings.SUMMARY_SERVICE_ENDPOINT = "https://summary.test/api/v2/tasks"
settings.SUMMARY_SERVICE_API_TOKEN = "summary-token"
settings.RECORDING_DOWNLOAD_BASE_URL = "https://app.test/recordings"
settings.SCREEN_RECORDING_BASE_URL = None
settings.METADATA_COLLECTOR_ENABLED = False
recording = factories.RecordingFactory(room__name="Daily")
owner = factories.UserFactory(
email="owner@test.com",
sub="owner-sub",
language="en-us",
timezone="UTC",
)
factories.UserRecordingAccessFactory(
recording=recording, role=models.RoleChoices.OWNER, user=owner
)
mock_get_recording_timestamps.return_value = (None, None)
mock_generate_download_s3_url.return_value = "https://storage.test/recording.mp4"
mock_response = mock.Mock()
mock_response.raise_for_status.return_value = None
mock_response.json.return_value = {"job_id": "job-51"}
mock_post.return_value = mock_response
with mock.patch(
"core.recording.event.notification.is_user_feature_flag_enabled",
return_value=auto_create_summary_enabled,
) as mock_is_feature_flag_enabled:
result = NotificationService._notify_summary_service(recording)
assert result is True
expected_payload = {
"user_sub": owner.sub,
"user_email": owner.email,
"cloud_storage_url": "https://storage.test/recording.mp4",
"language": "en",
"context_language": owner.language,
"push_to_docs_config": {
"user_email": owner.email,
"title": "Transcription",
"download_link": f"{settings.RECORDING_DOWNLOAD_BASE_URL}/{recording.id}",
"auto_create_summary": auto_create_summary_enabled,
"form_link": None,
},
"metadata": None,
}
expected_headers = {
"Content-Type": "application/json",
"Authorization": "Bearer summary-token",
}
mock_post.assert_called_once_with(
"https://summary.test/api/v2/tasks",
json=expected_payload,
headers=expected_headers,
timeout=30,
)
mock_generate_download_s3_url.assert_called_once_with(
recording.key, expires_in=60 * 60 * 24, override_domain=False
)
mock_get_recording_timestamps.assert_awaited_once_with(recording.worker_id)
mock_is_feature_flag_enabled.assert_called_once_with(
owner, UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
)
@@ -0,0 +1,134 @@
"""
Test recordings API endpoints: external process hook.
"""
# pylint: disable=redefined-outer-name,unused-argument
import pytest
from ...factories import RecordingFactory
from ...models import RecordingStatusChoices
pytestmark = pytest.mark.django_db
@pytest.fixture
def external_process_settings(settings):
"""Configure authentication token for the external process webhook."""
settings.SUMMARY_SERVICE_WEBHOOK_API_TOKEN = "testWebhookToken"
return settings
def test_external_process_event_missing_authorization_header(
external_process_settings, client
):
"""Requests without authorization must be rejected."""
response = client.post(
"/api/v1.0/recordings/external-process-hook/",
{"job_id": "job-1", "type": "transcript", "status": "success"},
)
assert response.status_code == 401
def test_external_process_event_wrong_bearer_token(external_process_settings, client):
"""Requests with invalid bearer token must be rejected."""
response = client.post(
"/api/v1.0/recordings/external-process-hook/",
{"job_id": "job-1", "type": "transcript", "status": "success"},
HTTP_AUTHORIZATION="Bearer wrongToken",
)
assert response.status_code == 401
def test_external_process_event_missing_job_id(external_process_settings, client):
"""Payload without job_id must fail validation."""
response = client.post(
"/api/v1.0/recordings/external-process-hook/",
{"type": "transcript", "status": "success"},
HTTP_AUTHORIZATION="Bearer testWebhookToken",
)
assert response.status_code == 400
assert response.json() == {"job_id": ["This field is required."]}
def test_external_process_event_success_updates_recording_status(
external_process_settings, client
):
"""A successful transcript process should update recording status."""
recording = RecordingFactory(
status=RecordingStatusChoices.SAVED,
external_process_id="job-123",
)
response = client.post(
"/api/v1.0/recordings/external-process-hook/",
{"job_id": "job-123", "type": "transcript", "status": "success"},
HTTP_AUTHORIZATION="Bearer testWebhookToken",
)
assert response.status_code == 200
assert response.json() == {"message": "Event processed."}
recording.refresh_from_db()
assert recording.status == RecordingStatusChoices.EXTERNAL_PROCESS_SUCCESSFUL
def test_external_process_event_failure_updates_recording_status(
external_process_settings, client
):
"""A failing transcript process should update recording status."""
recording = RecordingFactory(
status=RecordingStatusChoices.SAVED,
external_process_id="job-456",
)
response = client.post(
"/api/v1.0/recordings/external-process-hook/",
{"job_id": "job-456", "type": "transcript", "status": "failure"},
HTTP_AUTHORIZATION="Bearer testWebhookToken",
)
assert response.status_code == 200
assert response.json() == {"message": "Event processed."}
recording.refresh_from_db()
assert recording.status == RecordingStatusChoices.EXTERNAL_PROCESS_FAILED
def test_external_process_event_unknown_recording_is_ignored(
external_process_settings, client
):
"""Unknown job_id should not fail the webhook processing."""
response = client.post(
"/api/v1.0/recordings/external-process-hook/",
{"job_id": "missing-job", "type": "transcript", "status": "success"},
HTTP_AUTHORIZATION="Bearer testWebhookToken",
)
assert response.status_code == 200
assert response.json() == {"message": "Event processed."}
def test_external_process_event_non_transcript_event_does_not_change_status(
external_process_settings, client
):
"""Only transcript events should update recording status."""
recording = RecordingFactory(
status=RecordingStatusChoices.SAVED,
external_process_id="job-789",
)
response = client.post(
"/api/v1.0/recordings/external-process-hook/",
{"job_id": "job-789", "type": "thumbnail", "status": "success"},
HTTP_AUTHORIZATION="Bearer testWebhookToken",
)
assert response.status_code == 200
assert response.json() == {"message": "Event processed."}
recording.refresh_from_db()
assert recording.status == RecordingStatusChoices.SAVED
@@ -130,6 +130,9 @@ def test_api_rooms_retrieve_anonymous_unregistered_allowed(mock_token):
assert response.status_code == 200 assert response.status_code == 200
assert response.json() == { assert response.json() == {
"id": None, "id": None,
"slug": "unregistered-room",
"access_level": "public",
"is_administrable": False,
"livekit": { "livekit": {
"url": "test_url_value", "url": "test_url_value",
"room": "unregistered-room", "room": "unregistered-room",
@@ -162,6 +165,9 @@ def test_api_rooms_retrieve_anonymous_unregistered_allowed_not_normalized(mock_t
assert response.status_code == 200 assert response.status_code == 200
assert response.json() == { assert response.json() == {
"id": None, "id": None,
"slug": "reunion",
"access_level": "public",
"is_administrable": False,
"livekit": { "livekit": {
"url": "test_url_value", "url": "test_url_value",
"room": "reunion", "room": "reunion",
+97 -1
View File
@@ -2,13 +2,109 @@
Test utils functions Test utils functions
""" """
# pylint: disable=W0621
import json import json
from unittest import mock from unittest import mock
from django.conf import settings
from django.contrib.auth.models import AnonymousUser
import jwt
import pytest import pytest
from livekit.api import TwirpError from livekit.api import TwirpError
from core.utils import NotificationError, create_livekit_client, notify_participants from core.factories import UserFactory
from core.utils import (
NotificationError,
create_livekit_client,
generate_token,
notify_participants,
)
pytestmark = pytest.mark.django_db
def decode_token(token: str) -> dict:
"""Decode a LiveKit JWT access token for inspection."""
return jwt.decode(
token,
settings.LIVEKIT_CONFIGURATION["api_secret"],
algorithms=["HS256"],
)
def test_generate_token_authenticated_uses_full_name():
"""The token's display name should default to the user's full name."""
user = UserFactory(full_name="Jane Doe")
token = generate_token(room="my-room", user=user)
claims = decode_token(token)
assert claims["name"] == "Jane Doe"
assert claims["sub"] == str(user.sub)
def test_generate_token_authenticated_fallback_user_representation():
"""
When the user has no full name, the token's display name should fall back
to the user's string representation.
"""
user = UserFactory(full_name=None)
token = generate_token(room="my-room", user=user)
claims = decode_token(token)
assert claims["name"] == str(user)
def test_generate_token_explicit_username_overrides_default():
"""An explicitly provided username should take precedence over the full name."""
user = UserFactory(full_name="Jane Doe")
token = generate_token(room="my-room", user=user, username="Custom Name")
claims = decode_token(token)
assert claims["name"] == "Custom Name"
def test_authenticated_username_ignored_when_editing_disabled(settings):
"""With editing disabled, an authenticated user's username is ignored."""
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = False
user = UserFactory(full_name="Jane Doe")
token = generate_token(room="my-room", user=user, username="Custom Name")
claims = decode_token(token)
assert claims["name"] == "Jane Doe"
def test_authenticated_default_name_unaffected_when_editing_disabled(settings):
"""Disabling editing doesn't disturb the default full-name path."""
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = False
user = UserFactory(full_name="Jane Doe")
token = generate_token(room="my-room", user=user)
claims = decode_token(token)
assert claims["name"] == "Jane Doe"
def test_anonymous_uses_username_when_provided():
"""An anonymous user's provided username is used as the display name."""
token = generate_token(room="my-room", user=AnonymousUser(), username="Guest42")
claims = decode_token(token)
assert claims["name"] == "Guest42"
def test_anonymous_username_used_even_when_editing_disabled(settings):
"""The setting governs authenticated users only; anonymous can still set a name."""
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = False
token = generate_token(room="my-room", user=AnonymousUser(), username="Guest42")
claims = decode_token(token)
assert claims["name"] == "Guest42"
def test_anonymous_falls_back_to_anonymous_label():
"""With no username, an anonymous user is labelled 'Anonymous'."""
token = generate_token(room="my-room", user=AnonymousUser())
claims = decode_token(token)
assert claims["name"] == "Anonymous"
@mock.patch("asyncio.get_running_loop") @mock.patch("asyncio.get_running_loop")
+13 -6
View File
@@ -109,11 +109,16 @@ def generate_token(
default_username = "Anonymous" default_username = "Anonymous"
else: else:
identity = str(user.sub) identity = str(user.sub)
default_username = str(user) default_username = user.full_name or str(user)
if color is None: if color is None:
color = generate_color(identity) color = generate_color(identity)
can_edit = (
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME or user.is_anonymous
)
display_name = (username or default_username) if can_edit else default_username
token = ( token = (
AccessToken( AccessToken(
api_key=settings.LIVEKIT_CONFIGURATION["api_key"], api_key=settings.LIVEKIT_CONFIGURATION["api_key"],
@@ -121,7 +126,7 @@ def generate_token(
) )
.with_grants(video_grants) .with_grants(video_grants)
.with_identity(identity) .with_identity(identity)
.with_name(username or default_username) .with_name(display_name)
.with_attributes( .with_attributes(
{"color": color, "room_admin": "true" if is_admin_or_owner else "false"} {"color": color, "room_admin": "true" if is_admin_or_owner else "false"}
) )
@@ -459,12 +464,14 @@ def generate_upload_policy(file):
return policy return policy
def generate_download_file_url(file, *, expires_in: int, override_domain: bool = True): def generate_download_s3_url(
key: str, *, expires_in: int, override_domain: bool = True
):
""" """
Generate a S3 signed download url for a given file. Generate a S3 signed download url for a given key.
""" """
if not key:
key = file.file_key raise ValueError("key cannot be empty")
# This setting should be used if the backend application and the frontend application # This setting should be used if the backend application and the frontend application
# can't connect to the object storage with the same domain. This is the case in the # can't connect to the object storage with the same domain. This is the case in the
+159 -101
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-04-21 14:09+0000\n" "POT-Creation-Date: 2026-07-02 10:47+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -17,70 +17,92 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n" "Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
#: core/admin.py:29 #: core/admin.py:67
msgid "Personal info" msgid "Personal info"
msgstr "Persönliche Informationen" msgstr "Persönliche Informationen"
#: core/admin.py:42 #: core/admin.py:80
msgid "Permissions" msgid "Permissions"
msgstr "Berechtigungen" msgstr "Berechtigungen"
#: core/admin.py:54 #: core/admin.py:92
msgid "Important dates" msgid "Important dates"
msgstr "Wichtige Daten" msgstr "Wichtige Daten"
#: core/admin.py:132 core/admin.py:275 #: core/admin.py:206
msgid "Content"
msgstr ""
#: core/admin.py:217
#, fuzzy
#| msgid "Delete rooms"
msgid "Deletion"
msgstr "Räume löschen"
#: core/admin.py:226
msgid "Derived info"
msgstr ""
#: core/admin.py:237
msgid "Timestamps"
msgstr ""
#: core/admin.py:240
msgid "File preview"
msgstr ""
#: core/admin.py:300 core/admin.py:443
msgid "No owner" msgid "No owner"
msgstr "Kein Eigentümer" msgstr "Kein Eigentümer"
#: core/admin.py:135 core/admin.py:278 #: core/admin.py:303 core/admin.py:446
msgid "Multiple owners" msgid "Multiple owners"
msgstr "Mehrere Eigentümer" msgstr "Mehrere Eigentümer"
#: core/admin.py:148 #: core/admin.py:316
msgid "Resend notification to external service" msgid "Resend notification to external service"
msgstr "Benachrichtigung erneut an externen Dienst senden" msgstr "Benachrichtigung erneut an externen Dienst senden"
#: core/admin.py:171 #: core/admin.py:339
#, python-format #, python-format
msgid "Failed to notify for recording %(id)s" msgid "Failed to notify for recording %(id)s"
msgstr "Benachrichtigung für Aufnahme %(id)s fehlgeschlagen" msgstr "Benachrichtigung für Aufnahme %(id)s fehlgeschlagen"
#: core/admin.py:179 #: core/admin.py:347
#, python-format #, python-format
msgid "Failed to notify for recording %(id)s: %(error)s" msgid "Failed to notify for recording %(id)s: %(error)s"
msgstr "Benachrichtigung für Aufnahme %(id)s fehlgeschlagen: %(error)s" msgstr "Benachrichtigung für Aufnahme %(id)s fehlgeschlagen: %(error)s"
#: core/admin.py:187 #: core/admin.py:355
#, python-format #, python-format
msgid "Successfully sent notifications for %(count)s recording(s)." msgid "Successfully sent notifications for %(count)s recording(s)."
msgstr "Benachrichtigungen für %(count)s Aufnahme(n) erfolgreich gesendet." msgstr "Benachrichtigungen für %(count)s Aufnahme(n) erfolgreich gesendet."
#: core/admin.py:195 #: core/admin.py:363
#, python-format #, python-format
msgid "Skipped %(count)s expired recording(s)." msgid "Skipped %(count)s expired recording(s)."
msgstr "%(count)s abgelaufene Aufnahme(n) übersprungen." msgstr "%(count)s abgelaufene Aufnahme(n) übersprungen."
#: core/admin.py:200 #: core/admin.py:368
msgid "Mark selected recordings as 'Failed to Stop'" msgid "Mark selected recordings as 'Failed to Stop'"
msgstr "Ausgewählte Aufnahmen als Fehler beim Stoppen markieren" msgstr "Ausgewählte Aufnahmen als Fehler beim Stoppen markieren"
#: core/admin.py:218 #: core/admin.py:386
#, python-format #, python-format
msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'." msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'."
msgstr "%(count)s Aufnahme(n) erfolgreich als Fehler beim Stoppen markiert." msgstr "%(count)s Aufnahme(n) erfolgreich als Fehler beim Stoppen markiert."
#: core/admin.py:226 #: core/admin.py:394
#, fuzzy, python-format #, fuzzy, python-format
#| msgid "Skipped %(count)s expired recording(s)." #| msgid "Skipped %(count)s expired recording(s)."
msgid "Skipped %(count)s recording(s) with an ineligible status." msgid "Skipped %(count)s recording(s) with an ineligible status."
msgstr "%(count)s abgelaufene Aufnahme(n) übersprungen." msgstr "%(count)s abgelaufene Aufnahme(n) übersprungen."
#: core/admin.py:342 #: core/admin.py:510
msgid "No scopes" msgid "No scopes"
msgstr "Keine Scopes" msgstr "Keine Scopes"
#: core/admin.py:344 #: core/admin.py:512
msgid "Scopes" msgid "Scopes"
msgstr "Scopes" msgstr "Scopes"
@@ -88,17 +110,17 @@ msgstr "Scopes"
msgid "Creator is me" msgid "Creator is me"
msgstr "Ersteller bin ich" msgstr "Ersteller bin ich"
#: core/api/serializers.py:88 #: core/api/serializers.py:89
msgid "You must be administrator or owner of a room to add accesses to it." msgid "You must be administrator or owner of a room to add accesses to it."
msgstr "" msgstr ""
"Sie müssen Administrator oder Eigentümer eines Raums sein, um Zugriffe " "Sie müssen Administrator oder Eigentümer eines Raums sein, um Zugriffe "
"hinzuzufügen." "hinzuzufügen."
#: core/api/serializers.py:509 #: core/api/serializers.py:534
msgid "This file extension is not allowed." msgid "This file extension is not allowed."
msgstr "Diese Dateiendung ist nicht erlaubt." msgstr "Diese Dateiendung ist nicht erlaubt."
#: core/api/viewsets.py:1090 #: core/api/viewsets.py:1222
msgid "You have reached the maximum number of files for this type." msgid "You have reached the maximum number of files for this type."
msgstr "Sie haben die maximale Anzahl an Dateien dieses Typs erreicht." msgstr "Sie haben die maximale Anzahl an Dateien dieses Typs erreicht."
@@ -146,51 +168,59 @@ msgstr "Stopp fehlgeschlagen"
msgid "Notification succeeded" msgid "Notification succeeded"
msgstr "Benachrichtigung erfolgreich" msgstr "Benachrichtigung erfolgreich"
#: core/models.py:89 #: core/models.py:65
msgid "External process successful"
msgstr "Externer Prozess erfolgreich"
#: core/models.py:67
msgid "External process failed"
msgstr "Externer Prozess fehlgeschlagen"
#: core/models.py:96
msgid "SCREEN_RECORDING" msgid "SCREEN_RECORDING"
msgstr "BILDSCHIRMAUFZEICHNUNG" msgstr "BILDSCHIRMAUFZEICHNUNG"
#: core/models.py:90 #: core/models.py:97
msgid "TRANSCRIPT" msgid "TRANSCRIPT"
msgstr "TRANSKRIPT" msgstr "TRANSKRIPT"
#: core/models.py:96 #: core/models.py:103
msgid "Public Access" msgid "Public Access"
msgstr "Öffentlicher Zugriff" msgstr "Öffentlicher Zugriff"
#: core/models.py:97 #: core/models.py:104
msgid "Trusted Access" msgid "Trusted Access"
msgstr "Vertrauenswürdiger Zugriff" msgstr "Vertrauenswürdiger Zugriff"
#: core/models.py:98 #: core/models.py:105
msgid "Restricted Access" msgid "Restricted Access"
msgstr "Eingeschränkter Zugriff" msgstr "Eingeschränkter Zugriff"
#: core/models.py:110 #: core/models.py:117
msgid "id" msgid "id"
msgstr "ID" msgstr "ID"
#: core/models.py:111 #: core/models.py:118
msgid "primary key for the record as UUID" msgid "primary key for the record as UUID"
msgstr "Primärschlüssel des Eintrags als UUID" msgstr "Primärschlüssel des Eintrags als UUID"
#: core/models.py:117 #: core/models.py:124
msgid "created on" msgid "created on"
msgstr "erstellt am" msgstr "erstellt am"
#: core/models.py:118 #: core/models.py:125
msgid "date and time at which a record was created" msgid "date and time at which a record was created"
msgstr "Datum und Uhrzeit der Erstellung eines Eintrags" msgstr "Datum und Uhrzeit der Erstellung eines Eintrags"
#: core/models.py:123 #: core/models.py:130
msgid "updated on" msgid "updated on"
msgstr "aktualisiert am" msgstr "aktualisiert am"
#: core/models.py:124 #: core/models.py:131
msgid "date and time at which a record was last updated" msgid "date and time at which a record was last updated"
msgstr "Datum und Uhrzeit der letzten Aktualisierung eines Eintrags" msgstr "Datum und Uhrzeit der letzten Aktualisierung eines Eintrags"
#: core/models.py:144 #: core/models.py:151
msgid "" msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/" "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters." "_ characters."
@@ -198,11 +228,11 @@ msgstr ""
"Geben Sie einen gültigen Sub ein. Dieser Wert darf nur Buchstaben, Zahlen " "Geben Sie einen gültigen Sub ein. Dieser Wert darf nur Buchstaben, Zahlen "
"und die Zeichen @/./+/-/_ enthalten." "und die Zeichen @/./+/-/_ enthalten."
#: core/models.py:150 #: core/models.py:157
msgid "sub" msgid "sub"
msgstr "Sub" msgstr "Sub"
#: core/models.py:152 #: core/models.py:159
msgid "" msgid ""
"Optional for pending users; required upon account activation. 255 characters " "Optional for pending users; required upon account activation. 255 characters "
"or fewer. Letters, numbers, and @/./+/-/_ characters only." "or fewer. Letters, numbers, and @/./+/-/_ characters only."
@@ -210,55 +240,55 @@ msgstr ""
"Optional für ausstehende Benutzer; erforderlich nach Kontoaktivierung. " "Optional für ausstehende Benutzer; erforderlich nach Kontoaktivierung. "
"Maximal 255 Zeichen. Nur Buchstaben, Zahlen und @/./+/-/_ Zeichen erlaubt." "Maximal 255 Zeichen. Nur Buchstaben, Zahlen und @/./+/-/_ Zeichen erlaubt."
#: core/models.py:161 #: core/models.py:168
msgid "identity email address" msgid "identity email address"
msgstr "Identitäts-E-Mail-Adresse" msgstr "Identitäts-E-Mail-Adresse"
#: core/models.py:166 #: core/models.py:173
msgid "admin email address" msgid "admin email address"
msgstr "Administrator-E-Mail-Adresse" msgstr "Administrator-E-Mail-Adresse"
#: core/models.py:168 #: core/models.py:175
msgid "full name" msgid "full name"
msgstr "Vollständiger Name" msgstr "Vollständiger Name"
#: core/models.py:170 #: core/models.py:177
msgid "short name" msgid "short name"
msgstr "Kurzname" msgstr "Kurzname"
#: core/models.py:176 #: core/models.py:183
msgid "language" msgid "language"
msgstr "Sprache" msgstr "Sprache"
#: core/models.py:177 #: core/models.py:184
msgid "The language in which the user wants to see the interface." 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." msgstr "Die Sprache, in der der Benutzer die Oberfläche sehen möchte."
#: core/models.py:183 #: core/models.py:190
msgid "The timezone in which the user wants to see times." msgid "The timezone in which the user wants to see times."
msgstr "Die Zeitzone, in der der Benutzer die Zeiten sehen möchte." msgstr "Die Zeitzone, in der der Benutzer die Zeiten sehen möchte."
#: core/models.py:186 #: core/models.py:193
msgid "device" msgid "device"
msgstr "Gerät" msgstr "Gerät"
#: core/models.py:188 #: core/models.py:195
msgid "Whether the user is a device or a real user." msgid "Whether the user is a device or a real user."
msgstr "Ob es sich um ein Gerät oder einen echten Benutzer handelt." msgstr "Ob es sich um ein Gerät oder einen echten Benutzer handelt."
#: core/models.py:191 #: core/models.py:198
msgid "staff status" msgid "staff status"
msgstr "Mitarbeiterstatus" msgstr "Mitarbeiterstatus"
#: core/models.py:193 #: core/models.py:200
msgid "Whether the user can log into this admin site." msgid "Whether the user can log into this admin site."
msgstr "Ob der Benutzer sich bei dieser Admin-Seite anmelden kann." msgstr "Ob der Benutzer sich bei dieser Admin-Seite anmelden kann."
#: core/models.py:196 #: core/models.py:203
msgid "active" msgid "active"
msgstr "aktiv" msgstr "aktiv"
#: core/models.py:199 #: core/models.py:206
msgid "" msgid ""
"Whether this user should be treated as active. Unselect this instead of " "Whether this user should be treated as active. Unselect this instead of "
"deleting accounts." "deleting accounts."
@@ -266,66 +296,66 @@ msgstr ""
"Ob dieser Benutzer als aktiv behandelt werden soll. Deaktivieren Sie dies " "Ob dieser Benutzer als aktiv behandelt werden soll. Deaktivieren Sie dies "
"anstelle des Löschens des Kontos." "anstelle des Löschens des Kontos."
#: core/models.py:212 #: core/models.py:219
msgid "user" msgid "user"
msgstr "Benutzer" msgstr "Benutzer"
#: core/models.py:213 #: core/models.py:220
msgid "users" msgid "users"
msgstr "Benutzer" msgstr "Benutzer"
#: core/models.py:272 #: core/models.py:286
msgid "Resource" msgid "Resource"
msgstr "Ressource" msgstr "Ressource"
#: core/models.py:273 #: core/models.py:287
msgid "Resources" msgid "Resources"
msgstr "Ressourcen" msgstr "Ressourcen"
#: core/models.py:331 #: core/models.py:345
msgid "Resource access" msgid "Resource access"
msgstr "Ressourcenzugriff" msgstr "Ressourcenzugriff"
#: core/models.py:332 #: core/models.py:346
msgid "Resource accesses" msgid "Resource accesses"
msgstr "Ressourcenzugriffe" msgstr "Ressourcenzugriffe"
#: core/models.py:338 #: core/models.py:352
msgid "Resource access with this User and Resource already exists." msgid "Resource access with this User and Resource already exists."
msgstr "" msgstr ""
"Ein Ressourcenzugriff mit diesem Benutzer und dieser Ressource existiert " "Ein Ressourcenzugriff mit diesem Benutzer und dieser Ressource existiert "
"bereits." "bereits."
#: core/models.py:394 #: core/models.py:409
msgid "Visio room configuration" msgid "Visio room configuration"
msgstr "Visio-Raumkonfiguration" msgstr "Visio-Raumkonfiguration"
#: core/models.py:395 #: core/models.py:410
msgid "Values for Visio parameters to configure the room." msgid "Values for Visio parameters to configure the room."
msgstr "Werte für Visio-Parameter zur Konfiguration des Raums." msgstr "Werte für Visio-Parameter zur Konfiguration des Raums."
#: core/models.py:402 #: core/models.py:417
msgid "Room PIN code" msgid "Room PIN code"
msgstr "PIN-Code für den Raum" msgstr "PIN-Code für den Raum"
#: core/models.py:403 #: core/models.py:418
msgid "Unique n-digit code that identifies this room in telephony mode." msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr "" msgstr ""
"Eindeutiger n-stelliger Code, der diesen Raum im Telephonmodus identifiziert." "Eindeutiger n-stelliger Code, der diesen Raum im Telephonmodus identifiziert."
#: core/models.py:409 core/models.py:563 #: core/models.py:424 core/models.py:578
msgid "Room" msgid "Room"
msgstr "Raum" msgstr "Raum"
#: core/models.py:410 #: core/models.py:425
msgid "Rooms" msgid "Rooms"
msgstr "Räume" msgstr "Räume"
#: core/models.py:574 #: core/models.py:589
msgid "Worker ID" msgid "Worker ID"
msgstr "Worker-ID" msgstr "Worker-ID"
#: core/models.py:576 #: core/models.py:591
msgid "" msgid ""
"Enter an identifier for the worker recording.This ID is retained even when " "Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking." "the worker stops, allowing for easy tracking."
@@ -334,141 +364,153 @@ msgstr ""
"erhalten, auch wenn der Worker stoppt, was ein einfaches Nachverfolgen " "erhalten, auch wenn der Worker stoppt, was ein einfaches Nachverfolgen "
"ermöglicht." "ermöglicht."
#: core/models.py:584 #: core/models.py:599
msgid "Recording mode" msgid "Recording mode"
msgstr "Aufzeichnungsmodus" msgstr "Aufzeichnungsmodus"
#: core/models.py:585 #: core/models.py:600
msgid "Defines the mode of recording being called." msgid "Defines the mode of recording being called."
msgstr "Definiert den aufgerufenen Aufzeichnungsmodus." msgstr "Definiert den aufgerufenen Aufzeichnungsmodus."
#: core/models.py:590 core/models.py:591 #: core/models.py:605 core/models.py:606
msgid "Recording options" msgid "Recording options"
msgstr "Aufnahmeoptionen" msgstr "Aufnahmeoptionen"
#: core/models.py:597 #: core/models.py:613
msgid "External Process ID"
msgstr "External Process ID"
#: core/models.py:614
msgid "ID of the external process associated with the recording."
msgstr "ID des externen Prozesses, der mit der Aufzeichnung verknüpft ist"
#: core/models.py:620
msgid "Recording" msgid "Recording"
msgstr "Aufzeichnung" msgstr "Aufzeichnung"
#: core/models.py:598 #: core/models.py:621
msgid "Recordings" msgid "Recordings"
msgstr "Aufzeichnungen" msgstr "Aufzeichnungen"
#: core/models.py:706 #: core/models.py:731
msgid "Recording/user relation" msgid "Recording/user relation"
msgstr "Beziehung Aufzeichnung/Benutzer" msgstr "Beziehung Aufzeichnung/Benutzer"
#: core/models.py:707 #: core/models.py:732
msgid "Recording/user relations" msgid "Recording/user relations"
msgstr "Beziehungen Aufzeichnung/Benutzer" msgstr "Beziehungen Aufzeichnung/Benutzer"
#: core/models.py:713 #: core/models.py:738
msgid "This user is already in this recording." msgid "This user is already in this recording."
msgstr "Dieser Benutzer ist bereits Teil dieser Aufzeichnung." msgstr "Dieser Benutzer ist bereits Teil dieser Aufzeichnung."
#: core/models.py:719 #: core/models.py:744
msgid "This team is already in this recording." msgid "This team is already in this recording."
msgstr "Dieses Team ist bereits Teil dieser Aufzeichnung." msgstr "Dieses Team ist bereits Teil dieser Aufzeichnung."
#: core/models.py:725 #: core/models.py:750
msgid "Either user or team must be set, not both." msgid "Either user or team must be set, not both."
msgstr "Entweder Benutzer oder Team muss festgelegt werden, nicht beides." msgstr "Entweder Benutzer oder Team muss festgelegt werden, nicht beides."
#: core/models.py:742 #: core/models.py:767
msgid "Create rooms" msgid "Create rooms"
msgstr "Räume erstellen" msgstr "Räume erstellen"
#: core/models.py:743 #: core/models.py:768
msgid "List rooms" msgid "List rooms"
msgstr "Räume auflisten" msgstr "Räume auflisten"
#: core/models.py:744 #: core/models.py:769
msgid "Retrieve room details" msgid "Retrieve room details"
msgstr "Raumdetails abrufen" msgstr "Raumdetails abrufen"
#: core/models.py:745 #: core/models.py:770
msgid "Update rooms" msgid "Update rooms"
msgstr "Räume aktualisieren" msgstr "Räume aktualisieren"
#: core/models.py:746 #: core/models.py:771
msgid "Delete rooms" msgid "Delete rooms"
msgstr "Räume löschen" msgstr "Räume löschen"
#: core/models.py:759 #: core/models.py:784
msgid "Application name" msgid "Application name"
msgstr "Anwendungsname" msgstr "Anwendungsname"
#: core/models.py:760 #: core/models.py:785
msgid "Descriptive name for this application." msgid "Descriptive name for this application."
msgstr "Beschreibender Name für diese Anwendung." msgstr "Beschreibender Name für diese Anwendung."
#: core/models.py:770 #: core/models.py:795
msgid "Hashed on Save. Copy it now if this is a new secret." msgid "Hashed on Save. Copy it now if this is a new secret."
msgstr "" msgstr ""
"Beim Speichern gehasht. Jetzt kopieren, wenn dies ein neues Geheimnis ist." "Beim Speichern gehasht. Jetzt kopieren, wenn dies ein neues Geheimnis ist."
#: core/models.py:781 #: core/models.py:806
msgid "Application" msgid "Application"
msgstr "Anwendung" msgstr "Anwendung"
#: core/models.py:782 #: core/models.py:807
msgid "Applications" msgid "Applications"
msgstr "Anwendungen" msgstr "Anwendungen"
#: core/models.py:805 #: core/models.py:830
msgid "Enter a valid domain" msgid "Enter a valid domain"
msgstr "Geben Sie eine gültige Domain ein" msgstr "Geben Sie eine gültige Domain ein"
#: core/models.py:808 #: core/models.py:833
msgid "Domain" msgid "Domain"
msgstr "Domain" msgstr "Domain"
#: core/models.py:809 #: core/models.py:834
msgid "Email domain this application can act on behalf of." msgid "Email domain this application can act on behalf of."
msgstr "E-Mail-Domain, im Namen der diese Anwendung handeln kann." msgstr "E-Mail-Domain, im Namen der diese Anwendung handeln kann."
#: core/models.py:821 #: core/models.py:846
msgid "Application domain" msgid "Application domain"
msgstr "Anwendungsdomain" msgstr "Anwendungsdomain"
#: core/models.py:822 #: core/models.py:847
msgid "Application domains" msgid "Application domains"
msgstr "Anwendungsdomains" msgstr "Anwendungsdomains"
#: core/models.py:840 #: core/models.py:865
msgid "Pending" msgid "Pending"
msgstr "Ausstehend" msgstr "Ausstehend"
#: core/models.py:848 #: core/models.py:866
msgid "Analyzing"
msgstr ""
#: core/models.py:873
msgid "Ready" msgid "Ready"
msgstr "Bereit" msgstr "Bereit"
#: core/models.py:854 #: core/models.py:879
msgid "Background image" msgid "Background image"
msgstr "Hintergrundbild" msgstr "Hintergrundbild"
#: core/models.py:866 #: core/models.py:891
msgid "title" msgid "title"
msgstr "Titel" msgstr "Titel"
#: core/models.py:890 #: core/models.py:915
msgid "Malware detection info when the analysis status is unsafe." msgid "Malware detection info when the analysis status is unsafe."
msgstr "" msgstr ""
"Informationen zur Malware-Erkennung, wenn der Analyse-Status unsicher ist." "Informationen zur Malware-Erkennung, wenn der Analyse-Status unsicher ist."
#: core/models.py:895 #: core/models.py:920
msgid "File" msgid "File"
msgstr "Datei" msgstr "Datei"
#: core/models.py:896 #: core/models.py:921
msgid "Files" msgid "Files"
msgstr "Dateien" msgstr "Dateien"
#: core/models.py:1000 #: core/models.py:1041
msgid "This file is already hard deleted." msgid "This file is already hard deleted."
msgstr "Diese Datei wurde bereits endgültig gelöscht." msgstr "Diese Datei wurde bereits endgültig gelöscht."
#: core/models.py:1010 #: core/models.py:1051
#, fuzzy #, 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."
msgid "To hard delete a file, it must first be soft deleted." msgid "To hard delete a file, it must first be soft deleted."
@@ -476,10 +518,20 @@ msgstr ""
"Um eine Datei endgültig zu löschen, muss sie zuvor weich gelöscht worden " "Um eine Datei endgültig zu löschen, muss sie zuvor weich gelöscht worden "
"sein." "sein."
#: core/recording/event/notification.py:116 #: core/recording/event/notification.py:123
msgid "Your recording is ready" msgid "Your recording is ready"
msgstr "Ihre Aufzeichnung ist bereit" msgstr "Ihre Aufzeichnung ist bereit"
#: core/recording/event/notification.py:194
msgid "Transcription"
msgstr "Transkription"
#: core/recording/event/notification.py:204
#, python-brace-format
msgid "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}"
msgstr ""
"Besprechung \"{room}\" am {room_recording_date} um {room_recording_time}"
#: core/services/invitation.py:44 #: core/services/invitation.py:44
#, python-brace-format #, python-brace-format
msgid "Video call in progress: {sender.email} is waiting for you to connect" msgid "Video call in progress: {sender.email} is waiting for you to connect"
@@ -575,7 +627,7 @@ msgid "To keep this recording permanently:"
msgstr "So speichern Sie diese Aufzeichnung dauerhaft:" msgstr "So speichern Sie diese Aufzeichnung dauerhaft:"
#: core/templates/mail/html/screen_recording.html:208 #: core/templates/mail/html/screen_recording.html:208
#: core/templates/mail/text/screen_recording.txt:13 #, python-format
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below " msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
msgstr "Klicken Sie auf den Link „<a href=\"%(link)s\">Öffnen</a>\" unten " msgstr "Klicken Sie auf den Link „<a href=\"%(link)s\">Öffnen</a>\" unten "
@@ -604,18 +656,24 @@ msgstr ""
" Wenn Sie Fragen haben oder Unterstützung benötigen, wenden Sie sich bitte " " Wenn Sie Fragen haben oder Unterstützung benötigen, wenden Sie sich bitte "
"an unser Support-Team unter %(support_email)s. " "an unser Support-Team unter %(support_email)s. "
#: meet/settings.py:223 #: core/templates/mail/text/screen_recording.txt:13
#, fuzzy, python-format
#| msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
msgid "Click the \"Open [%(link)s]\" link below "
msgstr "Klicken Sie auf den Link „<a href=\"%(link)s\">Öffnen</a>\" unten "
#: meet/settings.py:228
msgid "English" msgid "English"
msgstr "Englisch" msgstr "Englisch"
#: meet/settings.py:224 #: meet/settings.py:229
msgid "French" msgid "French"
msgstr "Französisch" msgstr "Französisch"
#: meet/settings.py:225 #: meet/settings.py:230
msgid "Dutch" msgid "Dutch"
msgstr "Niederländisch" msgstr "Niederländisch"
#: meet/settings.py:226 #: meet/settings.py:231
msgid "German" msgid "German"
msgstr "Deutsch" msgstr "Deutsch"
+158 -101
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-04-21 14:09+0000\n" "POT-Creation-Date: 2026-07-02 10:47+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -17,70 +17,92 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n" "Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
#: core/admin.py:29 #: core/admin.py:67
msgid "Personal info" msgid "Personal info"
msgstr "Personal info" msgstr "Personal info"
#: core/admin.py:42 #: core/admin.py:80
msgid "Permissions" msgid "Permissions"
msgstr "Permissions" msgstr "Permissions"
#: core/admin.py:54 #: core/admin.py:92
msgid "Important dates" msgid "Important dates"
msgstr "Important dates" msgstr "Important dates"
#: core/admin.py:132 core/admin.py:275 #: core/admin.py:206
msgid "Content"
msgstr ""
#: core/admin.py:217
#, fuzzy
#| msgid "Delete rooms"
msgid "Deletion"
msgstr "Delete rooms"
#: core/admin.py:226
msgid "Derived info"
msgstr ""
#: core/admin.py:237
msgid "Timestamps"
msgstr ""
#: core/admin.py:240
msgid "File preview"
msgstr ""
#: core/admin.py:300 core/admin.py:443
msgid "No owner" msgid "No owner"
msgstr "No owner" msgstr "No owner"
#: core/admin.py:135 core/admin.py:278 #: core/admin.py:303 core/admin.py:446
msgid "Multiple owners" msgid "Multiple owners"
msgstr "Multiple owners" msgstr "Multiple owners"
#: core/admin.py:148 #: core/admin.py:316
msgid "Resend notification to external service" msgid "Resend notification to external service"
msgstr "Resend notification to external service" msgstr "Resend notification to external service"
#: core/admin.py:171 #: core/admin.py:339
#, python-format #, python-format
msgid "Failed to notify for recording %(id)s" msgid "Failed to notify for recording %(id)s"
msgstr "Failed to notify for recording %(id)s" msgstr "Failed to notify for recording %(id)s"
#: core/admin.py:179 #: core/admin.py:347
#, python-format #, python-format
msgid "Failed to notify for recording %(id)s: %(error)s" msgid "Failed to notify for recording %(id)s: %(error)s"
msgstr "Failed to notify for recording %(id)s: %(error)s" msgstr "Failed to notify for recording %(id)s: %(error)s"
#: core/admin.py:187 #: core/admin.py:355
#, python-format #, python-format
msgid "Successfully sent notifications for %(count)s recording(s)." msgid "Successfully sent notifications for %(count)s recording(s)."
msgstr "Successfully sent notifications for %(count)s recording(s)." msgstr "Successfully sent notifications for %(count)s recording(s)."
#: core/admin.py:195 #: core/admin.py:363
#, python-format #, python-format
msgid "Skipped %(count)s expired recording(s)." msgid "Skipped %(count)s expired recording(s)."
msgstr "Skipped %(count)s expired recording(s)." msgstr "Skipped %(count)s expired recording(s)."
#: core/admin.py:200 #: core/admin.py:368
msgid "Mark selected recordings as 'Failed to Stop'" msgid "Mark selected recordings as 'Failed to Stop'"
msgstr "Mark selected recordings as 'Failed to Stop'" msgstr "Mark selected recordings as 'Failed to Stop'"
#: core/admin.py:218 #: core/admin.py:386
#, python-format #, python-format
msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'." msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'."
msgstr "%(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 #: core/admin.py:394
#, fuzzy, python-format #, fuzzy, python-format
#| msgid "Skipped %(count)s expired recording(s)." #| msgid "Skipped %(count)s expired recording(s)."
msgid "Skipped %(count)s recording(s) with an ineligible status." msgid "Skipped %(count)s recording(s) with an ineligible status."
msgstr "Skipped %(count)s expired recording(s)." msgstr "Skipped %(count)s expired recording(s)."
#: core/admin.py:342 #: core/admin.py:510
msgid "No scopes" msgid "No scopes"
msgstr "No scopes" msgstr "No scopes"
#: core/admin.py:344 #: core/admin.py:512
msgid "Scopes" msgid "Scopes"
msgstr "Scopes" msgstr "Scopes"
@@ -88,15 +110,15 @@ msgstr "Scopes"
msgid "Creator is me" msgid "Creator is me"
msgstr "Creator is me" msgstr "Creator is me"
#: core/api/serializers.py:88 #: core/api/serializers.py:89
msgid "You must be administrator or owner of a room to add accesses to it." 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." msgstr "You must be administrator or owner of a room to add accesses to it."
#: core/api/serializers.py:509 #: core/api/serializers.py:534
msgid "This file extension is not allowed." msgid "This file extension is not allowed."
msgstr "This file extension is not allowed." msgstr "This file extension is not allowed."
#: core/api/viewsets.py:1090 #: core/api/viewsets.py:1222
msgid "You have reached the maximum number of files for this type." msgid "You have reached the maximum number of files for this type."
msgstr "You have reached the maximum number of files for this type." msgstr "You have reached the maximum number of files for this type."
@@ -144,51 +166,59 @@ msgstr "Failed to Stop"
msgid "Notification succeeded" msgid "Notification succeeded"
msgstr "Notification succeeded" msgstr "Notification succeeded"
#: core/models.py:89 #: core/models.py:65
msgid "External process successful"
msgstr "External process successful"
#: core/models.py:67
msgid "External process failed"
msgstr "External process failed"
#: core/models.py:96
msgid "SCREEN_RECORDING" msgid "SCREEN_RECORDING"
msgstr "SCREEN_RECORDING" msgstr "SCREEN_RECORDING"
#: core/models.py:90 #: core/models.py:97
msgid "TRANSCRIPT" msgid "TRANSCRIPT"
msgstr "TRANSCRIPT" msgstr "TRANSCRIPT"
#: core/models.py:96 #: core/models.py:103
msgid "Public Access" msgid "Public Access"
msgstr "Public Access" msgstr "Public Access"
#: core/models.py:97 #: core/models.py:104
msgid "Trusted Access" msgid "Trusted Access"
msgstr "Trusted Access" msgstr "Trusted Access"
#: core/models.py:98 #: core/models.py:105
msgid "Restricted Access" msgid "Restricted Access"
msgstr "Restricted Access" msgstr "Restricted Access"
#: core/models.py:110 #: core/models.py:117
msgid "id" msgid "id"
msgstr "id" msgstr "id"
#: core/models.py:111 #: core/models.py:118
msgid "primary key for the record as UUID" msgid "primary key for the record as UUID"
msgstr "primary key for the record as UUID" msgstr "primary key for the record as UUID"
#: core/models.py:117 #: core/models.py:124
msgid "created on" msgid "created on"
msgstr "created on" msgstr "created on"
#: core/models.py:118 #: core/models.py:125
msgid "date and time at which a record was created" msgid "date and time at which a record was created"
msgstr "date and time at which a record was created" msgstr "date and time at which a record was created"
#: core/models.py:123 #: core/models.py:130
msgid "updated on" msgid "updated on"
msgstr "updated on" msgstr "updated on"
#: core/models.py:124 #: core/models.py:131
msgid "date and time at which a record was last updated" msgid "date and time at which a record was last updated"
msgstr "date and time at which a record was last updated" msgstr "date and time at which a record was last updated"
#: core/models.py:144 #: core/models.py:151
msgid "" msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/" "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters." "_ characters."
@@ -196,11 +226,11 @@ msgstr ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/" "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters." "_ characters."
#: core/models.py:150 #: core/models.py:157
msgid "sub" msgid "sub"
msgstr "sub" msgstr "sub"
#: core/models.py:152 #: core/models.py:159
msgid "" msgid ""
"Optional for pending users; required upon account activation. 255 characters " "Optional for pending users; required upon account activation. 255 characters "
"or fewer. Letters, numbers, and @/./+/-/_ characters only." "or fewer. Letters, numbers, and @/./+/-/_ characters only."
@@ -208,55 +238,55 @@ msgstr ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ " "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only." "characters only."
#: core/models.py:161 #: core/models.py:168
msgid "identity email address" msgid "identity email address"
msgstr "identity email address" msgstr "identity email address"
#: core/models.py:166 #: core/models.py:173
msgid "admin email address" msgid "admin email address"
msgstr "admin email address" msgstr "admin email address"
#: core/models.py:168 #: core/models.py:175
msgid "full name" msgid "full name"
msgstr "full name" msgstr "full name"
#: core/models.py:170 #: core/models.py:177
msgid "short name" msgid "short name"
msgstr "short name" msgstr "short name"
#: core/models.py:176 #: core/models.py:183
msgid "language" msgid "language"
msgstr "language" msgstr "language"
#: core/models.py:177 #: core/models.py:184
msgid "The language in which the user wants to see the interface." msgid "The language in which the user wants to see the interface."
msgstr "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:183 #: core/models.py:190
msgid "The timezone in which the user wants to see times." msgid "The timezone in which the user wants to see times."
msgstr "The timezone in which the user wants to see times." msgstr "The timezone in which the user wants to see times."
#: core/models.py:186 #: core/models.py:193
msgid "device" msgid "device"
msgstr "device" msgstr "device"
#: core/models.py:188 #: core/models.py:195
msgid "Whether the user is a device or a real user." msgid "Whether the user is a device or a real user."
msgstr "Whether the user is a device or a real user." msgstr "Whether the user is a device or a real user."
#: core/models.py:191 #: core/models.py:198
msgid "staff status" msgid "staff status"
msgstr "staff status" msgstr "staff status"
#: core/models.py:193 #: core/models.py:200
msgid "Whether the user can log into this admin site." msgid "Whether the user can log into this admin site."
msgstr "Whether the user can log into this admin site." msgstr "Whether the user can log into this admin site."
#: core/models.py:196 #: core/models.py:203
msgid "active" msgid "active"
msgstr "active" msgstr "active"
#: core/models.py:199 #: core/models.py:206
msgid "" msgid ""
"Whether this user should be treated as active. Unselect this instead of " "Whether this user should be treated as active. Unselect this instead of "
"deleting accounts." "deleting accounts."
@@ -264,63 +294,63 @@ msgstr ""
"Whether this user should be treated as active. Unselect this instead of " "Whether this user should be treated as active. Unselect this instead of "
"deleting accounts." "deleting accounts."
#: core/models.py:212 #: core/models.py:219
msgid "user" msgid "user"
msgstr "user" msgstr "user"
#: core/models.py:213 #: core/models.py:220
msgid "users" msgid "users"
msgstr "users" msgstr "users"
#: core/models.py:272 #: core/models.py:286
msgid "Resource" msgid "Resource"
msgstr "Resource" msgstr "Resource"
#: core/models.py:273 #: core/models.py:287
msgid "Resources" msgid "Resources"
msgstr "Resources" msgstr "Resources"
#: core/models.py:331 #: core/models.py:345
msgid "Resource access" msgid "Resource access"
msgstr "Resource access" msgstr "Resource access"
#: core/models.py:332 #: core/models.py:346
msgid "Resource accesses" msgid "Resource accesses"
msgstr "Resource accesses" msgstr "Resource accesses"
#: core/models.py:338 #: core/models.py:352
msgid "Resource access with this User and Resource already exists." msgid "Resource access with this User and Resource already exists."
msgstr "Resource access with this User and Resource already exists." msgstr "Resource access with this User and Resource already exists."
#: core/models.py:394 #: core/models.py:409
msgid "Visio room configuration" msgid "Visio room configuration"
msgstr "Visio room configuration" msgstr "Visio room configuration"
#: core/models.py:395 #: core/models.py:410
msgid "Values for Visio parameters to configure the room." msgid "Values for Visio parameters to configure the room."
msgstr "Values for Visio parameters to configure the room." msgstr "Values for Visio parameters to configure the room."
#: core/models.py:402 #: core/models.py:417
msgid "Room PIN code" msgid "Room PIN code"
msgstr "Room PIN code" msgstr "Room PIN code"
#: core/models.py:403 #: core/models.py:418
msgid "Unique n-digit code that identifies this room in telephony mode." msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr "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:409 core/models.py:563 #: core/models.py:424 core/models.py:578
msgid "Room" msgid "Room"
msgstr "Room" msgstr "Room"
#: core/models.py:410 #: core/models.py:425
msgid "Rooms" msgid "Rooms"
msgstr "Rooms" msgstr "Rooms"
#: core/models.py:574 #: core/models.py:589
msgid "Worker ID" msgid "Worker ID"
msgstr "Worker ID" msgstr "Worker ID"
#: core/models.py:576 #: core/models.py:591
msgid "" msgid ""
"Enter an identifier for the worker recording.This ID is retained even when " "Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking." "the worker stops, allowing for easy tracking."
@@ -328,154 +358,175 @@ msgstr ""
"Enter an identifier for the worker recording.This ID is retained even when " "Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking." "the worker stops, allowing for easy tracking."
#: core/models.py:584 #: core/models.py:599
msgid "Recording mode" msgid "Recording mode"
msgstr "Recording mode" msgstr "Recording mode"
#: core/models.py:585 #: core/models.py:600
msgid "Defines the mode of recording being called." msgid "Defines the mode of recording being called."
msgstr "Defines the mode of recording being called." msgstr "Defines the mode of recording being called."
#: core/models.py:590 core/models.py:591 #: core/models.py:605 core/models.py:606
msgid "Recording options" msgid "Recording options"
msgstr "Recording options" msgstr "Recording options"
#: core/models.py:597 #: core/models.py:613
msgid "External Process ID"
msgstr "External Process ID"
#: core/models.py:614
msgid "ID of the external process associated with the recording."
msgstr "ID of the external process associated with the recording."
#: core/models.py:620
msgid "Recording" msgid "Recording"
msgstr "Recording" msgstr "Recording"
#: core/models.py:598 #: core/models.py:621
msgid "Recordings" msgid "Recordings"
msgstr "Recordings" msgstr "Recordings"
#: core/models.py:706 #: core/models.py:731
msgid "Recording/user relation" msgid "Recording/user relation"
msgstr "Recording/user relation" msgstr "Recording/user relation"
#: core/models.py:707 #: core/models.py:732
msgid "Recording/user relations" msgid "Recording/user relations"
msgstr "Recording/user relations" msgstr "Recording/user relations"
#: core/models.py:713 #: core/models.py:738
msgid "This user is already in this recording." msgid "This user is already in this recording."
msgstr "This user is already in this recording." msgstr "This user is already in this recording."
#: core/models.py:719 #: core/models.py:744
msgid "This team is already in this recording." msgid "This team is already in this recording."
msgstr "This team is already in this recording." msgstr "This team is already in this recording."
#: core/models.py:725 #: core/models.py:750
msgid "Either user or team must be set, not both." msgid "Either user or team must be set, not both."
msgstr "Either user or team must be set, not both." msgstr "Either user or team must be set, not both."
#: core/models.py:742 #: core/models.py:767
#, fuzzy #, fuzzy
#| msgid "created on" #| msgid "created on"
msgid "Create rooms" msgid "Create rooms"
msgstr "Create rooms" msgstr "Create rooms"
#: core/models.py:743 #: core/models.py:768
msgid "List rooms" msgid "List rooms"
msgstr "List rooms" msgstr "List rooms"
#: core/models.py:744 #: core/models.py:769
msgid "Retrieve room details" msgid "Retrieve room details"
msgstr "Retrieve room details" msgstr "Retrieve room details"
#: core/models.py:745 #: core/models.py:770
#, fuzzy #, fuzzy
#| msgid "updated on" #| msgid "updated on"
msgid "Update rooms" msgid "Update rooms"
msgstr "Update rooms" msgstr "Update rooms"
#: core/models.py:746 #: core/models.py:771
msgid "Delete rooms" msgid "Delete rooms"
msgstr "Delete rooms" msgstr "Delete rooms"
#: core/models.py:759 #: core/models.py:784
msgid "Application name" msgid "Application name"
msgstr "Application name" msgstr "Application name"
#: core/models.py:760 #: core/models.py:785
msgid "Descriptive name for this application." msgid "Descriptive name for this application."
msgstr "Descriptive name for this application." msgstr "Descriptive name for this application."
#: core/models.py:770 #: core/models.py:795
msgid "Hashed on Save. Copy it now if this is a new secret." 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." msgstr "Hashed on Save. Copy it now if this is a new secret."
#: core/models.py:781 #: core/models.py:806
msgid "Application" msgid "Application"
msgstr "Application" msgstr "Application"
#: core/models.py:782 #: core/models.py:807
msgid "Applications" msgid "Applications"
msgstr "Applications" msgstr "Applications"
#: core/models.py:805 #: core/models.py:830
msgid "Enter a valid domain" msgid "Enter a valid domain"
msgstr "Enter a valid domain" msgstr "Enter a valid domain"
#: core/models.py:808 #: core/models.py:833
msgid "Domain" msgid "Domain"
msgstr "Domain" msgstr "Domain"
#: core/models.py:809 #: core/models.py:834
msgid "Email domain this application can act on behalf of." msgid "Email domain this application can act on behalf of."
msgstr "Email domain this application can act on behalf of." msgstr "Email domain this application can act on behalf of."
#: core/models.py:821 #: core/models.py:846
msgid "Application domain" msgid "Application domain"
msgstr "Application domain" msgstr "Application domain"
#: core/models.py:822 #: core/models.py:847
msgid "Application domains" msgid "Application domains"
msgstr "Application domains" msgstr "Application domains"
#: core/models.py:840 #: core/models.py:865
#, fuzzy #, fuzzy
#| msgid "Recording" #| msgid "Recording"
msgid "Pending" msgid "Pending"
msgstr "Pending" msgstr "Pending"
#: core/models.py:848 #: core/models.py:866
msgid "Analyzing"
msgstr ""
#: core/models.py:873
msgid "Ready" msgid "Ready"
msgstr "Ready" msgstr "Ready"
#: core/models.py:854 #: core/models.py:879
msgid "Background image" msgid "Background image"
msgstr "Background image" msgstr "Background image"
#: core/models.py:866 #: core/models.py:891
msgid "title" msgid "title"
msgstr "title" msgstr "title"
#: core/models.py:890 #: core/models.py:915
msgid "Malware detection info when the analysis status is unsafe." msgid "Malware detection info when the analysis status is unsafe."
msgstr "Malware detection info when the analysis status is unsafe." msgstr "Malware detection info when the analysis status is unsafe."
#: core/models.py:895 #: core/models.py:920
msgid "File" msgid "File"
msgstr "File" msgstr "File"
#: core/models.py:896 #: core/models.py:921
msgid "Files" msgid "Files"
msgstr "Files" msgstr "Files"
#: core/models.py:1000 #: core/models.py:1041
#, fuzzy #, fuzzy
#| msgid "This user is already in this recording." #| msgid "This user is already in this recording."
msgid "This file is already hard deleted." msgid "This file is already hard deleted."
msgstr "This file is already hard deleted." msgstr "This file is already hard deleted."
#: core/models.py:1010 #: core/models.py:1051
msgid "To hard delete a file, it must first be soft deleted." msgid "To hard delete a file, it must first be soft deleted."
msgstr "To hard delete a file, it must first be soft deleted." msgstr "To hard delete a file, it must first be soft deleted."
#: core/recording/event/notification.py:116 #: core/recording/event/notification.py:123
msgid "Your recording is ready" msgid "Your recording is ready"
msgstr "Your recording is ready" msgstr "Your recording is ready"
#: core/recording/event/notification.py:194
msgid "Transcription"
msgstr "Transcription"
#: core/recording/event/notification.py:204
#, python-brace-format
msgid "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}"
msgstr "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}"
#: core/services/invitation.py:44 #: core/services/invitation.py:44
#, python-brace-format #, python-brace-format
msgid "Video call in progress: {sender.email} is waiting for you to connect" msgid "Video call in progress: {sender.email} is waiting for you to connect"
@@ -571,7 +622,7 @@ msgid "To keep this recording permanently:"
msgstr "To keep this recording permanently:" msgstr "To keep this recording permanently:"
#: core/templates/mail/html/screen_recording.html:208 #: core/templates/mail/html/screen_recording.html:208
#: core/templates/mail/text/screen_recording.txt:13 #, python-format
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below " msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
msgstr "Click the \"<a href=\"%(link)s\">Open</a>\" link below " msgstr "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
@@ -600,18 +651,24 @@ msgstr ""
" If you have any questions or need assistance, please contact our support " " If you have any questions or need assistance, please contact our support "
"team at %(support_email)s. " "team at %(support_email)s. "
#: meet/settings.py:223 #: core/templates/mail/text/screen_recording.txt:13
#, fuzzy, python-format
#| msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
msgid "Click the \"Open [%(link)s]\" link below "
msgstr "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
#: meet/settings.py:228
msgid "English" msgid "English"
msgstr "English" msgstr "English"
#: meet/settings.py:224 #: meet/settings.py:229
msgid "French" msgid "French"
msgstr "French" msgstr "French"
#: meet/settings.py:225 #: meet/settings.py:230
msgid "Dutch" msgid "Dutch"
msgstr "Dutch" msgstr "Dutch"
#: meet/settings.py:226 #: meet/settings.py:231
msgid "German" msgid "German"
msgstr "German" msgstr "German"
+158 -101
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-04-21 14:09+0000\n" "POT-Creation-Date: 2026-07-02 10:47+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: antoine.lebaud@mail.numerique.gouv.fr\n" "Last-Translator: antoine.lebaud@mail.numerique.gouv.fr\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -17,71 +17,93 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n" "Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
#: core/admin.py:29 #: core/admin.py:67
msgid "Personal info" msgid "Personal info"
msgstr "Informations personnelles" msgstr "Informations personnelles"
#: core/admin.py:42 #: core/admin.py:80
msgid "Permissions" msgid "Permissions"
msgstr "Permissions" msgstr "Permissions"
#: core/admin.py:54 #: core/admin.py:92
msgid "Important dates" msgid "Important dates"
msgstr "Dates importantes" msgstr "Dates importantes"
#: core/admin.py:132 core/admin.py:275 #: core/admin.py:206
msgid "Content"
msgstr ""
#: core/admin.py:217
#, fuzzy
#| msgid "Delete rooms"
msgid "Deletion"
msgstr "Supprimer les salles"
#: core/admin.py:226
msgid "Derived info"
msgstr ""
#: core/admin.py:237
msgid "Timestamps"
msgstr ""
#: core/admin.py:240
msgid "File preview"
msgstr ""
#: core/admin.py:300 core/admin.py:443
msgid "No owner" msgid "No owner"
msgstr "Pas de propriétaire" msgstr "Pas de propriétaire"
#: core/admin.py:135 core/admin.py:278 #: core/admin.py:303 core/admin.py:446
msgid "Multiple owners" msgid "Multiple owners"
msgstr "Plusieurs propriétaires" msgstr "Plusieurs propriétaires"
#: core/admin.py:148 #: core/admin.py:316
msgid "Resend notification to external service" msgid "Resend notification to external service"
msgstr "Renvoyer la notification au service externe" msgstr "Renvoyer la notification au service externe"
#: core/admin.py:171 #: core/admin.py:339
#, python-format #, python-format
msgid "Failed to notify for recording %(id)s" msgid "Failed to notify for recording %(id)s"
msgstr "Échec de la notification pour lenregistrement %(id)s" msgstr "Échec de la notification pour lenregistrement %(id)s"
#: core/admin.py:179 #: core/admin.py:347
#, python-format #, python-format
msgid "Failed to notify for recording %(id)s: %(error)s" msgid "Failed to notify for recording %(id)s: %(error)s"
msgstr "Échec de la notification pour lenregistrement %(id)s : %(error)s" msgstr "Échec de la notification pour lenregistrement %(id)s : %(error)s"
#: core/admin.py:187 #: core/admin.py:355
#, python-format #, python-format
msgid "Successfully sent notifications for %(count)s recording(s)." msgid "Successfully sent notifications for %(count)s recording(s)."
msgstr "Notifications envoyées avec succès pour %(count)s enregistrement(s)." msgstr "Notifications envoyées avec succès pour %(count)s enregistrement(s)."
#: core/admin.py:195 #: core/admin.py:363
#, python-format #, python-format
msgid "Skipped %(count)s expired recording(s)." msgid "Skipped %(count)s expired recording(s)."
msgstr "%(count)s enregistrement(s) expiré(s) ignoré(s)." msgstr "%(count)s enregistrement(s) expiré(s) ignoré(s)."
#: core/admin.py:200 #: core/admin.py:368
msgid "Mark selected recordings as 'Failed to Stop'" msgid "Mark selected recordings as 'Failed to Stop'"
msgstr "Marquer les enregistrements sélectionnés comme « Échec darrêt »" msgstr "Marquer les enregistrements sélectionnés comme « Échec darrêt »"
#: core/admin.py:218 #: core/admin.py:386
#, python-format #, python-format
msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'." msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'."
msgstr "" msgstr ""
"%(count)s enregistrement(s) marqué(s) avec succès comme « Échec darrêt »." "%(count)s enregistrement(s) marqué(s) avec succès comme « Échec darrêt »."
#: core/admin.py:226 #: core/admin.py:394
#, fuzzy, python-format #, fuzzy, python-format
#| msgid "Skipped %(count)s expired recording(s)." #| msgid "Skipped %(count)s expired recording(s)."
msgid "Skipped %(count)s recording(s) with an ineligible status." msgid "Skipped %(count)s recording(s) with an ineligible status."
msgstr "%(count)s enregistrement(s) avec un statut inéligible ignoré(s)." msgstr "%(count)s enregistrement(s) avec un statut inéligible ignoré(s)."
#: core/admin.py:342 #: core/admin.py:510
msgid "No scopes" msgid "No scopes"
msgstr "Aucun scopes" msgstr "Aucun scopes"
#: core/admin.py:344 #: core/admin.py:512
msgid "Scopes" msgid "Scopes"
msgstr "Scopes" msgstr "Scopes"
@@ -89,17 +111,17 @@ msgstr "Scopes"
msgid "Creator is me" msgid "Creator is me"
msgstr "Je suis le créateur" msgstr "Je suis le créateur"
#: core/api/serializers.py:88 #: core/api/serializers.py:89
msgid "You must be administrator or owner of a room to add accesses to it." msgid "You must be administrator or owner of a room to add accesses to it."
msgstr "" msgstr ""
"Vous devez être administrateur ou propriétaire d'une salle pour y ajouter " "Vous devez être administrateur ou propriétaire d'une salle pour y ajouter "
"des accès." "des accès."
#: core/api/serializers.py:509 #: core/api/serializers.py:534
msgid "This file extension is not allowed." msgid "This file extension is not allowed."
msgstr "Cette extension n'est pas autorisée" msgstr "Cette extension n'est pas autorisée"
#: core/api/viewsets.py:1090 #: core/api/viewsets.py:1222
msgid "You have reached the maximum number of files for this type." msgid "You have reached the maximum number of files for this type."
msgstr "Vous avez atteint le nombre maximum de fichiers de ce type" msgstr "Vous avez atteint le nombre maximum de fichiers de ce type"
@@ -147,53 +169,61 @@ msgstr "Échec à l'arrêt"
msgid "Notification succeeded" msgid "Notification succeeded"
msgstr "Notification réussie" msgstr "Notification réussie"
#: core/models.py:89 #: core/models.py:65
msgid "External process successful"
msgstr "Traitement externe : succès"
#: core/models.py:67
msgid "External process failed"
msgstr "Traitement externe : erreur"
#: core/models.py:96
msgid "SCREEN_RECORDING" msgid "SCREEN_RECORDING"
msgstr "ENREGISTREMENT_ÉCRAN" msgstr "ENREGISTREMENT_ÉCRAN"
#: core/models.py:90 #: core/models.py:97
msgid "TRANSCRIPT" msgid "TRANSCRIPT"
msgstr "TRANSCRIPTION" msgstr "TRANSCRIPTION"
#: core/models.py:96 #: core/models.py:103
msgid "Public Access" msgid "Public Access"
msgstr "Accès public" msgstr "Accès public"
#: core/models.py:97 #: core/models.py:104
msgid "Trusted Access" msgid "Trusted Access"
msgstr "Accès de confiance" msgstr "Accès de confiance"
#: core/models.py:98 #: core/models.py:105
msgid "Restricted Access" msgid "Restricted Access"
msgstr "Accès restreint" msgstr "Accès restreint"
#: core/models.py:110 #: core/models.py:117
msgid "id" msgid "id"
msgstr "id" msgstr "id"
#: core/models.py:111 #: core/models.py:118
msgid "primary key for the record as UUID" msgid "primary key for the record as UUID"
msgstr "clé primaire pour l'enregistrement sous forme d'UUID" msgstr "clé primaire pour l'enregistrement sous forme d'UUID"
#: core/models.py:117 #: core/models.py:124
msgid "created on" msgid "created on"
msgstr "créé le" msgstr "créé le"
#: core/models.py:118 #: core/models.py:125
msgid "date and time at which a record was created" msgid "date and time at which a record was created"
msgstr "date et heure auxquelles un enregistrement a été créé" msgstr "date et heure auxquelles un enregistrement a été créé"
#: core/models.py:123 #: core/models.py:130
msgid "updated on" msgid "updated on"
msgstr "mis à jour le" msgstr "mis à jour le"
#: core/models.py:124 #: core/models.py:131
msgid "date and time at which a record was last updated" msgid "date and time at which a record was last updated"
msgstr "" msgstr ""
"date et heure auxquelles un enregistrement a été mis à jour pour la dernière " "date et heure auxquelles un enregistrement a été mis à jour pour la dernière "
"fois" "fois"
#: core/models.py:144 #: core/models.py:151
msgid "" msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/" "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters." "_ characters."
@@ -201,11 +231,11 @@ msgstr ""
"Entrez un sub valide. Cette valeur ne peut contenir que des lettres, des " "Entrez un sub valide. Cette valeur ne peut contenir que des lettres, des "
"chiffres et les caractères @/./+/-/_." "chiffres et les caractères @/./+/-/_."
#: core/models.py:150 #: core/models.py:157
msgid "sub" msgid "sub"
msgstr "sub" msgstr "sub"
#: core/models.py:152 #: core/models.py:159
msgid "" msgid ""
"Optional for pending users; required upon account activation. 255 characters " "Optional for pending users; required upon account activation. 255 characters "
"or fewer. Letters, numbers, and @/./+/-/_ characters only." "or fewer. Letters, numbers, and @/./+/-/_ characters only."
@@ -213,55 +243,55 @@ msgstr ""
"Optionnel pour les utilisateurs en attente ; requis lors de l'activation du " "Optionnel pour les utilisateurs en attente ; requis lors de l'activation du "
"compte. 255 caractères maximum. Lettres, chiffres et @/./+/-/_ uniquement." "compte. 255 caractères maximum. Lettres, chiffres et @/./+/-/_ uniquement."
#: core/models.py:161 #: core/models.py:168
msgid "identity email address" msgid "identity email address"
msgstr "adresse e-mail d'identité" msgstr "adresse e-mail d'identité"
#: core/models.py:166 #: core/models.py:173
msgid "admin email address" msgid "admin email address"
msgstr "adresse e-mail d'administrateur" msgstr "adresse e-mail d'administrateur"
#: core/models.py:168 #: core/models.py:175
msgid "full name" msgid "full name"
msgstr "nom complet" msgstr "nom complet"
#: core/models.py:170 #: core/models.py:177
msgid "short name" msgid "short name"
msgstr "nom court" msgstr "nom court"
#: core/models.py:176 #: core/models.py:183
msgid "language" msgid "language"
msgstr "langue" msgstr "langue"
#: core/models.py:177 #: core/models.py:184
msgid "The language in which the user wants to see the interface." msgid "The language in which the user wants to see the interface."
msgstr "La langue dans laquelle l'utilisateur souhaite voir l'interface." msgstr "La langue dans laquelle l'utilisateur souhaite voir l'interface."
#: core/models.py:183 #: core/models.py:190
msgid "The timezone in which the user wants to see times." msgid "The timezone in which the user wants to see times."
msgstr "Le fuseau horaire dans lequel l'utilisateur souhaite voir les heures." msgstr "Le fuseau horaire dans lequel l'utilisateur souhaite voir les heures."
#: core/models.py:186 #: core/models.py:193
msgid "device" msgid "device"
msgstr "appareil" msgstr "appareil"
#: core/models.py:188 #: core/models.py:195
msgid "Whether the user is a device or a real user." msgid "Whether the user is a device or a real user."
msgstr "Si l'utilisateur est un appareil ou un utilisateur réel." msgstr "Si l'utilisateur est un appareil ou un utilisateur réel."
#: core/models.py:191 #: core/models.py:198
msgid "staff status" msgid "staff status"
msgstr "statut du personnel" msgstr "statut du personnel"
#: core/models.py:193 #: core/models.py:200
msgid "Whether the user can log into this admin site." msgid "Whether the user can log into this admin site."
msgstr "Si l'utilisateur peut se connecter à ce site d'administration." msgstr "Si l'utilisateur peut se connecter à ce site d'administration."
#: core/models.py:196 #: core/models.py:203
msgid "active" msgid "active"
msgstr "actif" msgstr "actif"
#: core/models.py:199 #: core/models.py:206
msgid "" msgid ""
"Whether this user should be treated as active. Unselect this instead of " "Whether this user should be treated as active. Unselect this instead of "
"deleting accounts." "deleting accounts."
@@ -269,65 +299,65 @@ msgstr ""
"Si cet utilisateur doit être traité comme actif. Désélectionnez cette option " "Si cet utilisateur doit être traité comme actif. Désélectionnez cette option "
"au lieu de supprimer des comptes." "au lieu de supprimer des comptes."
#: core/models.py:212 #: core/models.py:219
msgid "user" msgid "user"
msgstr "utilisateur" msgstr "utilisateur"
#: core/models.py:213 #: core/models.py:220
msgid "users" msgid "users"
msgstr "utilisateurs" msgstr "utilisateurs"
#: core/models.py:272 #: core/models.py:286
msgid "Resource" msgid "Resource"
msgstr "Ressource" msgstr "Ressource"
#: core/models.py:273 #: core/models.py:287
msgid "Resources" msgid "Resources"
msgstr "Ressources" msgstr "Ressources"
#: core/models.py:331 #: core/models.py:345
msgid "Resource access" msgid "Resource access"
msgstr "Accès aux ressources" msgstr "Accès aux ressources"
#: core/models.py:332 #: core/models.py:346
msgid "Resource accesses" msgid "Resource accesses"
msgstr "Accès aux ressources" msgstr "Accès aux ressources"
#: core/models.py:338 #: core/models.py:352
msgid "Resource access with this User and Resource already exists." msgid "Resource access with this User and Resource already exists."
msgstr "" msgstr ""
"L'accès à la ressource avec cet utilisateur et cette ressource existe déjà." "L'accès à la ressource avec cet utilisateur et cette ressource existe déjà."
#: core/models.py:394 #: core/models.py:409
msgid "Visio room configuration" msgid "Visio room configuration"
msgstr "Configuration de la salle de visioconférence" msgstr "Configuration de la salle de visioconférence"
#: core/models.py:395 #: core/models.py:410
msgid "Values for Visio parameters to configure the room." msgid "Values for Visio parameters to configure the room."
msgstr "Valeurs des paramètres de visioconférence pour configurer la salle." msgstr "Valeurs des paramètres de visioconférence pour configurer la salle."
#: core/models.py:402 #: core/models.py:417
msgid "Room PIN code" msgid "Room PIN code"
msgstr "Code PIN de la salle" msgstr "Code PIN de la salle"
#: core/models.py:403 #: core/models.py:418
msgid "Unique n-digit code that identifies this room in telephony mode." msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr "" msgstr ""
"Code unique à n chiffres qui identifie cette salle en mode téléphonique." "Code unique à n chiffres qui identifie cette salle en mode téléphonique."
#: core/models.py:409 core/models.py:563 #: core/models.py:424 core/models.py:578
msgid "Room" msgid "Room"
msgstr "Salle" msgstr "Salle"
#: core/models.py:410 #: core/models.py:425
msgid "Rooms" msgid "Rooms"
msgstr "Salles" msgstr "Salles"
#: core/models.py:574 #: core/models.py:589
msgid "Worker ID" msgid "Worker ID"
msgstr "ID du Worker" msgstr "ID du Worker"
#: core/models.py:576 #: core/models.py:591
msgid "" msgid ""
"Enter an identifier for the worker recording.This ID is retained even when " "Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking." "the worker stops, allowing for easy tracking."
@@ -335,153 +365,174 @@ msgstr ""
"Entrez un identifiant pour l'enregistrement du Worker. Cet identifiant est " "Entrez un identifiant pour l'enregistrement du Worker. Cet identifiant est "
"conservé même lorsque le Worker s'arrête, permettant un suivi facile." "conservé même lorsque le Worker s'arrête, permettant un suivi facile."
#: core/models.py:584 #: core/models.py:599
msgid "Recording mode" msgid "Recording mode"
msgstr "Mode d'enregistrement" msgstr "Mode d'enregistrement"
#: core/models.py:585 #: core/models.py:600
msgid "Defines the mode of recording being called." msgid "Defines the mode of recording being called."
msgstr "Définit le mode d'enregistrement appelé." msgstr "Définit le mode d'enregistrement appelé."
#: core/models.py:590 core/models.py:591 #: core/models.py:605 core/models.py:606
msgid "Recording options" msgid "Recording options"
msgstr "Options d'enregistrement" msgstr "Options d'enregistrement"
#: core/models.py:597 #: core/models.py:613
msgid "External Process ID"
msgstr "ID Traitement externe"
#: core/models.py:614
msgid "ID of the external process associated with the recording."
msgstr "ID du traitement externe associé avec l'enregistrement."
#: core/models.py:620
msgid "Recording" msgid "Recording"
msgstr "Enregistrement" msgstr "Enregistrement"
#: core/models.py:598 #: core/models.py:621
msgid "Recordings" msgid "Recordings"
msgstr "Enregistrements" msgstr "Enregistrements"
#: core/models.py:706 #: core/models.py:731
msgid "Recording/user relation" msgid "Recording/user relation"
msgstr "Relation enregistrement/utilisateur" msgstr "Relation enregistrement/utilisateur"
#: core/models.py:707 #: core/models.py:732
msgid "Recording/user relations" msgid "Recording/user relations"
msgstr "Relations enregistrement/utilisateur" msgstr "Relations enregistrement/utilisateur"
#: core/models.py:713 #: core/models.py:738
msgid "This user is already in this recording." msgid "This user is already in this recording."
msgstr "Cet utilisateur est déjà dans cet enregistrement." msgstr "Cet utilisateur est déjà dans cet enregistrement."
#: core/models.py:719 #: core/models.py:744
msgid "This team is already in this recording." msgid "This team is already in this recording."
msgstr "Cette équipe est déjà dans cet enregistrement." msgstr "Cette équipe est déjà dans cet enregistrement."
#: core/models.py:725 #: core/models.py:750
msgid "Either user or team must be set, not both." msgid "Either user or team must be set, not both."
msgstr "Soit l'utilisateur, soit l'équipe doit être défini, pas les deux." msgstr "Soit l'utilisateur, soit l'équipe doit être défini, pas les deux."
#: core/models.py:742 #: core/models.py:767
msgid "Create rooms" msgid "Create rooms"
msgstr "Créer des salles" msgstr "Créer des salles"
#: core/models.py:743 #: core/models.py:768
msgid "List rooms" msgid "List rooms"
msgstr "Lister les salles" msgstr "Lister les salles"
#: core/models.py:744 #: core/models.py:769
msgid "Retrieve room details" msgid "Retrieve room details"
msgstr "Afficher les détails dune salle" msgstr "Afficher les détails dune salle"
#: core/models.py:745 #: core/models.py:770
msgid "Update rooms" msgid "Update rooms"
msgstr "Mettre à jour les salles" msgstr "Mettre à jour les salles"
#: core/models.py:746 #: core/models.py:771
msgid "Delete rooms" msgid "Delete rooms"
msgstr "Supprimer les salles" msgstr "Supprimer les salles"
#: core/models.py:759 #: core/models.py:784
msgid "Application name" msgid "Application name"
msgstr "Nom de lapplication" msgstr "Nom de lapplication"
#: core/models.py:760 #: core/models.py:785
msgid "Descriptive name for this application." msgid "Descriptive name for this application."
msgstr "Nom descriptif de cette application." msgstr "Nom descriptif de cette application."
#: core/models.py:770 #: core/models.py:795
msgid "Hashed on Save. Copy it now if this is a new secret." msgid "Hashed on Save. Copy it now if this is a new secret."
msgstr "" msgstr ""
"Haché lors de lenregistrement. Copiez-le maintenant sil sagit dun " "Haché lors de lenregistrement. Copiez-le maintenant sil sagit dun "
"nouveau secret." "nouveau secret."
#: core/models.py:781 #: core/models.py:806
msgid "Application" msgid "Application"
msgstr "Application" msgstr "Application"
#: core/models.py:782 #: core/models.py:807
msgid "Applications" msgid "Applications"
msgstr "Applications" msgstr "Applications"
#: core/models.py:805 #: core/models.py:830
msgid "Enter a valid domain" msgid "Enter a valid domain"
msgstr "Saisissez un domaine valide" msgstr "Saisissez un domaine valide"
#: core/models.py:808 #: core/models.py:833
msgid "Domain" msgid "Domain"
msgstr "Domaine" msgstr "Domaine"
#: core/models.py:809 #: core/models.py:834
msgid "Email domain this application can act on behalf of." msgid "Email domain this application can act on behalf of."
msgstr "Domaine de messagerie au nom duquel cette application peut agir." msgstr "Domaine de messagerie au nom duquel cette application peut agir."
#: core/models.py:821 #: core/models.py:846
msgid "Application domain" msgid "Application domain"
msgstr "Domaine dapplication" msgstr "Domaine dapplication"
#: core/models.py:822 #: core/models.py:847
msgid "Application domains" msgid "Application domains"
msgstr "Domaines dapplication" msgstr "Domaines dapplication"
#: core/models.py:840 #: core/models.py:865
msgid "Pending" msgid "Pending"
msgstr "En attente" msgstr "En attente"
#: core/models.py:848 #: core/models.py:866
msgid "Analyzing"
msgstr ""
#: core/models.py:873
msgid "Ready" msgid "Ready"
msgstr "Prêt" msgstr "Prêt"
#: core/models.py:854 #: core/models.py:879
msgid "Background image" msgid "Background image"
msgstr "Image de fond" msgstr "Image de fond"
#: core/models.py:866 #: core/models.py:891
msgid "title" msgid "title"
msgstr "Titre" msgstr "Titre"
#: core/models.py:890 #: core/models.py:915
msgid "Malware detection info when the analysis status is unsafe." msgid "Malware detection info when the analysis status is unsafe."
msgstr "" msgstr ""
"Information concernant la détection de Malware cand le statut n'est pas sain" "Information concernant la détection de Malware cand le statut n'est pas sain"
#: core/models.py:895 #: core/models.py:920
msgid "File" msgid "File"
msgstr "Fichier" msgstr "Fichier"
#: core/models.py:896 #: core/models.py:921
msgid "Files" msgid "Files"
msgstr "Fichiers" msgstr "Fichiers"
#: core/models.py:1000 #: core/models.py:1041
#, fuzzy #, fuzzy
#| msgid "This user is already in this recording." #| msgid "This user is already in this recording."
msgid "This file is already hard deleted." msgid "This file is already hard deleted."
msgstr "Ce fichier a été supprimé." msgstr "Ce fichier a été supprimé."
#: core/models.py:1010 #: core/models.py:1051
msgid "To hard delete a file, it must first be soft deleted." msgid "To hard delete a file, it must first be soft deleted."
msgstr "" msgstr ""
"Pour supprimer définitivement un fichier il doit d'abord avoir été marqué " "Pour supprimer définitivement un fichier il doit d'abord avoir été marqué "
"comme supprimé (soft delete)" "comme supprimé (soft delete)"
#: core/recording/event/notification.py:116 #: core/recording/event/notification.py:123
msgid "Your recording is ready" msgid "Your recording is ready"
msgstr "Votre enregistrement est prêt" msgstr "Votre enregistrement est prêt"
#: core/recording/event/notification.py:194
msgid "Transcription"
msgstr "Transcription"
#: core/recording/event/notification.py:204
#, python-brace-format
msgid "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}"
msgstr "Réunion \"{room}\" du {room_recording_date} à {room_recording_time}"
#: core/services/invitation.py:44 #: core/services/invitation.py:44
#, python-brace-format #, python-brace-format
msgid "Video call in progress: {sender.email} is waiting for you to connect" msgid "Video call in progress: {sender.email} is waiting for you to connect"
@@ -577,7 +628,7 @@ msgid "To keep this recording permanently:"
msgstr "Pour conserver cet enregistrement de façon permanente :" msgstr "Pour conserver cet enregistrement de façon permanente :"
#: core/templates/mail/html/screen_recording.html:208 #: core/templates/mail/html/screen_recording.html:208
#: core/templates/mail/text/screen_recording.txt:13 #, python-format
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below " msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
msgstr "Cliquez sur le lien \"<a href=\"%(link)s\">Ouvrir</a>\" ci-dessous " msgstr "Cliquez sur le lien \"<a href=\"%(link)s\">Ouvrir</a>\" ci-dessous "
@@ -606,18 +657,24 @@ msgstr ""
" Si vous avez des questions ou besoin d'assistance, veuillez contacter notre " " Si vous avez des questions ou besoin d'assistance, veuillez contacter notre "
"équipe d'assistance à %(support_email)s. " "équipe d'assistance à %(support_email)s. "
#: meet/settings.py:223 #: core/templates/mail/text/screen_recording.txt:13
#, fuzzy, python-format
#| msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
msgid "Click the \"Open [%(link)s]\" link below "
msgstr "Cliquez sur le lien \"<a href=\"%(link)s\">Ouvrir</a>\" ci-dessous "
#: meet/settings.py:228
msgid "English" msgid "English"
msgstr "Anglais" msgstr "Anglais"
#: meet/settings.py:224 #: meet/settings.py:229
msgid "French" msgid "French"
msgstr "Français" msgstr "Français"
#: meet/settings.py:225 #: meet/settings.py:230
msgid "Dutch" msgid "Dutch"
msgstr "Néerlandais" msgstr "Néerlandais"
#: meet/settings.py:226 #: meet/settings.py:231
msgid "German" msgid "German"
msgstr "Allemand" msgstr "Allemand"
+159 -101
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-04-21 14:09+0000\n" "POT-Creation-Date: 2026-07-02 10:47+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -17,70 +17,92 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n" "Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
#: core/admin.py:29 #: core/admin.py:67
msgid "Personal info" msgid "Personal info"
msgstr "Persoonlijke informatie" msgstr "Persoonlijke informatie"
#: core/admin.py:42 #: core/admin.py:80
msgid "Permissions" msgid "Permissions"
msgstr "Rechten" msgstr "Rechten"
#: core/admin.py:54 #: core/admin.py:92
msgid "Important dates" msgid "Important dates"
msgstr "Belangrijke datums" msgstr "Belangrijke datums"
#: core/admin.py:132 core/admin.py:275 #: core/admin.py:206
msgid "Content"
msgstr ""
#: core/admin.py:217
#, fuzzy
#| msgid "Delete rooms"
msgid "Deletion"
msgstr "Ruimtes verwijderen"
#: core/admin.py:226
msgid "Derived info"
msgstr ""
#: core/admin.py:237
msgid "Timestamps"
msgstr ""
#: core/admin.py:240
msgid "File preview"
msgstr ""
#: core/admin.py:300 core/admin.py:443
msgid "No owner" msgid "No owner"
msgstr "Geen eigenaar" msgstr "Geen eigenaar"
#: core/admin.py:135 core/admin.py:278 #: core/admin.py:303 core/admin.py:446
msgid "Multiple owners" msgid "Multiple owners"
msgstr "Meerdere eigenaren" msgstr "Meerdere eigenaren"
#: core/admin.py:148 #: core/admin.py:316
msgid "Resend notification to external service" msgid "Resend notification to external service"
msgstr "Melding opnieuw verzenden naar externe dienst" msgstr "Melding opnieuw verzenden naar externe dienst"
#: core/admin.py:171 #: core/admin.py:339
#, python-format #, python-format
msgid "Failed to notify for recording %(id)s" msgid "Failed to notify for recording %(id)s"
msgstr "Melding voor opname %(id)s mislukt" msgstr "Melding voor opname %(id)s mislukt"
#: core/admin.py:179 #: core/admin.py:347
#, python-format #, python-format
msgid "Failed to notify for recording %(id)s: %(error)s" msgid "Failed to notify for recording %(id)s: %(error)s"
msgstr "Melding voor opname %(id)s mislukt: %(error)s" msgstr "Melding voor opname %(id)s mislukt: %(error)s"
#: core/admin.py:187 #: core/admin.py:355
#, python-format #, python-format
msgid "Successfully sent notifications for %(count)s recording(s)." msgid "Successfully sent notifications for %(count)s recording(s)."
msgstr "Meldingen succesvol verzonden voor %(count)s opname(n)." msgstr "Meldingen succesvol verzonden voor %(count)s opname(n)."
#: core/admin.py:195 #: core/admin.py:363
#, python-format #, python-format
msgid "Skipped %(count)s expired recording(s)." msgid "Skipped %(count)s expired recording(s)."
msgstr "%(count)s verlopen opname(n) overgeslagen." msgstr "%(count)s verlopen opname(n) overgeslagen."
#: core/admin.py:200 #: core/admin.py:368
msgid "Mark selected recordings as 'Failed to Stop'" msgid "Mark selected recordings as 'Failed to Stop'"
msgstr "Geselecteerde opnames markeren als 'Mislukt bij stoppen'" msgstr "Geselecteerde opnames markeren als 'Mislukt bij stoppen'"
#: core/admin.py:218 #: core/admin.py:386
#, python-format #, python-format
msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'." msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'."
msgstr "%(count)s opname(s) succesvol gemarkeerd als 'Mislukt bij stoppen'." msgstr "%(count)s opname(s) succesvol gemarkeerd als 'Mislukt bij stoppen'."
#: core/admin.py:226 #: core/admin.py:394
#, fuzzy, python-format #, fuzzy, python-format
#| msgid "Skipped %(count)s expired recording(s)." #| msgid "Skipped %(count)s expired recording(s)."
msgid "Skipped %(count)s recording(s) with an ineligible status." msgid "Skipped %(count)s recording(s) with an ineligible status."
msgstr "%(count)s opname(s) met een niet-toegestane status overgeslagen." msgstr "%(count)s opname(s) met een niet-toegestane status overgeslagen."
#: core/admin.py:342 #: core/admin.py:510
msgid "No scopes" msgid "No scopes"
msgstr "Geen scopes" msgstr "Geen scopes"
#: core/admin.py:344 #: core/admin.py:512
msgid "Scopes" msgid "Scopes"
msgstr "Scopes" msgstr "Scopes"
@@ -88,16 +110,16 @@ msgstr "Scopes"
msgid "Creator is me" msgid "Creator is me"
msgstr "Maker ben ik" msgstr "Maker ben ik"
#: core/api/serializers.py:88 #: core/api/serializers.py:89
msgid "You must be administrator or owner of a room to add accesses to it." msgid "You must be administrator or owner of a room to add accesses to it."
msgstr "" msgstr ""
"Je moet beheerder of eigenaar van een ruimte zijn om toegang toe te voegen." "Je moet beheerder of eigenaar van een ruimte zijn om toegang toe te voegen."
#: core/api/serializers.py:509 #: core/api/serializers.py:534
msgid "This file extension is not allowed." msgid "This file extension is not allowed."
msgstr "Deze bestandsextensie is niet toegestaan." msgstr "Deze bestandsextensie is niet toegestaan."
#: core/api/viewsets.py:1090 #: core/api/viewsets.py:1222
msgid "You have reached the maximum number of files for this type." msgid "You have reached the maximum number of files for this type."
msgstr "Het maximale aantal bestanden voor dit type is bereikt." msgstr "Het maximale aantal bestanden voor dit type is bereikt."
@@ -145,51 +167,59 @@ msgstr "Stoppen mislukt"
msgid "Notification succeeded" msgid "Notification succeeded"
msgstr "Notificatie geslaagd" msgstr "Notificatie geslaagd"
#: core/models.py:89 #: core/models.py:65
msgid "External process successful"
msgstr "Externe procedure succesvol"
#: core/models.py:67
msgid "External process failed"
msgstr "Het externe proces is mislukt."
#: core/models.py:96
msgid "SCREEN_RECORDING" msgid "SCREEN_RECORDING"
msgstr "SCHERM_OPNAME" msgstr "SCHERM_OPNAME"
#: core/models.py:90 #: core/models.py:97
msgid "TRANSCRIPT" msgid "TRANSCRIPT"
msgstr "TRANSCRIPT" msgstr "TRANSCRIPT"
#: core/models.py:96 #: core/models.py:103
msgid "Public Access" msgid "Public Access"
msgstr "Openbare toegang" msgstr "Openbare toegang"
#: core/models.py:97 #: core/models.py:104
msgid "Trusted Access" msgid "Trusted Access"
msgstr "Vertrouwde toegang" msgstr "Vertrouwde toegang"
#: core/models.py:98 #: core/models.py:105
msgid "Restricted Access" msgid "Restricted Access"
msgstr "Beperkte toegang" msgstr "Beperkte toegang"
#: core/models.py:110 #: core/models.py:117
msgid "id" msgid "id"
msgstr "id" msgstr "id"
#: core/models.py:111 #: core/models.py:118
msgid "primary key for the record as UUID" msgid "primary key for the record as UUID"
msgstr "primaire sleutel voor het record als UUID" msgstr "primaire sleutel voor het record als UUID"
#: core/models.py:117 #: core/models.py:124
msgid "created on" msgid "created on"
msgstr "aangemaakt op" msgstr "aangemaakt op"
#: core/models.py:118 #: core/models.py:125
msgid "date and time at which a record was created" msgid "date and time at which a record was created"
msgstr "datum en tijd waarop een record werd aangemaakt" msgstr "datum en tijd waarop een record werd aangemaakt"
#: core/models.py:123 #: core/models.py:130
msgid "updated on" msgid "updated on"
msgstr "bijgewerkt op" msgstr "bijgewerkt op"
#: core/models.py:124 #: core/models.py:131
msgid "date and time at which a record was last updated" msgid "date and time at which a record was last updated"
msgstr "datum en tijd waarop een record voor het laatst werd bijgewerkt" msgstr "datum en tijd waarop een record voor het laatst werd bijgewerkt"
#: core/models.py:144 #: core/models.py:151
msgid "" msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/" "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters." "_ characters."
@@ -197,11 +227,11 @@ msgstr ""
"Voer een geldige sub in. Deze waarde mag alleen letters, cijfers en @/./+/-/" "Voer een geldige sub in. Deze waarde mag alleen letters, cijfers en @/./+/-/"
"_ tekens bevatten." "_ tekens bevatten."
#: core/models.py:150 #: core/models.py:157
msgid "sub" msgid "sub"
msgstr "sub" msgstr "sub"
#: core/models.py:152 #: core/models.py:159
msgid "" msgid ""
"Optional for pending users; required upon account activation. 255 characters " "Optional for pending users; required upon account activation. 255 characters "
"or fewer. Letters, numbers, and @/./+/-/_ characters only." "or fewer. Letters, numbers, and @/./+/-/_ characters only."
@@ -209,55 +239,55 @@ msgstr ""
"Optioneel voor gebruikers in afwachting; vereist bij accountactivering. " "Optioneel voor gebruikers in afwachting; vereist bij accountactivering. "
"Maximum 255 tekens. Alleen letters, cijfers en @/./+/-/_ toegestaan." "Maximum 255 tekens. Alleen letters, cijfers en @/./+/-/_ toegestaan."
#: core/models.py:161 #: core/models.py:168
msgid "identity email address" msgid "identity email address"
msgstr "identiteit e-mailadres" msgstr "identiteit e-mailadres"
#: core/models.py:166 #: core/models.py:173
msgid "admin email address" msgid "admin email address"
msgstr "beheerder e-mailadres" msgstr "beheerder e-mailadres"
#: core/models.py:168 #: core/models.py:175
msgid "full name" msgid "full name"
msgstr "volledige naam" msgstr "volledige naam"
#: core/models.py:170 #: core/models.py:177
msgid "short name" msgid "short name"
msgstr "korte naam" msgstr "korte naam"
#: core/models.py:176 #: core/models.py:183
msgid "language" msgid "language"
msgstr "taal" msgstr "taal"
#: core/models.py:177 #: core/models.py:184
msgid "The language in which the user wants to see the interface." msgid "The language in which the user wants to see the interface."
msgstr "De taal waarin de gebruiker de interface wil zien." msgstr "De taal waarin de gebruiker de interface wil zien."
#: core/models.py:183 #: core/models.py:190
msgid "The timezone in which the user wants to see times." msgid "The timezone in which the user wants to see times."
msgstr "De tijdzone waarin de gebruiker tijden wil zien." msgstr "De tijdzone waarin de gebruiker tijden wil zien."
#: core/models.py:186 #: core/models.py:193
msgid "device" msgid "device"
msgstr "apparaat" msgstr "apparaat"
#: core/models.py:188 #: core/models.py:195
msgid "Whether the user is a device or a real user." msgid "Whether the user is a device or a real user."
msgstr "Of de gebruiker een apparaat is of een echte gebruiker." msgstr "Of de gebruiker een apparaat is of een echte gebruiker."
#: core/models.py:191 #: core/models.py:198
msgid "staff status" msgid "staff status"
msgstr "personeelsstatus" msgstr "personeelsstatus"
#: core/models.py:193 #: core/models.py:200
msgid "Whether the user can log into this admin site." msgid "Whether the user can log into this admin site."
msgstr "Of de gebruiker kan inloggen op deze beheersite." msgstr "Of de gebruiker kan inloggen op deze beheersite."
#: core/models.py:196 #: core/models.py:203
msgid "active" msgid "active"
msgstr "actief" msgstr "actief"
#: core/models.py:199 #: core/models.py:206
msgid "" msgid ""
"Whether this user should be treated as active. Unselect this instead of " "Whether this user should be treated as active. Unselect this instead of "
"deleting accounts." "deleting accounts."
@@ -265,64 +295,64 @@ msgstr ""
"Of deze gebruiker als actief moet worden behandeld. Deselecteer dit in " "Of deze gebruiker als actief moet worden behandeld. Deselecteer dit in "
"plaats van accounts te verwijderen." "plaats van accounts te verwijderen."
#: core/models.py:212 #: core/models.py:219
msgid "user" msgid "user"
msgstr "gebruiker" msgstr "gebruiker"
#: core/models.py:213 #: core/models.py:220
msgid "users" msgid "users"
msgstr "gebruikers" msgstr "gebruikers"
#: core/models.py:272 #: core/models.py:286
msgid "Resource" msgid "Resource"
msgstr "Bron" msgstr "Bron"
#: core/models.py:273 #: core/models.py:287
msgid "Resources" msgid "Resources"
msgstr "Bronnen" msgstr "Bronnen"
#: core/models.py:331 #: core/models.py:345
msgid "Resource access" msgid "Resource access"
msgstr "Brontoegang" msgstr "Brontoegang"
#: core/models.py:332 #: core/models.py:346
msgid "Resource accesses" msgid "Resource accesses"
msgstr "Brontoegangsrechten" msgstr "Brontoegangsrechten"
#: core/models.py:338 #: core/models.py:352
msgid "Resource access with this User and Resource already exists." msgid "Resource access with this User and Resource already exists."
msgstr "Brontoegang met deze gebruiker en bron bestaat al." msgstr "Brontoegang met deze gebruiker en bron bestaat al."
#: core/models.py:394 #: core/models.py:409
msgid "Visio room configuration" msgid "Visio room configuration"
msgstr "Visio-ruimteconfiguratie" msgstr "Visio-ruimteconfiguratie"
#: core/models.py:395 #: core/models.py:410
msgid "Values for Visio parameters to configure the room." msgid "Values for Visio parameters to configure the room."
msgstr "Waarden voor Visio-parameters om de ruimte te configureren." msgstr "Waarden voor Visio-parameters om de ruimte te configureren."
#: core/models.py:402 #: core/models.py:417
msgid "Room PIN code" msgid "Room PIN code"
msgstr "Pincode van de kamer" msgstr "Pincode van de kamer"
#: core/models.py:403 #: core/models.py:418
msgid "Unique n-digit code that identifies this room in telephony mode." msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr "" msgstr ""
"Unieke n-cijferige code die deze kamer identificeert in telefonie-modus." "Unieke n-cijferige code die deze kamer identificeert in telefonie-modus."
#: core/models.py:409 core/models.py:563 #: core/models.py:424 core/models.py:578
msgid "Room" msgid "Room"
msgstr "Ruimte" msgstr "Ruimte"
#: core/models.py:410 #: core/models.py:425
msgid "Rooms" msgid "Rooms"
msgstr "Ruimtes" msgstr "Ruimtes"
#: core/models.py:574 #: core/models.py:589
msgid "Worker ID" msgid "Worker ID"
msgstr "Worker ID" msgstr "Worker ID"
#: core/models.py:576 #: core/models.py:591
msgid "" msgid ""
"Enter an identifier for the worker recording.This ID is retained even when " "Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking." "the worker stops, allowing for easy tracking."
@@ -330,140 +360,152 @@ msgstr ""
"Voer een identificatie in voor de worker-opname. Deze ID blijft behouden, " "Voer een identificatie in voor de worker-opname. Deze ID blijft behouden, "
"zelfs wanneer de worker stopt, waardoor eenvoudige tracking mogelijk is." "zelfs wanneer de worker stopt, waardoor eenvoudige tracking mogelijk is."
#: core/models.py:584 #: core/models.py:599
msgid "Recording mode" msgid "Recording mode"
msgstr "Opnamemodus" msgstr "Opnamemodus"
#: core/models.py:585 #: core/models.py:600
msgid "Defines the mode of recording being called." msgid "Defines the mode of recording being called."
msgstr "Definieert de modus van opname die wordt aangeroepen." msgstr "Definieert de modus van opname die wordt aangeroepen."
#: core/models.py:590 core/models.py:591 #: core/models.py:605 core/models.py:606
msgid "Recording options" msgid "Recording options"
msgstr "Opnameopties" msgstr "Opnameopties"
#: core/models.py:597 #: core/models.py:613
msgid "External Process ID"
msgstr "Externe proces-ID"
#: core/models.py:614
msgid "ID of the external process associated with the recording."
msgstr "ID van het externe proces dat aan de opname is gekoppeld."
#: core/models.py:620
msgid "Recording" msgid "Recording"
msgstr "Opname" msgstr "Opname"
#: core/models.py:598 #: core/models.py:621
msgid "Recordings" msgid "Recordings"
msgstr "Opnames" msgstr "Opnames"
#: core/models.py:706 #: core/models.py:731
msgid "Recording/user relation" msgid "Recording/user relation"
msgstr "Opname/gebruiker-relatie" msgstr "Opname/gebruiker-relatie"
#: core/models.py:707 #: core/models.py:732
msgid "Recording/user relations" msgid "Recording/user relations"
msgstr "Opname/gebruiker-relaties" msgstr "Opname/gebruiker-relaties"
#: core/models.py:713 #: core/models.py:738
msgid "This user is already in this recording." msgid "This user is already in this recording."
msgstr "Deze gebruiker is al in deze opname." msgstr "Deze gebruiker is al in deze opname."
#: core/models.py:719 #: core/models.py:744
msgid "This team is already in this recording." msgid "This team is already in this recording."
msgstr "Dit team is al in deze opname." msgstr "Dit team is al in deze opname."
#: core/models.py:725 #: core/models.py:750
msgid "Either user or team must be set, not both." msgid "Either user or team must be set, not both."
msgstr "Ofwel gebruiker of team moet worden ingesteld, niet beide." msgstr "Ofwel gebruiker of team moet worden ingesteld, niet beide."
#: core/models.py:742 #: core/models.py:767
msgid "Create rooms" msgid "Create rooms"
msgstr "Ruimtes aanmaken" msgstr "Ruimtes aanmaken"
#: core/models.py:743 #: core/models.py:768
msgid "List rooms" msgid "List rooms"
msgstr "Ruimtes weergeven" msgstr "Ruimtes weergeven"
#: core/models.py:744 #: core/models.py:769
msgid "Retrieve room details" msgid "Retrieve room details"
msgstr "Details van een ruimte ophalen" msgstr "Details van een ruimte ophalen"
#: core/models.py:745 #: core/models.py:770
msgid "Update rooms" msgid "Update rooms"
msgstr "Ruimtes bijwerken" msgstr "Ruimtes bijwerken"
#: core/models.py:746 #: core/models.py:771
msgid "Delete rooms" msgid "Delete rooms"
msgstr "Ruimtes verwijderen" msgstr "Ruimtes verwijderen"
#: core/models.py:759 #: core/models.py:784
msgid "Application name" msgid "Application name"
msgstr "Naam van de applicatie" msgstr "Naam van de applicatie"
#: core/models.py:760 #: core/models.py:785
msgid "Descriptive name for this application." msgid "Descriptive name for this application."
msgstr "Beschrijvende naam voor deze applicatie." msgstr "Beschrijvende naam voor deze applicatie."
#: core/models.py:770 #: core/models.py:795
msgid "Hashed on Save. Copy it now if this is a new secret." msgid "Hashed on Save. Copy it now if this is a new secret."
msgstr "" msgstr ""
"Wordt gehasht bij het opslaan. Kopieer het nu als dit een nieuw geheim is." "Wordt gehasht bij het opslaan. Kopieer het nu als dit een nieuw geheim is."
#: core/models.py:781 #: core/models.py:806
msgid "Application" msgid "Application"
msgstr "Applicatie" msgstr "Applicatie"
#: core/models.py:782 #: core/models.py:807
msgid "Applications" msgid "Applications"
msgstr "Applicaties" msgstr "Applicaties"
#: core/models.py:805 #: core/models.py:830
msgid "Enter a valid domain" msgid "Enter a valid domain"
msgstr "Voer een geldig domein in" msgstr "Voer een geldig domein in"
#: core/models.py:808 #: core/models.py:833
msgid "Domain" msgid "Domain"
msgstr "Domein" msgstr "Domein"
#: core/models.py:809 #: core/models.py:834
msgid "Email domain this application can act on behalf of." msgid "Email domain this application can act on behalf of."
msgstr "E-maildomein namens welke deze applicatie kan handelen." msgstr "E-maildomein namens welke deze applicatie kan handelen."
#: core/models.py:821 #: core/models.py:846
msgid "Application domain" msgid "Application domain"
msgstr "Applicatiedomein" msgstr "Applicatiedomein"
#: core/models.py:822 #: core/models.py:847
msgid "Application domains" msgid "Application domains"
msgstr "Applicatiedomeinen" msgstr "Applicatiedomeinen"
#: core/models.py:840 #: core/models.py:865
msgid "Pending" msgid "Pending"
msgstr "In afwachting" msgstr "In afwachting"
#: core/models.py:848 #: core/models.py:866
msgid "Analyzing"
msgstr ""
#: core/models.py:873
msgid "Ready" msgid "Ready"
msgstr "Klaar" msgstr "Klaar"
#: core/models.py:854 #: core/models.py:879
msgid "Background image" msgid "Background image"
msgstr "Achtergrondafbeelding" msgstr "Achtergrondafbeelding"
#: core/models.py:866 #: core/models.py:891
msgid "title" msgid "title"
msgstr "Titel" msgstr "Titel"
#: core/models.py:890 #: core/models.py:915
msgid "Malware detection info when the analysis status is unsafe." msgid "Malware detection info when the analysis status is unsafe."
msgstr "Informatie over malwaredetectie wanneer de analysestatus onveilig is." msgstr "Informatie over malwaredetectie wanneer de analysestatus onveilig is."
#: core/models.py:895 #: core/models.py:920
msgid "File" msgid "File"
msgstr "Bestand" msgstr "Bestand"
#: core/models.py:896 #: core/models.py:921
msgid "Files" msgid "Files"
msgstr "Bestanden" msgstr "Bestanden"
#: core/models.py:1000 #: core/models.py:1041
msgid "This file is already hard deleted." msgid "This file is already hard deleted."
msgstr "Dit bestand is al definitief verwijderd." msgstr "Dit bestand is al definitief verwijderd."
#: core/models.py:1010 #: core/models.py:1051
#, fuzzy #, 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."
msgid "To hard delete a file, it must first be soft deleted." msgid "To hard delete a file, it must first be soft deleted."
@@ -471,10 +513,20 @@ msgstr ""
"Om een bestand definitief te verwijderen, moet het eerst zacht verwijderd " "Om een bestand definitief te verwijderen, moet het eerst zacht verwijderd "
"zijn." "zijn."
#: core/recording/event/notification.py:116 #: core/recording/event/notification.py:123
msgid "Your recording is ready" msgid "Your recording is ready"
msgstr "Je opname is klaar" msgstr "Je opname is klaar"
#: core/recording/event/notification.py:194
msgid "Transcription"
msgstr "Transcriptie"
#: core/recording/event/notification.py:204
#, python-brace-format
msgid "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}"
msgstr ""
"Vergadering \"{room}\" op {room_recording_date} om {room_recording_time}"
#: core/services/invitation.py:44 #: core/services/invitation.py:44
#, python-brace-format #, python-brace-format
msgid "Video call in progress: {sender.email} is waiting for you to connect" msgid "Video call in progress: {sender.email} is waiting for you to connect"
@@ -570,7 +622,7 @@ msgid "To keep this recording permanently:"
msgstr "Om deze opname permanent te bewaren:" msgstr "Om deze opname permanent te bewaren:"
#: core/templates/mail/html/screen_recording.html:208 #: core/templates/mail/html/screen_recording.html:208
#: core/templates/mail/text/screen_recording.txt:13 #, python-format
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below " msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
msgstr "Klik op de \"<a href=\"%(link)s\">Openen</a>\"-link hieronder " msgstr "Klik op de \"<a href=\"%(link)s\">Openen</a>\"-link hieronder "
@@ -599,18 +651,24 @@ msgstr ""
" Als je vragen hebt of hulp nodig hebt, neem dan contact op met ons support " " Als je vragen hebt of hulp nodig hebt, neem dan contact op met ons support "
"team via %(support_email)s. " "team via %(support_email)s. "
#: meet/settings.py:223 #: core/templates/mail/text/screen_recording.txt:13
#, fuzzy, python-format
#| msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
msgid "Click the \"Open [%(link)s]\" link below "
msgstr "Klik op de \"<a href=\"%(link)s\">Openen</a>\"-link hieronder "
#: meet/settings.py:228
msgid "English" msgid "English"
msgstr "Engels" msgstr "Engels"
#: meet/settings.py:224 #: meet/settings.py:229
msgid "French" msgid "French"
msgstr "Frans" msgstr "Frans"
#: meet/settings.py:225 #: meet/settings.py:230
msgid "Dutch" msgid "Dutch"
msgstr "Nederlands" msgstr "Nederlands"
#: meet/settings.py:226 #: meet/settings.py:231
msgid "German" msgid "German"
msgstr "Duits" msgstr "Duits"
+39 -1
View File
@@ -13,6 +13,7 @@ https://docs.djangoproject.com/en/3.1/ref/settings/
# pylint: disable=too-many-lines # pylint: disable=too-many-lines
import json import json
import warnings
from os import path from os import path
from socket import gethostbyname, gethostname from socket import gethostbyname, gethostname
@@ -670,6 +671,11 @@ class Base(Configuration):
environ_name="PARTICIPANT_FORBIDDEN_PERMISSION_FIELDS", environ_name="PARTICIPANT_FORBIDDEN_PERMISSION_FIELDS",
environ_prefix=None, environ_prefix=None,
) )
AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = values.BooleanValue(
True,
environ_name="AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME",
environ_prefix=None,
)
# Recording settings # Recording settings
RECORDING_ENABLE = values.BooleanValue( RECORDING_ENABLE = values.BooleanValue(
@@ -748,12 +754,30 @@ class Base(Configuration):
environ_prefix=None, environ_prefix=None,
) )
SUMMARY_SERVICE_VERSION = values.PositiveIntegerValue(
1, environ_name="SUMMARY_SERVICE_VERSION", environ_prefix=None
)
SUMMARY_SERVICE_ENDPOINT = values.Value( SUMMARY_SERVICE_ENDPOINT = values.Value(
None, environ_name="SUMMARY_SERVICE_ENDPOINT", environ_prefix=None None, environ_name="SUMMARY_SERVICE_ENDPOINT", environ_prefix=None
) )
SUMMARY_SERVICE_API_TOKEN = SecretFileValue( SUMMARY_SERVICE_API_TOKEN = SecretFileValue(
None, environ_name="SUMMARY_SERVICE_API_TOKEN", environ_prefix=None None, environ_name="SUMMARY_SERVICE_API_TOKEN", environ_prefix=None
) )
SUMMARY_SERVICE_WEBHOOK_API_TOKEN = SecretFileValue(
None, environ_name="SUMMARY_SERVICE_WEBHOOK_API_TOKEN", environ_prefix=None
)
SUMMARY_SERVICE_CLOUD_STORAGE_SIGNED_URL_EXPIRY_SECONDS = (
values.PositiveIntegerValue(
60 * 60 * 24,
environ_name="SUMMARY_SERVICE_CLOUD_STORAGE_SIGNED_URL_EXPIRY_SECONDS",
environ_prefix=None,
)
)
TRANSCRIPTION_SATISFACTION_FORM_BASE_URL = values.Value(
None,
environ_name="TRANSCRIPTION_SATISFACTION_FORM_BASE_URL",
environ_prefix=None,
)
SCREEN_RECORDING_BASE_URL = values.Value( SCREEN_RECORDING_BASE_URL = values.Value(
None, environ_name="SCREEN_RECORDING_BASE_URL", environ_prefix=None None, environ_name="SCREEN_RECORDING_BASE_URL", environ_prefix=None
) )
@@ -1110,11 +1134,25 @@ class Base(Configuration):
"FILE_UPLOAD_TMP_PATH cannot be the same as FILE_UPLOAD_PATH" "FILE_UPLOAD_TMP_PATH cannot be the same as FILE_UPLOAD_PATH"
) )
if (
cls.SUMMARY_SERVICE_VERSION == 1
and cls.SUMMARY_SERVICE_ENDPOINT is not None
):
warnings.warn(
"SUMMARY_SERVICE_VERSION=1 is deprecated. "
"The legacy v1 API has been removed from the experimental "
"Summary service. Please update your Summary service deployment to "
"the v2 API and set SUMMARY_SERVICE_VERSION=2.",
# We use UserWarning to make sure it shows up in production deployment
UserWarning,
stacklevel=2,
)
# The SENTRY_DSN setting should be available to activate sentry for an environment # The SENTRY_DSN setting should be available to activate sentry for an environment
if cls.SENTRY_DSN is not None: if cls.SENTRY_DSN is not None:
sentry_sdk.init( sentry_sdk.init(
dsn=cls.SENTRY_DSN, dsn=cls.SENTRY_DSN,
environment=cls.__name__.lower(), environment=cls.__name__.lower(), # build, test, development, production
release=get_release(), release=get_release(),
integrations=[DjangoIntegration()], integrations=[DjangoIntegration()],
) )
+1 -1
View File
@@ -7,7 +7,7 @@ build-backend = "uv_build"
[project] [project]
name = "meet" name = "meet"
version = "1.22.0" version = "1.24.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }] authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [ classifiers = [
"Development Status :: 5 - Production/Stable", "Development Status :: 5 - Production/Stable",
+1 -1
View File
@@ -1187,7 +1187,7 @@ wheels = [
[[package]] [[package]]
name = "meet" name = "meet"
version = "1.22.0" version = "1.24.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "aiohttp" }, { name = "aiohttp" },
+2 -2
View File
@@ -1,4 +1,4 @@
FROM node:20-alpine AS frontend-deps FROM node:22-alpine AS frontend-deps
USER node USER node
@@ -39,7 +39,7 @@ ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
RUN npm run build RUN npm run build
# ---- Front-end image ---- # ---- Front-end image ----
FROM nginxinc/nginx-unprivileged:alpine3.23 AS frontend-production FROM nginxinc/nginx-unprivileged:1.30.3-alpine3.23 AS frontend-production
USER root USER root
+228 -49
View File
@@ -1,12 +1,12 @@
{ {
"name": "meet", "name": "meet",
"version": "1.22.0", "version": "1.24.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "meet", "name": "meet",
"version": "1.22.0", "version": "1.24.0",
"dependencies": { "dependencies": {
"@fontsource-variable/atkinson-hyperlegible-next": "5.2.6", "@fontsource-variable/atkinson-hyperlegible-next": "5.2.6",
"@fontsource-variable/lexend": "5.2.11", "@fontsource-variable/lexend": "5.2.11",
@@ -15,10 +15,11 @@
"@livekit/components-react": "2.9.21", "@livekit/components-react": "2.9.21",
"@livekit/components-styles": "1.2.0", "@livekit/components-styles": "1.2.0",
"@livekit/track-processors": "0.7.2", "@livekit/track-processors": "0.7.2",
"@mediapipe/tasks-vision": "0.10.14",
"@pandacss/preset-panda": "1.11.3", "@pandacss/preset-panda": "1.11.3",
"@react-types/overlays": "3.10.0", "@react-types/overlays": "3.10.0",
"@remixicon/react": "4.9.0", "@remixicon/react": "4.9.0",
"@tanstack/react-query": "5.100.14", "@tanstack/react-query": "5.101.0",
"@timephy/rnnoise-wasm": "1.0.0", "@timephy/rnnoise-wasm": "1.0.0",
"crisp-sdk-web": "1.1.2", "crisp-sdk-web": "1.1.2",
"derive-valtio": "0.2.0", "derive-valtio": "0.2.0",
@@ -28,14 +29,14 @@
"i18next-browser-languagedetector": "8.2.1", "i18next-browser-languagedetector": "8.2.1",
"i18next-parser": "9.4.0", "i18next-parser": "9.4.0",
"i18next-resources-to-backend": "1.2.1", "i18next-resources-to-backend": "1.2.1",
"livekit-client": "2.19.0", "livekit-client": "2.19.2",
"posthog-js": "1.386.5", "posthog-js": "1.391.2",
"react": "18.3.1", "react": "18.3.1",
"react-aria": "3.49.0", "react-aria": "3.50.0",
"react-aria-components": "1.18.0", "react-aria-components": "1.19.0",
"react-dom": "18.3.1", "react-dom": "18.3.1",
"react-i18next": "17.0.8", "react-i18next": "17.0.8",
"react-stately": "3.47.0", "react-stately": "3.48.0",
"use-sound": "5.0.0", "use-sound": "5.0.0",
"valtio": "2.3.2", "valtio": "2.3.2",
"wouter": "3.10.0" "wouter": "3.10.0"
@@ -63,6 +64,7 @@
"typescript": "6.0.3", "typescript": "6.0.3",
"typescript-eslint": "8.60.1", "typescript-eslint": "8.60.1",
"vite": "8.0.14", "vite": "8.0.14",
"vite-plugin-static-copy": "4.1.1",
"vite-plugin-svgr": "5.2.0" "vite-plugin-svgr": "5.2.0"
} }
}, },
@@ -110,6 +112,62 @@
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0" "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0"
} }
}, },
"node_modules/@adobe/react-spectrum/node_modules/react-aria": {
"version": "3.49.0",
"resolved": "https://registry.npmjs.org/react-aria/-/react-aria-3.49.0.tgz",
"integrity": "sha512-4+oK9FwJQWYhyA5zLfj/feOGY0zZbkE1muoF4gyxMroHVypjcYaRSTlJwvxph2zIlxt757KX6xIK2wJ5Aw1Kog==",
"license": "Apache-2.0",
"dependencies": {
"@internationalized/date": "^3.12.2",
"@internationalized/number": "^3.6.7",
"@internationalized/string": "^3.2.9",
"@react-types/shared": "^3.35.0",
"@swc/helpers": "^0.5.0",
"aria-hidden": "^1.2.3",
"clsx": "^2.0.0",
"react-stately": "3.47.0",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@adobe/react-spectrum/node_modules/react-aria-components": {
"version": "1.18.0",
"resolved": "https://registry.npmjs.org/react-aria-components/-/react-aria-components-1.18.0.tgz",
"integrity": "sha512-FhRQjuDkH4WhgFv+O2sYTzK3JzdZTGpBeaqfRlfTo+DcSZzD8elJEkytHe7SDpcexVKeire8NVd7OruZHfCVoA==",
"license": "Apache-2.0",
"dependencies": {
"@internationalized/date": "^3.12.2",
"@react-types/shared": "^3.35.0",
"@swc/helpers": "^0.5.0",
"client-only": "^0.0.1",
"react-aria": "3.49.0",
"react-stately": "3.47.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@adobe/react-spectrum/node_modules/react-stately": {
"version": "3.47.0",
"resolved": "https://registry.npmjs.org/react-stately/-/react-stately-3.47.0.tgz",
"integrity": "sha512-H3ar+SOWP920EbVg7qWfP3fZjZiwhlEJAEJQqjt+w8oKijCwFgr0+R4941PIHscOXRNRvEOjvWilitImC0DdBg==",
"license": "Apache-2.0",
"dependencies": {
"@internationalized/date": "^3.12.2",
"@internationalized/number": "^3.6.7",
"@internationalized/string": "^3.2.9",
"@react-types/shared": "^3.35.0",
"@swc/helpers": "^0.5.0",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
"version": "7.29.0", "version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
@@ -1752,9 +1810,9 @@
} }
}, },
"node_modules/@react-types/shared": { "node_modules/@react-types/shared": {
"version": "3.35.0", "version": "3.36.0",
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.35.0.tgz", "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.36.0.tgz",
"integrity": "sha512-iNWvuzEwANttpQpdlu8nPBtdHb0mcCMj1ZTH//iRB5E/14IAnyRlR25rxH7pNLyzHINsPGEKnWvpwDMCT6vziQ==", "integrity": "sha512-DkP/H0C2YjjS7gZWKNqOmU8a16qHPjQNdzMwmTq9SzplM6Iw0kVMTZ0OIoe6FOgGqa+FwMsE2QbPjh/n3g/jXQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peerDependencies": { "peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
@@ -2357,9 +2415,9 @@
} }
}, },
"node_modules/@tanstack/query-core": { "node_modules/@tanstack/query-core": {
"version": "5.100.14", "version": "5.101.0",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.14.tgz", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.0.tgz",
"integrity": "sha512-5X41dGpxgeaHISCRW2oYwcSycZeULZzAunaudXT9ov1KOTj9xwt0CH6hbwqP1/z74ZWF7rYFnDpyYH07XFcZew==", "integrity": "sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"type": "github", "type": "github",
@@ -2378,12 +2436,12 @@
} }
}, },
"node_modules/@tanstack/react-query": { "node_modules/@tanstack/react-query": {
"version": "5.100.14", "version": "5.101.0",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.14.tgz", "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.0.tgz",
"integrity": "sha512-oOr6aRdSFEwWhzxEkD/9ZcItM3+LjBSkeVmadWKwUssAHTsqd/7bOjWrX4AbvEkoEhgAxzN0Xk6H/aYzXiYBAw==", "integrity": "sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@tanstack/query-core": "5.100.14" "@tanstack/query-core": "5.101.0"
}, },
"funding": { "funding": {
"type": "github", "type": "github",
@@ -3192,6 +3250,19 @@
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/bl": { "node_modules/bl": {
"version": "5.1.0", "version": "5.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-5.1.0.tgz", "resolved": "https://registry.npmjs.org/bl/-/bl-5.1.0.tgz",
@@ -7094,6 +7165,19 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
"dev": true,
"license": "MIT",
"dependencies": {
"binary-extensions": "^2.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/is-boolean-object": { "node_modules/is-boolean-object": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz",
@@ -8035,9 +8119,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/livekit-client": { "node_modules/livekit-client": {
"version": "2.19.0", "version": "2.19.2",
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.19.0.tgz", "resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.19.2.tgz",
"integrity": "sha512-aolY1XDAtx0nHKBNm29W9OhzBnSz1CP5kq3phvRhFfi1NbvMXs8tcACjAkZTnIKgihkp+BiJScZZ3tZv0Gz8sA==", "integrity": "sha512-Kvk07QYDWRAbmYNLRll04ZIuxMQobW/oLPYnmR1kCy8GGHpU0gqyHf704Rz+29zfy8IJZRjKqeVbzGSKn9sumw==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@livekit/mutex": "1.1.1", "@livekit/mutex": "1.1.1",
@@ -8675,6 +8759,19 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/p-map": {
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.5.tgz",
"integrity": "sha512-e8vJF4XdVkzqqSHguEMz41mQO1wKwxKm5ENrUJQUu9kLDCtn83cxbyHZcszr4QC5zEA7WffRRC4gsTecC7J9oA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/package-manager-detector": { "node_modules/package-manager-detector": {
"version": "1.6.0", "version": "1.6.0",
"resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz", "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz",
@@ -9033,19 +9130,19 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/posthog-js": { "node_modules/posthog-js": {
"version": "1.386.5", "version": "1.391.2",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.386.5.tgz", "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.391.2.tgz",
"integrity": "sha512-ASejQQf5Xw0XolMwH/KCLZlZtoyLK6VsvORwGagAtfa8/ElIOF76BMQspkDsRTybEI+uzHqRDm2m/na1Dki2mA==", "integrity": "sha512-q0DZN6ljchSnAFJIXf+sQFTPlsLjTlRa+TvrL+QRb6413BGtib/MNiQy1bnwLKt8KR+f6xJYvkqdLyty9s4Aww==",
"license": "SEE LICENSE IN LICENSE", "license": "SEE LICENSE IN LICENSE",
"dependencies": { "dependencies": {
"@posthog/core": "^1.32.3", "@posthog/core": "^1.35.3",
"@posthog/types": "^1.386.3", "@posthog/types": "^1.390.2",
"core-js": "^3.38.1", "core-js": "^3.38.1",
"dompurify": "^3.3.2", "dompurify": "^3.3.2",
"fflate": "^0.4.8", "fflate": "^0.4.8",
"preact": "^10.28.2", "preact": "^10.29.2",
"query-selector-shadow-dom": "^1.0.1", "query-selector-shadow-dom": "^1.0.1",
"web-vitals": "^5.1.0" "web-vitals": "^5.3.0"
} }
}, },
"node_modules/powershell-utils": { "node_modules/powershell-utils": {
@@ -9062,13 +9159,21 @@
} }
}, },
"node_modules/preact": { "node_modules/preact": {
"version": "10.28.3", "version": "10.29.7",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.28.3.tgz", "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.7.tgz",
"integrity": "sha512-tCmoRkPQLpBeWzpmbhryairGnhW9tKV6c6gr/w+RhoRoKEJwsjzipwp//1oCpGPOchvSLaAPlpcJi9MwMmoPyA==", "integrity": "sha512-DCHYrK/B10yUD3ZjLfhZ3WIE/9Vf9VFUODcRE2dRomTYDpJk6z6L9wecSfhfE6M9ZTHUdyQkoC46arIDhEV84Q==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
"url": "https://opencollective.com/preact" "url": "https://opencollective.com/preact"
},
"peerDependencies": {
"preact-render-to-string": ">=5"
},
"peerDependenciesMeta": {
"preact-render-to-string": {
"optional": true
}
} }
}, },
"node_modules/prelude-ls": { "node_modules/prelude-ls": {
@@ -9275,19 +9380,19 @@
} }
}, },
"node_modules/react-aria": { "node_modules/react-aria": {
"version": "3.49.0", "version": "3.50.0",
"resolved": "https://registry.npmjs.org/react-aria/-/react-aria-3.49.0.tgz", "resolved": "https://registry.npmjs.org/react-aria/-/react-aria-3.50.0.tgz",
"integrity": "sha512-4+oK9FwJQWYhyA5zLfj/feOGY0zZbkE1muoF4gyxMroHVypjcYaRSTlJwvxph2zIlxt757KX6xIK2wJ5Aw1Kog==", "integrity": "sha512-S0Os6QZk33fzUAKu1QLT9afoUaCBt1ZNdoiq0n2YMVgKIdNIQS8zxiZ8O9hYE6QyDkHKjD6q39LQZ+qaSAIgjw==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@internationalized/date": "^3.12.2", "@internationalized/date": "^3.12.2",
"@internationalized/number": "^3.6.7", "@internationalized/number": "^3.6.7",
"@internationalized/string": "^3.2.9", "@internationalized/string": "^3.2.9",
"@react-types/shared": "^3.35.0", "@react-types/shared": "^3.36.0",
"@swc/helpers": "^0.5.0", "@swc/helpers": "^0.5.0",
"aria-hidden": "^1.2.3", "aria-hidden": "^1.2.3",
"clsx": "^2.0.0", "clsx": "^2.0.0",
"react-stately": "3.47.0", "react-stately": "3.48.0",
"use-sync-external-store": "^1.6.0" "use-sync-external-store": "^1.6.0"
}, },
"peerDependencies": { "peerDependencies": {
@@ -9296,17 +9401,17 @@
} }
}, },
"node_modules/react-aria-components": { "node_modules/react-aria-components": {
"version": "1.18.0", "version": "1.19.0",
"resolved": "https://registry.npmjs.org/react-aria-components/-/react-aria-components-1.18.0.tgz", "resolved": "https://registry.npmjs.org/react-aria-components/-/react-aria-components-1.19.0.tgz",
"integrity": "sha512-FhRQjuDkH4WhgFv+O2sYTzK3JzdZTGpBeaqfRlfTo+DcSZzD8elJEkytHe7SDpcexVKeire8NVd7OruZHfCVoA==", "integrity": "sha512-2smSS5nqJ8cGYMQezuUXveZm7eMyHCqTN6mDpylQBYLYbdF5dxCCuW1DHn1VKLe1DybSfPvX/cZtJlDmvFfn8A==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@internationalized/date": "^3.12.2", "@internationalized/date": "^3.12.2",
"@react-types/shared": "^3.35.0", "@react-types/shared": "^3.36.0",
"@swc/helpers": "^0.5.0", "@swc/helpers": "^0.5.0",
"client-only": "^0.0.1", "client-only": "^0.0.1",
"react-aria": "3.49.0", "react-aria": "3.50.0",
"react-stately": "3.47.0" "react-stately": "3.48.0"
}, },
"peerDependencies": { "peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
@@ -9360,15 +9465,15 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/react-stately": { "node_modules/react-stately": {
"version": "3.47.0", "version": "3.48.0",
"resolved": "https://registry.npmjs.org/react-stately/-/react-stately-3.47.0.tgz", "resolved": "https://registry.npmjs.org/react-stately/-/react-stately-3.48.0.tgz",
"integrity": "sha512-H3ar+SOWP920EbVg7qWfP3fZjZiwhlEJAEJQqjt+w8oKijCwFgr0+R4941PIHscOXRNRvEOjvWilitImC0DdBg==", "integrity": "sha512-ImicSAG+lTotAe5izcs1fz49Zk48w7pDusqYg04WaPhCoej8BJ24soMu3iLXIrsi273s4P1gZrYGrqReMfgEEA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@internationalized/date": "^3.12.2", "@internationalized/date": "^3.12.2",
"@internationalized/number": "^3.6.7", "@internationalized/number": "^3.6.7",
"@internationalized/string": "^3.2.9", "@internationalized/string": "^3.2.9",
"@react-types/shared": "^3.35.0", "@react-types/shared": "^3.36.0",
"@swc/helpers": "^0.5.0", "@swc/helpers": "^0.5.0",
"use-sync-external-store": "^1.6.0" "use-sync-external-store": "^1.6.0"
}, },
@@ -11137,6 +11242,80 @@
} }
} }
}, },
"node_modules/vite-plugin-static-copy": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/vite-plugin-static-copy/-/vite-plugin-static-copy-4.1.1.tgz",
"integrity": "sha512-GrlA8YklrAfSyxJ4M3fdQLOo9oNkp56IM9FYgX/WtEgeIFkPwhu4wzpufBCIuNKCa6Fn77FkRdYxkHqV0FwjAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"chokidar": "^3.6.0",
"p-map": "^7.0.4",
"picocolors": "^1.1.1",
"tinyglobby": "^0.2.17"
},
"engines": {
"node": "^22.0.0 || >=24.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/sapphi-red"
},
"peerDependencies": {
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/vite-plugin-static-copy/node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"dev": true,
"license": "MIT",
"dependencies": {
"anymatch": "~3.1.2",
"braces": "~3.0.2",
"glob-parent": "~5.1.2",
"is-binary-path": "~2.1.0",
"is-glob": "~4.0.1",
"normalize-path": "~3.0.0",
"readdirp": "~3.6.0"
},
"engines": {
"node": ">= 8.10.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
},
"optionalDependencies": {
"fsevents": "~2.3.2"
}
},
"node_modules/vite-plugin-static-copy/node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/vite-plugin-static-copy/node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"dev": true,
"license": "MIT",
"dependencies": {
"picomatch": "^2.2.1"
},
"engines": {
"node": ">=8.10.0"
}
},
"node_modules/vite-plugin-svgr": { "node_modules/vite-plugin-svgr": {
"version": "5.2.0", "version": "5.2.0",
"resolved": "https://registry.npmjs.org/vite-plugin-svgr/-/vite-plugin-svgr-5.2.0.tgz", "resolved": "https://registry.npmjs.org/vite-plugin-svgr/-/vite-plugin-svgr-5.2.0.tgz",
@@ -11470,9 +11649,9 @@
} }
}, },
"node_modules/web-vitals": { "node_modules/web-vitals": {
"version": "5.1.0", "version": "5.3.0",
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.1.0.tgz", "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.3.0.tgz",
"integrity": "sha512-ArI3kx5jI0atlTtmV0fWU3fjpLmq/nD3Zr1iFFlJLaqa5wLBkUSzINwBPySCX/8jRyjlmy1Volw1kz1g9XE4Jg==", "integrity": "sha512-q6LWsLatGYZp5VGBIOvbTj6JBV2nOmC8KvWztXBmwJcfFAzhwKwbOxhUH306XY3CcaZDUlSmSuNPBsCn0bFu+g==",
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/webrtc-adapter": { "node_modules/webrtc-adapter": {
+10 -8
View File
@@ -1,7 +1,7 @@
{ {
"name": "meet", "name": "meet",
"private": true, "private": true,
"version": "1.22.0", "version": "1.24.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "panda codegen && vite", "dev": "panda codegen && vite",
@@ -17,15 +17,16 @@
"dependencies": { "dependencies": {
"@fontsource-variable/atkinson-hyperlegible-next": "5.2.6", "@fontsource-variable/atkinson-hyperlegible-next": "5.2.6",
"@fontsource-variable/lexend": "5.2.11", "@fontsource-variable/lexend": "5.2.11",
"@libreaudio/la-call": "0.1.4",
"@fontsource/opendyslexic": "5.2.5", "@fontsource/opendyslexic": "5.2.5",
"@libreaudio/la-call": "0.1.4",
"@livekit/components-react": "2.9.21", "@livekit/components-react": "2.9.21",
"@livekit/components-styles": "1.2.0", "@livekit/components-styles": "1.2.0",
"@livekit/track-processors": "0.7.2", "@livekit/track-processors": "0.7.2",
"@mediapipe/tasks-vision": "0.10.14",
"@pandacss/preset-panda": "1.11.3", "@pandacss/preset-panda": "1.11.3",
"@react-types/overlays": "3.10.0", "@react-types/overlays": "3.10.0",
"@remixicon/react": "4.9.0", "@remixicon/react": "4.9.0",
"@tanstack/react-query": "5.100.14", "@tanstack/react-query": "5.101.0",
"@timephy/rnnoise-wasm": "1.0.0", "@timephy/rnnoise-wasm": "1.0.0",
"crisp-sdk-web": "1.1.2", "crisp-sdk-web": "1.1.2",
"derive-valtio": "0.2.0", "derive-valtio": "0.2.0",
@@ -35,14 +36,14 @@
"i18next-browser-languagedetector": "8.2.1", "i18next-browser-languagedetector": "8.2.1",
"i18next-parser": "9.4.0", "i18next-parser": "9.4.0",
"i18next-resources-to-backend": "1.2.1", "i18next-resources-to-backend": "1.2.1",
"livekit-client": "2.19.0", "livekit-client": "2.19.2",
"posthog-js": "1.386.5", "posthog-js": "1.391.2",
"react": "18.3.1", "react": "18.3.1",
"react-aria": "3.49.0", "react-aria": "3.50.0",
"react-aria-components": "1.18.0", "react-aria-components": "1.19.0",
"react-dom": "18.3.1", "react-dom": "18.3.1",
"react-i18next": "17.0.8", "react-i18next": "17.0.8",
"react-stately": "3.47.0", "react-stately": "3.48.0",
"use-sound": "5.0.0", "use-sound": "5.0.0",
"valtio": "2.3.2", "valtio": "2.3.2",
"wouter": "3.10.0" "wouter": "3.10.0"
@@ -70,6 +71,7 @@
"typescript": "6.0.3", "typescript": "6.0.3",
"typescript-eslint": "8.60.1", "typescript-eslint": "8.60.1",
"vite": "8.0.14", "vite": "8.0.14",
"vite-plugin-static-copy": "4.1.1",
"vite-plugin-svgr": "5.2.0" "vite-plugin-svgr": "5.2.0"
} }
} }
+1
View File
@@ -58,6 +58,7 @@ export interface ApiConfig {
transcription_destination?: string transcription_destination?: string
max_participants_for_sound: number max_participants_for_sound: number
auto_mute_on_join_threshold: number auto_mute_on_join_threshold: number
authenticated_users_can_edit_display_name: boolean
} }
const fetchConfig = (): Promise<ApiConfig> => { const fetchConfig = (): Promise<ApiConfig> => {
@@ -9,10 +9,11 @@ import { useState } from 'react'
import { menuRecipe } from '@/primitives/menuRecipe' import { menuRecipe } from '@/primitives/menuRecipe'
import { ApiRoom } from '@/features/rooms/api/ApiRoom' import { ApiRoom } from '@/features/rooms/api/ApiRoom'
import { loadUserChoices } from '@livekit/components-core' import { useSnapshot } from 'valtio'
import { userStore } from '@/stores/user'
export const CreateMeetingMenu = () => { export const CreateMeetingMenu = () => {
const { username } = loadUserChoices() const { username } = useSnapshot(userStore)
const { t } = useTranslation('home') const { t } = useTranslation('home')
const { mutateAsync: createRoom } = useCreateRoom() const { mutateAsync: createRoom } = useCreateRoom()
@@ -26,6 +26,7 @@ export const PipFocusLayout = memo(
<ParticipantTile <ParticipantTile
key={getTrackKey(mainTrack)} key={getTrackKey(mainTrack)}
trackRef={mainTrack} trackRef={mainTrack}
disableTileControls
/> />
</MainSlot> </MainSlot>
)} )}
@@ -34,6 +35,7 @@ export const PipFocusLayout = memo(
<ParticipantTile <ParticipantTile
key={getTrackKey(thumbnailTrack)} key={getTrackKey(thumbnailTrack)}
trackRef={thumbnailTrack} trackRef={thumbnailTrack}
disableTileControls
/> />
</Thumbnail> </Thumbnail>
)} )}
@@ -48,9 +50,10 @@ const FocusContainer = styled('div', {
position: 'relative', position: 'relative',
width: '100%', width: '100%',
height: '100%', height: '100%',
borderRadius: '4px', borderRadius: '8px',
overflow: 'hidden', overflow: 'hidden',
backgroundColor: 'primaryDark.100', backgroundColor: 'primaryDark.100',
boxSizing: 'border-box',
}, },
}) })
@@ -58,6 +61,8 @@ const MainSlot = styled('div', {
base: { base: {
width: '100%', width: '100%',
height: '100%', height: '100%',
borderRadius: '8px',
overflow: 'hidden',
'& .lk-participant-media-video': { '& .lk-participant-media-video': {
objectFit: 'contain', objectFit: 'contain',
}, },
@@ -67,13 +72,13 @@ const MainSlot = styled('div', {
const Thumbnail = styled('div', { const Thumbnail = styled('div', {
base: { base: {
position: 'absolute', position: 'absolute',
right: '1rem', right: '1.25rem',
bottom: '1rem', bottom: '1.25rem',
width: '42%', width: '42%',
maxWidth: '220px', maxWidth: '220px',
minWidth: '140px', minWidth: '140px',
aspectRatio: '16 / 9', aspectRatio: '16 / 9',
borderRadius: '4px', borderRadius: '8px',
overflow: 'hidden', overflow: 'hidden',
boxShadow: 'md', boxShadow: 'md',
zIndex: 2, zIndex: 2,
@@ -41,7 +41,7 @@ export const PipGridLayout = memo(({ tracks }: PipGridLayoutProps) => {
<GridContainer ref={containerRef} style={gridStyle}> <GridContainer ref={containerRef} style={gridStyle}>
{tracks.map((track, index) => ( {tracks.map((track, index) => (
<GridCell key={getTrackKey(track)} style={placements[index]}> <GridCell key={getTrackKey(track)} style={placements[index]}>
<ParticipantTile trackRef={track} /> <ParticipantTile trackRef={track} disableTileControls />
</GridCell> </GridCell>
))} ))}
</GridContainer> </GridContainer>
@@ -54,7 +54,8 @@ const GridContainer = styled('div', {
width: '100%', width: '100%',
height: '100%', height: '100%',
display: 'grid', display: 'grid',
gap: '0.25rem', gap: '0.5rem',
boxSizing: 'border-box',
}, },
}) })
@@ -63,7 +64,7 @@ const GridCell = styled('div', {
position: 'relative', position: 'relative',
minWidth: 0, minWidth: 0,
minHeight: 0, minHeight: 0,
borderRadius: '4px', borderRadius: '8px',
overflow: 'hidden', overflow: 'hidden',
backgroundColor: 'primaryDark.100', backgroundColor: 'primaryDark.100',
// Paint on own layer so FLIP transforms don't trigger layout thrash. // Paint on own layer so FLIP transforms don't trigger layout thrash.
@@ -0,0 +1,97 @@
import { memo } from 'react'
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
import { styled } from '@/styled-system/jsx'
import { ParticipantTile } from '@/features/rooms/livekit/components/ParticipantTile'
import { getTrackKey } from '../../utils/pipTrackSelection'
type PipScreenShareLayoutProps = {
screenShareTrack: TrackReferenceOrPlaceholder
cameraTracks: TrackReferenceOrPlaceholder[]
}
/**
* Layout when a screen share is active.
* Camera tiles are shown as a compact row at the top; the screen share occupies
* the remaining space below, much larger than the camera tiles.
*/
export const PipScreenShareLayout = memo(
({ screenShareTrack, cameraTracks }: PipScreenShareLayoutProps) => {
return (
<LayoutContainer>
{cameraTracks.length > 0 && (
<CameraTilesRow>
{cameraTracks.map((track) => (
<CameraTile key={getTrackKey(track)}>
<ParticipantTile trackRef={track} disableTileControls />
</CameraTile>
))}
</CameraTilesRow>
)}
<ScreenShareSlot>
<ParticipantTile trackRef={screenShareTrack} disableTileControls />
</ScreenShareSlot>
</LayoutContainer>
)
}
)
PipScreenShareLayout.displayName = 'PipScreenShareLayout'
const LayoutContainer = styled('div', {
base: {
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
boxSizing: 'border-box',
overflow: 'hidden',
},
})
const CameraTilesRow = styled('div', {
base: {
display: 'flex',
flexDirection: 'row',
justifyContent: 'center',
gap: '0.5rem',
flexShrink: 0,
height: '22%',
minHeight: '60px',
maxHeight: '120px',
},
})
const CameraTile = styled('div', {
base: {
position: 'relative',
flex: '0 1 auto',
height: '100%',
aspectRatio: '16 / 9',
minWidth: 0,
borderRadius: '8px',
overflow: 'hidden',
backgroundColor: 'primaryDark.100',
'& .lk-participant-tile': {
width: '100%',
height: '100%',
},
},
})
const ScreenShareSlot = styled('div', {
base: {
position: 'relative',
flex: 1,
minHeight: 0,
borderRadius: '8px',
overflow: 'hidden',
backgroundColor: 'primaryDark.100',
'& .lk-participant-tile': {
width: '100%',
height: '100%',
},
'& .lk-participant-media-video': {
objectFit: 'contain',
},
},
})
@@ -1,10 +1,11 @@
import { useMemo } from 'react' import React, { useMemo } from 'react'
import { usePagination, useTracks } from '@livekit/components-react' import { usePagination, useTracks } from '@livekit/components-react'
import { RoomEvent, Track } from 'livekit-client' import { RoomEvent, Track } from 'livekit-client'
import { styled } from '@/styled-system/jsx' import { styled } from '@/styled-system/jsx'
import { PipFocusLayout } from './PipFocusLayout' import { PipFocusLayout } from './PipFocusLayout'
import { PipGridLayout } from './PipGridLayout' import { PipGridLayout } from './PipGridLayout'
import { PipPagination } from './PipPagination' import { PipPagination } from './PipPagination'
import { PipScreenShareLayout } from './PipScreenShareLayout'
import { StageFrame } from './StageFrame' import { StageFrame } from './StageFrame'
import { MAX_PIP_TILES } from '../../utils/pipGrid' import { MAX_PIP_TILES } from '../../utils/pipGrid'
import { import {
@@ -13,9 +14,12 @@ import {
} from '@livekit/components-core' } from '@livekit/components-core'
/** /**
* PipStage picks between two layouts based on track count: * PipStage picks between three layouts:
* - Focus mode ( 2 tracks): one main track + one thumbnail overlay. * - Screen share mode (any screen share active):
* - Grid mode (3+ tracks): adaptive tiling. * small camera tiles in a row at the top, large screen share below.
* - Grid mode (3+ camera tracks, no screen share): adaptive tiling.
* - Focus mode ( 2 camera tracks, no screen share): one main track
* + one thumbnail overlay.
*/ */
export const PipStage = () => { export const PipStage = () => {
const tracks = useTracks( const tracks = useTracks(
@@ -41,51 +45,55 @@ export const PipStage = () => {
[tracks] [tracks]
) )
// Grid mode order: screen share leads, then cameras (already ordered by // Cap camera tiles in screen-share mode. Called unconditionally for hook rules.
// active speaker via the `ActiveSpeakersChanged` update above). const paginatedCameraTracks = usePagination(MAX_PIP_TILES - 1, cameraTracks)
const gridTracks = useMemo(
() =>
screenShareTrack ? [screenShareTrack, ...cameraTracks] : cameraTracks,
[screenShareTrack, cameraTracks]
)
// Cap the grid at MAX_PIP_TILES per page. `usePagination` keeps the visible // Cap the grid at MAX_PIP_TILES per page for the non-screenshare grid mode.
// page visually stable (active/recent speakers stay put) via its internal const pagination = usePagination(MAX_PIP_TILES, cameraTracks)
// `useVisualStableUpdate`. Called unconditionally to respect hook rules.
const pagination = usePagination(MAX_PIP_TILES, gridTracks)
if (tracks.length === 0) return null if (tracks.length === 0) return null
/** // Screen share active
* The focus layout shows one main track with one thumbnail overlay, if (screenShareTrack) {
* so it can only fit 2 tracks. Beyond that we switch to the grid. // Solo presenter: screen share fills the area, camera as small thumbnail
*/ if (cameraTracks.length <= 1) {
if (gridTracks.length > 2) { return (
return (
<StageWrapper>
<StageFrame> <StageFrame>
<PipGridLayout tracks={pagination.tracks} /> <PipFocusLayout
mainTrack={screenShareTrack}
thumbnailTrack={cameraTracks[0]}
/>
</StageFrame> </StageFrame>
<PipPagination )
totalPageCount={pagination.totalPageCount} }
currentPage={pagination.currentPage} // Multiple cameras: camera row at top, screen share below
nextPage={pagination.nextPage} return (
prevPage={pagination.prevPage} <PaginatedStage pagination={paginatedCameraTracks}>
<PipScreenShareLayout
screenShareTrack={screenShareTrack}
cameraTracks={paginatedCameraTracks.tracks}
/> />
</StageWrapper> </PaginatedStage>
) )
} }
// 3+ camera tracks → adaptive grid
if (cameraTracks.length > 2) {
return (
<PaginatedStage pagination={pagination}>
<PipGridLayout tracks={pagination.tracks} />
</PaginatedStage>
)
}
// ≤ 2 camera tracks → focus layout (main + optional thumbnail)
const localCameraTrack = cameraTracks.find( const localCameraTrack = cameraTracks.find(
(track) => track.participant?.isLocal (track) => track.participant?.isLocal
) )
const remoteCameraTrack = cameraTracks.find( const remoteCameraTrack = cameraTracks.find(
(track) => !track.participant?.isLocal (track) => !track.participant?.isLocal
) )
const mainTrack = remoteCameraTrack ?? localCameraTrack
const mainTrack = screenShareTrack ?? remoteCameraTrack ?? localCameraTrack
const thumbnailTrack = const thumbnailTrack =
mainTrack === localCameraTrack ? undefined : localCameraTrack mainTrack === localCameraTrack ? undefined : localCameraTrack
@@ -96,6 +104,31 @@ export const PipStage = () => {
) )
} }
type PaginationResult = {
totalPageCount: number
currentPage: number
nextPage: () => void
prevPage: () => void
}
const PaginatedStage = ({
pagination,
children,
}: {
pagination: PaginationResult
children: React.ReactNode
}) => (
<StageWrapper>
<StageFrame>{children}</StageFrame>
<PipPagination
totalPageCount={pagination.totalPageCount}
currentPage={pagination.currentPage}
nextPage={pagination.nextPage}
prevPage={pagination.prevPage}
/>
</StageWrapper>
)
const StageWrapper = styled('div', { const StageWrapper = styled('div', {
base: { base: {
display: 'flex', display: 'flex',
@@ -25,8 +25,8 @@ const Container = styled('div', {
flex: 1, flex: 1,
minWidth: 0, minWidth: 0,
minHeight: 0, minHeight: 0,
marginLeft: '0.5rem', padding: '0.5rem',
marginRight: '0.5rem', boxSizing: 'border-box',
borderRadius: '4px', borderRadius: '4px',
overflow: 'hidden', overflow: 'hidden',
}, },
@@ -57,7 +57,9 @@ const RecordingDownload = () => {
const isSaved = const isSaved =
data?.status === RecordingStatus.Saved || data?.status === RecordingStatus.Saved ||
data?.status === RecordingStatus.NotificationSucceed || data?.status === RecordingStatus.NotificationSucceed ||
data?.status === RecordingStatus.FailedToStop data?.status === RecordingStatus.FailedToStop ||
data?.status === RecordingStatus.ExternalProcessFailed ||
data?.status === RecordingStatus.ExternalProcessSuccessful
const pageTitle = useMemo(() => { const pageTitle = useMemo(() => {
if (isError) return `${APP_TITLE} - ${t('error.title')}` if (isError) return `${APP_TITLE} - ${t('error.title')}`
@@ -91,6 +93,8 @@ const RecordingDownload = () => {
} }
if ( if (
data.status !== RecordingStatus.ExternalProcessFailed &&
data.status !== RecordingStatus.ExternalProcessSuccessful &&
data.status !== RecordingStatus.Saved && data.status !== RecordingStatus.Saved &&
data.status !== RecordingStatus.NotificationSucceed && data.status !== RecordingStatus.NotificationSucceed &&
data.status !== RecordingStatus.FailedToStop data.status !== RecordingStatus.FailedToStop
@@ -12,4 +12,6 @@ export enum RecordingStatus {
FailedToStart = 'failedToStart', FailedToStart = 'failedToStart',
FailedToStop = 'failedToStop', FailedToStop = 'failedToStop',
NotificationSucceed = 'notification_succeeded', NotificationSucceed = 'notification_succeeded',
ExternalProcessSuccessful = 'external_process_successful',
ExternalProcessFailed = 'external_process_failed',
} }
@@ -22,7 +22,7 @@ export type ApiRoom = {
id: string id: string
name: string name: string
slug: string slug: string
pin_code: string pin_code?: string
is_administrable: boolean is_administrable: boolean
access_level: ApiAccessLevel access_level: ApiAccessLevel
livekit?: ApiLiveKit livekit?: ApiLiveKit
@@ -36,6 +36,7 @@ import { PictureInPictureConference } from '@/features/pip/components/PictureInP
import { notifyAutoMutedOnJoin } from '@/features/notifications/utils' import { notifyAutoMutedOnJoin } from '@/features/notifications/utils'
import { useSnapshot } from 'valtio' import { useSnapshot } from 'valtio'
import { userPreferencesStore } from '@/stores/userPreferences' import { userPreferencesStore } from '@/stores/userPreferences'
import { userStore } from '@/stores/user'
export const Conference = ({ export const Conference = ({
roomId, roomId,
@@ -53,6 +54,8 @@ export const Conference = ({
userChoices: LocalUserChoices userChoices: LocalUserChoices
} }
const { username } = useSnapshot(userStore)
useEffect(() => { useEffect(() => {
posthog.capture('visit-room', { slug: roomId }) posthog.capture('visit-room', { slug: roomId })
}, [roomId, posthog]) }, [roomId, posthog])
@@ -83,10 +86,10 @@ export const Conference = ({
queryFn: () => queryFn: () =>
fetchRoom({ fetchRoom({
roomId: roomId as string, roomId: roomId as string,
username: userConfig.username, username: username,
}).catch((error) => { }).catch((error) => {
if (error.statusCode == '404') { if (error.statusCode == '404') {
createRoom({ slug: roomId, username: userConfig.username }) createRoom({ slug: roomId, username })
} }
}), }),
retry: false, retry: false,
@@ -42,14 +42,17 @@ import {
saveAudioInputDeviceId, saveAudioInputDeviceId,
saveAudioInputEnabled, saveAudioInputEnabled,
saveAudioOutputDeviceId, saveAudioOutputDeviceId,
saveUsername,
saveVideoInputDeviceId, saveVideoInputDeviceId,
saveVideoInputEnabled, saveVideoInputEnabled,
userChoicesStore, userChoicesStore,
} from '@/stores/userChoices' } from '@/stores/userChoices'
import { saveUsername, userStore } from '@/stores/user'
import { useCannotUseDevice } from '../livekit/hooks/useCannotUseDevice' import { useCannotUseDevice } from '../livekit/hooks/useCannotUseDevice'
import { useSnapshot } from 'valtio' import { useSnapshot } from 'valtio'
import { useUser } from '@/features/auth/api/useUser'
import { useConfig } from '@/api/useConfig'
const onError = (e: Error) => console.error('ERROR', e) const onError = (e: Error) => console.error('ERROR', e)
@@ -114,6 +117,9 @@ export const Join = ({
}) => { }) => {
const { t } = useTranslation('rooms', { keyPrefix: 'join' }) const { t } = useTranslation('rooms', { keyPrefix: 'join' })
const { data: configData } = useConfig()
const { isLoggedIn, user } = useUser()
const { const {
audioEnabled, audioEnabled,
videoEnabled, videoEnabled,
@@ -121,9 +127,10 @@ export const Join = ({
audioOutputDeviceId, audioOutputDeviceId,
videoDeviceId, videoDeviceId,
processorConfig, processorConfig,
username,
} = useSnapshot(userChoicesStore) } = useSnapshot(userChoicesStore)
const { username } = useSnapshot(userStore)
const initialUserChoices = useRef<LocalUserChoices | null>(null) const initialUserChoices = useRef<LocalUserChoices | null>(null)
if (initialUserChoices.current === null) { if (initialUserChoices.current === null) {
@@ -134,7 +141,6 @@ export const Join = ({
audioOutputDeviceId, audioOutputDeviceId,
videoDeviceId, videoDeviceId,
processorConfig, processorConfig,
username,
} }
} }
@@ -448,20 +454,23 @@ export const Join = ({
<H lvl={1} margin="sm" centered> <H lvl={1} margin="sm" centered>
{t('heading')} {t('heading')}
</H> </H>
<Field {(!isLoggedIn ||
type="text" configData?.authenticated_users_can_edit_display_name) && (
onChange={saveUsername} <Field
label={t('usernameLabel')} type="text"
id="input-name" onChange={saveUsername}
defaultValue={username} label={t('usernameLabel')}
validate={(value) => !value && t('errors.usernameEmpty')} id="input-name"
wrapperProps={{ defaultValue={username || user?.full_name}
noMargin: true, validate={(value) => !value && t('errors.usernameEmpty')}
fullWidth: true, wrapperProps={{
}} noMargin: true,
autoComplete="name" fullWidth: true,
maxLength={50} }}
/> autoComplete="name"
maxLength={50}
/>
)}
</VStack> </VStack>
</Form> </Form>
) )
@@ -2,6 +2,7 @@ import type { Participant } from 'livekit-client'
import { styled } from '@/styled-system/jsx' import { styled } from '@/styled-system/jsx'
import { Avatar } from '@/components/Avatar' import { Avatar } from '@/components/Avatar'
import { useIsSpeaking } from '@livekit/components-react' import { useIsSpeaking } from '@livekit/components-react'
import { getParticipantBackgroundGradient } from '@/features/rooms/utils/getParticipantBackgroundGradient'
import { getParticipantColor } from '@/features/rooms/utils/getParticipantColor' import { getParticipantColor } from '@/features/rooms/utils/getParticipantColor'
import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver' import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver'
import { useMemo, useRef } from 'react' import { useMemo, useRef } from 'react'
@@ -10,7 +11,6 @@ const StyledParticipantPlaceHolder = styled('div', {
base: { base: {
width: '100%', width: '100%',
height: '100%', height: '100%',
backgroundColor: 'primaryDark.100',
display: 'flex', display: 'flex',
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
@@ -26,6 +26,10 @@ export const ParticipantPlaceholder = ({
}: ParticipantPlaceholderProps) => { }: ParticipantPlaceholderProps) => {
const isSpeaking = useIsSpeaking(participant) const isSpeaking = useIsSpeaking(participant)
const participantColor = getParticipantColor(participant) const participantColor = getParticipantColor(participant)
const backgroundGradient = useMemo(
() => getParticipantBackgroundGradient(participantColor),
[participantColor]
)
const placeholderEl = useRef<HTMLDivElement>(null) const placeholderEl = useRef<HTMLDivElement>(null)
const { width, height } = useSize(placeholderEl) const { width, height } = useSize(placeholderEl)
@@ -39,7 +43,13 @@ export const ParticipantPlaceholder = ({
const initialSize = useMemo(() => Math.round(avatarSize * 0.3), [avatarSize]) const initialSize = useMemo(() => Math.round(avatarSize * 0.3), [avatarSize])
return ( return (
<StyledParticipantPlaceHolder ref={placeholderEl}> <StyledParticipantPlaceHolder
ref={placeholderEl}
style={{
backgroundColor: participantColor,
backgroundImage: backgroundGradient,
}}
>
<div <div
style={{ style={{
borderRadius: '50%', borderRadius: '50%',
@@ -52,6 +52,7 @@ export function TrackRefContextIfNeeded(
interface ParticipantTileExtendedProps extends ParticipantTileProps { interface ParticipantTileExtendedProps extends ParticipantTileProps {
disableMetadata?: boolean disableMetadata?: boolean
disableTileControls?: boolean
} }
export const ParticipantTile: ( export const ParticipantTile: (
@@ -66,6 +67,7 @@ export const ParticipantTile: (
onParticipantClick, onParticipantClick,
disableSpeakingIndicator, disableSpeakingIndicator,
disableMetadata, disableMetadata,
disableTileControls,
...htmlProps ...htmlProps
}: ParticipantTileExtendedProps, }: ParticipantTileExtendedProps,
ref ref
@@ -231,7 +233,7 @@ export const ParticipantTile: (
)} )}
</> </>
)} )}
{!disableMetadata && ( {!disableMetadata && !disableTileControls && (
<ParticipantTileFocus <ParticipantTileFocus
trackRef={trackReference} trackRef={trackReference}
hasKeyboardFocus={hasKeyboardFocus} hasKeyboardFocus={hasKeyboardFocus}
@@ -6,8 +6,8 @@ import { Button, Div } from '@/primitives'
import { RiArrowLeftLine, RiCloseLine } from '@remixicon/react' import { RiArrowLeftLine, RiCloseLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { ParticipantsList } from './controls/Participants/ParticipantsList' import { ParticipantsList } from './controls/Participants/ParticipantsList'
import { useSidePanel } from '../hooks/useSidePanel' import { PanelId, useSidePanel } from '../hooks/useSidePanel'
import { ReactNode } from 'react' import React, { ReactNode, useCallback, useRef } from 'react'
import { Chat } from '../prefabs/Chat' import { Chat } from '../prefabs/Chat'
import { Effects } from './effects/Effects' import { Effects } from './effects/Effects'
import { Admin } from './Admin' import { Admin } from './Admin'
@@ -15,6 +15,7 @@ import { Tools } from './Tools'
import { Info } from './Info' import { Info } from './Info'
import { HStack } from '@/styled-system/jsx' import { HStack } from '@/styled-system/jsx'
import { useReactionsToolbar } from '@/features/reactions/hooks/useReactionsToolbar' import { useReactionsToolbar } from '@/features/reactions/hooks/useReactionsToolbar'
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
type StyledSidePanelProps = { type StyledSidePanelProps = {
title: string title: string
@@ -29,103 +30,115 @@ type StyledSidePanelProps = {
isReactionToolbarOpen?: boolean isReactionToolbarOpen?: boolean
} }
const StyledSidePanel = ({ const StyledSidePanel = React.forwardRef<HTMLElement, StyledSidePanelProps>(
title, (
ariaLabel, {
children, title,
onClose, ariaLabel,
isClosed, children,
isReactionToolbarOpen, onClose,
closeButtonTooltip, isClosed,
isSubmenu = false, isReactionToolbarOpen,
onBack, closeButtonTooltip,
backButtonLabel, isSubmenu = false,
}: StyledSidePanelProps) => ( onBack,
<aside backButtonLabel,
className={css({ },
borderWidth: '1px', ref
borderStyle: 'solid', ) => (
borderColor: 'box.border', <aside
backgroundColor: 'box.bg', ref={ref}
color: 'box.text', tabIndex={-1}
borderRadius: 8, className={css({
flex: 1, borderWidth: '1px',
position: 'absolute', borderStyle: 'solid',
overflow: 'hidden', borderColor: 'box.border',
display: 'flex', backgroundColor: 'box.bg',
flexDirection: 'column', color: 'box.text',
margin: 'var(--sizes-room-side-panel-margin)', borderRadius: 8,
marginLeft: 0, flex: 1,
marginBottom: 0, position: 'absolute',
padding: 0, overflow: 'hidden',
gap: 0, display: 'flex',
right: 0, flexDirection: 'column',
top: 0, margin: 'var(--sizes-room-side-panel-margin)',
width: 'var(--sizes-room-side-panel)', marginLeft: 0,
transition: '.5s cubic-bezier(.4,0,.2,1) 5ms', marginBottom: 0,
})} padding: 0,
style={{ gap: 0,
transform: isClosed right: 0,
? 'translateX(calc(var(--sizes-room-side-panel) + var(--sizes-room-side-panel-margin)))' top: 0,
: 'none', width: 'var(--sizes-room-side-panel)',
bottom: isReactionToolbarOpen transition: '.5s cubic-bezier(.4,0,.2,1) 5ms',
? 'calc( var(--sizes-room-control-bar) + var(--sizes-room-reaction-toolbar-height) + calc(var(--lk-grid-gap) / 2))' '&:focus': {
: 'var(--sizes-room-control-bar)', outline: 'none',
}} },
aria-hidden={isClosed} })}
aria-label={ariaLabel} style={{
> transform: isClosed
<HStack alignItems="center"> ? 'translateX(calc(var(--sizes-room-side-panel) + var(--sizes-room-side-panel-margin)))'
{isSubmenu && ( : 'none',
<Button bottom: isReactionToolbarOpen
variant="secondaryText" ? 'calc( var(--sizes-room-control-bar) + var(--sizes-room-reaction-toolbar-height) + calc(var(--lk-grid-gap) / 2))'
size="sm" : 'var(--sizes-room-control-bar)',
square }}
className={css({ marginRight: '0.5rem', marginLeft: '1rem' })} aria-hidden={isClosed}
aria-label={backButtonLabel} aria-label={ariaLabel}
onPress={onBack} >
<HStack alignItems="center">
{isSubmenu && (
<Button
variant="secondaryText"
size="sm"
square
className={css({ marginRight: '0.5rem', marginLeft: '1rem' })}
aria-label={backButtonLabel}
onPress={onBack}
>
<RiArrowLeftLine size={20} aria-hidden="true" />
</Button>
)}
<Heading
slot="title"
level={1}
className={text({ variant: 'h2' })}
style={{
paddingLeft: isSubmenu ? 0 : '1.5rem',
paddingTop: '1rem',
display: isClosed ? 'none' : 'flex',
justifyContent: 'start',
alignItems: 'center',
}}
> >
<RiArrowLeftLine size={20} aria-hidden="true" /> {title}
</Button> </Heading>
)} </HStack>
<Heading <Div
slot="title" position="absolute"
level={1} top="5"
className={text({ variant: 'h2' })} right="5"
style={{ style={{
paddingLeft: isSubmenu ? 0 : '1.5rem', display: isClosed ? 'none' : undefined,
paddingTop: '1rem',
display: isClosed ? 'none' : 'flex',
justifyContent: 'start',
alignItems: 'center',
}} }}
> >
{title} <Button
</Heading> invisible
</HStack> variant="tertiaryText"
<Div size="xs"
position="absolute" onPress={onClose}
top="5" aria-label={closeButtonTooltip}
right="5" tooltip={closeButtonTooltip}
style={{ >
display: isClosed ? 'none' : undefined, <RiCloseLine />
}} </Button>
> </Div>
<Button {children}
invisible </aside>
variant="tertiaryText" )
size="xs"
onPress={onClose}
aria-label={closeButtonTooltip}
tooltip={closeButtonTooltip}
>
<RiCloseLine />
</Button>
</Div>
{children}
</aside>
) )
StyledSidePanel.displayName = 'StyledSidePanel'
type PanelProps = { type PanelProps = {
isOpen: boolean isOpen: boolean
children: React.ReactNode children: React.ReactNode
@@ -162,8 +175,28 @@ export const SidePanel = () => {
const { isOpen: isReactionToolbarOpen } = useReactionsToolbar() const { isOpen: isReactionToolbarOpen } = useReactionsToolbar()
const asideRef = useRef<HTMLElement>(null)
const focusAside = useCallback(() => {
requestAnimationFrame(() => {
asideRef.current?.focus({ preventScroll: true })
})
}, [])
const handlePanelOpened = useCallback(() => {
if (activePanelId === PanelId.CHAT) return
focusAside()
}, [activePanelId, focusAside])
useRestoreFocus(isSidePanelOpen, {
onOpened: handlePanelOpened,
preventScroll: true,
activeKey: activePanelId,
})
return ( return (
<StyledSidePanel <StyledSidePanel
ref={asideRef}
title={title} title={title}
ariaLabel={t('ariaLabel', { title })} ariaLabel={t('ariaLabel', { title })}
onClose={() => { onClose={() => {
@@ -95,8 +95,13 @@ const ToolButton = ({
export const Tools = () => { export const Tools = () => {
const { data } = useConfig() const { data } = useConfig()
const { openTranscript, openScreenRecording, activeSubPanelId, isToolsOpen } = const {
useSidePanel() openTranscript,
openScreenRecording,
activeSubPanelId,
isToolsOpen,
isSidePanelOpen,
} = useSidePanel()
const { t } = useTranslation('rooms', { keyPrefix: 'moreTools' }) const { t } = useTranslation('rooms', { keyPrefix: 'moreTools' })
// Restore focus to the element that opened the Tools panel // Restore focus to the element that opened the Tools panel
@@ -113,6 +118,7 @@ export const Tools = () => {
}, },
restoreFocusRaf: true, restoreFocusRaf: true,
preventScroll: true, preventScroll: true,
shouldRestoreOnClose: () => !isSidePanelOpen,
}) })
const isTranscriptEnabled = useIsRecordingModeEnabled( const isTranscriptEnabled = useIsRecordingModeEnabled(
@@ -13,8 +13,10 @@ import {
} from './TimerWorker' } from './TimerWorker'
import { import {
BackgroundProcessorInterface, BackgroundProcessorInterface,
SELFIE_SEGMENTER_MODEL_PATH,
type ProcessorConfig, type ProcessorConfig,
type ProcessorType, type ProcessorType,
MEDIAPIPE_PATH_WASM,
} from '.' } from '.'
const PROCESSING_WIDTH = 256 const PROCESSING_WIDTH = 256
@@ -153,13 +155,10 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
} }
async initSegmenter() { async initSegmenter() {
const vision = await FilesetResolver.forVisionTasks( const vision = await FilesetResolver.forVisionTasks(MEDIAPIPE_PATH_WASM)
'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision/wasm'
)
this.imageSegmenter = await ImageSegmenter.createFromOptions(vision, { this.imageSegmenter = await ImageSegmenter.createFromOptions(vision, {
baseOptions: { baseOptions: {
modelAssetPath: modelAssetPath: SELFIE_SEGMENTER_MODEL_PATH,
'https://storage.googleapis.com/mediapipe-models/image_segmenter/selfie_segmenter_landscape/float16/latest/selfie_segmenter_landscape.tflite',
delegate: 'CPU', // Use CPU for Firefox. delegate: 'CPU', // Use CPU for Firefox.
}, },
runningMode: 'VIDEO', runningMode: 'VIDEO',
@@ -11,7 +11,11 @@ import {
TIMEOUT_TICK, TIMEOUT_TICK,
timerWorkerScript, timerWorkerScript,
} from './TimerWorker' } from './TimerWorker'
import { ProcessorType } from '.' import {
FACE_LANDMARKS_MODEL_PATH,
ProcessorType,
MEDIAPIPE_PATH_WASM,
} from '.'
const PROCESSING_WIDTH = 256 * 3 const PROCESSING_WIDTH = 256 * 3
const PROCESSING_HEIGHT = 144 * 3 const PROCESSING_HEIGHT = 144 * 3
@@ -128,13 +132,10 @@ export class FaceLandmarksProcessor implements TrackProcessor<Track.Kind> {
} }
async initFaceLandmarker() { async initFaceLandmarker() {
const vision = await FilesetResolver.forVisionTasks( const vision = await FilesetResolver.forVisionTasks(MEDIAPIPE_PATH_WASM)
'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision/wasm'
)
this.faceLandmarker = await FaceLandmarker.createFromOptions(vision, { this.faceLandmarker = await FaceLandmarker.createFromOptions(vision, {
baseOptions: { baseOptions: {
modelAssetPath: modelAssetPath: FACE_LANDMARKS_MODEL_PATH,
'https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/latest/face_landmarker.task',
delegate: 'GPU', delegate: 'GPU',
}, },
runningMode: 'VIDEO', runningMode: 'VIDEO',
@@ -1,13 +1,14 @@
import type { ProcessorOptions, Track } from 'livekit-client' import type { ProcessorOptions, Track } from 'livekit-client'
import { import {
BackgroundBlur,
ProcessorWrapper, ProcessorWrapper,
VirtualBackground, BackgroundProcessor,
} from '@livekit/track-processors' } from '@livekit/track-processors'
import { import {
type ProcessorConfig, type ProcessorConfig,
BackgroundProcessorInterface, BackgroundProcessorInterface,
ProcessorType, ProcessorType,
MEDIAPIPE_PATH_WASM,
SELFIE_SEGMENTER_MODEL_PATH,
} from '.' } from '.'
export class UnifiedBackgroundTrackProcessor implements BackgroundProcessorInterface { export class UnifiedBackgroundTrackProcessor implements BackgroundProcessorInterface {
@@ -20,10 +21,24 @@ export class UnifiedBackgroundTrackProcessor implements BackgroundProcessorInter
if (opts.type === 'virtual') { if (opts.type === 'virtual') {
this.processorType = ProcessorType.VIRTUAL this.processorType = ProcessorType.VIRTUAL
this.processor = VirtualBackground(opts.imagePath) this.processor = BackgroundProcessor({
mode: 'virtual-background',
imagePath: opts.imagePath,
assetPaths: {
tasksVisionFileSet: MEDIAPIPE_PATH_WASM,
modelAssetPath: SELFIE_SEGMENTER_MODEL_PATH,
},
})
} else if (opts.type === 'blur') { } else if (opts.type === 'blur') {
this.processorType = ProcessorType.BLUR this.processorType = ProcessorType.BLUR
this.processor = BackgroundBlur(opts.blurRadius) this.processor = BackgroundProcessor({
mode: 'background-blur',
blurRadius: opts.blurRadius,
assetPaths: {
tasksVisionFileSet: MEDIAPIPE_PATH_WASM,
modelAssetPath: SELFIE_SEGMENTER_MODEL_PATH,
},
})
} else { } else {
throw new Error( throw new Error(
'Must provide either imagePath for virtual background or blurRadius for blur' 'Must provide either imagePath for virtual background or blurRadius for blur'
@@ -4,6 +4,14 @@ import { BackgroundCustomProcessor } from './BackgroundCustomProcessor'
import { UnifiedBackgroundTrackProcessor } from './UnifiedBackgroundTrackProcessor' import { UnifiedBackgroundTrackProcessor } from './UnifiedBackgroundTrackProcessor'
import { FaceLandmarksOptions } from './FaceLandmarksProcessor' import { FaceLandmarksOptions } from './FaceLandmarksProcessor'
export const SELFIE_SEGMENTER_MODEL_PATH =
'/assets/mediapipe/models/selfie_segmenter_landscape.tflite'
export const FACE_LANDMARKS_MODEL_PATH =
'/assets/mediapipe/models/face_landmarker.task'
export const MEDIAPIPE_PATH_WASM = '/assets/mediapipe/wasm'
export enum ProcessorType { export enum ProcessorType {
BLUR = 'blur', BLUR = 'blur',
VIRTUAL = 'virtual', VIRTUAL = 'virtual',
@@ -1,23 +1,20 @@
import { Button } from '@/primitives' import { Button } from '@/primitives'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import type { Participant } from 'livekit-client' import type { Participant } from 'livekit-client'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
import { useMuteParticipants } from '@/features/rooms/api/muteParticipants' import { useMuteParticipants } from '@/features/rooms/api/muteParticipants'
import { RiMicOffLine } from '@remixicon/react' import { RiMicOffLine } from '@remixicon/react'
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { AdminOrOwnerOnly } from '@/features/rooms/components/AdminOrOwnerOnly'
type MuteEveryoneButtonProps = { type MuteEveryoneButtonProps = {
participants: Array<Participant> participants: Array<Participant>
} }
export const MuteEveryoneButton = ({ const MuteEveryoneButtonInner = ({ participants }: MuteEveryoneButtonProps) => {
participants,
}: MuteEveryoneButtonProps) => {
const { muteParticipants } = useMuteParticipants() const { muteParticipants } = useMuteParticipants()
const { t } = useTranslation('rooms') const { t } = useTranslation('rooms')
const isAdminOrOwner = useIsAdminOrOwner() if (!participants.length) return null
if (!isAdminOrOwner || !participants.length) return null
return ( return (
<Button <Button
@@ -36,3 +33,13 @@ export const MuteEveryoneButton = ({
</Button> </Button>
) )
} }
export const MuteEveryoneButton = ({
participants,
}: MuteEveryoneButtonProps) => {
return (
<AdminOrOwnerOnly>
<MuteEveryoneButtonInner participants={participants} />
</AdminOrOwnerOnly>
)
}
@@ -37,7 +37,7 @@ export function Chat({ ...props }: ChatProps) {
const room = useRoomContext() const room = useRoomContext()
const { send, chatMessages, isSending } = useChat() const { send, chatMessages, isSending } = useChat()
const { isChatOpen } = useSidePanel() const { isChatOpen, isSidePanelOpen } = useSidePanel()
const chatSnap = useSnapshot(chatStore) const chatSnap = useSnapshot(chatStore)
// Keep track of the element that opened the chat so we can restore focus // Keep track of the element that opened the chat so we can restore focus
@@ -51,6 +51,7 @@ export function Chat({ ...props }: ChatProps) {
}) })
}, },
preventScroll: true, preventScroll: true,
shouldRestoreOnClose: () => !isSidePanelOpen,
}) })
// Use useParticipants hook to trigger a re-render when the participant list changes. // Use useParticipants hook to trigger a re-render when the participant list changes.
@@ -0,0 +1,12 @@
/**
* Builds a radial gradient from the participant's avatar color:
* brighter in the center (where the avatar sits), darker at the edges.
* Mixed in oklch so the color stays vivid as it darkens instead of greying out.
*/
export const getParticipantBackgroundGradient = (color: string): string =>
`radial-gradient(circle at 50% 45%,
color-mix(in oklch, ${color} 82%, white) 0%,
color-mix(in oklch, ${color} 90%, white) 8%,
${color} 35%,
color-mix(in oklch, ${color} 85%, black) 65%,
color-mix(in oklch, ${color} 65%, black) 100%)`
@@ -8,8 +8,9 @@ import { HStack } from '@/styled-system/jsx'
import { useState } from 'react' import { useState } from 'react'
import { LoginButton } from '@/components/LoginButton' import { LoginButton } from '@/components/LoginButton'
import { useRenameParticipant } from '@/features/rooms/api/renameParticipant' import { useRenameParticipant } from '@/features/rooms/api/renameParticipant'
import { saveUsername } from '@/stores/userChoices' import { saveUsername } from '@/stores/user'
import { logout } from '@/features/auth/utils/logout' import { logout } from '@/features/auth/utils/logout'
import { useConfig } from '@/api/useConfig'
export type AccountTabProps = Pick<DialogProps, 'onOpenChange'> & export type AccountTabProps = Pick<DialogProps, 'onOpenChange'> &
Pick<TabPanelProps, 'id'> Pick<TabPanelProps, 'id'>
@@ -17,6 +18,7 @@ export type AccountTabProps = Pick<DialogProps, 'onOpenChange'> &
export const AccountTab = ({ id, onOpenChange }: AccountTabProps) => { export const AccountTab = ({ id, onOpenChange }: AccountTabProps) => {
const { t } = useTranslation('settings') const { t } = useTranslation('settings')
const room = useRoomContext() const room = useRoomContext()
const { data } = useConfig()
const { user, isLoggedIn } = useUser() const { user, isLoggedIn } = useUser()
const { renameParticipant } = useRenameParticipant() const { renameParticipant } = useRenameParticipant()
@@ -45,15 +47,17 @@ export const AccountTab = ({ id, onOpenChange }: AccountTabProps) => {
return ( return (
<TabPanel padding={'md'} flex id={id}> <TabPanel padding={'md'} flex id={id}>
<H lvl={2}>{t('account.heading')}</H> <H lvl={2}>{t('account.heading')}</H>
<Field {(!isLoggedIn || data?.authenticated_users_can_edit_display_name) && (
type="text" <Field
label={t('account.nameLabel')} type="text"
value={name} label={t('account.nameLabel')}
onChange={setName} value={name}
validate={(value) => { onChange={setName}
return !value ? <p>{t('account.nameError')}</p> : null validate={(value) => {
}} return !value ? <p>{t('account.nameError')}</p> : null
/> }}
/>
)}
<H lvl={2}>{t('account.authentication')}</H> <H lvl={2}>{t('account.authentication')}</H>
{isLoggedIn ? ( {isLoggedIn ? (
<> <>
+24 -1
View File
@@ -6,6 +6,10 @@ export type RestoreFocusOptions = {
onClosed?: () => void onClosed?: () => void
restoreFocusRaf?: boolean restoreFocusRaf?: boolean
preventScroll?: boolean preventScroll?: boolean
/** When the panel stays open but its content changes, update the restore target. */
activeKey?: string | null
/** Return false to skip restoring focus on close (e.g. when switching to another panel). */
shouldRestoreOnClose?: () => boolean
} }
/** /**
@@ -22,9 +26,12 @@ export function useRestoreFocus(
onClosed, onClosed,
restoreFocusRaf = false, restoreFocusRaf = false,
preventScroll = true, preventScroll = true,
activeKey,
shouldRestoreOnClose,
} = options } = options
const prevIsOpenRef = useRef(false) const prevIsOpenRef = useRef(false)
const prevActiveKeyRef = useRef(activeKey)
const triggerRef = useRef<HTMLElement | null>(null) const triggerRef = useRef<HTMLElement | null>(null)
useEffect(() => { useEffect(() => {
@@ -37,25 +44,41 @@ export function useRestoreFocus(
onOpened?.() onOpened?.()
} }
// Panel switched while staying open
if (wasOpen && isOpen && activeKey !== prevActiveKeyRef.current) {
const activeEl = document.activeElement as HTMLElement | null
triggerRef.current = resolveTrigger ? resolveTrigger(activeEl) : activeEl
onOpened?.()
}
// Just closed // Just closed
if (wasOpen && !isOpen) { if (wasOpen && !isOpen) {
const trigger = triggerRef.current const trigger = triggerRef.current
if (trigger && document.contains(trigger)) { const shouldRestore =
(shouldRestoreOnClose?.() ?? true) &&
trigger &&
document.contains(trigger)
if (shouldRestore) {
const focus = () => trigger.focus({ preventScroll }) const focus = () => trigger.focus({ preventScroll })
if (restoreFocusRaf) requestAnimationFrame(focus) if (restoreFocusRaf) requestAnimationFrame(focus)
else focus() else focus()
} }
triggerRef.current = null triggerRef.current = null
onClosed?.() onClosed?.()
} }
prevIsOpenRef.current = isOpen prevIsOpenRef.current = isOpen
prevActiveKeyRef.current = activeKey
}, [ }, [
isOpen, isOpen,
activeKey,
onClosed, onClosed,
onOpened, onOpened,
preventScroll, preventScroll,
resolveTrigger, resolveTrigger,
restoreFocusRaf, restoreFocusRaf,
shouldRestoreOnClose,
]) ])
} }
+37
View File
@@ -0,0 +1,37 @@
import { proxy, subscribe } from 'valtio'
import { STORAGE_KEYS } from '@/utils/storageKeys'
type State = {
username: string
}
const DEFAULT_STATE = {
username: '',
}
function getUserState(): State {
try {
const stored = localStorage.getItem(STORAGE_KEYS.USER)
if (!stored) return DEFAULT_STATE
const parsed = JSON.parse(stored)
return {
...parsed,
}
} catch (error: unknown) {
console.error(
'[UserPreferencesStore] Failed to parse stored settings:',
error
)
return DEFAULT_STATE
}
}
export const userStore = proxy<State>(getUserState())
subscribe(userStore, () => {
localStorage.setItem(STORAGE_KEYS.USER, JSON.stringify(userStore))
})
export const saveUsername = (username: string) => {
userStore.username = username
}
+8 -6
View File
@@ -12,7 +12,7 @@ import { VideoQuality } from 'livekit-client'
export type VideoResolution = 'h720' | 'h360' | 'h180' export type VideoResolution = 'h720' | 'h360' | 'h180'
export type LocalUserChoices = LocalUserChoicesLK & { export type LocalUserChoices = Omit<LocalUserChoicesLK, 'username'> & {
processorConfig?: ProcessorConfig processorConfig?: ProcessorConfig
noiseReductionEnabled?: boolean noiseReductionEnabled?: boolean
audioOutputDeviceId?: string audioOutputDeviceId?: string
@@ -32,7 +32,13 @@ function getUserChoicesState(): LocalUserChoices {
export const userChoicesStore = proxy<LocalUserChoices>(getUserChoicesState()) export const userChoicesStore = proxy<LocalUserChoices>(getUserChoicesState())
subscribe(userChoicesStore, () => { subscribe(userChoicesStore, () => {
saveUserChoices(userChoicesStore, false) // TEMPORARY: cast needed because our store omits `username`, which we no
// longer persis in this store, while LiveKit's `saveUserChoices` still expects the full
// `LocalUserChoices` shape. `username` ends up `undefined` in the saved
// object, which `saveUserChoices` tolerates at runtime.
// We are migrating away from LiveKit's persistence logic to our own store
// handling for more control — this cast can be removed once that lands.
saveUserChoices(userChoicesStore as LocalUserChoicesLK, false)
}) })
// we run some logic on store loading to check if the processor config is still valid // we run some logic on store loading to check if the processor config is still valid
@@ -90,10 +96,6 @@ export const saveVideoSubscribeQuality = (quality: VideoQuality) => {
userChoicesStore.videoSubscribeQuality = quality userChoicesStore.videoSubscribeQuality = quality
} }
export const saveUsername = (username: string) => {
userChoicesStore.username = username
}
export const saveNoiseReductionEnabled = (enabled: boolean) => { export const saveNoiseReductionEnabled = (enabled: boolean) => {
userChoicesStore.noiseReductionEnabled = enabled userChoicesStore.noiseReductionEnabled = enabled
} }
+1
View File
@@ -4,5 +4,6 @@
export const STORAGE_KEYS = { export const STORAGE_KEYS = {
NOTIFICATIONS: 'app_notification_settings', NOTIFICATIONS: 'app_notification_settings',
USER_PREFERENCES: 'app_user_preferences', USER_PREFERENCES: 'app_user_preferences',
USER: 'app_user',
ACCESSIBILITY: 'app_accessibility_settings', ACCESSIBILITY: 'app_accessibility_settings',
} as const } as const
+22 -7
View File
@@ -2,6 +2,7 @@ import { defineConfig, loadEnv } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import { visualizer } from 'rollup-plugin-visualizer' import { visualizer } from 'rollup-plugin-visualizer'
import svgr from 'vite-plugin-svgr' import svgr from 'vite-plugin-svgr'
import { viteStaticCopy } from 'vite-plugin-static-copy'
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig(({ mode }) => { export default defineConfig(({ mode }) => {
@@ -11,18 +12,32 @@ export default defineConfig(({ mode }) => {
react(), react(),
svgr({ svgr({
svgrOptions: { svgrOptions: {
replaceAttrValues: { '#000': 'currentColor', '#000000': 'currentColor', '#1f1f1f': 'currentColor' }, replaceAttrValues: {
'#000': 'currentColor',
'#000000': 'currentColor',
'#1f1f1f': 'currentColor',
},
}, },
}), }),
env.VITE_ANALYZE === 'true' && visualizer({ viteStaticCopy({
open: true, targets: [
filename: 'rollup-plugin-visualizer/stats.html', {
gzipSize: true, src: 'node_modules/@mediapipe/tasks-vision/wasm/*',
brotliSize: true, dest: 'assets/mediapipe/wasm',
rename: { stripBase: 4 },
},
],
}), }),
env.VITE_ANALYZE === 'true' &&
visualizer({
open: true,
filename: 'rollup-plugin-visualizer/stats.html',
gzipSize: true,
brotliSize: true,
}),
], ],
resolve: { resolve: {
tsconfigPaths: true tsconfigPaths: true,
}, },
build: { build: {
sourcemap: env.VITE_BUILD_SOURCEMAP === 'true', sourcemap: env.VITE_BUILD_SOURCEMAP === 'true',
+25 -4
View File
@@ -28,7 +28,25 @@ _summaryEnvVars: &summaryEnvVars
AWS_S3_ACCESS_KEY_ID: meet AWS_S3_ACCESS_KEY_ID: meet
AWS_S3_SECRET_ACCESS_KEY: password AWS_S3_SECRET_ACCESS_KEY: password
AWS_S3_SECURE_ACCESS: False AWS_S3_SECURE_ACCESS: False
AUTHORIZED_TENANTS: '[{"id": "dictaphone", "api_key": "dictaphone_token", "webhook_url": "http://dictaphone-backend.dictaphone.svc.cluster.local/api/v1.0/ai-jobs/webhook/", "webhook_api_key": "token_summary"}]' AUTHORIZED_TENANTS: >
[
{
"id": "dictaphone",
"api_key": "dictaphone_token",
"webhook_url": "http://dictaphone-backend.dictaphone.svc.cluster.local/api/v1.0/ai-jobs/webhook/",
"webhook_api_key": "token_summary",
"allowed_push_to_docs": false
},
{
"id": "meet",
"api_key": "password",
"webhook_url": "https://meet.127.0.0.1.nip.io/api/v1.0/recordings/external-process-hook/",
"webhook_api_key": "webhook-password",
"allowed_push_to_docs": false
}
]
SSL_CERT_FILE: /usr/local/lib/python3.13/site-packages/certifi/cacert.pem
IS_DOCS_INTEGRATION_ENABLED: false
WHISPERX_API_KEY: WHISPERX_API_KEY:
secretKeyRef: secretKeyRef:
name: secret-dev name: secret-dev
@@ -169,9 +187,12 @@ backend:
RECORDING_ENABLE: True RECORDING_ENABLE: True
RECORDING_STORAGE_EVENT_ENABLE: True RECORDING_STORAGE_EVENT_ENABLE: True
RECORDING_STORAGE_EVENT_TOKEN: password RECORDING_STORAGE_EVENT_TOKEN: password
SUMMARY_SERVICE_ENDPOINT: http://meet-summary:80/api/v1/tasks/ SUMMARY_SERVICE_ENDPOINT: http://meet-summary:80/api/v2/async-jobs/transcribe/
SUMMARY_SERVICE_API_TOKEN: password SUMMARY_SERVICE_API_TOKEN: password
SUMMARY_SERVICE_WEBHOOK_API_TOKEN: webhook-password
RECORDING_DOWNLOAD_BASE_URL: https://meet.127.0.0.1.nip.io/recording RECORDING_DOWNLOAD_BASE_URL: https://meet.127.0.0.1.nip.io/recording
OIDC_USERINFO_FULLNAME_FIELDS: first_name, last_name
OIDC_USERINFO_SHORTNAME_FIELD: first_name
migrate: migrate:
command: command:
@@ -291,7 +312,7 @@ celeryTranscribe:
- "--pool=solo" - "--pool=solo"
- "--loglevel=info" - "--loglevel=info"
- "-Q" - "-Q"
- "transcribe-queue,transcribe-queue-v2" - "transcribe-queue-v2"
celerySummarize: celerySummarize:
replicas: 1 replicas: 1
@@ -308,7 +329,7 @@ celerySummarize:
- "--pool=solo" - "--pool=solo"
- "--loglevel=info" - "--loglevel=info"
- "-Q" - "-Q"
- "summarize-queue,summarize-queue-v2" - "summarize-queue-v2"
celerySummaryBackend: celerySummaryBackend:
replicas: 1 replicas: 1
+1 -1
View File
@@ -1,4 +1,4 @@
apiVersion: v2 apiVersion: v2
type: application type: application
name: meet name: meet
version: 0.0.26 version: 0.0.27
+170 -1
View File
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?> <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<OfficeApp xmlns="http://schemas.microsoft.com/office/appforoffice/1.1" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:bt="http://schemas.microsoft.com/office/officeappbasictypes/1.0" xmlns:mailappor="http://schemas.microsoft.com/office/mailappversionoverrides/1.0" xsi:type="MailApp"> <OfficeApp xmlns="http://schemas.microsoft.com/office/appforoffice/1.1" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:bt="http://schemas.microsoft.com/office/officeappbasictypes/1.0" xmlns:mailappor="http://schemas.microsoft.com/office/mailappversionoverrides/1.0" xsi:type="MailApp">
<Id>{{ .id }}</Id> <Id>{{ .id }}</Id>
<Version>0.0.2.0</Version> <Version>1.0.0.0</Version>
<ProviderName>{{ .appName }}</ProviderName> <ProviderName>{{ .appName }}</ProviderName>
<DefaultLocale>fr-FR</DefaultLocale> <DefaultLocale>fr-FR</DefaultLocale>
<DisplayName DefaultValue="{{ .appName }}"/> <DisplayName DefaultValue="{{ .appName }}"/>
@@ -205,5 +205,174 @@
</bt:String> </bt:String>
</bt:LongStrings> </bt:LongStrings>
</Resources> </Resources>
<!-- ─── V1.1 override: MUST be nested inside the V1.0 block, after Resources ─── -->
<VersionOverrides xmlns="http://schemas.microsoft.com/office/mailappversionoverrides/1.1" xsi:type="VersionOverridesV1_1">
<Requirements>
<bt:Sets DefaultMinVersion="1.8">
<bt:Set Name="Mailbox"/>
</bt:Sets>
</Requirements>
<Hosts>
<Host xsi:type="MailHost">
<DesktopFormFactor>
<SupportsSharedFolders>true</SupportsSharedFolders>
<FunctionFile resid="Commands.Url"/>
<!-- ─── Mail: Read ─────────────────────────────────────────── -->
<ExtensionPoint xsi:type="MessageReadCommandSurface">
<OfficeTab id="TabDefault">
<Group id="msgReadGroup">
<Label resid="GroupLabel"/>
<Control xsi:type="Button" id="msgReadOpenPaneButton">
<Label resid="TaskpaneButton.Label"/>
<Supertip>
<Title resid="TaskpaneButton.Label"/>
<Description resid="TaskpaneButton.Tooltip"/>
</Supertip>
<Icon>
<bt:Image size="16" resid="Icon.16x16"/>
<bt:Image size="32" resid="Icon.32x32"/>
<bt:Image size="80" resid="Icon.80x80"/>
</Icon>
<Action xsi:type="ShowTaskpane">
<SourceLocation resid="Taskpane.Url"/>
</Action>
</Control>
</Group>
</OfficeTab>
</ExtensionPoint>
<!-- ─── Mail: Compose ─────────────────────────────────────── -->
<ExtensionPoint xsi:type="MessageComposeCommandSurface">
<OfficeTab id="TabDefault">
<Group id="msgComposeGroup">
<Label resid="GroupLabel"/>
<Control xsi:type="Button" id="msgComposeGenerateLinkButton">
<Label resid="GenerateLink.Label"/>
<Supertip>
<Title resid="GenerateLink.Label"/>
<Description resid="GenerateLink.Tooltip"/>
</Supertip>
<Icon>
<bt:Image size="16" resid="Icon.16x16"/>
<bt:Image size="32" resid="Icon.32x32"/>
<bt:Image size="80" resid="Icon.80x80"/>
</Icon>
<Action xsi:type="ExecuteFunction">
<FunctionName>generateMeetingLinkFromMail</FunctionName>
</Action>
</Control>
<Control xsi:type="Button" id="msgComposeOpenPaneButton">
<Label resid="TaskpaneButton.Label"/>
<Supertip>
<Title resid="TaskpaneButton.Label"/>
<Description resid="TaskpaneButton.Tooltip"/>
</Supertip>
<Icon>
<bt:Image size="16" resid="Settings.16x16"/>
<bt:Image size="32" resid="Settings.32x32"/>
<bt:Image size="80" resid="Settings.80x80"/>
</Icon>
<Action xsi:type="ShowTaskpane">
<SourceLocation resid="Taskpane.Url"/>
</Action>
</Control>
</Group>
</OfficeTab>
</ExtensionPoint>
<!-- ─── Calendar: Compose (New/Edit appointment) ──────────── -->
<ExtensionPoint xsi:type="AppointmentOrganizerCommandSurface">
<OfficeTab id="TabDefault">
<Group id="apptComposeGroup">
<Label resid="GroupLabel"/>
<Control xsi:type="Button" id="apptGenerateLinkButton">
<Label resid="GenerateLink.Label"/>
<Supertip>
<Title resid="GenerateLink.Label"/>
<Description resid="GenerateLink.Tooltip"/>
</Supertip>
<Icon>
<bt:Image size="16" resid="Icon.16x16"/>
<bt:Image size="32" resid="Icon.32x32"/>
<bt:Image size="80" resid="Icon.80x80"/>
</Icon>
<Action xsi:type="ExecuteFunction">
<FunctionName>generateMeetingLinkFromCalendar</FunctionName>
</Action>
</Control>
<Control xsi:type="Button" id="apptOpenSettingsButton">
<Label resid="OpenSettings.Label"/>
<Supertip>
<Title resid="OpenSettings.Label"/>
<Description resid="OpenSettings.Tooltip"/>
</Supertip>
<Icon>
<bt:Image size="16" resid="Settings.16x16"/>
<bt:Image size="32" resid="Settings.32x32"/>
<bt:Image size="80" resid="Settings.80x80"/>
</Icon>
<Action xsi:type="ShowTaskpane">
<SourceLocation resid="Taskpane.Url"/>
</Action>
</Control>
</Group>
</OfficeTab>
</ExtensionPoint>
</DesktopFormFactor>
</Host>
</Hosts>
<Resources>
<bt:Images>
<bt:Image id="Settings.16x16" DefaultValue="{{ .baseUrl }}/assets/settings-16.png"/>
<bt:Image id="Settings.32x32" DefaultValue="{{ .baseUrl }}/assets/settings-32.png"/>
<bt:Image id="Settings.80x80" DefaultValue="{{ .baseUrl }}/assets/settings-80.png"/>
<bt:Image id="Add.16x16" DefaultValue="{{ .baseUrl }}/assets/add-16.png"/>
<bt:Image id="Add.32x32" DefaultValue="{{ .baseUrl }}/assets/add-32.png"/>
<bt:Image id="Add.80x80" DefaultValue="{{ .baseUrl }}/assets/add-80.png"/>
<bt:Image id="Icon.16x16" DefaultValue="{{ .baseUrl }}/assets/icon-16.png"/>
<bt:Image id="Icon.32x32" DefaultValue="{{ .baseUrl }}/assets/icon-32.png"/>
<bt:Image id="Icon.80x80" DefaultValue="{{ .baseUrl }}/assets/icon-80.png"/>
</bt:Images>
<bt:Urls>
<bt:Url id="Commands.Url" DefaultValue="{{ .baseUrl }}/commands.html"/>
<bt:Url id="Taskpane.Url" DefaultValue="{{ .baseUrl }}/taskpane.html"/>
</bt:Urls>
<bt:ShortStrings>
<!-- Default (French) -->
<bt:String id="GroupLabel" DefaultValue="{{ .appName }}"/>
<bt:String id="GenerateLink.Label" DefaultValue="Ajouter un lien {{ .appName }}">
<bt:Override Locale="en-US" Value="Add a {{ .appName }} link"/>
<bt:Override Locale="de-DE" Value="{{ .appName }}-Link hinzufügen"/>
</bt:String>
<bt:String id="TaskpaneButton.Label" DefaultValue="Ouvrir les paramètres">
<bt:Override Locale="en-US" Value="Open settings"/>
<bt:Override Locale="de-DE" Value="Einstellungen öffnen"/>
</bt:String>
<bt:String id="OpenSettings.Label" DefaultValue="Paramètres">
<bt:Override Locale="en-US" Value="Settings"/>
<bt:Override Locale="de-DE" Value="Einstellungen"/>
</bt:String>
</bt:ShortStrings>
<bt:LongStrings>
<bt:String id="GenerateLink.Tooltip" DefaultValue="Génère un lien de réunion {{ .appName }} et l'insère dans l'événement.">
<bt:Override Locale="de-DE" Value="Generiert einen {{ .appName }}-Besprechungslink und fügt ihn in den Termin ein."/>
<bt:Override Locale="en-US" Value="Generates a {{ .appName }} meeting link and inserts it into the item."/>
</bt:String>
<bt:String id="TaskpaneButton.Tooltip" DefaultValue="Ouvre les paramètres de connexion {{ .appName }}.">
<bt:Override Locale="de-DE" Value="Öffnet die {{ .appName }}-Verbindungseinstellungen."/>
<bt:Override Locale="en-US" Value="Opens the {{ .appName }} connection settings."/>
</bt:String>
<bt:String id="OpenSettings.Tooltip" DefaultValue="Ouvre les paramètres de connexion {{ .appName }}.">
<bt:Override Locale="de-DE" Value="Öffnet die {{ .appName }}-Verbindungseinstellungen."/>
<bt:Override Locale="en-US" Value="Opens the {{ .appName }} connection settings."/>
</bt:String>
</bt:LongStrings>
</Resources>
</VersionOverrides>
</VersionOverrides> </VersionOverrides>
</OfficeApp> </OfficeApp>
+4
View File
@@ -1170,6 +1170,8 @@ agentMetadata:
## @extra agentMetadata.envVars.FROM_SECRET.secretKeyRef.key Key within a Secret when configuring env vars from a Secret ## @extra agentMetadata.envVars.FROM_SECRET.secretKeyRef.key Key within a Secret when configuring env vars from a Secret
## @extra agentMetadata.envVars.DEEPGRAM_STT_MODEL Deepgram model to use for speech-to-text (default: nova-3) ## @extra agentMetadata.envVars.DEEPGRAM_STT_MODEL Deepgram model to use for speech-to-text (default: nova-3)
## @extra agentMetadata.envVars.DEEPGRAM_STT_LANGUAGE Language code for transcription or 'multi' for automatic multilingual support with real-time code-switching (default: multi, supports: en, es, fr, de, hi, ru, pt, ja, it, nl) ## @extra agentMetadata.envVars.DEEPGRAM_STT_LANGUAGE Language code for transcription or 'multi' for automatic multilingual support with real-time code-switching (default: multi, supports: en, es, fr, de, hi, ru, pt, ja, it, nl)
## @extra agentMetadata.envVars.SENTRY_DSN Sentry DSN to enable error reporting (disabled when unset)
## @extra agentMetadata.envVars.SENTRY_ENVIRONMENT Environment name reported to Sentry (e.g. production, staging)
## @skip agentMetadata.envVars ## @skip agentMetadata.envVars
envVars: envVars:
<<: *commonEnvVars <<: *commonEnvVars
@@ -1249,6 +1251,8 @@ agentSubtitles:
## @extra agentSubtitles.envVars.FROM_SECRET.secretKeyRef.key Key within a Secret when configuring env vars from a Secret ## @extra agentSubtitles.envVars.FROM_SECRET.secretKeyRef.key Key within a Secret when configuring env vars from a Secret
## @extra agentSubtitles.envVars.DEEPGRAM_STT_MODEL Deepgram model to use for speech-to-text (default: nova-3) ## @extra agentSubtitles.envVars.DEEPGRAM_STT_MODEL Deepgram model to use for speech-to-text (default: nova-3)
## @extra agentSubtitles.envVars.DEEPGRAM_STT_LANGUAGE Language code for transcription or 'multi' for automatic multilingual support with real-time code-switching (default: multi, supports: en, es, fr, de, hi, ru, pt, ja, it, nl) ## @extra agentSubtitles.envVars.DEEPGRAM_STT_LANGUAGE Language code for transcription or 'multi' for automatic multilingual support with real-time code-switching (default: multi, supports: en, es, fr, de, hi, ru, pt, ja, it, nl)
## @extra agentSubtitles.envVars.SENTRY_DSN Sentry DSN to enable error reporting (disabled when unset)
## @extra agentSubtitles.envVars.SENTRY_ENVIRONMENT Environment name reported to Sentry (e.g. production, staging)
## @skip agentSubtitles.envVars ## @skip agentSubtitles.envVars
envVars: envVars:
<<: *commonEnvVars <<: *commonEnvVars
+1 -1
View File
@@ -6,4 +6,4 @@ DIR_MAILS="../backend/core/templates/mail/html/"
if [ ! -d "${DIR_MAILS}" ]; then if [ ! -d "${DIR_MAILS}" ]; then
mkdir -p "${DIR_MAILS}"; mkdir -p "${DIR_MAILS}";
fi fi
mjml mjml/*.mjml -o "${DIR_MAILS}"; mjml mjml/*.mjml -o "${DIR_MAILS}" --config.allowIncludes true;
+7 -2
View File
@@ -2,6 +2,11 @@
<mj-include path="./partial/header.mjml" /> <mj-include path="./partial/header.mjml" />
<mj-body mj-class="bg--blue-100"> <mj-body mj-class="bg--blue-100">
<!--
We load django tags here so they appear in the body of the HTML output.
This ensures html-to-text also includes them in the plain text template.
-->
<mj-raw>{% load i18n static extra_tags %}</mj-raw>
<mj-wrapper css-class="wrapper" padding="0 40px 40px 40px"> <mj-wrapper css-class="wrapper" padding="0 40px 40px 40px">
<mj-section css-class="wrapper-logo"> <mj-section css-class="wrapper-logo">
<mj-column> <mj-column>
@@ -59,7 +64,7 @@
</mj-column> </mj-column>
</mj-section> </mj-section>
</mj-wrapper> </mj-wrapper>
</mj-body>
<mj-include path="./partial/footer.mjml" /> <mj-include path="./partial/footer.mjml" />
</mj-body>
</mjml> </mjml>
+4 -9
View File
@@ -1,21 +1,16 @@
<mj-head> <mj-head>
<mj-title>{{ title }}</mj-title> <mj-title>{{ title }}</mj-title>
<mj-preview> <mj-preview>{{ title }}</mj-preview>
<!-- <mj-font name="Roboto" href="https://fonts.bunny.net/css?family=roboto:400,700,900" />
We load django tags here, in this way there are put within the body in html output
so the html-to-text command includes it within its output
-->
{% load i18n static extra_tags %}
{{ title }}
</mj-preview>
<mj-attributes> <mj-attributes>
<mj-font name="Roboto" href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;700;900&display=swap" />
<mj-all <mj-all
font-family="Roboto, -apple-system, BlinkMacSystemFont, 'Segoe UI', Oxygen, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif" font-family="Roboto, -apple-system, BlinkMacSystemFont, 'Segoe UI', Oxygen, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif"
font-size="16px" font-size="16px"
line-height="1.5em" line-height="1.5em"
color="#3A3A3A" color="#3A3A3A"
/> />
<mj-text font-family="Roboto, sans-serif" />
<mj-button font-family="Roboto, sans-serif" />
</mj-attributes> </mj-attributes>
<mj-style> <mj-style>
/* Reset */ /* Reset */
+5
View File
@@ -2,6 +2,11 @@
<mj-include path="./partial/header.mjml" /> <mj-include path="./partial/header.mjml" />
<mj-body mj-class="bg--blue-100"> <mj-body mj-class="bg--blue-100">
<!--
We load django tags here so they appear in the body of the HTML output.
This ensures html-to-text also includes them in the plain text template.
-->
<mj-raw>{% load i18n static extra_tags %}</mj-raw>
<mj-wrapper css-class="wrapper" padding="5px 25px 0px 25px"> <mj-wrapper css-class="wrapper" padding="5px 25px 0px 25px">
<mj-section css-class="wrapper-logo"> <mj-section css-class="wrapper-logo">
<mj-column> <mj-column>
+1904 -942
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -1,11 +1,11 @@
{ {
"name": "mail_mjml", "name": "mail_mjml",
"version": "1.22.0", "version": "1.24.0",
"description": "An util to generate html and text django's templates from mjml templates", "description": "An util to generate html and text django's templates from mjml templates",
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@html-to/text-cli": "0.5.4", "@html-to/text-cli": "0.6.0",
"mjml": "4.18.0" "mjml": "5.4.0"
}, },
"private": true, "private": true,
"scripts": { "scripts": {
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "sdk", "name": "sdk",
"version": "1.22.0", "version": "1.24.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "sdk", "name": "sdk",
"version": "1.22.0", "version": "1.24.0",
"license": "ISC", "license": "ISC",
"workspaces": [ "workspaces": [
"./library", "./library",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "sdk", "name": "sdk",
"version": "1.22.0", "version": "1.24.0",
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"description": "", "description": "",
+1 -1
View File
@@ -1,7 +1,7 @@
[project] [project]
name = "summary" name = "summary"
version = "1.22.0" version = "1.24.0"
dependencies = [ dependencies = [
"fastapi[standard]>=0.105.0", "fastapi[standard]>=0.105.0",
"uvicorn>=0.24.0", "uvicorn>=0.24.0",
+1 -4
View File
@@ -2,11 +2,8 @@
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from summary.api.route import tasks, tasks_v2 from summary.api.route import tasks_v2
from summary.core.security import verify_tenant_api_key from summary.core.security import verify_tenant_api_key
api_router_v1 = APIRouter(dependencies=[Depends(verify_tenant_api_key)])
api_router_v1.include_router(tasks.router_tasks_v1, tags=["tasks"])
api_router_v2 = APIRouter(dependencies=[Depends(verify_tenant_api_key)]) api_router_v2 = APIRouter(dependencies=[Depends(verify_tenant_api_key)])
api_router_v2.include_router(tasks_v2.router_tasks_v2, tags=["tasks"]) api_router_v2.include_router(tasks_v2.router_tasks_v2, tags=["tasks"])
-79
View File
@@ -1,79 +0,0 @@
"""API routes related to application tasks."""
import time
from typing import Optional
from celery.result import AsyncResult
from fastapi import APIRouter
from pydantic import BaseModel, field_validator
from summary.core.celery_worker import (
process_audio_transcribe_summarize_v2,
)
from summary.core.config import get_settings
settings = get_settings()
class TranscribeSummarizeTaskCreation(BaseModel):
"""Transcription and summarization parameters."""
owner_id: str
recording_filename: str
metadata_filename: Optional[str] = None
email: str
sub: str
version: Optional[int] = 2
room: Optional[str]
owner_timezone: Optional[str]
language: Optional[str]
download_link: Optional[str]
context_language: Optional[str] = None
recording_start_at: Optional[str] = None
recording_end_at: Optional[str] = None
@field_validator("language")
@classmethod
def validate_language(cls, v):
"""Validate 'language' parameter."""
if v is not None and v not in settings.whisperx_allowed_languages:
raise ValueError(
f"Language '{v}' is not allowed. "
f"Allowed languages: {', '.join(settings.whisperx_allowed_languages)}"
)
return v
router_tasks_v1 = APIRouter(prefix="/tasks")
@router_tasks_v1.post("/")
async def create_transcribe_summarize_task(request: TranscribeSummarizeTaskCreation):
"""Create a transcription and summarization task."""
task = process_audio_transcribe_summarize_v2.apply_async(
args=[
request.owner_id,
request.recording_filename,
request.metadata_filename,
request.email,
request.sub,
time.time(),
request.room,
request.owner_timezone,
request.language,
request.download_link,
request.context_language,
request.recording_start_at,
request.recording_end_at,
],
queue=settings.transcribe_queue,
)
return {"id": task.id, "message": "Task created"}
@router_tasks_v1.get("/{task_id}")
async def get_task_status(task_id: str):
"""Check task status by ID."""
task = AsyncResult(task_id)
return {"id": task_id, "status": task.status}
+64 -8
View File
@@ -1,15 +1,19 @@
"""API routes related to application tasks (V2 / tenant friendly).""" """API routes related to application tasks (V2 / tenant friendly)."""
from celery.result import AsyncResult import logging
from fastapi import APIRouter, Depends, HTTPException, Request from datetime import datetime, timezone
from celery.result import AsyncResult
from fastapi import APIRouter, Depends, HTTPException, Request, status
from summary.core.analytics import get_analytics
from summary.core.celery_worker import ( from summary.core.celery_worker import (
celery, celery,
process_audio_transcribe_v2_task, process_audio_transcribe_v2_task,
summarize_v2_task, summarize_v2_task,
) )
from summary.core.config import AuthorizedTenant from summary.core.config import AuthorizedTenant, get_settings
from summary.core.models import SummarizeTaskV2Request, TranscribeTaskV2Request from summary.core.models import SummarizeTaskApiRequest, TranscribeTaskApiRequest
from summary.core.security import verify_tenant_api_key_v2 from summary.core.security import verify_tenant_api_key_v2
from summary.core.shared_models import ( from summary.core.shared_models import (
SummarizeWebhookFailurePayload, SummarizeWebhookFailurePayload,
@@ -20,17 +24,51 @@ from summary.core.shared_models import (
TranscribeWebhookSuccessPayload, TranscribeWebhookSuccessPayload,
) )
logger = logging.getLogger(__name__)
router_tasks_v2 = APIRouter() router_tasks_v2 = APIRouter()
analytics = get_analytics()
settings = get_settings()
@router_tasks_v2.post("/async-jobs/transcribe") @router_tasks_v2.post("/async-jobs/transcribe")
async def create_transcribe_task_v2( async def create_transcribe_task_v2(
request: TranscribeTaskV2Request, request: TranscribeTaskApiRequest,
request_tenant: AuthorizedTenant = Depends(verify_tenant_api_key_v2), request_tenant: AuthorizedTenant = Depends(verify_tenant_api_key_v2),
): ):
"""Create a transcription task.""" """Create a transcription task."""
if (
request.push_to_docs_config is not None
and not request_tenant.allowed_push_to_docs
):
logger.warning(
"Push to docs is not allowed for this tenant (%s).", request_tenant.id
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Push to docs is not allowed for this tenant.",
)
task = process_audio_transcribe_v2_task.apply_async( task = process_audio_transcribe_v2_task.apply_async(
args=[{**request.model_dump(), "tenant_id": request_tenant.id}] args=[
{
**request.model_dump(),
"tenant_id": request_tenant.id,
"received_at": datetime.now(timezone.utc),
}
]
)
properties = {}
if request.user_email:
properties["$set"] = {"email": request.user_email}
# We track the request, this also properly initializes the user in the
# analytics system, so that later feature flags work properly
analytics.capture(
settings.posthog_transcript_request,
request.user_sub,
properties=properties,
) )
return TranscribeWebhookPendingPayload(job_id=task.id).model_dump() return TranscribeWebhookPendingPayload(job_id=task.id).model_dump()
@@ -38,14 +76,32 @@ async def create_transcribe_task_v2(
@router_tasks_v2.post("/async-jobs/summarize") @router_tasks_v2.post("/async-jobs/summarize")
async def create_summarize_task_v2( async def create_summarize_task_v2(
request: SummarizeTaskV2Request, request: SummarizeTaskApiRequest,
request_tenant: AuthorizedTenant = Depends(verify_tenant_api_key_v2), request_tenant: AuthorizedTenant = Depends(verify_tenant_api_key_v2),
): ):
"""Create a summarization task.""" """Create a summarization task."""
task = summarize_v2_task.apply_async( task = summarize_v2_task.apply_async(
args=[{**request.model_dump(), "tenant_id": request_tenant.id}] args=[
{
**request.model_dump(),
"tenant_id": request_tenant.id,
"received_at": datetime.now(timezone.utc),
}
]
) )
# We track the request, this also properly initializes the user in the
# analytics system, so that later feature flags work properly
properties = {}
if request.user_email:
properties["$set"] = {"email": request.user_email}
analytics.capture(
settings.posthog_summary_request,
request.user_sub,
properties=properties,
)
return SummarizeWebhookPendingPayload(job_id=task.id).model_dump() return SummarizeWebhookPendingPayload(job_id=task.id).model_dump()
+18 -10
View File
@@ -4,12 +4,14 @@ import json
import time import time
from collections import Counter from collections import Counter
from functools import lru_cache from functools import lru_cache
from urllib.parse import urlsplit, urlunsplit
import redis import redis
from celery.utils.log import get_task_logger from celery.utils.log import get_task_logger
from posthog import Posthog from posthog import Posthog
from summary.core.config import get_settings from summary.core.config import get_settings
from summary.core.models import SummarizeTaskJob, TranscribeTaskJob
logger = get_task_logger(__name__) logger = get_task_logger(__name__)
settings = get_settings() settings = get_settings()
@@ -107,25 +109,31 @@ class MetadataManager:
"""Check if task_id exists in tasks metadata cache.""" """Check if task_id exists in tasks metadata cache."""
return self._redis.exists(self._get_redis_key(task_id)) return self._redis.exists(self._get_redis_key(task_id))
def create(self, task_id, task_args): def create(self, task_id: str, task_payload: TranscribeTaskJob | SummarizeTaskJob):
"""Create initial metadata entry for a new task.""" """Create initial metadata entry for a new task."""
if self._is_disabled or self.has_task_id(task_id): if self._is_disabled or self.has_task_id(task_id):
return return
# Positional args mirror process_audio_transcribe_summarize_v2 signature:
# owner_id, recording_filename, metadata_filename, email, sub, received_at, ...
_, filename, _, email, _, received_at, *_ = task_args
start_time = time.time() start_time = time.time()
initial_metadata = { initial_metadata = {
"start_time": start_time, "start_time": start_time,
"asr_model": settings.whisperx_asr_model,
"retries": 0, "retries": 0,
"filename": filename, "sub": task_payload.user_sub,
"email": email, # avoid None in redis, it shouldn't happen anyway in prod
"queuing_time": round(start_time - received_at, 2), "email": task_payload.user_email or "",
"tenant_id": task_payload.tenant_id,
"queuing_time": round(start_time - task_payload.received_at.timestamp(), 2),
} }
if isinstance(task_payload, TranscribeTaskJob):
parts = urlsplit(task_payload.cloud_storage_url)
clean_url = urlunsplit((parts.scheme, parts.netloc, parts.path, "", ""))
initial_metadata["source_url"] = clean_url
initial_metadata["asr_model"] = settings.whisperx_asr_model
elif isinstance(task_payload, SummarizeTaskJob):
initial_metadata["content_length"] = len(task_payload.content)
initial_metadata["llm_model"] = settings.llm_model
self._save_metadata(task_id, initial_metadata) self._save_metadata(task_id, initial_metadata)
def retry(self, task_id): def retry(self, task_id):
@@ -210,6 +218,6 @@ class MetadataManager:
self.clear(task_id) self.clear(task_id)
try: try:
self._analytics.capture(event_name, metadata.get("email"), metadata) self._analytics.capture(event_name, metadata.get("sub"), metadata)
except AnalyticsException: except AnalyticsException:
logger.exception("Failed to capture analytics event") logger.exception("Failed to capture analytics event")
+190 -209
View File
@@ -4,7 +4,7 @@
import json import json
import time import time
from datetime import datetime from datetime import datetime, timezone
from typing import Any from typing import Any
from urllib.parse import urljoin from urllib.parse import urljoin
@@ -17,7 +17,9 @@ from requests import exceptions
from summary.core.analytics import MetadataManager, get_analytics from summary.core.analytics import MetadataManager, get_analytics
from summary.core.config import get_settings from summary.core.config import get_settings
from summary.core.docs_service import create_document_in_lasuite_docs
from summary.core.file_service import ( from summary.core.file_service import (
CorruptedAudioFile,
FileService, FileService,
FileServiceException, FileServiceException,
TranscribeError, TranscribeError,
@@ -25,8 +27,10 @@ from summary.core.file_service import (
from summary.core.llm_service import LLMException, LLMObservability, LLMService from summary.core.llm_service import LLMException, LLMObservability, LLMService
from summary.core.locales import get_locale from summary.core.locales import get_locale
from summary.core.models import ( from summary.core.models import (
SummarizeTaskV2Payload, PushToDocsBaseConfig,
TranscribeTaskV2Payload, RecordingMetadata,
SummarizeTaskJob,
TranscribeTaskJob,
) )
from summary.core.prompt import ( from summary.core.prompt import (
FORMAT_NEXT_STEPS, FORMAT_NEXT_STEPS,
@@ -50,7 +54,6 @@ from summary.core.transcript_formatter import TranscriptFormatter
from summary.core.user_assign import resolve_speaker_identities from summary.core.user_assign import resolve_speaker_identities
from summary.core.webhook_service import ( from summary.core.webhook_service import (
call_webhook_v2, call_webhook_v2,
submit_content,
) )
settings = get_settings() settings = get_settings()
@@ -86,29 +89,22 @@ file_service = FileService()
def transcribe_audio( def transcribe_audio(
*, *,
task_id: str, task_id: str,
recording_filename: str | None = None,
language: str, language: str,
cloud_storage_url=None, cloud_storage_url: str,
raises: bool = False, raises: bool = False,
): ):
"""Transcribe an audio file using WhisperX. """Transcribe an audio file using WhisperX.
Downloads the audio from MinIO or a cloud storage URL, sends it to Downloads the audio from a cloud storage URL, sends it to
WhisperX for transcription, and tracks metadata throughout the process. WhisperX for transcription, and tracks metadata throughout the process.
Returns the transcription object, or None if the file could not be retrieved. Returns the transcription object, or None if the file could not be retrieved.
""" """
if bool(recording_filename) == bool(cloud_storage_url):
raise ValueError(
"Either filename or cloud_storage_url must be provided, but not both."
)
logger.info("Initiating WhisperX client") logger.info("Initiating WhisperX client")
# Transcription # Transcription
try: try:
with file_service.prepare_audio_file( with file_service.prepare_audio_file(
remote_object_key=recording_filename,
cloud_storage_url=cloud_storage_url, cloud_storage_url=cloud_storage_url,
) as (audio_file, metadata): ) as (audio_file, metadata):
metadata_manager.track(task_id, {"audio_length": metadata["duration"]}) metadata_manager.track(task_id, {"audio_length": metadata["duration"]})
@@ -156,10 +152,20 @@ def transcribe_audio(
# Mimic OpenAI's timeout settings # Mimic OpenAI's timeout settings
timeout=(60, 10 * 60), timeout=(60, 10 * 60),
) )
if res.status_code == 400:
logger.info(
"WhisperX transcription failed, "
"likely due to a corrupted audio file: %s",
res.text,
)
raise CorruptedAudioFile("WhisperX coudln't decode the audio file.")
try: try:
res.raise_for_status() res.raise_for_status()
except requests.exceptions.HTTPError as e: except requests.exceptions.HTTPError:
raise RuntimeError("WhisperX transcription failed, %s", res.text) from e logger.exception("WhisperX transcription failed")
# We reraise the error so that it can be retried by celery
raise
transcription_json: dict[str, Any] = res.json() transcription_json: dict[str, Any] = res.json()
# We remove the "usage" key from the transcription_json dictionary # We remove the "usage" key from the transcription_json dictionary
@@ -197,11 +203,7 @@ def transcribe_audio(
cloud_storage_url.split("?", 1)[0] if cloud_storage_url else None cloud_storage_url.split("?", 1)[0] if cloud_storage_url else None
) )
logger.exception( logger.exception(
( ("Unexpected error while preparing file %s "),
"Unexpected error while preparing file | filename: %s "
"| cloud_storage_url: %s"
),
recording_filename,
redacted_cloud_storage_url, redacted_cloud_storage_url,
) )
return None return None
@@ -211,44 +213,34 @@ def transcribe_audio(
def resolve_speaker_identities_and_apply_to( def resolve_speaker_identities_and_apply_to(
transcription, recording_start_at, recording_end_at, metadata_filename, task_id *, transcription: WhisperXResponse, recording_metadata: RecordingMetadata, task_id
): ) -> WhisperXResponse:
"""Assign users to detected speakers and rewrite the transcriptions. """Assign users to detected speakers and rewrite the transcriptions.
Args: Args:
transcription: output of meet-whisperx after transcription and diarization transcription: output of meet-whisperx after transcription and diarization
recording_start_at: sourced from LiveKit FileInfo via the egress_ended webhook recording_metadata: Metadata of the recording
recording_end_at: sourced from LiveKit FileInfo via the egress_ended webhook
metadata_filename: name of metadata file containing VAD information in S3
task_id: current task id, for logging purposes task_id: current task id, for logging purposes
""" """
recording_start_dt = (
datetime.fromisoformat(recording_start_at) if recording_start_at else None
)
recording_end_dt = (
datetime.fromisoformat(recording_end_at) if recording_end_at else None
)
logger.debug( logger.debug(
"recording_start_dt: %s ; recording_end_dt: %s", "recording_start_dt: %s ; recording_end_dt: %s",
recording_start_dt, recording_metadata.started_at,
recording_end_dt, recording_metadata.ended_at,
) )
if (recording_start_dt is None) or (recording_end_dt is None):
logger.debug("Skipping resolve_speaker_identities")
return transcription
logger.debug("Running resolve_speaker_identities") logger.debug("Running resolve_speaker_identities")
try: try:
metadata = file_service.read_json(metadata_filename) metadata = file_service.read_cloud_storage_json(
recording_metadata.cloud_storage_url
)
speaker_mapping = resolve_speaker_identities( speaker_mapping = resolve_speaker_identities(
metadata, metadata,
transcription, transcription.model_dump(),
recording_start_dt, recording_metadata.started_at,
recording_end_dt, recording_metadata.ended_at,
) )
new_transcription = speaker_mapping.apply_to(transcription.model_dump()) new_transcription = speaker_mapping.apply_to(transcription.model_dump())
return new_transcription return WhisperXResponse.model_validate(new_transcription)
except FileServiceException as exc: except FileServiceException as exc:
logger.error( logger.error(
@@ -273,12 +265,9 @@ def format_transcript(
transcription, transcription,
context_language: str | None, context_language: str | None,
language: str, language: str,
room: str | None,
recording_datetime: str | None,
owner_timezone: str | None,
download_link: str | None, download_link: str | None,
form_link: str | None, form_link: str | None,
) -> tuple[str, str]: ) -> str:
"""Format a transcription into readable content with a title. """Format a transcription into readable content with a title.
Resolves the locale from context_language / language, then uses Resolves the locale from context_language / language, then uses
@@ -291,9 +280,6 @@ def format_transcript(
return formatter.format( return formatter.format(
transcription, transcription,
room=room,
recording_datetime=recording_datetime,
owner_timezone=owner_timezone,
download_link=download_link, download_link=download_link,
form_link=form_link, form_link=form_link,
) )
@@ -317,138 +303,8 @@ def format_actions(llm_output: dict) -> str:
return "" return ""
@celery.task(
bind=True,
autoretry_for=[exceptions.HTTPError],
max_retries=settings.celery_max_retries,
queue=settings.transcribe_queue,
)
def process_audio_transcribe_summarize_v2(
self,
owner_id: str,
recording_filename: str,
metadata_filename: str | None,
email: str,
sub: str,
received_at: float,
room: str | None,
owner_timezone: str | None,
language: str | None,
download_link: str | None,
context_language: str | None = None,
recording_start_at: str | None = None,
recording_end_at: str | None = None,
):
"""Process an audio file by transcribing it and generating a summary.
This Celery task orchestrates:
1. Audio transcription via WhisperX
2. Transcript formatting
3. Webhook submission
4. Conditional summarization queuing
Args:
self: Celery task instance (passed on with bind=True)
owner_id: Unique identifier of the recording owner.
recording_filename: Name of the audio file in MinIO storage.
metadata_filename: Name of the audio file in MinIO storage.
email: Email address of the recording owner.
sub: OIDC subject identifier of the recording owner.
received_at: Unix timestamp when the recording was received.
room: room name where the recording took place.
owner_timezone: IANA timezone of the recording owner (e.g. "Europe/Paris").
language: ISO 639-1 language code for transcription.
download_link: URL to download the original recording.
context_language: ISO 639-1 language code of the meeting summary context text.
recording_start_at: ISO 8601 timestamp of when file recording actually started
(from LiveKit FileInfo.started_at via the egress_ended webhook).
recording_end_at: ISO 8601 timestamp of when file recording ended
(from LiveKit FileInfo.ended_at via the egress_ended webhook).
"""
logger.info(
"Notification received | Owner: %s | Room: %s",
owner_id,
room,
)
task_id = self.request.id
# Transcribe the audio
transcription = transcribe_audio(
task_id=task_id, recording_filename=recording_filename, language=language
)
if transcription is None:
return
# Assign speakers and rewrite transcription/diarization output
if settings.is_resolve_speaker_identities_enabled and (
metadata_filename is not None
):
transcription = resolve_speaker_identities_and_apply_to(
transcription,
recording_start_at,
recording_end_at,
metadata_filename,
task_id,
)
form_base_url = settings.transcription_satisfaction_form_base_url
form_link = (
f"{form_base_url}?room_id={room}"
if (form_base_url and metadata_filename is not None)
else None
)
# Format output
content, title = format_transcript(
transcription,
context_language,
language,
room,
recording_start_at,
owner_timezone,
download_link,
form_link,
)
submit_content(content, title, email, sub)
metadata_manager.capture(task_id, settings.posthog_event_success)
# LLM Summarization
if (
analytics.is_feature_enabled("summary-enabled", distinct_id=owner_id)
and settings.is_summary_enabled
):
logger.info("Queuing summary generation task.")
summarize_transcription.apply_async(
args=[owner_id, content, email, sub, title],
queue=settings.summarize_queue,
)
else:
logger.info("Summary generation not enabled for this user. Skipping.")
@signals.task_prerun.connect(sender=process_audio_transcribe_summarize_v2)
def task_started(task_id=None, task=None, args=None, **kwargs):
"""Signal handler called before task execution begins."""
task_args = args or []
metadata_manager.create(task_id, task_args)
@signals.task_retry.connect(sender=process_audio_transcribe_summarize_v2)
def task_retry_handler(request=None, reason=None, einfo=None, **kwargs):
"""Signal handler called when task execution retries."""
metadata_manager.retry(request.id)
@signals.task_failure.connect(sender=process_audio_transcribe_summarize_v2)
def task_failure_handler(task_id, exception=None, **kwargs):
"""Signal handler called when task execution fails permanently."""
metadata_manager.capture(task_id, settings.posthog_event_failure)
def summarize_transcription_internals( def summarize_transcription_internals(
*, owner_id: str, transcript: str, session_id: str *, distinct_id: str, transcript: str, session_id: str
) -> str: ) -> str:
"""Generate a summary from the provided transcription text. """Generate a summary from the provided transcription text.
@@ -459,11 +315,11 @@ def summarize_transcription_internals(
""" """
logger.info( logger.info(
"Starting summarization task | Owner: %s", "Starting summarization task | Owner: %s",
owner_id, distinct_id,
) )
user_has_tracing_consent = analytics.is_feature_enabled( user_has_tracing_consent = analytics.is_feature_enabled(
"summary-tracing-consent", distinct_id=owner_id "summary-tracing-consent", distinct_id=distinct_id
) )
# NOTE: We must instantiate a new LLMObservability client for each task invocation # NOTE: We must instantiate a new LLMObservability client for each task invocation
@@ -474,7 +330,7 @@ def summarize_transcription_internals(
llm_observability = LLMObservability( llm_observability = LLMObservability(
user_has_tracing_consent=user_has_tracing_consent, user_has_tracing_consent=user_has_tracing_consent,
session_id=session_id, session_id=session_id,
user_id=owner_id, user_id=distinct_id,
) )
llm_service = LLMService(llm_observability=llm_observability) llm_service = LLMService(llm_observability=llm_observability)
@@ -526,34 +382,52 @@ def summarize_transcription_internals(
return summary return summary
@celery.task(
bind=True,
autoretry_for=[LLMException, Exception],
max_retries=settings.celery_max_retries,
queue=settings.summarize_queue,
)
def summarize_transcription(
self, owner_id: str, transcript: str, email: str, sub: str, title: str
):
"""Generate a summary from the provided transcription text.
This Celery task performs the following operations:
1. Run summary internals
2. Sends the final summary via webhook.
"""
summary = summarize_transcription_internals(
owner_id=owner_id, transcript=transcript, session_id=self.request.id
)
summary_title = settings.summary_title_template.format(title=title)
submit_content(summary, summary_title, email, sub)
################################################################################## ##################################################################################
# Tasks v2 # Tasks v2
################################################################################## ##################################################################################
def _should_push_to_docs(
payload: TranscribeTaskJob | SummarizeTaskJob,
) -> bool:
"""Determines if the transcription should be pushed to docs.
Based on the payload and settings.
"""
if not payload.push_to_docs_config:
reason = "Push to docs is not requested in the payload"
elif not settings.is_lasuite_docs_integration_enabled:
reason = "Docs integration is disabled"
elif not settings.get_authorized_tenant(
tenant_id=payload.tenant_id
).allowed_push_to_docs:
reason = "Tenant is not allowed to push to docs"
else:
return True
logger.info("Push to docs is not requested: %s", reason)
return False
def _should_auto_create_summary(payload: TranscribeTaskJob) -> bool:
"""Determines if the transcription should have an auto-created summary.
Based on the payload and settings.
"""
if (
payload.push_to_docs_config is None
or not payload.push_to_docs_config.auto_create_summary
):
reason = "Auto create summary is not requested in the payload"
elif not settings.is_summary_enabled:
reason = "Summary feature is disabled"
else:
return True
logger.info("Auto create summary is not requested: %s", reason)
return False
@celery.task( @celery.task(
max_retries=3, max_retries=3,
queue=settings.call_webhook_queue_v2, queue=settings.call_webhook_queue_v2,
@@ -590,7 +464,7 @@ def process_audio_transcribe_v2_task(
self: Celery task instance (passed on with bind=True) self: Celery task instance (passed on with bind=True)
payload: Serialized dictionary of TranscribeSummarizeTaskCreationV2 payload: Serialized dictionary of TranscribeSummarizeTaskCreationV2
""" """
payload = TranscribeTaskV2Payload.model_validate(payload) payload = TranscribeTaskJob.model_validate(payload)
logger.info( logger.info(
"Transcribing for object received | Owner: %s", "Transcribing for object received | Owner: %s",
payload.user_sub, payload.user_sub,
@@ -617,6 +491,60 @@ def process_audio_transcribe_v2_task(
) )
return failure_payload.model_dump() return failure_payload.model_dump()
# Assign speakers and rewrite transcription/diarization output
if settings.is_resolve_speaker_identities_enabled and payload.metadata is not None:
try:
transcription_res = resolve_speaker_identities_and_apply_to(
transcription=transcription_res,
recording_metadata=payload.metadata,
task_id=job_id,
)
except Exception as e:
logger.error(f"Failed to resolve speaker identities, skipping: {e}")
should_push_to_docs = _should_push_to_docs(payload)
# We do it synchronously for now
if should_push_to_docs:
if payload.push_to_docs_config is None:
raise ValueError("Push to docs config is missing")
# Format output
content = format_transcript(
transcription_res.model_dump(),
payload.context_language,
payload.language,
payload.push_to_docs_config.download_link,
payload.push_to_docs_config.form_link,
)
create_document_in_lasuite_docs(
content=content,
title=payload.push_to_docs_config.title,
email=payload.push_to_docs_config.user_email,
sub=payload.user_sub,
)
if _should_auto_create_summary(payload):
locale = get_locale(payload.context_language, payload.language)
summarize_v2_task.apply_async(
args=[
SummarizeTaskJob(
received_at=datetime.now(timezone.utc),
tenant_id=payload.tenant_id,
user_sub=payload.user_sub,
user_email=payload.user_email,
push_to_docs_config=PushToDocsBaseConfig(
user_email=payload.push_to_docs_config.user_email,
title=locale.summary_title_template.format(
title=payload.push_to_docs_config.title
),
),
content=content,
).model_dump()
],
)
file_service.store_transcript( file_service.store_transcript(
transcript=transcription_res, transcript=transcription_res,
job_id=job_id, job_id=job_id,
@@ -629,9 +557,30 @@ def process_audio_transcribe_v2_task(
call_webhook_v2_task.apply_async( call_webhook_v2_task.apply_async(
args=[success_payload.model_dump(), payload.tenant_id] args=[success_payload.model_dump(), payload.tenant_id]
) )
metadata_manager.capture(job_id, settings.posthog_transcript_success)
return success_payload.model_dump() return success_payload.model_dump()
@signals.task_prerun.connect(sender=process_audio_transcribe_v2_task)
def task_started_transcript(task_id=None, task=None, args=None, **kwargs):
"""Signal handler called before task execution begins."""
if args:
metadata_manager.create(task_id, TranscribeTaskJob.model_validate(args[0]))
@signals.task_retry.connect(sender=process_audio_transcribe_v2_task)
def task_retry_handler_transcript(request=None, reason=None, einfo=None, **kwargs):
"""Signal handler called when task execution retries."""
metadata_manager.retry(request.id)
@signals.task_failure.connect(sender=process_audio_transcribe_v2_task)
def task_failure_handler_transcript(task_id, exception=None, **kwargs):
"""Signal handler called when task execution fails permanently."""
metadata_manager.capture(task_id, settings.posthog_transcript_failure)
@signals.task_failure.connect(sender=process_audio_transcribe_v2_task) @signals.task_failure.connect(sender=process_audio_transcribe_v2_task)
def handle_transcribe_v2_failed( def handle_transcribe_v2_failed(
sender, sender,
@@ -687,15 +636,26 @@ def summarize_v2_task(
1. Run summary internals 1. Run summary internals
2. Sends the final summary via webhook. 2. Sends the final summary via webhook.
""" """
payload = SummarizeTaskV2Payload.model_validate(payload) payload = SummarizeTaskJob.model_validate(payload)
summary = summarize_transcription_internals( summary = summarize_transcription_internals(
owner_id=payload.user_sub, distinct_id=payload.user_sub,
transcript=payload.content, transcript=payload.content,
session_id=self.request.id, session_id=self.request.id,
) )
job_id = self.request.id job_id = self.request.id
file_service.store_summary(summary=summary, job_id=job_id) file_service.store_summary(summary=summary, job_id=job_id)
if _should_push_to_docs(payload):
if payload.push_to_docs_config is None:
raise ValueError("Push to docs config is missing")
create_document_in_lasuite_docs(
content=summary,
title=payload.push_to_docs_config.title,
email=payload.push_to_docs_config.user_email,
sub=payload.user_sub,
)
success_payload = SummarizeWebhookSuccessPayload( success_payload = SummarizeWebhookSuccessPayload(
job_id=job_id, job_id=job_id,
summary_data_url=file_service.get_summary_signed_url(job_id), summary_data_url=file_service.get_summary_signed_url(job_id),
@@ -703,9 +663,30 @@ def summarize_v2_task(
call_webhook_v2_task.apply_async( call_webhook_v2_task.apply_async(
args=[success_payload.model_dump(), payload.tenant_id] args=[success_payload.model_dump(), payload.tenant_id]
) )
metadata_manager.capture(job_id, settings.posthog_summary_success)
return success_payload.model_dump() return success_payload.model_dump()
@signals.task_prerun.connect(sender=summarize_v2_task)
def task_started_summary(task_id=None, task=None, args=None, **kwargs):
"""Signal handler called before task execution begins."""
if args:
metadata_manager.create(task_id, SummarizeTaskJob.model_validate(args[0]))
@signals.task_retry.connect(sender=summarize_v2_task)
def task_retry_handler_summary(request=None, reason=None, einfo=None, **kwargs):
"""Signal handler called when task execution retries."""
metadata_manager.retry(request.id)
@signals.task_failure.connect(sender=summarize_v2_task)
def task_failure_handler_summary(task_id, exception=None, **kwargs):
"""Signal handler called when task execution fails permanently."""
metadata_manager.capture(task_id, settings.posthog_summary_failure)
@signals.task_failure.connect(sender=summarize_v2_task) @signals.task_failure.connect(sender=summarize_v2_task)
def handle_summarize_v2_failed( def handle_summarize_v2_failed(
sender, sender,
+39 -55
View File
@@ -1,9 +1,8 @@
"""Application configuration and settings.""" """Application configuration and settings."""
import logging import logging
import os
from functools import cached_property, lru_cache from functools import cached_property, lru_cache
from typing import Annotated, Any, List, Literal, Mapping, Optional, Set from typing import Annotated, List, Mapping, Optional, Set
from fastapi import Depends from fastapi import Depends
from pydantic import ( from pydantic import (
@@ -34,9 +33,12 @@ class AuthorizedTenant(BaseModel):
title="Webhook API Key", title="Webhook API Key",
description="The api_key to authenticate the webhook request.", description="The api_key to authenticate the webhook request.",
) )
allowed_push_to_docs: bool = Field(
title="Allow Push to Docs",
V1_DEFAULT_TENANT_ID = "__deprecated_meet_tenant__" description="Whether to allow pushing transcript"
" and summaries to docs for this tenant.",
default=False,
)
class Settings(BaseSettings): class Settings(BaseSettings):
@@ -45,14 +47,12 @@ class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", frozen=True) model_config = SettingsConfigDict(env_file=".env", frozen=True)
app_name: str = "summary" app_name: str = "summary"
app_api_v1_str: str = "/api/v1"
app_api_v2_str: str = "/api/v2" app_api_v2_str: str = "/api/v2"
# Authorized Tenants # Authorized Tenants
# Using env variables to store authorized tenants for now # Using env variables to store authorized tenants for now
# to avoid any other external dependency (DB) # to avoid any other external dependency (DB)
authorized_tenants: tuple[AuthorizedTenant, ...] = Field(default_factory=tuple) authorized_tenants: tuple[AuthorizedTenant, ...] = Field(min_length=1)
v1_tenant_id: str = V1_DEFAULT_TENANT_ID
# Audio recordings # Audio recordings
recording_max_duration: Optional[int] = None recording_max_duration: Optional[int] = None
@@ -72,8 +72,6 @@ class Settings(BaseSettings):
celery_result_backend: str = "redis://redis/0" celery_result_backend: str = "redis://redis/0"
celery_max_retries: int = 1 celery_max_retries: int = 1
transcribe_queue: str = "transcribe-queue"
summarize_queue: str = "summarize-queue"
# v2 tasks # v2 tasks
transcribe_queue_v2: str = "transcribe-queue-v2" transcribe_queue_v2: str = "transcribe-queue-v2"
summarize_queue_v2: str = "summarize-queue-v2" summarize_queue_v2: str = "summarize-queue-v2"
@@ -90,7 +88,7 @@ class Settings(BaseSettings):
# AI-related settings # AI-related settings
whisperx_api_key: SecretStr whisperx_api_key: SecretStr
whisperx_base_url: str = "https://api.openai.com/v1" whisperx_base_url: Url = "https://api.openai.com/v1"
whisperx_asr_model: str = "whisper-1" whisperx_asr_model: str = "whisper-1"
# ISO 639-1 language code (e.g., "en", "fr", "es") # ISO 639-1 language code (e.g., "en", "fr", "es")
whisperx_default_language: Optional[str] = None whisperx_default_language: Optional[str] = None
@@ -112,16 +110,17 @@ class Settings(BaseSettings):
webhook_max_retries: int = 2 webhook_max_retries: int = 2
webhook_status_forcelist: List[int] = [502, 503, 504] webhook_status_forcelist: List[int] = [502, 503, 504]
webhook_backoff_factor: float = 0.1 webhook_backoff_factor: float = 0.1
app_external_user_agent: str = "summary"
# Locale
default_context_language: Literal["de", "en", "fr", "nl"] = "fr"
# Output related settings
summary_title_template: Optional[str] = "Résumé de {title}"
# Summary related settings # Summary related settings
is_summary_enabled: bool = True is_summary_enabled: bool = True
transcription_satisfaction_form_base_url: Optional[str] = None
# Docs service configuration
is_lasuite_docs_integration_enabled: bool = False
lasuite_docs_base_url: Url = "https://example.com"
lasuite_docs_server_to_server_api_key: SecretStr = Field(
title="API key for using docs server to server api", default="NO_API_KEY"
)
# Sentry # Sentry
sentry_is_enabled: bool = False sentry_is_enabled: bool = False
@@ -131,8 +130,12 @@ class Settings(BaseSettings):
posthog_enabled: bool = False posthog_enabled: bool = False
posthog_api_key: Optional[str] = None posthog_api_key: Optional[str] = None
posthog_api_host: Optional[str] = "https://eu.i.posthog.com" posthog_api_host: Optional[str] = "https://eu.i.posthog.com"
posthog_event_failure: str = "transcript-failure" posthog_transcript_request: str = "transcript-request"
posthog_event_success: str = "transcript-success" posthog_transcript_failure: str = "transcript-failure"
posthog_transcript_success: str = "transcript-success"
posthog_summary_request: str = "summary-request"
posthog_summary_failure: str = "summary-failure"
posthog_summary_success: str = "summary-success"
# Langfuse (LLM Observability) # Langfuse (LLM Observability)
langfuse_enabled: bool = False langfuse_enabled: bool = False
@@ -145,38 +148,9 @@ class Settings(BaseSettings):
task_tracker_redis_url: str = "redis://redis/0" task_tracker_redis_url: str = "redis://redis/0"
task_tracker_prefix: str = "task_metadata:" task_tracker_prefix: str = "task_metadata:"
@model_validator(mode="before")
@classmethod
def legacy_default_tenant_config(cls, data: Any) -> Any:
"""Migrate the legacy default tenant configuration."""
if isinstance(data, dict):
api_key = os.getenv("APP_API_TOKEN")
webhook_api_key = os.getenv("WEBHOOK_API_TOKEN")
webhook_url = os.getenv("WEBHOOK_URL")
if api_key and webhook_api_key and webhook_url:
logger.warning(
"Deprecated legacy app configuration detected, "
"please use only the new 'authorized_tenants' field instead."
)
authorized_tenants = list(data.get("authorized_tenants", []))
authorized_tenants.append(
AuthorizedTenant(
id=V1_DEFAULT_TENANT_ID,
api_key=SecretStr(api_key),
webhook_url=webhook_url,
webhook_api_key=SecretStr(webhook_api_key),
)
)
data["authorized_tenants"] = tuple(authorized_tenants)
return data
@model_validator(mode="after") @model_validator(mode="after")
def validate_authorized_tenants(self): def validate_authorized_tenants(self):
"""Validate authorized tenants configuration.""" """Validate authorized tenants configuration."""
if len(self.authorized_tenants) == 0:
raise ValueError("No authorized tenants configured")
tenant_ids = {tenant.id for tenant in self.authorized_tenants} tenant_ids = {tenant.id for tenant in self.authorized_tenants}
if len(tenant_ids) != len(self.authorized_tenants): if len(tenant_ids) != len(self.authorized_tenants):
@@ -191,13 +165,23 @@ class Settings(BaseSettings):
return self return self
@model_validator(mode="after") @model_validator(mode="after")
def validate_default_v1_tenant(self): def validate_docs_config(self):
"""Validate default v1 tenant configuration.""" """Validate docs integration configuration."""
if not any( if not self.is_lasuite_docs_integration_enabled:
tenant.id == self.v1_tenant_id for tenant in self.authorized_tenants return self
):
raise ValueError("v1 tenant is not configured in authorized tenants")
if not self.lasuite_docs_base_url:
raise ValueError(
"lasuite_docs_base_url is required when docs integration is enabled"
)
if self.lasuite_docs_server_to_server_api_key.get_secret_value() in {
"",
"NO_API_KEY",
}:
raise ValueError(
"Valid lasuite_docs_server_to_server_api_key is required when"
" docs integration is enabled"
)
return self return self
@cached_property @cached_property
+94
View File
@@ -0,0 +1,94 @@
"""Service for delivering content to external destinations."""
import json
import logging
from urllib.parse import urljoin
from requests import Session
from requests.adapters import HTTPAdapter
from urllib3.util import Retry
from summary.core.config import get_settings
settings = get_settings()
logger = logging.getLogger(__name__)
def _create_retry_session(api_key: str | None = None):
"""Create an HTTP session configured with retry logic."""
session = Session()
retries = Retry(
total=settings.webhook_max_retries,
backoff_factor=settings.webhook_backoff_factor,
status_forcelist=settings.webhook_status_forcelist,
allowed_methods={"POST"},
)
session.mount("https://", HTTPAdapter(max_retries=retries))
if api_key:
session.headers.update(
{
"Authorization": f"Bearer {api_key}",
"User-Agent": settings.app_external_user_agent,
}
)
return session
def _post_with_retries(*, url, data, api_key: str | None = None):
"""Send POST request with automatic retries."""
session = _create_retry_session(api_key=api_key)
try:
response = session.post(url, json=data, timeout=(20, 3 * 60))
response.raise_for_status()
return response
finally:
session.close()
def create_document_in_lasuite_docs(
*, content: str, title: str, email: str, sub: str
) -> None:
"""Call the Docs API to create a document on behalf of the user there.
Builds the payload, sends it with retries, and logs the outcome.
"""
data = {
"title": title,
"content": content,
"email": email,
"sub": sub,
}
logger.debug(
"Submitting document to docs endpoint: %s", settings.lasuite_docs_base_url
)
logger.debug(
"Docs payload metadata | title_len=%s content_len=%s has_email=%s has_sub=%s",
len(title),
len(content),
bool(email),
bool(sub),
)
response = _post_with_retries(
url=urljoin(
settings.lasuite_docs_base_url, "/api/v1.0/documents/create-for-owner/"
),
api_key=settings.lasuite_docs_server_to_server_api_key.get_secret_value(),
data=data,
)
try:
response_data = response.json()
document_id = response_data.get("id", "N/A")
except (json.JSONDecodeError, AttributeError):
document_id = "Unable to parse response"
logger.info(
"Delivery success | Document %s submitted (HTTP %s)",
document_id,
response.status_code,
)
logger.debug("Docs response received (body omitted)")

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