mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-27 20:29:09 +00:00
Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d7dcea7383 | |||
| 9c94ae82bf | |||
| f5311e3db8 | |||
| 12c28ec37d | |||
| f5d0458f29 | |||
| 6379c6f7eb | |||
| 222c2c97c8 | |||
| ab707d866f | |||
| dac0b9c000 | |||
| 53cc8642eb | |||
| c8d9d2fea8 | |||
| e910b1f0b7 | |||
| 581e115c03 | |||
| 8118ef0612 | |||
| 85f886d9c5 | |||
| b66f82e4b7 | |||
| aabb7d629c | |||
| 1e5f5c4fe9 | |||
| f55fa0c42b | |||
| 2bc5e47c75 | |||
| 0d5136206f | |||
| 05a67320b1 | |||
| 2fbaa49089 | |||
| 76542b2235 | |||
| 10231b0333 | |||
| 9b9e6578ee | |||
| 0c81d29ee7 | |||
| 839a8f1c71 | |||
| 40ef420cda | |||
| d995bd9041 | |||
| 8ac4c2409d | |||
| e47ad42656 | |||
| 2509452c52 | |||
| 1022780027 | |||
| 4a1a04f86e | |||
| 8702638cf3 | |||
| f88c0307ea | |||
| e7f15b50ff | |||
| 75ba2ff146 | |||
| bbe2a32efc | |||
| 86fff16eed | |||
| 195d2b5006 | |||
| 30cf264276 | |||
| 945d779b45 | |||
| d43b335546 | |||
| e6f0ff5112 | |||
| 224d6ce358 | |||
| 20718f2d5a | |||
| 8edcb6e973 | |||
| b00c4e5d6d | |||
| 3c0ba03976 | |||
| 7fa172d100 | |||
| ecf8f0fe3f | |||
| 27da57aff5 | |||
| e327a5e35f |
@@ -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"
|
||||||
|
|||||||
@@ -8,9 +8,57 @@ and this project adheres to
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### 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
|
||||||
|
- ✨(backend) add per-recording encoding quality presets to start-recording API
|
||||||
|
|
||||||
|
### 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
|
||||||
|
- 💥(backend) replace recording encoding options with a profile model
|
||||||
|
|
||||||
|
## [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
|
||||||
|
- 💥(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
|
||||||
|
|
||||||
@@ -30,6 +78,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
@@ -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
@@ -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`.
|
||||||
|
|||||||
@@ -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..."
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
+41
-34
@@ -100,13 +100,13 @@ sequenceDiagram
|
|||||||
| **RECORDING_STORAGE_EVENT_TOKEN** | Secret/File | `None` | Token used to authenticate storage webhook requests, if `RECORDING_ENABLE_STORAGE_EVENT_AUTH` is enabled. |
|
| **RECORDING_STORAGE_EVENT_TOKEN** | Secret/File | `None` | Token used to authenticate storage webhook requests, if `RECORDING_ENABLE_STORAGE_EVENT_AUTH` is enabled. |
|
||||||
| **RECORDING_EXPIRATION_DAYS** | Integer | `None` | Number of days before recordings expire. Should match bucket lifecycle policy. Set to `None` for no expiration. |
|
| **RECORDING_EXPIRATION_DAYS** | Integer | `None` | Number of days before recordings expire. Should match bucket lifecycle policy. Set to `None` for no expiration. |
|
||||||
| **RECORDING_MAX_DURATION** | Integer | `None` | Maximum duration of a recording in milliseconds. Must be synced with the LiveKit Egress configuration. Set to None for unlimited duration. When the maximum duration is reached, the recording is automatically stopped and saved, and the user is prompted in the frontend with an alert message. |
|
| **RECORDING_MAX_DURATION** | Integer | `None` | Maximum duration of a recording in milliseconds. Must be synced with the LiveKit Egress configuration. Set to None for unlimited duration. When the maximum duration is reached, the recording is automatically stopped and saved, and the user is prompted in the frontend with an alert message. |
|
||||||
| **RECORDING_ENCODING_ENABLED** | Boolean | `False` | When `False`, LiveKit Egress uses its built-in `H264_720P_30` preset. When `True`, the `RECORDING_ENCODING_*` values below are sent to LiveKit as advanced `EncodingOptions`. See [Tuning recording encoding](#tuning-recording-encoding). |
|
| **RECORDING_CUSTOM_ENCODING_ENABLED** | Boolean | `False` | Whether the start-recording API accepts a per-recording `encoding` object (resolution/profile) that overrides the default. When `False`, the API rejects per-recording `encoding`; when `True`, clients may pick from the available resolutions/profiles. The default encoding below is applied regardless of this flag. See [Tuning recording encoding](#tuning-recording-encoding). |
|
||||||
| **RECORDING_ENCODING_WIDTH** | Integer | `1280` | Recording video width in pixels. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
|
| **RECORDING_ENCODING_AVAILABLE_RESOLUTIONS** | Dict | `{"540p": {"width": 960, "height": 540}, "720p": {"width": 1280, "height": 720}, "1080p": {"width": 1920, "height": 1080}}` | Maps a resolution name to its `{"width", "height"}` in pixels. Both the default encoding and the per-recording start-recording API pick from these keys. |
|
||||||
| **RECORDING_ENCODING_HEIGHT** | Integer | `720` | Recording video height in pixels. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
|
| **RECORDING_ENCODING_AVAILABLE_PROFILES** | Dict | `{"full": {"fps": 30, "kbps": {…}}, …}` | Maps a profile name to `{"fps", "kbps": {resolution: video_bitrate_kbps}}`. Every profile must define a bitrate for each available resolution (validated at startup). |
|
||||||
| **RECORDING_ENCODING_FRAMERATE** | Integer | `30` | Recording video framerate (fps). Directly impacts egress worker CPU (roughly linear). Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
|
| **RECORDING_ENCODING_DEFAULT_RESOLUTION** | String | `"720p"` | Resolution used by the default encoding. When set, must be a key of `RECORDING_ENCODING_AVAILABLE_RESOLUTIONS`. Leave unset (together with, or instead of, the default profile) to disable the custom default encoding and fall back to LiveKit's built-in preset (a startup warning is emitted). |
|
||||||
| **RECORDING_ENCODING_VIDEO_BITRATE_KBPS** | Integer | `3000` | H.264 MAIN video bitrate in kbps. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
|
| **RECORDING_ENCODING_DEFAULT_PROFILE** | String | `"full"` | Profile used by the default encoding. When set, must be a key of `RECORDING_ENCODING_AVAILABLE_PROFILES`. Leave unset (together with, or instead of, the default resolution) to disable the custom default encoding and fall back to LiveKit's built-in preset (a startup warning is emitted). |
|
||||||
| **RECORDING_ENCODING_AUDIO_BITRATE_KBPS** | Integer | `128` | AAC audio bitrate in kbps. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
|
| **RECORDING_ENCODING_AUDIO_BITRATE_KBPS** | Integer | `128` | AAC audio bitrate in kbps used in the default encoding. |
|
||||||
| **RECORDING_ENCODING_KEY_FRAME_INTERVAL_S** | Float | `4.0` | Keyframe interval in seconds. Drives seek granularity in the recorded MP4 (a player can only seek to keyframe boundaries). Larger values give the encoder slightly more bits for non-keyframe content at a fixed bitrate. `4.0` is a standard VOD value. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
|
| **RECORDING_ENCODING_KEY_FRAME_INTERVAL_S** | Float | `4.0` | Keyframe interval in seconds. Drives seek granularity in the recorded MP4 (a player can only seek to keyframe boundaries). Larger values give the encoder slightly more bits for non-keyframe content at a fixed bitrate. `4.0` is a standard VOD value. |
|
||||||
|
|
||||||
|
|
||||||
### Manual Storage Webhook
|
### Manual Storage Webhook
|
||||||
@@ -150,52 +150,59 @@ This allows you to verify which recordings are in progress, troubleshoot egress
|
|||||||
|
|
||||||
## Tuning recording encoding
|
## Tuning recording encoding
|
||||||
|
|
||||||
By default, LiveKit Egress records with the built-in `H264_720P_30` preset: 1280×720 at 30 fps, 3000 kbps H.264 MAIN video and 128 kbps AAC audio. For a one-hour meeting this produces a file of roughly **1.4 GB**, which is often heavier than necessary for talking-head content and screen sharing.
|
Every video recording is encoded from a default resolved from `RECORDING_ENCODING_DEFAULT_PROFILE` + `RECORDING_ENCODING_DEFAULT_RESOLUTION` and passed to LiveKit as advanced `EncodingOptions`. The shipped defaults (`full` profile) match LiveKit's built-in `H264_720P_30` preset. For a one-hour meeting that produces a file of roughly **1.4 GB**, which is often heavier than necessary for talking-head content and screen sharing; lowering the default profile/resolution shrinks it. If either default is left unset, no custom default encoding is built: a warning is logged at startup and LiveKit's built-in preset is used instead.
|
||||||
|
|
||||||
The `RECORDING_ENCODING_*` settings let operators override this preset without modifying the source. Values are passed straight through LiveKit's `EncodingOptions.advanced` to the GStreamer pipeline (`x264enc` for video, `faac` for audio), so there are no hidden conversions — what you set is what the encoder receives.
|
Encoding is chosen from two maps: `RECORDING_ENCODING_AVAILABLE_RESOLUTIONS` (`resolution → {"width", "height"}`) and `RECORDING_ENCODING_AVAILABLE_PROFILES` (`profile → {"fps", "kbps": {resolution: video_bitrate_kbps}}`):
|
||||||
|
|
||||||
|
- **Default**: `RECORDING_ENCODING_DEFAULT_PROFILE` + `RECORDING_ENCODING_DEFAULT_RESOLUTION` set the encoding used by every recording that doesn't override it. Leave either unset to fall back to LiveKit's built-in preset (a startup warning is emitted).
|
||||||
|
- **Per recording (opt-in)**: set `RECORDING_CUSTOM_ENCODING_ENABLED=True` to let clients override the default per recording. The start-recording API then accepts an `encoding` object selecting a `resolution` (required) and `profile` (optional): a resolution-only request sets the frame size but leaves LiveKit's default framerate/bitrate; adding a profile pins fps and bitrate too. When `RECORDING_CUSTOM_ENCODING_ENABLED=False`, the API rejects any per-recording `encoding` and the default is used.
|
||||||
|
|
||||||
|
The resolved values are passed straight through LiveKit's `EncodingOptions.advanced` to the GStreamer pipeline (`x264enc` for video, `faac` for audio), so there are no hidden conversions — what the profile/resolution resolve to is what the encoder receives.
|
||||||
|
|
||||||
### How values map to GStreamer
|
### How values map to GStreamer
|
||||||
|
|
||||||
| Setting | GStreamer element | Property |
|
| Resolved value | GStreamer element | Property |
|
||||||
| ------------------------------------- | ----------------- | ---------------------------------- |
|
| ----------------------------------------- | ----------------- | ---------------------------------- |
|
||||||
| `RECORDING_ENCODING_WIDTH/HEIGHT` | capsfilter | `video/x-raw,width=W,height=H` |
|
| resolution `width` / `height` | capsfilter | `video/x-raw,width=W,height=H` |
|
||||||
| `RECORDING_ENCODING_FRAMERATE` | capsfilter | `framerate=F/1` |
|
| profile `fps` | capsfilter | `framerate=F/1` |
|
||||||
| `RECORDING_ENCODING_VIDEO_BITRATE_KBPS` | `x264enc` | `bitrate=kbps` (kilobits) |
|
| profile `kbps[resolution]` | `x264enc` | `bitrate=kbps` (kilobits) |
|
||||||
| `RECORDING_ENCODING_KEY_FRAME_INTERVAL_S` | `x264enc` | `key-int-max = interval × fps` |
|
| `RECORDING_ENCODING_KEY_FRAME_INTERVAL_S` | `x264enc` | `key-int-max = interval × fps` |
|
||||||
| `RECORDING_ENCODING_AUDIO_BITRATE_KBPS` | `faac` | `bitrate = kbps × 1000` (bits) |
|
| `RECORDING_ENCODING_AUDIO_BITRATE_KBPS` | `faac` | `bitrate = kbps × 1000` (bits) |
|
||||||
|
|
||||||
The H.264 profile is fixed to MAIN and the x264 `speed-preset` to `veryfast` by LiveKit (real-time constraint) — lowering the framerate is therefore the main lever to save CPU, while lowering the bitrate is the main lever to shrink the output file.
|
The H.264 profile is fixed to MAIN and the x264 `speed-preset` to `veryfast` by LiveKit (real-time constraint) — lowering the framerate is therefore the main lever to save CPU, while lowering the bitrate is the main lever to shrink the output file.
|
||||||
|
|
||||||
### Reference profiles
|
### Built-in profiles
|
||||||
|
|
||||||
Rough 30-minute file-size estimates assume video + audio bitrate multiplied by duration. Actual sizes vary with content (static talking heads compress better than heavy screen motion). Egress CPU figures are indicative, measured on a single Ryzen laptop core saturated by the default preset (= 100 %); scaling is roughly linear with `framerate × bitrate` but the absolute numbers depend on the host hardware.
|
The default `RECORDING_ENCODING_AVAILABLE_PROFILES` ship four profiles. Framerate is fixed per profile; video bitrate (kbps) scales with resolution so quality stays consistent across sizes. File size scales roughly with `framerate × bitrate`, and so does egress CPU cost.
|
||||||
|
|
||||||
| Profile | Resolution | FPS | Video (kbps) | Audio (kbps) | Keyframe (s) | ~ size / 30 min | Egress CPU (vs. default) | Suitable for |
|
| Profile | FPS | 540p (kbps) | 720p (kbps) | 1080p (kbps) | Suitable for |
|
||||||
| ---------------------- | ---------- | --- | ------------ | ------------ | ------------ | --------------- | ------------------------ | --------------------------------------------------- |
|
| --------------- | --- | ----------- | ----------- | ------------ | -------------------------------------------------- |
|
||||||
| Default (preset) | 1280×720 | 30 | 3000 | 128 | 4 | **~690 MB** | 100 % | Unchanged LiveKit behaviour |
|
| `talking_heads` | 15 | 400 | 700 | 1200 | Talking-head dominant meetings + occasional slides |
|
||||||
| Balanced | 1280×720 | 20 | 1000 | 96 | 4 | ~240 MB | ~67 % | Mixed content, moderate motion |
|
| `text` | 15 | 600 | 1000 | 1800 | Frequent dense screen sharing (decks, IDE, docs) |
|
||||||
| **Low CPU / small file** | 1280×720 | 15 | 600 | 64 | 4 | **~150 MB** | ~50 % | Talking-head dominant meetings + occasional slides ★ |
|
| `mixed` | 20 | 900 | 1500 | 2500 | Mixed content, moderate motion |
|
||||||
| Slide-heavy | 1280×720 | 15 | 900 | 64 | 4 | ~210 MB | ~55 % | Frequent dense screen sharing (decks, IDE, docs) |
|
| `full` | 30 | 2000 | 3000 | 4500 | Highest fidelity; closest to the LiveKit preset |
|
||||||
| Minimum CPU | 960×540 | 15 | 500 | 64 | 4 | ~125 MB | ~30 % | Voice-first meetings, readable text not required |
|
|
||||||
| Audio-heavy fallback | 1280×720 | 10 | 400 | 96 | 4 | ~110 MB | ~35 % | Long webinars, low motion |
|
|
||||||
|
|
||||||
★ Recommended starting point for typical LaSuite Meet usage.
|
To pick a profile per recording (requires `RECORDING_CUSTOM_ENCODING_ENABLED=True`), the client sends it in the start-recording request:
|
||||||
|
|
||||||
Environment variables for the **Low CPU / small file** profile:
|
```json
|
||||||
|
{
|
||||||
|
"mode": "screen_recording",
|
||||||
|
"options": {"encoding": {"resolution": "720p", "profile": "talking_heads"}}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
To change the default encoding applied to every recording:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
RECORDING_ENCODING_ENABLED=True
|
RECORDING_ENCODING_DEFAULT_RESOLUTION=720p
|
||||||
RECORDING_ENCODING_WIDTH=1280
|
RECORDING_ENCODING_DEFAULT_PROFILE=talking_heads
|
||||||
RECORDING_ENCODING_HEIGHT=720
|
|
||||||
RECORDING_ENCODING_FRAMERATE=15
|
|
||||||
RECORDING_ENCODING_VIDEO_BITRATE_KBPS=600
|
|
||||||
RECORDING_ENCODING_AUDIO_BITRATE_KBPS=64
|
RECORDING_ENCODING_AUDIO_BITRATE_KBPS=64
|
||||||
RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=4.0
|
RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=4.0
|
||||||
```
|
```
|
||||||
|
|
||||||
### Caveats
|
### Caveats
|
||||||
|
|
||||||
- **Screen-share readability — think bits/frame, not bitrate**: at 720p, text legibility starts to break down below ~40 kbits/frame (= `bitrate ÷ framerate`). The recommended preset (600 kbps × 15 fps) sits at exactly that threshold, comfortable for talking heads with occasional slide sharing. The same 600 kbps at 30 fps would only deliver 20 kbits/frame and visibly blur dense slides — which is why **lowering framerate is a more screen-share-friendly lever than lowering bitrate**. For deck-heavy or IDE-share meetings, prefer the **Slide-heavy** profile (900 kbps × 15 fps ≈ 60 kbits/frame).
|
- **Screen-share readability — think bits/frame, not bitrate**: at 720p, text legibility starts to break down below ~40 kbits/frame (= `bitrate ÷ framerate`). The `talking_heads` profile (700 kbps × 15 fps) sits just above that threshold, comfortable for talking heads with occasional slide sharing. The same bitrate at 30 fps would only deliver ~23 kbits/frame and visibly blur dense slides — which is why **lowering framerate is a more screen-share-friendly lever than lowering bitrate**. For deck-heavy or IDE-share meetings, prefer the **`text`** profile (1000 kbps × 15 fps ≈ 67 kbits/frame).
|
||||||
- **Motion handling**: the `veryfast` x264 preset is set by LiveKit and cannot be overridden here. Low-bitrate settings will therefore show more artefacts on fast motion than an offline re-encode with a slower preset would. This is the other reason FPS reduction is the safer tuning lever for meeting recordings.
|
- **Motion handling**: the `veryfast` x264 preset is set by LiveKit and cannot be overridden here. Low-bitrate settings will therefore show more artefacts on fast motion than an offline re-encode with a slower preset would. This is the other reason FPS reduction is the safer tuning lever for meeting recordings.
|
||||||
- **Audio**: AAC at 64 kbps stereo is transparent for voice but starts to compress music noticeably. Keep 128 kbps if you expect music playback in meetings.
|
- **Audio**: AAC at 64 kbps stereo is transparent for voice but starts to compress music noticeably. Keep 128 kbps if you expect music playback in meetings.
|
||||||
- **Codec choice**: H.264 MAIN is hardcoded on purpose. Switching to HEVC or VP9 would increase egress CPU cost 2×–5×, defeating the goal of this tuning.
|
- **Codec choice**: H.264 MAIN is hardcoded on purpose. Switching to HEVC or VP9 would increase egress CPU cost 2×–5×, defeating the goal of this tuning.
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -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}]'
|
||||||
|
|||||||
@@ -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,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,
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -1,21 +1,22 @@
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "agents"
|
name = "agents"
|
||||||
version = "1.22.0"
|
version = "1.23.0"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"livekit-agents==1.5.13",
|
"livekit-agents==1.6.4",
|
||||||
"livekit-plugins-deepgram==1.5.13",
|
"livekit-plugins-deepgram==1.6.4",
|
||||||
"livekit-plugins-silero==1.5.13",
|
"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.6",
|
"protobuf==6.33.6",
|
||||||
"minio==7.2.20"
|
"minio==7.2.20",
|
||||||
|
"sentry-sdk==2.60.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
"ruff==0.15.14",
|
"ruff==0.15.19",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.uv]
|
[tool.uv]
|
||||||
|
|||||||
@@ -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
|
||||||
Generated
+936
-832
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -13,7 +13,12 @@ from django.core.exceptions import SuspiciousOperation
|
|||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from django_pydantic_field.rest_framework import SchemaField
|
from django_pydantic_field.rest_framework import SchemaField
|
||||||
from pydantic import BaseModel, Field, field_serializer
|
from pydantic import (
|
||||||
|
BaseModel,
|
||||||
|
Field,
|
||||||
|
field_serializer,
|
||||||
|
field_validator,
|
||||||
|
)
|
||||||
from pydantic import ValidationError as PydanticValidationError
|
from pydantic import ValidationError as PydanticValidationError
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from rest_framework.exceptions import PermissionDenied
|
from rest_framework.exceptions import PermissionDenied
|
||||||
@@ -227,6 +232,49 @@ class BaseValidationOnlySerializer(serializers.Serializer):
|
|||||||
raise NotImplementedError(f"{self.__class__.__name__} is validation-only")
|
raise NotImplementedError(f"{self.__class__.__name__} is validation-only")
|
||||||
|
|
||||||
|
|
||||||
|
class EncodingConfig(BaseModel):
|
||||||
|
"""Configuration options for recording encoding.
|
||||||
|
|
||||||
|
The allowed `resolution` and `profile` values are derived at validation time
|
||||||
|
from ``settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS`` and
|
||||||
|
``settings.RECORDING_ENCODING_AVAILABLE_PROFILES``, so adding a resolution or profile
|
||||||
|
to those maps is enough to make it accepted here.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
resolution: Target video resolution.
|
||||||
|
profile: Encoding profile to balance quality and CPU usage. When `None`,
|
||||||
|
LiveKit default framerate/bitrate are used for the resolution.
|
||||||
|
"""
|
||||||
|
|
||||||
|
resolution: str
|
||||||
|
profile: str | None = None
|
||||||
|
model_config = {"extra": "forbid"}
|
||||||
|
|
||||||
|
@field_validator("resolution")
|
||||||
|
@classmethod
|
||||||
|
def _validate_resolution(cls, value):
|
||||||
|
"""Reject resolutions absent from RECORDING_ENCODING_AVAILABLE_RESOLUTIONS."""
|
||||||
|
allowed = set(settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS)
|
||||||
|
if value not in allowed:
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid resolution '{value}'. Choose from {sorted(allowed)}."
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
@field_validator("profile")
|
||||||
|
@classmethod
|
||||||
|
def _validate_profile(cls, value):
|
||||||
|
"""Reject profiles absent from RECORDING_ENCODING_AVAILABLE_PROFILES."""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
allowed = set(settings.RECORDING_ENCODING_AVAILABLE_PROFILES)
|
||||||
|
if value not in allowed:
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid profile '{value}'. Choose from {sorted(allowed)}."
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
class RecordingOptions(BaseModel):
|
class RecordingOptions(BaseModel):
|
||||||
"""Configuration options for recording.
|
"""Configuration options for recording.
|
||||||
|
|
||||||
@@ -247,7 +295,7 @@ class RecordingOptions(BaseModel):
|
|||||||
transcribe: bool | None = None
|
transcribe: bool | None = None
|
||||||
collect_metadata: bool | None = None
|
collect_metadata: bool | None = None
|
||||||
original_mode: Literal["screen_recording", "transcript"] | None = None
|
original_mode: Literal["screen_recording", "transcript"] | None = None
|
||||||
|
encoding: EncodingConfig | None = None
|
||||||
model_config = {"extra": "forbid"}
|
model_config = {"extra": "forbid"}
|
||||||
|
|
||||||
|
|
||||||
@@ -563,3 +611,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)
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -60,6 +63,7 @@ from core.recording.worker.exceptions import (
|
|||||||
RecordingStopError,
|
RecordingStopError,
|
||||||
)
|
)
|
||||||
from core.recording.worker.factories import (
|
from core.recording.worker.factories import (
|
||||||
|
build_encoding_options,
|
||||||
get_worker_service,
|
get_worker_service,
|
||||||
)
|
)
|
||||||
from core.recording.worker.mediator import (
|
from core.recording.worker.mediator import (
|
||||||
@@ -89,6 +93,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 +269,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,
|
||||||
@@ -376,12 +384,34 @@ class RoomViewSet(
|
|||||||
options = serializer.validated_data.get("options")
|
options = serializer.validated_data.get("options")
|
||||||
room = self.get_object()
|
room = self.get_object()
|
||||||
|
|
||||||
|
if (
|
||||||
|
options is not None
|
||||||
|
and options.encoding is not None
|
||||||
|
and not settings.RECORDING_CUSTOM_ENCODING_ENABLED
|
||||||
|
):
|
||||||
|
# Per-recording encoding selection is gated by
|
||||||
|
# RECORDING_CUSTOM_ENCODING_ENABLED. When disabled, recordings use
|
||||||
|
# encoding defined by RECORDING_ENCODING_DEFAULT_RESOLUTION
|
||||||
|
# and RECORDING_ENCODING_DEFAULT_PROFILE.
|
||||||
|
return drf_response.Response(
|
||||||
|
{"detail": "Per-recording encoding selection is disabled."},
|
||||||
|
status=drf_status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
options_data = options.model_dump(exclude_none=True) if options else {}
|
||||||
|
if options is not None and options.encoding is not None:
|
||||||
|
# Persist the resolved encoding (concrete width/height/framerate/
|
||||||
|
# bitrate) alongside the requested resolution/profile for traceability.
|
||||||
|
options_data["encoding"]["resolved"] = build_encoding_options(
|
||||||
|
options.encoding.resolution, options.encoding.profile
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
recording = models.Recording.objects.create(
|
recording = models.Recording.objects.create(
|
||||||
room=room,
|
room=room,
|
||||||
mode=mode,
|
mode=mode,
|
||||||
options=options.model_dump(exclude_none=True) if options else {},
|
options=options_data,
|
||||||
)
|
)
|
||||||
models.RecordingAccess.objects.create(
|
models.RecordingAccess.objects.create(
|
||||||
user=self.request.user,
|
user=self.request.user,
|
||||||
@@ -999,6 +1029,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},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
+23
@@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -22,6 +22,44 @@ _RECORDING_AUDIO_CODEC = livekit_api.AudioCodec.AAC
|
|||||||
_RECORDING_AUDIO_FREQUENCY_HZ = 48000
|
_RECORDING_AUDIO_FREQUENCY_HZ = 48000
|
||||||
|
|
||||||
|
|
||||||
|
def build_encoding_options(resolution, profile):
|
||||||
|
"""Assemble the LiveKit ``EncodingOptions`` kwargs for a resolution/profile.
|
||||||
|
|
||||||
|
Single source of truth shared by the default encoding
|
||||||
|
(``WorkerServiceConfig.from_settings``) and the per-recording encoding
|
||||||
|
persisted by the start-recording API, so both paths always produce the
|
||||||
|
same shape.
|
||||||
|
|
||||||
|
The profile-independent fields (audio bitrate, keyframe interval and the
|
||||||
|
pinned codec / frequency constants) are always included.
|
||||||
|
The resolution-dependent fields are added only when they can be resolved:
|
||||||
|
width/height require a resolution; framerate/video_bitrate require both a
|
||||||
|
resolution and a profile (a resolution-only encoding leaves framerate and
|
||||||
|
bitrate to LiveKit's defaults).
|
||||||
|
"""
|
||||||
|
options: Dict[str, Any] = {
|
||||||
|
"audio_bitrate": settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS,
|
||||||
|
"key_frame_interval": settings.RECORDING_ENCODING_KEY_FRAME_INTERVAL_S,
|
||||||
|
"video_codec": _RECORDING_VIDEO_CODEC,
|
||||||
|
"audio_codec": _RECORDING_AUDIO_CODEC,
|
||||||
|
"audio_frequency": _RECORDING_AUDIO_FREQUENCY_HZ,
|
||||||
|
}
|
||||||
|
|
||||||
|
if resolution:
|
||||||
|
resolution_config = settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS[
|
||||||
|
resolution
|
||||||
|
]
|
||||||
|
options["width"] = resolution_config["width"]
|
||||||
|
options["height"] = resolution_config["height"]
|
||||||
|
|
||||||
|
if resolution and profile:
|
||||||
|
profile_config = settings.RECORDING_ENCODING_AVAILABLE_PROFILES[profile]
|
||||||
|
options["framerate"] = profile_config["fps"]
|
||||||
|
options["video_bitrate"] = profile_config["kbps"][resolution]
|
||||||
|
|
||||||
|
return options
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class WorkerServiceConfig:
|
class WorkerServiceConfig:
|
||||||
"""Declare Worker Service common configurations"""
|
"""Declare Worker Service common configurations"""
|
||||||
@@ -38,22 +76,16 @@ class WorkerServiceConfig:
|
|||||||
|
|
||||||
logger.debug("Loading WorkerServiceConfig from settings.")
|
logger.debug("Loading WorkerServiceConfig from settings.")
|
||||||
|
|
||||||
|
# The default encoding is resolved from the default profile/resolution and
|
||||||
|
# applied to every recording that carries no per-recording encoding.
|
||||||
|
# When either default is missing, we leave this as None so LiveKit falls
|
||||||
|
# back to its built-in preset.
|
||||||
|
resolution = settings.RECORDING_ENCODING_DEFAULT_RESOLUTION
|
||||||
|
profile = settings.RECORDING_ENCODING_DEFAULT_PROFILE
|
||||||
|
|
||||||
encoding_options: Optional[Dict[str, Any]] = None
|
encoding_options: Optional[Dict[str, Any]] = None
|
||||||
if settings.RECORDING_ENCODING_ENABLED:
|
if resolution and profile:
|
||||||
# Single source of truth for the EncodingOptions kwargs:
|
encoding_options = build_encoding_options(resolution, profile)
|
||||||
# operator-tunable values live in Django settings, codec / frequency
|
|
||||||
# are pinned constants. The services layer only unpacks this dict.
|
|
||||||
encoding_options = {
|
|
||||||
"width": settings.RECORDING_ENCODING_WIDTH,
|
|
||||||
"height": settings.RECORDING_ENCODING_HEIGHT,
|
|
||||||
"framerate": settings.RECORDING_ENCODING_FRAMERATE,
|
|
||||||
"video_bitrate": settings.RECORDING_ENCODING_VIDEO_BITRATE_KBPS,
|
|
||||||
"audio_bitrate": settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS,
|
|
||||||
"key_frame_interval": settings.RECORDING_ENCODING_KEY_FRAME_INTERVAL_S,
|
|
||||||
"video_codec": _RECORDING_VIDEO_CODEC,
|
|
||||||
"audio_codec": _RECORDING_AUDIO_CODEC,
|
|
||||||
"audio_frequency": _RECORDING_AUDIO_FREQUENCY_HZ,
|
|
||||||
}
|
|
||||||
|
|
||||||
return cls(
|
return cls(
|
||||||
output_folder=settings.RECORDING_OUTPUT_FOLDER,
|
output_folder=settings.RECORDING_OUTPUT_FOLDER,
|
||||||
@@ -78,7 +110,12 @@ class WorkerService(Protocol):
|
|||||||
def __init__(self, config: WorkerServiceConfig):
|
def __init__(self, config: WorkerServiceConfig):
|
||||||
"""Initialize the service with the given configuration."""
|
"""Initialize the service with the given configuration."""
|
||||||
|
|
||||||
def start(self, room_id: str, recording_id: str) -> str:
|
def start(
|
||||||
|
self,
|
||||||
|
room_id: str,
|
||||||
|
recording_id: str,
|
||||||
|
encoding_options: Optional[Dict[str, Any]] = None,
|
||||||
|
) -> str:
|
||||||
"""Start a recording for a specified room."""
|
"""Start a recording for a specified room."""
|
||||||
|
|
||||||
def stop(self, worker_id: str) -> str:
|
def stop(self, worker_id: str) -> str:
|
||||||
|
|||||||
@@ -47,8 +47,11 @@ class WorkerServiceMediator:
|
|||||||
raise RecordingStartError()
|
raise RecordingStartError()
|
||||||
|
|
||||||
room_name = str(recording.room.id)
|
room_name = str(recording.room.id)
|
||||||
|
encoding_options = (recording.options.get("encoding") or {}).get("resolved")
|
||||||
try:
|
try:
|
||||||
worker_id = self._worker_service.start(room_name, recording.id)
|
worker_id = self._worker_service.start(
|
||||||
|
room_name, recording.id, encoding_options=encoding_options
|
||||||
|
)
|
||||||
except (WorkerRequestError, WorkerConnectionError, WorkerResponseError) as e:
|
except (WorkerRequestError, WorkerConnectionError, WorkerResponseError) as e:
|
||||||
logger.exception(
|
logger.exception(
|
||||||
"Failed to start recording for room %s: %s", recording.room.slug, e
|
"Failed to start recording for room %s: %s", recording.room.slug, e
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ class BaseEgressService:
|
|||||||
|
|
||||||
return "FAILED_TO_STOP"
|
return "FAILED_TO_STOP"
|
||||||
|
|
||||||
def start(self, room_name, recording_id):
|
def start(self, room_name, recording_id, encoding_options=None):
|
||||||
"""Start the egress process for a recording (not implemented in the base class).
|
"""Start the egress process for a recording (not implemented in the base class).
|
||||||
Each derived class must implement this method, providing the necessary parameters for
|
Each derived class must implement this method, providing the necessary parameters for
|
||||||
its specific egress type (e.g. audio_only, streaming output).
|
its specific egress type (e.g. audio_only, streaming output).
|
||||||
@@ -99,13 +99,24 @@ class BaseEgressService:
|
|||||||
|
|
||||||
return livekit_api.EncodingOptions(**opts)
|
return livekit_api.EncodingOptions(**opts)
|
||||||
|
|
||||||
|
def _resolve_encoding_options(self, encoding_options):
|
||||||
|
"""Build LiveKit EncodingOptions from a resolved per-recording dict, or None.
|
||||||
|
|
||||||
|
``encoding_options`` is the dict persisted by the API in
|
||||||
|
``recording.options["encoding"]["resolved"]``.
|
||||||
|
"""
|
||||||
|
if not encoding_options:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return livekit_api.EncodingOptions(**encoding_options)
|
||||||
|
|
||||||
|
|
||||||
class VideoCompositeEgressService(BaseEgressService):
|
class VideoCompositeEgressService(BaseEgressService):
|
||||||
"""Record multiple participant video and audio tracks into a single output '.mp4' file."""
|
"""Record multiple participant video and audio tracks into a single output '.mp4' file."""
|
||||||
|
|
||||||
hrid = "video-recording-composite-livekit-egress"
|
hrid = "video-recording-composite-livekit-egress"
|
||||||
|
|
||||||
def start(self, room_name, recording_id):
|
def start(self, room_name, recording_id, encoding_options=None):
|
||||||
"""Start the video composite egress process for a recording."""
|
"""Start the video composite egress process for a recording."""
|
||||||
|
|
||||||
# Save room's recording as a mp4 video file.
|
# Save room's recording as a mp4 video file.
|
||||||
@@ -126,7 +137,10 @@ class VideoCompositeEgressService(BaseEgressService):
|
|||||||
"layout": "speaker-light",
|
"layout": "speaker-light",
|
||||||
}
|
}
|
||||||
|
|
||||||
advanced = self._build_encoding_options()
|
advanced = (
|
||||||
|
self._resolve_encoding_options(encoding_options)
|
||||||
|
or self._build_encoding_options()
|
||||||
|
)
|
||||||
if advanced is not None:
|
if advanced is not None:
|
||||||
request_kwargs["advanced"] = advanced
|
request_kwargs["advanced"] = advanced
|
||||||
|
|
||||||
@@ -145,8 +159,13 @@ class AudioCompositeEgressService(BaseEgressService):
|
|||||||
|
|
||||||
hrid = "audio-recording-composite-livekit-egress"
|
hrid = "audio-recording-composite-livekit-egress"
|
||||||
|
|
||||||
def start(self, room_name, recording_id):
|
def start(self, room_name, recording_id, encoding_options=None):
|
||||||
"""Start the audio composite egress process for a recording."""
|
"""Start the audio composite egress process for a recording.
|
||||||
|
|
||||||
|
``encoding_options`` is accepted for signature compatibility with the
|
||||||
|
WorkerService protocol but ignored: audio-only egress has no
|
||||||
|
encoding to configure.
|
||||||
|
"""
|
||||||
|
|
||||||
# Save room's recording as an ogg audio file.
|
# Save room's recording as an ogg audio file.
|
||||||
file_type = livekit_api.EncodedFileType.OGG
|
file_type = livekit_api.EncodedFileType.OGG
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"""Tests for the per-recording encoding resolution in BaseEgressService."""
|
||||||
|
|
||||||
|
# pylint: disable=protected-access,redefined-outer-name,unused-argument,no-member
|
||||||
|
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from livekit import api as livekit_api
|
||||||
|
from pydantic import ValidationError as PydanticValidationError
|
||||||
|
|
||||||
|
from core.api.serializers import EncodingConfig
|
||||||
|
from core.recording.worker.factories import build_encoding_options
|
||||||
|
from core.recording.worker.services import VideoCompositeEgressService
|
||||||
|
|
||||||
|
|
||||||
|
def make_config():
|
||||||
|
"""Build a minimal WorkerServiceConfig-like mock for service instantiation."""
|
||||||
|
config = Mock()
|
||||||
|
config.bucket_args = {
|
||||||
|
"endpoint": "https://s3.test.com",
|
||||||
|
"access_key": "test_key",
|
||||||
|
"secret": "test_secret",
|
||||||
|
"region": "test-region",
|
||||||
|
"bucket": "test-bucket",
|
||||||
|
"force_path_style": True,
|
||||||
|
}
|
||||||
|
config.encoding_options = None
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def service():
|
||||||
|
"""Return a VideoCompositeEgressService with mocked handle_request."""
|
||||||
|
svc = VideoCompositeEgressService(make_config())
|
||||||
|
svc._handle_request = Mock()
|
||||||
|
return svc
|
||||||
|
|
||||||
|
|
||||||
|
# --- build_encoding_options ---
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_options_without_profile_omits_profile_fields():
|
||||||
|
"""A resolution-only config should resolve dimensions but no framerate/bitrate.
|
||||||
|
|
||||||
|
The profile-independent fields (audio bitrate, keyframe interval, codec /
|
||||||
|
frequency pins) are always present, matching the default encoding.
|
||||||
|
"""
|
||||||
|
resolved = build_encoding_options("720p", None)
|
||||||
|
|
||||||
|
assert resolved == {
|
||||||
|
"audio_bitrate": settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS,
|
||||||
|
"key_frame_interval": settings.RECORDING_ENCODING_KEY_FRAME_INTERVAL_S,
|
||||||
|
"video_codec": livekit_api.VideoCodec.H264_MAIN,
|
||||||
|
"audio_codec": livekit_api.AudioCodec.AAC,
|
||||||
|
"audio_frequency": 48000,
|
||||||
|
"width": 1280,
|
||||||
|
"height": 720,
|
||||||
|
}
|
||||||
|
assert "framerate" not in resolved
|
||||||
|
assert "video_bitrate" not in resolved
|
||||||
|
|
||||||
|
|
||||||
|
def test_encoding_config_requires_resolution():
|
||||||
|
"""A profile-only or empty encoding config should be rejected at validation."""
|
||||||
|
with pytest.raises(PydanticValidationError):
|
||||||
|
EncodingConfig(profile="mixed")
|
||||||
|
with pytest.raises(PydanticValidationError):
|
||||||
|
EncodingConfig()
|
||||||
|
|
||||||
|
|
||||||
|
# --- _resolve_encoding_options ---
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("encoding_options", [None, {}])
|
||||||
|
def test_resolve_options_returns_none_when_empty(service, encoding_options):
|
||||||
|
"""Resolver should return None when the resolved dict is empty or missing."""
|
||||||
|
assert service._resolve_encoding_options(encoding_options) is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"resolution",
|
||||||
|
list(settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS),
|
||||||
|
)
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"profile",
|
||||||
|
list(settings.RECORDING_ENCODING_AVAILABLE_PROFILES),
|
||||||
|
)
|
||||||
|
def test_resolve_profile_resolution_combinations(service, profile, resolution):
|
||||||
|
"""Every (profile, resolution) pair should resolve to the values from settings."""
|
||||||
|
resolution_config = settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS[resolution]
|
||||||
|
expected_width = resolution_config["width"]
|
||||||
|
expected_height = resolution_config["height"]
|
||||||
|
profile_config = settings.RECORDING_ENCODING_AVAILABLE_PROFILES[profile]
|
||||||
|
expected_fps = profile_config["fps"]
|
||||||
|
expected_bitrate = profile_config["kbps"][resolution]
|
||||||
|
|
||||||
|
resolved = build_encoding_options(resolution, profile)
|
||||||
|
result = service._resolve_encoding_options(resolved)
|
||||||
|
|
||||||
|
assert result.width == expected_width
|
||||||
|
assert result.height == expected_height
|
||||||
|
assert result.framerate == expected_fps
|
||||||
|
assert result.video_bitrate == expected_bitrate
|
||||||
|
# Profile-independent fields match the default encoding, never dropped.
|
||||||
|
assert result.audio_bitrate == settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS
|
||||||
|
assert result.video_codec == livekit_api.VideoCodec.H264_MAIN
|
||||||
|
assert result.audio_codec == livekit_api.AudioCodec.AAC
|
||||||
|
assert result.audio_frequency == 48000
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_options_none_profile_uses_livekit_defaults(service):
|
||||||
|
"""Missing profile should pass 0 fps/bitrate (LiveKit protobuf default).
|
||||||
|
|
||||||
|
The pinned codec / audio fields are still applied even without a profile.
|
||||||
|
"""
|
||||||
|
resolved = build_encoding_options("720p", None)
|
||||||
|
result = service._resolve_encoding_options(resolved)
|
||||||
|
assert result.width == 1280
|
||||||
|
assert result.height == 720
|
||||||
|
assert result.framerate == 0
|
||||||
|
assert result.video_bitrate == 0
|
||||||
|
assert result.audio_bitrate == settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS
|
||||||
|
assert result.video_codec == livekit_api.VideoCodec.H264_MAIN
|
||||||
|
assert result.audio_codec == livekit_api.AudioCodec.AAC
|
||||||
@@ -40,6 +40,16 @@ def test_settings():
|
|||||||
"AWS_S3_SECRET_ACCESS_KEY": "test_secret",
|
"AWS_S3_SECRET_ACCESS_KEY": "test_secret",
|
||||||
"AWS_S3_REGION_NAME": "test-region",
|
"AWS_S3_REGION_NAME": "test-region",
|
||||||
"AWS_STORAGE_BUCKET_NAME": "test-bucket",
|
"AWS_STORAGE_BUCKET_NAME": "test-bucket",
|
||||||
|
"RECORDING_ENCODING_AVAILABLE_RESOLUTIONS": {
|
||||||
|
"720p": {"width": 1280, "height": 720}
|
||||||
|
},
|
||||||
|
"RECORDING_ENCODING_AVAILABLE_PROFILES": {
|
||||||
|
"full": {"fps": 30, "kbps": {"720p": 3000}}
|
||||||
|
},
|
||||||
|
"RECORDING_ENCODING_DEFAULT_RESOLUTION": "720p",
|
||||||
|
"RECORDING_ENCODING_DEFAULT_PROFILE": "full",
|
||||||
|
"RECORDING_ENCODING_AUDIO_BITRATE_KBPS": 128,
|
||||||
|
"RECORDING_ENCODING_KEY_FRAME_INTERVAL_S": 4.0,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Use override_settings to properly patch Django settings
|
# Use override_settings to properly patch Django settings
|
||||||
@@ -66,8 +76,18 @@ def test_config_initialization(default_config):
|
|||||||
"bucket": "test-bucket",
|
"bucket": "test-bucket",
|
||||||
"force_path_style": True,
|
"force_path_style": True,
|
||||||
}
|
}
|
||||||
# Encoding override is opt-in; disabled by default.
|
# The default encoding is always resolved from the default profile/resolution.
|
||||||
assert default_config.encoding_options is None
|
assert default_config.encoding_options == {
|
||||||
|
"width": 1280,
|
||||||
|
"height": 720,
|
||||||
|
"framerate": 30,
|
||||||
|
"video_bitrate": 3000,
|
||||||
|
"audio_bitrate": 128,
|
||||||
|
"key_frame_interval": 4.0,
|
||||||
|
"video_codec": livekit_api_codec.VideoCodec.H264_MAIN,
|
||||||
|
"audio_codec": livekit_api_codec.AudioCodec.AAC,
|
||||||
|
"audio_frequency": 48000,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_config_immutability(default_config):
|
def test_config_immutability(default_config):
|
||||||
@@ -76,6 +96,7 @@ def test_config_immutability(default_config):
|
|||||||
default_config.output_folder = "new/path"
|
default_config.output_folder = "new/path"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("custom_encoding_enabled", [True, False])
|
||||||
@override_settings(
|
@override_settings(
|
||||||
RECORDING_OUTPUT_FOLDER="/test/output",
|
RECORDING_OUTPUT_FOLDER="/test/output",
|
||||||
LIVEKIT_CONFIGURATION={"server": "test.example.com"},
|
LIVEKIT_CONFIGURATION={"server": "test.example.com"},
|
||||||
@@ -84,23 +105,25 @@ def test_config_immutability(default_config):
|
|||||||
AWS_S3_SECRET_ACCESS_KEY="test_secret",
|
AWS_S3_SECRET_ACCESS_KEY="test_secret",
|
||||||
AWS_S3_REGION_NAME="test-region",
|
AWS_S3_REGION_NAME="test-region",
|
||||||
AWS_STORAGE_BUCKET_NAME="test-bucket",
|
AWS_STORAGE_BUCKET_NAME="test-bucket",
|
||||||
RECORDING_ENCODING_ENABLED=True,
|
RECORDING_ENCODING_AVAILABLE_RESOLUTIONS={"720p": {"width": 1280, "height": 720}},
|
||||||
RECORDING_ENCODING_WIDTH=1280,
|
RECORDING_ENCODING_AVAILABLE_PROFILES={"low": {"fps": 15, "kbps": {"720p": 600}}},
|
||||||
RECORDING_ENCODING_HEIGHT=720,
|
RECORDING_ENCODING_DEFAULT_RESOLUTION="720p",
|
||||||
RECORDING_ENCODING_FRAMERATE=15,
|
RECORDING_ENCODING_DEFAULT_PROFILE="low",
|
||||||
RECORDING_ENCODING_VIDEO_BITRATE_KBPS=600,
|
|
||||||
RECORDING_ENCODING_AUDIO_BITRATE_KBPS=64,
|
RECORDING_ENCODING_AUDIO_BITRATE_KBPS=64,
|
||||||
RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=10.0,
|
RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=10.0,
|
||||||
)
|
)
|
||||||
def test_config_encoding_options_enabled():
|
def test_config_encoding_options_default(custom_encoding_enabled):
|
||||||
"""When RECORDING_ENCODING_ENABLED is True, encoding options are populated.
|
"""The default encoding is always resolved from the default profile/resolution.
|
||||||
|
|
||||||
The dict mixes operator-tunable values from settings with pinned codec /
|
The default fallback resolves the default profile/resolution and mixes those
|
||||||
frequency constants, so the services layer can simply unpack it.
|
operator-tunable values with pinned codec / frequency constants. This works
|
||||||
|
regardless of RECORDING_CUSTOM_ENCODING_ENABLED, which only gates the
|
||||||
|
per-recording API, so both toggle states produce the same default.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
WorkerServiceConfig.from_settings.cache_clear()
|
with override_settings(RECORDING_CUSTOM_ENCODING_ENABLED=custom_encoding_enabled):
|
||||||
config = WorkerServiceConfig.from_settings()
|
WorkerServiceConfig.from_settings.cache_clear()
|
||||||
|
config = WorkerServiceConfig.from_settings()
|
||||||
|
|
||||||
assert config.encoding_options == {
|
assert config.encoding_options == {
|
||||||
"width": 1280,
|
"width": 1280,
|
||||||
@@ -115,6 +138,27 @@ def test_config_encoding_options_enabled():
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("default_resolution", "default_profile"),
|
||||||
|
[("", "full"), ("720p", ""), ("", "")],
|
||||||
|
)
|
||||||
|
def test_config_encoding_options_none_when_default_missing(
|
||||||
|
test_settings, default_resolution, default_profile
|
||||||
|
):
|
||||||
|
"""A missing default resolution/profile leaves encoding_options None.
|
||||||
|
|
||||||
|
The service then omits the `advanced` field so LiveKit uses its built-in preset.
|
||||||
|
"""
|
||||||
|
with override_settings(
|
||||||
|
RECORDING_ENCODING_DEFAULT_RESOLUTION=default_resolution,
|
||||||
|
RECORDING_ENCODING_DEFAULT_PROFILE=default_profile,
|
||||||
|
):
|
||||||
|
WorkerServiceConfig.from_settings.cache_clear()
|
||||||
|
config = WorkerServiceConfig.from_settings()
|
||||||
|
|
||||||
|
assert config.encoding_options is None
|
||||||
|
|
||||||
|
|
||||||
@override_settings(
|
@override_settings(
|
||||||
RECORDING_OUTPUT_FOLDER="/test/output",
|
RECORDING_OUTPUT_FOLDER="/test/output",
|
||||||
LIVEKIT_CONFIGURATION={"server": "test.example.com"},
|
LIVEKIT_CONFIGURATION={"server": "test.example.com"},
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ def test_start_recording_success(
|
|||||||
# Verify worker service call
|
# Verify worker service call
|
||||||
expected_room_name = str(mock_recording.room.id)
|
expected_room_name = str(mock_recording.room.id)
|
||||||
mock_worker_service.start.assert_called_once_with(
|
mock_worker_service.start.assert_called_once_with(
|
||||||
expected_room_name, mock_recording.id
|
expected_room_name, mock_recording.id, encoding_options=None
|
||||||
)
|
)
|
||||||
|
|
||||||
# Verify recording updates
|
# Verify recording updates
|
||||||
@@ -66,6 +66,38 @@ def test_start_recording_success(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch("core.utils.update_room_metadata")
|
||||||
|
def test_start_recording_passes_resolved_encoding(
|
||||||
|
mock_update_room_metadata, mediator, mock_worker_service
|
||||||
|
):
|
||||||
|
"""The resolved encoding persisted in recording.options reaches the worker."""
|
||||||
|
mock_worker_service.start.return_value = "test-worker-123"
|
||||||
|
|
||||||
|
resolved = {
|
||||||
|
"key_frame_interval": 4.0,
|
||||||
|
"width": 1280,
|
||||||
|
"height": 720,
|
||||||
|
"framerate": 15,
|
||||||
|
"video_bitrate": 700,
|
||||||
|
}
|
||||||
|
mock_recording = RecordingFactory(
|
||||||
|
status=RecordingStatusChoices.INITIATED,
|
||||||
|
worker_id=None,
|
||||||
|
options={
|
||||||
|
"encoding": {
|
||||||
|
"resolution": "720p",
|
||||||
|
"profile": "talking_heads",
|
||||||
|
"resolved": resolved,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
mediator.start(mock_recording)
|
||||||
|
|
||||||
|
mock_worker_service.start.assert_called_once_with(
|
||||||
|
str(mock_recording.room.id), mock_recording.id, encoding_options=resolved
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"error_class", [WorkerRequestError, WorkerConnectionError, WorkerResponseError]
|
"error_class", [WorkerRequestError, WorkerConnectionError, WorkerResponseError]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -2,11 +2,12 @@
|
|||||||
Test rooms API endpoints in the Meet core app: start recording.
|
Test rooms API endpoints in the Meet core app: start recording.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# pylint: disable=redefined-outer-name,unused-argument
|
# pylint: disable=redefined-outer-name,unused-argument,no-member
|
||||||
|
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from livekit import api as livekit_api
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
from ...factories import RoomFactory, UserFactory
|
from ...factories import RoomFactory, UserFactory
|
||||||
@@ -470,6 +471,194 @@ def test_start_recording_options_unknown_field_rejected(settings):
|
|||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_recording_options_encoding_valid(
|
||||||
|
settings, mock_worker_service_factory, mock_worker_manager
|
||||||
|
):
|
||||||
|
"""Should accept a valid encoding configuration."""
|
||||||
|
settings.RECORDING_ENABLE = True
|
||||||
|
settings.RECORDING_CUSTOM_ENCODING_ENABLED = True
|
||||||
|
room = RoomFactory()
|
||||||
|
user = UserFactory()
|
||||||
|
room.accesses.create(user=user, role="owner")
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||||
|
{
|
||||||
|
"mode": "screen_recording",
|
||||||
|
"options": {"encoding": {"resolution": "720p", "profile": "talking_heads"}},
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_recording_options_encoding_rejected_when_custom_encoding_disabled(
|
||||||
|
settings, mock_worker_service_factory, mock_worker_manager
|
||||||
|
):
|
||||||
|
"""Per-recording encoding is rejected when RECORDING_CUSTOM_ENCODING_ENABLED is off."""
|
||||||
|
settings.RECORDING_ENABLE = True
|
||||||
|
settings.RECORDING_CUSTOM_ENCODING_ENABLED = False
|
||||||
|
room = RoomFactory()
|
||||||
|
user = UserFactory()
|
||||||
|
room.accesses.create(user=user, role="owner")
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||||
|
{
|
||||||
|
"mode": "screen_recording",
|
||||||
|
"options": {"encoding": {"resolution": "720p", "profile": "talking_heads"}},
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert not Recording.objects.filter(room=room).exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_recording_persists_resolved_encoding(
|
||||||
|
settings, mock_worker_service_factory, mock_worker_manager
|
||||||
|
):
|
||||||
|
"""The resolved encoding should be persisted in recording.options alongside
|
||||||
|
the requested resolution/profile for traceability."""
|
||||||
|
settings.RECORDING_ENABLE = True
|
||||||
|
settings.RECORDING_CUSTOM_ENCODING_ENABLED = True
|
||||||
|
room = RoomFactory()
|
||||||
|
user = UserFactory()
|
||||||
|
room.accesses.create(user=user, role="owner")
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||||
|
{
|
||||||
|
"mode": "screen_recording",
|
||||||
|
"options": {"encoding": {"resolution": "720p", "profile": "talking_heads"}},
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
recording = Recording.objects.get(room=room)
|
||||||
|
assert recording.options["encoding"] == {
|
||||||
|
"resolution": "720p",
|
||||||
|
"profile": "talking_heads",
|
||||||
|
"resolved": {
|
||||||
|
"audio_bitrate": settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS,
|
||||||
|
"key_frame_interval": settings.RECORDING_ENCODING_KEY_FRAME_INTERVAL_S,
|
||||||
|
"video_codec": livekit_api.VideoCodec.H264_MAIN,
|
||||||
|
"audio_codec": livekit_api.AudioCodec.AAC,
|
||||||
|
"audio_frequency": 48000,
|
||||||
|
"width": 1280,
|
||||||
|
"height": 720,
|
||||||
|
"framerate": 15,
|
||||||
|
"video_bitrate": 700,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_recording_forwards_resolved_encoding_to_worker(
|
||||||
|
settings, mock_worker_service, mock_worker_service_factory
|
||||||
|
):
|
||||||
|
"""The resolved encoding should passed on to the worker."""
|
||||||
|
settings.RECORDING_ENABLE = True
|
||||||
|
settings.RECORDING_CUSTOM_ENCODING_ENABLED = True
|
||||||
|
room = RoomFactory()
|
||||||
|
user = UserFactory()
|
||||||
|
room.accesses.create(user=user, role="owner")
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
mock_worker_service.start.return_value = "egress-123"
|
||||||
|
|
||||||
|
with mock.patch("core.utils.update_room_metadata"):
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||||
|
{
|
||||||
|
"mode": "screen_recording",
|
||||||
|
"options": {
|
||||||
|
"encoding": {"resolution": "720p", "profile": "talking_heads"}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
recording = Recording.objects.get(room=room)
|
||||||
|
mock_worker_service.start.assert_called_once_with(
|
||||||
|
str(room.id),
|
||||||
|
recording.id,
|
||||||
|
encoding_options=recording.options["encoding"]["resolved"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_recording_options_encoding_invalid_resolution(settings):
|
||||||
|
"""Should reject invalid encoding resolution values."""
|
||||||
|
settings.RECORDING_ENABLE = True
|
||||||
|
settings.RECORDING_CUSTOM_ENCODING_ENABLED = True
|
||||||
|
room = RoomFactory()
|
||||||
|
user = UserFactory()
|
||||||
|
room.accesses.create(user=user, role="owner")
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||||
|
{"mode": "screen_recording", "options": {"encoding": {"resolution": "4K"}}},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_recording_options_encoding_unknown_key_rejected(settings):
|
||||||
|
"""Should reject unknown keys in encoding configuration."""
|
||||||
|
settings.RECORDING_ENABLE = True
|
||||||
|
settings.RECORDING_CUSTOM_ENCODING_ENABLED = True
|
||||||
|
room = RoomFactory()
|
||||||
|
user = UserFactory()
|
||||||
|
room.accesses.create(user=user, role="owner")
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||||
|
{
|
||||||
|
"mode": "screen_recording",
|
||||||
|
"options": {"encoding": {"bitrate": 9000}},
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_recording_options_without_encoding_unchanged(
|
||||||
|
settings, mock_worker_service_factory, mock_worker_manager
|
||||||
|
):
|
||||||
|
"""Requests without encoding should keep existing options behavior."""
|
||||||
|
settings.RECORDING_ENABLE = True
|
||||||
|
room = RoomFactory()
|
||||||
|
user = UserFactory()
|
||||||
|
room.accesses.create(user=user, role="owner")
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||||
|
{"mode": "screen_recording", "options": {"language": "fr"}},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
recording = Recording.objects.get(room=room)
|
||||||
|
assert recording.options == {"language": "fr"}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("value", ["foo", 12])
|
@pytest.mark.parametrize("value", ["foo", 12])
|
||||||
def test_start_recording_options_invalid_transcribe_type(settings, value):
|
def test_start_recording_options_invalid_transcribe_type(settings, value):
|
||||||
"""Should reject non-boolean transcribe values."""
|
"""Should reject non-boolean transcribe values."""
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 l’enregistrement %(id)s"
|
msgstr "Échec de la notification pour l’enregistrement %(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 l’enregistrement %(id)s : %(error)s"
|
msgstr "Échec de la notification pour l’enregistrement %(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 d’arrêt »"
|
msgstr "Marquer les enregistrements sélectionnés comme « Échec d’arrê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 d’arrêt »."
|
"%(count)s enregistrement(s) marqué(s) avec succès comme « Échec d’arrê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 d’une salle"
|
msgstr "Afficher les détails d’une 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 l’application"
|
msgstr "Nom de l’application"
|
||||||
|
|
||||||
#: 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 l’enregistrement. Copiez-le maintenant s’il s’agit d’un "
|
"Haché lors de l’enregistrement. Copiez-le maintenant s’il s’agit d’un "
|
||||||
"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 d’application"
|
msgstr "Domaine d’application"
|
||||||
|
|
||||||
#: core/models.py:822
|
#: core/models.py:847
|
||||||
msgid "Application domains"
|
msgid "Application domains"
|
||||||
msgstr "Domaines d’application"
|
msgstr "Domaines d’application"
|
||||||
|
|
||||||
#: 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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
+171
-20
@@ -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(
|
||||||
@@ -715,28 +721,74 @@ class Base(Configuration):
|
|||||||
# These settings affect screen recordings handled by VideoCompositeEgressService;
|
# These settings affect screen recordings handled by VideoCompositeEgressService;
|
||||||
# they are silently ignored by AudioCompositeEgressService (audio-only transcript
|
# they are silently ignored by AudioCompositeEgressService (audio-only transcript
|
||||||
# recordings), whose request never carries advanced EncodingOptions.
|
# recordings), whose request never carries advanced EncodingOptions.
|
||||||
# When disabled, LiveKit falls back to its built-in H264_720P_30 preset
|
#
|
||||||
# (1280x720, 30 fps, 3000 kbps H.264 MAIN video, 128 kbps AAC audio).
|
# A default encoding is applied to every recording: it is resolved from the default
|
||||||
# When enabled, the values below are passed to LiveKit as EncodingOptions
|
# profile and resolution below and passed to LiveKit as EncodingOptions (advanced),
|
||||||
# (advanced) and replace the preset. Lowering framerate and bitrate reduces
|
# replacing LiveKit's built-in H264_720P_30 preset. Lowering framerate and bitrate
|
||||||
# output file size and CPU load on the egress worker.
|
# reduces output file size and CPU load on the egress worker. If either
|
||||||
RECORDING_ENCODING_ENABLED = values.BooleanValue(
|
# RECORDING_ENCODING_DEFAULT_RESOLUTION or RECORDING_ENCODING_DEFAULT_PROFILE is
|
||||||
False, environ_name="RECORDING_ENCODING_ENABLED", environ_prefix=None
|
# unset, no default encoding is built (a startup warning is emitted) and LiveKit's
|
||||||
|
# built-in preset is used instead.
|
||||||
|
#
|
||||||
|
# RECORDING_CUSTOM_ENCODING_ENABLED gates whether the start-recording API lets a
|
||||||
|
# client override that default per recording (via an `encoding` object selecting a
|
||||||
|
# resolution/profile). When False, the API rejects per-recording `encoding` and
|
||||||
|
# every recording uses the default; when True, clients may pick from the
|
||||||
|
# available resolutions/profiles below.
|
||||||
|
RECORDING_CUSTOM_ENCODING_ENABLED = values.BooleanValue(
|
||||||
|
False, environ_name="RECORDING_CUSTOM_ENCODING_ENABLED", environ_prefix=None
|
||||||
)
|
)
|
||||||
RECORDING_ENCODING_WIDTH = values.PositiveIntegerValue(
|
|
||||||
1280, environ_name="RECORDING_ENCODING_WIDTH", environ_prefix=None
|
# Map resolution string -> {"width", "height"} in pixels.
|
||||||
)
|
RECORDING_ENCODING_AVAILABLE_RESOLUTIONS = values.DictValue(
|
||||||
RECORDING_ENCODING_HEIGHT = values.PositiveIntegerValue(
|
{
|
||||||
720, environ_name="RECORDING_ENCODING_HEIGHT", environ_prefix=None
|
"540p": {"width": 960, "height": 540},
|
||||||
)
|
"720p": {"width": 1280, "height": 720},
|
||||||
RECORDING_ENCODING_FRAMERATE = values.PositiveIntegerValue(
|
"1080p": {"width": 1920, "height": 1080},
|
||||||
30, environ_name="RECORDING_ENCODING_FRAMERATE", environ_prefix=None
|
},
|
||||||
)
|
environ_name="RECORDING_ENCODING_AVAILABLE_RESOLUTIONS",
|
||||||
RECORDING_ENCODING_VIDEO_BITRATE_KBPS = values.PositiveIntegerValue(
|
|
||||||
3000,
|
|
||||||
environ_name="RECORDING_ENCODING_VIDEO_BITRATE_KBPS",
|
|
||||||
environ_prefix=None,
|
environ_prefix=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Map profile string -> {"fps", "kbps": {resolution: video_bitrate_kbps}}.
|
||||||
|
# Bitrate scales with resolution so quality stays consistent across sizes.
|
||||||
|
RECORDING_ENCODING_AVAILABLE_PROFILES = values.DictValue(
|
||||||
|
{
|
||||||
|
"talking_heads": {
|
||||||
|
"fps": 15,
|
||||||
|
"kbps": {"540p": 400, "720p": 700, "1080p": 1200},
|
||||||
|
},
|
||||||
|
"text": {
|
||||||
|
"fps": 15,
|
||||||
|
"kbps": {"540p": 600, "720p": 1000, "1080p": 1800},
|
||||||
|
},
|
||||||
|
"mixed": {
|
||||||
|
"fps": 20,
|
||||||
|
"kbps": {"540p": 900, "720p": 1500, "1080p": 2500},
|
||||||
|
},
|
||||||
|
"full": {
|
||||||
|
"fps": 30,
|
||||||
|
"kbps": {"540p": 2000, "720p": 3000, "1080p": 4500},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
environ_name="RECORDING_ENCODING_AVAILABLE_PROFILES",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Defaults used when no profile/resolution is specified per recording.
|
||||||
|
# Must be keys of the two dicts above (validated at startup).
|
||||||
|
RECORDING_ENCODING_DEFAULT_PROFILE = values.Value(
|
||||||
|
"full",
|
||||||
|
environ_name="RECORDING_ENCODING_DEFAULT_PROFILE",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
RECORDING_ENCODING_DEFAULT_RESOLUTION = values.Value(
|
||||||
|
"720p",
|
||||||
|
environ_name="RECORDING_ENCODING_DEFAULT_RESOLUTION",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Settings independent of profile/resolution.
|
||||||
RECORDING_ENCODING_AUDIO_BITRATE_KBPS = values.PositiveIntegerValue(
|
RECORDING_ENCODING_AUDIO_BITRATE_KBPS = values.PositiveIntegerValue(
|
||||||
128,
|
128,
|
||||||
environ_name="RECORDING_ENCODING_AUDIO_BITRATE_KBPS",
|
environ_name="RECORDING_ENCODING_AUDIO_BITRATE_KBPS",
|
||||||
@@ -748,12 +800,31 @@ 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
|
||||||
)
|
)
|
||||||
@@ -1097,6 +1168,70 @@ class Base(Configuration):
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _check_recording_encoding_maps(cls):
|
||||||
|
"""Ensure the per-recording encoding maps are mutually consistent.
|
||||||
|
|
||||||
|
Every profile in RECORDING_ENCODING_AVAILABLE_PROFILES must define a bitrate for
|
||||||
|
each resolution declared in RECORDING_ENCODING_AVAILABLE_RESOLUTIONS.
|
||||||
|
|
||||||
|
The default profile / resolution feed the default encoding. When either is
|
||||||
|
missing, no custom default encoding can be built: a warning is emitted and
|
||||||
|
recordings fall back to LiveKit's built-in preset. When both are set, they
|
||||||
|
must reference keys that actually exist in the maps above.
|
||||||
|
"""
|
||||||
|
resolutions = set(cls.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS)
|
||||||
|
profiles = set(cls.RECORDING_ENCODING_AVAILABLE_PROFILES)
|
||||||
|
# DictValue resolves to a dict at runtime; pylint sees the descriptor.
|
||||||
|
for (
|
||||||
|
profile,
|
||||||
|
profile_config,
|
||||||
|
) in cls.RECORDING_ENCODING_AVAILABLE_PROFILES.items(): # pylint: disable=no-member
|
||||||
|
profile_resolutions = set(profile_config["kbps"])
|
||||||
|
if profile_resolutions != resolutions:
|
||||||
|
raise ValueError(
|
||||||
|
f"Profile '{profile}' in RECORDING_ENCODING_AVAILABLE_PROFILES must "
|
||||||
|
"define a bitrate for exactly the resolutions in "
|
||||||
|
"RECORDING_ENCODING_AVAILABLE_RESOLUTIONS, mismatch on: "
|
||||||
|
f"{resolutions ^ profile_resolutions}"
|
||||||
|
)
|
||||||
|
|
||||||
|
missing = [
|
||||||
|
name
|
||||||
|
for name, value in (
|
||||||
|
(
|
||||||
|
"RECORDING_ENCODING_DEFAULT_RESOLUTION",
|
||||||
|
cls.RECORDING_ENCODING_DEFAULT_RESOLUTION,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"RECORDING_ENCODING_DEFAULT_PROFILE",
|
||||||
|
cls.RECORDING_ENCODING_DEFAULT_PROFILE,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if not value
|
||||||
|
]
|
||||||
|
if missing:
|
||||||
|
warnings.warn(
|
||||||
|
f"{' and '.join(missing)} not set; recordings will use LiveKit's "
|
||||||
|
"built-in encoding preset instead of a custom default encoding.",
|
||||||
|
UserWarning,
|
||||||
|
stacklevel=2,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if cls.RECORDING_ENCODING_DEFAULT_RESOLUTION not in resolutions:
|
||||||
|
raise ValueError(
|
||||||
|
"RECORDING_ENCODING_DEFAULT_RESOLUTION "
|
||||||
|
f"'{cls.RECORDING_ENCODING_DEFAULT_RESOLUTION}' is not a key of "
|
||||||
|
f"RECORDING_ENCODING_AVAILABLE_RESOLUTIONS ({sorted(resolutions)})."
|
||||||
|
)
|
||||||
|
if cls.RECORDING_ENCODING_DEFAULT_PROFILE not in profiles:
|
||||||
|
raise ValueError(
|
||||||
|
"RECORDING_ENCODING_DEFAULT_PROFILE "
|
||||||
|
f"'{cls.RECORDING_ENCODING_DEFAULT_PROFILE}' is not a key of "
|
||||||
|
f"RECORDING_ENCODING_AVAILABLE_PROFILES ({sorted(profiles)})."
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def post_setup(cls):
|
def post_setup(cls):
|
||||||
"""Post setup configuration.
|
"""Post setup configuration.
|
||||||
@@ -1110,11 +1245,27 @@ class Base(Configuration):
|
|||||||
"FILE_UPLOAD_TMP_PATH cannot be the same as FILE_UPLOAD_PATH"
|
"FILE_UPLOAD_TMP_PATH cannot be the same as FILE_UPLOAD_PATH"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
cls._check_recording_encoding_maps()
|
||||||
|
|
||||||
|
if (
|
||||||
|
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()],
|
||||||
)
|
)
|
||||||
|
|||||||
+13
-13
@@ -7,7 +7,7 @@ build-backend = "uv_build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "meet"
|
name = "meet"
|
||||||
version = "1.22.0"
|
version = "1.23.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",
|
||||||
@@ -24,19 +24,19 @@ keywords = ["Django", "Contacts", "Templates", "RBAC"]
|
|||||||
license = "MIT"
|
license = "MIT"
|
||||||
requires-python = ">=3.13"
|
requires-python = ">=3.13"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"boto3==1.43.14",
|
"boto3==1.43.36",
|
||||||
"Brotli==1.2.0",
|
"Brotli==1.2.0",
|
||||||
"brevo-python==1.2.0",
|
"brevo-python==1.2.0",
|
||||||
"celery[redis]==5.6.3",
|
"celery[redis]==5.6.3",
|
||||||
"dj-database-url==3.1.2",
|
"dj-database-url==3.1.2",
|
||||||
"django-configurations==2.5.1",
|
"django-configurations==2.5.1",
|
||||||
"django-cors-headers==4.9.0",
|
"django-cors-headers==4.9.0",
|
||||||
"django-countries==8.2.0",
|
"django-countries==9.0.0",
|
||||||
"django-filter==25.2",
|
"django-filter==25.2",
|
||||||
"django-lasuite[all]==0.0.26",
|
"django-lasuite[all]==0.0.27",
|
||||||
"django-parler==2.4",
|
"django-parler==2.4",
|
||||||
"redis==5.2.1",
|
"redis==5.2.1",
|
||||||
"django-redis==6.0.0",
|
"django-redis==7.0.0",
|
||||||
"django-storages[s3]==1.14.6",
|
"django-storages[s3]==1.14.6",
|
||||||
"django-timezone-field>=5.1",
|
"django-timezone-field>=5.1",
|
||||||
"django-pydantic-field==0.5.4",
|
"django-pydantic-field==0.5.4",
|
||||||
@@ -57,13 +57,13 @@ dependencies = [
|
|||||||
"python-frontmatter==1.3.0",
|
"python-frontmatter==1.3.0",
|
||||||
"python-magic==0.4.27",
|
"python-magic==0.4.27",
|
||||||
"requests==2.34.2",
|
"requests==2.34.2",
|
||||||
"sentry-sdk==2.60.0",
|
"sentry-sdk==2.63.0",
|
||||||
"whitenoise==6.12.0",
|
"whitenoise==6.12.0",
|
||||||
"mozilla-django-oidc==5.0.2",
|
"mozilla-django-oidc==5.0.2",
|
||||||
"livekit-api==1.1.0",
|
"livekit-api==1.1.1",
|
||||||
"aiohttp==3.14.0",
|
"aiohttp==3.14.1",
|
||||||
"urllib3==2.7.0",
|
"urllib3==2.7.0",
|
||||||
"phonenumbers==9.0.31",
|
"phonenumbers==9.0.33",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
@@ -75,20 +75,20 @@ dependencies = [
|
|||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
"django-extensions==4.1",
|
"django-extensions==4.1",
|
||||||
"drf-spectacular-sidecar==2026.5.1",
|
"drf-spectacular-sidecar==2026.6.1",
|
||||||
"freezegun==1.5.5",
|
"freezegun==1.5.5",
|
||||||
"ipdb==0.13.13",
|
"ipdb==0.13.13",
|
||||||
"ipython==9.13.0",
|
"ipython==9.14.1",
|
||||||
"pyfakefs==6.2.0",
|
"pyfakefs==6.2.0",
|
||||||
"pylint-django==2.7.0",
|
"pylint-django==2.7.0",
|
||||||
"pylint<4.0.0",
|
"pylint<4.0.0",
|
||||||
"pytest-cov==7.1.0",
|
"pytest-cov==7.1.0",
|
||||||
"pytest-django==4.12.0",
|
"pytest-django==4.12.0",
|
||||||
"pytest==9.0.3",
|
"pytest==9.1.1",
|
||||||
"pytest-icdiff==0.9",
|
"pytest-icdiff==0.9",
|
||||||
"pytest-xdist==3.8.0",
|
"pytest-xdist==3.8.0",
|
||||||
"responses==0.26.1",
|
"responses==0.26.1",
|
||||||
"ruff==0.15.14",
|
"ruff==0.15.19",
|
||||||
"types-requests==2.33.0.20260518",
|
"types-requests==2.33.0.20260518",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
Generated
+508
-517
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
|
|
||||||
|
|||||||
Generated
+228
-49
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"version": "1.22.0",
|
"version": "1.23.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"version": "1.22.0",
|
"version": "1.23.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": {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.22.0",
|
"version": "1.23.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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -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(
|
||||||
|
|||||||
+4
-5
@@ -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',
|
||||||
|
|||||||
+19
-4
@@ -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',
|
||||||
|
|||||||
+13
-6
@@ -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 ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -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,
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Generated
+1904
-942
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "mail_mjml",
|
"name": "mail_mjml",
|
||||||
"version": "1.22.0",
|
"version": "1.23.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": {
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "sdk",
|
"name": "sdk",
|
||||||
"version": "1.22.0",
|
"version": "1.23.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sdk",
|
"name": "sdk",
|
||||||
"version": "1.22.0",
|
"version": "1.23.0",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"./library",
|
"./library",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sdk",
|
"name": "sdk",
|
||||||
"version": "1.22.0",
|
"version": "1.23.0",
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"description": "",
|
"description": "",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "summary"
|
name = "summary"
|
||||||
version = "1.22.0"
|
version = "1.23.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastapi[standard]>=0.105.0",
|
"fastapi[standard]>=0.105.0",
|
||||||
"uvicorn>=0.24.0",
|
"uvicorn>=0.24.0",
|
||||||
@@ -10,17 +10,17 @@ dependencies = [
|
|||||||
"celery==5.6.3",
|
"celery==5.6.3",
|
||||||
"redis==5.2.1",
|
"redis==5.2.1",
|
||||||
"minio==7.2.20",
|
"minio==7.2.20",
|
||||||
"openai==2.38.0",
|
"openai==2.44.0",
|
||||||
"posthog==7.15.4",
|
"posthog==7.20.5",
|
||||||
"requests==2.34.2",
|
"requests==2.34.2",
|
||||||
"sentry-sdk[fastapi, celery]==2.60.0",
|
"sentry-sdk[fastapi, celery]==2.63.0",
|
||||||
"langfuse==4.6.1"
|
"langfuse==4.11.0"
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
"ruff==0.15.14",
|
"ruff==0.15.19",
|
||||||
"pytest==9.0.3",
|
"pytest==9.1.1",
|
||||||
"responses>=0.25.8",
|
"responses>=0.25.8",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
|||||||
@@ -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}
|
|
||||||
@@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user