Compare commits

..

6 Commits

Author SHA1 Message Date
lebaudantoine 21866a731f fixup! 🐛(backend) allow all printable ASCII in the user sub field 2026-09-03 15:23:24 +02:00
lebaudantoine 3a93826166 🐛(backend) allow all printable ASCII in the user sub field
The user `sub` field was rejecting some ASCII characters that are
actually valid according to the OIDC spec.

Loosen the validation to accept the full ASCII range except control
characters, so the field is compliant with the RFC and works with
any spec-compliant identity provider.

Based on the Stack Overflow discussion in question 279832.

Closes #1609.
2026-09-03 14:50:56 +02:00
leo 8473069670 ⬆️(docker) upgrade LiveKit server to v1.13.6
The compose stack referenced livekit/livekit-server without a tag,
which resolved to :latest. Docker doesn't re-resolve a mutable tag it
already holds locally, so images earlier than v1.12.0 crashed
on startup:

    could not parse config: yaml: unmarshal errors:
      line 20: field allow_restricted_peer_cidrs not found in
      type config.TURNConfig

LiveKit parses its config in strict mode, and allow_restricted_peer_cidrs
only exists since v1.12.0. The TURN block added in bd81c994 therefore
carried an minimum version which was not reflected.

The Tilt/Helm stack builds its own image from docker/livekit/Dockerfile
to inject the mkcert root CA, and that was still based on v1.9.4. Bump
it to the same version so both dev stacks run the same server.
2026-09-03 00:16:04 +02:00
Paul Csiki cf3960db95 (backend) add Traefik reverse proxy support for media-auth
Adds support for serving media behind Traefik, which currently cannot work
at all.

The media-auth subrequest views read the original request URL from a
hardcoded HTTP_X_ORIGINAL_URL header. That header is an nginx-ingress
convention. Traefik's ForwardAuth middleware sends X-Forwarded-Uri instead
and has no mechanism to emit X-Original-URL, so behind Traefik every
recording download and file attachment is rejected with a bare 403 --
indistinguishable from a legitimate permission denial, which makes it
painful to diagnose.

Add MEDIA_AUTH_ORIGINAL_URL_HEADER, defaulting to HTTP_X_ORIGINAL_URL so
existing nginx-ingress deployments are unaffected. Traefik deployments set
it to HTTP_X_FORWARDED_URI. It is used in both places that resolve the
header: RecordingViewSet._auth_get_original_url and the file attachment
_authorize_subrequest. The log message on a missing header now names the
header actually expected, which is what makes the failure diagnosable.

This mirrors the setting the sibling Docs project already exposes
(suitenumerique/docs, MEDIA_AUTH_ORIGINAL_URL_HEADER) for the same reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 18:07:23 +02:00
lebaudantoine d80d31897c 🔒️(frontend) fix HIGH CVEs in libexpat 2.8.2-r0
Address the following HIGH severity CVEs in libexpat 2.8.2-r0,
reported by Trivy:

* CVE-2026-66046
* CVE-2026-76641
2026-09-02 15:05:01 +02:00
kaelvar 63a7751072 (frontend) add 1080p sending resolution option
The sending resolution selector stopped at 720p while `VideoPresets` already
exposes `h1080` (1920x1080), so publishers on a good uplink could not make use
of the capacity they had. Add "Very high definition (1080p)" above the existing
entries, translated in the five supported locales.

The default stays `h720`, so nothing changes unless a user goes and picks the
new entry. Being explicit about what that costs, since 1080p roughly doubles a
publisher's uplink: this is a per-user choice, and an instance operator has no
way today to decline it. Whether that warrants a server-side setting alongside
the existing `ApiConfig` flags is a call for maintainers — happy to add one if
you want it, rather than change the API contract unasked in a frontend PR.

While here, make the option list harder to get wrong. Resolutions now come from
a single `VIDEO_RESOLUTIONS` tuple that `VideoResolution` derives from, the
selector items are built by mapping over it against a
`Record<VideoResolution, string>` of labels — so a resolution cannot be added
to one and forgotten in the other — and a persisted value that is not in the
tuple falls back to `h720` instead of reaching `VideoPresets[...]` as
`undefined`, since `loadUserChoices` spreads localStorage without validating
it.

Known limitation, unchanged by this patch: `restartTrack` passes the resolution
as an `ideal` constraint, so a camera that cannot reach the selected height
degrades silently. That is already true of 720p on a 480p webcam; 1080p is the
first step where the gap is the common case rather than the edge one.
2026-09-02 15:05:01 +02:00
37 changed files with 1467 additions and 1820 deletions
+5 -3
View File
@@ -10,14 +10,16 @@ and this project adheres to
### Added
- ✨(backend) add per-recording encoding quality presets to start-recording API
- ✨(frontend) add 1080p sending resolution option #1660
- ✨(backend) add Traefik support via configurable media-auth url header #1649
### Changed
git
- 💥(backend) replace recording encoding options with a profile model
- ⬆️(dev) pin LiveKit server to v1.13.6
### Fixed
- 🐛(backend) allow any printable ASCII characters in user sub field #1673
- 🐛(frontend) keep the sending resolution picked while the camera is off #1667
## [1.30.0] - 2026-09-01
-122
View File
@@ -16,128 +16,6 @@ the following command inside your docker container:
## [Unreleased]
### Recording encoding settings replaced by a resolution/profile model
The `RECORDING_ENCODING_*` settings introduced in v1.16.0 exposed raw encoder
values (width, height, framerate, bitrate). They are replaced by two named and configurable sets of
dimensions, a **resolution** (default: `540p`, `720p`, `1080p`) and a **profile**
(default: `talking_heads`, `text`, `mixed`, `full`), which are resolved to the width, height,
fps and video bitrate.
**The following environment variables are no longer read. If they are still set in
your deployment they are silently ignored, and your recordings will be encoded with
the new defaults instead of your tuned values.**
| Removed variable | Replaced by |
| --------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| `RECORDING_ENCODING_ENABLED` | Nothing. A default encoding is now always built (see below). **Not** `RECORDING_CUSTOM_ENCODING_ENABLED`, which gates a different feature. |
| `RECORDING_ENCODING_WIDTH` | The `width` of the entry selected by `RECORDING_ENCODING_DEFAULT_RESOLUTION` in `RECORDING_ENCODING_AVAILABLE_RESOLUTIONS`. |
| `RECORDING_ENCODING_HEIGHT` | The `height` of that same entry. |
| `RECORDING_ENCODING_FRAMERATE` | The `fps` of the profile selected by `RECORDING_ENCODING_DEFAULT_PROFILE` in `RECORDING_ENCODING_AVAILABLE_PROFILES`. |
| `RECORDING_ENCODING_VIDEO_BITRATE_KBPS` | That profile's `kbps`. |
`RECORDING_ENCODING_AUDIO_BITRATE_KBPS` and `RECORDING_ENCODING_KEY_FRAME_INTERVAL_S`
keep their names and meaning. The keyframe interval now defaults to `0` (unset,
encoder's choice) instead of `4.0`.
#### If you never set `RECORDING_ENCODING_ENABLED=True`
The shipped defaults (`RECORDING_ENCODING_DEFAULT_PROFILE=full`,
`RECORDING_ENCODING_DEFAULT_RESOLUTION=720p`) match LiveKit's built-in
`H264_720P_30` preset: 1280×720, 30 fps, 3000 kbps H.264 MAIN, 128 kbps AAC.
Video output is therefore unchanged.
Audio and keyframing may not be. These values are now sent explicitly as advanced
`EncodingOptions` rather than relying on LiveKit's preset, so
`RECORDING_ENCODING_AUDIO_BITRATE_KBPS` and `RECORDING_ENCODING_KEY_FRAME_INTERVAL_S`
now apply to every recording. They previously applied only when
`RECORDING_ENCODING_ENABLED` was `True`. **If you set either of them while the
feature was disabled, they had no effect and now do**; check them before upgrading.
If you never set them, no action is required: 128 kbps AAC is what the preset used,
and the keyframe interval now defaults to `0`, which leaves the field unset so the
encoder keeps picking it as before. Set `RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=4.0`
if you want fixed 4-second keyframes (the value the setting defaulted to while it
was gated behind `RECORDING_ENCODING_ENABLED`).
To keep letting LiveKit pick the encoding instead, set either default to an empty
value:
```
RECORDING_ENCODING_DEFAULT_RESOLUTION=
RECORDING_ENCODING_DEFAULT_PROFILE=
```
#### If you had tuned `RECORDING_ENCODING_*` values
Translate your old values into a default resolution and a default profile. Declare your own resolution and/or profile. Both maps are read from the
environment as a single-line Python/JSON dict literal (parsed with
`ast.literal_eval`, so use double-quoted keys and no trailing commas, and do not
add outer quotes in `.env`-style files):
```bash
RECORDING_ENCODING_AVAILABLE_RESOLUTIONS={"540p": {"width": 960, "height": 540}, "720p": {"width": 1280, "height": 720}, "1080p": {"width": 1920, "height": 1080}}
RECORDING_ENCODING_AVAILABLE_PROFILES={"my_old_profile": {"fps": 15, "kbps": {"540p": 350, "720p": 600, "1080p": 1100}}}
RECORDING_ENCODING_DEFAULT_RESOLUTION=720p
RECORDING_ENCODING_DEFAULT_PROFILE=my_old_profile
```
Both maps are validated at startup and a malformed one raises a `ValueError`:
- every entry of `RECORDING_ENCODING_AVAILABLE_RESOLUTIONS` must declare `width` and
`height`, and every entry of `RECORDING_ENCODING_AVAILABLE_PROFILES` an `fps` and a
`kbps` map;
- every profile must define a `kbps` entry for **exactly** the keys of
`RECORDING_ENCODING_AVAILABLE_RESOLUTIONS`; overriding one of the two maps usually
means overriding both;
- `RECORDING_ENCODING_DEFAULT_RESOLUTION` and `RECORDING_ENCODING_DEFAULT_PROFILE`,
when non-empty, must be keys of their respective map.
#### Breaking: custom worker services must accept `encoding_options`
Only concerns deployments pointing `RECORDING_WORKER_CLASSES` at their own worker
class. The shipped `VideoCompositeEgressService` and `AudioCompositeEgressService`
are already updated.
The `WorkerService` protocol's `start()` takes a third argument, and the mediator
now always passes it as a keyword when the recording carries no per-recording encoding:
```python
# before
def start(self, room_id: str, recording_id: str) -> str: ...
# now
def start(
self,
room_id: str,
recording_id: str,
encoding_options: Optional[Dict[str, Any]] = None,
) -> str: ...
```
#### Optional: per-recording encoding
`RECORDING_CUSTOM_ENCODING_ENABLED` (default `False`) toggles whether the
start-recording API accepts an `encoding` object
(`{"resolution": "720p", "profile": "talking_heads"}`, `profile` optional. It
falls back to `RECORDING_ENCODING_DEFAULT_PROFILE`) that overrides the default for
a single recording. It does not enable or disable the
default encoding, which is built from the two `RECORDING_ENCODING_DEFAULT_*`
settings either way. Leaving it at `False` preserves the previous behaviour, where
every recording uses the server-side encoding: requests carrying
`options.encoding` are rejected with a `400` before the recording is created, so
nothing is persisted and no egress is started.
Before enabling it:
- clients can only pick keys you declared; there is no way to send a raw width or bitrate
- as of this implementation, the frontend never sends `encoding`
- `encoding` is accepted but ignored for `transcript` recordings, whose audio-only
egress has no video encoding to configure.
See [docs/features/recording.md](docs/features/recording.md#tuning-recording-encoding)
for the full setting reference, the shipped profile table and the tuning caveats.
## v1.30.0
### Removing S3 storage-event webhooks for recordings
+1 -1
View File
@@ -207,7 +207,7 @@ services:
- kc_postgresql
livekit:
image: livekit/livekit-server
image: livekit/livekit-server:v1.13.6
entrypoint: /livekit-server --dev --bind 0.0.0.0 --config ./config.yaml
ports:
- "7880:7880"
+1
View File
@@ -65,6 +65,7 @@ RUN apk update && apk upgrade \
musl \
musl-utils \
zlib>=1.3.2-r0 \
libexpat>=2.8.4-r0 \
&& apk del curl
USER nginx
+1 -1
View File
@@ -1,4 +1,4 @@
FROM livekit/livekit-server:v1.9.4
FROM livekit/livekit-server:v1.13.6
# We inject the nip.io certificate manually because the livekit chart doesn't support volume mounting
COPY rootCA.pem /etc/ssl/certs/
+34 -41
View File
@@ -93,13 +93,13 @@ sequenceDiagram
| **RECORDING_WORKER_CLASSES** | Dict | `{ "screen_recording": "core.recording.worker.services.VideoCompositeEgressService", "transcript": "core.recording.worker.services.AudioCompositeEgressService" }` | Maps recording types to their worker service classes. |
| **RECORDING_EXPIRATION_DAYS** | Integer | `None` | Number of days before recordings expire. Should match bucket lifecycle policy. Set to `None` for no expiration. |
| **RECORDING_MAX_DURATION** | Integer | `None` | Maximum duration of a recording in milliseconds. Must be synced with the LiveKit Egress configuration. Set to None for unlimited duration. When the maximum duration is reached, the recording is automatically stopped and saved, and the user is prompted in the frontend with an alert message. |
| **RECORDING_CUSTOM_ENCODING_ENABLED** | Boolean | `False` | Whether the start-recording API accepts a per-recording `encoding` object (resolution/profile) that overrides the default. When `False`, the API rejects per-recording `encoding`; when `True`, clients may pick from the available resolutions/profiles. The default encoding below is applied regardless of this flag. See [Tuning recording encoding](#tuning-recording-encoding). |
| **RECORDING_ENCODING_AVAILABLE_RESOLUTIONS** | Dict | `{"540p": {"width": 960, "height": 540}, "720p": {"width": 1280, "height": 720}, "1080p": {"width": 1920, "height": 1080}}` | Maps a resolution name to its `{"width", "height"}` in pixels. Both the default encoding and the per-recording start-recording API pick from these keys. |
| **RECORDING_ENCODING_AVAILABLE_PROFILES** | Dict | `{"full": {"fps": 30, "kbps": {…}}, …}` | Maps a profile name to `{"fps", "kbps": {resolution: video_bitrate_kbps}}`. Every profile must define a bitrate for each available resolution (validated at startup). |
| **RECORDING_ENCODING_DEFAULT_RESOLUTION** | String | `"720p"` | Resolution used by the default encoding. When set, must be a key of `RECORDING_ENCODING_AVAILABLE_RESOLUTIONS`. Leave unset (together with, or instead of, the default profile) to disable the custom default encoding and fall back to LiveKit's built-in preset (a startup warning is emitted). |
| **RECORDING_ENCODING_DEFAULT_PROFILE** | String | `"full"` | Profile used by the default encoding. When set, must be a key of `RECORDING_ENCODING_AVAILABLE_PROFILES`. Leave unset (together with, or instead of, the default resolution) to disable the custom default encoding and fall back to LiveKit's built-in preset (a startup warning is emitted). |
| **RECORDING_ENCODING_AUDIO_BITRATE_KBPS** | Integer | `128` | AAC audio bitrate in kbps used in the default encoding. |
| **RECORDING_ENCODING_KEY_FRAME_INTERVAL_S** | Float | `0.0` | Keyframe interval in seconds. Drives seek granularity in the recorded MP4 (a player can only seek to keyframe boundaries). Larger values give the encoder slightly more bits for non-keyframe content at a fixed bitrate. `0` leaves the field unset, letting the encoder pick; `4.0` is a standard VOD value. |
| **RECORDING_ENCODING_ENABLED** | Boolean | `False` | When `False`, LiveKit Egress uses its built-in `H264_720P_30` preset. When `True`, the `RECORDING_ENCODING_*` values below are sent to LiveKit as advanced `EncodingOptions`. See [Tuning recording encoding](#tuning-recording-encoding). |
| **RECORDING_ENCODING_WIDTH** | Integer | `1280` | Recording video width in pixels. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
| **RECORDING_ENCODING_HEIGHT** | Integer | `720` | Recording video height in pixels. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
| **RECORDING_ENCODING_FRAMERATE** | Integer | `30` | Recording video framerate (fps). Directly impacts egress worker CPU (roughly linear). Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
| **RECORDING_ENCODING_VIDEO_BITRATE_KBPS** | Integer | `3000` | H.264 MAIN video bitrate in kbps. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
| **RECORDING_ENCODING_AUDIO_BITRATE_KBPS** | Integer | `128` | AAC audio bitrate in kbps. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
| **RECORDING_ENCODING_KEY_FRAME_INTERVAL_S** | Float | `4.0` | Keyframe interval in seconds. Drives seek granularity in the recorded MP4 (a player can only seek to keyframe boundaries). Larger values give the encoder slightly more bits for non-keyframe content at a fixed bitrate. `4.0` is a standard VOD value. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
> [!NOTE]
@@ -130,59 +130,52 @@ This allows you to verify which recordings are in progress, troubleshoot egress
## Tuning recording encoding
Every video recording is encoded from a default resolved from `RECORDING_ENCODING_DEFAULT_PROFILE` + `RECORDING_ENCODING_DEFAULT_RESOLUTION` and passed to LiveKit as advanced `EncodingOptions`. The shipped defaults (`full` profile) match LiveKit's built-in `H264_720P_30` preset. For a one-hour meeting that produces a file of roughly **1.4 GB**, which is often heavier than necessary for talking-head content and screen sharing; lowering the default profile/resolution shrinks it. If either default is left unset, no custom default encoding is built: a warning is logged at startup and LiveKit's built-in preset is used instead.
By default, LiveKit Egress records with the built-in `H264_720P_30` preset: 1280×720 at 30 fps, 3000 kbps H.264 MAIN video and 128 kbps AAC audio. For a one-hour meeting this produces a file of roughly **1.4 GB**, which is often heavier than necessary for talking-head content and screen sharing.
Encoding is chosen from two maps: `RECORDING_ENCODING_AVAILABLE_RESOLUTIONS` (`resolution → {"width", "height"}`) and `RECORDING_ENCODING_AVAILABLE_PROFILES` (`profile → {"fps", "kbps": {resolution: video_bitrate_kbps}}`):
- **Default**: `RECORDING_ENCODING_DEFAULT_PROFILE` + `RECORDING_ENCODING_DEFAULT_RESOLUTION` set the encoding used by every recording that doesn't override it. Leave either unset to fall back to LiveKit's built-in preset (a startup warning is emitted).
- **Per recording (opt-in)**: set `RECORDING_CUSTOM_ENCODING_ENABLED=True` to let clients override the default per recording. The start-recording API then accepts an `encoding` object selecting a `resolution` (required) and `profile` (optional): a resolution-only request keeps `RECORDING_ENCODING_DEFAULT_PROFILE` for fps and bitrate, so clients can only pick from pre-defined values. When `RECORDING_CUSTOM_ENCODING_ENABLED=False`, the API rejects any per-recording `encoding` and the default is used.
The resolved values are passed straight through LiveKit's `EncodingOptions.advanced` to the GStreamer pipeline (`x264enc` for video, `faac` for audio), so there are no hidden conversions — what the profile/resolution resolve to is what the encoder receives.
The `RECORDING_ENCODING_*` settings let operators override this preset without modifying the source. Values are passed straight through LiveKit's `EncodingOptions.advanced` to the GStreamer pipeline (`x264enc` for video, `faac` for audio), so there are no hidden conversions — what you set is what the encoder receives.
### How values map to GStreamer
| Resolved value | GStreamer element | Property |
| ----------------------------------------- | ----------------- | ---------------------------------- |
| resolution `width` / `height` | capsfilter | `video/x-raw,width=W,height=H` |
| profile `fps` | capsfilter | `framerate=F/1` |
| profile `kbps[resolution]` | `x264enc` | `bitrate=kbps` (kilobits) |
| `RECORDING_ENCODING_KEY_FRAME_INTERVAL_S` | `x264enc` | `key-int-max = interval × fps` |
| `RECORDING_ENCODING_AUDIO_BITRATE_KBPS` | `faac` | `bitrate = kbps × 1000` (bits) |
| Setting | GStreamer element | Property |
| ------------------------------------- | ----------------- | ---------------------------------- |
| `RECORDING_ENCODING_WIDTH/HEIGHT` | capsfilter | `video/x-raw,width=W,height=H` |
| `RECORDING_ENCODING_FRAMERATE` | capsfilter | `framerate=F/1` |
| `RECORDING_ENCODING_VIDEO_BITRATE_KBPS` | `x264enc` | `bitrate=kbps` (kilobits) |
| `RECORDING_ENCODING_KEY_FRAME_INTERVAL_S` | `x264enc` | `key-int-max = interval × fps` |
| `RECORDING_ENCODING_AUDIO_BITRATE_KBPS` | `faac` | `bitrate = kbps × 1000` (bits) |
The H.264 profile is fixed to MAIN and the x264 `speed-preset` to `veryfast` by LiveKit (real-time constraint) — lowering the framerate is therefore the main lever to save CPU, while lowering the bitrate is the main lever to shrink the output file.
### Built-in profiles
### Reference profiles
The default `RECORDING_ENCODING_AVAILABLE_PROFILES` ship four profiles. Framerate is fixed per profile; video bitrate (kbps) scales with resolution so quality stays consistent across sizes. File size scales roughly with `framerate × bitrate`, and so does egress CPU cost.
Rough 30-minute file-size estimates assume video + audio bitrate multiplied by duration. Actual sizes vary with content (static talking heads compress better than heavy screen motion). Egress CPU figures are indicative, measured on a single Ryzen laptop core saturated by the default preset (= 100 %); scaling is roughly linear with `framerate × bitrate` but the absolute numbers depend on the host hardware.
| Profile | FPS | 540p (kbps) | 720p (kbps) | 1080p (kbps) | Suitable for |
| --------------- | --- | ----------- | ----------- | ------------ | -------------------------------------------------- |
| `talking_heads` | 15 | 400 | 700 | 1200 | Talking-head dominant meetings + occasional slides |
| `text` | 15 | 600 | 1000 | 1800 | Frequent dense screen sharing (decks, IDE, docs) |
| `mixed` | 20 | 900 | 1500 | 2500 | Mixed content, moderate motion |
| `full` | 30 | 2000 | 3000 | 4500 | Highest fidelity; closest to the LiveKit default preset |
| Profile | Resolution | FPS | Video (kbps) | Audio (kbps) | Keyframe (s) | ~ size / 30 min | Egress CPU (vs. default) | Suitable for |
| ---------------------- | ---------- | --- | ------------ | ------------ | ------------ | --------------- | ------------------------ | --------------------------------------------------- |
| Default (preset) | 1280×720 | 30 | 3000 | 128 | 4 | **~690 MB** | 100 % | Unchanged LiveKit behaviour |
| Balanced | 1280×720 | 20 | 1000 | 96 | 4 | ~240 MB | ~67 % | Mixed content, moderate motion |
| **Low CPU / small file** | 1280×720 | 15 | 600 | 64 | 4 | **~150 MB** | ~50 % | Talking-head dominant meetings + occasional slides ★ |
| Slide-heavy | 1280×720 | 15 | 900 | 64 | 4 | ~210 MB | ~55 % | Frequent dense screen sharing (decks, IDE, docs) |
| Minimum CPU | 960×540 | 15 | 500 | 64 | 4 | ~125 MB | ~30 % | Voice-first meetings, readable text not required |
| Audio-heavy fallback | 1280×720 | 10 | 400 | 96 | 4 | ~110 MB | ~35 % | Long webinars, low motion |
To pick a profile per recording (requires `RECORDING_CUSTOM_ENCODING_ENABLED=True`), the client sends it in the start-recording request:
★ Recommended starting point for typical LaSuite Meet usage.
```json
{
"mode": "screen_recording",
"options": {"encoding": {"resolution": "720p", "profile": "talking_heads"}}
}
```
To change the default encoding applied to every recording:
Environment variables for the **Low CPU / small file** profile:
```bash
RECORDING_ENCODING_DEFAULT_RESOLUTION=720p
RECORDING_ENCODING_DEFAULT_PROFILE=talking_heads
RECORDING_ENCODING_ENABLED=True
RECORDING_ENCODING_WIDTH=1280
RECORDING_ENCODING_HEIGHT=720
RECORDING_ENCODING_FRAMERATE=15
RECORDING_ENCODING_VIDEO_BITRATE_KBPS=600
RECORDING_ENCODING_AUDIO_BITRATE_KBPS=64
RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=4.0
```
### Caveats
- **Screen-share readability — think bits/frame, not bitrate**: at 720p, text legibility starts to break down below ~40 kbits/frame (= `bitrate ÷ framerate`). The `talking_heads` profile (700 kbps × 15 fps) sits just above that threshold, comfortable for talking heads with occasional slide sharing. The same bitrate at 30 fps would only deliver ~23 kbits/frame and visibly blur dense slides — which is why **lowering framerate is a more screen-share-friendly lever than lowering bitrate**. For deck-heavy or IDE-share meetings, prefer the **`text`** profile (1000 kbps × 15 fps ≈ 67 kbits/frame).
- **Screen-share readability — think bits/frame, not bitrate**: at 720p, text legibility starts to break down below ~40 kbits/frame (= `bitrate ÷ framerate`). The recommended preset (600 kbps × 15 fps) sits at exactly that threshold, comfortable for talking heads with occasional slide sharing. The same 600 kbps at 30 fps would only deliver 20 kbits/frame and visibly blur dense slides — which is why **lowering framerate is a more screen-share-friendly lever than lowering bitrate**. For deck-heavy or IDE-share meetings, prefer the **Slide-heavy** profile (900 kbps × 15 fps ≈ 60 kbits/frame).
- **Motion handling**: the `veryfast` x264 preset is set by LiveKit and cannot be overridden here. Low-bitrate settings will therefore show more artefacts on fast motion than an offline re-encode with a slower preset would. This is the other reason FPS reduction is the safer tuning lever for meeting recordings.
- **Audio**: AAC at 64 kbps stereo is transparent for voice but starts to compress music noticeably. Keep 128 kbps if you expect music playback in meetings.
- **Codec choice**: H.264 MAIN is hardcoded on purpose. Switching to HEVC or VP9 would increase egress CPU cost 2×–5×, defeating the goal of this tuning.
+2 -50
View File
@@ -13,12 +13,7 @@ from django.core.exceptions import SuspiciousOperation
from django.utils.translation import gettext_lazy as _
from django_pydantic_field.rest_framework import SchemaField
from pydantic import (
BaseModel,
Field,
field_serializer,
field_validator,
)
from pydantic import BaseModel, Field, field_serializer
from pydantic import ValidationError as PydanticValidationError
from rest_framework import serializers
from rest_framework.exceptions import PermissionDenied
@@ -249,49 +244,6 @@ class BaseValidationOnlySerializer(serializers.Serializer):
raise NotImplementedError(f"{self.__class__.__name__} is validation-only")
class EncodingConfig(BaseModel):
"""Configuration options for recording encoding.
The allowed `resolution` and `profile` values are derived at validation time
from ``settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS`` and
``settings.RECORDING_ENCODING_AVAILABLE_PROFILES``, so adding a resolution or profile
to those maps is enough to make it accepted here.
Attributes:
resolution: Target video resolution.
profile: Encoding profile to fps and kbps. When `None`,
`settings.RECORDING_ENCODING_DEFAULT_PROFILE` applies.
"""
resolution: str
profile: str | None = None
model_config = {"extra": "forbid"}
@field_validator("resolution")
@classmethod
def _validate_resolution(cls, value):
"""Reject resolutions absent from RECORDING_ENCODING_AVAILABLE_RESOLUTIONS."""
allowed = set(settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS)
if value not in allowed:
raise ValueError(
f"Invalid resolution '{value}'. Choose from {sorted(allowed)}."
)
return value
@field_validator("profile")
@classmethod
def _validate_profile(cls, value):
"""Reject profiles absent from RECORDING_ENCODING_AVAILABLE_PROFILES."""
if value is None:
return None
allowed = set(settings.RECORDING_ENCODING_AVAILABLE_PROFILES)
if value not in allowed:
raise ValueError(
f"Invalid profile '{value}'. Choose from {sorted(allowed)}."
)
return value
class RecordingOptions(BaseModel):
"""Configuration options for recording.
@@ -312,7 +264,7 @@ class RecordingOptions(BaseModel):
transcribe: bool | None = None
collect_metadata: bool | None = None
original_mode: Literal["screen_recording", "transcript"] | None = None
encoding: EncodingConfig | None = None
model_config = {"extra": "forbid"}
+18 -31
View File
@@ -55,7 +55,6 @@ from core.recording.worker.exceptions import (
RecordingStopError,
)
from core.recording.worker.factories import (
build_encoding_options,
get_worker_service,
)
from core.recording.worker.mediator import (
@@ -401,34 +400,12 @@ class RoomViewSet(
options = serializer.validated_data.get("options")
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:
with transaction.atomic():
recording = models.Recording.objects.create(
room=room,
mode=mode,
options=options_data,
options=options.model_dump(exclude_none=True) if options else {},
)
models.RecordingAccess.objects.create(
user=self.request.user,
@@ -1099,9 +1076,10 @@ class RecordingViewSet(
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 configured header.
Raises PermissionDenied if the header is missing.
The original url is passed by nginx in the "HTTP_X_ORIGINAL_URL" header.
The original url is passed by the reverse proxy in the header named by the
MEDIA_AUTH_ORIGINAL_URL_HEADER setting, which defaults to "HTTP_X_ORIGINAL_URL".
See corresponding ingress configuration in Helm chart and read about the
nginx.ingress.kubernetes.io/auth-url annotation to understand how the Nginx ingress
is configured to do this.
@@ -1111,9 +1089,13 @@ class RecordingViewSet(
reasons.
"""
# Extract the original URL from the request header
original_url = request.META.get("HTTP_X_ORIGINAL_URL")
original_url = request.META.get(settings.MEDIA_AUTH_ORIGINAL_URL_HEADER)
if not original_url:
logger.warning("Missing HTTP_X_ORIGINAL_URL header in subrequest")
logger.warning(
"Missing %s header in subrequest. Set MEDIA_AUTH_ORIGINAL_URL_HEADER "
"to the header your reverse proxy sends.",
settings.MEDIA_AUTH_ORIGINAL_URL_HEADER,
)
raise drf_exceptions.PermissionDenied()
logger.debug("Original url: '%s'", original_url)
@@ -1438,7 +1420,8 @@ class FileViewSet(
Authorize access based on the original URL of an Nginx subrequest
and user permissions. Returns a dictionary of URL parameters if authorized.
The original url is passed by nginx in the "HTTP_X_ORIGINAL_URL" header.
The original url is passed by the reverse proxy in the header named by the
MEDIA_AUTH_ORIGINAL_URL_HEADER setting, which defaults to "HTTP_X_ORIGINAL_URL".
See corresponding ingress configuration in Helm chart and read about the
nginx.ingress.kubernetes.io/auth-url annotation to understand how the Nginx ingress
is configured to do this.
@@ -1457,9 +1440,13 @@ class FileViewSet(
- PermissionDenied if authorization fails.
"""
# Extract the original URL from the request header
original_url = request.META.get("HTTP_X_ORIGINAL_URL")
original_url = request.META.get(settings.MEDIA_AUTH_ORIGINAL_URL_HEADER)
if not original_url:
logger.warning("Missing HTTP_X_ORIGINAL_URL header in subrequest")
logger.warning(
"Missing %s header in subrequest. Set MEDIA_AUTH_ORIGINAL_URL_HEADER "
"to the header your reverse proxy sends.",
settings.MEDIA_AUTH_ORIGINAL_URL_HEADER,
)
raise drf_exceptions.PermissionDenied()
parsed_url = urlparse(original_url)
+19 -1
View File
@@ -3,7 +3,11 @@
import contextlib
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation
from django.core.exceptions import (
ImproperlyConfigured,
SuspiciousOperation,
ValidationError,
)
from django.utils.translation import gettext_lazy as _
from lasuite.oidc_login.backends import (
@@ -17,6 +21,7 @@ from core.services.marketing import (
ContactData,
get_marketing_service,
)
from core.validators import sub_validator
class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
@@ -84,6 +89,19 @@ class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
def get_existing_user(self, sub, email):
"""Fetch existing user by sub or email."""
sub = str(sub)
try:
sub_validator(sub)
except ValidationError as err:
raise SuspiciousOperation(
"User info contained an invalid sub claim"
) from err
if len(sub) > 255:
raise SuspiciousOperation("User info contained an invalid sub claim")
try:
return User.objects.get(sub=sub)
except User.DoesNotExist:
+3 -1
View File
@@ -8,6 +8,8 @@ import uuid
from django.conf import settings
from django.db import migrations, models
import core.validators
class Migration(migrations.Migration):
@@ -41,7 +43,7 @@ class Migration(migrations.Migration):
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
('sub', models.CharField(blank=True, help_text='Optional for pending users; required upon account activation. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only.', max_length=255, null=True, unique=True, validators=[django.core.validators.RegexValidator(message='Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters.', regex='^[\\w.@+-]+\\Z')], verbose_name='sub')),
('sub', models.CharField(blank=True, help_text='Optional for pending users; required upon account activation. 255 characters or fewer. Printable ASCII characters only.', max_length=255, null=True, unique=True, validators=[core.validators.sub_validator], verbose_name='sub')),
('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='identity email address')),
('admin_email', models.EmailField(blank=True, max_length=254, null=True, unique=True, verbose_name='admin email address')),
('language', models.CharField(choices=settings.LANGUAGES, default=settings.LANGUAGE_CODE, help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language')),
+2 -9
View File
@@ -27,6 +27,7 @@ from timezone_field import TimeZoneField
from . import fields, utils
from .recording.enums import FileExtension
from .validators import sub_validator
logger = getLogger(__name__)
@@ -145,19 +146,11 @@ class BaseModel(models.Model):
class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
"""User model to work with OIDC only authentication."""
sub_validator = validators.RegexValidator(
regex=r"^[\w.@+-]+\Z",
message=_(
"Enter a valid sub. This value may contain only letters, "
"numbers, and @/./+/-/_ characters."
),
)
sub = models.CharField(
_("sub"),
help_text=_(
"Optional for pending users; required upon account activation. "
"255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
"255 characters or fewer. Printable ASCII characters only."
),
max_length=255,
unique=True,
+16 -55
View File
@@ -22,46 +22,6 @@ _RECORDING_AUDIO_CODEC = livekit_api.AudioCodec.AAC
_RECORDING_AUDIO_FREQUENCY_HZ = 48000
def build_encoding_options(resolution, profile=None):
"""Assemble the LiveKit ``EncodingOptions`` kwargs for a resolution/profile.
Single source of truth shared by the default encoding
(``WorkerServiceConfig.from_settings``) and the per-recording encoding
persisted by the start-recording API, so both paths always produce the
same shape.
The profile-independent fields (audio bitrate, keyframe interval and the
pinned codec / frequency constants) are always included.
An omitted profile falls back to RECORDING_ENCODING_DEFAULT_PROFILE.
Framerate and bitrate are left to LiveKit only when the operator
declared no default profile at all.
"""
profile = profile or settings.RECORDING_ENCODING_DEFAULT_PROFILE
options: Dict[str, Any] = {
"audio_bitrate": settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS,
"key_frame_interval": settings.RECORDING_ENCODING_KEY_FRAME_INTERVAL_S,
"video_codec": _RECORDING_VIDEO_CODEC,
"audio_codec": _RECORDING_AUDIO_CODEC,
"audio_frequency": _RECORDING_AUDIO_FREQUENCY_HZ,
}
if resolution:
resolution_config = settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS[
resolution
]
options["width"] = resolution_config["width"]
options["height"] = resolution_config["height"]
if resolution and profile:
profile_config = settings.RECORDING_ENCODING_AVAILABLE_PROFILES[profile]
options["framerate"] = profile_config["fps"]
options["video_bitrate"] = profile_config["kbps"][resolution]
return options
@dataclass(frozen=True)
class WorkerServiceConfig:
"""Declare Worker Service common configurations"""
@@ -78,16 +38,22 @@ class WorkerServiceConfig:
logger.debug("Loading WorkerServiceConfig from settings.")
# The default encoding is resolved from the default profile/resolution and
# applied to every recording that carries no per-recording encoding.
# When either default is missing, we leave this as None so LiveKit falls
# back to its built-in preset.
resolution = settings.RECORDING_ENCODING_DEFAULT_RESOLUTION
profile = settings.RECORDING_ENCODING_DEFAULT_PROFILE
encoding_options: Optional[Dict[str, Any]] = None
if resolution and profile:
encoding_options = build_encoding_options(resolution, profile)
if settings.RECORDING_ENCODING_ENABLED:
# Single source of truth for the EncodingOptions kwargs:
# operator-tunable values live in Django settings, codec / frequency
# are pinned constants. The services layer only unpacks this dict.
encoding_options = {
"width": settings.RECORDING_ENCODING_WIDTH,
"height": settings.RECORDING_ENCODING_HEIGHT,
"framerate": settings.RECORDING_ENCODING_FRAMERATE,
"video_bitrate": settings.RECORDING_ENCODING_VIDEO_BITRATE_KBPS,
"audio_bitrate": settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS,
"key_frame_interval": settings.RECORDING_ENCODING_KEY_FRAME_INTERVAL_S,
"video_codec": _RECORDING_VIDEO_CODEC,
"audio_codec": _RECORDING_AUDIO_CODEC,
"audio_frequency": _RECORDING_AUDIO_FREQUENCY_HZ,
}
return cls(
output_folder=settings.RECORDING_OUTPUT_FOLDER,
@@ -112,12 +78,7 @@ class WorkerService(Protocol):
def __init__(self, config: WorkerServiceConfig):
"""Initialize the service with the given configuration."""
def start(
self,
room_id: str,
recording_id: str,
encoding_options: Optional[Dict[str, Any]] = None,
) -> str:
def start(self, room_id: str, recording_id: str) -> str:
"""Start a recording for a specified room."""
def stop(self, worker_id: str) -> str:
@@ -51,11 +51,8 @@ class WorkerServiceMediator:
raise RecordingStartError()
room_name = str(recording.room.id)
encoding_options = (recording.options.get("encoding") or {}).get("resolved")
try:
worker_id = self._worker_service.start(
room_name, recording.id, encoding_options=encoding_options
)
worker_id = self._worker_service.start(room_name, recording.id)
except (WorkerRequestError, WorkerConnectionError, WorkerResponseError) as e:
logger.exception(
"Failed to start recording for room %s: %s", recording.room.slug, e
+14 -19
View File
@@ -76,28 +76,28 @@ class BaseEgressService:
return "FAILED_TO_STOP"
def start(self, room_name, recording_id, encoding_options=None):
def start(self, room_name, recording_id):
"""Start the egress process for a recording (not implemented in the base class).
Each derived class must implement this method, providing the necessary parameters for
its specific egress type (e.g. audio_only, streaming output).
"""
raise NotImplementedError("Subclass must implement this method.")
def _resolve_encoding_options(self, encoding_options):
"""Build a LiveKit EncodingOptions from a resolved kwargs dict, or None.
``encoding_options`` is the per-recording dict persisted by the API in
``recording.options["encoding"]["resolved"]``; it falls back to the
default encoding carried by the service config.
def _build_encoding_options(self):
"""Build a LiveKit EncodingOptions from the service config, or None.
When None is returned, the caller should omit the `advanced` field so
LiveKit Egress falls back to its built-in preset (H264_720P_30).
The full EncodingOptions kwargs (operator-tunable values + pinned
codec / frequency constants) are assembled in `WorkerServiceConfig`,
so this method is a thin protobuf adapter.
"""
encoding_options = encoding_options or self._config.encoding_options
if not encoding_options:
opts = self._config.encoding_options
if not opts:
return None
return livekit_api.EncodingOptions(**encoding_options)
return livekit_api.EncodingOptions(**opts)
class VideoCompositeEgressService(BaseEgressService):
@@ -105,7 +105,7 @@ class VideoCompositeEgressService(BaseEgressService):
hrid = "video-recording-composite-livekit-egress"
def start(self, room_name, recording_id, encoding_options=None):
def start(self, room_name, recording_id):
"""Start the video composite egress process for a recording."""
# Save room's recording as a mp4 video file.
@@ -126,7 +126,7 @@ class VideoCompositeEgressService(BaseEgressService):
"layout": "speaker-light",
}
advanced = self._resolve_encoding_options(encoding_options)
advanced = self._build_encoding_options()
if advanced is not None:
request_kwargs["advanced"] = advanced
@@ -145,13 +145,8 @@ class AudioCompositeEgressService(BaseEgressService):
hrid = "audio-recording-composite-livekit-egress"
def start(self, room_name, recording_id, encoding_options=None):
"""Start the audio composite egress process for a recording.
``encoding_options`` is accepted for signature compatibility with the
WorkerService protocol but ignored: audio-only egress has no
encoding to configure.
"""
def start(self, room_name, recording_id):
"""Start the audio composite egress process for a recording."""
# Save room's recording as an ogg audio file.
file_type = livekit_api.EncodedFileType.OGG
@@ -40,6 +40,111 @@ def test_authentication_getter_existing_user(monkeypatch):
assert user == db_user
@pytest.mark.parametrize(
"sub",
[
# NUL (U+0000) passes str.isascii() but PostgreSQL text fields
# cannot store or compare it (DataError)
"auth0|abc\x00def",
# lone surrogates cannot be encoded to UTF-8 for the DB lookup
# (UnicodeEncodeError), which runs before any model validation
"bad\ud800sub",
# plainly invalid subs would otherwise escape as ValidationError
# on user creation, which mozilla-django-oidc does not catch
"\u00e9milie",
"a" * 256,
# ASCII control characters are rejected by policy
"tab\tsub",
"del\x7fsub",
],
)
def test_authentication_getter_invalid_sub_rejected_cleanly(monkeypatch, sub):
"""
Subs that can never be persisted should be rejected with
SuspiciousOperation (turned into a clean authentication failure by
mozilla-django-oidc) instead of leaking DataError, UnicodeEncodeError
or ValidationError as a server error.
"""
klass = OIDCAuthenticationBackend()
def get_userinfo_mocked(*args):
return {"sub": sub, "email": "john@example.com"}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with pytest.raises(
SuspiciousOperation,
match="User info contained an invalid sub claim",
):
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
assert models.User.objects.exists() is False
def test_authentication_getter_numeric_sub(monkeypatch):
"""
Some providers serialize the sub as a JSON number. It should keep working
(CharField coerces it to a string on save) and must not crash the early
sub checks in get_existing_user.
"""
klass = OIDCAuthenticationBackend()
def get_userinfo_mocked(*args):
return {"sub": 12345, "email": "john@example.com"}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user.sub == "12345"
assert models.User.objects.count() == 1
def test_authentication_getter_new_user_auth0_pipe_sub(monkeypatch):
"""
A first login with an Auth0-style sub containing a pipe ("provider|user-id")
should create the user instead of raising a ValidationError.
Regression test for https://github.com/suitenumerique/meet/issues/[XXX].
"""
klass = OIDCAuthenticationBackend()
def get_userinfo_mocked(*args):
return {"sub": "auth0|644c0bc8f1874ef6d339fb34", "email": "john@example.com"}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user.sub == "auth0|644c0bc8f1874ef6d339fb34"
assert user.email == "john@example.com"
assert models.User.objects.count() == 1
def test_authentication_getter_existing_user_auth0_pipe_sub(monkeypatch):
"""
A returning user with an Auth0-style pipe sub should be matched by sub,
not duplicated or rejected.
"""
klass = OIDCAuthenticationBackend()
db_user = UserFactory(sub="auth0|644c0bc8f1874ef6d339fb34")
def get_userinfo_mocked(*args):
return {"sub": db_user.sub}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user == db_user
assert models.User.objects.count() == 1
def test_authentication_getter_new_user_no_email(monkeypatch):
"""
If no user matches, a user should be created.
@@ -7,6 +7,7 @@ from urllib.parse import quote, urlparse
from django.conf import settings
from django.core.files.storage import default_storage
from django.test import override_settings
from django.utils import timezone
import pytest
@@ -143,3 +144,59 @@ def test_api_files_media_auth_own_file_deleted():
)
assert response.status_code == 403
@override_settings(MEDIA_AUTH_ORIGINAL_URL_HEADER="HTTP_X_FORWARDED_URI")
def test_api_files_media_auth_custom_original_url_header():
"""
Authorization should honour the configured original-url header.
Covers the attachment subrequest path, which resolves the header separately
from the recording one. Reverse proxies other than nginx-ingress use
different headers: Traefik's ForwardAuth sends X-Forwarded-Uri and cannot
emit X-Original-URL at all.
"""
user = factories.UserFactory()
file = factories.FileFactory(
type=models.FileTypeChoices.BACKGROUND_IMAGE,
update_upload_state=models.FileUploadStateChoices.READY,
creator=user,
)
client = APIClient()
client.force_login(user)
default_storage.save(file.file_key, BytesIO(b"my prose"))
original_url = f"http://localhost/media/{file.file_key:s}"
response = client.get(
"/api/v1.0/files/media-auth/", HTTP_X_FORWARDED_URI=original_url
)
assert response.status_code == 200
assert "AWS4-HMAC-SHA256 Credential=" in response["Authorization"]
@override_settings(MEDIA_AUTH_ORIGINAL_URL_HEADER="HTTP_X_FORWARDED_URI")
def test_api_files_media_auth_default_header_ignored_when_reconfigured():
"""
Only the configured header should be honoured, never a hardcoded fallback.
"""
user = factories.UserFactory()
file = factories.FileFactory(
type=models.FileTypeChoices.BACKGROUND_IMAGE,
update_upload_state=models.FileUploadStateChoices.READY,
creator=user,
)
client = APIClient()
client.force_login(user)
original_url = f"http://localhost/media/{file.file_key:s}"
response = client.get(
"/api/v1.0/files/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 403
@@ -8,6 +8,7 @@ from uuid import uuid4
from django.conf import settings
from django.core.files.storage import default_storage
from django.test import override_settings
from django.utils import timezone
import pytest
@@ -282,3 +283,63 @@ def test_api_recordings_media_auth_success_administrator(mode):
timeout=1,
)
assert response.content.decode("utf-8") == "my prose"
def test_api_recordings_media_auth_missing_header():
"""
Test that a subrequest without the configured original-url header is rejected.
"""
user = UserFactory()
client = APIClient()
client.force_login(user)
response = client.get("/api/v1.0/recordings/media-auth/")
assert response.status_code == 403
@override_settings(MEDIA_AUTH_ORIGINAL_URL_HEADER="HTTP_X_FORWARDED_URI")
def test_api_recordings_media_auth_custom_original_url_header():
"""
Test that the header carrying the original URL can be configured.
Reverse proxies other than nginx-ingress use different headers: Traefik's
ForwardAuth sends X-Forwarded-Uri and cannot emit X-Original-URL at all.
"""
user = UserFactory()
client = APIClient()
client.force_login(user)
original_url = f"http://localhost/media/recordings/{uuid4()!s}.mp4"
response = client.get(
"/api/v1.0/recordings/media-auth/", HTTP_X_FORWARDED_URI=original_url
)
# The header was read and parsed: we get as far as looking the recording up,
# rather than being rejected for a missing header.
assert response.status_code == 404
@override_settings(MEDIA_AUTH_ORIGINAL_URL_HEADER="HTTP_X_FORWARDED_URI")
def test_api_recordings_media_auth_default_header_ignored_when_reconfigured():
"""
Test that only the configured header is honoured.
Guards against the header being read from a hardcoded name in parallel with
the setting.
"""
user = UserFactory()
client = APIClient()
client.force_login(user)
original_url = f"http://localhost/media/recordings/{uuid4()!s}.mp4"
response = client.get(
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 403
@@ -1,169 +0,0 @@
"""Tests for the per-recording encoding resolution in BaseEgressService."""
# pylint: disable=protected-access,redefined-outer-name,unused-argument,no-member
from unittest.mock import Mock
from django.conf import settings
from django.test import override_settings
import pytest
from livekit import api as livekit_api
from pydantic import ValidationError as PydanticValidationError
from core.api.serializers import EncodingConfig
from core.recording.worker.factories import build_encoding_options
from core.recording.worker.services import VideoCompositeEgressService
def make_config():
"""Build a minimal WorkerServiceConfig-like mock for service instantiation."""
config = Mock()
config.bucket_args = {
"endpoint": "https://s3.test.com",
"access_key": "test_key",
"secret": "test_secret",
"region": "test-region",
"bucket": "test-bucket",
"force_path_style": True,
}
config.encoding_options = None
return config
@pytest.fixture
def service():
"""Return a VideoCompositeEgressService with mocked handle_request."""
svc = VideoCompositeEgressService(make_config())
svc._handle_request = Mock()
return svc
# --- build_encoding_options ---
def test_build_options_without_profile_uses_default_profile():
"""A resolution-only config should fall back to the default profile.
Left unset, framerate and video_bitrate take LiveKit's own EncodingOptions
defaults. The profile-independent fields (audio bitrate, keyframe interval,
codec/frequency pins) are always present, matching the default encoding.
"""
default_profile = settings.RECORDING_ENCODING_AVAILABLE_PROFILES[
settings.RECORDING_ENCODING_DEFAULT_PROFILE
]
resolved = build_encoding_options("540p")
assert resolved == {
"audio_bitrate": settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS,
"key_frame_interval": settings.RECORDING_ENCODING_KEY_FRAME_INTERVAL_S,
"video_codec": livekit_api.VideoCodec.H264_MAIN,
"audio_codec": livekit_api.AudioCodec.AAC,
"audio_frequency": 48000,
"width": 960,
"height": 540,
"framerate": default_profile["fps"],
"video_bitrate": default_profile["kbps"]["540p"],
}
@override_settings(RECORDING_ENCODING_DEFAULT_PROFILE="")
def test_build_options_omits_profile_fields_without_default_profile():
"""With no default profile declared, framerate/bitrate are left to LiveKit."""
resolved = build_encoding_options("720p", None)
assert resolved == {
"audio_bitrate": settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS,
"key_frame_interval": settings.RECORDING_ENCODING_KEY_FRAME_INTERVAL_S,
"video_codec": livekit_api.VideoCodec.H264_MAIN,
"audio_codec": livekit_api.AudioCodec.AAC,
"audio_frequency": 48000,
"width": 1280,
"height": 720,
}
assert "framerate" not in resolved
assert "video_bitrate" not in resolved
def test_encoding_config_requires_resolution():
"""A profile-only or empty encoding config should be rejected at validation."""
with pytest.raises(PydanticValidationError):
EncodingConfig(profile="mixed")
with pytest.raises(PydanticValidationError):
EncodingConfig()
# --- _resolve_encoding_options ---
@pytest.mark.parametrize("encoding_options", [None, {}])
def test_resolve_options_returns_none_when_empty(service, encoding_options):
"""Resolver should return None when the resolved dict is empty or missing."""
assert service._resolve_encoding_options(encoding_options) is None
@pytest.mark.parametrize(
"resolution",
list(settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS),
)
@pytest.mark.parametrize(
"profile",
list(settings.RECORDING_ENCODING_AVAILABLE_PROFILES),
)
def test_resolve_profile_resolution_combinations(service, profile, resolution):
"""Every (profile, resolution) pair should resolve to the values from settings."""
resolution_config = settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS[resolution]
expected_width = resolution_config["width"]
expected_height = resolution_config["height"]
profile_config = settings.RECORDING_ENCODING_AVAILABLE_PROFILES[profile]
expected_fps = profile_config["fps"]
expected_bitrate = profile_config["kbps"][resolution]
resolved = build_encoding_options(resolution, profile)
result = service._resolve_encoding_options(resolved)
assert result.width == expected_width
assert result.height == expected_height
assert result.framerate == expected_fps
assert result.video_bitrate == expected_bitrate
# Profile-independent fields match the default encoding, never dropped.
assert result.audio_bitrate == settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS
assert result.video_codec == livekit_api.VideoCodec.H264_MAIN
assert result.audio_codec == livekit_api.AudioCodec.AAC
assert result.audio_frequency == 48000
def test_resolve_options_none_profile_uses_default_profile(service):
"""A missing profile should resolve to the default profile's fps/bitrate."""
default_profile = settings.RECORDING_ENCODING_AVAILABLE_PROFILES[
settings.RECORDING_ENCODING_DEFAULT_PROFILE
]
resolved = build_encoding_options("720p", None)
result = service._resolve_encoding_options(resolved)
assert result.width == 1280
assert result.height == 720
assert result.framerate == default_profile["fps"]
assert result.video_bitrate == default_profile["kbps"]["720p"]
assert result.audio_bitrate == settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS
assert result.video_codec == livekit_api.VideoCodec.H264_MAIN
@override_settings(RECORDING_ENCODING_DEFAULT_PROFILE="")
def test_resolve_options_passes_zero_when_no_default_profile(service):
"""With no default profile, fps/bitrate reach LiveKit unset (protobuf 0).
The pinned codec / audio fields are still applied.
"""
resolved = build_encoding_options("720p", None)
result = service._resolve_encoding_options(resolved)
assert result.width == 1280
assert result.height == 720
assert result.framerate == 0
assert result.video_bitrate == 0
assert result.audio_bitrate == settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS
assert result.video_codec == livekit_api.VideoCodec.H264_MAIN
assert result.audio_codec == livekit_api.AudioCodec.AAC
@@ -40,16 +40,6 @@ def test_settings():
"AWS_S3_SECRET_ACCESS_KEY": "test_secret",
"AWS_S3_REGION_NAME": "test-region",
"AWS_STORAGE_BUCKET_NAME": "test-bucket",
"RECORDING_ENCODING_AVAILABLE_RESOLUTIONS": {
"720p": {"width": 1280, "height": 720}
},
"RECORDING_ENCODING_AVAILABLE_PROFILES": {
"full": {"fps": 30, "kbps": {"720p": 3000}}
},
"RECORDING_ENCODING_DEFAULT_RESOLUTION": "720p",
"RECORDING_ENCODING_DEFAULT_PROFILE": "full",
"RECORDING_ENCODING_AUDIO_BITRATE_KBPS": 128,
"RECORDING_ENCODING_KEY_FRAME_INTERVAL_S": 4.0,
}
# Use override_settings to properly patch Django settings
@@ -76,18 +66,8 @@ def test_config_initialization(default_config):
"bucket": "test-bucket",
"force_path_style": True,
}
# The default encoding is always resolved from the default profile/resolution.
assert default_config.encoding_options == {
"width": 1280,
"height": 720,
"framerate": 30,
"video_bitrate": 3000,
"audio_bitrate": 128,
"key_frame_interval": 4.0,
"video_codec": livekit_api_codec.VideoCodec.H264_MAIN,
"audio_codec": livekit_api_codec.AudioCodec.AAC,
"audio_frequency": 48000,
}
# Encoding override is opt-in; disabled by default.
assert default_config.encoding_options is None
def test_config_immutability(default_config):
@@ -96,7 +76,6 @@ def test_config_immutability(default_config):
default_config.output_folder = "new/path"
@pytest.mark.parametrize("custom_encoding_enabled", [True, False])
@override_settings(
RECORDING_OUTPUT_FOLDER="/test/output",
LIVEKIT_CONFIGURATION={"server": "test.example.com"},
@@ -105,25 +84,23 @@ def test_config_immutability(default_config):
AWS_S3_SECRET_ACCESS_KEY="test_secret",
AWS_S3_REGION_NAME="test-region",
AWS_STORAGE_BUCKET_NAME="test-bucket",
RECORDING_ENCODING_AVAILABLE_RESOLUTIONS={"720p": {"width": 1280, "height": 720}},
RECORDING_ENCODING_AVAILABLE_PROFILES={"low": {"fps": 15, "kbps": {"720p": 600}}},
RECORDING_ENCODING_DEFAULT_RESOLUTION="720p",
RECORDING_ENCODING_DEFAULT_PROFILE="low",
RECORDING_ENCODING_ENABLED=True,
RECORDING_ENCODING_WIDTH=1280,
RECORDING_ENCODING_HEIGHT=720,
RECORDING_ENCODING_FRAMERATE=15,
RECORDING_ENCODING_VIDEO_BITRATE_KBPS=600,
RECORDING_ENCODING_AUDIO_BITRATE_KBPS=64,
RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=10.0,
)
def test_config_encoding_options_default(custom_encoding_enabled):
"""The default encoding is always resolved from the default profile/resolution.
def test_config_encoding_options_enabled():
"""When RECORDING_ENCODING_ENABLED is True, encoding options are populated.
The default fallback resolves the default profile/resolution and mixes those
operator-tunable values with pinned codec / frequency constants. This works
regardless of RECORDING_CUSTOM_ENCODING_ENABLED, which only gates the
per-recording API, so both toggle states produce the same default.
The dict mixes operator-tunable values from settings with pinned codec /
frequency constants, so the services layer can simply unpack it.
"""
with override_settings(RECORDING_CUSTOM_ENCODING_ENABLED=custom_encoding_enabled):
WorkerServiceConfig.from_settings.cache_clear()
config = WorkerServiceConfig.from_settings()
WorkerServiceConfig.from_settings.cache_clear()
config = WorkerServiceConfig.from_settings()
assert config.encoding_options == {
"width": 1280,
@@ -138,27 +115,6 @@ def test_config_encoding_options_default(custom_encoding_enabled):
}
@pytest.mark.parametrize(
("default_resolution", "default_profile"),
[("", "full"), ("720p", ""), ("", "")],
)
def test_config_encoding_options_none_when_default_missing(
test_settings, default_resolution, default_profile
):
"""A missing default resolution/profile leaves encoding_options None.
The service then omits the `advanced` field so LiveKit uses its built-in preset.
"""
with override_settings(
RECORDING_ENCODING_DEFAULT_RESOLUTION=default_resolution,
RECORDING_ENCODING_DEFAULT_PROFILE=default_profile,
):
WorkerServiceConfig.from_settings.cache_clear()
config = WorkerServiceConfig.from_settings()
assert config.encoding_options is None
@override_settings(
RECORDING_OUTPUT_FOLDER="/test/output",
LIVEKIT_CONFIGURATION={"server": "test.example.com"},
@@ -50,7 +50,7 @@ def test_start_recording_success(mock_update_metadata, mediator, mock_worker_ser
# Verify worker service call
expected_room_name = str(mock_recording.room.id)
mock_worker_service.start.assert_called_once_with(
expected_room_name, mock_recording.id, encoding_options=None
expected_room_name, mock_recording.id
)
# Verify recording updates
@@ -64,38 +64,6 @@ def test_start_recording_success(mock_update_metadata, mediator, mock_worker_ser
)
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_start_recording_passes_resolved_encoding(
mock_update_metadata, mediator, mock_worker_service
):
"""The resolved encoding persisted in recording.options reaches the worker."""
mock_worker_service.start.return_value = "test-worker-123"
resolved = {
"key_frame_interval": 4.0,
"width": 1280,
"height": 720,
"framerate": 15,
"video_bitrate": 700,
}
mock_recording = RecordingFactory(
status=RecordingStatusChoices.INITIATED,
worker_id=None,
options={
"encoding": {
"resolution": "720p",
"profile": "talking_heads",
"resolved": resolved,
}
},
)
mediator.start(mock_recording)
mock_worker_service.start.assert_called_once_with(
str(mock_recording.room.id), mock_recording.id, encoding_options=resolved
)
@pytest.mark.parametrize(
"error_class", [WorkerRequestError, WorkerConnectionError, WorkerResponseError]
)
@@ -2,12 +2,11 @@
Test rooms API endpoints in the Meet core app: start recording.
"""
# pylint: disable=redefined-outer-name,unused-argument,no-member
# pylint: disable=redefined-outer-name,unused-argument
from unittest import mock
import pytest
from livekit import api as livekit_api
from rest_framework.test import APIClient
from ...factories import RoomFactory, UserFactory
@@ -471,224 +470,6 @@ def test_start_recording_options_unknown_field_rejected(settings):
assert response.status_code == 400
def test_start_recording_options_encoding_valid(
settings, mock_worker_service_factory, mock_worker_manager
):
"""Should accept a valid encoding configuration."""
settings.RECORDING_ENABLE = True
settings.RECORDING_CUSTOM_ENCODING_ENABLED = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{
"mode": "screen_recording",
"options": {"encoding": {"resolution": "720p", "profile": "talking_heads"}},
},
format="json",
)
assert response.status_code == 201
def test_start_recording_options_encoding_rejected_when_custom_encoding_disabled(
settings, mock_worker_service_factory, mock_worker_manager
):
"""Per-recording encoding is rejected when RECORDING_CUSTOM_ENCODING_ENABLED is off."""
settings.RECORDING_ENABLE = True
settings.RECORDING_CUSTOM_ENCODING_ENABLED = False
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{
"mode": "screen_recording",
"options": {"encoding": {"resolution": "720p", "profile": "talking_heads"}},
},
format="json",
)
assert response.status_code == 400
assert not Recording.objects.filter(room=room).exists()
def test_start_recording_persists_resolved_encoding(
settings, mock_worker_service_factory, mock_worker_manager
):
"""The resolved encoding should be persisted in recording.options alongside
the requested resolution/profile for traceability."""
settings.RECORDING_ENABLE = True
settings.RECORDING_CUSTOM_ENCODING_ENABLED = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{
"mode": "screen_recording",
"options": {"encoding": {"resolution": "720p", "profile": "talking_heads"}},
},
format="json",
)
assert response.status_code == 201
recording = Recording.objects.get(room=room)
assert recording.options["encoding"] == {
"resolution": "720p",
"profile": "talking_heads",
"resolved": {
"audio_bitrate": settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS,
"key_frame_interval": settings.RECORDING_ENCODING_KEY_FRAME_INTERVAL_S,
"video_codec": livekit_api.VideoCodec.H264_MAIN,
"audio_codec": livekit_api.AudioCodec.AAC,
"audio_frequency": 48000,
"width": 1280,
"height": 720,
"framerate": 15,
"video_bitrate": 700,
},
}
def test_start_recording_resolution_only_uses_default_profile(
settings, mock_worker_service_factory, mock_worker_manager
):
"""An encoding without a profile should resolve the default profile."""
settings.RECORDING_ENABLE = True
settings.RECORDING_CUSTOM_ENCODING_ENABLED = True
settings.RECORDING_ENCODING_DEFAULT_PROFILE = "talking_heads"
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording", "options": {"encoding": {"resolution": "540p"}}},
format="json",
)
assert response.status_code == 201
recording = Recording.objects.get(room=room)
resolved = recording.options["encoding"]["resolved"]
assert resolved["width"] == 960
assert resolved["height"] == 540
assert resolved["framerate"] == 15
assert resolved["video_bitrate"] == 400
# The requested payload is persisted as sent: no profile was asked for.
assert "profile" not in recording.options["encoding"]
def test_start_recording_forwards_resolved_encoding_to_worker(
settings, mock_worker_service, mock_worker_service_factory
):
"""The resolved encoding should passed on to the worker."""
settings.RECORDING_ENABLE = True
settings.RECORDING_CUSTOM_ENCODING_ENABLED = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
mock_worker_service.start.return_value = "egress-123"
with mock.patch("core.services.room_management.RoomManagement.update_metadata"):
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{
"mode": "screen_recording",
"options": {
"encoding": {"resolution": "720p", "profile": "talking_heads"}
},
},
format="json",
)
assert response.status_code == 201
recording = Recording.objects.get(room=room)
mock_worker_service.start.assert_called_once_with(
str(room.id),
recording.id,
encoding_options=recording.options["encoding"]["resolved"],
)
def test_start_recording_options_encoding_invalid_resolution(settings):
"""Should reject invalid encoding resolution values."""
settings.RECORDING_ENABLE = True
settings.RECORDING_CUSTOM_ENCODING_ENABLED = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording", "options": {"encoding": {"resolution": "4K"}}},
format="json",
)
assert response.status_code == 400
def test_start_recording_options_encoding_unknown_key_rejected(settings):
"""Should reject unknown keys in encoding configuration."""
settings.RECORDING_ENABLE = True
settings.RECORDING_CUSTOM_ENCODING_ENABLED = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{
"mode": "screen_recording",
"options": {"encoding": {"bitrate": 9000}},
},
format="json",
)
assert response.status_code == 400
def test_start_recording_options_without_encoding_unchanged(
settings, mock_worker_service_factory, mock_worker_manager
):
"""Requests without encoding should keep existing options behavior."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording", "options": {"language": "fr"}},
format="json",
)
assert response.status_code == 201
recording = Recording.objects.get(room=room)
assert recording.options == {"language": "fr"}
@pytest.mark.parametrize("value", ["foo", 12])
def test_start_recording_options_invalid_transcribe_type(settings, value):
"""Should reject non-boolean transcribe values."""
@@ -26,6 +26,64 @@ def test_models_users_id_unique():
factories.UserFactory(id=user.id)
@pytest.mark.parametrize(
"sub,is_valid",
[
# cases from suitenumerique/docs PR #1295 (same validator)
("valid_sub.@+-:=/", True),
("invalid süb", False),
(12345, True),
# Auth0 emits "provider|user-id" subject identifiers
("auth0|644c0bc8f1874ef6d339fb34", True),
("google-oauth2|103547991597142817347", True),
# Keycloak-style UUID
("f:550e8400-e29b-41d4-a716-446655440000:jdoe", True),
# base64/URN-style identifiers
("dGVzdC1zdWItdmFsdWU=", True),
("urn:example:user/42", True),
# legacy format still accepted
("user@example.com", True),
# space (U+0020) is printable ASCII and remains allowed
("sub with space", True),
# non-ASCII values are rejected
("émilie", False),
# ASCII control characters (U+0000-U+001F, U+007F) are rejected:
# NUL passes isascii() but cannot be stored in PostgreSQL text
# fields, and the others invite log injection and interop issues
("nul\x00sub", False),
("\x00", False),
("tab\tsub", False),
("newline\nsub", False),
("del\x7fsub", False),
],
)
def test_models_users_sub_validator(sub, is_valid):
"""
The "sub" field should accept any ASCII string as required by
OpenID Connect Core 1.0 §2 and RFC 7519 §4.1.2, and reject non-ASCII values.
"""
user = factories.UserFactory()
user.sub = sub
if is_valid:
user.full_clean()
else:
with pytest.raises(
ValidationError,
match="Enter a valid sub. This value should be printable ASCII only.",
):
user.full_clean()
def test_models_users_sub_max_length():
"""The "sub" field should enforce the 255 ASCII characters limit of OIDC Core 1.0 §2."""
user = factories.UserFactory(sub="a" * 255)
assert user.sub == "a" * 255
user.sub = "a" * 256
with pytest.raises(ValidationError, match="at most 255 characters"):
user.full_clean()
def test_models_users_send_mail_main_existing():
"""The "email_user' method should send mail to the user's email address."""
user = factories.UserFactory()
+23
View File
@@ -0,0 +1,23 @@
"""Custom validators for the core app."""
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
def sub_validator(value):
"""Validate that the sub is printable ASCII only.
OpenID Connect Core 1.0 (section 2) allows any ASCII (RFC 20) string of
at most 255 characters, so no character whitelist is applied: providers
legitimately emit "|" (Auth0), ":" (Keycloak), "=", "/", etc. As a
deliberate hardening beyond the spec, ASCII control characters
(U+0000-U+001F and U+007F) are rejected: no known provider emits them,
NUL cannot be stored in PostgreSQL text fields, and the others invite
log-injection and interoperability issues. For str values,
``isprintable()`` is false exactly for those control characters, while
space (U+0020) remains allowed.
"""
if not value.isascii() or not value.isprintable():
raise ValidationError(
_("Enter a valid sub. This value should be printable ASCII only.")
)
+186 -159
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-07-02 10:47+0000\n"
"POT-Creation-Date: 2026-09-02 22:52+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -51,11 +51,11 @@ msgstr ""
msgid "File preview"
msgstr ""
#: core/admin.py:300 core/admin.py:443
#: core/admin.py:300 core/admin.py:450
msgid "No owner"
msgstr "Kein Eigentümer"
#: core/admin.py:303 core/admin.py:446
#: core/admin.py:303 core/admin.py:453
msgid "Multiple owners"
msgstr "Mehrere Eigentümer"
@@ -98,11 +98,11 @@ msgstr "%(count)s Aufnahme(n) erfolgreich als Fehler beim Stoppen markiert
msgid "Skipped %(count)s recording(s) with an ineligible status."
msgstr "%(count)s abgelaufene Aufnahme(n) übersprungen."
#: core/admin.py:510
#: core/admin.py:517
msgid "No scopes"
msgstr "Keine Scopes"
#: core/admin.py:512
#: core/admin.py:519
msgid "Scopes"
msgstr "Scopes"
@@ -110,185 +110,201 @@ msgstr "Scopes"
msgid "Creator is me"
msgstr "Ersteller bin ich"
#: core/api/serializers.py:89
#: core/api/serializers.py:108
msgid "You must be administrator or owner of a room to add accesses to it."
msgstr ""
"Sie müssen Administrator oder Eigentümer eines Raums sein, um Zugriffe "
"hinzuzufügen."
#: core/api/serializers.py:534
#: core/api/serializers.py:560
msgid "This file extension is not allowed."
msgstr "Diese Dateiendung ist nicht erlaubt."
#: core/api/viewsets.py:1222
#: core/api/viewsets.py:1268
msgid "You have reached the maximum number of files for this type."
msgstr "Sie haben die maximale Anzahl an Dateien dieses Typs erreicht."
#: core/models.py:37
#: core/models.py:38
msgid "Member"
msgstr "Mitglied"
#: core/models.py:38
#: core/models.py:39
msgid "Administrator"
msgstr "Administrator"
#: core/models.py:39
#: core/models.py:40
msgid "Owner"
msgstr "Eigentümer"
#: core/models.py:55
#: core/models.py:56
msgid "Initiated"
msgstr "Gestartet"
#: core/models.py:56
#: core/models.py:57
msgid "Active"
msgstr "Aktiv"
#: core/models.py:57
#: core/models.py:58
msgid "Stopped"
msgstr "Beendet"
#: core/models.py:58
#: core/models.py:59
msgid "Saved"
msgstr "Gespeichert"
#: core/models.py:59
#: core/models.py:60
msgid "Aborted"
msgstr "Abgebrochen"
#: core/models.py:60
#: core/models.py:61
msgid "Failed to Start"
msgstr "Start fehlgeschlagen"
#: core/models.py:61
#: core/models.py:62
msgid "Failed to Stop"
msgstr "Stopp fehlgeschlagen"
#: core/models.py:62
#: core/models.py:63
msgid "Notification succeeded"
msgstr "Benachrichtigung erfolgreich"
#: core/models.py:65
#: core/models.py:66
msgid "External process successful"
msgstr "Externer Prozess erfolgreich"
#: core/models.py:67
#: core/models.py:68
msgid "External process failed"
msgstr "Externer Prozess fehlgeschlagen"
#: core/models.py:96
#: core/models.py:97
msgid "SCREEN_RECORDING"
msgstr "BILDSCHIRMAUFZEICHNUNG"
#: core/models.py:97
#: core/models.py:98
msgid "TRANSCRIPT"
msgstr "TRANSKRIPT"
#: core/models.py:103
#: core/models.py:104
msgid "Public Access"
msgstr "Öffentlicher Zugriff"
#: core/models.py:104
#: core/models.py:105
msgid "Trusted Access"
msgstr "Vertrauenswürdiger Zugriff"
#: core/models.py:105
#: core/models.py:106
msgid "Restricted Access"
msgstr "Eingeschränkter Zugriff"
#: core/models.py:117
#: core/models.py:118
msgid "id"
msgstr "ID"
#: core/models.py:118
#: core/models.py:119
msgid "primary key for the record as UUID"
msgstr "Primärschlüssel des Eintrags als UUID"
#: core/models.py:124
#: core/models.py:125
msgid "created on"
msgstr "erstellt am"
#: core/models.py:125
#: core/models.py:126
msgid "date and time at which a record was created"
msgstr "Datum und Uhrzeit der Erstellung eines Eintrags"
#: core/models.py:130
#: core/models.py:131
msgid "updated on"
msgstr "aktualisiert am"
#: core/models.py:131
#: core/models.py:132
msgid "date and time at which a record was last updated"
msgstr "Datum und Uhrzeit der letzten Aktualisierung eines Eintrags"
#: core/models.py:151
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
msgstr ""
"Geben Sie einen gültigen Sub ein. Dieser Wert darf nur Buchstaben, Zahlen "
"und die Zeichen @/./+/-/_ enthalten."
#: core/models.py:157
#: core/models.py:150
msgid "sub"
msgstr "Sub"
#: core/models.py:159
#: core/models.py:152
msgid ""
"Optional for pending users; required upon account activation. 255 characters "
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
"or fewer. Printable ASCII characters only."
msgstr ""
"Optional für ausstehende Benutzer; erforderlich nach Kontoaktivierung. "
"Maximal 255 Zeichen. Nur Buchstaben, Zahlen und @/./+/-/_ Zeichen erlaubt."
"Maximal 255 Zeichen. Nur druckbare ASCII-Zeichen erlaubt."
#: core/models.py:168
#: core/validators.py:22
msgid "Enter a valid sub. This value should be printable ASCII only."
msgstr "Geben Sie einen gültigen sub ein. Dieser Wert darf nur druckbare ASCII-Zeichen enthalten."
#: core/models.py:161
msgid "identity email address"
msgstr "Identitäts-E-Mail-Adresse"
#: core/models.py:173
#: core/models.py:166
msgid "admin email address"
msgstr "Administrator-E-Mail-Adresse"
#: core/models.py:175
#: core/models.py:168
msgid "full name"
msgstr "Vollständiger Name"
#: core/models.py:177
#: core/models.py:170
msgid "short name"
msgstr "Kurzname"
#: core/models.py:183
#: core/models.py:176
msgid "language"
msgstr "Sprache"
#: core/models.py:184
#: core/models.py:177
msgid "The language in which the user wants to see the interface."
msgstr "Die Sprache, in der der Benutzer die Oberfläche sehen möchte."
#: core/models.py:190
#: core/models.py:183
msgid "The timezone in which the user wants to see times."
msgstr "Die Zeitzone, in der der Benutzer die Zeiten sehen möchte."
#: core/models.py:193
#: core/models.py:190
msgid "default room access level"
msgstr ""
#: core/models.py:192
msgid ""
"Access level applied by default to new rooms created by this user. When "
"empty, the instance default is used."
msgstr ""
#: core/models.py:199
#, fuzzy
#| msgid "Visio room configuration"
msgid "default room configuration"
msgstr "Visio-Raumkonfiguration"
#: core/models.py:201
msgid "Configurations applied by default to new rooms created by this user."
msgstr ""
#: core/models.py:205
msgid "device"
msgstr "Gerät"
#: core/models.py:195
#: core/models.py:207
msgid "Whether the user is a device or a real user."
msgstr "Ob es sich um ein Gerät oder einen echten Benutzer handelt."
#: core/models.py:198
#: core/models.py:210
msgid "staff status"
msgstr "Mitarbeiterstatus"
#: core/models.py:200
#: core/models.py:212
msgid "Whether the user can log into this admin site."
msgstr "Ob der Benutzer sich bei dieser Admin-Seite anmelden kann."
#: core/models.py:203
#: core/models.py:215
msgid "active"
msgstr "aktiv"
#: core/models.py:206
#: core/models.py:218
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
@@ -296,66 +312,66 @@ msgstr ""
"Ob dieser Benutzer als aktiv behandelt werden soll. Deaktivieren Sie dies "
"anstelle des Löschens des Kontos."
#: core/models.py:219
#: core/models.py:231
msgid "user"
msgstr "Benutzer"
#: core/models.py:220
#: core/models.py:232
msgid "users"
msgstr "Benutzer"
#: core/models.py:286
#: core/models.py:298
msgid "Resource"
msgstr "Ressource"
#: core/models.py:287
#: core/models.py:299
msgid "Resources"
msgstr "Ressourcen"
#: core/models.py:345
#: core/models.py:357
msgid "Resource access"
msgstr "Ressourcenzugriff"
#: core/models.py:346
#: core/models.py:358
msgid "Resource accesses"
msgstr "Ressourcenzugriffe"
#: core/models.py:352
#: core/models.py:364
msgid "Resource access with this User and Resource already exists."
msgstr ""
"Ein Ressourcenzugriff mit diesem Benutzer und dieser Ressource existiert "
"bereits."
#: core/models.py:409
#: core/models.py:421
msgid "Visio room configuration"
msgstr "Visio-Raumkonfiguration"
#: core/models.py:410
#: core/models.py:422
msgid "Values for Visio parameters to configure the room."
msgstr "Werte für Visio-Parameter zur Konfiguration des Raums."
#: core/models.py:417
#: core/models.py:429
msgid "Room PIN code"
msgstr "PIN-Code für den Raum"
#: core/models.py:418
#: core/models.py:430
msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr ""
"Eindeutiger n-stelliger Code, der diesen Raum im Telephonmodus identifiziert."
#: core/models.py:424 core/models.py:578
#: core/models.py:436 core/models.py:597
msgid "Room"
msgstr "Raum"
#: core/models.py:425
#: core/models.py:437
msgid "Rooms"
msgstr "Räume"
#: core/models.py:589
#: core/models.py:608
msgid "Worker ID"
msgstr "Worker-ID"
#: core/models.py:591
#: core/models.py:610
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
@@ -364,153 +380,153 @@ msgstr ""
"erhalten, auch wenn der Worker stoppt, was ein einfaches Nachverfolgen "
"ermöglicht."
#: core/models.py:599
#: core/models.py:618
msgid "Recording mode"
msgstr "Aufzeichnungsmodus"
#: core/models.py:600
#: core/models.py:619
msgid "Defines the mode of recording being called."
msgstr "Definiert den aufgerufenen Aufzeichnungsmodus."
#: core/models.py:605 core/models.py:606
#: core/models.py:624 core/models.py:625
msgid "Recording options"
msgstr "Aufnahmeoptionen"
#: core/models.py:613
#: core/models.py:632
msgid "External Process ID"
msgstr "External Process ID"
#: core/models.py:614
#: core/models.py:633
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
#: core/models.py:639
msgid "Recording"
msgstr "Aufzeichnung"
#: core/models.py:621
#: core/models.py:640
msgid "Recordings"
msgstr "Aufzeichnungen"
#: core/models.py:731
#: core/models.py:750
msgid "Recording/user relation"
msgstr "Beziehung Aufzeichnung/Benutzer"
#: core/models.py:732
#: core/models.py:751
msgid "Recording/user relations"
msgstr "Beziehungen Aufzeichnung/Benutzer"
#: core/models.py:738
#: core/models.py:757
msgid "This user is already in this recording."
msgstr "Dieser Benutzer ist bereits Teil dieser Aufzeichnung."
#: core/models.py:744
#: core/models.py:763
msgid "This team is already in this recording."
msgstr "Dieses Team ist bereits Teil dieser Aufzeichnung."
#: core/models.py:750
#: core/models.py:769
msgid "Either user or team must be set, not both."
msgstr "Entweder Benutzer oder Team muss festgelegt werden, nicht beides."
#: core/models.py:767
#: core/models.py:786
msgid "Create rooms"
msgstr "Räume erstellen"
#: core/models.py:768
#: core/models.py:787
msgid "List rooms"
msgstr "Räume auflisten"
#: core/models.py:769
#: core/models.py:788
msgid "Retrieve room details"
msgstr "Raumdetails abrufen"
#: core/models.py:770
#: core/models.py:789
msgid "Update rooms"
msgstr "Räume aktualisieren"
#: core/models.py:771
#: core/models.py:790
msgid "Delete rooms"
msgstr "Räume löschen"
#: core/models.py:784
#: core/models.py:803
msgid "Application name"
msgstr "Anwendungsname"
#: core/models.py:785
#: core/models.py:804
msgid "Descriptive name for this application."
msgstr "Beschreibender Name für diese Anwendung."
#: core/models.py:795
#: core/models.py:814
msgid "Hashed on Save. Copy it now if this is a new secret."
msgstr ""
"Beim Speichern gehasht. Jetzt kopieren, wenn dies ein neues Geheimnis ist."
#: core/models.py:806
#: core/models.py:825
msgid "Application"
msgstr "Anwendung"
#: core/models.py:807
#: core/models.py:826
msgid "Applications"
msgstr "Anwendungen"
#: core/models.py:830
#: core/models.py:849
msgid "Enter a valid domain"
msgstr "Geben Sie eine gültige Domain ein"
#: core/models.py:833
#: core/models.py:852
msgid "Domain"
msgstr "Domain"
#: core/models.py:834
#: core/models.py:853
msgid "Email domain this application can act on behalf of."
msgstr "E-Mail-Domain, im Namen der diese Anwendung handeln kann."
#: core/models.py:846
#: core/models.py:865
msgid "Application domain"
msgstr "Anwendungsdomain"
#: core/models.py:847
#: core/models.py:866
msgid "Application domains"
msgstr "Anwendungsdomains"
#: core/models.py:865
#: core/models.py:884
msgid "Pending"
msgstr "Ausstehend"
#: core/models.py:866
#: core/models.py:885
msgid "Analyzing"
msgstr ""
#: core/models.py:873
#: core/models.py:892
msgid "Ready"
msgstr "Bereit"
#: core/models.py:879
#: core/models.py:898
msgid "Background image"
msgstr "Hintergrundbild"
#: core/models.py:891
#: core/models.py:910
msgid "title"
msgstr "Titel"
#: core/models.py:915
#: core/models.py:934
msgid "Malware detection info when the analysis status is unsafe."
msgstr ""
"Informationen zur Malware-Erkennung, wenn der Analyse-Status unsicher ist."
#: core/models.py:920
#: core/models.py:939
msgid "File"
msgstr "Datei"
#: core/models.py:921
#: core/models.py:940
msgid "Files"
msgstr "Dateien"
#: core/models.py:1041
#: core/models.py:1060
msgid "This file is already hard deleted."
msgstr "Diese Datei wurde bereits endgültig gelöscht."
#: core/models.py:1051
#: core/models.py:1070
#, 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."
@@ -518,15 +534,15 @@ msgstr ""
"Um eine Datei endgültig zu löschen, muss sie zuvor weich gelöscht worden "
"sein."
#: core/recording/event/notification.py:123
#: core/recording/event/notification.py:124
msgid "Your recording is ready"
msgstr "Ihre Aufzeichnung ist bereit"
#: core/recording/event/notification.py:194
#: core/recording/event/notification.py:195
msgid "Transcription"
msgstr "Transkription"
#: core/recording/event/notification.py:204
#: core/recording/event/notification.py:206
#, python-brace-format
msgid "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}"
msgstr ""
@@ -537,25 +553,25 @@ msgstr ""
msgid "Video call in progress: {sender.email} is waiting for you to connect"
msgstr "Videoanruf läuft: {sender.email} wartet auf Ihre Teilnahme"
#: core/templates/mail/html/invitation.html:159
#: core/templates/mail/html/screen_recording.html:159
#: core/templates/mail/text/invitation.txt:3
#: core/templates/mail/text/screen_recording.txt:3
#: core/templates/mail/html/invitation.html:151
#: core/templates/mail/html/screen_recording.html:151
#: core/templates/mail/text/invitation.txt:4
#: core/templates/mail/text/screen_recording.txt:4
msgid "Logo email"
msgstr "Logo-E-Mail"
#: core/templates/mail/html/invitation.html:189
#: core/templates/mail/text/invitation.txt:5
#: core/templates/mail/html/invitation.html:181
#: core/templates/mail/text/invitation.txt:6
msgid "invites you to join an ongoing video call"
msgstr "lädt Sie zu einem laufenden Videoanruf ein"
#: core/templates/mail/html/invitation.html:200
#: core/templates/mail/text/invitation.txt:7
#: core/templates/mail/html/invitation.html:192
#: core/templates/mail/text/invitation.txt:8
msgid "JOIN THE CALL"
msgstr "AM ANRUF TEILNEHMEN"
#: core/templates/mail/html/invitation.html:227
#: core/templates/mail/text/invitation.txt:13
#: core/templates/mail/html/invitation.html:219
#: core/templates/mail/text/invitation.txt:14
msgid ""
"If you can't click the button, copy and paste the URL into your browser to "
"join the call."
@@ -563,41 +579,47 @@ msgstr ""
"Wenn Sie den Button nicht anklicken können, kopieren Sie die URL und fügen "
"Sie sie in Ihren Browser ein, um am Anruf teilzunehmen."
#: core/templates/mail/html/invitation.html:235
#: core/templates/mail/text/invitation.txt:15
#: core/templates/mail/html/invitation.html:227
#: core/templates/mail/text/invitation.txt:16
msgid "Tips for a better experience:"
msgstr "Tipps für ein besseres Erlebnis:"
#: core/templates/mail/html/invitation.html:237
#: core/templates/mail/text/invitation.txt:17
#: core/templates/mail/html/invitation.html:229
#: core/templates/mail/text/invitation.txt:18
msgid "Use Chrome or Firefox for better call quality"
msgstr "Verwenden Sie Chrome oder Firefox für eine bessere Anrufqualität"
#: core/templates/mail/html/invitation.html:238
#: core/templates/mail/text/invitation.txt:18
#: core/templates/mail/html/invitation.html:230
#: core/templates/mail/text/invitation.txt:19
msgid "Test your microphone and camera before joining"
msgstr "Testen Sie Ihr Mikrofon und Ihre Kamera vor dem Beitritt"
#: core/templates/mail/html/invitation.html:239
#: core/templates/mail/text/invitation.txt:19
#: core/templates/mail/html/invitation.html:231
#: core/templates/mail/text/invitation.txt:20
msgid "Make sure you have a stable internet connection"
msgstr "Stellen Sie sicher, dass Sie eine stabile Internetverbindung haben"
#: core/templates/mail/html/invitation.html:248
#: core/templates/mail/html/screen_recording.html:245
#: core/templates/mail/text/invitation.txt:21
#: core/templates/mail/text/screen_recording.txt:23
#: core/templates/mail/html/invitation.html:240
#: core/templates/mail/html/screen_recording.html:237
#: core/templates/mail/text/invitation.txt:22
#: core/templates/mail/text/screen_recording.txt:24
#, python-format
msgid " Thank you for using %(brandname)s. "
msgstr " Vielen Dank für die Nutzung von %(brandname)s. "
#: core/templates/mail/html/screen_recording.html:188
#: core/templates/mail/text/screen_recording.txt:6
#: core/templates/mail/html/invitation.html:271
#, python-format
msgid ""
"This mail has been sent to %(email)s by <a href=\"%(href)s\">%(name)s</a>"
msgstr ""
#: core/templates/mail/html/screen_recording.html:180
#: core/templates/mail/text/screen_recording.txt:7
msgid "Your recording is ready!"
msgstr "Ihre Aufzeichnung ist fertig!"
#: core/templates/mail/html/screen_recording.html:195
#: core/templates/mail/text/screen_recording.txt:8
#: core/templates/mail/html/screen_recording.html:187
#: core/templates/mail/text/screen_recording.txt:9
#, python-format
msgid ""
" Your recording of \"%(room_name)s\" on %(recording_date)s at "
@@ -606,14 +628,14 @@ msgstr ""
" Ihre Aufzeichnung von \"%(room_name)s\" am %(recording_date)s um "
"%(recording_time)s steht nun zum Herunterladen bereit. "
#: core/templates/mail/html/screen_recording.html:195
#: core/templates/mail/text/screen_recording.txt:8
#: core/templates/mail/html/screen_recording.html:187
#: core/templates/mail/text/screen_recording.txt:9
#, python-format
msgid " The recording will expire in %(days)s days. "
msgstr " Die Aufzeichnung wird in %(days)s Tagen ablaufen. "
#: core/templates/mail/html/screen_recording.html:200
#: core/templates/mail/text/screen_recording.txt:9
#: core/templates/mail/html/screen_recording.html:192
#: core/templates/mail/text/screen_recording.txt:10
msgid ""
" Sharing the recording via link is not yet available. Only organizers can "
"download it. "
@@ -621,33 +643,33 @@ msgstr ""
" Die Freigabe der Aufzeichnung per Link ist noch nicht verfügbar. Nur "
"Organisatoren können sie herunterladen. "
#: core/templates/mail/html/screen_recording.html:206
#: core/templates/mail/text/screen_recording.txt:11
#: core/templates/mail/html/screen_recording.html:198
#: core/templates/mail/text/screen_recording.txt:12
msgid "To keep this recording permanently:"
msgstr "So speichern Sie diese Aufzeichnung dauerhaft:"
#: core/templates/mail/html/screen_recording.html:208
#: core/templates/mail/html/screen_recording.html:200
#, python-format
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
msgstr "Klicken Sie auf den Link „<a href=\"%(link)s\">Öffnen</a>\" unten "
#: core/templates/mail/html/screen_recording.html:209
#: core/templates/mail/text/screen_recording.txt:14
#: core/templates/mail/html/screen_recording.html:201
#: core/templates/mail/text/screen_recording.txt:15
msgid "Use the \"Download\" button in the interface "
msgstr "Verwenden Sie den Button „Herunterladen“ in der Oberfläche "
#: core/templates/mail/html/screen_recording.html:210
#: core/templates/mail/text/screen_recording.txt:15
#: core/templates/mail/html/screen_recording.html:202
#: core/templates/mail/text/screen_recording.txt:16
msgid "Save the file to your preferred location"
msgstr "Speichern Sie die Datei an einem gewünschten Ort"
#: core/templates/mail/html/screen_recording.html:221
#: core/templates/mail/text/screen_recording.txt:17
#: core/templates/mail/html/screen_recording.html:213
#: core/templates/mail/text/screen_recording.txt:18
msgid "Open"
msgstr "Öffnen"
#: core/templates/mail/html/screen_recording.html:230
#: core/templates/mail/text/screen_recording.txt:19
#: core/templates/mail/html/screen_recording.html:222
#: core/templates/mail/text/screen_recording.txt:20
#, python-format
msgid ""
" If you have any questions or need assistance, please contact our support "
@@ -656,28 +678,33 @@ msgstr ""
" Wenn Sie Fragen haben oder Unterstützung benötigen, wenden Sie sich bitte "
"an unser Support-Team unter %(support_email)s. "
#: core/templates/mail/text/screen_recording.txt:13
#: core/templates/mail/text/invitation.txt:24
#, python-format
msgid "This mail has been sent to %(email)s by %(name)s [%(href)s]"
msgstr ""
#: core/templates/mail/text/screen_recording.txt:14
#, 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
#: meet/settings.py:238
msgid "English"
msgstr "Englisch"
#: meet/settings.py:229
#: meet/settings.py:239
msgid "French"
msgstr "Französisch"
#: meet/settings.py:230
#: meet/settings.py:240
msgid "Dutch"
msgstr "Niederländisch"
#: meet/settings.py:231
#: meet/settings.py:241
msgid "German"
msgstr "Deutsch"
#: meet/settings.py:233
#: meet/settings.py:242
msgid "Spanish"
msgstr "Spanisch"
+187 -160
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-07-02 10:47+0000\n"
"POT-Creation-Date: 2026-09-02 22:52+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -51,11 +51,11 @@ msgstr ""
msgid "File preview"
msgstr ""
#: core/admin.py:300 core/admin.py:443
#: core/admin.py:300 core/admin.py:450
msgid "No owner"
msgstr "No owner"
#: core/admin.py:303 core/admin.py:446
#: core/admin.py:303 core/admin.py:453
msgid "Multiple owners"
msgstr "Multiple owners"
@@ -98,11 +98,11 @@ msgstr "%(count)s recording(s) successfully marked as 'Failed to Stop'."
msgid "Skipped %(count)s recording(s) with an ineligible status."
msgstr "Skipped %(count)s expired recording(s)."
#: core/admin.py:510
#: core/admin.py:517
msgid "No scopes"
msgstr "No scopes"
#: core/admin.py:512
#: core/admin.py:519
msgid "Scopes"
msgstr "Scopes"
@@ -110,183 +110,199 @@ msgstr "Scopes"
msgid "Creator is me"
msgstr "Creator is me"
#: core/api/serializers.py:89
#: core/api/serializers.py:108
msgid "You must be administrator or owner of a room to add accesses to it."
msgstr "You must be administrator or owner of a room to add accesses to it."
#: core/api/serializers.py:534
#: core/api/serializers.py:560
msgid "This file extension is not allowed."
msgstr "This file extension is not allowed."
#: core/api/viewsets.py:1222
#: core/api/viewsets.py:1268
msgid "You have reached the maximum number of files for this type."
msgstr "You have reached the maximum number of files for this type."
#: core/models.py:37
#: core/models.py:38
msgid "Member"
msgstr "Member"
#: core/models.py:38
#: core/models.py:39
msgid "Administrator"
msgstr "Administrator"
#: core/models.py:39
#: core/models.py:40
msgid "Owner"
msgstr "Owner"
#: core/models.py:55
#: core/models.py:56
msgid "Initiated"
msgstr "Initiated"
#: core/models.py:56
#: core/models.py:57
msgid "Active"
msgstr "Active"
#: core/models.py:57
#: core/models.py:58
msgid "Stopped"
msgstr "Stopped"
#: core/models.py:58
#: core/models.py:59
msgid "Saved"
msgstr "Saved"
#: core/models.py:59
#: core/models.py:60
msgid "Aborted"
msgstr "Aborted"
#: core/models.py:60
#: core/models.py:61
msgid "Failed to Start"
msgstr "Failed to Start"
#: core/models.py:61
#: core/models.py:62
msgid "Failed to Stop"
msgstr "Failed to Stop"
#: core/models.py:62
#: core/models.py:63
msgid "Notification succeeded"
msgstr "Notification succeeded"
#: core/models.py:65
#: core/models.py:66
msgid "External process successful"
msgstr "External process successful"
#: core/models.py:67
#: core/models.py:68
msgid "External process failed"
msgstr "External process failed"
#: core/models.py:96
#: core/models.py:97
msgid "SCREEN_RECORDING"
msgstr "SCREEN_RECORDING"
#: core/models.py:97
#: core/models.py:98
msgid "TRANSCRIPT"
msgstr "TRANSCRIPT"
#: core/models.py:103
#: core/models.py:104
msgid "Public Access"
msgstr "Public Access"
#: core/models.py:104
#: core/models.py:105
msgid "Trusted Access"
msgstr "Trusted Access"
#: core/models.py:105
#: core/models.py:106
msgid "Restricted Access"
msgstr "Restricted Access"
#: core/models.py:117
#: core/models.py:118
msgid "id"
msgstr "id"
#: core/models.py:118
#: core/models.py:119
msgid "primary key for the record as UUID"
msgstr "primary key for the record as UUID"
#: core/models.py:124
#: core/models.py:125
msgid "created on"
msgstr "created on"
#: core/models.py:125
#: core/models.py:126
msgid "date and time at which a record was created"
msgstr "date and time at which a record was created"
#: core/models.py:130
#: core/models.py:131
msgid "updated on"
msgstr "updated on"
#: core/models.py:131
#: core/models.py:132
msgid "date and time at which a record was last updated"
msgstr "date and time at which a record was last updated"
#: core/models.py:151
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
msgstr ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
#: core/models.py:157
#: core/models.py:150
msgid "sub"
msgstr "sub"
#: core/models.py:159
#: core/models.py:152
msgid ""
"Optional for pending users; required upon account activation. 255 characters "
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
"or fewer. Printable ASCII characters only."
msgstr ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
"Optional for pending users; required upon account activation. 255 characters "
"or fewer. Printable ASCII characters only."
#: core/models.py:168
#: core/validators.py:22
msgid "Enter a valid sub. This value should be printable ASCII only."
msgstr "Enter a valid sub. This value should be printable ASCII only."
#: core/models.py:161
msgid "identity email address"
msgstr "identity email address"
#: core/models.py:173
#: core/models.py:166
msgid "admin email address"
msgstr "admin email address"
#: core/models.py:175
#: core/models.py:168
msgid "full name"
msgstr "full name"
#: core/models.py:177
#: core/models.py:170
msgid "short name"
msgstr "short name"
#: core/models.py:183
#: core/models.py:176
msgid "language"
msgstr "language"
#: core/models.py:184
#: core/models.py:177
msgid "The language in which the user wants to see the interface."
msgstr "The language in which the user wants to see the interface."
#: core/models.py:190
#: core/models.py:183
msgid "The timezone in which the user wants to see times."
msgstr "The timezone in which the user wants to see times."
#: core/models.py:193
#: core/models.py:190
msgid "default room access level"
msgstr ""
#: core/models.py:192
msgid ""
"Access level applied by default to new rooms created by this user. When "
"empty, the instance default is used."
msgstr ""
#: core/models.py:199
#, fuzzy
#| msgid "Visio room configuration"
msgid "default room configuration"
msgstr "Visio room configuration"
#: core/models.py:201
msgid "Configurations applied by default to new rooms created by this user."
msgstr ""
#: core/models.py:205
msgid "device"
msgstr "device"
#: core/models.py:195
#: core/models.py:207
msgid "Whether the user is a device or a real user."
msgstr "Whether the user is a device or a real user."
#: core/models.py:198
#: core/models.py:210
msgid "staff status"
msgstr "staff status"
#: core/models.py:200
#: core/models.py:212
msgid "Whether the user can log into this admin site."
msgstr "Whether the user can log into this admin site."
#: core/models.py:203
#: core/models.py:215
msgid "active"
msgstr "active"
#: core/models.py:206
#: core/models.py:218
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
@@ -294,63 +310,63 @@ msgstr ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
#: core/models.py:219
#: core/models.py:231
msgid "user"
msgstr "user"
#: core/models.py:220
#: core/models.py:232
msgid "users"
msgstr "users"
#: core/models.py:286
#: core/models.py:298
msgid "Resource"
msgstr "Resource"
#: core/models.py:287
#: core/models.py:299
msgid "Resources"
msgstr "Resources"
#: core/models.py:345
#: core/models.py:357
msgid "Resource access"
msgstr "Resource access"
#: core/models.py:346
#: core/models.py:358
msgid "Resource accesses"
msgstr "Resource accesses"
#: core/models.py:352
#: core/models.py:364
msgid "Resource access with this User and Resource already exists."
msgstr "Resource access with this User and Resource already exists."
#: core/models.py:409
#: core/models.py:421
msgid "Visio room configuration"
msgstr "Visio room configuration"
#: core/models.py:410
#: core/models.py:422
msgid "Values for Visio parameters to configure the room."
msgstr "Values for Visio parameters to configure the room."
#: core/models.py:417
#: core/models.py:429
msgid "Room PIN code"
msgstr "Room PIN code"
#: core/models.py:418
#: core/models.py:430
msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr "Unique n-digit code that identifies this room in telephony mode."
#: core/models.py:424 core/models.py:578
#: core/models.py:436 core/models.py:597
msgid "Room"
msgstr "Room"
#: core/models.py:425
#: core/models.py:437
msgid "Rooms"
msgstr "Rooms"
#: core/models.py:589
#: core/models.py:608
msgid "Worker ID"
msgstr "Worker ID"
#: core/models.py:591
#: core/models.py:610
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
@@ -358,171 +374,171 @@ msgstr ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
#: core/models.py:599
#: core/models.py:618
msgid "Recording mode"
msgstr "Recording mode"
#: core/models.py:600
#: core/models.py:619
msgid "Defines the mode of recording being called."
msgstr "Defines the mode of recording being called."
#: core/models.py:605 core/models.py:606
#: core/models.py:624 core/models.py:625
msgid "Recording options"
msgstr "Recording options"
#: core/models.py:613
#: core/models.py:632
msgid "External Process ID"
msgstr "External Process ID"
#: core/models.py:614
#: core/models.py:633
msgid "ID of the external process associated with the recording."
msgstr "ID of the external process associated with the recording."
#: core/models.py:620
#: core/models.py:639
msgid "Recording"
msgstr "Recording"
#: core/models.py:621
#: core/models.py:640
msgid "Recordings"
msgstr "Recordings"
#: core/models.py:731
#: core/models.py:750
msgid "Recording/user relation"
msgstr "Recording/user relation"
#: core/models.py:732
#: core/models.py:751
msgid "Recording/user relations"
msgstr "Recording/user relations"
#: core/models.py:738
#: core/models.py:757
msgid "This user is already in this recording."
msgstr "This user is already in this recording."
#: core/models.py:744
#: core/models.py:763
msgid "This team is already in this recording."
msgstr "This team is already in this recording."
#: core/models.py:750
#: core/models.py:769
msgid "Either user or team must be set, not both."
msgstr "Either user or team must be set, not both."
#: core/models.py:767
#: core/models.py:786
#, fuzzy
#| msgid "created on"
msgid "Create rooms"
msgstr "Create rooms"
#: core/models.py:768
#: core/models.py:787
msgid "List rooms"
msgstr "List rooms"
#: core/models.py:769
#: core/models.py:788
msgid "Retrieve room details"
msgstr "Retrieve room details"
#: core/models.py:770
#: core/models.py:789
#, fuzzy
#| msgid "updated on"
msgid "Update rooms"
msgstr "Update rooms"
#: core/models.py:771
#: core/models.py:790
msgid "Delete rooms"
msgstr "Delete rooms"
#: core/models.py:784
#: core/models.py:803
msgid "Application name"
msgstr "Application name"
#: core/models.py:785
#: core/models.py:804
msgid "Descriptive name for this application."
msgstr "Descriptive name for this application."
#: core/models.py:795
#: core/models.py:814
msgid "Hashed on Save. Copy it now if this is a new secret."
msgstr "Hashed on Save. Copy it now if this is a new secret."
#: core/models.py:806
#: core/models.py:825
msgid "Application"
msgstr "Application"
#: core/models.py:807
#: core/models.py:826
msgid "Applications"
msgstr "Applications"
#: core/models.py:830
#: core/models.py:849
msgid "Enter a valid domain"
msgstr "Enter a valid domain"
#: core/models.py:833
#: core/models.py:852
msgid "Domain"
msgstr "Domain"
#: core/models.py:834
#: core/models.py:853
msgid "Email domain this application can act on behalf of."
msgstr "Email domain this application can act on behalf of."
#: core/models.py:846
#: core/models.py:865
msgid "Application domain"
msgstr "Application domain"
#: core/models.py:847
#: core/models.py:866
msgid "Application domains"
msgstr "Application domains"
#: core/models.py:865
#: core/models.py:884
#, fuzzy
#| msgid "Recording"
msgid "Pending"
msgstr "Pending"
#: core/models.py:866
#: core/models.py:885
msgid "Analyzing"
msgstr ""
#: core/models.py:873
#: core/models.py:892
msgid "Ready"
msgstr "Ready"
#: core/models.py:879
#: core/models.py:898
msgid "Background image"
msgstr "Background image"
#: core/models.py:891
#: core/models.py:910
msgid "title"
msgstr "title"
#: core/models.py:915
#: core/models.py:934
msgid "Malware detection info when the analysis status is unsafe."
msgstr "Malware detection info when the analysis status is unsafe."
#: core/models.py:920
#: core/models.py:939
msgid "File"
msgstr "File"
#: core/models.py:921
#: core/models.py:940
msgid "Files"
msgstr "Files"
#: core/models.py:1041
#: core/models.py:1060
#, fuzzy
#| msgid "This user is already in this recording."
msgid "This file is already hard deleted."
msgstr "This file is already hard deleted."
#: core/models.py:1051
#: core/models.py:1070
msgid "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:123
#: core/recording/event/notification.py:124
msgid "Your recording is ready"
msgstr "Your recording is ready"
#: core/recording/event/notification.py:194
#: core/recording/event/notification.py:195
msgid "Transcription"
msgstr "Transcription"
#: core/recording/event/notification.py:204
#: core/recording/event/notification.py:206
#, 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}"
@@ -532,25 +548,25 @@ msgstr "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}"
msgid "Video call in progress: {sender.email} is waiting for you to connect"
msgstr "Video call in progress: {sender.email} is waiting for you to connect"
#: core/templates/mail/html/invitation.html:159
#: core/templates/mail/html/screen_recording.html:159
#: core/templates/mail/text/invitation.txt:3
#: core/templates/mail/text/screen_recording.txt:3
#: core/templates/mail/html/invitation.html:151
#: core/templates/mail/html/screen_recording.html:151
#: core/templates/mail/text/invitation.txt:4
#: core/templates/mail/text/screen_recording.txt:4
msgid "Logo email"
msgstr "Logo email"
#: core/templates/mail/html/invitation.html:189
#: core/templates/mail/text/invitation.txt:5
#: core/templates/mail/html/invitation.html:181
#: core/templates/mail/text/invitation.txt:6
msgid "invites you to join an ongoing video call"
msgstr "invites you to join an ongoing video call"
#: core/templates/mail/html/invitation.html:200
#: core/templates/mail/text/invitation.txt:7
#: core/templates/mail/html/invitation.html:192
#: core/templates/mail/text/invitation.txt:8
msgid "JOIN THE CALL"
msgstr "JOIN THE CALL"
#: core/templates/mail/html/invitation.html:227
#: core/templates/mail/text/invitation.txt:13
#: core/templates/mail/html/invitation.html:219
#: core/templates/mail/text/invitation.txt:14
msgid ""
"If you can't click the button, copy and paste the URL into your browser to "
"join the call."
@@ -558,41 +574,47 @@ msgstr ""
"If you can't click the button, copy and paste the URL into your browser to "
"join the call."
#: core/templates/mail/html/invitation.html:235
#: core/templates/mail/text/invitation.txt:15
#: core/templates/mail/html/invitation.html:227
#: core/templates/mail/text/invitation.txt:16
msgid "Tips for a better experience:"
msgstr "Tips for a better experience:"
#: core/templates/mail/html/invitation.html:237
#: core/templates/mail/text/invitation.txt:17
#: core/templates/mail/html/invitation.html:229
#: core/templates/mail/text/invitation.txt:18
msgid "Use Chrome or Firefox for better call quality"
msgstr "Use Chrome or Firefox for better call quality"
#: core/templates/mail/html/invitation.html:238
#: core/templates/mail/text/invitation.txt:18
#: core/templates/mail/html/invitation.html:230
#: core/templates/mail/text/invitation.txt:19
msgid "Test your microphone and camera before joining"
msgstr "Test your microphone and camera before joining"
#: core/templates/mail/html/invitation.html:239
#: core/templates/mail/text/invitation.txt:19
#: core/templates/mail/html/invitation.html:231
#: core/templates/mail/text/invitation.txt:20
msgid "Make sure you have a stable internet connection"
msgstr "Make sure you have a stable internet connection"
#: core/templates/mail/html/invitation.html:248
#: core/templates/mail/html/screen_recording.html:245
#: core/templates/mail/text/invitation.txt:21
#: core/templates/mail/text/screen_recording.txt:23
#: core/templates/mail/html/invitation.html:240
#: core/templates/mail/html/screen_recording.html:237
#: core/templates/mail/text/invitation.txt:22
#: core/templates/mail/text/screen_recording.txt:24
#, python-format
msgid " Thank you for using %(brandname)s. "
msgstr " Thank you for using %(brandname)s. "
#: core/templates/mail/html/screen_recording.html:188
#: core/templates/mail/text/screen_recording.txt:6
#: core/templates/mail/html/invitation.html:271
#, python-format
msgid ""
"This mail has been sent to %(email)s by <a href=\"%(href)s\">%(name)s</a>"
msgstr ""
#: core/templates/mail/html/screen_recording.html:180
#: core/templates/mail/text/screen_recording.txt:7
msgid "Your recording is ready!"
msgstr "Your recording is ready!"
#: core/templates/mail/html/screen_recording.html:195
#: core/templates/mail/text/screen_recording.txt:8
#: core/templates/mail/html/screen_recording.html:187
#: core/templates/mail/text/screen_recording.txt:9
#, python-format
msgid ""
" Your recording of \"%(room_name)s\" on %(recording_date)s at "
@@ -601,14 +623,14 @@ msgstr ""
" Your recording of \"%(room_name)s\" on %(recording_date)s at "
"%(recording_time)s is now ready to download. "
#: core/templates/mail/html/screen_recording.html:195
#: core/templates/mail/text/screen_recording.txt:8
#: core/templates/mail/html/screen_recording.html:187
#: core/templates/mail/text/screen_recording.txt:9
#, python-format
msgid " The recording will expire in %(days)s days. "
msgstr " The recording will expire in %(days)s days. "
#: core/templates/mail/html/screen_recording.html:200
#: core/templates/mail/text/screen_recording.txt:9
#: core/templates/mail/html/screen_recording.html:192
#: core/templates/mail/text/screen_recording.txt:10
msgid ""
" Sharing the recording via link is not yet available. Only organizers can "
"download it. "
@@ -616,33 +638,33 @@ msgstr ""
" Sharing the recording via link is not yet available. Only organizers can "
"download it. "
#: core/templates/mail/html/screen_recording.html:206
#: core/templates/mail/text/screen_recording.txt:11
#: core/templates/mail/html/screen_recording.html:198
#: core/templates/mail/text/screen_recording.txt:12
msgid "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:200
#, python-format
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
msgstr "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
#: core/templates/mail/html/screen_recording.html:209
#: core/templates/mail/text/screen_recording.txt:14
#: core/templates/mail/html/screen_recording.html:201
#: core/templates/mail/text/screen_recording.txt:15
msgid "Use the \"Download\" button in the interface "
msgstr "Use the \"Download\" button in the interface "
#: core/templates/mail/html/screen_recording.html:210
#: core/templates/mail/text/screen_recording.txt:15
#: core/templates/mail/html/screen_recording.html:202
#: core/templates/mail/text/screen_recording.txt:16
msgid "Save the file to your preferred location"
msgstr "Save the file to your preferred location"
#: core/templates/mail/html/screen_recording.html:221
#: core/templates/mail/text/screen_recording.txt:17
#: core/templates/mail/html/screen_recording.html:213
#: core/templates/mail/text/screen_recording.txt:18
msgid "Open"
msgstr "Open"
#: core/templates/mail/html/screen_recording.html:230
#: core/templates/mail/text/screen_recording.txt:19
#: core/templates/mail/html/screen_recording.html:222
#: core/templates/mail/text/screen_recording.txt:20
#, python-format
msgid ""
" If you have any questions or need assistance, please contact our support "
@@ -651,28 +673,33 @@ msgstr ""
" If you have any questions or need assistance, please contact our support "
"team at %(support_email)s. "
#: core/templates/mail/text/screen_recording.txt:13
#: core/templates/mail/text/invitation.txt:24
#, python-format
msgid "This mail has been sent to %(email)s by %(name)s [%(href)s]"
msgstr ""
#: core/templates/mail/text/screen_recording.txt:14
#, 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
#: meet/settings.py:238
msgid "English"
msgstr "English"
#: meet/settings.py:229
#: meet/settings.py:239
msgid "French"
msgstr "French"
#: meet/settings.py:230
#: meet/settings.py:240
msgid "Dutch"
msgstr "Dutch"
#: meet/settings.py:231
#: meet/settings.py:241
msgid "German"
msgstr "German"
#: meet/settings.py:233
#: meet/settings.py:242
msgid "Spanish"
msgstr "Spanish"
+229 -174
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-07-02 10:47+0000\n"
"POT-Creation-Date: 2026-09-02 22:52+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -41,20 +41,20 @@ msgstr "Eliminar Salas"
msgid "Derived info"
msgstr ""
#: core/admin.py:237
# 'Marcas de tiempo' is a bad translation for spanish
#: core/admin.py:237
msgid "Timestamps"
msgstr ""
msgstr ""
#: core/admin.py:240
msgid "File preview"
msgstr "Vista previa"
#: core/admin.py:300 core/admin.py:443
#: core/admin.py:300 core/admin.py:450
msgid "No owner"
msgstr "Sin propietario"
#: core/admin.py:303 core/admin.py:446
#: core/admin.py:303 core/admin.py:453
msgid "Multiple owners"
msgstr "Varios propietarios"
@@ -89,18 +89,19 @@ msgstr "Marcar las grabaciones seleccionadas como «Error al detener»"
#: core/admin.py:386
#, python-format
msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'."
msgstr "%(count)s grabación(es) marcada(s) correctamente como «Error al detener»."
msgstr ""
"%(count)s grabación(es) marcada(s) correctamente como «Error al detener»."
#: core/admin.py:394
#, python-format
msgid "Skipped %(count)s recording(s) with an ineligible status."
msgstr "Se han omitido %(count)s grabación(es) con un estado no elegible."
#: core/admin.py:510
#: core/admin.py:517
msgid "No scopes"
msgstr ""
#: core/admin.py:512
#: core/admin.py:519
msgid "Scopes"
msgstr "Ámbitos"
@@ -108,545 +109,599 @@ msgstr "Ámbitos"
msgid "Creator is me"
msgstr "Yo soy el creador"
#: core/api/serializers.py:89
#: core/api/serializers.py:108
msgid "You must be administrator or owner of a room to add accesses to it."
msgstr "Debes ser administrador o propietario de una reunión para añadirle accesos."
msgstr ""
"Debes ser administrador o propietario de una reunión para añadirle accesos."
#: core/api/serializers.py:534
#: core/api/serializers.py:560
msgid "This file extension is not allowed."
msgstr "Esta extensión de archivo no está permitida."
#: core/api/viewsets.py:1222
#: core/api/viewsets.py:1268
msgid "You have reached the maximum number of files for this type."
msgstr "Has alcanzado el número máximo de archivos de este tipo."
#: core/models.py:37
#: core/models.py:38
msgid "Member"
msgstr "Miembro"
#: core/models.py:38
#: core/models.py:39
msgid "Administrator"
msgstr "Administrador"
#: core/models.py:39
#: core/models.py:40
msgid "Owner"
msgstr "Propietario"
#: core/models.py:55
# To check here and following lines for gender agreement (Masculine/Feminine)
#: core/models.py:56
msgid "Initiated"
msgstr "Iniciada"
#: core/models.py:56
#: core/models.py:57
msgid "Active"
msgstr "Activa"
#: core/models.py:57
#: core/models.py:58
msgid "Stopped"
msgstr "Detenida"
#: core/models.py:58
#: core/models.py:59
msgid "Saved"
msgstr "Guardada"
#: core/models.py:59
#: core/models.py:60
msgid "Aborted"
msgstr "Cancelada"
#: core/models.py:60
#: core/models.py:61
msgid "Failed to Start"
msgstr "Error al iniciar"
#: core/models.py:61
#: core/models.py:62
msgid "Failed to Stop"
msgstr "Error al detener"
#: core/models.py:62
#: core/models.py:63
msgid "Notification succeeded"
msgstr "Notificado correctamente"
#: core/models.py:65
#: core/models.py:66
msgid "External process successful"
msgstr "Proceso externo finalizado correctamente"
#: core/models.py:67
#: core/models.py:68
msgid "External process failed"
msgstr "Error en el Proceso externo"
#: core/models.py:96
#: core/models.py:97
msgid "SCREEN_RECORDING"
msgstr "GRABACIÓN_DE_PANTALLA"
#: core/models.py:97
#: core/models.py:98
msgid "TRANSCRIPT"
msgstr "TRANSCRIPCIÓN"
#: core/models.py:103
#: core/models.py:104
msgid "Public Access"
msgstr "Acceso público"
#: core/models.py:104
#: core/models.py:105
msgid "Trusted Access"
msgstr "Acceso usuarios autorizados"
#: core/models.py:105
#: core/models.py:106
msgid "Restricted Access"
msgstr "Acceso restringido"
#: core/models.py:117
#: core/models.py:118
msgid "id"
msgstr "id"
#: core/models.py:118
#: core/models.py:119
msgid "primary key for the record as UUID"
msgstr "clave primaria del registro en forma de UUID"
#: core/models.py:124
#: core/models.py:125
msgid "created on"
msgstr "creado el"
#: core/models.py:125
#: core/models.py:126
msgid "date and time at which a record was created"
msgstr "fecha y hora en que se creó un registro"
#: core/models.py:130
#: core/models.py:131
msgid "updated on"
msgstr "actualizado el"
#: core/models.py:131
#: core/models.py:132
msgid "date and time at which a record was last updated"
msgstr "fecha y hora de la última actualización de un registro"
#: core/models.py:151
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
msgstr "Introduce un sub válido. Este valor solo puede contener letras, números y los caracteres @/./+/-/_."
#: core/models.py:157
#: core/models.py:150
msgid "sub"
msgstr "sub"
#: core/models.py:159
#: core/models.py:152
msgid ""
"Optional for pending users; required upon account activation. 255 characters "
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
msgstr "Opcional para los usuarios pendientes; obligatorio al activar la cuenta. 255 caracteres como máximo. Solo letras, números y los caracteres @/./+/-/_."
"or fewer. Printable ASCII characters only."
msgstr ""
"Opcional para los usuarios pendientes; obligatorio al activar la cuenta. "
"255 caracteres como máximo. Solo caracteres ASCII imprimibles."
#: core/models.py:168
#: core/validators.py:22
msgid "Enter a valid sub. This value should be printable ASCII only."
msgstr "Introduzca un sub válido. Este valor solo debe contener caracteres ASCII imprimibles."
#: core/models.py:161
msgid "identity email address"
msgstr "dirección de correo electrónico"
#: core/models.py:173
#: core/models.py:166
msgid "admin email address"
msgstr "dirección de correo electrónico de administrador"
#: core/models.py:175
#: core/models.py:168
msgid "full name"
msgstr "nombre completo"
#: core/models.py:177
#: core/models.py:170
msgid "short name"
msgstr "nombre corto"
#: core/models.py:183
#: core/models.py:176
msgid "language"
msgstr "idioma"
#: core/models.py:184
#: core/models.py:177
msgid "The language in which the user wants to see the interface."
msgstr "El idioma preferido de interfaz."
#: core/models.py:190
#: core/models.py:183
msgid "The timezone in which the user wants to see times."
msgstr "La zona horaria en la que el usuario quiere ver las horas."
#: core/models.py:193
#: core/models.py:190
msgid "default room access level"
msgstr ""
#: core/models.py:192
msgid ""
"Access level applied by default to new rooms created by this user. When "
"empty, the instance default is used."
msgstr ""
#: core/models.py:199
#, fuzzy
#| msgid "Visio room configuration"
msgid "default room configuration"
msgstr "Configuración de la videoconferencia"
#: core/models.py:201
msgid "Configurations applied by default to new rooms created by this user."
msgstr ""
#: core/models.py:205
msgid "device"
msgstr "dispositivo"
#: core/models.py:195
#: core/models.py:207
msgid "Whether the user is a device or a real user."
msgstr "Si el usuario es un dispositivo o un usuario real."
#: core/models.py:198
#: core/models.py:210
msgid "staff status"
msgstr "estado del personal"
#: core/models.py:200
#: core/models.py:212
msgid "Whether the user can log into this admin site."
msgstr "Si el usuario puede acceder a este sitio de administración."
#: core/models.py:203
#: core/models.py:215
msgid "active"
msgstr "activo"
#: core/models.py:206
#: core/models.py:218
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
msgstr "Si este usuario debe considerarse activo. Desmarca esta opción en lugar de eliminar cuentas."
msgstr ""
"Si este usuario debe considerarse activo. Desmarca esta opción en lugar de "
"eliminar cuentas."
#: core/models.py:219
#: core/models.py:231
msgid "user"
msgstr "usuario"
#: core/models.py:220
#: core/models.py:232
msgid "users"
msgstr "usuarios"
#: core/models.py:286
#: core/models.py:298
msgid "Resource"
msgstr "Recurso"
#: core/models.py:287
#: core/models.py:299
msgid "Resources"
msgstr "Recursos"
#: core/models.py:345
#: core/models.py:357
msgid "Resource access"
msgstr "Acceso a recursos"
#: core/models.py:346
#: core/models.py:358
msgid "Resource accesses"
msgstr "Accesos a recursos"
#: core/models.py:352
#: core/models.py:364
msgid "Resource access with this User and Resource already exists."
msgstr "Ya existe un acceso al recurso con este usuario y este recurso."
#: core/models.py:409
#: core/models.py:421
msgid "Visio room configuration"
msgstr "Configuración de la videoconferencia"
#: core/models.py:410
#: core/models.py:422
msgid "Values for Visio parameters to configure the room."
msgstr "Valores de los parámetros de videoconferencia para configurar la reunión."
msgstr ""
"Valores de los parámetros de videoconferencia para configurar la reunión."
#: core/models.py:417
#: core/models.py:429
msgid "Room PIN code"
msgstr "Código PIN de la reunión"
#: core/models.py:418
#: core/models.py:430
msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr "Código único de n dígitos que identifica esta reunión en modo telefónico."
msgstr ""
"Código único de n dígitos que identifica esta reunión en modo telefónico."
#: core/models.py:424 core/models.py:578
#: core/models.py:436 core/models.py:597
msgid "Room"
msgstr "Reunión"
#: core/models.py:425
#: core/models.py:437
msgid "Rooms"
msgstr "Reuniones"
#: core/models.py:589
#: core/models.py:608
msgid "Worker ID"
msgstr "ID del worker"
#: core/models.py:591
#: core/models.py:610
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
msgstr "Introduce un identificador para la grabación del worker. Este identificador se conserva incluso cuando el worker se detiene, lo que permite un seguimiento sencillo."
msgstr ""
"Introduce un identificador para la grabación del worker. Este identificador "
"se conserva incluso cuando el worker se detiene, lo que permite un "
"seguimiento sencillo."
#: core/models.py:599
#: core/models.py:618
msgid "Recording mode"
msgstr "Modo de grabación"
#: core/models.py:600
#: core/models.py:619
msgid "Defines the mode of recording being called."
msgstr "Define el modo de grabación a utilizar."
#: core/models.py:605 core/models.py:606
#: core/models.py:624 core/models.py:625
msgid "Recording options"
msgstr "Opciones de grabación"
#: core/models.py:613
#: core/models.py:632
msgid "External Process ID"
msgstr "ID del proceso externo"
#: core/models.py:614
#: core/models.py:633
msgid "ID of the external process associated with the recording."
msgstr "ID del proceso externo asociado a la grabación."
#: core/models.py:620
#: core/models.py:639
msgid "Recording"
msgstr "Grabación"
#: core/models.py:621
#: core/models.py:640
msgid "Recordings"
msgstr "Grabaciones"
#: core/models.py:731
#: core/models.py:750
msgid "Recording/user relation"
msgstr "Relación grabación/usuario"
#: core/models.py:732
#: core/models.py:751
msgid "Recording/user relations"
msgstr "Relaciones grabación/usuario"
#: core/models.py:738
#: core/models.py:757
msgid "This user is already in this recording."
msgstr "Este usuario ya está en esta grabación."
#: core/models.py:744
#: core/models.py:763
msgid "This team is already in this recording."
msgstr "Este equipo ya está en esta grabación."
#: core/models.py:750
#: core/models.py:769
msgid "Either user or team must be set, not both."
msgstr "Debe definirse el usuario o el equipo, pero no ambos."
#: core/models.py:767
#: core/models.py:786
msgid "Create rooms"
msgstr "Crear reunión"
#: core/models.py:768
#: core/models.py:787
msgid "List rooms"
msgstr "Listar reuniones"
#: core/models.py:769
#: core/models.py:788
msgid "Retrieve room details"
msgstr "Ver los detalles de una reunión"
#: core/models.py:770
#: core/models.py:789
msgid "Update rooms"
msgstr "Actualizar las reuniones"
#: core/models.py:771
#: core/models.py:790
msgid "Delete rooms"
msgstr "Eliminar las reuniones"
#: core/models.py:784
#: core/models.py:803
msgid "Application name"
msgstr "Nombre de la aplicación"
#: core/models.py:785
#: core/models.py:804
msgid "Descriptive name for this application."
msgstr "Nombre descriptivo de esta aplicación."
#: core/models.py:795
#: core/models.py:814
msgid "Hashed on Save. Copy it now if this is a new secret."
msgstr "Se cifra al guardar. Cópialo ahora si se trata de un secreto nuevo."
#: core/models.py:806
#: core/models.py:825
msgid "Application"
msgstr "Aplicación"
#: core/models.py:807
#: core/models.py:826
msgid "Applications"
msgstr "Aplicaciones"
#: core/models.py:830
#: core/models.py:849
msgid "Enter a valid domain"
msgstr "Introduce un dominio válido"
#: core/models.py:833
#: core/models.py:852
msgid "Domain"
msgstr "Dominio"
#: core/models.py:834
#: core/models.py:853
msgid "Email domain this application can act on behalf of."
msgstr "Dominio de correo electrónico en cuyo nombre puede actuar esta aplicación."
msgstr ""
"Dominio de correo electrónico en cuyo nombre puede actuar esta aplicación."
#: core/models.py:846
#: core/models.py:865
msgid "Application domain"
msgstr "Dominio de aplicación"
#: core/models.py:847
#: core/models.py:866
msgid "Application domains"
msgstr "Dominios de aplicación"
#: core/models.py:865
#: core/models.py:884
msgid "Pending"
msgstr "Pendiente"
#: core/models.py:866
#: core/models.py:885
msgid "Analyzing"
msgstr "Analizando"
#: core/models.py:873
#: core/models.py:892
msgid "Ready"
msgstr "Listo"
#: core/models.py:879
#: core/models.py:898
msgid "Background image"
msgstr "Imagen de fondo"
#: core/models.py:891
#: core/models.py:910
msgid "title"
msgstr "título"
#: core/models.py:915
#: core/models.py:934
msgid "Malware detection info when the analysis status is unsafe."
msgstr "Información sobre la detección de malware cuando el estado del análisis no es seguro."
msgstr ""
"Información sobre la detección de malware cuando el estado del análisis no "
"es seguro."
#: core/models.py:920
#: core/models.py:939
msgid "File"
msgstr "Archivo"
#: core/models.py:921
#: core/models.py:940
msgid "Files"
msgstr "Archivos"
#: core/models.py:1041
#: core/models.py:1060
msgid "This file is already hard deleted."
msgstr "Este archivo ya se ha eliminado definitivamente."
#: core/models.py:1051
#: core/models.py:1070
msgid "To hard delete a file, it must first be soft deleted."
msgstr "Para eliminar definitivamente un archivo, primero debe haberse marcado como eliminado."
msgstr ""
"Para eliminar definitivamente un archivo, primero debe haberse marcado como "
"eliminado."
#: core/recording/event/notification.py:123
#: core/recording/event/notification.py:124
msgid "Your recording is ready"
msgstr "Tu grabación está lista"
#: core/recording/event/notification.py:194
#: core/recording/event/notification.py:195
msgid "Transcription"
msgstr "Transcripción"
#: core/recording/event/notification.py:204
#: core/recording/event/notification.py:206
#, python-brace-format
msgid "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}"
msgstr "Reunión \"{room}\" del {room_recording_date} a las {room_recording_time}"
msgstr ""
"Reunión \"{room}\" del {room_recording_date} a las {room_recording_time}"
#: core/services/invitation.py:44
#, python-brace-format
msgid "Video call in progress: {sender.email} is waiting for you to connect"
msgstr "Videollamada en curso: {sender.email} está esperando a que te conectes"
#: core/templates/mail/html/invitation.html:159
#: core/templates/mail/html/screen_recording.html:159
#: core/templates/mail/text/invitation.txt:3
#: core/templates/mail/text/screen_recording.txt:3
#: core/templates/mail/html/invitation.html:151
#: core/templates/mail/html/screen_recording.html:151
#: core/templates/mail/text/invitation.txt:4
#: core/templates/mail/text/screen_recording.txt:4
msgid "Logo email"
msgstr "Logotipo del correo"
#: core/templates/mail/html/invitation.html:189
#: core/templates/mail/text/invitation.txt:5
#: core/templates/mail/html/invitation.html:181
#: core/templates/mail/text/invitation.txt:6
msgid "invites you to join an ongoing video call"
msgstr "te invita a unirte a una videollamada en curso"
#: core/templates/mail/html/invitation.html:200
#: core/templates/mail/text/invitation.txt:7
#: core/templates/mail/html/invitation.html:192
#: core/templates/mail/text/invitation.txt:8
msgid "JOIN THE CALL"
msgstr "UNIRSE A LA LLAMADA"
#: core/templates/mail/html/invitation.html:227
#: core/templates/mail/text/invitation.txt:13
#: core/templates/mail/html/invitation.html:219
#: core/templates/mail/text/invitation.txt:14
msgid ""
"If you can't click the button, copy and paste the URL into your browser to "
"join the call."
msgstr "Si no puedes hacer clic en el botón, copia y pega la URL en tu navegador para unirte a la llamada."
msgstr ""
"Si no puedes hacer clic en el botón, copia y pega la URL en tu navegador "
"para unirte a la llamada."
#: core/templates/mail/html/invitation.html:235
#: core/templates/mail/text/invitation.txt:15
#: core/templates/mail/html/invitation.html:227
#: core/templates/mail/text/invitation.txt:16
msgid "Tips for a better experience:"
msgstr "Consejos para una mejor experiencia:"
#: core/templates/mail/html/invitation.html:237
#: core/templates/mail/text/invitation.txt:17
#: core/templates/mail/html/invitation.html:229
#: core/templates/mail/text/invitation.txt:18
msgid "Use Chrome or Firefox for better call quality"
msgstr "Usa Chrome o Firefox para una mejor calidad de llamada"
#: core/templates/mail/html/invitation.html:238
#: core/templates/mail/text/invitation.txt:18
#: core/templates/mail/html/invitation.html:230
#: core/templates/mail/text/invitation.txt:19
msgid "Test your microphone and camera before joining"
msgstr "Prueba tu micrófono y tu cámara antes de unirte"
#: core/templates/mail/html/invitation.html:239
#: core/templates/mail/text/invitation.txt:19
#: core/templates/mail/html/invitation.html:231
#: core/templates/mail/text/invitation.txt:20
msgid "Make sure you have a stable internet connection"
msgstr "Asegúrate de tener una conexión a internet estable"
#: core/templates/mail/html/invitation.html:248
#: core/templates/mail/html/screen_recording.html:245
#: core/templates/mail/text/invitation.txt:21
#: core/templates/mail/text/screen_recording.txt:23
#: core/templates/mail/html/invitation.html:240
#: core/templates/mail/html/screen_recording.html:237
#: core/templates/mail/text/invitation.txt:22
#: core/templates/mail/text/screen_recording.txt:24
#, python-format
msgid " Thank you for using %(brandname)s. "
msgstr " Gracias por usar %(brandname)s. "
#: core/templates/mail/html/screen_recording.html:188
#: core/templates/mail/text/screen_recording.txt:6
#: core/templates/mail/html/invitation.html:271
#, python-format
msgid ""
"This mail has been sent to %(email)s by <a href=\"%(href)s\">%(name)s</a>"
msgstr ""
#: core/templates/mail/html/screen_recording.html:180
#: core/templates/mail/text/screen_recording.txt:7
msgid "Your recording is ready!"
msgstr "¡Tu grabación está lista!"
#: core/templates/mail/html/screen_recording.html:195
#: core/templates/mail/text/screen_recording.txt:8
#: core/templates/mail/html/screen_recording.html:187
#: core/templates/mail/text/screen_recording.txt:9
#, python-format
msgid ""
" Your recording of \"%(room_name)s\" on %(recording_date)s at "
"%(recording_time)s is now ready to download. "
msgstr " Tu grabación de \"%(room_name)s\" del %(recording_date)s a las %(recording_time)s ya está lista para descargar. "
msgstr ""
" Tu grabación de \"%(room_name)s\" del %(recording_date)s a las "
"%(recording_time)s ya está lista para descargar. "
#: core/templates/mail/html/screen_recording.html:195
#: core/templates/mail/text/screen_recording.txt:8
#: core/templates/mail/html/screen_recording.html:187
#: core/templates/mail/text/screen_recording.txt:9
#, python-format
msgid " The recording will expire in %(days)s days. "
msgstr " La grabación caducará dentro de %(days)s días. "
#: core/templates/mail/html/screen_recording.html:200
#: core/templates/mail/text/screen_recording.txt:9
#: core/templates/mail/html/screen_recording.html:192
#: core/templates/mail/text/screen_recording.txt:10
msgid ""
" Sharing the recording via link is not yet available. Only organizers can "
"download it. "
msgstr " Compartir la grabación mediante un enlace todavía no está disponible. Solo los organizadores pueden descargarla. "
msgstr ""
" Compartir la grabación mediante un enlace todavía no está disponible. Solo "
"los organizadores pueden descargarla. "
#: core/templates/mail/html/screen_recording.html:206
#: core/templates/mail/text/screen_recording.txt:11
#: core/templates/mail/html/screen_recording.html:198
#: core/templates/mail/text/screen_recording.txt:12
msgid "To keep this recording permanently:"
msgstr "Para conservar esta grabación de forma permanente:"
#: core/templates/mail/html/screen_recording.html:208
#: core/templates/mail/html/screen_recording.html:200
#, python-format
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
msgstr "Haz clic en el enlace \"<a href=\"%(link)s\">Abrir</a>\" que aparece abajo "
msgstr ""
"Haz clic en el enlace \"<a href=\"%(link)s\">Abrir</a>\" que aparece abajo "
#: core/templates/mail/html/screen_recording.html:209
#: core/templates/mail/text/screen_recording.txt:14
#: core/templates/mail/html/screen_recording.html:201
#: core/templates/mail/text/screen_recording.txt:15
msgid "Use the \"Download\" button in the interface "
msgstr "Usa el botón \"Descargar\" de la interfaz "
#: core/templates/mail/html/screen_recording.html:210
#: core/templates/mail/text/screen_recording.txt:15
#: core/templates/mail/html/screen_recording.html:202
#: core/templates/mail/text/screen_recording.txt:16
msgid "Save the file to your preferred location"
msgstr "Guarda el archivo en la ubicación que prefieras"
#: core/templates/mail/html/screen_recording.html:221
#: core/templates/mail/text/screen_recording.txt:17
#: core/templates/mail/html/screen_recording.html:213
#: core/templates/mail/text/screen_recording.txt:18
msgid "Open"
msgstr "Abrir"
#: core/templates/mail/html/screen_recording.html:230
#: core/templates/mail/text/screen_recording.txt:19
#: core/templates/mail/html/screen_recording.html:222
#: core/templates/mail/text/screen_recording.txt:20
#, python-format
msgid ""
" If you have any questions or need assistance, please contact our support "
"team at %(support_email)s. "
msgstr " Si tienes alguna pregunta o necesitas ayuda, ponte en contacto con nuestro equipo de soporte en %(support_email)s. "
msgstr ""
" Si tienes alguna pregunta o necesitas ayuda, ponte en contacto con nuestro "
"equipo de soporte en %(support_email)s. "
#: core/templates/mail/text/screen_recording.txt:13
#: core/templates/mail/text/invitation.txt:24
#, python-format
msgid "This mail has been sent to %(email)s by %(name)s [%(href)s]"
msgstr ""
#: core/templates/mail/text/screen_recording.txt:14
#, python-format
msgid "Click the \"Open [%(link)s]\" link below "
msgstr "Haz clic en el enlace \"Abrir [%(link)s]\" que aparece abajo "
#: meet/settings.py:228
#: meet/settings.py:238
msgid "English"
msgstr "Inglés"
#: meet/settings.py:229
#: meet/settings.py:239
msgid "French"
msgstr "Francés"
#: meet/settings.py:230
#: meet/settings.py:240
msgid "Dutch"
msgstr "Neerlandés"
#: meet/settings.py:231
#: meet/settings.py:241
msgid "German"
msgstr "Alemán"
#: meet/settings.py:233
#: meet/settings.py:242
msgid "Spanish"
msgstr "Español"
+187 -160
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-07-02 10:47+0000\n"
"POT-Creation-Date: 2026-09-02 22:52+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: antoine.lebaud@mail.numerique.gouv.fr\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -51,11 +51,11 @@ msgstr ""
msgid "File preview"
msgstr ""
#: core/admin.py:300 core/admin.py:443
#: core/admin.py:300 core/admin.py:450
msgid "No owner"
msgstr "Pas de propriétaire"
#: core/admin.py:303 core/admin.py:446
#: core/admin.py:303 core/admin.py:453
msgid "Multiple owners"
msgstr "Plusieurs propriétaires"
@@ -99,11 +99,11 @@ msgstr ""
msgid "Skipped %(count)s recording(s) with an ineligible status."
msgstr "%(count)s enregistrement(s) avec un statut inéligible ignoré(s)."
#: core/admin.py:510
#: core/admin.py:517
msgid "No scopes"
msgstr "Aucun scopes"
#: core/admin.py:512
#: core/admin.py:519
msgid "Scopes"
msgstr "Scopes"
@@ -111,187 +111,203 @@ msgstr "Scopes"
msgid "Creator is me"
msgstr "Je suis le créateur"
#: core/api/serializers.py:89
#: core/api/serializers.py:108
msgid "You must be administrator or owner of a room to add accesses to it."
msgstr ""
"Vous devez être administrateur ou propriétaire d'une salle pour y ajouter "
"des accès."
#: core/api/serializers.py:534
#: core/api/serializers.py:560
msgid "This file extension is not allowed."
msgstr "Cette extension n'est pas autorisée"
#: core/api/viewsets.py:1222
#: core/api/viewsets.py:1268
msgid "You have reached the maximum number of files for this type."
msgstr "Vous avez atteint le nombre maximum de fichiers de ce type"
#: core/models.py:37
#: core/models.py:38
msgid "Member"
msgstr "Membre"
#: core/models.py:38
#: core/models.py:39
msgid "Administrator"
msgstr "Administrateur"
#: core/models.py:39
#: core/models.py:40
msgid "Owner"
msgstr "Propriétaire"
#: core/models.py:55
#: core/models.py:56
msgid "Initiated"
msgstr "Initié"
#: core/models.py:56
#: core/models.py:57
msgid "Active"
msgstr "Actif"
#: core/models.py:57
#: core/models.py:58
msgid "Stopped"
msgstr "Arrêté"
#: core/models.py:58
#: core/models.py:59
msgid "Saved"
msgstr "Enregistré"
#: core/models.py:59
#: core/models.py:60
msgid "Aborted"
msgstr "Abandonné"
#: core/models.py:60
#: core/models.py:61
msgid "Failed to Start"
msgstr "Échec au démarrage"
#: core/models.py:61
#: core/models.py:62
msgid "Failed to Stop"
msgstr "Échec à l'arrêt"
#: core/models.py:62
#: core/models.py:63
msgid "Notification succeeded"
msgstr "Notification réussie"
#: core/models.py:65
#: core/models.py:66
msgid "External process successful"
msgstr "Traitement externe : succès"
#: core/models.py:67
#: core/models.py:68
msgid "External process failed"
msgstr "Traitement externe : erreur"
#: core/models.py:96
#: core/models.py:97
msgid "SCREEN_RECORDING"
msgstr "ENREGISTREMENT_ÉCRAN"
#: core/models.py:97
#: core/models.py:98
msgid "TRANSCRIPT"
msgstr "TRANSCRIPTION"
#: core/models.py:103
#: core/models.py:104
msgid "Public Access"
msgstr "Accès public"
#: core/models.py:104
#: core/models.py:105
msgid "Trusted Access"
msgstr "Accès de confiance"
#: core/models.py:105
#: core/models.py:106
msgid "Restricted Access"
msgstr "Accès restreint"
#: core/models.py:117
#: core/models.py:118
msgid "id"
msgstr "id"
#: core/models.py:118
#: core/models.py:119
msgid "primary key for the record as UUID"
msgstr "clé primaire pour l'enregistrement sous forme d'UUID"
#: core/models.py:124
#: core/models.py:125
msgid "created on"
msgstr "créé le"
#: core/models.py:125
#: core/models.py:126
msgid "date and time at which a record was created"
msgstr "date et heure auxquelles un enregistrement a été créé"
#: core/models.py:130
#: core/models.py:131
msgid "updated on"
msgstr "mis à jour le"
#: core/models.py:131
#: core/models.py:132
msgid "date and time at which a record was last updated"
msgstr ""
"date et heure auxquelles un enregistrement a été mis à jour pour la dernière "
"fois"
#: core/models.py:151
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
msgstr ""
"Entrez un sub valide. Cette valeur ne peut contenir que des lettres, des "
"chiffres et les caractères @/./+/-/_."
#: core/models.py:157
#: core/models.py:150
msgid "sub"
msgstr "sub"
#: core/models.py:159
#: core/models.py:152
msgid ""
"Optional for pending users; required upon account activation. 255 characters "
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
"or fewer. Printable ASCII characters only."
msgstr ""
"Optionnel pour les utilisateurs en attente ; requis lors de l'activation du "
"compte. 255 caractères maximum. Lettres, chiffres et @/./+/-/_ uniquement."
"Facultatif pour les utilisateurs en attente ; obligatoire lors de lactivation "
"du compte. 255 caractères maximum. Caractères ASCII imprimables uniquement."
#: core/models.py:168
#: core/validators.py:22
msgid "Enter a valid sub. This value should be printable ASCII only."
msgstr "Saisissez un sub valide. Cette valeur ne doit contenir que des caractères ASCII imprimables."
#: core/models.py:161
msgid "identity email address"
msgstr "adresse e-mail d'identité"
#: core/models.py:173
#: core/models.py:166
msgid "admin email address"
msgstr "adresse e-mail d'administrateur"
#: core/models.py:175
#: core/models.py:168
msgid "full name"
msgstr "nom complet"
#: core/models.py:177
#: core/models.py:170
msgid "short name"
msgstr "nom court"
#: core/models.py:183
#: core/models.py:176
msgid "language"
msgstr "langue"
#: core/models.py:184
#: core/models.py:177
msgid "The language in which the user wants to see the interface."
msgstr "La langue dans laquelle l'utilisateur souhaite voir l'interface."
#: core/models.py:190
#: core/models.py:183
msgid "The timezone in which the user wants to see times."
msgstr "Le fuseau horaire dans lequel l'utilisateur souhaite voir les heures."
#: core/models.py:193
#: core/models.py:190
msgid "default room access level"
msgstr ""
#: core/models.py:192
msgid ""
"Access level applied by default to new rooms created by this user. When "
"empty, the instance default is used."
msgstr ""
#: core/models.py:199
#, fuzzy
#| msgid "Visio room configuration"
msgid "default room configuration"
msgstr "Configuration de la salle de visioconférence"
#: core/models.py:201
msgid "Configurations applied by default to new rooms created by this user."
msgstr ""
#: core/models.py:205
msgid "device"
msgstr "appareil"
#: core/models.py:195
#: core/models.py:207
msgid "Whether the user is a device or a real user."
msgstr "Si l'utilisateur est un appareil ou un utilisateur réel."
#: core/models.py:198
#: core/models.py:210
msgid "staff status"
msgstr "statut du personnel"
#: core/models.py:200
#: core/models.py:212
msgid "Whether the user can log into this admin site."
msgstr "Si l'utilisateur peut se connecter à ce site d'administration."
#: core/models.py:203
#: core/models.py:215
msgid "active"
msgstr "actif"
#: core/models.py:206
#: core/models.py:218
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
@@ -299,65 +315,65 @@ msgstr ""
"Si cet utilisateur doit être traité comme actif. Désélectionnez cette option "
"au lieu de supprimer des comptes."
#: core/models.py:219
#: core/models.py:231
msgid "user"
msgstr "utilisateur"
#: core/models.py:220
#: core/models.py:232
msgid "users"
msgstr "utilisateurs"
#: core/models.py:286
#: core/models.py:298
msgid "Resource"
msgstr "Ressource"
#: core/models.py:287
#: core/models.py:299
msgid "Resources"
msgstr "Ressources"
#: core/models.py:345
#: core/models.py:357
msgid "Resource access"
msgstr "Accès aux ressources"
#: core/models.py:346
#: core/models.py:358
msgid "Resource accesses"
msgstr "Accès aux ressources"
#: core/models.py:352
#: core/models.py:364
msgid "Resource access with this User and Resource already exists."
msgstr ""
"L'accès à la ressource avec cet utilisateur et cette ressource existe déjà."
#: core/models.py:409
#: core/models.py:421
msgid "Visio room configuration"
msgstr "Configuration de la salle de visioconférence"
#: core/models.py:410
#: core/models.py:422
msgid "Values for Visio parameters to configure the room."
msgstr "Valeurs des paramètres de visioconférence pour configurer la salle."
#: core/models.py:417
#: core/models.py:429
msgid "Room PIN code"
msgstr "Code PIN de la salle"
#: core/models.py:418
#: core/models.py:430
msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr ""
"Code unique à n chiffres qui identifie cette salle en mode téléphonique."
#: core/models.py:424 core/models.py:578
#: core/models.py:436 core/models.py:597
msgid "Room"
msgstr "Salle"
#: core/models.py:425
#: core/models.py:437
msgid "Rooms"
msgstr "Salles"
#: core/models.py:589
#: core/models.py:608
msgid "Worker ID"
msgstr "ID du Worker"
#: core/models.py:591
#: core/models.py:610
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
@@ -365,170 +381,170 @@ msgstr ""
"Entrez un identifiant pour l'enregistrement du Worker. Cet identifiant est "
"conservé même lorsque le Worker s'arrête, permettant un suivi facile."
#: core/models.py:599
#: core/models.py:618
msgid "Recording mode"
msgstr "Mode d'enregistrement"
#: core/models.py:600
#: core/models.py:619
msgid "Defines the mode of recording being called."
msgstr "Définit le mode d'enregistrement appelé."
#: core/models.py:605 core/models.py:606
#: core/models.py:624 core/models.py:625
msgid "Recording options"
msgstr "Options d'enregistrement"
#: core/models.py:613
#: core/models.py:632
msgid "External Process ID"
msgstr "ID Traitement externe"
#: core/models.py:614
#: core/models.py:633
msgid "ID of the external process associated with the recording."
msgstr "ID du traitement externe associé avec l'enregistrement."
#: core/models.py:620
#: core/models.py:639
msgid "Recording"
msgstr "Enregistrement"
#: core/models.py:621
#: core/models.py:640
msgid "Recordings"
msgstr "Enregistrements"
#: core/models.py:731
#: core/models.py:750
msgid "Recording/user relation"
msgstr "Relation enregistrement/utilisateur"
#: core/models.py:732
#: core/models.py:751
msgid "Recording/user relations"
msgstr "Relations enregistrement/utilisateur"
#: core/models.py:738
#: core/models.py:757
msgid "This user is already in this recording."
msgstr "Cet utilisateur est déjà dans cet enregistrement."
#: core/models.py:744
#: core/models.py:763
msgid "This team is already in this recording."
msgstr "Cette équipe est déjà dans cet enregistrement."
#: core/models.py:750
#: core/models.py:769
msgid "Either user or team must be set, not both."
msgstr "Soit l'utilisateur, soit l'équipe doit être défini, pas les deux."
#: core/models.py:767
#: core/models.py:786
msgid "Create rooms"
msgstr "Créer des salles"
#: core/models.py:768
#: core/models.py:787
msgid "List rooms"
msgstr "Lister les salles"
#: core/models.py:769
#: core/models.py:788
msgid "Retrieve room details"
msgstr "Afficher les détails dune salle"
#: core/models.py:770
#: core/models.py:789
msgid "Update rooms"
msgstr "Mettre à jour les salles"
#: core/models.py:771
#: core/models.py:790
msgid "Delete rooms"
msgstr "Supprimer les salles"
#: core/models.py:784
#: core/models.py:803
msgid "Application name"
msgstr "Nom de lapplication"
#: core/models.py:785
#: core/models.py:804
msgid "Descriptive name for this application."
msgstr "Nom descriptif de cette application."
#: core/models.py:795
#: core/models.py:814
msgid "Hashed on Save. Copy it now if this is a new secret."
msgstr ""
"Haché lors de lenregistrement. Copiez-le maintenant sil sagit dun "
"nouveau secret."
#: core/models.py:806
#: core/models.py:825
msgid "Application"
msgstr "Application"
#: core/models.py:807
#: core/models.py:826
msgid "Applications"
msgstr "Applications"
#: core/models.py:830
#: core/models.py:849
msgid "Enter a valid domain"
msgstr "Saisissez un domaine valide"
#: core/models.py:833
#: core/models.py:852
msgid "Domain"
msgstr "Domaine"
#: core/models.py:834
#: core/models.py:853
msgid "Email domain this application can act on behalf of."
msgstr "Domaine de messagerie au nom duquel cette application peut agir."
#: core/models.py:846
#: core/models.py:865
msgid "Application domain"
msgstr "Domaine dapplication"
#: core/models.py:847
#: core/models.py:866
msgid "Application domains"
msgstr "Domaines dapplication"
#: core/models.py:865
#: core/models.py:884
msgid "Pending"
msgstr "En attente"
#: core/models.py:866
#: core/models.py:885
msgid "Analyzing"
msgstr ""
#: core/models.py:873
#: core/models.py:892
msgid "Ready"
msgstr "Prêt"
#: core/models.py:879
#: core/models.py:898
msgid "Background image"
msgstr "Image de fond"
#: core/models.py:891
#: core/models.py:910
msgid "title"
msgstr "Titre"
#: core/models.py:915
#: core/models.py:934
msgid "Malware detection info when the analysis status is unsafe."
msgstr ""
"Information concernant la détection de Malware cand le statut n'est pas sain"
#: core/models.py:920
#: core/models.py:939
msgid "File"
msgstr "Fichier"
#: core/models.py:921
#: core/models.py:940
msgid "Files"
msgstr "Fichiers"
#: core/models.py:1041
#: core/models.py:1060
#, fuzzy
#| msgid "This user is already in this recording."
msgid "This file is already hard deleted."
msgstr "Ce fichier a été supprimé."
#: core/models.py:1051
#: core/models.py:1070
msgid "To hard delete a file, it must first be soft deleted."
msgstr ""
"Pour supprimer définitivement un fichier il doit d'abord avoir été marqué "
"comme supprimé (soft delete)"
#: core/recording/event/notification.py:123
#: core/recording/event/notification.py:124
msgid "Your recording is ready"
msgstr "Votre enregistrement est prêt"
#: core/recording/event/notification.py:194
#: core/recording/event/notification.py:195
msgid "Transcription"
msgstr "Transcription"
#: core/recording/event/notification.py:204
#: core/recording/event/notification.py:206
#, 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}"
@@ -538,25 +554,25 @@ msgstr "Réunion \"{room}\" du {room_recording_date} à {room_recording_time}"
msgid "Video call in progress: {sender.email} is waiting for you to connect"
msgstr "Appel vidéo en cours : {sender.email} attend que vous vous connectiez"
#: core/templates/mail/html/invitation.html:159
#: core/templates/mail/html/screen_recording.html:159
#: core/templates/mail/text/invitation.txt:3
#: core/templates/mail/text/screen_recording.txt:3
#: core/templates/mail/html/invitation.html:151
#: core/templates/mail/html/screen_recording.html:151
#: core/templates/mail/text/invitation.txt:4
#: core/templates/mail/text/screen_recording.txt:4
msgid "Logo email"
msgstr "Logo email"
#: core/templates/mail/html/invitation.html:189
#: core/templates/mail/text/invitation.txt:5
#: core/templates/mail/html/invitation.html:181
#: core/templates/mail/text/invitation.txt:6
msgid "invites you to join an ongoing video call"
msgstr "vous invite à rejoindre un appel vidéo en cours"
#: core/templates/mail/html/invitation.html:200
#: core/templates/mail/text/invitation.txt:7
#: core/templates/mail/html/invitation.html:192
#: core/templates/mail/text/invitation.txt:8
msgid "JOIN THE CALL"
msgstr "REJOINDRE L'APPEL"
#: core/templates/mail/html/invitation.html:227
#: core/templates/mail/text/invitation.txt:13
#: core/templates/mail/html/invitation.html:219
#: core/templates/mail/text/invitation.txt:14
msgid ""
"If you can't click the button, copy and paste the URL into your browser to "
"join the call."
@@ -564,41 +580,47 @@ msgstr ""
"Si vous ne pouvez pas cliquer sur le bouton, copiez et collez l'URL dans "
"votre navigateur pour rejoindre l'appel."
#: core/templates/mail/html/invitation.html:235
#: core/templates/mail/text/invitation.txt:15
#: core/templates/mail/html/invitation.html:227
#: core/templates/mail/text/invitation.txt:16
msgid "Tips for a better experience:"
msgstr "Conseils pour une meilleure expérience :"
#: core/templates/mail/html/invitation.html:237
#: core/templates/mail/text/invitation.txt:17
#: core/templates/mail/html/invitation.html:229
#: core/templates/mail/text/invitation.txt:18
msgid "Use Chrome or Firefox for better call quality"
msgstr "Utilisez Chrome ou Firefox pour une meilleure qualité d'appel"
#: core/templates/mail/html/invitation.html:238
#: core/templates/mail/text/invitation.txt:18
#: core/templates/mail/html/invitation.html:230
#: core/templates/mail/text/invitation.txt:19
msgid "Test your microphone and camera before joining"
msgstr "Testez votre microphone et votre caméra avant de rejoindre"
#: core/templates/mail/html/invitation.html:239
#: core/templates/mail/text/invitation.txt:19
#: core/templates/mail/html/invitation.html:231
#: core/templates/mail/text/invitation.txt:20
msgid "Make sure you have a stable internet connection"
msgstr "Assurez-vous d'avoir une connexion Internet stable"
#: core/templates/mail/html/invitation.html:248
#: core/templates/mail/html/screen_recording.html:245
#: core/templates/mail/text/invitation.txt:21
#: core/templates/mail/text/screen_recording.txt:23
#: core/templates/mail/html/invitation.html:240
#: core/templates/mail/html/screen_recording.html:237
#: core/templates/mail/text/invitation.txt:22
#: core/templates/mail/text/screen_recording.txt:24
#, python-format
msgid " Thank you for using %(brandname)s. "
msgstr " Merci d'utiliser %(brandname)s. "
#: core/templates/mail/html/screen_recording.html:188
#: core/templates/mail/text/screen_recording.txt:6
#: core/templates/mail/html/invitation.html:271
#, python-format
msgid ""
"This mail has been sent to %(email)s by <a href=\"%(href)s\">%(name)s</a>"
msgstr ""
#: core/templates/mail/html/screen_recording.html:180
#: core/templates/mail/text/screen_recording.txt:7
msgid "Your recording is ready!"
msgstr "Votre enregistrement est prêt !"
#: core/templates/mail/html/screen_recording.html:195
#: core/templates/mail/text/screen_recording.txt:8
#: core/templates/mail/html/screen_recording.html:187
#: core/templates/mail/text/screen_recording.txt:9
#, python-format
msgid ""
" Your recording of \"%(room_name)s\" on %(recording_date)s at "
@@ -607,14 +629,14 @@ msgstr ""
" Votre enregistrement de \"%(room_name)s\" du %(recording_date)s à "
"%(recording_time)s est maintenant prêt à être téléchargé. "
#: core/templates/mail/html/screen_recording.html:195
#: core/templates/mail/text/screen_recording.txt:8
#: core/templates/mail/html/screen_recording.html:187
#: core/templates/mail/text/screen_recording.txt:9
#, python-format
msgid " The recording will expire in %(days)s days. "
msgstr " L'enregistrement expirera dans %(days)s jours. "
#: core/templates/mail/html/screen_recording.html:200
#: core/templates/mail/text/screen_recording.txt:9
#: core/templates/mail/html/screen_recording.html:192
#: core/templates/mail/text/screen_recording.txt:10
msgid ""
" Sharing the recording via link is not yet available. Only organizers can "
"download it. "
@@ -622,33 +644,33 @@ msgstr ""
"Le partage de l'enregistrement via lien n'est pas encore disponible. Seuls "
"les organisateurs peuvent le télécharger."
#: core/templates/mail/html/screen_recording.html:206
#: core/templates/mail/text/screen_recording.txt:11
#: core/templates/mail/html/screen_recording.html:198
#: core/templates/mail/text/screen_recording.txt:12
msgid "To keep this recording permanently:"
msgstr "Pour conserver cet enregistrement de façon permanente :"
#: core/templates/mail/html/screen_recording.html:208
#: core/templates/mail/html/screen_recording.html:200
#, python-format
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
msgstr "Cliquez sur le lien \"<a href=\"%(link)s\">Ouvrir</a>\" ci-dessous "
#: core/templates/mail/html/screen_recording.html:209
#: core/templates/mail/text/screen_recording.txt:14
#: core/templates/mail/html/screen_recording.html:201
#: core/templates/mail/text/screen_recording.txt:15
msgid "Use the \"Download\" button in the interface "
msgstr "Utilisez le bouton \"Télécharger\" dans l'interface "
#: core/templates/mail/html/screen_recording.html:210
#: core/templates/mail/text/screen_recording.txt:15
#: core/templates/mail/html/screen_recording.html:202
#: core/templates/mail/text/screen_recording.txt:16
msgid "Save the file to your preferred location"
msgstr "Enregistrez le fichier à l'emplacement de votre choix"
#: core/templates/mail/html/screen_recording.html:221
#: core/templates/mail/text/screen_recording.txt:17
#: core/templates/mail/html/screen_recording.html:213
#: core/templates/mail/text/screen_recording.txt:18
msgid "Open"
msgstr "Ouvrir"
#: core/templates/mail/html/screen_recording.html:230
#: core/templates/mail/text/screen_recording.txt:19
#: core/templates/mail/html/screen_recording.html:222
#: core/templates/mail/text/screen_recording.txt:20
#, python-format
msgid ""
" If you have any questions or need assistance, please contact our support "
@@ -657,28 +679,33 @@ msgstr ""
" Si vous avez des questions ou besoin d'assistance, veuillez contacter notre "
"équipe d'assistance à %(support_email)s. "
#: core/templates/mail/text/screen_recording.txt:13
#: core/templates/mail/text/invitation.txt:24
#, python-format
msgid "This mail has been sent to %(email)s by %(name)s [%(href)s]"
msgstr ""
#: core/templates/mail/text/screen_recording.txt:14
#, 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
#: meet/settings.py:238
msgid "English"
msgstr "Anglais"
#: meet/settings.py:229
#: meet/settings.py:239
msgid "French"
msgstr "Français"
#: meet/settings.py:230
#: meet/settings.py:240
msgid "Dutch"
msgstr "Néerlandais"
#: meet/settings.py:231
#: meet/settings.py:241
msgid "German"
msgstr "Allemand"
#: meet/settings.py:233
#: meet/settings.py:242
msgid "Spanish"
msgstr "Espagnol"
+187 -160
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-07-02 10:47+0000\n"
"POT-Creation-Date: 2026-09-02 22:52+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -51,11 +51,11 @@ msgstr ""
msgid "File preview"
msgstr ""
#: core/admin.py:300 core/admin.py:443
#: core/admin.py:300 core/admin.py:450
msgid "No owner"
msgstr "Geen eigenaar"
#: core/admin.py:303 core/admin.py:446
#: core/admin.py:303 core/admin.py:453
msgid "Multiple owners"
msgstr "Meerdere eigenaren"
@@ -98,11 +98,11 @@ msgstr "%(count)s opname(s) succesvol gemarkeerd als 'Mislukt bij stoppen'."
msgid "Skipped %(count)s recording(s) with an ineligible status."
msgstr "%(count)s opname(s) met een niet-toegestane status overgeslagen."
#: core/admin.py:510
#: core/admin.py:517
msgid "No scopes"
msgstr "Geen scopes"
#: core/admin.py:512
#: core/admin.py:519
msgid "Scopes"
msgstr "Scopes"
@@ -110,184 +110,200 @@ msgstr "Scopes"
msgid "Creator is me"
msgstr "Maker ben ik"
#: core/api/serializers.py:89
#: core/api/serializers.py:108
msgid "You must be administrator or owner of a room to add accesses to it."
msgstr ""
"Je moet beheerder of eigenaar van een ruimte zijn om toegang toe te voegen."
#: core/api/serializers.py:534
#: core/api/serializers.py:560
msgid "This file extension is not allowed."
msgstr "Deze bestandsextensie is niet toegestaan."
#: core/api/viewsets.py:1222
#: core/api/viewsets.py:1268
msgid "You have reached the maximum number of files for this type."
msgstr "Het maximale aantal bestanden voor dit type is bereikt."
#: core/models.py:37
#: core/models.py:38
msgid "Member"
msgstr "Lid"
#: core/models.py:38
#: core/models.py:39
msgid "Administrator"
msgstr "Beheerder"
#: core/models.py:39
#: core/models.py:40
msgid "Owner"
msgstr "Eigenaar"
#: core/models.py:55
#: core/models.py:56
msgid "Initiated"
msgstr "Gestart"
#: core/models.py:56
#: core/models.py:57
msgid "Active"
msgstr "Actief"
#: core/models.py:57
#: core/models.py:58
msgid "Stopped"
msgstr "Gestopt"
#: core/models.py:58
#: core/models.py:59
msgid "Saved"
msgstr "Opgeslagen"
#: core/models.py:59
#: core/models.py:60
msgid "Aborted"
msgstr "Afgebroken"
#: core/models.py:60
#: core/models.py:61
msgid "Failed to Start"
msgstr "Starten mislukt"
#: core/models.py:61
#: core/models.py:62
msgid "Failed to Stop"
msgstr "Stoppen mislukt"
#: core/models.py:62
#: core/models.py:63
msgid "Notification succeeded"
msgstr "Notificatie geslaagd"
#: core/models.py:65
#: core/models.py:66
msgid "External process successful"
msgstr "Externe procedure succesvol"
#: core/models.py:67
#: core/models.py:68
msgid "External process failed"
msgstr "Het externe proces is mislukt."
#: core/models.py:96
#: core/models.py:97
msgid "SCREEN_RECORDING"
msgstr "SCHERM_OPNAME"
#: core/models.py:97
#: core/models.py:98
msgid "TRANSCRIPT"
msgstr "TRANSCRIPT"
#: core/models.py:103
#: core/models.py:104
msgid "Public Access"
msgstr "Openbare toegang"
#: core/models.py:104
#: core/models.py:105
msgid "Trusted Access"
msgstr "Vertrouwde toegang"
#: core/models.py:105
#: core/models.py:106
msgid "Restricted Access"
msgstr "Beperkte toegang"
#: core/models.py:117
#: core/models.py:118
msgid "id"
msgstr "id"
#: core/models.py:118
#: core/models.py:119
msgid "primary key for the record as UUID"
msgstr "primaire sleutel voor het record als UUID"
#: core/models.py:124
#: core/models.py:125
msgid "created on"
msgstr "aangemaakt op"
#: core/models.py:125
#: core/models.py:126
msgid "date and time at which a record was created"
msgstr "datum en tijd waarop een record werd aangemaakt"
#: core/models.py:130
#: core/models.py:131
msgid "updated on"
msgstr "bijgewerkt op"
#: core/models.py:131
#: core/models.py:132
msgid "date and time at which a record was last updated"
msgstr "datum en tijd waarop een record voor het laatst werd bijgewerkt"
#: core/models.py:151
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
msgstr ""
"Voer een geldige sub in. Deze waarde mag alleen letters, cijfers en @/./+/-/"
"_ tekens bevatten."
#: core/models.py:157
#: core/models.py:150
msgid "sub"
msgstr "sub"
#: core/models.py:159
#: core/models.py:152
msgid ""
"Optional for pending users; required upon account activation. 255 characters "
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
"or fewer. Printable ASCII characters only."
msgstr ""
"Optioneel voor gebruikers in afwachting; vereist bij accountactivering. "
"Maximum 255 tekens. Alleen letters, cijfers en @/./+/-/_ toegestaan."
"Optioneel voor gebruikers in afwachting; verplicht bij het activeren van het "
"account. Maximaal 255 tekens. Alleen afdrukbare ASCII-tekens."
#: core/models.py:168
#: core/validators.py:22
msgid "Enter a valid sub. This value should be printable ASCII only."
msgstr "Voer een geldige sub in. Deze waarde mag alleen afdrukbare ASCII-tekens bevatten."
#: core/models.py:161
msgid "identity email address"
msgstr "identiteit e-mailadres"
#: core/models.py:173
#: core/models.py:166
msgid "admin email address"
msgstr "beheerder e-mailadres"
#: core/models.py:175
#: core/models.py:168
msgid "full name"
msgstr "volledige naam"
#: core/models.py:177
#: core/models.py:170
msgid "short name"
msgstr "korte naam"
#: core/models.py:183
#: core/models.py:176
msgid "language"
msgstr "taal"
#: core/models.py:184
#: core/models.py:177
msgid "The language in which the user wants to see the interface."
msgstr "De taal waarin de gebruiker de interface wil zien."
#: core/models.py:190
#: core/models.py:183
msgid "The timezone in which the user wants to see times."
msgstr "De tijdzone waarin de gebruiker tijden wil zien."
#: core/models.py:193
#: core/models.py:190
msgid "default room access level"
msgstr ""
#: core/models.py:192
msgid ""
"Access level applied by default to new rooms created by this user. When "
"empty, the instance default is used."
msgstr ""
#: core/models.py:199
#, fuzzy
#| msgid "Visio room configuration"
msgid "default room configuration"
msgstr "Visio-ruimteconfiguratie"
#: core/models.py:201
msgid "Configurations applied by default to new rooms created by this user."
msgstr ""
#: core/models.py:205
msgid "device"
msgstr "apparaat"
#: core/models.py:195
#: core/models.py:207
msgid "Whether the user is a device or a real user."
msgstr "Of de gebruiker een apparaat is of een echte gebruiker."
#: core/models.py:198
#: core/models.py:210
msgid "staff status"
msgstr "personeelsstatus"
#: core/models.py:200
#: core/models.py:212
msgid "Whether the user can log into this admin site."
msgstr "Of de gebruiker kan inloggen op deze beheersite."
#: core/models.py:203
#: core/models.py:215
msgid "active"
msgstr "actief"
#: core/models.py:206
#: core/models.py:218
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
@@ -295,64 +311,64 @@ msgstr ""
"Of deze gebruiker als actief moet worden behandeld. Deselecteer dit in "
"plaats van accounts te verwijderen."
#: core/models.py:219
#: core/models.py:231
msgid "user"
msgstr "gebruiker"
#: core/models.py:220
#: core/models.py:232
msgid "users"
msgstr "gebruikers"
#: core/models.py:286
#: core/models.py:298
msgid "Resource"
msgstr "Bron"
#: core/models.py:287
#: core/models.py:299
msgid "Resources"
msgstr "Bronnen"
#: core/models.py:345
#: core/models.py:357
msgid "Resource access"
msgstr "Brontoegang"
#: core/models.py:346
#: core/models.py:358
msgid "Resource accesses"
msgstr "Brontoegangsrechten"
#: core/models.py:352
#: core/models.py:364
msgid "Resource access with this User and Resource already exists."
msgstr "Brontoegang met deze gebruiker en bron bestaat al."
#: core/models.py:409
#: core/models.py:421
msgid "Visio room configuration"
msgstr "Visio-ruimteconfiguratie"
#: core/models.py:410
#: core/models.py:422
msgid "Values for Visio parameters to configure the room."
msgstr "Waarden voor Visio-parameters om de ruimte te configureren."
#: core/models.py:417
#: core/models.py:429
msgid "Room PIN code"
msgstr "Pincode van de kamer"
#: core/models.py:418
#: core/models.py:430
msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr ""
"Unieke n-cijferige code die deze kamer identificeert in telefonie-modus."
#: core/models.py:424 core/models.py:578
#: core/models.py:436 core/models.py:597
msgid "Room"
msgstr "Ruimte"
#: core/models.py:425
#: core/models.py:437
msgid "Rooms"
msgstr "Ruimtes"
#: core/models.py:589
#: core/models.py:608
msgid "Worker ID"
msgstr "Worker ID"
#: core/models.py:591
#: core/models.py:610
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
@@ -360,152 +376,152 @@ msgstr ""
"Voer een identificatie in voor de worker-opname. Deze ID blijft behouden, "
"zelfs wanneer de worker stopt, waardoor eenvoudige tracking mogelijk is."
#: core/models.py:599
#: core/models.py:618
msgid "Recording mode"
msgstr "Opnamemodus"
#: core/models.py:600
#: core/models.py:619
msgid "Defines the mode of recording being called."
msgstr "Definieert de modus van opname die wordt aangeroepen."
#: core/models.py:605 core/models.py:606
#: core/models.py:624 core/models.py:625
msgid "Recording options"
msgstr "Opnameopties"
#: core/models.py:613
#: core/models.py:632
msgid "External Process ID"
msgstr "Externe proces-ID"
#: core/models.py:614
#: core/models.py:633
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
#: core/models.py:639
msgid "Recording"
msgstr "Opname"
#: core/models.py:621
#: core/models.py:640
msgid "Recordings"
msgstr "Opnames"
#: core/models.py:731
#: core/models.py:750
msgid "Recording/user relation"
msgstr "Opname/gebruiker-relatie"
#: core/models.py:732
#: core/models.py:751
msgid "Recording/user relations"
msgstr "Opname/gebruiker-relaties"
#: core/models.py:738
#: core/models.py:757
msgid "This user is already in this recording."
msgstr "Deze gebruiker is al in deze opname."
#: core/models.py:744
#: core/models.py:763
msgid "This team is already in this recording."
msgstr "Dit team is al in deze opname."
#: core/models.py:750
#: core/models.py:769
msgid "Either user or team must be set, not both."
msgstr "Ofwel gebruiker of team moet worden ingesteld, niet beide."
#: core/models.py:767
#: core/models.py:786
msgid "Create rooms"
msgstr "Ruimtes aanmaken"
#: core/models.py:768
#: core/models.py:787
msgid "List rooms"
msgstr "Ruimtes weergeven"
#: core/models.py:769
#: core/models.py:788
msgid "Retrieve room details"
msgstr "Details van een ruimte ophalen"
#: core/models.py:770
#: core/models.py:789
msgid "Update rooms"
msgstr "Ruimtes bijwerken"
#: core/models.py:771
#: core/models.py:790
msgid "Delete rooms"
msgstr "Ruimtes verwijderen"
#: core/models.py:784
#: core/models.py:803
msgid "Application name"
msgstr "Naam van de applicatie"
#: core/models.py:785
#: core/models.py:804
msgid "Descriptive name for this application."
msgstr "Beschrijvende naam voor deze applicatie."
#: core/models.py:795
#: core/models.py:814
msgid "Hashed on Save. Copy it now if this is a new secret."
msgstr ""
"Wordt gehasht bij het opslaan. Kopieer het nu als dit een nieuw geheim is."
#: core/models.py:806
#: core/models.py:825
msgid "Application"
msgstr "Applicatie"
#: core/models.py:807
#: core/models.py:826
msgid "Applications"
msgstr "Applicaties"
#: core/models.py:830
#: core/models.py:849
msgid "Enter a valid domain"
msgstr "Voer een geldig domein in"
#: core/models.py:833
#: core/models.py:852
msgid "Domain"
msgstr "Domein"
#: core/models.py:834
#: core/models.py:853
msgid "Email domain this application can act on behalf of."
msgstr "E-maildomein namens welke deze applicatie kan handelen."
#: core/models.py:846
#: core/models.py:865
msgid "Application domain"
msgstr "Applicatiedomein"
#: core/models.py:847
#: core/models.py:866
msgid "Application domains"
msgstr "Applicatiedomeinen"
#: core/models.py:865
#: core/models.py:884
msgid "Pending"
msgstr "In afwachting"
#: core/models.py:866
#: core/models.py:885
msgid "Analyzing"
msgstr ""
#: core/models.py:873
#: core/models.py:892
msgid "Ready"
msgstr "Klaar"
#: core/models.py:879
#: core/models.py:898
msgid "Background image"
msgstr "Achtergrondafbeelding"
#: core/models.py:891
#: core/models.py:910
msgid "title"
msgstr "Titel"
#: core/models.py:915
#: core/models.py:934
msgid "Malware detection info when the analysis status is unsafe."
msgstr "Informatie over malwaredetectie wanneer de analysestatus onveilig is."
#: core/models.py:920
#: core/models.py:939
msgid "File"
msgstr "Bestand"
#: core/models.py:921
#: core/models.py:940
msgid "Files"
msgstr "Bestanden"
#: core/models.py:1041
#: core/models.py:1060
msgid "This file is already hard deleted."
msgstr "Dit bestand is al definitief verwijderd."
#: core/models.py:1051
#: core/models.py:1070
#, 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."
@@ -513,15 +529,15 @@ msgstr ""
"Om een bestand definitief te verwijderen, moet het eerst zacht verwijderd "
"zijn."
#: core/recording/event/notification.py:123
#: core/recording/event/notification.py:124
msgid "Your recording is ready"
msgstr "Je opname is klaar"
#: core/recording/event/notification.py:194
#: core/recording/event/notification.py:195
msgid "Transcription"
msgstr "Transcriptie"
#: core/recording/event/notification.py:204
#: core/recording/event/notification.py:206
#, python-brace-format
msgid "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}"
msgstr ""
@@ -532,25 +548,25 @@ msgstr ""
msgid "Video call in progress: {sender.email} is waiting for you to connect"
msgstr "Video-oproep bezig: {sender.email} wacht op je verbinding"
#: core/templates/mail/html/invitation.html:159
#: core/templates/mail/html/screen_recording.html:159
#: core/templates/mail/text/invitation.txt:3
#: core/templates/mail/text/screen_recording.txt:3
#: core/templates/mail/html/invitation.html:151
#: core/templates/mail/html/screen_recording.html:151
#: core/templates/mail/text/invitation.txt:4
#: core/templates/mail/text/screen_recording.txt:4
msgid "Logo email"
msgstr "Logo e-mail"
#: core/templates/mail/html/invitation.html:189
#: core/templates/mail/text/invitation.txt:5
#: core/templates/mail/html/invitation.html:181
#: core/templates/mail/text/invitation.txt:6
msgid "invites you to join an ongoing video call"
msgstr "nodigt je uit om deel te nemen aan een lopende video-oproep"
#: core/templates/mail/html/invitation.html:200
#: core/templates/mail/text/invitation.txt:7
#: core/templates/mail/html/invitation.html:192
#: core/templates/mail/text/invitation.txt:8
msgid "JOIN THE CALL"
msgstr "NEEM DEEL AAN DE OPROEP"
#: core/templates/mail/html/invitation.html:227
#: core/templates/mail/text/invitation.txt:13
#: core/templates/mail/html/invitation.html:219
#: core/templates/mail/text/invitation.txt:14
msgid ""
"If you can't click the button, copy and paste the URL into your browser to "
"join the call."
@@ -558,41 +574,47 @@ msgstr ""
"Als je niet op de knop kunt klikken, kopieer en plak dan de URL in je "
"browser om deel te nemen aan de oproep."
#: core/templates/mail/html/invitation.html:235
#: core/templates/mail/text/invitation.txt:15
#: core/templates/mail/html/invitation.html:227
#: core/templates/mail/text/invitation.txt:16
msgid "Tips for a better experience:"
msgstr "Tips voor een betere ervaring:"
#: core/templates/mail/html/invitation.html:237
#: core/templates/mail/text/invitation.txt:17
#: core/templates/mail/html/invitation.html:229
#: core/templates/mail/text/invitation.txt:18
msgid "Use Chrome or Firefox for better call quality"
msgstr "Gebruik Chrome of Firefox voor betere gesprekskwaliteit"
#: core/templates/mail/html/invitation.html:238
#: core/templates/mail/text/invitation.txt:18
#: core/templates/mail/html/invitation.html:230
#: core/templates/mail/text/invitation.txt:19
msgid "Test your microphone and camera before joining"
msgstr "Test je microfoon en camera voordat je deelneemt"
#: core/templates/mail/html/invitation.html:239
#: core/templates/mail/text/invitation.txt:19
#: core/templates/mail/html/invitation.html:231
#: core/templates/mail/text/invitation.txt:20
msgid "Make sure you have a stable internet connection"
msgstr "Zorg ervoor dat je een stabiele internetverbinding hebt"
#: core/templates/mail/html/invitation.html:248
#: core/templates/mail/html/screen_recording.html:245
#: core/templates/mail/text/invitation.txt:21
#: core/templates/mail/text/screen_recording.txt:23
#: core/templates/mail/html/invitation.html:240
#: core/templates/mail/html/screen_recording.html:237
#: core/templates/mail/text/invitation.txt:22
#: core/templates/mail/text/screen_recording.txt:24
#, python-format
msgid " Thank you for using %(brandname)s. "
msgstr " Bedankt voor het gebruik van %(brandname)s. "
#: core/templates/mail/html/screen_recording.html:188
#: core/templates/mail/text/screen_recording.txt:6
#: core/templates/mail/html/invitation.html:271
#, python-format
msgid ""
"This mail has been sent to %(email)s by <a href=\"%(href)s\">%(name)s</a>"
msgstr ""
#: core/templates/mail/html/screen_recording.html:180
#: core/templates/mail/text/screen_recording.txt:7
msgid "Your recording is ready!"
msgstr "Je opname is klaar!"
#: core/templates/mail/html/screen_recording.html:195
#: core/templates/mail/text/screen_recording.txt:8
#: core/templates/mail/html/screen_recording.html:187
#: core/templates/mail/text/screen_recording.txt:9
#, python-format
msgid ""
" Your recording of \"%(room_name)s\" on %(recording_date)s at "
@@ -601,14 +623,14 @@ msgstr ""
" Je opname van \"%(room_name)s\" op %(recording_date)s om %(recording_time)s "
"is nu klaar om te downloaden. "
#: core/templates/mail/html/screen_recording.html:195
#: core/templates/mail/text/screen_recording.txt:8
#: core/templates/mail/html/screen_recording.html:187
#: core/templates/mail/text/screen_recording.txt:9
#, python-format
msgid " The recording will expire in %(days)s days. "
msgstr " De opname verloopt over %(days)s dagen. "
#: core/templates/mail/html/screen_recording.html:200
#: core/templates/mail/text/screen_recording.txt:9
#: core/templates/mail/html/screen_recording.html:192
#: core/templates/mail/text/screen_recording.txt:10
msgid ""
" Sharing the recording via link is not yet available. Only organizers can "
"download it. "
@@ -616,33 +638,33 @@ msgstr ""
"Het delen van de opname via een link is nog niet beschikbaar. Alleen "
"organisatoren kunnen deze downloaden."
#: core/templates/mail/html/screen_recording.html:206
#: core/templates/mail/text/screen_recording.txt:11
#: core/templates/mail/html/screen_recording.html:198
#: core/templates/mail/text/screen_recording.txt:12
msgid "To keep this recording permanently:"
msgstr "Om deze opname permanent te bewaren:"
#: core/templates/mail/html/screen_recording.html:208
#: core/templates/mail/html/screen_recording.html:200
#, python-format
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
msgstr "Klik op de \"<a href=\"%(link)s\">Openen</a>\"-link hieronder "
#: core/templates/mail/html/screen_recording.html:209
#: core/templates/mail/text/screen_recording.txt:14
#: core/templates/mail/html/screen_recording.html:201
#: core/templates/mail/text/screen_recording.txt:15
msgid "Use the \"Download\" button in the interface "
msgstr "Gebruik de \"Download\"-knop in de interface "
#: core/templates/mail/html/screen_recording.html:210
#: core/templates/mail/text/screen_recording.txt:15
#: core/templates/mail/html/screen_recording.html:202
#: core/templates/mail/text/screen_recording.txt:16
msgid "Save the file to your preferred location"
msgstr "Sla het bestand op naar je gewenste locatie"
#: core/templates/mail/html/screen_recording.html:221
#: core/templates/mail/text/screen_recording.txt:17
#: core/templates/mail/html/screen_recording.html:213
#: core/templates/mail/text/screen_recording.txt:18
msgid "Open"
msgstr "Openen"
#: core/templates/mail/html/screen_recording.html:230
#: core/templates/mail/text/screen_recording.txt:19
#: core/templates/mail/html/screen_recording.html:222
#: core/templates/mail/text/screen_recording.txt:20
#, python-format
msgid ""
" If you have any questions or need assistance, please contact our support "
@@ -651,28 +673,33 @@ msgstr ""
" Als je vragen hebt of hulp nodig hebt, neem dan contact op met ons support "
"team via %(support_email)s. "
#: core/templates/mail/text/screen_recording.txt:13
#: core/templates/mail/text/invitation.txt:24
#, python-format
msgid "This mail has been sent to %(email)s by %(name)s [%(href)s]"
msgstr ""
#: core/templates/mail/text/screen_recording.txt:14
#, 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
#: meet/settings.py:238
msgid "English"
msgstr "Engels"
#: meet/settings.py:229
#: meet/settings.py:239
msgid "French"
msgstr "Frans"
#: meet/settings.py:230
#: meet/settings.py:240
msgid "Dutch"
msgstr "Nederlands"
#: meet/settings.py:231
#: meet/settings.py:241
msgid "German"
msgstr "Duits"
#: meet/settings.py:233
#: meet/settings.py:242
msgid "Spanish"
msgstr "Spaans"
+29 -173
View File
@@ -23,8 +23,6 @@ import dj_database_url
import sentry_sdk
from configurations import Configuration, values
from lasuite.configuration.values import SecretFileValue
from pydantic import BaseModel, PositiveInt, TypeAdapter
from pydantic import ValidationError as PydanticValidationError
from sentry_sdk.integrations.django import DjangoIntegration
from sentry_sdk.integrations.logging import ignore_logger
@@ -55,28 +53,6 @@ def get_release():
return "NA" # Default: not available
class Resolution(BaseModel):
"""Shape of a RECORDING_ENCODING_AVAILABLE_RESOLUTIONS entry."""
model_config = {"extra": "forbid"}
width: PositiveInt
height: PositiveInt
class Profile(BaseModel):
"""Shape of a RECORDING_ENCODING_AVAILABLE_PROFILES entry."""
model_config = {"extra": "forbid"}
fps: PositiveInt
kbps: dict[str, PositiveInt]
RESOLUTION_MAP_ADAPTER = TypeAdapter(dict[str, Resolution])
PROFILE_MAP_ADAPTER = TypeAdapter(dict[str, Profile])
class Base(Configuration):
"""
This is the base configuration every configuration (aka environment) should inherit from. It
@@ -153,6 +129,15 @@ class Base(Configuration):
MEDIA_BASE_URL = values.Value(
"", environ_name="MEDIA_BASE_URL", environ_prefix=None
)
# Header the reverse proxy uses to pass the original request URL to the
# media-auth subrequest views. nginx-ingress sends X-Original-URL, which is
# the default. Other proxies use different headers -- Traefik's ForwardAuth,
# for instance, sends X-Forwarded-Uri and cannot emit X-Original-URL at all.
MEDIA_AUTH_ORIGINAL_URL_HEADER = values.Value(
default="HTTP_X_ORIGINAL_URL",
environ_name="MEDIA_AUTH_ORIGINAL_URL_HEADER",
environ_prefix=None,
)
SITE_ID = 1
@@ -769,81 +754,35 @@ class Base(Configuration):
# These settings affect screen recordings handled by VideoCompositeEgressService;
# they are silently ignored by AudioCompositeEgressService (audio-only transcript
# recordings), whose request never carries advanced EncodingOptions.
#
# A default encoding is applied to every recording: it is resolved from the default
# profile and resolution below and passed to LiveKit as EncodingOptions (advanced),
# replacing LiveKit's built-in H264_720P_30 preset. Lowering framerate and bitrate
# reduces output file size and CPU load on the egress worker. If either
# RECORDING_ENCODING_DEFAULT_RESOLUTION or RECORDING_ENCODING_DEFAULT_PROFILE is
# unset, no default encoding is built (a startup warning is emitted) and LiveKit's
# built-in preset is used instead.
#
# RECORDING_CUSTOM_ENCODING_ENABLED gates whether the start-recording API lets a
# client override that default per recording (via an `encoding` object selecting a
# resolution/profile). When False, the API rejects per-recording `encoding` and
# every recording uses the default; when True, clients may pick from the
# available resolutions/profiles below.
RECORDING_CUSTOM_ENCODING_ENABLED = values.BooleanValue(
False, environ_name="RECORDING_CUSTOM_ENCODING_ENABLED", environ_prefix=None
# When disabled, LiveKit falls back to its built-in H264_720P_30 preset
# (1280x720, 30 fps, 3000 kbps H.264 MAIN video, 128 kbps AAC audio).
# When enabled, the values below are passed to LiveKit as EncodingOptions
# (advanced) and replace the preset. Lowering framerate and bitrate reduces
# output file size and CPU load on the egress worker.
RECORDING_ENCODING_ENABLED = values.BooleanValue(
False, environ_name="RECORDING_ENCODING_ENABLED", environ_prefix=None
)
# Map resolution string -> {"width", "height"} in pixels.
RECORDING_ENCODING_AVAILABLE_RESOLUTIONS = values.DictValue(
{
"540p": {"width": 960, "height": 540},
"720p": {"width": 1280, "height": 720},
"1080p": {"width": 1920, "height": 1080},
},
environ_name="RECORDING_ENCODING_AVAILABLE_RESOLUTIONS",
RECORDING_ENCODING_WIDTH = values.PositiveIntegerValue(
1280, environ_name="RECORDING_ENCODING_WIDTH", environ_prefix=None
)
RECORDING_ENCODING_HEIGHT = values.PositiveIntegerValue(
720, environ_name="RECORDING_ENCODING_HEIGHT", environ_prefix=None
)
RECORDING_ENCODING_FRAMERATE = values.PositiveIntegerValue(
30, environ_name="RECORDING_ENCODING_FRAMERATE", environ_prefix=None
)
RECORDING_ENCODING_VIDEO_BITRATE_KBPS = values.PositiveIntegerValue(
3000,
environ_name="RECORDING_ENCODING_VIDEO_BITRATE_KBPS",
environ_prefix=None,
)
# Map profile string -> {"fps", "kbps": {resolution: video_bitrate_kbps}}.
# Bitrate scales with resolution so quality stays consistent across sizes.
RECORDING_ENCODING_AVAILABLE_PROFILES = values.DictValue(
{
"talking_heads": {
"fps": 15,
"kbps": {"540p": 400, "720p": 700, "1080p": 1200},
},
"text": {
"fps": 15,
"kbps": {"540p": 600, "720p": 1000, "1080p": 1800},
},
"mixed": {
"fps": 20,
"kbps": {"540p": 900, "720p": 1500, "1080p": 2500},
},
"full": {
"fps": 30,
"kbps": {"540p": 2000, "720p": 3000, "1080p": 4500},
},
},
environ_name="RECORDING_ENCODING_AVAILABLE_PROFILES",
environ_prefix=None,
)
# Defaults used when no profile/resolution is specified per recording.
# Must be keys of the two dicts above (validated at startup).
RECORDING_ENCODING_DEFAULT_PROFILE = values.Value(
"full",
environ_name="RECORDING_ENCODING_DEFAULT_PROFILE",
environ_prefix=None,
)
RECORDING_ENCODING_DEFAULT_RESOLUTION = values.Value(
"720p",
environ_name="RECORDING_ENCODING_DEFAULT_RESOLUTION",
environ_prefix=None,
)
# Settings independent of profile/resolution.
RECORDING_ENCODING_AUDIO_BITRATE_KBPS = values.PositiveIntegerValue(
128,
environ_name="RECORDING_ENCODING_AUDIO_BITRATE_KBPS",
environ_prefix=None,
)
RECORDING_ENCODING_KEY_FRAME_INTERVAL_S = values.FloatValue(
0.0,
4.0,
environ_name="RECORDING_ENCODING_KEY_FRAME_INTERVAL_S",
environ_prefix=None,
)
@@ -851,7 +790,6 @@ class Base(Configuration):
SUMMARY_SERVICE_VERSION = values.PositiveIntegerValue(
1, environ_name="SUMMARY_SERVICE_VERSION", environ_prefix=None
)
SUMMARY_SERVICE_ENDPOINT = values.Value(
None, environ_name="SUMMARY_SERVICE_ENDPOINT", environ_prefix=None
)
@@ -1240,86 +1178,6 @@ class Base(Configuration):
},
}
@classmethod
def _check_recording_encoding_maps(cls):
"""Ensure the per-recording encoding maps are well-formed and consistent.
Each entry of RECORDING_ENCODING_AVAILABLE_RESOLUTIONS must declare a width and
a height, each entry of RECORDING_ENCODING_AVAILABLE_PROFILES an fps and a kbps
map, and every profile must define a bitrate for each declared resolution.
The default profile / resolution feed the default encoding. When either is
missing, no custom default encoding can be built: a warning is emitted and
recordings fall back to LiveKit's built-in preset. When both are set, they
must reference keys that actually exist in the maps above.
"""
resolutions = set(cls.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS)
profiles = set(cls.RECORDING_ENCODING_AVAILABLE_PROFILES)
for name, adapter in (
("RECORDING_ENCODING_AVAILABLE_RESOLUTIONS", RESOLUTION_MAP_ADAPTER),
("RECORDING_ENCODING_AVAILABLE_PROFILES", PROFILE_MAP_ADAPTER),
):
try:
adapter.validate_python(getattr(cls, name))
except PydanticValidationError as exc:
raise ValueError(f"{name} is malformed: {exc}") from exc
for (
profile,
profile_config,
) in cls.RECORDING_ENCODING_AVAILABLE_PROFILES.items(): # pylint: disable=no-member
profile_resolutions = set(profile_config["kbps"])
if profile_resolutions != resolutions:
raise ValueError(
f"Profile '{profile}' in RECORDING_ENCODING_AVAILABLE_PROFILES must "
"define a bitrate for exactly the resolutions in "
"RECORDING_ENCODING_AVAILABLE_RESOLUTIONS, mismatch on: "
f"{resolutions ^ profile_resolutions}"
)
# Check that default resolutions and profiles are actually defined
if (
cls.RECORDING_ENCODING_DEFAULT_RESOLUTION
and cls.RECORDING_ENCODING_DEFAULT_RESOLUTION not in resolutions
):
raise ValueError(
"RECORDING_ENCODING_DEFAULT_RESOLUTION "
f"'{cls.RECORDING_ENCODING_DEFAULT_RESOLUTION}' is not a key of "
f"RECORDING_ENCODING_AVAILABLE_RESOLUTIONS ({sorted(resolutions)})."
)
if (
cls.RECORDING_ENCODING_DEFAULT_PROFILE
and cls.RECORDING_ENCODING_DEFAULT_PROFILE not in profiles
):
raise ValueError(
"RECORDING_ENCODING_DEFAULT_PROFILE "
f"'{cls.RECORDING_ENCODING_DEFAULT_PROFILE}' is not a key of "
f"RECORDING_ENCODING_AVAILABLE_PROFILES ({sorted(profiles)})."
)
missing = [
name
for name, value in (
(
"RECORDING_ENCODING_DEFAULT_RESOLUTION",
cls.RECORDING_ENCODING_DEFAULT_RESOLUTION,
),
(
"RECORDING_ENCODING_DEFAULT_PROFILE",
cls.RECORDING_ENCODING_DEFAULT_PROFILE,
),
)
if not value
]
if missing:
warnings.warn(
f"{' and '.join(missing)} not set; recordings will use LiveKit's "
"built-in encoding preset instead of a custom default encoding.",
UserWarning,
stacklevel=2,
)
@classmethod
def post_setup(cls):
"""Post setup configuration.
@@ -1333,8 +1191,6 @@ class Base(Configuration):
"FILE_UPLOAD_TMP_PATH cannot be the same as FILE_UPLOAD_PATH"
)
cls._check_recording_encoding_maps()
if (
cls.SUMMARY_SERVICE_VERSION == 1
and cls.SUMMARY_SERVICE_ENDPOINT is not None
+1
View File
@@ -53,6 +53,7 @@ RUN apk update && apk upgrade \
musl \
musl-utils \
zlib>=1.3.2-r0 \
libexpat>=2.8.4-r0 \
&& apk del curl
USER nginx
@@ -18,6 +18,7 @@ import {
saveVideoPublishResolution,
saveVideoSubscribeQuality,
userChoicesStore,
VIDEO_RESOLUTIONS,
VideoResolution,
} from '@/stores/userChoices'
import { RowWrapper } from './layout/RowWrapper'
@@ -70,7 +71,7 @@ export const VideoTab = ({ id }: VideoTabProps) => {
isDisabled: true,
}
const handleVideoResolutionChange = async (key: 'h720' | 'h360' | 'h180') => {
const handleVideoResolutionChange = async (key: VideoResolution) => {
saveVideoPublishResolution(key)
const videoTrack = localParticipant.getTrackPublication(
Track.Source.Camera
@@ -124,20 +125,13 @@ export const VideoTab = ({ id }: VideoTabProps) => {
}, [videoDeviceId, videoElement])
const resolutionItems = useMemo(() => {
return [
{
value: 'h720',
label: `${t('resolution.publish.items.high')} (720p)`,
},
{
value: 'h360',
label: `${t('resolution.publish.items.medium')} (360p)`,
},
{
value: 'h180',
label: `${t('resolution.publish.items.low')} (180p)`,
},
]
const labels: Record<VideoResolution, string> = {
h1080: `${t('resolution.publish.items.veryHigh')} (1080p)`,
h720: `${t('resolution.publish.items.high')} (720p)`,
h360: `${t('resolution.publish.items.medium')} (360p)`,
h180: `${t('resolution.publish.items.low')} (180p)`,
}
return VIDEO_RESOLUTIONS.map((value) => ({ value, label: labels[value] }))
}, [t])
const videoQualityItems = useMemo(() => {
@@ -56,6 +56,7 @@
"publish": {
"label": "Wähle die maximale Auflösung beim Senden",
"items": {
"veryHigh": "Sehr hohe Auflösung",
"high": "Hohe Auflösung",
"medium": "Mittlere Auflösung",
"low": "Niedrige Auflösung"
@@ -56,6 +56,7 @@
"publish": {
"label": "Select your sending resolution (max.)",
"items": {
"veryHigh": "Very high definition",
"high": "High definition",
"medium": "Standard definition",
"low": "Low definition"
@@ -56,6 +56,7 @@
"publish": {
"label": "Selecciona tu resolución de envío (máx.)",
"items": {
"veryHigh": "Muy alta definición",
"high": "Alta definición",
"medium": "Definición estándar",
"low": "Baja definición"
@@ -56,6 +56,7 @@
"publish": {
"label": "Sélectionner votre résolution d'envoi (max.)",
"items": {
"veryHigh": "Très haute définition",
"high": "Haute définition",
"medium": "Définition standard",
"low": "Basse définition"
@@ -56,6 +56,7 @@
"publish": {
"label": "Selecteer uw verzendresolutie (max.)",
"items": {
"veryHigh": "Zeer hoge definitie",
"high": "Hoge definitie",
"medium": "Standaarddefinitie",
"low": "Lage definitie"
+11 -2
View File
@@ -10,7 +10,12 @@ import {
} from '@livekit/components-core'
import { VideoQuality } from 'livekit-client'
export type VideoResolution = 'h720' | 'h360' | 'h180'
export const VIDEO_RESOLUTIONS = ['h1080', 'h720', 'h360', 'h180'] as const
export type VideoResolution = (typeof VIDEO_RESOLUTIONS)[number]
const isVideoResolution = (value: unknown): value is VideoResolution =>
VIDEO_RESOLUTIONS.includes(value as VideoResolution)
export type LocalUserChoices = Omit<LocalUserChoicesLK, 'username'> & {
processorConfig?: ProcessorConfig
@@ -21,13 +26,17 @@ export type LocalUserChoices = Omit<LocalUserChoicesLK, 'username'> & {
}
function getUserChoicesState(): LocalUserChoices {
return {
const stored: LocalUserChoices = {
noiseReductionEnabled: false,
audioOutputDeviceId: 'default', // Use 'default' to match LiveKit's standard device selection behavior
videoPublishResolution: 'h720',
videoSubscribeQuality: VideoQuality.HIGH,
...loadUserChoices(),
}
if (!isVideoResolution(stored.videoPublishResolution)) {
stored.videoPublishResolution = 'h720'
}
return stored
}
export const userChoicesStore = proxy<LocalUserChoices>(getUserChoicesState())