Compare commits

...

5 Commits

Author SHA1 Message Date
leo 1f9f54e896 wip 2026-09-08 18:15:45 +02:00
leo 93e0ba945e lint 2026-09-08 17:56:27 +02:00
leo 10f8dddf1a wip 2026-09-08 17:49:48 +02:00
leo 9e01d297a1 wip 2026-09-08 17:36:50 +02:00
leo 7247b05a56 wip 2026-09-08 17:12:17 +02:00
17 changed files with 396 additions and 42 deletions
+2
View File
@@ -11,6 +11,8 @@ and this project adheres to
### Fixed
- 🔒️(backend) enforce display name setting on rename API
- 🩹(backend) handle failed and aborted egresses
- 🩹(frontend) notify participants when a recording fails or is aborted
## [1.31.0] - 2026-09-08
@@ -0,0 +1,18 @@
# Generated by Django 5.2.14 on 2026-09-08 15:12
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0022_user_default_room_access_level_and_more'),
]
operations = [
migrations.AlterField(
model_name='recording',
name='status',
field=models.CharField(choices=[('initiated', 'Initiated'), ('active', 'Active'), ('stopped', 'Stopped'), ('saved', 'Saved'), ('aborted', 'Aborted'), ('failed', 'Failed'), ('failed_to_start', 'Failed to Start'), ('failed_to_stop', 'Failed to Stop'), ('notification_succeeded', 'Notification succeeded'), ('external_process_successful', 'External process successful'), ('external_process_failed', 'External process failed')], default='initiated', max_length=50),
),
]
+4 -6
View File
@@ -57,7 +57,8 @@ class RecordingStatusChoices(models.TextChoices):
ACTIVE = "active", _("Active")
STOPPED = "stopped", _("Stopped")
SAVED = "saved", _("Saved")
ABORTED = "aborted", _("Aborted")
ABORTED = "aborted", _("Aborted") # from livekit egress
FAILED = "failed", _("Failed") # from livekit egress
FAILED_TO_START = "failed_to_start", _("Failed to Start")
FAILED_TO_STOP = "failed_to_stop", _("Failed to Stop")
NOTIFICATION_SUCCEEDED = "notification_succeeded", _("Notification succeeded")
@@ -79,17 +80,13 @@ class RecordingStatusChoices(models.TextChoices):
cls.STOPPED,
cls.SAVED,
cls.ABORTED,
cls.FAILED,
cls.EXTERNAL_PROCESS_SUCCESSFUL,
cls.EXTERNAL_PROCESS_FAILED,
cls.FAILED_TO_START,
cls.FAILED_TO_STOP,
}
@classmethod
def is_unsuccessful(cls, status):
"""Determine if the recording status represents an unsuccessful state."""
return status in {cls.ABORTED, cls.FAILED_TO_START, cls.FAILED_TO_STOP}
class RecordingModeChoices(models.TextChoices):
"""Recording mode choices."""
@@ -582,6 +579,7 @@ class Recording(BaseModel):
4. NOTIFICATION_SUCCEEDED: External service has been notified of this recording
Error States:
- FAILED: Livekit egress returned EGRESS_FAILED
- FAILED_TO_START: Worker failed to initialize recording
- FAILED_TO_STOP: Worker failed during stop operation
- ABORTED: Recording was terminated before completion
@@ -2,6 +2,7 @@
# pylint: disable=no-member
from enum import Enum
from logging import getLogger
from livekit import api
@@ -26,19 +27,81 @@ class RecordingNotSavableError(Exception):
"""Recording cannot be saved because it is either in an error state or has already been saved"""
class RecordingEvent(Enum):
"""Recording outcomes participants are notified about."""
LIMIT_REACHED = "limit reached"
FAILED = "failed"
ABORTED = "aborted"
# Notification sent to the room's participants, per event and recording mode.
NOTIFICATION_TYPES = {
RecordingEvent.LIMIT_REACHED: {
models.RecordingModeChoices.SCREEN_RECORDING: "screenRecordingLimitReached",
models.RecordingModeChoices.TRANSCRIPT: "transcriptionLimitReached",
},
RecordingEvent.FAILED: {
models.RecordingModeChoices.SCREEN_RECORDING: "screenRecordingFailed",
models.RecordingModeChoices.TRANSCRIPT: "transcriptionFailed",
},
RecordingEvent.ABORTED: {
models.RecordingModeChoices.SCREEN_RECORDING: "screenRecordingAborted",
models.RecordingModeChoices.TRANSCRIPT: "transcriptionAborted",
},
}
class RecordingEventsService:
"""Handles recording-related LiveKit webhook events."""
@staticmethod
def _notify_participants(recording: Recording, event: RecordingEvent):
"""Notify the room's participants that a recording ended on the given event."""
notification_type = NOTIFICATION_TYPES[event].get(recording.mode)
if not notification_type:
logger.warning(
"Could not find notification type for: "
"room=%s, recording_id=%s, mode=%s, event=%s",
recording.room.id,
recording.id,
recording.mode,
event.value,
)
return
try:
utils.notify_participants(
room_name=str(recording.room.id),
notification_data={"type": notification_type},
)
except utils.NotificationError as e:
logger.exception(
"Failed to notify participants about recording %s: "
"room=%s, recording_id=%s, mode=%s",
event.value,
recording.room.id,
recording.id,
recording.mode,
)
raise RecordingEventsError(
f"Failed to notify participants in room '{recording.room.id}' about "
f"recording {event.value} (recording_id={recording.id})"
) from e
@staticmethod
def handle_update(recording: Recording, egress_status):
"""Handle egress status updates and sync recording state to room metadata."""
"""Handle egress status updates and sync recording state to room metadata.
Egress updates are sent for statuses EGRESS_ACTIVE and EGRESS_ENDING.
"""
room_name = str(recording.room.id)
status_mapping = {
api.EgressStatus.EGRESS_ACTIVE: "started",
api.EgressStatus.EGRESS_ENDING: "saving",
api.EgressStatus.EGRESS_ABORTED: "aborted",
}
recording_status = status_mapping.get(egress_status)
@@ -55,39 +118,38 @@ class RecordingEventsService:
except RoomManagementException as e:
logger.exception("Failed to update room's metadata: %s", e)
@staticmethod
def handle_limit_reached(recording: Recording):
@classmethod
def handle_limit_reached(cls, recording: Recording):
"""Stop recording and notify participants when limit is reached."""
recording.status = models.RecordingStatusChoices.STOPPED
recording.save()
notification_mapping = {
models.RecordingModeChoices.SCREEN_RECORDING: "screenRecordingLimitReached",
models.RecordingModeChoices.TRANSCRIPT: "transcriptionLimitReached",
}
cls._notify_participants(recording, RecordingEvent.LIMIT_REACHED)
notification_type = notification_mapping.get(recording.mode)
if not notification_type:
return
@classmethod
def handle_failed(cls, recording: Recording):
"""Set recording status to failed, matching egress status, and notify participants.
try:
utils.notify_participants(
room_name=str(recording.room.id),
notification_data={"type": notification_type},
)
except utils.NotificationError as e:
logger.exception(
"Failed to notify participants about recording limit reached: "
"room=%s, recording_id=%s, mode=%s",
recording.room.id,
recording.id,
recording.mode,
)
raise RecordingEventsError(
f"Failed to notify participants in room '{recording.room.id}' about "
f"recording limit reached (recording_id={recording.id})"
) from e
EGRESS_FAILED: used when an actual runtime/pipeline error occurs after the
egress has started
"""
recording.status = models.RecordingStatusChoices.FAILED
recording.save()
cls._notify_participants(recording, RecordingEvent.FAILED)
@classmethod
def handle_aborted(cls, recording: Recording):
"""Set recording status to aborted, matching egress status, and notify participants.
EGRESS_ABORTED: used when the egress stops before it ever became
active/recording
"""
recording.status = models.RecordingStatusChoices.ABORTED
recording.save()
cls._notify_participants(recording, RecordingEvent.ABORTED)
@staticmethod
def handle_complete(recording: Recording):
+58 -4
View File
@@ -178,9 +178,36 @@ class LiveKitEventsService:
egress_status = data.egress_info.status
self.recording_events.handle_update(recording, egress_status)
def _handle_egress_ended(self, data):
"""Handle 'egress_ended' event."""
@staticmethod
def _log_egress_error(data, recording, event):
"""Log the reason LiveKit reported an unsuccessful egress."""
logger.error(
"Egress %s for recording %s (room=%s, mode=%s): %s (error_code=%s)",
event,
recording.id,
recording.room.id,
recording.mode,
data.egress_info.error or "no error reported",
data.egress_info.error_code or "no error_code reported",
)
@staticmethod
def _log_notification_failure(recording, event):
"""Log a participant notification error on an unsuccessful egress."""
logger.exception(
"Failed to notify participants that recording %s %s (room=%s)",
recording.id,
event,
recording.room.id,
)
def _handle_egress_ended(self, data): # noqa: PLR0912
"""Handle 'egress_ended' event."""
# pylint: disable=too-many-branches
# Fetch recording
try:
recording = models.Recording.objects.select_related("room").get(
worker_id=data.egress_info.egress_id
@@ -190,6 +217,7 @@ class LiveKitEventsService:
f"Recording with worker ID {data.egress_info.egress_id} does not exist"
) from err
# Update room
try:
room_name = str(recording.room.id)
RoomManagement.update_metadata(
@@ -203,12 +231,15 @@ class LiveKitEventsService:
except RoomManagementException as e:
logger.exception("Failed to update room's metadata: %s", e)
# Stop metadata collector
if recording.options.get("metadata_collector_dispatch_id", None) is not None:
try:
MetadataCollectorService().stop(recording)
except MetadataCollectorException:
logger.warning("Failed to stop the MetadataCollectorService")
# Handle case: EGRESS_LIMIT_REACHED
# question: can we remove or factorize condition on ACTIVE ?
if (
data.egress_info.status == api.EgressStatus.EGRESS_LIMIT_REACHED
and recording.status == models.RecordingStatusChoices.ACTIVE
@@ -220,6 +251,31 @@ class LiveKitEventsService:
f"Failed to process limit reached event for recording {recording}"
) from e
# Handle case: EGRESS_ABORTED
if (
data.egress_info.status == api.EgressStatus.EGRESS_ABORTED
and recording.status == models.RecordingStatusChoices.ACTIVE
):
self._log_egress_error(data, recording, "aborted")
try:
self.recording_events.handle_aborted(recording)
except RecordingEventsError:
self._log_notification_failure(recording, "aborted")
return
# Handle case: EGRESS_FAILED
if (
data.egress_info.status == api.EgressStatus.EGRESS_FAILED
and recording.status == models.RecordingStatusChoices.ACTIVE
):
self._log_egress_error(data, recording, "failed")
try:
self.recording_events.handle_failed(recording)
except RecordingEventsError:
self._log_notification_failure(recording, "failed")
return
# Handle cases: EGRESS_COMPLETE & EGRESS_LIMIT_REACHED
# Finalize the recording, the egress has uploaded the file to the storage
if data.egress_info.status in [
api.EgressStatus.EGRESS_COMPLETE,
@@ -234,8 +290,6 @@ class LiveKitEventsService:
recording.id,
)
# Silently ignoring EGRESS_ABORTED, EGRESS_FAILED
@staticmethod
def _is_connection_test_room(room_name: str) -> bool:
"""Return True for ephemeral rooms created by the connection test endpoint."""
@@ -73,6 +73,90 @@ def test_handle_limit_reached_error(mock_notify, mode, notification_type, servic
)
@pytest.mark.parametrize(
("mode", "notification_type"),
(
("screen_recording", "screenRecordingFailed"),
("transcript", "transcriptionFailed"),
),
)
@mock.patch("core.utils.notify_participants")
def test_handle_failed_success(mock_notify, mode, notification_type, service):
"""Test handle_failed marks recording as failed and notifies participants."""
recording = RecordingFactory(status="active", mode=mode)
service.handle_failed(recording)
assert recording.status == "failed"
mock_notify.assert_called_once_with(
room_name=str(recording.room.id), notification_data={"type": notification_type}
)
@mock.patch("core.utils.notify_participants")
def test_handle_failed_error(mock_notify, service):
"""Test handle_failed raises RecordingEventsError when notification fails."""
mock_notify.side_effect = NotificationError("Error notifying")
recording = RecordingFactory(status="active", mode="screen_recording")
with pytest.raises(
RecordingEventsError,
match=r"Failed to notify participants in room '.+' "
r"about recording failed \(recording_id=.+\)",
):
service.handle_failed(recording)
assert recording.status == "failed"
mock_notify.assert_called_once_with(
room_name=str(recording.room.id),
notification_data={"type": "screenRecordingFailed"},
)
@pytest.mark.parametrize(
("mode", "notification_type"),
(
("screen_recording", "screenRecordingAborted"),
("transcript", "transcriptionAborted"),
),
)
@mock.patch("core.utils.notify_participants")
def test_handle_aborted_success(mock_notify, mode, notification_type, service):
"""Test handle_aborted marks recording as aborted and notifies participants."""
recording = RecordingFactory(status="active", mode=mode)
service.handle_aborted(recording)
assert recording.status == "aborted"
mock_notify.assert_called_once_with(
room_name=str(recording.room.id), notification_data={"type": notification_type}
)
@mock.patch("core.utils.notify_participants")
def test_handle_aborted_error(mock_notify, service):
"""Test handle_aborted raises RecordingEventsError when notification fails."""
mock_notify.side_effect = NotificationError("Error notifying")
recording = RecordingFactory(status="active", mode="screen_recording")
with pytest.raises(
RecordingEventsError,
match=r"Failed to notify participants in room '.+' "
r"about recording aborted \(recording_id=.+\)",
):
service.handle_aborted(recording)
assert recording.status == "aborted"
mock_notify.assert_called_once_with(
room_name=str(recording.room.id),
notification_data={"type": "screenRecordingAborted"},
)
@pytest.mark.parametrize("status", ["active", "stopped"])
@pytest.mark.parametrize(
("notify_return_value", "expected_status"),
@@ -3,6 +3,7 @@ Test LiveKitEvents service.
"""
# pylint: disable=W0621,W0613, W0212, E0611
import logging
import uuid
from unittest import mock
@@ -105,7 +106,6 @@ def test_handle_egress_ended_success( # pylint: disable=too-many-arguments, too
(
(EgressStatus.EGRESS_ACTIVE, "started"),
(EgressStatus.EGRESS_ENDING, "saving"),
(EgressStatus.EGRESS_ABORTED, "aborted"),
),
)
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
@@ -130,6 +130,7 @@ def test_handle_egress_updated_success(
"egress_status",
(
EgressStatus.EGRESS_FAILED,
EgressStatus.EGRESS_ABORTED,
EgressStatus.EGRESS_LIMIT_REACHED,
),
)
@@ -348,7 +349,7 @@ def test_handle_egress_ended_does_not_call_metadata_collector_stop_when_conditio
"notify_return_value, recording_status",
[(True, "notification_succeeded"), (False, "saved")],
)
def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913, PLR0917
def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913
mock_update_metadata,
mock_notify,
mock_notify_external_services,
@@ -375,14 +376,108 @@ def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913, PLR0917
assert recording.status == recording_status
@pytest.mark.parametrize(
("egress_status", "recording_status", "notification_type"),
(
(EgressStatus.EGRESS_ABORTED, "aborted", "screenRecordingAborted"),
(EgressStatus.EGRESS_FAILED, "failed", "screenRecordingFailed"),
),
)
@mock.patch("core.utils.notify_participants")
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_unsuccessful_egress( # noqa: PLR0913
mock_update_metadata,
mock_notify,
egress_status,
recording_status,
notification_type,
service,
): # pylint: disable=too-many-arguments,too-many-positional-arguments
"""Should flag the recording and notify participants on aborted/failed egress."""
recording = RecordingFactory(
worker_id="worker-1", status="active", mode="screen_recording"
)
mock_data = mock.MagicMock()
mock_data.egress_info.egress_id = recording.worker_id
mock_data.egress_info.status = egress_status
service._handle_egress_ended(mock_data)
mock_notify.assert_called_once_with(
room_name=str(recording.room.id), notification_data={"type": notification_type}
)
recording.refresh_from_db()
assert recording.status == recording_status
@pytest.mark.parametrize(
("egress_status", "recording_status"),
(
(EgressStatus.EGRESS_ABORTED, "aborted"),
(EgressStatus.EGRESS_FAILED, "failed"),
),
)
@mock.patch("core.utils.notify_participants")
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_unsuccessful_egress_notification_fails(
mock_update_metadata,
mock_notify,
egress_status,
recording_status,
service,
): # pylint: disable=too-many-arguments,too-many-positional-arguments
"""Test that notification failure does not disrupt the update."""
mock_notify.side_effect = NotificationError("Error notifying")
recording = RecordingFactory(worker_id="worker-1", status="active")
mock_data = mock.MagicMock()
mock_data.egress_info.egress_id = recording.worker_id
mock_data.egress_info.status = egress_status
service._handle_egress_ended(mock_data)
recording.refresh_from_db()
assert recording.status == recording_status
@pytest.mark.parametrize(
("egress_status", "event"),
(
(EgressStatus.EGRESS_ABORTED, "aborted"),
(EgressStatus.EGRESS_FAILED, "failed"),
),
)
@mock.patch("core.utils.notify_participants")
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_logs_livekit_error( # noqa: PLR0913
mock_update_metadata, mock_notify, egress_status, event, service, caplog
): # pylint: disable=too-many-arguments,too-many-positional-arguments
"""Should log the reason LiveKit reported an unsuccessful egress."""
recording = RecordingFactory(worker_id="worker-1", status="active")
mock_data = mock.MagicMock()
mock_data.egress_info.egress_id = recording.worker_id
mock_data.egress_info.status = egress_status
mock_data.egress_info.error = "could not connect to the room"
mock_data.egress_info.error_code = 500
with caplog.at_level(logging.ERROR):
service._handle_egress_ended(mock_data)
assert f"Egress {event} for recording {recording.id}" in caplog.text
assert "could not connect to the room" in caplog.text
assert "error_code=500" in caplog.text
@pytest.mark.parametrize(
"egress_status",
[
EgressStatus.EGRESS_STARTING,
EgressStatus.EGRESS_ACTIVE,
EgressStatus.EGRESS_ENDING,
EgressStatus.EGRESS_FAILED,
EgressStatus.EGRESS_ABORTED,
],
)
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
@@ -96,6 +96,10 @@ export const MainNotificationToast = () => {
case NotificationType.ScreenRecordingStopped:
case NotificationType.TranscriptionLimitReached:
case NotificationType.ScreenRecordingLimitReached:
case NotificationType.TranscriptionFailed:
case NotificationType.ScreenRecordingFailed:
case NotificationType.TranscriptionAborted:
case NotificationType.ScreenRecordingAborted:
toastQueue.add(
{
participant,
@@ -10,11 +10,15 @@ export enum NotificationType {
TranscriptionStarted = 'transcriptionStarted',
TranscriptionStopped = 'transcriptionStopped',
TranscriptionLimitReached = 'transcriptionLimitReached',
TranscriptionFailed = 'transcriptionFailed',
TranscriptionAborted = 'transcriptionAborted',
TranscriptionRequested = 'transcriptionRequested',
ScreenRecordingStarted = 'screenRecordingStarted',
ScreenRecordingStopped = 'screenRecordingStopped',
ScreenRecordingRequested = 'screenRecordingRequested',
ScreenRecordingLimitReached = 'screenRecordingLimitReached',
ScreenRecordingFailed = 'screenRecordingFailed',
ScreenRecordingAborted = 'screenRecordingAborted',
RecordingSaving = 'recordingSaving',
PermissionsRemoved = 'permissionsRemoved',
RoleChanged = 'roleChanged',
@@ -22,12 +22,20 @@ export function ToastAnyRecording({ state, ...props }: Readonly<ToastProps>) {
return 'transcript.stopped'
case NotificationType.TranscriptionLimitReached:
return 'transcript.limitReached'
case NotificationType.TranscriptionFailed:
return 'transcript.failed'
case NotificationType.TranscriptionAborted:
return 'transcript.aborted'
case NotificationType.ScreenRecordingStarted:
return 'screenRecording.started'
case NotificationType.ScreenRecordingStopped:
return 'screenRecording.stopped'
case NotificationType.ScreenRecordingLimitReached:
return 'screenRecording.limitReached'
case NotificationType.ScreenRecordingFailed:
return 'screenRecording.failed'
case NotificationType.ScreenRecordingAborted:
return 'screenRecording.aborted'
default:
return
}
@@ -58,6 +58,10 @@ const renderToast = (
case NotificationType.ScreenRecordingStarted:
case NotificationType.ScreenRecordingStopped:
case NotificationType.ScreenRecordingLimitReached:
case NotificationType.TranscriptionFailed:
case NotificationType.ScreenRecordingFailed:
case NotificationType.TranscriptionAborted:
case NotificationType.ScreenRecordingAborted:
return <ToastAnyRecording key={toast.key} toast={toast} state={state} />
case NotificationType.TranscriptionRequested:
@@ -9,6 +9,7 @@ export enum RecordingStatus {
Stopped = 'stopped',
Saved = 'saved',
Aborted = 'aborted',
Failed = 'failed',
FailedToStart = 'failedToStart',
FailedToStop = 'failedToStop',
NotificationSucceed = 'notification_succeeded',
@@ -36,12 +36,16 @@
"started": "{{name}} hat die Meeting-Transkription gestartet.",
"stopped": "{{name}} hat die Meeting-Transkription gestoppt.",
"limitReached": "Die Transkription hat die maximal zulässige Dauer überschritten und wird automatisch gespeichert.",
"failed": "Die Transkription wurde unerwartet beendet und konnte nicht gespeichert werden.",
"aborted": "Die Transkription konnte nicht gestartet werden.",
"requested": "{{name}} möchte die Meeting-Transkription starten."
},
"screenRecording": {
"started": "{{name}} hat die Meeting-Aufzeichnung gestartet.",
"stopped": "{{name}} hat die Meeting-Aufzeichnung gestoppt.",
"limitReached": "Die Aufzeichnung hat die maximal zulässige Dauer überschritten und wird automatisch gespeichert.",
"failed": "Die Aufzeichnung wurde unerwartet beendet und konnte nicht gespeichert werden.",
"aborted": "Die Aufzeichnung konnte nicht gestartet werden.",
"requested": "{{name}} möchte die Meeting-Aufzeichnung starten."
},
"recordingSave": {
@@ -36,12 +36,16 @@
"started": "{{name}} started the meeting transcription.",
"stopped": "{{name}} stopped the meeting transcription.",
"limitReached": "The transcription has exceeded the maximum allowed duration and will be automatically saved.",
"failed": "The transcription stopped unexpectedly and could not be saved.",
"aborted": "The transcription could not be started.",
"requested": "{{name}} wants to start the meeting transcription."
},
"screenRecording": {
"started": "{{name}} started the meeting recording.",
"stopped": "{{name}} stopped the meeting recording.",
"limitReached": "The recording has exceeded the maximum allowed duration and will be automatically saved.",
"failed": "The recording stopped unexpectedly and could not be saved.",
"aborted": "The recording could not be started.",
"requested": "{{name}} wants to start the meeting recording."
},
"recordingSave": {
@@ -36,12 +36,16 @@
"started": "{{name}} ha iniciado la transcripción de la reunión.",
"stopped": "{{name}} ha detenido la transcripción de la reunión.",
"limitReached": "La transcripción ha superado la duración máxima permitida, se va a guardar automáticamente.",
"failed": "La transcripción se ha interrumpido de forma inesperada y no se ha podido guardar.",
"aborted": "No se ha podido iniciar la transcripción.",
"requested": "{{name}} ha solicitado iniciar la transcripción."
},
"screenRecording": {
"started": "{{name}} ha iniciado la grabación de la reunión.",
"stopped": "{{name}} ha detenido la grabación de la reunión.",
"limitReached": "La grabación ha superado la duración máxima permitida, se va a guardar automáticamente.",
"failed": "La grabación se ha interrumpido de forma inesperada y no se ha podido guardar.",
"aborted": "No se ha podido iniciar la grabación.",
"requested": "{{name}} ha solicitado iniciar la grabación."
},
"recordingSave": {
@@ -36,12 +36,16 @@
"started": "{{name}} a démarré la transcription de la réunion.",
"stopped": "{{name}} a arrêté la transcription de la réunion.",
"limitReached": "La transcription a dépassé la durée maximale autorisée, elle va être automatiquement sauvegardée.",
"failed": "La transcription s'est interrompue de façon inattendue et n'a pas pu être sauvegardée.",
"aborted": "La transcription n'a pas pu être démarrée.",
"requested": "{{name}} a demandé à démarrer la transcription."
},
"screenRecording": {
"started": "{{name}} a démarré l'enregistrement de la réunion.",
"stopped": "{{name}} a arrêté l'enregistrement de la réunion.",
"limitReached": "L'enregistrement a dépassé la durée maximale autorisée, il va être automatiquement sauvegardé.",
"failed": "L'enregistrement s'est interrompu de façon inattendue et n'a pas pu être sauvegardé.",
"aborted": "L'enregistrement n'a pas pu être démarré.",
"requested": "{{name}} a demandé à démarrer l'enregistrement."
},
"recordingSave": {
@@ -36,12 +36,16 @@
"started": "{{name}} is de transcriptie van de vergadering gestart.",
"stopped": "{{name}} heeft de transcriptie van de vergadering gestopt.",
"limitReached": "De transcriptie heeft de maximaal toegestane duur overschreden en wordt automatisch opgeslagen.",
"failed": "De transcriptie is onverwacht gestopt en kon niet worden opgeslagen.",
"aborted": "De transcriptie kon niet worden gestart.",
"requested": "{{name}} wil graag de transcriptie starten."
},
"screenRecording": {
"started": "{{name}} is begonnen met het opnemen van de vergadering.",
"stopped": "{{name}} is gestopt met het opnemen van de vergadering.",
"limitReached": "De opname heeft de maximaal toegestane duur overschreden en wordt automatisch opgeslagen.",
"failed": "De opname is onverwacht gestopt en kon niet worden opgeslagen.",
"aborted": "De opname kon niet worden gestart.",
"requested": "{{name}} wil graag de opname starten."
},
"recordingSave": {