Compare commits

..

13 Commits

Author SHA1 Message Date
lebaudantoine 98d0b48015 fixup! wip 2026-09-02 23:47:39 +02:00
lebaudantoine 7412c02341 fixup! wip enhance analytics 2026-09-02 23:47:09 +02:00
lebaudantoine f2b3af6633 fixup! wip 2026-09-02 23:12:35 +02:00
lebaudantoine 2aafd93b46 fixup! wip 2026-09-02 22:46:15 +02:00
lebaudantoine 87931f50cd fixup! wip try to enhance error handling 2026-09-02 22:30:16 +02:00
lebaudantoine 35cb114160 wip try to enhance error handling 2026-09-02 20:48:16 +02:00
lebaudantoine c783fefa57 fixup! wip 2026-09-02 20:36:33 +02:00
lebaudantoine 09a0238936 wip propose back a no effect options to help user deactivate their effect 2026-09-02 20:09:52 +02:00
lebaudantoine 03870ad1fa wip enhance analytics 2026-09-02 19:54:44 +02:00
lebaudantoine f37992a089 fixup! wip 2026-09-02 19:08:57 +02:00
lebaudantoine c7c6d20b91 wip silence
info log which are purely informational and log with std err
and failure that mediapipe also raises as real js exceptions
2026-09-02 19:05:12 +02:00
lebaudantoine 2a71e5b836 wip 2026-09-02 18:53:33 +02:00
lebaudantoine 089d016d12 🐛(frontend) cache supportsBackgroundProcessors WebGL2 probe result
`supportsBackgroundProcessors()` creates a live WebGL2 context on
every call and never releases it. The method is called from render
paths (e.g. the Effects button on the join screen), so without
caching, each re-render leaks a context until the browser hits its
live-context limit.

This is one of the ways MediaPipe later fails with:

  "emscripten_webgl_create_context() returned error 0"

Support cannot change within a session, so probe once and cache the
result.
2026-09-02 18:26:12 +02:00
35 changed files with 1367 additions and 2276 deletions
-7
View File
@@ -12,17 +12,10 @@ and this project adheres to
- ✨(frontend) add 1080p sending resolution option #1660
- ✨(backend) add Traefik support via configurable media-auth url header #1649
- ✨(backend) update a room's attributes from the external API
### Changed
- ⬆️(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
- 🐛(frontend) restore automatic lower-hand on speaking
## [1.30.0] - 2026-09-01
+1 -1
View File
@@ -207,7 +207,7 @@ services:
- kc_postgresql
livekit:
image: livekit/livekit-server:v1.13.6
image: livekit/livekit-server
entrypoint: /livekit-server --dev --bind 0.0.0.0 --config ./config.yaml
ports:
- "7880:7880"
+1 -1
View File
@@ -1,4 +1,4 @@
FROM livekit/livekit-server:v1.13.6
FROM livekit/livekit-server:v1.9.4
# We inject the nip.io certificate manually because the livekit chart doesn't support volume mounting
COPY rootCA.pem /etc/ssl/certs/
+3 -78
View File
@@ -16,11 +16,11 @@ info:
* `rooms:list` List rooms accessible to the delegated user.
* `rooms:retrieve` Retrieve details of a specific room.
* `rooms:create` Create new rooms.
* `rooms:update` Update the access level and configuration of existing rooms.
* `rooms:update` **Coming soon** Update existing rooms, e.g., add attendees to a room.
* `rooms:delete` **Coming soon** Delete rooms generated by the application.
#### Upcoming Features
* **Add attendees to a room:** You will be able to update a room to include a list of attendees, allowing them to bypass the lobby system automatically.
* **Delete application-generated rooms:** Rooms created via the application can be deleted when no longer needed.
@@ -310,67 +310,6 @@ paths:
'404':
$ref: '#/components/responses/RoomNotFoundError'
patch:
tags:
- Rooms
summary: Update a room
description: |
Partially updates a room. Only the delegated user's rooms where they are
administrator or owner can be updated; any other role gets a `403`.
**Updatable fields:** `access_level` and `configuration`. Every other field
(`id`, `name`, `slug`, `pin_code`) is read-only and silently ignored when sent.
`configuration` is replaced as a whole, it is not merged with the stored one.
Send the complete object you want the room to end up with.
Full replacement (`PUT`) is not supported. Use `PATCH` instead.
operationId: updateRoom
security:
- BearerAuth: [rooms:update]
parameters:
- name: id
in: path
required: true
description: Room UUID
schema:
type: string
format: uuid
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/RoomUpdate'
examples:
accessLevelOnly:
summary: Change the access level
value:
access_level: "restricted"
configurationOnly:
summary: Replace the room configuration
value:
configuration:
everyone_can_mute: true
responses:
'200':
description: Room updated successfully
content:
application/json:
schema:
$ref: '#/components/schemas/Room'
'400':
$ref: '#/components/responses/BadRequestError'
'401':
$ref: '#/components/responses/UnauthorizedError'
'403':
$ref: '#/components/responses/ForbiddenError'
'404':
$ref: '#/components/responses/RoomNotFoundError'
'405':
description: |
Method not allowed, `PUT` is not supported on this endpoint.
components:
securitySchemes:
BearerAuth:
@@ -447,17 +386,6 @@ components:
configuration:
$ref: '#/components/schemas/RoomConfiguration'
RoomUpdate:
type: object
description: |
Fields that can be updated on an existing room. Both are optional, omitted
fields keep their current value.
properties:
access_level:
$ref: '#/components/schemas/RoomAccessLevel'
configuration:
$ref: '#/components/schemas/RoomConfiguration'
RoomConfiguration:
type: object
description: |
@@ -499,9 +427,6 @@ components:
- `public`: Anyone with the room link can join directly, no authentication required.
- `trusted`: Authenticated users join directly. Unauthenticated users wait in the lobby for approval.
- `restricted`: Only participants explicitly trusted by the owner bypass the lobby. Everyone else waits for approval regardless of authentication.
`public` is rejected with a `400` unless the deployment explicitly enables it
for this API. This applies both when creating a room and when updating one.
example: "trusted"
Room:
+1 -76
View File
@@ -20,7 +20,7 @@ info:
* `lasuite_visio:rooms:list` List rooms accessible to the delegated user.
* `lasuite_visio:rooms:retrieve` Retrieve details of a specific room.
* `lasuite_visio:rooms:create` Create new rooms.
* `lasuite_visio:rooms:update` Update the access level and configuration of existing rooms.
* `lasuite_visio:rooms:update` **Coming soon** Update existing rooms, e.g., add attendees to a room.
* `lasuite_visio:rooms:delete` **Coming soon** Delete rooms generated by the application.
#### Upcoming Features
@@ -206,67 +206,6 @@ paths:
'404':
$ref: '#/components/responses/RoomNotFoundError'
patch:
tags:
- Rooms
summary: Update a room
description: |
Partially updates a room. Only rooms where the user is administrator or
owner can be updated; any other role gets a `403`.
**Updatable fields:** `access_level` and `configuration`. Every other field
(`id`, `name`, `slug`, `pin_code`) is read-only and silently ignored when sent.
`configuration` is replaced as a whole, it is not merged with the stored one.
Send the complete object you want the room to end up with.
Full replacement (`PUT`) is not supported. Use `PATCH` instead.
operationId: updateRoom
security:
- BearerAuth: [rooms:update]
parameters:
- name: id
in: path
required: true
description: Room UUID
schema:
type: string
format: uuid
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/RoomUpdate'
examples:
accessLevelOnly:
summary: Change the access level
value:
access_level: "restricted"
configurationOnly:
summary: Replace the room configuration
value:
configuration:
everyone_can_mute: true
responses:
'200':
description: Room updated successfully
content:
application/json:
schema:
$ref: '#/components/schemas/Room'
'400':
$ref: '#/components/responses/BadRequestError'
'401':
$ref: '#/components/responses/UnauthorizedError'
'403':
$ref: '#/components/responses/ForbiddenError'
'404':
$ref: '#/components/responses/RoomNotFoundError'
'405':
description: |
Method not allowed, `PUT` is not supported on this endpoint.
components:
securitySchemes:
BearerAuth:
@@ -288,17 +227,6 @@ components:
configuration:
$ref: '#/components/schemas/RoomConfiguration'
RoomUpdate:
type: object
description: |
Fields that can be updated on an existing room. Both are optional, omitted
fields keep their current value.
properties:
access_level:
$ref: '#/components/schemas/RoomAccessLevel'
configuration:
$ref: '#/components/schemas/RoomConfiguration'
RoomConfiguration:
type: object
description: |
@@ -340,9 +268,6 @@ components:
- `public`: Anyone with the room link can join directly, no authentication required.
- `trusted`: Authenticated users join directly. Unauthenticated users wait in the lobby for approval.
- `restricted`: Only participants explicitly trusted by the owner bypass the lobby. Everyone else waits for approval regardless of authentication.
`public` is rejected with a `400` unless the deployment explicitly enables it
for this API. This applies both when creating a room and when updating one.
example: "trusted"
Room:
-1
View File
@@ -8,7 +8,6 @@ class AnalyticsEvent(StrEnum):
# Rooms
ROOM_CREATED = "room_created"
ROOM_UPDATED = "room_updated"
# Roomkit (meeting-room SIP devices)
ROOMKIT_JOINED = "roomkit_joined"
+25 -2
View File
@@ -75,7 +75,11 @@ from core.services.participants_management import (
ParticipantsManagementException,
)
from core.services.room_creation import RoomCreation
from core.services.room_management import RoomManagement
from core.services.room_management import (
RoomManagement,
RoomManagementException,
RoomNotFoundException,
)
from core.services.room_roles import (
RoomRoleError,
RoomRoleService,
@@ -352,7 +356,26 @@ class RoomViewSet(
):
return
RoomManagement.sync_room_metadata(room)
metadata = {
"configuration": room.configuration,
"access_level": room.access_level,
}
try:
RoomManagement().update_metadata(
room_name=str(room.id),
metadata=metadata,
)
except RoomNotFoundException:
logger.info(
"LiveKit room %s does not exist yet, skipping metadata sync",
room.id,
)
except RoomManagementException:
logger.warning(
"Failed to sync metadata to LiveKit for room %s",
room.id,
)
@decorators.action(
detail=True,
+1 -19
View File
@@ -3,11 +3,7 @@
import contextlib
from django.conf import settings
from django.core.exceptions import (
ImproperlyConfigured,
SuspiciousOperation,
ValidationError,
)
from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation
from django.utils.translation import gettext_lazy as _
from lasuite.oidc_login.backends import (
@@ -21,7 +17,6 @@ from core.services.marketing import (
ContactData,
get_marketing_service,
)
from core.validators import sub_validator
class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
@@ -89,19 +84,6 @@ 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:
+21 -64
View File
@@ -1,6 +1,5 @@
"""External API endpoints"""
import copy
from logging import getLogger
from django.conf import settings
@@ -26,7 +25,6 @@ from rest_framework import (
from core import analytics, api, models
from core.api.feature_flag import FeatureFlag
from core.services.jwt_token import JwtTokenService
from core.services.room_management import RoomManagement
from ..services.provisional_user_service import (
ProvisionalUserCreationDisabledError,
@@ -144,7 +142,6 @@ class RoomViewSet(
mixins.CreateModelMixin,
mixins.RetrieveModelMixin,
mixins.ListModelMixin,
mixins.UpdateModelMixin,
viewsets.GenericViewSet,
):
"""Application-delegated API for room management.
@@ -157,12 +154,8 @@ class RoomViewSet(
- list: List rooms the user has access to (requires 'rooms:list' scope)
- retrieve: Get room details (requires 'rooms:retrieve' scope)
- create: Create a new room owned by the user (requires 'rooms:create' scope)
- partial_update: Update a room's access level and configuration, for
administrators and owners only (requires 'rooms:update' scope)
"""
http_method_names = ["get", "post", "patch", "head", "options"]
authentication_classes = [
authentication.ApplicationJWTAuthentication,
authentication.AddonsJWTAuthentication,
@@ -196,39 +189,7 @@ class RoomViewSet(
serializer = self.get_serializer(queryset, many=True)
return drf_response.Response(serializer.data)
def _track_room_event(self, room, event, **extra_properties):
"""Log a room operation for auditing and forward it to analytics."""
auth_method = type(self.request.successful_authenticator).__name__
client_id = (self.request.auth or {}).get("client_id", "unknown")
# Log for auditing
details = "".join(f", {key}={value}" for key, value in extra_properties.items())
logger.info(
"Room %s via application: room_id=%s, user_id=%s, client_id=%s, auth_method=%s%s",
event.removeprefix("room_"),
room.id,
self.request.user.id,
client_id,
auth_method,
details,
)
analytics.capture(
self.request.user,
event,
{
"room_id": str(room.pk),
"access_level": room.access_level,
"client_id": client_id,
"external_api": True,
"auth_method": auth_method,
**extra_properties,
"$set": {"email": self.request.user.email},
},
)
def perform_create(self, serializer: serializers.RoomSerializer):
def perform_create(self, serializer):
"""Set the current user as owner of the newly created room."""
room = serializer.save()
models.ResourceAccess.objects.create(
@@ -237,31 +198,27 @@ class RoomViewSet(
role=models.RoleChoices.OWNER,
)
self._track_room_event(room, analytics.AnalyticsEvent.ROOM_CREATED)
auth_method = type(self.request.successful_authenticator).__name__
client_id = (self.request.auth or {}).get("client_id", "unknown")
def perform_update(self, serializer: serializers.RoomSerializer):
"""Persist the room update, sync it to LiveKit, then log and track it."""
previous_values = {
"access_level": serializer.instance.access_level,
"configuration": copy.deepcopy(serializer.instance.configuration),
}
room = serializer.save()
# Report the fields that actually changed, not the ones that were submitted.
updated_fields = sorted(
field
for field, previous_value in previous_values.items()
if getattr(room, field) != previous_value
# Log for auditing
logger.info(
"Room created via application: room_id=%s, user_id=%s, client_id=%s, auth_method=%s",
room.id,
self.request.user.id,
client_id,
auth_method,
)
if updated_fields:
RoomManagement.sync_room_metadata(room)
self._track_room_event(
room,
analytics.AnalyticsEvent.ROOM_UPDATED,
updated_fields=updated_fields,
previous_access_level=previous_values["access_level"],
analytics.capture(
self.request.user,
analytics.AnalyticsEvent.ROOM_CREATED,
{
"room_id": str(room.pk),
"access_level": room.access_level,
"client_id": client_id,
"external_api": True,
"auth_method": auth_method,
"$set": {"email": self.request.user.email},
},
)
+4
View File
@@ -48,6 +48,8 @@ class ResourceFactory(factory.django.DjangoModelFactory):
else:
UserResourceAccessFactory(resource=self, user=item[0], role=item[1])
self.save()
class UserResourceAccessFactory(factory.django.DjangoModelFactory):
"""Create fake resource user accesses for testing."""
@@ -95,6 +97,8 @@ class RecordingFactory(factory.django.DjangoModelFactory):
recording=self, user=item[0], role=item[1]
)
self.save()
class UserRecordingAccessFactory(factory.django.DjangoModelFactory):
"""Create fake recording user accesses for testing."""
+1 -3
View File
@@ -8,8 +8,6 @@ import uuid
from django.conf import settings
from django.db import migrations, models
import core.validators
class Migration(migrations.Migration):
@@ -43,7 +41,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. Printable ASCII characters only.', max_length=255, null=True, unique=True, validators=[core.validators.sub_validator], verbose_name='sub')),
('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')),
('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')),
+9 -2
View File
@@ -27,7 +27,6 @@ from timezone_field import TimeZoneField
from . import fields, utils
from .recording.enums import FileExtension
from .validators import sub_validator
logger = getLogger(__name__)
@@ -146,11 +145,19 @@ 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. Printable ASCII characters only."
"255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
),
max_length=255,
unique=True,
@@ -44,7 +44,7 @@ class RecordingEventsService:
recording_status = status_mapping.get(egress_status)
if recording_status:
try:
RoomManagement.update_metadata(
RoomManagement().update_metadata(
room_name, {"recording_status": recording_status}
)
except RoomNotFoundException:
@@ -68,7 +68,7 @@ class WorkerServiceMediator:
mode = recording.options.get("original_mode", None) or recording.mode
try:
RoomManagement.update_metadata(
RoomManagement().update_metadata(
room_name, {"recording_mode": mode, "recording_status": "starting"}
)
except RoomNotFoundException:
+1 -1
View File
@@ -192,7 +192,7 @@ class LiveKitEventsService:
try:
room_name = str(recording.room.id)
RoomManagement.update_metadata(
RoomManagement().update_metadata(
room_name, remove_keys=["recording_mode", "recording_status"]
)
except RoomNotFoundException:
+2 -33
View File
@@ -30,10 +30,9 @@ class RoomNotFoundException(RoomManagementException):
class RoomManagement:
"""Service for managing LiveKit rooms."""
@classmethod
@async_to_sync
async def update_metadata(
cls,
self,
room_name: str,
metadata: Optional[Dict] = None,
remove_keys: Optional[list[str]] = None,
@@ -91,9 +90,8 @@ class RoomManagement:
finally:
await lkapi.aclose()
@classmethod
@async_to_sync
async def delete_room(cls, room_name: str):
async def delete_room(self, room_name: str):
"""Delete a LiveKit room and disconnect all participants.
Raises:
@@ -118,32 +116,3 @@ class RoomManagement:
raise RoomManagementException("Could not delete room") from e
finally:
await lkapi.aclose()
@classmethod
def sync_room_metadata(cls, room):
"""Push a room's configuration and access level to its LiveKit room metadata.
Failures are swallowed: a room that is not live yet, or a LiveKit hiccup,
should never fail the request that triggered the update.
"""
metadata = {
"configuration": room.configuration,
"access_level": room.access_level,
}
try:
cls.update_metadata(
room_name=str(room.id),
metadata=metadata,
)
except RoomNotFoundException:
logger.info(
"LiveKit room %s does not exist yet, skipping metadata sync",
room.id,
)
except RoomManagementException:
logger.warning(
"Failed to sync metadata to LiveKit for room %s",
room.id,
)
+1 -1
View File
@@ -31,7 +31,7 @@ def delete_connection_test_room(room_name: str):
return
try:
RoomManagement.delete_room(room_name)
RoomManagement().delete_room(room_name)
except RoomNotFoundException:
# Room may already be gone after empty/departure timeout.
logger.info("Connection test room '%s' already gone.", room_name)
@@ -40,111 +40,6 @@ 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.
@@ -381,11 +381,38 @@ def test_api_rooms_update_administrators_of_another():
assert other_room.slug == "old-name"
@pytest.mark.parametrize("exception", [RoomNotFoundException, RoomManagementException])
@patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_livekit_sync_failure(mock_update_metadata, exception):
"""Should not fail the API request when the LiveKit metadata sync fails."""
mock_update_metadata.side_effect = exception
@patch.object(RoomManagement, "update_metadata", side_effect=RoomNotFoundException)
def test_api_rooms_update_livekit_room_not_found(mock_update_metadata):
"""Should not fail the API request when the LiveKit room does not exist yet."""
user = UserFactory()
room = RoomFactory(
users=[(user, random.choice(["administrator", "owner"]))],
configuration={},
)
client = APIClient()
client.force_login(user)
response = client.patch(
f"/api/v1.0/rooms/{room.id!s}/",
{"configuration": {"can_publish_sources": ["camera"]}},
format="json",
)
assert response.status_code == 200
room.refresh_from_db()
assert room.configuration == {"can_publish_sources": ["camera"]}
mock_update_metadata.assert_called_once_with(
room_name=str(room.id),
metadata={
"access_level": room.access_level,
"configuration": {"can_publish_sources": ["camera"]},
},
)
@patch.object(RoomManagement, "update_metadata", side_effect=RoomManagementException)
def test_api_rooms_update_livekit_sync_failure(mock_update_metadata):
"""Should not fail the API request when the LiveKit metadata sync fails."""
user = UserFactory()
room = RoomFactory(
users=[(user, random.choice(["administrator", "owner"]))],
@@ -5,8 +5,6 @@ from unittest import mock
import pytest
from livekit.api import TwirpError
from core.factories import RoomFactory
from core.models import RoomAccessLevel
from core.services.room_management import (
RoomManagement,
RoomManagementException,
@@ -22,7 +20,7 @@ def test_delete_room_calls_livekit(mock_create_livekit_client):
mock_api.aclose = mock.AsyncMock()
mock_create_livekit_client.return_value = mock_api
RoomManagement.delete_room("room-abc")
RoomManagement().delete_room("room-abc")
mock_api.room.delete_room.assert_awaited_once()
request = mock_api.room.delete_room.await_args.args[0]
@@ -41,7 +39,7 @@ def test_delete_room_raises_not_found(mock_create_livekit_client):
mock_create_livekit_client.return_value = mock_api
with pytest.raises(RoomNotFoundException):
RoomManagement.delete_room("missing-room")
RoomManagement().delete_room("missing-room")
mock_api.aclose.assert_awaited_once()
@@ -57,25 +55,6 @@ def test_delete_room_raises_management_exception(mock_create_livekit_client):
mock_create_livekit_client.return_value = mock_api
with pytest.raises(RoomManagementException):
RoomManagement.delete_room("room-abc")
RoomManagement().delete_room("room-abc")
mock_api.aclose.assert_awaited_once()
@mock.patch.object(RoomManagement, "update_metadata")
def test_sync_room_metadata_pushes_configuration_and_access_level(mock_update_metadata):
"""The room's configuration and access level are forwarded to LiveKit."""
room = RoomFactory.build(
access_level=RoomAccessLevel.RESTRICTED,
configuration={"everyone_can_mute": True},
)
RoomManagement.sync_room_metadata(room)
mock_update_metadata.assert_called_once_with(
room_name=str(room.id),
metadata={
"configuration": {"everyone_can_mute": True},
"access_level": RoomAccessLevel.RESTRICTED,
},
)
@@ -16,17 +16,8 @@ import responses
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
from rest_framework.test import APIClient
from core.analytics import AnalyticsEvent
from core.factories import ApplicationFactory, RoomFactory, UserFactory
from core.models import (
Application,
ApplicationScope,
RoleChoices,
Room,
RoomAccessLevel,
User,
)
from core.services.room_management import RoomManagement
from core.models import ApplicationScope, RoleChoices, Room, RoomAccessLevel, User
pytestmark = pytest.mark.django_db
@@ -889,509 +880,6 @@ def test_api_rooms_create_public_access_level_when_default_is_public(settings):
assert response.data["access_level"] == RoomAccessLevel.PUBLIC
@mock.patch("core.external_api.viewsets.analytics.capture")
def test_api_rooms_create_tracks_analytics(mock_capture):
"""Creating a room should emit a ROOM_CREATED analytics event."""
user = UserFactory()
token = generate_test_token(user, [ApplicationScope.ROOMS_CREATE])
application = Application.objects.get()
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.post(
"/external-api/v1.0/rooms/",
{"access_level": RoomAccessLevel.RESTRICTED},
format="json",
)
assert response.status_code == 201
mock_capture.assert_called_once()
captured_user, event, properties = mock_capture.call_args[0]
assert captured_user == user
assert event == AnalyticsEvent.ROOM_CREATED
assert properties == {
"room_id": response.data["id"],
"access_level": RoomAccessLevel.RESTRICTED,
"client_id": str(application.client_id),
"external_api": True,
"auth_method": "ApplicationJWTAuthentication",
"$set": {"email": user.email},
}
def test_api_rooms_update_requires_authentication():
"""Updating a room without authentication should return 401."""
room = RoomFactory(users=[(UserFactory(), RoleChoices.OWNER)])
client = APIClient()
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{"access_level": RoomAccessLevel.RESTRICTED},
format="json",
)
assert response.status_code == 401
def test_api_rooms_update_requires_scope():
"""Updating a room requires the ROOMS_UPDATE scope."""
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
# Token without ROOMS_UPDATE scope
token = generate_test_token(user, [ApplicationScope.ROOMS_RETRIEVE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{"access_level": RoomAccessLevel.RESTRICTED},
format="json",
)
assert response.status_code == 403
assert (
"insufficient permissions. required scope: rooms:update"
in str(response.data).lower()
)
def test_api_rooms_update_no_scope():
"""Updating a room without any scope should return 403."""
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
token = generate_test_token(user, [])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{"access_level": RoomAccessLevel.RESTRICTED},
format="json",
)
assert response.status_code == 403
assert "insufficient permissions." in str(response.data).lower()
@mock.patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_owner_success(mock_update_metadata, settings):
"""An owner should be able to update the access level and the configuration."""
settings.APPLICATION_BASE_URL = "http://your-application.com"
user = UserFactory()
room = RoomFactory(
users=[(user, RoleChoices.OWNER)],
access_level=RoomAccessLevel.TRUSTED,
configuration={},
)
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{
"access_level": RoomAccessLevel.RESTRICTED,
"configuration": {"everyone_can_mute": True},
},
format="json",
)
assert response.status_code == 200
assert response.data["id"] == str(room.id)
assert response.data["access_level"] == RoomAccessLevel.RESTRICTED
assert response.data["configuration"] == {"everyone_can_mute": True}
assert response.data["url"] == f"http://your-application.com/{room.slug}"
room.refresh_from_db()
assert room.access_level == RoomAccessLevel.RESTRICTED
assert room.configuration == {"everyone_can_mute": True}
mock_update_metadata.assert_called_once_with(
room_name=str(room.id),
metadata={
"configuration": {"everyone_can_mute": True},
"access_level": RoomAccessLevel.RESTRICTED,
},
)
@mock.patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_replaces_configuration(mock_update_metadata):
"""The configuration is replaced as a whole, it is not merged with the stored one."""
user = UserFactory()
room = RoomFactory(
users=[(user, RoleChoices.OWNER)],
configuration={"can_publish_sources": ["camera"], "everyone_can_mute": True},
)
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{"configuration": {"everyone_can_mute": False}},
format="json",
)
assert response.status_code == 200
# The keys missing from the payload are dropped, not kept.
assert response.data["configuration"] == {"everyone_can_mute": False}
room.refresh_from_db()
assert room.configuration == {"everyone_can_mute": False}
mock_update_metadata.assert_called_once_with(
room_name=str(room.id),
metadata={
"configuration": {"everyone_can_mute": False},
"access_level": room.access_level,
},
)
@mock.patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_administrator_success(mock_update_metadata):
"""An administrator should be able to update a room."""
user = UserFactory()
room = RoomFactory(
users=[(user, RoleChoices.ADMIN)],
access_level=RoomAccessLevel.TRUSTED,
)
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{"access_level": RoomAccessLevel.RESTRICTED},
format="json",
)
assert response.status_code == 200
room.refresh_from_db()
assert room.access_level == RoomAccessLevel.RESTRICTED
mock_update_metadata.assert_called_once()
@mock.patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_put_not_allowed(mock_update_metadata):
"""PUT is not exposed: full replacement is not supported, only PATCH is."""
user = UserFactory()
room = RoomFactory(
users=[(user, RoleChoices.OWNER)],
access_level=RoomAccessLevel.TRUSTED,
configuration={},
)
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.put(
f"/external-api/v1.0/rooms/{room.id}/",
{
"access_level": RoomAccessLevel.RESTRICTED,
"configuration": {"everyone_can_mute": True},
},
format="json",
)
assert response.status_code == 405
room.refresh_from_db()
assert room.access_level == RoomAccessLevel.TRUSTED
assert room.configuration == {}
mock_update_metadata.assert_not_called()
@pytest.mark.parametrize("role", [RoleChoices.MEMBER, None])
@mock.patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_without_privileges(mock_update_metadata, role):
"""Members and users without any role should not be able to update a room."""
user = UserFactory()
users = [(user, role)] if role else []
room = RoomFactory(users=users, access_level=RoomAccessLevel.TRUSTED)
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{"access_level": RoomAccessLevel.RESTRICTED},
format="json",
)
assert response.status_code == 403
room.refresh_from_db()
assert room.access_level == RoomAccessLevel.TRUSTED
mock_update_metadata.assert_not_called()
@mock.patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_readonly_enforcement(mock_update_metadata):
"""Read-only fields provided on update should be ignored, the slug stays immutable."""
user = UserFactory()
room = RoomFactory(
users=[(user, RoleChoices.OWNER)],
access_level=RoomAccessLevel.TRUSTED,
)
expected_id, expected_name = str(room.id), room.name
expected_slug, expected_pin_code = room.slug, room.pin_code
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{
"id": str(uuid.uuid4()),
"name": "fake-name",
"slug": "fake-slug",
"pin_code": "000000",
"access_level": RoomAccessLevel.RESTRICTED,
},
format="json",
)
assert response.status_code == 200
assert response.data["id"] == expected_id
assert response.data["name"] == expected_name
assert response.data["slug"] == expected_slug
room.refresh_from_db()
assert str(room.id) == expected_id
assert room.name == expected_name
assert room.slug == expected_slug
assert room.pin_code == expected_pin_code
# The one writable field in the payload was applied
assert room.access_level == RoomAccessLevel.RESTRICTED
mock_update_metadata.assert_called_once()
@mock.patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_rejects_invalid_configuration(mock_update_metadata):
"""Updating a room with unsupported configuration keys should fail."""
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.OWNER)], configuration={})
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{"configuration": {"unsupported_flag": True}},
format="json",
)
assert response.status_code == 400
assert "extra inputs are not permitted" in str(response.data).lower()
room.refresh_from_db()
assert room.configuration == {}
mock_update_metadata.assert_not_called()
@pytest.mark.parametrize(
"invalid_configuration",
[
{"can_publish_sources": ["invalid-source"]},
{"everyone_can_mute": "invalid-value"},
],
)
@mock.patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_rejects_invalid_configuration_values(
mock_update_metadata, invalid_configuration
):
"""Updating a room with invalid configuration values should fail."""
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.OWNER)], configuration={})
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{"configuration": invalid_configuration},
format="json",
)
assert response.status_code == 400
room.refresh_from_db()
assert room.configuration == {}
mock_update_metadata.assert_not_called()
@mock.patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_public_access_disabled_by_default(mock_update_metadata):
"""Switching a room to public should be disabled for the external API by default."""
user = UserFactory()
room = RoomFactory(
users=[(user, RoleChoices.OWNER)],
access_level=RoomAccessLevel.TRUSTED,
)
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{"access_level": RoomAccessLevel.PUBLIC},
format="json",
)
assert response.status_code == 400
assert "public rooms are disabled" in str(response.data).lower()
room.refresh_from_db()
assert room.access_level == RoomAccessLevel.TRUSTED
mock_update_metadata.assert_not_called()
@mock.patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_public_access_enabled_with_settings(
mock_update_metadata, settings
):
"""Switching a room to public should be allowed when explicitly enabled."""
settings.EXTERNAL_API_ALLOW_PUBLIC_ACCESS = True
user = UserFactory()
room = RoomFactory(
users=[(user, RoleChoices.OWNER)],
access_level=RoomAccessLevel.TRUSTED,
)
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{"access_level": RoomAccessLevel.PUBLIC},
format="json",
)
assert response.status_code == 200
assert response.data["access_level"] == RoomAccessLevel.PUBLIC
room.refresh_from_db()
assert room.access_level == RoomAccessLevel.PUBLIC
mock_update_metadata.assert_called_once()
@mock.patch("core.external_api.viewsets.analytics.capture")
@mock.patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_unchanged_skips_livekit_sync(
mock_update_metadata, mock_capture
):
"""An update that changes nothing should not sync metadata nor report changes."""
user = UserFactory()
room = RoomFactory(
users=[(user, RoleChoices.OWNER)],
access_level=RoomAccessLevel.TRUSTED,
configuration={"everyone_can_mute": True},
)
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{
"access_level": RoomAccessLevel.TRUSTED,
"configuration": {"everyone_can_mute": True},
},
format="json",
)
assert response.status_code == 200
mock_update_metadata.assert_not_called()
# The event is still emitted for auditing, but reports an empty delta.
_, _, properties = mock_capture.call_args[0]
assert properties["updated_fields"] == []
@mock.patch("core.external_api.viewsets.analytics.capture")
@mock.patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_tracks_analytics(mock_update_metadata, mock_capture):
"""Updating a room should emit a ROOM_UPDATED analytics event."""
user = UserFactory()
room = RoomFactory(
users=[(user, RoleChoices.OWNER)],
access_level=RoomAccessLevel.TRUSTED,
configuration={},
)
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
application = Application.objects.get()
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{
"access_level": RoomAccessLevel.RESTRICTED,
"configuration": {"everyone_can_mute": True},
},
format="json",
)
assert response.status_code == 200
mock_capture.assert_called_once()
captured_user, event, properties = mock_capture.call_args[0]
assert captured_user == user
assert event == AnalyticsEvent.ROOM_UPDATED
assert properties == {
"room_id": str(room.pk),
"access_level": RoomAccessLevel.RESTRICTED,
"updated_fields": ["access_level", "configuration"],
"previous_access_level": RoomAccessLevel.TRUSTED,
"client_id": str(application.client_id),
"external_api": True,
"auth_method": "ApplicationJWTAuthentication",
"$set": {"email": user.email},
}
mock_update_metadata.assert_called_once()
def test_api_rooms_response_no_url(settings):
"""Response should not include url field when APPLICATION_BASE_URL is None."""
settings.APPLICATION_BASE_URL = None
@@ -2009,106 +1497,6 @@ def test_resource_server_denies_access_with_insufficient_scopes(settings):
assert response.status_code == 403
@responses.activate
@mock.patch.object(RoomManagement, "update_metadata")
def test_resource_server_updates_room_with_prefixed_scope(
mock_update_metadata, settings
):
"""A resource server token carrying the prefixed update scope should be accepted."""
user = UserFactory(sub="very-specific-sub")
room = RoomFactory(
users=[(user, RoleChoices.OWNER)],
access_level=RoomAccessLevel.TRUSTED,
)
settings.OIDC_RS_CLIENT_ID = "some_client_id"
settings.OIDC_RS_CLIENT_SECRET = "some_client_secret"
settings.OIDC_RS_SCOPES_PREFIX = "lasuite_meet"
settings.OIDC_OP_URL = "https://oidc.example.com"
settings.OIDC_VERIFY_SSL = False
settings.OIDC_TIMEOUT = 5
settings.OIDC_PROXY = None
settings.OIDC_OP_JWKS_ENDPOINT = "https://oidc.example.com/jwks"
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
responses.add(
responses.POST,
"https://oidc.example.com/introspect",
json={
"iss": "https://oidc.example.com",
"aud": "some_client_id", # settings.OIDC_RS_CLIENT_ID
"sub": "very-specific-sub",
"client_id": "some_service_provider",
"scope": "openid lasuite_meet lasuite_meet:rooms:update",
"active": True,
},
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION="Bearer some_token")
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{"access_level": RoomAccessLevel.RESTRICTED},
format="json",
)
assert response.status_code == 200
room.refresh_from_db()
assert room.access_level == RoomAccessLevel.RESTRICTED
mock_update_metadata.assert_called_once()
@responses.activate
def test_resource_server_denies_room_update_without_update_scope(settings):
"""A resource server token without the update scope should be denied."""
user = UserFactory(sub="very-specific-sub")
room = RoomFactory(
users=[(user, RoleChoices.OWNER)],
access_level=RoomAccessLevel.TRUSTED,
)
settings.OIDC_RS_CLIENT_ID = "some_client_id"
settings.OIDC_RS_CLIENT_SECRET = "some_client_secret"
settings.OIDC_RS_SCOPES_PREFIX = "lasuite_meet"
settings.OIDC_OP_URL = "https://oidc.example.com"
settings.OIDC_VERIFY_SSL = False
settings.OIDC_TIMEOUT = 5
settings.OIDC_PROXY = None
settings.OIDC_OP_JWKS_ENDPOINT = "https://oidc.example.com/jwks"
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
responses.add(
responses.POST,
"https://oidc.example.com/introspect",
json={
"iss": "https://oidc.example.com",
"aud": "some_client_id", # settings.OIDC_RS_CLIENT_ID
"sub": "very-specific-sub",
"client_id": "some_service_provider",
"scope": "openid lasuite_meet lasuite_meet:rooms:retrieve",
"active": True,
},
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION="Bearer some_token")
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{"access_level": RoomAccessLevel.RESTRICTED},
format="json",
)
assert response.status_code == 403
room.refresh_from_db()
assert room.access_level == RoomAccessLevel.TRUSTED
# ==============================
# Addons
# ==============================
@@ -2160,32 +1548,6 @@ def test_api_rooms_create_with_valid_addons_token():
assert room.get_role(user) == RoleChoices.OWNER
@mock.patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_with_valid_addons_token(mock_update_metadata):
"""Updating a room with a valid addons token should succeed."""
user = UserFactory()
room = RoomFactory(
users=[(user, RoleChoices.OWNER)],
access_level=RoomAccessLevel.TRUSTED,
)
token = generate_addons_test_token(user, [ApplicationScope.ROOMS_UPDATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{"access_level": RoomAccessLevel.RESTRICTED},
format="json",
)
assert response.status_code == 200
room.refresh_from_db()
assert room.access_level == RoomAccessLevel.RESTRICTED
mock_update_metadata.assert_called_once()
def test_api_rooms_addons_token_inactive_user():
"""Addons token for an inactive user should return 401."""
user = UserFactory(is_active=False)
@@ -26,64 +26,6 @@ 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
@@ -1,23 +0,0 @@
"""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.")
)
+159 -186
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-09-02 22:52+0000\n"
"POT-Creation-Date: 2026-07-02 10:47+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:450
#: core/admin.py:300 core/admin.py:443
msgid "No owner"
msgstr "Kein Eigentümer"
#: core/admin.py:303 core/admin.py:453
#: core/admin.py:303 core/admin.py:446
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:517
#: core/admin.py:510
msgid "No scopes"
msgstr "Keine Scopes"
#: core/admin.py:519
#: core/admin.py:512
msgid "Scopes"
msgstr "Scopes"
@@ -110,201 +110,185 @@ msgstr "Scopes"
msgid "Creator is me"
msgstr "Ersteller bin ich"
#: core/api/serializers.py:108
#: core/api/serializers.py:89
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:560
#: core/api/serializers.py:534
msgid "This file extension is not allowed."
msgstr "Diese Dateiendung ist nicht erlaubt."
#: core/api/viewsets.py:1268
#: core/api/viewsets.py:1222
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:38
#: core/models.py:37
msgid "Member"
msgstr "Mitglied"
#: core/models.py:39
#: core/models.py:38
msgid "Administrator"
msgstr "Administrator"
#: core/models.py:40
#: core/models.py:39
msgid "Owner"
msgstr "Eigentümer"
#: core/models.py:56
#: core/models.py:55
msgid "Initiated"
msgstr "Gestartet"
#: core/models.py:57
#: core/models.py:56
msgid "Active"
msgstr "Aktiv"
#: core/models.py:58
#: core/models.py:57
msgid "Stopped"
msgstr "Beendet"
#: core/models.py:59
#: core/models.py:58
msgid "Saved"
msgstr "Gespeichert"
#: core/models.py:60
#: core/models.py:59
msgid "Aborted"
msgstr "Abgebrochen"
#: core/models.py:61
#: core/models.py:60
msgid "Failed to Start"
msgstr "Start fehlgeschlagen"
#: core/models.py:62
#: core/models.py:61
msgid "Failed to Stop"
msgstr "Stopp fehlgeschlagen"
#: core/models.py:63
#: core/models.py:62
msgid "Notification succeeded"
msgstr "Benachrichtigung erfolgreich"
#: core/models.py:66
#: core/models.py:65
msgid "External process successful"
msgstr "Externer Prozess erfolgreich"
#: core/models.py:68
#: core/models.py:67
msgid "External process failed"
msgstr "Externer Prozess fehlgeschlagen"
#: core/models.py:97
#: core/models.py:96
msgid "SCREEN_RECORDING"
msgstr "BILDSCHIRMAUFZEICHNUNG"
#: core/models.py:98
#: core/models.py:97
msgid "TRANSCRIPT"
msgstr "TRANSKRIPT"
#: core/models.py:104
#: core/models.py:103
msgid "Public Access"
msgstr "Öffentlicher Zugriff"
#: core/models.py:105
#: core/models.py:104
msgid "Trusted Access"
msgstr "Vertrauenswürdiger Zugriff"
#: core/models.py:106
#: core/models.py:105
msgid "Restricted Access"
msgstr "Eingeschränkter Zugriff"
#: core/models.py:118
#: core/models.py:117
msgid "id"
msgstr "ID"
#: core/models.py:119
#: core/models.py:118
msgid "primary key for the record as UUID"
msgstr "Primärschlüssel des Eintrags als UUID"
#: core/models.py:125
#: core/models.py:124
msgid "created on"
msgstr "erstellt am"
#: core/models.py:126
#: core/models.py:125
msgid "date and time at which a record was created"
msgstr "Datum und Uhrzeit der Erstellung eines Eintrags"
#: core/models.py:131
#: core/models.py:130
msgid "updated on"
msgstr "aktualisiert am"
#: core/models.py:132
#: core/models.py:131
msgid "date and time at which a record was last updated"
msgstr "Datum und Uhrzeit der letzten Aktualisierung eines Eintrags"
#: core/models.py:150
#: 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
msgid "sub"
msgstr "Sub"
#: core/models.py:152
#: core/models.py:159
msgid ""
"Optional for pending users; required upon account activation. 255 characters "
"or fewer. Printable ASCII characters only."
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
msgstr ""
"Optional für ausstehende Benutzer; erforderlich nach Kontoaktivierung. "
"Maximal 255 Zeichen. Nur druckbare ASCII-Zeichen erlaubt."
"Maximal 255 Zeichen. Nur Buchstaben, Zahlen und @/./+/-/_ Zeichen erlaubt."
#: 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
#: core/models.py:168
msgid "identity email address"
msgstr "Identitäts-E-Mail-Adresse"
#: core/models.py:166
#: core/models.py:173
msgid "admin email address"
msgstr "Administrator-E-Mail-Adresse"
#: core/models.py:168
#: core/models.py:175
msgid "full name"
msgstr "Vollständiger Name"
#: core/models.py:170
#: core/models.py:177
msgid "short name"
msgstr "Kurzname"
#: core/models.py:176
#: core/models.py:183
msgid "language"
msgstr "Sprache"
#: core/models.py:177
#: core/models.py:184
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:183
#: core/models.py:190
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: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
#: core/models.py:193
msgid "device"
msgstr "Gerät"
#: core/models.py:207
#: core/models.py:195
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:210
#: core/models.py:198
msgid "staff status"
msgstr "Mitarbeiterstatus"
#: core/models.py:212
#: core/models.py:200
msgid "Whether the user can log into this admin site."
msgstr "Ob der Benutzer sich bei dieser Admin-Seite anmelden kann."
#: core/models.py:215
#: core/models.py:203
msgid "active"
msgstr "aktiv"
#: core/models.py:218
#: core/models.py:206
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
@@ -312,66 +296,66 @@ msgstr ""
"Ob dieser Benutzer als aktiv behandelt werden soll. Deaktivieren Sie dies "
"anstelle des Löschens des Kontos."
#: core/models.py:231
#: core/models.py:219
msgid "user"
msgstr "Benutzer"
#: core/models.py:232
#: core/models.py:220
msgid "users"
msgstr "Benutzer"
#: core/models.py:298
#: core/models.py:286
msgid "Resource"
msgstr "Ressource"
#: core/models.py:299
#: core/models.py:287
msgid "Resources"
msgstr "Ressourcen"
#: core/models.py:357
#: core/models.py:345
msgid "Resource access"
msgstr "Ressourcenzugriff"
#: core/models.py:358
#: core/models.py:346
msgid "Resource accesses"
msgstr "Ressourcenzugriffe"
#: core/models.py:364
#: core/models.py:352
msgid "Resource access with this User and Resource already exists."
msgstr ""
"Ein Ressourcenzugriff mit diesem Benutzer und dieser Ressource existiert "
"bereits."
#: core/models.py:421
#: core/models.py:409
msgid "Visio room configuration"
msgstr "Visio-Raumkonfiguration"
#: core/models.py:422
#: core/models.py:410
msgid "Values for Visio parameters to configure the room."
msgstr "Werte für Visio-Parameter zur Konfiguration des Raums."
#: core/models.py:429
#: core/models.py:417
msgid "Room PIN code"
msgstr "PIN-Code für den Raum"
#: core/models.py:430
#: core/models.py:418
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:436 core/models.py:597
#: core/models.py:424 core/models.py:578
msgid "Room"
msgstr "Raum"
#: core/models.py:437
#: core/models.py:425
msgid "Rooms"
msgstr "Räume"
#: core/models.py:608
#: core/models.py:589
msgid "Worker ID"
msgstr "Worker-ID"
#: core/models.py:610
#: core/models.py:591
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
@@ -380,153 +364,153 @@ msgstr ""
"erhalten, auch wenn der Worker stoppt, was ein einfaches Nachverfolgen "
"ermöglicht."
#: core/models.py:618
#: core/models.py:599
msgid "Recording mode"
msgstr "Aufzeichnungsmodus"
#: core/models.py:619
#: core/models.py:600
msgid "Defines the mode of recording being called."
msgstr "Definiert den aufgerufenen Aufzeichnungsmodus."
#: core/models.py:624 core/models.py:625
#: core/models.py:605 core/models.py:606
msgid "Recording options"
msgstr "Aufnahmeoptionen"
#: core/models.py:632
#: core/models.py:613
msgid "External Process ID"
msgstr "External Process ID"
#: core/models.py:633
#: core/models.py:614
msgid "ID of the external process associated with the recording."
msgstr "ID des externen Prozesses, der mit der Aufzeichnung verknüpft ist"
#: core/models.py:639
#: core/models.py:620
msgid "Recording"
msgstr "Aufzeichnung"
#: core/models.py:640
#: core/models.py:621
msgid "Recordings"
msgstr "Aufzeichnungen"
#: core/models.py:750
#: core/models.py:731
msgid "Recording/user relation"
msgstr "Beziehung Aufzeichnung/Benutzer"
#: core/models.py:751
#: core/models.py:732
msgid "Recording/user relations"
msgstr "Beziehungen Aufzeichnung/Benutzer"
#: core/models.py:757
#: core/models.py:738
msgid "This user is already in this recording."
msgstr "Dieser Benutzer ist bereits Teil dieser Aufzeichnung."
#: core/models.py:763
#: core/models.py:744
msgid "This team is already in this recording."
msgstr "Dieses Team ist bereits Teil dieser Aufzeichnung."
#: core/models.py:769
#: core/models.py:750
msgid "Either user or team must be set, not both."
msgstr "Entweder Benutzer oder Team muss festgelegt werden, nicht beides."
#: core/models.py:786
#: core/models.py:767
msgid "Create rooms"
msgstr "Räume erstellen"
#: core/models.py:787
#: core/models.py:768
msgid "List rooms"
msgstr "Räume auflisten"
#: core/models.py:788
#: core/models.py:769
msgid "Retrieve room details"
msgstr "Raumdetails abrufen"
#: core/models.py:789
#: core/models.py:770
msgid "Update rooms"
msgstr "Räume aktualisieren"
#: core/models.py:790
#: core/models.py:771
msgid "Delete rooms"
msgstr "Räume löschen"
#: core/models.py:803
#: core/models.py:784
msgid "Application name"
msgstr "Anwendungsname"
#: core/models.py:804
#: core/models.py:785
msgid "Descriptive name for this application."
msgstr "Beschreibender Name für diese Anwendung."
#: core/models.py:814
#: core/models.py:795
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:825
#: core/models.py:806
msgid "Application"
msgstr "Anwendung"
#: core/models.py:826
#: core/models.py:807
msgid "Applications"
msgstr "Anwendungen"
#: core/models.py:849
#: core/models.py:830
msgid "Enter a valid domain"
msgstr "Geben Sie eine gültige Domain ein"
#: core/models.py:852
#: core/models.py:833
msgid "Domain"
msgstr "Domain"
#: core/models.py:853
#: core/models.py:834
msgid "Email domain this application can act on behalf of."
msgstr "E-Mail-Domain, im Namen der diese Anwendung handeln kann."
#: core/models.py:865
#: core/models.py:846
msgid "Application domain"
msgstr "Anwendungsdomain"
#: core/models.py:866
#: core/models.py:847
msgid "Application domains"
msgstr "Anwendungsdomains"
#: core/models.py:884
#: core/models.py:865
msgid "Pending"
msgstr "Ausstehend"
#: core/models.py:885
#: core/models.py:866
msgid "Analyzing"
msgstr ""
#: core/models.py:892
#: core/models.py:873
msgid "Ready"
msgstr "Bereit"
#: core/models.py:898
#: core/models.py:879
msgid "Background image"
msgstr "Hintergrundbild"
#: core/models.py:910
#: core/models.py:891
msgid "title"
msgstr "Titel"
#: core/models.py:934
#: core/models.py:915
msgid "Malware detection info when the analysis status is unsafe."
msgstr ""
"Informationen zur Malware-Erkennung, wenn der Analyse-Status unsicher ist."
#: core/models.py:939
#: core/models.py:920
msgid "File"
msgstr "Datei"
#: core/models.py:940
#: core/models.py:921
msgid "Files"
msgstr "Dateien"
#: core/models.py:1060
#: core/models.py:1041
msgid "This file is already hard deleted."
msgstr "Diese Datei wurde bereits endgültig gelöscht."
#: core/models.py:1070
#: core/models.py:1051
#, 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."
@@ -534,15 +518,15 @@ msgstr ""
"Um eine Datei endgültig zu löschen, muss sie zuvor weich gelöscht worden "
"sein."
#: core/recording/event/notification.py:124
#: core/recording/event/notification.py:123
msgid "Your recording is ready"
msgstr "Ihre Aufzeichnung ist bereit"
#: core/recording/event/notification.py:195
#: core/recording/event/notification.py:194
msgid "Transcription"
msgstr "Transkription"
#: core/recording/event/notification.py:206
#: core/recording/event/notification.py:204
#, python-brace-format
msgid "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}"
msgstr ""
@@ -553,25 +537,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:151
#: core/templates/mail/html/screen_recording.html:151
#: core/templates/mail/text/invitation.txt:4
#: core/templates/mail/text/screen_recording.txt:4
#: 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
msgid "Logo email"
msgstr "Logo-E-Mail"
#: core/templates/mail/html/invitation.html:181
#: core/templates/mail/text/invitation.txt:6
#: core/templates/mail/html/invitation.html:189
#: core/templates/mail/text/invitation.txt:5
msgid "invites you to join an ongoing video call"
msgstr "lädt Sie zu einem laufenden Videoanruf ein"
#: core/templates/mail/html/invitation.html:192
#: core/templates/mail/text/invitation.txt:8
#: core/templates/mail/html/invitation.html:200
#: core/templates/mail/text/invitation.txt:7
msgid "JOIN THE CALL"
msgstr "AM ANRUF TEILNEHMEN"
#: core/templates/mail/html/invitation.html:219
#: core/templates/mail/text/invitation.txt:14
#: core/templates/mail/html/invitation.html:227
#: core/templates/mail/text/invitation.txt:13
msgid ""
"If you can't click the button, copy and paste the URL into your browser to "
"join the call."
@@ -579,47 +563,41 @@ 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:227
#: core/templates/mail/text/invitation.txt:16
#: core/templates/mail/html/invitation.html:235
#: core/templates/mail/text/invitation.txt:15
msgid "Tips for a better experience:"
msgstr "Tipps für ein besseres Erlebnis:"
#: core/templates/mail/html/invitation.html:229
#: core/templates/mail/text/invitation.txt:18
#: core/templates/mail/html/invitation.html:237
#: core/templates/mail/text/invitation.txt:17
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:230
#: core/templates/mail/text/invitation.txt:19
#: core/templates/mail/html/invitation.html:238
#: core/templates/mail/text/invitation.txt:18
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:231
#: core/templates/mail/text/invitation.txt:20
#: core/templates/mail/html/invitation.html:239
#: core/templates/mail/text/invitation.txt:19
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:240
#: core/templates/mail/html/screen_recording.html:237
#: core/templates/mail/text/invitation.txt:22
#: core/templates/mail/text/screen_recording.txt:24
#: 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
#, python-format
msgid " Thank you for using %(brandname)s. "
msgstr " Vielen Dank für die Nutzung von %(brandname)s. "
#: 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
#: core/templates/mail/html/screen_recording.html:188
#: core/templates/mail/text/screen_recording.txt:6
msgid "Your recording is ready!"
msgstr "Ihre Aufzeichnung ist fertig!"
#: core/templates/mail/html/screen_recording.html:187
#: core/templates/mail/text/screen_recording.txt:9
#: core/templates/mail/html/screen_recording.html:195
#: core/templates/mail/text/screen_recording.txt:8
#, python-format
msgid ""
" Your recording of \"%(room_name)s\" on %(recording_date)s at "
@@ -628,14 +606,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:187
#: core/templates/mail/text/screen_recording.txt:9
#: core/templates/mail/html/screen_recording.html:195
#: core/templates/mail/text/screen_recording.txt:8
#, 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:192
#: core/templates/mail/text/screen_recording.txt:10
#: core/templates/mail/html/screen_recording.html:200
#: core/templates/mail/text/screen_recording.txt:9
msgid ""
" Sharing the recording via link is not yet available. Only organizers can "
"download it. "
@@ -643,33 +621,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:198
#: core/templates/mail/text/screen_recording.txt:12
#: core/templates/mail/html/screen_recording.html:206
#: core/templates/mail/text/screen_recording.txt:11
msgid "To keep this recording permanently:"
msgstr "So speichern Sie diese Aufzeichnung dauerhaft:"
#: core/templates/mail/html/screen_recording.html:200
#: core/templates/mail/html/screen_recording.html:208
#, 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:201
#: core/templates/mail/text/screen_recording.txt:15
#: core/templates/mail/html/screen_recording.html:209
#: core/templates/mail/text/screen_recording.txt:14
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:202
#: core/templates/mail/text/screen_recording.txt:16
#: core/templates/mail/html/screen_recording.html:210
#: core/templates/mail/text/screen_recording.txt:15
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:213
#: core/templates/mail/text/screen_recording.txt:18
#: core/templates/mail/html/screen_recording.html:221
#: core/templates/mail/text/screen_recording.txt:17
msgid "Open"
msgstr "Öffnen"
#: core/templates/mail/html/screen_recording.html:222
#: core/templates/mail/text/screen_recording.txt:20
#: core/templates/mail/html/screen_recording.html:230
#: core/templates/mail/text/screen_recording.txt:19
#, python-format
msgid ""
" If you have any questions or need assistance, please contact our support "
@@ -678,33 +656,28 @@ 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/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
#: core/templates/mail/text/screen_recording.txt:13
#, fuzzy, python-format
#| msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
msgid "Click the \"Open [%(link)s]\" link below "
msgstr "Klicken Sie auf den Link „<a href=\"%(link)s\">Öffnen</a>\" unten "
#: meet/settings.py:238
#: meet/settings.py:228
msgid "English"
msgstr "Englisch"
#: meet/settings.py:239
#: meet/settings.py:229
msgid "French"
msgstr "Französisch"
#: meet/settings.py:240
#: meet/settings.py:230
msgid "Dutch"
msgstr "Niederländisch"
#: meet/settings.py:241
#: meet/settings.py:231
msgid "German"
msgstr "Deutsch"
#: meet/settings.py:242
#: meet/settings.py:233
msgid "Spanish"
msgstr "Spanisch"
+160 -187
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-09-02 22:52+0000\n"
"POT-Creation-Date: 2026-07-02 10:47+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:450
#: core/admin.py:300 core/admin.py:443
msgid "No owner"
msgstr "No owner"
#: core/admin.py:303 core/admin.py:453
#: core/admin.py:303 core/admin.py:446
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:517
#: core/admin.py:510
msgid "No scopes"
msgstr "No scopes"
#: core/admin.py:519
#: core/admin.py:512
msgid "Scopes"
msgstr "Scopes"
@@ -110,199 +110,183 @@ msgstr "Scopes"
msgid "Creator is me"
msgstr "Creator is me"
#: core/api/serializers.py:108
#: core/api/serializers.py:89
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:560
#: core/api/serializers.py:534
msgid "This file extension is not allowed."
msgstr "This file extension is not allowed."
#: core/api/viewsets.py:1268
#: core/api/viewsets.py:1222
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:38
#: core/models.py:37
msgid "Member"
msgstr "Member"
#: core/models.py:39
#: core/models.py:38
msgid "Administrator"
msgstr "Administrator"
#: core/models.py:40
#: core/models.py:39
msgid "Owner"
msgstr "Owner"
#: core/models.py:56
#: core/models.py:55
msgid "Initiated"
msgstr "Initiated"
#: core/models.py:57
#: core/models.py:56
msgid "Active"
msgstr "Active"
#: core/models.py:58
#: core/models.py:57
msgid "Stopped"
msgstr "Stopped"
#: core/models.py:59
#: core/models.py:58
msgid "Saved"
msgstr "Saved"
#: core/models.py:60
#: core/models.py:59
msgid "Aborted"
msgstr "Aborted"
#: core/models.py:61
#: core/models.py:60
msgid "Failed to Start"
msgstr "Failed to Start"
#: core/models.py:62
#: core/models.py:61
msgid "Failed to Stop"
msgstr "Failed to Stop"
#: core/models.py:63
#: core/models.py:62
msgid "Notification succeeded"
msgstr "Notification succeeded"
#: core/models.py:66
#: core/models.py:65
msgid "External process successful"
msgstr "External process successful"
#: core/models.py:68
#: core/models.py:67
msgid "External process failed"
msgstr "External process failed"
#: core/models.py:97
#: core/models.py:96
msgid "SCREEN_RECORDING"
msgstr "SCREEN_RECORDING"
#: core/models.py:98
#: core/models.py:97
msgid "TRANSCRIPT"
msgstr "TRANSCRIPT"
#: core/models.py:104
#: core/models.py:103
msgid "Public Access"
msgstr "Public Access"
#: core/models.py:105
#: core/models.py:104
msgid "Trusted Access"
msgstr "Trusted Access"
#: core/models.py:106
#: core/models.py:105
msgid "Restricted Access"
msgstr "Restricted Access"
#: core/models.py:118
#: core/models.py:117
msgid "id"
msgstr "id"
#: core/models.py:119
#: core/models.py:118
msgid "primary key for the record as UUID"
msgstr "primary key for the record as UUID"
#: core/models.py:125
#: core/models.py:124
msgid "created on"
msgstr "created on"
#: core/models.py:126
#: core/models.py:125
msgid "date and time at which a record was created"
msgstr "date and time at which a record was created"
#: core/models.py:131
#: core/models.py:130
msgid "updated on"
msgstr "updated on"
#: core/models.py:132
#: core/models.py:131
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:150
#: 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
msgid "sub"
msgstr "sub"
#: core/models.py:152
#: core/models.py:159
msgid ""
"Optional for pending users; required upon account activation. 255 characters "
"or fewer. Printable ASCII characters only."
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
msgstr ""
"Optional for pending users; required upon account activation. 255 characters "
"or fewer. Printable ASCII characters only."
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
#: 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
#: core/models.py:168
msgid "identity email address"
msgstr "identity email address"
#: core/models.py:166
#: core/models.py:173
msgid "admin email address"
msgstr "admin email address"
#: core/models.py:168
#: core/models.py:175
msgid "full name"
msgstr "full name"
#: core/models.py:170
#: core/models.py:177
msgid "short name"
msgstr "short name"
#: core/models.py:176
#: core/models.py:183
msgid "language"
msgstr "language"
#: core/models.py:177
#: core/models.py:184
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:183
#: core/models.py:190
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: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
#: core/models.py:193
msgid "device"
msgstr "device"
#: core/models.py:207
#: core/models.py:195
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:210
#: core/models.py:198
msgid "staff status"
msgstr "staff status"
#: core/models.py:212
#: core/models.py:200
msgid "Whether the user can log into this admin site."
msgstr "Whether the user can log into this admin site."
#: core/models.py:215
#: core/models.py:203
msgid "active"
msgstr "active"
#: core/models.py:218
#: core/models.py:206
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
@@ -310,63 +294,63 @@ msgstr ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
#: core/models.py:231
#: core/models.py:219
msgid "user"
msgstr "user"
#: core/models.py:232
#: core/models.py:220
msgid "users"
msgstr "users"
#: core/models.py:298
#: core/models.py:286
msgid "Resource"
msgstr "Resource"
#: core/models.py:299
#: core/models.py:287
msgid "Resources"
msgstr "Resources"
#: core/models.py:357
#: core/models.py:345
msgid "Resource access"
msgstr "Resource access"
#: core/models.py:358
#: core/models.py:346
msgid "Resource accesses"
msgstr "Resource accesses"
#: core/models.py:364
#: core/models.py:352
msgid "Resource access with this User and Resource already exists."
msgstr "Resource access with this User and Resource already exists."
#: core/models.py:421
#: core/models.py:409
msgid "Visio room configuration"
msgstr "Visio room configuration"
#: core/models.py:422
#: core/models.py:410
msgid "Values for Visio parameters to configure the room."
msgstr "Values for Visio parameters to configure the room."
#: core/models.py:429
#: core/models.py:417
msgid "Room PIN code"
msgstr "Room PIN code"
#: core/models.py:430
#: core/models.py:418
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:436 core/models.py:597
#: core/models.py:424 core/models.py:578
msgid "Room"
msgstr "Room"
#: core/models.py:437
#: core/models.py:425
msgid "Rooms"
msgstr "Rooms"
#: core/models.py:608
#: core/models.py:589
msgid "Worker ID"
msgstr "Worker ID"
#: core/models.py:610
#: core/models.py:591
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
@@ -374,171 +358,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:618
#: core/models.py:599
msgid "Recording mode"
msgstr "Recording mode"
#: core/models.py:619
#: core/models.py:600
msgid "Defines the mode of recording being called."
msgstr "Defines the mode of recording being called."
#: core/models.py:624 core/models.py:625
#: core/models.py:605 core/models.py:606
msgid "Recording options"
msgstr "Recording options"
#: core/models.py:632
#: core/models.py:613
msgid "External Process ID"
msgstr "External Process ID"
#: core/models.py:633
#: core/models.py:614
msgid "ID of the external process associated with the recording."
msgstr "ID of the external process associated with the recording."
#: core/models.py:639
#: core/models.py:620
msgid "Recording"
msgstr "Recording"
#: core/models.py:640
#: core/models.py:621
msgid "Recordings"
msgstr "Recordings"
#: core/models.py:750
#: core/models.py:731
msgid "Recording/user relation"
msgstr "Recording/user relation"
#: core/models.py:751
#: core/models.py:732
msgid "Recording/user relations"
msgstr "Recording/user relations"
#: core/models.py:757
#: core/models.py:738
msgid "This user is already in this recording."
msgstr "This user is already in this recording."
#: core/models.py:763
#: core/models.py:744
msgid "This team is already in this recording."
msgstr "This team is already in this recording."
#: core/models.py:769
#: core/models.py:750
msgid "Either user or team must be set, not both."
msgstr "Either user or team must be set, not both."
#: core/models.py:786
#: core/models.py:767
#, fuzzy
#| msgid "created on"
msgid "Create rooms"
msgstr "Create rooms"
#: core/models.py:787
#: core/models.py:768
msgid "List rooms"
msgstr "List rooms"
#: core/models.py:788
#: core/models.py:769
msgid "Retrieve room details"
msgstr "Retrieve room details"
#: core/models.py:789
#: core/models.py:770
#, fuzzy
#| msgid "updated on"
msgid "Update rooms"
msgstr "Update rooms"
#: core/models.py:790
#: core/models.py:771
msgid "Delete rooms"
msgstr "Delete rooms"
#: core/models.py:803
#: core/models.py:784
msgid "Application name"
msgstr "Application name"
#: core/models.py:804
#: core/models.py:785
msgid "Descriptive name for this application."
msgstr "Descriptive name for this application."
#: core/models.py:814
#: core/models.py:795
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:825
#: core/models.py:806
msgid "Application"
msgstr "Application"
#: core/models.py:826
#: core/models.py:807
msgid "Applications"
msgstr "Applications"
#: core/models.py:849
#: core/models.py:830
msgid "Enter a valid domain"
msgstr "Enter a valid domain"
#: core/models.py:852
#: core/models.py:833
msgid "Domain"
msgstr "Domain"
#: core/models.py:853
#: core/models.py:834
msgid "Email domain this application can act on behalf of."
msgstr "Email domain this application can act on behalf of."
#: core/models.py:865
#: core/models.py:846
msgid "Application domain"
msgstr "Application domain"
#: core/models.py:866
#: core/models.py:847
msgid "Application domains"
msgstr "Application domains"
#: core/models.py:884
#: core/models.py:865
#, fuzzy
#| msgid "Recording"
msgid "Pending"
msgstr "Pending"
#: core/models.py:885
#: core/models.py:866
msgid "Analyzing"
msgstr ""
#: core/models.py:892
#: core/models.py:873
msgid "Ready"
msgstr "Ready"
#: core/models.py:898
#: core/models.py:879
msgid "Background image"
msgstr "Background image"
#: core/models.py:910
#: core/models.py:891
msgid "title"
msgstr "title"
#: core/models.py:934
#: core/models.py:915
msgid "Malware detection info when the analysis status is unsafe."
msgstr "Malware detection info when the analysis status is unsafe."
#: core/models.py:939
#: core/models.py:920
msgid "File"
msgstr "File"
#: core/models.py:940
#: core/models.py:921
msgid "Files"
msgstr "Files"
#: core/models.py:1060
#: core/models.py:1041
#, 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:1070
#: core/models.py:1051
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:124
#: core/recording/event/notification.py:123
msgid "Your recording is ready"
msgstr "Your recording is ready"
#: core/recording/event/notification.py:195
#: core/recording/event/notification.py:194
msgid "Transcription"
msgstr "Transcription"
#: core/recording/event/notification.py:206
#: core/recording/event/notification.py:204
#, python-brace-format
msgid "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}"
msgstr "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}"
@@ -548,25 +532,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:151
#: core/templates/mail/html/screen_recording.html:151
#: core/templates/mail/text/invitation.txt:4
#: core/templates/mail/text/screen_recording.txt:4
#: 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
msgid "Logo email"
msgstr "Logo email"
#: core/templates/mail/html/invitation.html:181
#: core/templates/mail/text/invitation.txt:6
#: core/templates/mail/html/invitation.html:189
#: core/templates/mail/text/invitation.txt:5
msgid "invites you to join an ongoing video call"
msgstr "invites you to join an ongoing video call"
#: core/templates/mail/html/invitation.html:192
#: core/templates/mail/text/invitation.txt:8
#: core/templates/mail/html/invitation.html:200
#: core/templates/mail/text/invitation.txt:7
msgid "JOIN THE CALL"
msgstr "JOIN THE CALL"
#: core/templates/mail/html/invitation.html:219
#: core/templates/mail/text/invitation.txt:14
#: core/templates/mail/html/invitation.html:227
#: core/templates/mail/text/invitation.txt:13
msgid ""
"If you can't click the button, copy and paste the URL into your browser to "
"join the call."
@@ -574,47 +558,41 @@ 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:227
#: core/templates/mail/text/invitation.txt:16
#: core/templates/mail/html/invitation.html:235
#: core/templates/mail/text/invitation.txt:15
msgid "Tips for a better experience:"
msgstr "Tips for a better experience:"
#: core/templates/mail/html/invitation.html:229
#: core/templates/mail/text/invitation.txt:18
#: core/templates/mail/html/invitation.html:237
#: core/templates/mail/text/invitation.txt:17
msgid "Use Chrome or Firefox for better call quality"
msgstr "Use Chrome or Firefox for better call quality"
#: core/templates/mail/html/invitation.html:230
#: core/templates/mail/text/invitation.txt:19
#: core/templates/mail/html/invitation.html:238
#: core/templates/mail/text/invitation.txt:18
msgid "Test your microphone and camera before joining"
msgstr "Test your microphone and camera before joining"
#: core/templates/mail/html/invitation.html:231
#: core/templates/mail/text/invitation.txt:20
#: core/templates/mail/html/invitation.html:239
#: core/templates/mail/text/invitation.txt:19
msgid "Make sure you have a stable internet connection"
msgstr "Make sure you have a stable internet connection"
#: 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
#: 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
#, python-format
msgid " Thank you for using %(brandname)s. "
msgstr " Thank you for using %(brandname)s. "
#: 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
#: core/templates/mail/html/screen_recording.html:188
#: core/templates/mail/text/screen_recording.txt:6
msgid "Your recording is ready!"
msgstr "Your recording is ready!"
#: core/templates/mail/html/screen_recording.html:187
#: core/templates/mail/text/screen_recording.txt:9
#: core/templates/mail/html/screen_recording.html:195
#: core/templates/mail/text/screen_recording.txt:8
#, python-format
msgid ""
" Your recording of \"%(room_name)s\" on %(recording_date)s at "
@@ -623,14 +601,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:187
#: core/templates/mail/text/screen_recording.txt:9
#: core/templates/mail/html/screen_recording.html:195
#: core/templates/mail/text/screen_recording.txt:8
#, 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:192
#: core/templates/mail/text/screen_recording.txt:10
#: core/templates/mail/html/screen_recording.html:200
#: core/templates/mail/text/screen_recording.txt:9
msgid ""
" Sharing the recording via link is not yet available. Only organizers can "
"download it. "
@@ -638,33 +616,33 @@ msgstr ""
" Sharing the recording via link is not yet available. Only organizers can "
"download it. "
#: core/templates/mail/html/screen_recording.html:198
#: core/templates/mail/text/screen_recording.txt:12
#: core/templates/mail/html/screen_recording.html:206
#: core/templates/mail/text/screen_recording.txt:11
msgid "To keep this recording permanently:"
msgstr "To keep this recording permanently:"
#: core/templates/mail/html/screen_recording.html:200
#: core/templates/mail/html/screen_recording.html:208
#, 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:201
#: core/templates/mail/text/screen_recording.txt:15
#: core/templates/mail/html/screen_recording.html:209
#: core/templates/mail/text/screen_recording.txt:14
msgid "Use the \"Download\" button in the interface "
msgstr "Use the \"Download\" button in the interface "
#: core/templates/mail/html/screen_recording.html:202
#: core/templates/mail/text/screen_recording.txt:16
#: core/templates/mail/html/screen_recording.html:210
#: core/templates/mail/text/screen_recording.txt:15
msgid "Save the file to your preferred location"
msgstr "Save the file to your preferred location"
#: core/templates/mail/html/screen_recording.html:213
#: core/templates/mail/text/screen_recording.txt:18
#: core/templates/mail/html/screen_recording.html:221
#: core/templates/mail/text/screen_recording.txt:17
msgid "Open"
msgstr "Open"
#: core/templates/mail/html/screen_recording.html:222
#: core/templates/mail/text/screen_recording.txt:20
#: core/templates/mail/html/screen_recording.html:230
#: core/templates/mail/text/screen_recording.txt:19
#, python-format
msgid ""
" If you have any questions or need assistance, please contact our support "
@@ -673,33 +651,28 @@ msgstr ""
" If you have any questions or need assistance, please contact our support "
"team at %(support_email)s. "
#: 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
#: core/templates/mail/text/screen_recording.txt:13
#, fuzzy, python-format
#| msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
msgid "Click the \"Open [%(link)s]\" link below "
msgstr "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
#: meet/settings.py:238
#: meet/settings.py:228
msgid "English"
msgstr "English"
#: meet/settings.py:239
#: meet/settings.py:229
msgid "French"
msgstr "French"
#: meet/settings.py:240
#: meet/settings.py:230
msgid "Dutch"
msgstr "Dutch"
#: meet/settings.py:241
#: meet/settings.py:231
msgid "German"
msgstr "German"
#: meet/settings.py:242
#: meet/settings.py:233
msgid "Spanish"
msgstr "Spanish"
+174 -229
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-09-02 22:52+0000\n"
"POT-Creation-Date: 2026-07-02 10:47+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 ""
# 'Marcas de tiempo' is a bad translation for spanish
#: core/admin.py:237
# 'Marcas de tiempo' is a bad translation for spanish
msgid "Timestamps"
msgstr ""
msgstr ""
#: core/admin.py:240
msgid "File preview"
msgstr "Vista previa"
#: core/admin.py:300 core/admin.py:450
#: core/admin.py:300 core/admin.py:443
msgid "No owner"
msgstr "Sin propietario"
#: core/admin.py:303 core/admin.py:453
#: core/admin.py:303 core/admin.py:446
msgid "Multiple owners"
msgstr "Varios propietarios"
@@ -89,19 +89,18 @@ 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:517
#: core/admin.py:510
msgid "No scopes"
msgstr ""
#: core/admin.py:519
#: core/admin.py:512
msgid "Scopes"
msgstr "Ámbitos"
@@ -109,599 +108,545 @@ msgstr "Ámbitos"
msgid "Creator is me"
msgstr "Yo soy el creador"
#: core/api/serializers.py:108
#: core/api/serializers.py:89
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:560
#: core/api/serializers.py:534
msgid "This file extension is not allowed."
msgstr "Esta extensión de archivo no está permitida."
#: core/api/viewsets.py:1268
#: core/api/viewsets.py:1222
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:38
#: core/models.py:37
msgid "Member"
msgstr "Miembro"
#: core/models.py:39
#: core/models.py:38
msgid "Administrator"
msgstr "Administrador"
#: core/models.py:40
#: core/models.py:39
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:57
#: core/models.py:56
msgid "Active"
msgstr "Activa"
#: core/models.py:58
#: core/models.py:57
msgid "Stopped"
msgstr "Detenida"
#: core/models.py:59
#: core/models.py:58
msgid "Saved"
msgstr "Guardada"
#: core/models.py:60
#: core/models.py:59
msgid "Aborted"
msgstr "Cancelada"
#: core/models.py:61
#: core/models.py:60
msgid "Failed to Start"
msgstr "Error al iniciar"
#: core/models.py:62
#: core/models.py:61
msgid "Failed to Stop"
msgstr "Error al detener"
#: core/models.py:63
#: core/models.py:62
msgid "Notification succeeded"
msgstr "Notificado correctamente"
#: core/models.py:66
#: core/models.py:65
msgid "External process successful"
msgstr "Proceso externo finalizado correctamente"
#: core/models.py:68
#: core/models.py:67
msgid "External process failed"
msgstr "Error en el Proceso externo"
#: core/models.py:97
#: core/models.py:96
msgid "SCREEN_RECORDING"
msgstr "GRABACIÓN_DE_PANTALLA"
#: core/models.py:98
#: core/models.py:97
msgid "TRANSCRIPT"
msgstr "TRANSCRIPCIÓN"
#: core/models.py:104
#: core/models.py:103
msgid "Public Access"
msgstr "Acceso público"
#: core/models.py:105
#: core/models.py:104
msgid "Trusted Access"
msgstr "Acceso usuarios autorizados"
#: core/models.py:106
#: core/models.py:105
msgid "Restricted Access"
msgstr "Acceso restringido"
#: core/models.py:118
#: core/models.py:117
msgid "id"
msgstr "id"
#: core/models.py:119
#: core/models.py:118
msgid "primary key for the record as UUID"
msgstr "clave primaria del registro en forma de UUID"
#: core/models.py:125
#: core/models.py:124
msgid "created on"
msgstr "creado el"
#: core/models.py:126
#: core/models.py:125
msgid "date and time at which a record was created"
msgstr "fecha y hora en que se creó un registro"
#: core/models.py:131
#: core/models.py:130
msgid "updated on"
msgstr "actualizado el"
#: core/models.py:132
#: core/models.py:131
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:150
#: 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
msgid "sub"
msgstr "sub"
#: core/models.py:152
#: core/models.py:159
msgid ""
"Optional for pending users; required upon account activation. 255 characters "
"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."
"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 @/./+/-/_."
#: 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
#: core/models.py:168
msgid "identity email address"
msgstr "dirección de correo electrónico"
#: core/models.py:166
#: core/models.py:173
msgid "admin email address"
msgstr "dirección de correo electrónico de administrador"
#: core/models.py:168
#: core/models.py:175
msgid "full name"
msgstr "nombre completo"
#: core/models.py:170
#: core/models.py:177
msgid "short name"
msgstr "nombre corto"
#: core/models.py:176
#: core/models.py:183
msgid "language"
msgstr "idioma"
#: core/models.py:177
#: core/models.py:184
msgid "The language in which the user wants to see the interface."
msgstr "El idioma preferido de interfaz."
#: core/models.py:183
#: core/models.py:190
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: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
#: core/models.py:193
msgid "device"
msgstr "dispositivo"
#: core/models.py:207
#: core/models.py:195
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:210
#: core/models.py:198
msgid "staff status"
msgstr "estado del personal"
#: core/models.py:212
#: core/models.py:200
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:215
#: core/models.py:203
msgid "active"
msgstr "activo"
#: core/models.py:218
#: core/models.py:206
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:231
#: core/models.py:219
msgid "user"
msgstr "usuario"
#: core/models.py:232
#: core/models.py:220
msgid "users"
msgstr "usuarios"
#: core/models.py:298
#: core/models.py:286
msgid "Resource"
msgstr "Recurso"
#: core/models.py:299
#: core/models.py:287
msgid "Resources"
msgstr "Recursos"
#: core/models.py:357
#: core/models.py:345
msgid "Resource access"
msgstr "Acceso a recursos"
#: core/models.py:358
#: core/models.py:346
msgid "Resource accesses"
msgstr "Accesos a recursos"
#: core/models.py:364
#: core/models.py:352
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:421
#: core/models.py:409
msgid "Visio room configuration"
msgstr "Configuración de la videoconferencia"
#: core/models.py:422
#: core/models.py:410
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:429
#: core/models.py:417
msgid "Room PIN code"
msgstr "Código PIN de la reunión"
#: core/models.py:430
#: core/models.py:418
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:436 core/models.py:597
#: core/models.py:424 core/models.py:578
msgid "Room"
msgstr "Reunión"
#: core/models.py:437
#: core/models.py:425
msgid "Rooms"
msgstr "Reuniones"
#: core/models.py:608
#: core/models.py:589
msgid "Worker ID"
msgstr "ID del worker"
#: core/models.py:610
#: core/models.py:591
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:618
#: core/models.py:599
msgid "Recording mode"
msgstr "Modo de grabación"
#: core/models.py:619
#: core/models.py:600
msgid "Defines the mode of recording being called."
msgstr "Define el modo de grabación a utilizar."
#: core/models.py:624 core/models.py:625
#: core/models.py:605 core/models.py:606
msgid "Recording options"
msgstr "Opciones de grabación"
#: core/models.py:632
#: core/models.py:613
msgid "External Process ID"
msgstr "ID del proceso externo"
#: core/models.py:633
#: core/models.py:614
msgid "ID of the external process associated with the recording."
msgstr "ID del proceso externo asociado a la grabación."
#: core/models.py:639
#: core/models.py:620
msgid "Recording"
msgstr "Grabación"
#: core/models.py:640
#: core/models.py:621
msgid "Recordings"
msgstr "Grabaciones"
#: core/models.py:750
#: core/models.py:731
msgid "Recording/user relation"
msgstr "Relación grabación/usuario"
#: core/models.py:751
#: core/models.py:732
msgid "Recording/user relations"
msgstr "Relaciones grabación/usuario"
#: core/models.py:757
#: core/models.py:738
msgid "This user is already in this recording."
msgstr "Este usuario ya está en esta grabación."
#: core/models.py:763
#: core/models.py:744
msgid "This team is already in this recording."
msgstr "Este equipo ya está en esta grabación."
#: core/models.py:769
#: core/models.py:750
msgid "Either user or team must be set, not both."
msgstr "Debe definirse el usuario o el equipo, pero no ambos."
#: core/models.py:786
#: core/models.py:767
msgid "Create rooms"
msgstr "Crear reunión"
#: core/models.py:787
#: core/models.py:768
msgid "List rooms"
msgstr "Listar reuniones"
#: core/models.py:788
#: core/models.py:769
msgid "Retrieve room details"
msgstr "Ver los detalles de una reunión"
#: core/models.py:789
#: core/models.py:770
msgid "Update rooms"
msgstr "Actualizar las reuniones"
#: core/models.py:790
#: core/models.py:771
msgid "Delete rooms"
msgstr "Eliminar las reuniones"
#: core/models.py:803
#: core/models.py:784
msgid "Application name"
msgstr "Nombre de la aplicación"
#: core/models.py:804
#: core/models.py:785
msgid "Descriptive name for this application."
msgstr "Nombre descriptivo de esta aplicación."
#: core/models.py:814
#: core/models.py:795
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:825
#: core/models.py:806
msgid "Application"
msgstr "Aplicación"
#: core/models.py:826
#: core/models.py:807
msgid "Applications"
msgstr "Aplicaciones"
#: core/models.py:849
#: core/models.py:830
msgid "Enter a valid domain"
msgstr "Introduce un dominio válido"
#: core/models.py:852
#: core/models.py:833
msgid "Domain"
msgstr "Dominio"
#: core/models.py:853
#: core/models.py:834
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:865
#: core/models.py:846
msgid "Application domain"
msgstr "Dominio de aplicación"
#: core/models.py:866
#: core/models.py:847
msgid "Application domains"
msgstr "Dominios de aplicación"
#: core/models.py:884
#: core/models.py:865
msgid "Pending"
msgstr "Pendiente"
#: core/models.py:885
#: core/models.py:866
msgid "Analyzing"
msgstr "Analizando"
#: core/models.py:892
#: core/models.py:873
msgid "Ready"
msgstr "Listo"
#: core/models.py:898
#: core/models.py:879
msgid "Background image"
msgstr "Imagen de fondo"
#: core/models.py:910
#: core/models.py:891
msgid "title"
msgstr "título"
#: core/models.py:934
#: core/models.py:915
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:939
#: core/models.py:920
msgid "File"
msgstr "Archivo"
#: core/models.py:940
#: core/models.py:921
msgid "Files"
msgstr "Archivos"
#: core/models.py:1060
#: core/models.py:1041
msgid "This file is already hard deleted."
msgstr "Este archivo ya se ha eliminado definitivamente."
#: core/models.py:1070
#: core/models.py:1051
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:124
#: core/recording/event/notification.py:123
msgid "Your recording is ready"
msgstr "Tu grabación está lista"
#: core/recording/event/notification.py:195
#: core/recording/event/notification.py:194
msgid "Transcription"
msgstr "Transcripción"
#: core/recording/event/notification.py:206
#: core/recording/event/notification.py:204
#, 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:151
#: core/templates/mail/html/screen_recording.html:151
#: core/templates/mail/text/invitation.txt:4
#: core/templates/mail/text/screen_recording.txt:4
#: 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
msgid "Logo email"
msgstr "Logotipo del correo"
#: core/templates/mail/html/invitation.html:181
#: core/templates/mail/text/invitation.txt:6
#: core/templates/mail/html/invitation.html:189
#: core/templates/mail/text/invitation.txt:5
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:192
#: core/templates/mail/text/invitation.txt:8
#: core/templates/mail/html/invitation.html:200
#: core/templates/mail/text/invitation.txt:7
msgid "JOIN THE CALL"
msgstr "UNIRSE A LA LLAMADA"
#: core/templates/mail/html/invitation.html:219
#: core/templates/mail/text/invitation.txt:14
#: core/templates/mail/html/invitation.html:227
#: core/templates/mail/text/invitation.txt:13
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:227
#: core/templates/mail/text/invitation.txt:16
#: core/templates/mail/html/invitation.html:235
#: core/templates/mail/text/invitation.txt:15
msgid "Tips for a better experience:"
msgstr "Consejos para una mejor experiencia:"
#: core/templates/mail/html/invitation.html:229
#: core/templates/mail/text/invitation.txt:18
#: core/templates/mail/html/invitation.html:237
#: core/templates/mail/text/invitation.txt:17
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:230
#: core/templates/mail/text/invitation.txt:19
#: core/templates/mail/html/invitation.html:238
#: core/templates/mail/text/invitation.txt:18
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:231
#: core/templates/mail/text/invitation.txt:20
#: core/templates/mail/html/invitation.html:239
#: core/templates/mail/text/invitation.txt:19
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:240
#: core/templates/mail/html/screen_recording.html:237
#: core/templates/mail/text/invitation.txt:22
#: core/templates/mail/text/screen_recording.txt:24
#: 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
#, python-format
msgid " Thank you for using %(brandname)s. "
msgstr " Gracias por usar %(brandname)s. "
#: 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
#: core/templates/mail/html/screen_recording.html:188
#: core/templates/mail/text/screen_recording.txt:6
msgid "Your recording is ready!"
msgstr "¡Tu grabación está lista!"
#: core/templates/mail/html/screen_recording.html:187
#: core/templates/mail/text/screen_recording.txt:9
#: core/templates/mail/html/screen_recording.html:195
#: core/templates/mail/text/screen_recording.txt:8
#, 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:187
#: core/templates/mail/text/screen_recording.txt:9
#: core/templates/mail/html/screen_recording.html:195
#: core/templates/mail/text/screen_recording.txt:8
#, 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:192
#: core/templates/mail/text/screen_recording.txt:10
#: core/templates/mail/html/screen_recording.html:200
#: core/templates/mail/text/screen_recording.txt:9
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:198
#: core/templates/mail/text/screen_recording.txt:12
#: core/templates/mail/html/screen_recording.html:206
#: core/templates/mail/text/screen_recording.txt:11
msgid "To keep this recording permanently:"
msgstr "Para conservar esta grabación de forma permanente:"
#: core/templates/mail/html/screen_recording.html:200
#: core/templates/mail/html/screen_recording.html:208
#, 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:201
#: core/templates/mail/text/screen_recording.txt:15
#: core/templates/mail/html/screen_recording.html:209
#: core/templates/mail/text/screen_recording.txt:14
msgid "Use the \"Download\" button in the interface "
msgstr "Usa el botón \"Descargar\" de la interfaz "
#: core/templates/mail/html/screen_recording.html:202
#: core/templates/mail/text/screen_recording.txt:16
#: core/templates/mail/html/screen_recording.html:210
#: core/templates/mail/text/screen_recording.txt:15
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:213
#: core/templates/mail/text/screen_recording.txt:18
#: core/templates/mail/html/screen_recording.html:221
#: core/templates/mail/text/screen_recording.txt:17
msgid "Open"
msgstr "Abrir"
#: core/templates/mail/html/screen_recording.html:222
#: core/templates/mail/text/screen_recording.txt:20
#: core/templates/mail/html/screen_recording.html:230
#: core/templates/mail/text/screen_recording.txt:19
#, 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/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
#: core/templates/mail/text/screen_recording.txt:13
#, 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:238
#: meet/settings.py:228
msgid "English"
msgstr "Inglés"
#: meet/settings.py:239
#: meet/settings.py:229
msgid "French"
msgstr "Francés"
#: meet/settings.py:240
#: meet/settings.py:230
msgid "Dutch"
msgstr "Neerlandés"
#: meet/settings.py:241
#: meet/settings.py:231
msgid "German"
msgstr "Alemán"
#: meet/settings.py:242
#: meet/settings.py:233
msgid "Spanish"
msgstr "Español"
+160 -187
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-09-02 22:52+0000\n"
"POT-Creation-Date: 2026-07-02 10:47+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:450
#: core/admin.py:300 core/admin.py:443
msgid "No owner"
msgstr "Pas de propriétaire"
#: core/admin.py:303 core/admin.py:453
#: core/admin.py:303 core/admin.py:446
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:517
#: core/admin.py:510
msgid "No scopes"
msgstr "Aucun scopes"
#: core/admin.py:519
#: core/admin.py:512
msgid "Scopes"
msgstr "Scopes"
@@ -111,203 +111,187 @@ msgstr "Scopes"
msgid "Creator is me"
msgstr "Je suis le créateur"
#: core/api/serializers.py:108
#: core/api/serializers.py:89
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:560
#: core/api/serializers.py:534
msgid "This file extension is not allowed."
msgstr "Cette extension n'est pas autorisée"
#: core/api/viewsets.py:1268
#: core/api/viewsets.py:1222
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:38
#: core/models.py:37
msgid "Member"
msgstr "Membre"
#: core/models.py:39
#: core/models.py:38
msgid "Administrator"
msgstr "Administrateur"
#: core/models.py:40
#: core/models.py:39
msgid "Owner"
msgstr "Propriétaire"
#: core/models.py:56
#: core/models.py:55
msgid "Initiated"
msgstr "Initié"
#: core/models.py:57
#: core/models.py:56
msgid "Active"
msgstr "Actif"
#: core/models.py:58
#: core/models.py:57
msgid "Stopped"
msgstr "Arrêté"
#: core/models.py:59
#: core/models.py:58
msgid "Saved"
msgstr "Enregistré"
#: core/models.py:60
#: core/models.py:59
msgid "Aborted"
msgstr "Abandonné"
#: core/models.py:61
#: core/models.py:60
msgid "Failed to Start"
msgstr "Échec au démarrage"
#: core/models.py:62
#: core/models.py:61
msgid "Failed to Stop"
msgstr "Échec à l'arrêt"
#: core/models.py:63
#: core/models.py:62
msgid "Notification succeeded"
msgstr "Notification réussie"
#: core/models.py:66
#: core/models.py:65
msgid "External process successful"
msgstr "Traitement externe : succès"
#: core/models.py:68
#: core/models.py:67
msgid "External process failed"
msgstr "Traitement externe : erreur"
#: core/models.py:97
#: core/models.py:96
msgid "SCREEN_RECORDING"
msgstr "ENREGISTREMENT_ÉCRAN"
#: core/models.py:98
#: core/models.py:97
msgid "TRANSCRIPT"
msgstr "TRANSCRIPTION"
#: core/models.py:104
#: core/models.py:103
msgid "Public Access"
msgstr "Accès public"
#: core/models.py:105
#: core/models.py:104
msgid "Trusted Access"
msgstr "Accès de confiance"
#: core/models.py:106
#: core/models.py:105
msgid "Restricted Access"
msgstr "Accès restreint"
#: core/models.py:118
#: core/models.py:117
msgid "id"
msgstr "id"
#: core/models.py:119
#: core/models.py:118
msgid "primary key for the record as UUID"
msgstr "clé primaire pour l'enregistrement sous forme d'UUID"
#: core/models.py:125
#: core/models.py:124
msgid "created on"
msgstr "créé le"
#: core/models.py:126
#: core/models.py:125
msgid "date and time at which a record was created"
msgstr "date et heure auxquelles un enregistrement a été créé"
#: core/models.py:131
#: core/models.py:130
msgid "updated on"
msgstr "mis à jour le"
#: core/models.py:132
#: core/models.py:131
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:150
#: 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
msgid "sub"
msgstr "sub"
#: core/models.py:152
#: core/models.py:159
msgid ""
"Optional for pending users; required upon account activation. 255 characters "
"or fewer. Printable ASCII characters only."
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
msgstr ""
"Facultatif pour les utilisateurs en attente ; obligatoire lors de lactivation "
"du compte. 255 caractères maximum. Caractères ASCII imprimables uniquement."
"Optionnel pour les utilisateurs en attente ; requis lors de l'activation du "
"compte. 255 caractères maximum. Lettres, chiffres et @/./+/-/_ uniquement."
#: 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
#: core/models.py:168
msgid "identity email address"
msgstr "adresse e-mail d'identité"
#: core/models.py:166
#: core/models.py:173
msgid "admin email address"
msgstr "adresse e-mail d'administrateur"
#: core/models.py:168
#: core/models.py:175
msgid "full name"
msgstr "nom complet"
#: core/models.py:170
#: core/models.py:177
msgid "short name"
msgstr "nom court"
#: core/models.py:176
#: core/models.py:183
msgid "language"
msgstr "langue"
#: core/models.py:177
#: core/models.py:184
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:183
#: core/models.py:190
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: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
#: core/models.py:193
msgid "device"
msgstr "appareil"
#: core/models.py:207
#: core/models.py:195
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:210
#: core/models.py:198
msgid "staff status"
msgstr "statut du personnel"
#: core/models.py:212
#: core/models.py:200
msgid "Whether the user can log into this admin site."
msgstr "Si l'utilisateur peut se connecter à ce site d'administration."
#: core/models.py:215
#: core/models.py:203
msgid "active"
msgstr "actif"
#: core/models.py:218
#: core/models.py:206
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
@@ -315,65 +299,65 @@ msgstr ""
"Si cet utilisateur doit être traité comme actif. Désélectionnez cette option "
"au lieu de supprimer des comptes."
#: core/models.py:231
#: core/models.py:219
msgid "user"
msgstr "utilisateur"
#: core/models.py:232
#: core/models.py:220
msgid "users"
msgstr "utilisateurs"
#: core/models.py:298
#: core/models.py:286
msgid "Resource"
msgstr "Ressource"
#: core/models.py:299
#: core/models.py:287
msgid "Resources"
msgstr "Ressources"
#: core/models.py:357
#: core/models.py:345
msgid "Resource access"
msgstr "Accès aux ressources"
#: core/models.py:358
#: core/models.py:346
msgid "Resource accesses"
msgstr "Accès aux ressources"
#: core/models.py:364
#: core/models.py:352
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:421
#: core/models.py:409
msgid "Visio room configuration"
msgstr "Configuration de la salle de visioconférence"
#: core/models.py:422
#: core/models.py:410
msgid "Values for Visio parameters to configure the room."
msgstr "Valeurs des paramètres de visioconférence pour configurer la salle."
#: core/models.py:429
#: core/models.py:417
msgid "Room PIN code"
msgstr "Code PIN de la salle"
#: core/models.py:430
#: core/models.py:418
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:436 core/models.py:597
#: core/models.py:424 core/models.py:578
msgid "Room"
msgstr "Salle"
#: core/models.py:437
#: core/models.py:425
msgid "Rooms"
msgstr "Salles"
#: core/models.py:608
#: core/models.py:589
msgid "Worker ID"
msgstr "ID du Worker"
#: core/models.py:610
#: core/models.py:591
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
@@ -381,170 +365,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:618
#: core/models.py:599
msgid "Recording mode"
msgstr "Mode d'enregistrement"
#: core/models.py:619
#: core/models.py:600
msgid "Defines the mode of recording being called."
msgstr "Définit le mode d'enregistrement appelé."
#: core/models.py:624 core/models.py:625
#: core/models.py:605 core/models.py:606
msgid "Recording options"
msgstr "Options d'enregistrement"
#: core/models.py:632
#: core/models.py:613
msgid "External Process ID"
msgstr "ID Traitement externe"
#: core/models.py:633
#: core/models.py:614
msgid "ID of the external process associated with the recording."
msgstr "ID du traitement externe associé avec l'enregistrement."
#: core/models.py:639
#: core/models.py:620
msgid "Recording"
msgstr "Enregistrement"
#: core/models.py:640
#: core/models.py:621
msgid "Recordings"
msgstr "Enregistrements"
#: core/models.py:750
#: core/models.py:731
msgid "Recording/user relation"
msgstr "Relation enregistrement/utilisateur"
#: core/models.py:751
#: core/models.py:732
msgid "Recording/user relations"
msgstr "Relations enregistrement/utilisateur"
#: core/models.py:757
#: core/models.py:738
msgid "This user is already in this recording."
msgstr "Cet utilisateur est déjà dans cet enregistrement."
#: core/models.py:763
#: core/models.py:744
msgid "This team is already in this recording."
msgstr "Cette équipe est déjà dans cet enregistrement."
#: core/models.py:769
#: core/models.py:750
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:786
#: core/models.py:767
msgid "Create rooms"
msgstr "Créer des salles"
#: core/models.py:787
#: core/models.py:768
msgid "List rooms"
msgstr "Lister les salles"
#: core/models.py:788
#: core/models.py:769
msgid "Retrieve room details"
msgstr "Afficher les détails dune salle"
#: core/models.py:789
#: core/models.py:770
msgid "Update rooms"
msgstr "Mettre à jour les salles"
#: core/models.py:790
#: core/models.py:771
msgid "Delete rooms"
msgstr "Supprimer les salles"
#: core/models.py:803
#: core/models.py:784
msgid "Application name"
msgstr "Nom de lapplication"
#: core/models.py:804
#: core/models.py:785
msgid "Descriptive name for this application."
msgstr "Nom descriptif de cette application."
#: core/models.py:814
#: core/models.py:795
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:825
#: core/models.py:806
msgid "Application"
msgstr "Application"
#: core/models.py:826
#: core/models.py:807
msgid "Applications"
msgstr "Applications"
#: core/models.py:849
#: core/models.py:830
msgid "Enter a valid domain"
msgstr "Saisissez un domaine valide"
#: core/models.py:852
#: core/models.py:833
msgid "Domain"
msgstr "Domaine"
#: core/models.py:853
#: core/models.py:834
msgid "Email domain this application can act on behalf of."
msgstr "Domaine de messagerie au nom duquel cette application peut agir."
#: core/models.py:865
#: core/models.py:846
msgid "Application domain"
msgstr "Domaine dapplication"
#: core/models.py:866
#: core/models.py:847
msgid "Application domains"
msgstr "Domaines dapplication"
#: core/models.py:884
#: core/models.py:865
msgid "Pending"
msgstr "En attente"
#: core/models.py:885
#: core/models.py:866
msgid "Analyzing"
msgstr ""
#: core/models.py:892
#: core/models.py:873
msgid "Ready"
msgstr "Prêt"
#: core/models.py:898
#: core/models.py:879
msgid "Background image"
msgstr "Image de fond"
#: core/models.py:910
#: core/models.py:891
msgid "title"
msgstr "Titre"
#: core/models.py:934
#: core/models.py:915
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:939
#: core/models.py:920
msgid "File"
msgstr "Fichier"
#: core/models.py:940
#: core/models.py:921
msgid "Files"
msgstr "Fichiers"
#: core/models.py:1060
#: core/models.py:1041
#, 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:1070
#: core/models.py:1051
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:124
#: core/recording/event/notification.py:123
msgid "Your recording is ready"
msgstr "Votre enregistrement est prêt"
#: core/recording/event/notification.py:195
#: core/recording/event/notification.py:194
msgid "Transcription"
msgstr "Transcription"
#: core/recording/event/notification.py:206
#: core/recording/event/notification.py:204
#, python-brace-format
msgid "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}"
msgstr "Réunion \"{room}\" du {room_recording_date} à {room_recording_time}"
@@ -554,25 +538,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:151
#: core/templates/mail/html/screen_recording.html:151
#: core/templates/mail/text/invitation.txt:4
#: core/templates/mail/text/screen_recording.txt:4
#: 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
msgid "Logo email"
msgstr "Logo email"
#: core/templates/mail/html/invitation.html:181
#: core/templates/mail/text/invitation.txt:6
#: core/templates/mail/html/invitation.html:189
#: core/templates/mail/text/invitation.txt:5
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:192
#: core/templates/mail/text/invitation.txt:8
#: core/templates/mail/html/invitation.html:200
#: core/templates/mail/text/invitation.txt:7
msgid "JOIN THE CALL"
msgstr "REJOINDRE L'APPEL"
#: core/templates/mail/html/invitation.html:219
#: core/templates/mail/text/invitation.txt:14
#: core/templates/mail/html/invitation.html:227
#: core/templates/mail/text/invitation.txt:13
msgid ""
"If you can't click the button, copy and paste the URL into your browser to "
"join the call."
@@ -580,47 +564,41 @@ 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:227
#: core/templates/mail/text/invitation.txt:16
#: core/templates/mail/html/invitation.html:235
#: core/templates/mail/text/invitation.txt:15
msgid "Tips for a better experience:"
msgstr "Conseils pour une meilleure expérience :"
#: core/templates/mail/html/invitation.html:229
#: core/templates/mail/text/invitation.txt:18
#: core/templates/mail/html/invitation.html:237
#: core/templates/mail/text/invitation.txt:17
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:230
#: core/templates/mail/text/invitation.txt:19
#: core/templates/mail/html/invitation.html:238
#: core/templates/mail/text/invitation.txt:18
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:231
#: core/templates/mail/text/invitation.txt:20
#: core/templates/mail/html/invitation.html:239
#: core/templates/mail/text/invitation.txt:19
msgid "Make sure you have a stable internet connection"
msgstr "Assurez-vous d'avoir une connexion Internet stable"
#: 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
#: 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
#, python-format
msgid " Thank you for using %(brandname)s. "
msgstr " Merci d'utiliser %(brandname)s. "
#: 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
#: core/templates/mail/html/screen_recording.html:188
#: core/templates/mail/text/screen_recording.txt:6
msgid "Your recording is ready!"
msgstr "Votre enregistrement est prêt !"
#: core/templates/mail/html/screen_recording.html:187
#: core/templates/mail/text/screen_recording.txt:9
#: core/templates/mail/html/screen_recording.html:195
#: core/templates/mail/text/screen_recording.txt:8
#, python-format
msgid ""
" Your recording of \"%(room_name)s\" on %(recording_date)s at "
@@ -629,14 +607,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:187
#: core/templates/mail/text/screen_recording.txt:9
#: core/templates/mail/html/screen_recording.html:195
#: core/templates/mail/text/screen_recording.txt:8
#, 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:192
#: core/templates/mail/text/screen_recording.txt:10
#: core/templates/mail/html/screen_recording.html:200
#: core/templates/mail/text/screen_recording.txt:9
msgid ""
" Sharing the recording via link is not yet available. Only organizers can "
"download it. "
@@ -644,33 +622,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:198
#: core/templates/mail/text/screen_recording.txt:12
#: core/templates/mail/html/screen_recording.html:206
#: core/templates/mail/text/screen_recording.txt:11
msgid "To keep this recording permanently:"
msgstr "Pour conserver cet enregistrement de façon permanente :"
#: core/templates/mail/html/screen_recording.html:200
#: core/templates/mail/html/screen_recording.html:208
#, 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:201
#: core/templates/mail/text/screen_recording.txt:15
#: core/templates/mail/html/screen_recording.html:209
#: core/templates/mail/text/screen_recording.txt:14
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:202
#: core/templates/mail/text/screen_recording.txt:16
#: core/templates/mail/html/screen_recording.html:210
#: core/templates/mail/text/screen_recording.txt:15
msgid "Save the file to your preferred location"
msgstr "Enregistrez le fichier à l'emplacement de votre choix"
#: core/templates/mail/html/screen_recording.html:213
#: core/templates/mail/text/screen_recording.txt:18
#: core/templates/mail/html/screen_recording.html:221
#: core/templates/mail/text/screen_recording.txt:17
msgid "Open"
msgstr "Ouvrir"
#: core/templates/mail/html/screen_recording.html:222
#: core/templates/mail/text/screen_recording.txt:20
#: core/templates/mail/html/screen_recording.html:230
#: core/templates/mail/text/screen_recording.txt:19
#, python-format
msgid ""
" If you have any questions or need assistance, please contact our support "
@@ -679,33 +657,28 @@ msgstr ""
" Si vous avez des questions ou besoin d'assistance, veuillez contacter notre "
"équipe d'assistance à %(support_email)s. "
#: 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
#: core/templates/mail/text/screen_recording.txt:13
#, fuzzy, python-format
#| msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
msgid "Click the \"Open [%(link)s]\" link below "
msgstr "Cliquez sur le lien \"<a href=\"%(link)s\">Ouvrir</a>\" ci-dessous "
#: meet/settings.py:238
#: meet/settings.py:228
msgid "English"
msgstr "Anglais"
#: meet/settings.py:239
#: meet/settings.py:229
msgid "French"
msgstr "Français"
#: meet/settings.py:240
#: meet/settings.py:230
msgid "Dutch"
msgstr "Néerlandais"
#: meet/settings.py:241
#: meet/settings.py:231
msgid "German"
msgstr "Allemand"
#: meet/settings.py:242
#: meet/settings.py:233
msgid "Spanish"
msgstr "Espagnol"
+160 -187
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-09-02 22:52+0000\n"
"POT-Creation-Date: 2026-07-02 10:47+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:450
#: core/admin.py:300 core/admin.py:443
msgid "No owner"
msgstr "Geen eigenaar"
#: core/admin.py:303 core/admin.py:453
#: core/admin.py:303 core/admin.py:446
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:517
#: core/admin.py:510
msgid "No scopes"
msgstr "Geen scopes"
#: core/admin.py:519
#: core/admin.py:512
msgid "Scopes"
msgstr "Scopes"
@@ -110,200 +110,184 @@ msgstr "Scopes"
msgid "Creator is me"
msgstr "Maker ben ik"
#: core/api/serializers.py:108
#: core/api/serializers.py:89
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:560
#: core/api/serializers.py:534
msgid "This file extension is not allowed."
msgstr "Deze bestandsextensie is niet toegestaan."
#: core/api/viewsets.py:1268
#: core/api/viewsets.py:1222
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:38
#: core/models.py:37
msgid "Member"
msgstr "Lid"
#: core/models.py:39
#: core/models.py:38
msgid "Administrator"
msgstr "Beheerder"
#: core/models.py:40
#: core/models.py:39
msgid "Owner"
msgstr "Eigenaar"
#: core/models.py:56
#: core/models.py:55
msgid "Initiated"
msgstr "Gestart"
#: core/models.py:57
#: core/models.py:56
msgid "Active"
msgstr "Actief"
#: core/models.py:58
#: core/models.py:57
msgid "Stopped"
msgstr "Gestopt"
#: core/models.py:59
#: core/models.py:58
msgid "Saved"
msgstr "Opgeslagen"
#: core/models.py:60
#: core/models.py:59
msgid "Aborted"
msgstr "Afgebroken"
#: core/models.py:61
#: core/models.py:60
msgid "Failed to Start"
msgstr "Starten mislukt"
#: core/models.py:62
#: core/models.py:61
msgid "Failed to Stop"
msgstr "Stoppen mislukt"
#: core/models.py:63
#: core/models.py:62
msgid "Notification succeeded"
msgstr "Notificatie geslaagd"
#: core/models.py:66
#: core/models.py:65
msgid "External process successful"
msgstr "Externe procedure succesvol"
#: core/models.py:68
#: core/models.py:67
msgid "External process failed"
msgstr "Het externe proces is mislukt."
#: core/models.py:97
#: core/models.py:96
msgid "SCREEN_RECORDING"
msgstr "SCHERM_OPNAME"
#: core/models.py:98
#: core/models.py:97
msgid "TRANSCRIPT"
msgstr "TRANSCRIPT"
#: core/models.py:104
#: core/models.py:103
msgid "Public Access"
msgstr "Openbare toegang"
#: core/models.py:105
#: core/models.py:104
msgid "Trusted Access"
msgstr "Vertrouwde toegang"
#: core/models.py:106
#: core/models.py:105
msgid "Restricted Access"
msgstr "Beperkte toegang"
#: core/models.py:118
#: core/models.py:117
msgid "id"
msgstr "id"
#: core/models.py:119
#: core/models.py:118
msgid "primary key for the record as UUID"
msgstr "primaire sleutel voor het record als UUID"
#: core/models.py:125
#: core/models.py:124
msgid "created on"
msgstr "aangemaakt op"
#: core/models.py:126
#: core/models.py:125
msgid "date and time at which a record was created"
msgstr "datum en tijd waarop een record werd aangemaakt"
#: core/models.py:131
#: core/models.py:130
msgid "updated on"
msgstr "bijgewerkt op"
#: core/models.py:132
#: core/models.py:131
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:150
#: 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
msgid "sub"
msgstr "sub"
#: core/models.py:152
#: core/models.py:159
msgid ""
"Optional for pending users; required upon account activation. 255 characters "
"or fewer. Printable ASCII characters only."
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
msgstr ""
"Optioneel voor gebruikers in afwachting; verplicht bij het activeren van het "
"account. Maximaal 255 tekens. Alleen afdrukbare ASCII-tekens."
"Optioneel voor gebruikers in afwachting; vereist bij accountactivering. "
"Maximum 255 tekens. Alleen letters, cijfers en @/./+/-/_ toegestaan."
#: 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
#: core/models.py:168
msgid "identity email address"
msgstr "identiteit e-mailadres"
#: core/models.py:166
#: core/models.py:173
msgid "admin email address"
msgstr "beheerder e-mailadres"
#: core/models.py:168
#: core/models.py:175
msgid "full name"
msgstr "volledige naam"
#: core/models.py:170
#: core/models.py:177
msgid "short name"
msgstr "korte naam"
#: core/models.py:176
#: core/models.py:183
msgid "language"
msgstr "taal"
#: core/models.py:177
#: core/models.py:184
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:183
#: core/models.py:190
msgid "The timezone in which the user wants to see times."
msgstr "De tijdzone waarin de gebruiker tijden wil zien."
#: 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
#: core/models.py:193
msgid "device"
msgstr "apparaat"
#: core/models.py:207
#: core/models.py:195
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:210
#: core/models.py:198
msgid "staff status"
msgstr "personeelsstatus"
#: core/models.py:212
#: core/models.py:200
msgid "Whether the user can log into this admin site."
msgstr "Of de gebruiker kan inloggen op deze beheersite."
#: core/models.py:215
#: core/models.py:203
msgid "active"
msgstr "actief"
#: core/models.py:218
#: core/models.py:206
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
@@ -311,64 +295,64 @@ msgstr ""
"Of deze gebruiker als actief moet worden behandeld. Deselecteer dit in "
"plaats van accounts te verwijderen."
#: core/models.py:231
#: core/models.py:219
msgid "user"
msgstr "gebruiker"
#: core/models.py:232
#: core/models.py:220
msgid "users"
msgstr "gebruikers"
#: core/models.py:298
#: core/models.py:286
msgid "Resource"
msgstr "Bron"
#: core/models.py:299
#: core/models.py:287
msgid "Resources"
msgstr "Bronnen"
#: core/models.py:357
#: core/models.py:345
msgid "Resource access"
msgstr "Brontoegang"
#: core/models.py:358
#: core/models.py:346
msgid "Resource accesses"
msgstr "Brontoegangsrechten"
#: core/models.py:364
#: core/models.py:352
msgid "Resource access with this User and Resource already exists."
msgstr "Brontoegang met deze gebruiker en bron bestaat al."
#: core/models.py:421
#: core/models.py:409
msgid "Visio room configuration"
msgstr "Visio-ruimteconfiguratie"
#: core/models.py:422
#: core/models.py:410
msgid "Values for Visio parameters to configure the room."
msgstr "Waarden voor Visio-parameters om de ruimte te configureren."
#: core/models.py:429
#: core/models.py:417
msgid "Room PIN code"
msgstr "Pincode van de kamer"
#: core/models.py:430
#: core/models.py:418
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:436 core/models.py:597
#: core/models.py:424 core/models.py:578
msgid "Room"
msgstr "Ruimte"
#: core/models.py:437
#: core/models.py:425
msgid "Rooms"
msgstr "Ruimtes"
#: core/models.py:608
#: core/models.py:589
msgid "Worker ID"
msgstr "Worker ID"
#: core/models.py:610
#: core/models.py:591
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
@@ -376,152 +360,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:618
#: core/models.py:599
msgid "Recording mode"
msgstr "Opnamemodus"
#: core/models.py:619
#: core/models.py:600
msgid "Defines the mode of recording being called."
msgstr "Definieert de modus van opname die wordt aangeroepen."
#: core/models.py:624 core/models.py:625
#: core/models.py:605 core/models.py:606
msgid "Recording options"
msgstr "Opnameopties"
#: core/models.py:632
#: core/models.py:613
msgid "External Process ID"
msgstr "Externe proces-ID"
#: core/models.py:633
#: core/models.py:614
msgid "ID of the external process associated with the recording."
msgstr "ID van het externe proces dat aan de opname is gekoppeld."
#: core/models.py:639
#: core/models.py:620
msgid "Recording"
msgstr "Opname"
#: core/models.py:640
#: core/models.py:621
msgid "Recordings"
msgstr "Opnames"
#: core/models.py:750
#: core/models.py:731
msgid "Recording/user relation"
msgstr "Opname/gebruiker-relatie"
#: core/models.py:751
#: core/models.py:732
msgid "Recording/user relations"
msgstr "Opname/gebruiker-relaties"
#: core/models.py:757
#: core/models.py:738
msgid "This user is already in this recording."
msgstr "Deze gebruiker is al in deze opname."
#: core/models.py:763
#: core/models.py:744
msgid "This team is already in this recording."
msgstr "Dit team is al in deze opname."
#: core/models.py:769
#: core/models.py:750
msgid "Either user or team must be set, not both."
msgstr "Ofwel gebruiker of team moet worden ingesteld, niet beide."
#: core/models.py:786
#: core/models.py:767
msgid "Create rooms"
msgstr "Ruimtes aanmaken"
#: core/models.py:787
#: core/models.py:768
msgid "List rooms"
msgstr "Ruimtes weergeven"
#: core/models.py:788
#: core/models.py:769
msgid "Retrieve room details"
msgstr "Details van een ruimte ophalen"
#: core/models.py:789
#: core/models.py:770
msgid "Update rooms"
msgstr "Ruimtes bijwerken"
#: core/models.py:790
#: core/models.py:771
msgid "Delete rooms"
msgstr "Ruimtes verwijderen"
#: core/models.py:803
#: core/models.py:784
msgid "Application name"
msgstr "Naam van de applicatie"
#: core/models.py:804
#: core/models.py:785
msgid "Descriptive name for this application."
msgstr "Beschrijvende naam voor deze applicatie."
#: core/models.py:814
#: core/models.py:795
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:825
#: core/models.py:806
msgid "Application"
msgstr "Applicatie"
#: core/models.py:826
#: core/models.py:807
msgid "Applications"
msgstr "Applicaties"
#: core/models.py:849
#: core/models.py:830
msgid "Enter a valid domain"
msgstr "Voer een geldig domein in"
#: core/models.py:852
#: core/models.py:833
msgid "Domain"
msgstr "Domein"
#: core/models.py:853
#: core/models.py:834
msgid "Email domain this application can act on behalf of."
msgstr "E-maildomein namens welke deze applicatie kan handelen."
#: core/models.py:865
#: core/models.py:846
msgid "Application domain"
msgstr "Applicatiedomein"
#: core/models.py:866
#: core/models.py:847
msgid "Application domains"
msgstr "Applicatiedomeinen"
#: core/models.py:884
#: core/models.py:865
msgid "Pending"
msgstr "In afwachting"
#: core/models.py:885
#: core/models.py:866
msgid "Analyzing"
msgstr ""
#: core/models.py:892
#: core/models.py:873
msgid "Ready"
msgstr "Klaar"
#: core/models.py:898
#: core/models.py:879
msgid "Background image"
msgstr "Achtergrondafbeelding"
#: core/models.py:910
#: core/models.py:891
msgid "title"
msgstr "Titel"
#: core/models.py:934
#: core/models.py:915
msgid "Malware detection info when the analysis status is unsafe."
msgstr "Informatie over malwaredetectie wanneer de analysestatus onveilig is."
#: core/models.py:939
#: core/models.py:920
msgid "File"
msgstr "Bestand"
#: core/models.py:940
#: core/models.py:921
msgid "Files"
msgstr "Bestanden"
#: core/models.py:1060
#: core/models.py:1041
msgid "This file is already hard deleted."
msgstr "Dit bestand is al definitief verwijderd."
#: core/models.py:1070
#: core/models.py:1051
#, 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."
@@ -529,15 +513,15 @@ msgstr ""
"Om een bestand definitief te verwijderen, moet het eerst zacht verwijderd "
"zijn."
#: core/recording/event/notification.py:124
#: core/recording/event/notification.py:123
msgid "Your recording is ready"
msgstr "Je opname is klaar"
#: core/recording/event/notification.py:195
#: core/recording/event/notification.py:194
msgid "Transcription"
msgstr "Transcriptie"
#: core/recording/event/notification.py:206
#: core/recording/event/notification.py:204
#, python-brace-format
msgid "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}"
msgstr ""
@@ -548,25 +532,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:151
#: core/templates/mail/html/screen_recording.html:151
#: core/templates/mail/text/invitation.txt:4
#: core/templates/mail/text/screen_recording.txt:4
#: 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
msgid "Logo email"
msgstr "Logo e-mail"
#: core/templates/mail/html/invitation.html:181
#: core/templates/mail/text/invitation.txt:6
#: core/templates/mail/html/invitation.html:189
#: core/templates/mail/text/invitation.txt:5
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:192
#: core/templates/mail/text/invitation.txt:8
#: core/templates/mail/html/invitation.html:200
#: core/templates/mail/text/invitation.txt:7
msgid "JOIN THE CALL"
msgstr "NEEM DEEL AAN DE OPROEP"
#: core/templates/mail/html/invitation.html:219
#: core/templates/mail/text/invitation.txt:14
#: core/templates/mail/html/invitation.html:227
#: core/templates/mail/text/invitation.txt:13
msgid ""
"If you can't click the button, copy and paste the URL into your browser to "
"join the call."
@@ -574,47 +558,41 @@ 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:227
#: core/templates/mail/text/invitation.txt:16
#: core/templates/mail/html/invitation.html:235
#: core/templates/mail/text/invitation.txt:15
msgid "Tips for a better experience:"
msgstr "Tips voor een betere ervaring:"
#: core/templates/mail/html/invitation.html:229
#: core/templates/mail/text/invitation.txt:18
#: core/templates/mail/html/invitation.html:237
#: core/templates/mail/text/invitation.txt:17
msgid "Use Chrome or Firefox for better call quality"
msgstr "Gebruik Chrome of Firefox voor betere gesprekskwaliteit"
#: core/templates/mail/html/invitation.html:230
#: core/templates/mail/text/invitation.txt:19
#: core/templates/mail/html/invitation.html:238
#: core/templates/mail/text/invitation.txt:18
msgid "Test your microphone and camera before joining"
msgstr "Test je microfoon en camera voordat je deelneemt"
#: core/templates/mail/html/invitation.html:231
#: core/templates/mail/text/invitation.txt:20
#: core/templates/mail/html/invitation.html:239
#: core/templates/mail/text/invitation.txt:19
msgid "Make sure you have a stable internet connection"
msgstr "Zorg ervoor dat je een stabiele internetverbinding hebt"
#: 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
#: 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
#, python-format
msgid " Thank you for using %(brandname)s. "
msgstr " Bedankt voor het gebruik van %(brandname)s. "
#: 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
#: core/templates/mail/html/screen_recording.html:188
#: core/templates/mail/text/screen_recording.txt:6
msgid "Your recording is ready!"
msgstr "Je opname is klaar!"
#: core/templates/mail/html/screen_recording.html:187
#: core/templates/mail/text/screen_recording.txt:9
#: core/templates/mail/html/screen_recording.html:195
#: core/templates/mail/text/screen_recording.txt:8
#, python-format
msgid ""
" Your recording of \"%(room_name)s\" on %(recording_date)s at "
@@ -623,14 +601,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:187
#: core/templates/mail/text/screen_recording.txt:9
#: core/templates/mail/html/screen_recording.html:195
#: core/templates/mail/text/screen_recording.txt:8
#, 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:192
#: core/templates/mail/text/screen_recording.txt:10
#: core/templates/mail/html/screen_recording.html:200
#: core/templates/mail/text/screen_recording.txt:9
msgid ""
" Sharing the recording via link is not yet available. Only organizers can "
"download it. "
@@ -638,33 +616,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:198
#: core/templates/mail/text/screen_recording.txt:12
#: core/templates/mail/html/screen_recording.html:206
#: core/templates/mail/text/screen_recording.txt:11
msgid "To keep this recording permanently:"
msgstr "Om deze opname permanent te bewaren:"
#: core/templates/mail/html/screen_recording.html:200
#: core/templates/mail/html/screen_recording.html:208
#, 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:201
#: core/templates/mail/text/screen_recording.txt:15
#: core/templates/mail/html/screen_recording.html:209
#: core/templates/mail/text/screen_recording.txt:14
msgid "Use the \"Download\" button in the interface "
msgstr "Gebruik de \"Download\"-knop in de interface "
#: core/templates/mail/html/screen_recording.html:202
#: core/templates/mail/text/screen_recording.txt:16
#: core/templates/mail/html/screen_recording.html:210
#: core/templates/mail/text/screen_recording.txt:15
msgid "Save the file to your preferred location"
msgstr "Sla het bestand op naar je gewenste locatie"
#: core/templates/mail/html/screen_recording.html:213
#: core/templates/mail/text/screen_recording.txt:18
#: core/templates/mail/html/screen_recording.html:221
#: core/templates/mail/text/screen_recording.txt:17
msgid "Open"
msgstr "Openen"
#: core/templates/mail/html/screen_recording.html:222
#: core/templates/mail/text/screen_recording.txt:20
#: core/templates/mail/html/screen_recording.html:230
#: core/templates/mail/text/screen_recording.txt:19
#, python-format
msgid ""
" If you have any questions or need assistance, please contact our support "
@@ -673,33 +651,28 @@ 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/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
#: core/templates/mail/text/screen_recording.txt:13
#, fuzzy, python-format
#| msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
msgid "Click the \"Open [%(link)s]\" link below "
msgstr "Klik op de \"<a href=\"%(link)s\">Openen</a>\"-link hieronder "
#: meet/settings.py:238
#: meet/settings.py:228
msgid "English"
msgstr "Engels"
#: meet/settings.py:239
#: meet/settings.py:229
msgid "French"
msgstr "Frans"
#: meet/settings.py:240
#: meet/settings.py:230
msgid "Dutch"
msgstr "Nederlands"
#: meet/settings.py:241
#: meet/settings.py:231
msgid "German"
msgstr "Duits"
#: meet/settings.py:242
#: meet/settings.py:233
msgid "Spanish"
msgstr "Spaans"
@@ -8,6 +8,28 @@ const IGNORED_EXCEPTION_PATTERNS = [
// the close reason is already logged by the SDK.
// See: https://github.com/livekit/client-sdk-js/issues/2062
/^Event captured as exception with keys: isTrusted$/,
// MediaPipe's WASM writes its native logs to stderr, which Emscripten
// routes to console.error, which PostHog's console capture then promotes
// to an $exception — even though nothing was thrown. Two flavors:
//
// 1. "INFO: ..." lines are purely informational. In particular
// "INFO: Created TensorFlow Lite XNNPACK delegate for CPU." is a
// SUCCESS message: TFLite prints it when it lazily initializes CPU
// inference on the first segmented frame. It fires on every effects
// init, on every browser and delegate (the GPU delegate still
// instantiates the CPU/XNNPACK delegate for non-delegated ops), so it
// was our single noisiest "error" while carrying zero signal.
/^INFO: /,
//
// 2. absl-formatted log lines, e.g.
// "E0901 19:21:45.443000 1880752 gl_graph_runner_internal.cc:260]
// StartGraph failed: ..."
// (severity letter E/W/I/F + MMDD + timestamp). These are the stderr
// *copies* of failures that MediaPipe also raises as real JS
// exceptions, which we already capture via reportError / thrown
// errors. Dropping them de-duplicates each incident (previously
// counted 2-3x) without losing the actual error report.
/^[EWIF]\d{4} \d{2}:\d{2}:\d{2}\./,
]
const shouldIgnoreException = (value: unknown): boolean =>
@@ -17,7 +17,7 @@ import {
type ProcessorType,
MEDIAPIPE_PATH_WASM,
} from '.'
import { captureEvent } from '@/features/analytics/telemetry.ts'
import { captureEvent, reportError } from '@/features/analytics/telemetry'
const PROCESSING_WIDTH = 256
const PROCESSING_HEIGHT = 144
@@ -26,6 +26,40 @@ const SEGMENTATION_MASK_CANVAS_ID = 'background-blur-local-segmentation'
const BLUR_CANVAS_ID = 'background-blur-local'
const DEFAULT_BLUR = '10'
const CONCEALING_BLUR = '25'
const FRAME_INTERVAL_MS = 1000 / 30
// After this many consecutive failed frames, stop segmenting and fall back to
// publishing a fully blurred frame: the user keeps a live camera instead of a
// frozen one, without ever exposing the surroundings they chose to conceal.
const MAX_CONSECUTIVE_ERRORS = 5
let webgl2Supported: boolean | undefined
/**
* MediaPipe's ImageSegmenter requires a WebGL2 context on the web even with
* `delegate: 'CPU'` (only inference runs on CPU; the mask post-processing in
* TensorsToSegmentationCalculator is GL-based). Without this check, machines
* with WebGL disabled or blocklisted fail at StartGraph with
* `emscripten_webgl_create_context() returned error 0`.
*
* The result is cached and the probe context is explicitly released so that
* repeated support checks do not count against the browser's limit on live
* WebGL contexts.
*/
const isWebGL2Supported = () => {
if (webgl2Supported === undefined) {
try {
const canvas = document.createElement('canvas')
const gl = canvas.getContext('webgl2')
webgl2Supported = !!gl
gl?.getExtension('WEBGL_lose_context')?.loseContext()
} catch {
webgl2Supported = false
}
}
return webgl2Supported
}
/**
* This implementation of video blurring is made to be run on CPU for browser that are
@@ -42,14 +76,12 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
source?: MediaStreamTrack
sourceSettings?: MediaTrackSettings
videoElement?: HTMLVideoElement
videoElementLoaded?: boolean
// Canvas containing the video processing result, of which we extract as stream.
outputCanvas?: HTMLCanvasElement
outputCanvasCtx?: CanvasRenderingContext2D
imageSegmenter?: ImageSegmenter
imageSegmenterResult?: ImageSegmenterResult
// Canvas used for resizing video source and projecting mask.
segmentationMaskCanvas?: HTMLCanvasElement
@@ -66,6 +98,13 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
type: ProcessorType
virtualBackgroundImage?: HTMLImageElement
private virtualBackgroundImagePath?: string
private destroyed = false
private degraded = false
private consecutiveErrors = 0
private processing?: Promise<void>
private onVideoLoaded?: () => void
constructor(opts: ProcessorConfig) {
this.name = 'blur'
this.options = opts
@@ -73,7 +112,10 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
}
static get isSupported() {
return navigator.userAgent.toLowerCase().includes('firefox')
return (
navigator.userAgent.toLowerCase().includes('firefox') &&
isWebGL2Supported()
)
}
async init(opts: ProcessorOptions<Track.Kind>) {
@@ -81,6 +123,10 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
throw new Error('Element is required for processing')
}
this.destroyed = false
this.degraded = false
this.consecutiveErrors = 0
this.source = opts.track as MediaStreamTrack
this.sourceSettings = this.source!.getSettings()
this.videoElement = opts.element as HTMLVideoElement
@@ -97,26 +143,56 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
this.processedTrack = tracks[0]
this.segmentationMask = new ImageData(PROCESSING_WIDTH, PROCESSING_HEIGHT)
const t0 = performance.now()
await this.initSegmenter()
const segmenterInitMs = Math.round(performance.now() - t0)
this._initWorker()
captureEvent('firefox-blurring-init', {})
captureEvent('legacy-background-processor', {
effect_type: this.options.type,
hw_concurrency: navigator.hardwareConcurrency,
video_width: this.videoElement?.videoWidth,
video_height: this.videoElement?.videoHeight,
segmenter_init_ms: segmenterInitMs,
})
}
_initVirtualBackgroundImage() {
if (this.options.type !== 'virtual') {
if (this.options.type !== 'virtual' || !this.options.imagePath) {
return
}
const needsUpdate =
this.options.imagePath &&
if (
this.virtualBackgroundImage &&
this.virtualBackgroundImage.src !== this.options.imagePath
if (this.options.imagePath || needsUpdate) {
this.virtualBackgroundImage = document.createElement('img')
this.virtualBackgroundImage.crossOrigin = 'anonymous'
this.virtualBackgroundImage.src = this.options.imagePath!
this.virtualBackgroundImagePath === this.options.imagePath
) {
return
}
const image = document.createElement('img')
image.crossOrigin = 'anonymous'
image.src = this.options.imagePath
// Surface load failures once instead of letting drawImage throw on a
// broken image inside the processing loop.
image.decode().catch((error) => {
reportError('effects_processor_failure', error, {
context: 'Failed to load virtual background image',
image_path:
this.options.type === 'virtual' ? this.options.imagePath : undefined,
})
})
this.virtualBackgroundImage = image
this.virtualBackgroundImagePath = this.options.imagePath
}
_isVirtualBackgroundImageReady() {
return (
!!this.virtualBackgroundImage &&
this.virtualBackgroundImage.complete &&
this.virtualBackgroundImage.naturalWidth > 0
)
}
async update(opts: ProcessorConfig): Promise<void> {
@@ -129,26 +205,26 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
name: 'Blurring',
})
this.timerWorker.onmessage = (data) => this.onTimerMessage(data)
// When hiding camera then showing it again, the onloadeddata callback is not fired again.
if (this.videoElementLoaded) {
this.timerWorker!.postMessage({
id: SET_TIMEOUT,
timeMs: 1000 / 30,
})
const startLoop = () => {
this.onVideoLoaded = undefined
this._syncOutputCanvasSize()
this._scheduleNextFrame()
}
if (this.videoElement!.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
startLoop()
} else {
this.videoElement!.onloadeddata = () => {
this.videoElementLoaded = true
this.timerWorker!.postMessage({
id: SET_TIMEOUT,
timeMs: 1000 / 30,
})
}
this.onVideoLoaded = startLoop
this.videoElement!.addEventListener('loadeddata', this.onVideoLoaded, {
once: true,
})
}
}
onTimerMessage(response: { data: { id: number } }) {
if (response.data.id === TIMEOUT_TICK) {
this.process()
this.processing = this.process()
}
}
@@ -194,30 +270,47 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
*/
async segment() {
const startTimeMs = performance.now()
return new Promise<void>((resolve) => {
this.imageSegmenter!.segmentForVideo(
this.sourceImageData!,
startTimeMs,
(result: ImageSegmenterResult) => {
this.imageSegmenterResult = result
resolve()
}
)
return new Promise<void>((resolve, reject) => {
try {
this.imageSegmenter!.segmentForVideo(
this.sourceImageData!,
startTimeMs,
(result: ImageSegmenterResult) => {
try {
// The mask is only valid for the duration of this callback:
// MediaPipe frees the underlying WASM memory as soon as it
// returns, so the data must be copied out synchronously here.
this._applyMaskToAlphaChannel(result)
resolve()
} catch (error) {
reject(error)
}
}
)
} catch (error) {
reject(error)
}
})
}
/**
* TODO: future improvement with WebGL.
*/
async blur() {
if (this.options.type !== 'blur') {
throw new Error('Blurring is only supported for blur background')
_applyMaskToAlphaChannel(result: ImageSegmenterResult) {
const categoryMask = result.categoryMask
if (!categoryMask) {
return
}
const mask = this.imageSegmenterResult!.categoryMask!.getAsUint8Array()
for (let i = 0; i < mask.length; ++i) {
this.segmentationMask!.data[i * 4 + 3] = 255 - mask[i]
const mask = categoryMask.getAsUint8Array()
const alpha = this.segmentationMask!.data
const length = Math.min(mask.length, alpha.length / 4)
for (let i = 0; i < length; ++i) {
alpha[i * 4 + 3] = 255 - mask[i]
}
}
/**
* Composite the segmentation mask over the output canvas: mask first, then
* the clear body, leaving the background to be filled by the caller.
*/
_compositeMaskAndBody() {
this.segmentationMaskCanvasCtx!.putImageData(this.segmentationMask!, 0, 0)
this.outputCanvasCtx!.globalCompositeOperation = 'copy'
@@ -240,6 +333,16 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
this.outputCanvasCtx!.globalCompositeOperation = 'source-in'
this.outputCanvasCtx!.filter = 'none'
this.outputCanvasCtx!.drawImage(this.videoElement!, 0, 0)
}
/**
* TODO: future improvement with WebGL.
*/
async blur() {
if (this.options.type !== 'blur') {
throw new Error('Blurring is only supported for blur background')
}
this._compositeMaskAndBody()
// Draw blurry background.
this.outputCanvasCtx!.globalCompositeOperation = 'destination-over'
@@ -251,87 +354,150 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
* TODO: future improvement with WebGL.
*/
async drawVirtualBackground() {
const mask = this.imageSegmenterResult!.categoryMask!.getAsUint8Array()
for (let i = 0; i < mask.length; ++i) {
this.segmentationMask!.data[i * 4 + 3] = 255 - mask[i]
}
this._compositeMaskAndBody()
this.segmentationMaskCanvasCtx!.putImageData(this.segmentationMask!, 0, 0)
this.outputCanvasCtx!.globalCompositeOperation = 'copy'
this.outputCanvasCtx!.filter = 'blur(8px)'
// Put opacity mask.
this.outputCanvasCtx!.drawImage(
this.segmentationMaskCanvas!,
0,
0,
PROCESSING_WIDTH,
PROCESSING_HEIGHT,
0,
0,
this.videoElement!.videoWidth,
this.videoElement!.videoHeight
)
// Draw clear body.
this.outputCanvasCtx!.globalCompositeOperation = 'source-in'
this.outputCanvasCtx!.filter = 'none'
this.outputCanvasCtx!.drawImage(this.videoElement!, 0, 0)
// Draw virtual background.
this.outputCanvasCtx!.globalCompositeOperation = 'destination-over'
this.outputCanvasCtx!.drawImage(
this.virtualBackgroundImage!,
0,
0,
this.outputCanvas!.width,
this.outputCanvas!.height
)
this.outputCanvasCtx!.filter = 'none'
if (this._isVirtualBackgroundImageReady()) {
// Draw virtual background.
this.outputCanvasCtx!.drawImage(
this.virtualBackgroundImage!,
0,
0,
this.outputCanvas!.width,
this.outputCanvas!.height
)
} else {
// Image not decoded (yet, or failed to load): fill the background with
// a heavy blur instead. Never fall back to the raw video here — the
// user selected this effect to conceal their surroundings, so the
// fallback must keep concealing them.
this.outputCanvasCtx!.filter = `blur(${CONCEALING_BLUR}px)`
this.outputCanvasCtx!.drawImage(this.videoElement!, 0, 0)
}
}
/**
* Draw the whole frame heavily blurred (person included). Used when
* segmentation is broken: the outgoing video keeps flowing instead of
* freezing on a stale frame, while the surroundings the user chose to
* conceal stay concealed. Requires no segmenter, only one filtered draw.
*/
_drawDegradedFrame() {
this.outputCanvasCtx!.globalCompositeOperation = 'copy'
this.outputCanvasCtx!.filter = `blur(${CONCEALING_BLUR}px)`
this.outputCanvasCtx!.drawImage(this.videoElement!, 0, 0)
this.outputCanvasCtx!.filter = 'none'
}
async process() {
await this.sizeSource()
await this.segment()
if (this.options.type === 'blur') {
await this.blur()
} else {
await this.drawVirtualBackground()
if (this.destroyed) {
return
}
this.timerWorker!.postMessage({
try {
this._syncOutputCanvasSize()
// No decoded frame available (e.g. right after a device switch): skip
// this tick rather than processing a 0x0 source.
if (
!this.videoElement ||
this.videoElement.videoWidth === 0 ||
this.videoElement.videoHeight === 0
) {
this._scheduleNextFrame()
return
}
if (this.degraded) {
this._drawDegradedFrame()
this._scheduleNextFrame()
return
}
await this.sizeSource()
await this.segment()
if (this.destroyed) {
return
}
if (this.options.type === 'blur') {
await this.blur()
} else {
await this.drawVirtualBackground()
}
this.consecutiveErrors = 0
} catch (error) {
if (this.destroyed) {
return
}
this.consecutiveErrors += 1
if (this.consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
// Degrade to a fully blurred frame: a live camera beats a frozen
// one, and concealment must survive the failure.
this.degraded = true
reportError('effects_processor_failure', error, {
context:
'Background processing failed repeatedly, falling back to fully blurred video',
consecutive_errors: this.consecutiveErrors,
})
this.imageSegmenter?.close()
this.imageSegmenter = undefined
}
}
this._scheduleNextFrame()
}
_scheduleNextFrame() {
if (this.destroyed) {
return
}
this.timerWorker?.postMessage({
id: SET_TIMEOUT,
timeMs: 1000 / 30,
timeMs: FRAME_INTERVAL_MS,
})
}
_createMainCanvas() {
this.outputCanvas = document.querySelector(
'canvas#background-blur-local'
) as HTMLCanvasElement
if (!this.outputCanvas) {
this.outputCanvas = this._createCanvas(
BLUR_CANVAS_ID,
this.sourceSettings!.width!,
this.sourceSettings!.height!
)
/**
* Keep the output canvas in sync with the actual decoded video dimensions.
* `MediaStreamTrack.getSettings()` can be incomplete or stale on Firefox,
* so the video element is the source of truth.
*/
_syncOutputCanvasSize() {
const width = this.videoElement?.videoWidth
const height = this.videoElement?.videoHeight
if (!width || !height || !this.outputCanvas) {
return
}
if (
this.outputCanvas.width !== width ||
this.outputCanvas.height !== height
) {
this.outputCanvas.width = width
this.outputCanvas.height = height
}
}
_createMainCanvas() {
const width =
this.sourceSettings?.width || this.videoElement?.videoWidth || 1280
const height =
this.sourceSettings?.height || this.videoElement?.videoHeight || 720
this.outputCanvas = this._createCanvas(BLUR_CANVAS_ID, width, height)
this.outputCanvasCtx = this.outputCanvas.getContext('2d')!
}
_createMaskCanvas() {
this.segmentationMaskCanvas = document.querySelector(
`#${SEGMENTATION_MASK_CANVAS_ID}`
) as HTMLCanvasElement
if (!this.segmentationMaskCanvas) {
this.segmentationMaskCanvas = this._createCanvas(
SEGMENTATION_MASK_CANVAS_ID,
PROCESSING_WIDTH,
PROCESSING_HEIGHT
)
}
this.segmentationMaskCanvasCtx =
this.segmentationMaskCanvas.getContext('2d')!
this.segmentationMaskCanvas = this._createCanvas(
SEGMENTATION_MASK_CANVAS_ID,
PROCESSING_WIDTH,
PROCESSING_HEIGHT
)
// getImageData is called on this canvas 30 times per second: opt out of
// GPU backing to avoid a costly readback on every frame.
this.segmentationMaskCanvasCtx = this.segmentationMaskCanvas.getContext(
'2d',
{ willReadFrequently: true }
)!
}
_createCanvas(id: string, width: number, height: number) {
@@ -348,11 +514,39 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
}
async destroy() {
this.destroyed = true
this.timerWorker?.postMessage({
id: CLEAR_TIMEOUT,
})
// Let any in-flight frame finish before releasing the resources it uses,
// so segmentForVideo is never called on a closed segmenter.
try {
await this.processing
} catch {
// Failures are already handled inside process().
}
this.processing = undefined
if (this.onVideoLoaded && this.videoElement) {
this.videoElement.removeEventListener('loadeddata', this.onVideoLoaded)
}
this.onVideoLoaded = undefined
this.timerWorker?.terminate()
this.timerWorker = undefined
this.imageSegmenter?.close()
this.imageSegmenter = undefined
this.processedTrack?.stop()
this.processedTrack = undefined
this.outputCanvas = undefined
this.outputCanvasCtx = undefined
this.segmentationMaskCanvas = undefined
this.segmentationMaskCanvasCtx = undefined
this.sourceImageData = undefined
}
}
@@ -6,6 +6,7 @@ import type { Track, TrackProcessor } from 'livekit-client'
import { BackgroundCustomProcessor } from './BackgroundCustomProcessor'
import { UnifiedBackgroundTrackProcessor } from './UnifiedBackgroundTrackProcessor'
import { FaceLandmarksOptions } from './FaceLandmarksProcessor'
import { captureEvent } from '@/features/analytics/telemetry'
export const SELFIE_SEGMENTER_MODEL_PATH =
'/assets/mediapipe/models/selfie_segmenter_landscape.tflite'
@@ -31,15 +32,29 @@ export interface BackgroundProcessorInterface extends TrackProcessor<Track.Kind>
options: ProcessorConfig
}
let unsupportedReported = false
export class BackgroundProcessorFactory {
private static _isSupported?: boolean
static hasModernApiSupport() {
return ProcessorWrapper.hasModernApiSupport
}
static isSupported() {
return (
supportsBackgroundProcessors() || BackgroundCustomProcessor.isSupported
)
if (this._isSupported === undefined) {
this._isSupported =
supportsBackgroundProcessors() || BackgroundCustomProcessor.isSupported
}
if (!this._isSupported && !unsupportedReported) {
unsupportedReported = true
captureEvent('background-processor-unsupported', {
path: 'isSupported',
})
}
return this._isSupported
}
static getProcessor(
@@ -48,6 +63,8 @@ export class BackgroundProcessorFactory {
const isBlur = config.type === ProcessorType.BLUR
const isVirtual = config.type === ProcessorType.VIRTUAL
return new BackgroundCustomProcessor(config)
if (!isBlur && !isVirtual) return undefined
if (supportsBackgroundProcessors()) {
@@ -58,6 +75,12 @@ export class BackgroundProcessorFactory {
return new BackgroundCustomProcessor(config)
}
if (!unsupportedReported) {
captureEvent('background-processor-unsupported', {
path: 'getProcessor',
})
}
return undefined
}
@@ -2,7 +2,7 @@ import { useTranslation } from 'react-i18next'
import { RiHand } from '@remixicon/react'
import { ToggleButton } from '@/primitives'
import { css } from '@/styled-system/css'
import { useIsSpeaking, useRoomContext } from '@livekit/components-react'
import { useRoomContext } from '@livekit/components-react'
import { useRaisedHand } from '@/features/rooms/livekit/hooks/useRaisedHand'
import { useEffect, useRef, useState } from 'react'
import {
@@ -25,11 +25,11 @@ export const HandToggle = ({
const { t } = useTranslation('rooms', { keyPrefix: 'controls.hand' })
const room = useRoomContext()
const { isHandRaised, toggleRaisedHand, lowerHand } = useRaisedHand({
const { isHandRaised, toggleRaisedHand } = useRaisedHand({
participant: room.localParticipant,
})
const isSpeaking = useIsSpeaking(room.localParticipant)
const isSpeaking = room.localParticipant.isSpeaking
const speakingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [hasShownToast, setHasShownToast] = useState(false)
@@ -57,10 +57,9 @@ export const HandToggle = ({
if (shouldShowToast && !speakingTimerRef.current) {
speakingTimerRef.current = setTimeout(() => {
speakingTimerRef.current = null
setHasShownToast(true)
const onClose = () => {
lowerHand()
if (isHandRaised) toggleRaisedHand()
resetToastState()
}
showLowerHandToast(room.localParticipant, onClose)
@@ -71,17 +70,7 @@ export const HandToggle = ({
speakingTimerRef.current = null
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSpeaking, isHandRaised, hasShownToast, lowerHand])
// Clear any pending timer on unmount
useEffect(() => {
return () => {
if (speakingTimerRef.current) {
clearTimeout(speakingTimerRef.current)
speakingTimerRef.current = null
}
}
}, [])
}, [isSpeaking, isHandRaised, hasShownToast, toggleRaisedHand])
const tooltipLabel = isHandRaised ? 'lower' : 'raise'
@@ -25,7 +25,11 @@ import {
} from '@/features/files/api/listFiles.ts'
import { useCreateFile } from '@/features/files/api/createFile.ts'
import { FileTrigger } from 'react-aria-components'
import { RiDeleteBinLine, RiImageAddFill } from '@remixicon/react'
import {
RiDeleteBinLine,
RiImageAddFill,
RiProhibitedLine,
} from '@remixicon/react'
import { useDeleteFile } from '@/features/files/api/deleteFile.ts'
import { useUser } from '@/features/auth/api/useUser'
import { ApiFileItem } from '@/features/files/api/types.ts'
@@ -197,10 +201,23 @@ export const EffectsConfiguration = ({
*
* We arrive in this condition when we enter the room with the camera already off.
*/
const newProcessorTmp = BackgroundProcessorFactory.getProcessor(config)!
await toggle(true, {
processor: newProcessorTmp,
})
try {
const newProcessorTmp =
BackgroundProcessorFactory.getProcessor(config)!
await toggle(true, {
processor: newProcessorTmp,
})
} catch (error) {
reportError('effects_processor_failure', error, {
context: 'Error applying effect while enabling camera:',
})
saveProcessorConfig(undefined)
try {
await toggle(true)
} catch {
// Camera errors are handled by the toggle's own error path.
}
}
setTimeout(() => setProcessorPending(false))
return
}
@@ -242,6 +259,14 @@ export const EffectsConfiguration = ({
reportError('effects_processor_failure', error, {
context: 'Error applying effect:',
})
try {
if (videoTrack.getProcessor()) {
await videoTrack.stopProcessor()
}
} catch {
// Best effort: the processor may already be broken.
}
saveProcessorConfig(undefined)
} finally {
// Without setTimeout the DOM is not refreshing when updating the options.
setTimeout(() => setProcessorPending(false))
@@ -250,6 +275,24 @@ export const EffectsConfiguration = ({
[enabled, selectedId, toggle, updateEffectStatusMessage, videoTrack]
)
const clearEffect = useCallback(async () => {
if (selectedId === 'none') return
setProcessorPending(true)
try {
if (videoTrack?.getProcessor()) {
await videoTrack.stopProcessor()
}
saveProcessorConfig(undefined)
announceEffectStatusMessage(t('blur.status.none'))
} catch (error) {
reportError('effects_processor_failure', error, {
context: 'Error clearing effect:',
})
} finally {
setTimeout(() => setProcessorPending(false))
}
}, [announceEffectStatusMessage, selectedId, t, videoTrack])
const { data: appConfig } = useConfig()
const { isLoggedIn } = useUser()
const canUploadBackground =
@@ -647,6 +690,17 @@ export const EffectsConfiguration = ({
gap: '1.25rem',
})}
>
<ToggleButton
variant="bigSquare"
aria-label={t('clear')}
tooltip={t('clear')}
isDisabled={processorOptions.isDisabled}
onChange={clearEffect}
isSelected={selectedId === 'none'}
data-attr="toggle-effect-none"
>
<RiProhibitedLine />
</ToggleButton>
{processorOptions.blurBased.map(({ Icon, ...option }) => (
<ToggleButton
key={option.id}
@@ -22,10 +22,12 @@ import { VOICE_AUDIO_CONSTRAINTS } from '../utils/constants'
import {
saveAudioInputDeviceId,
saveAudioInputEnabled,
saveProcessorConfig,
saveVideoInputDeviceId,
saveVideoInputEnabled,
userChoicesStore,
} from '@/stores/userChoices'
import { reportError } from '@/features/analytics/telemetry'
import { useSyncTrackDeviceId } from './useSyncTrackDeviceId'
// Module-level: effect dependencies, must be referentially stable.
@@ -221,15 +223,32 @@ export function useJoinTracks(): {
[audioDeviceId]
)
const createVideo = useCallback(
() =>
createLocalVideoTrack({
const createVideo = useCallback(async () => {
const processor =
BackgroundProcessorFactory.fromProcessorConfig(processorConfig)
if (!processor) {
return createLocalVideoTrack({ deviceId: videoDeviceId })
}
try {
return await createLocalVideoTrack({
deviceId: videoDeviceId,
processor:
BackgroundProcessorFactory.fromProcessorConfig(processorConfig),
}),
[videoDeviceId, processorConfig]
)
processor,
})
} catch (error) {
// A camera problem (permission, device missing/busy) is not the
// effect's fault: let the normal media error handling deal with it
// without touching the user's saved effect.
const e = getMediaDeviceFailure(error as Error)
if (e !== MediaDeviceFailure.Other && !!e) {
throw error
}
reportError('effects_processor_failure', error, {
context: 'Restoring saved effect failed, retrying without it',
})
saveProcessorConfig(undefined)
return createLocalVideoTrack({ deviceId: videoDeviceId })
}
}, [videoDeviceId, processorConfig])
const audioTrack = useLocalTrack({
ready: audioReady,
@@ -86,16 +86,5 @@ export function useRaisedHand({ participant }: useRaisedHandProps) {
}
}
const lowerHand = async () => {
if (!isLocal(participant)) return
try {
await raiseHand(false)
} catch (e) {
reportError('generic_failure', e, {
context: 'lower_raised_hand',
})
}
}
return { isHandRaised, toggleRaisedHand, lowerHand }
return { isHandRaised, toggleRaisedHand }
}