From 1f9f54e896b513d3165589e9b9ce2e18535715b1 Mon Sep 17 00:00:00 2001 From: leo <260626284+cameledev@users.noreply.github.com> Date: Tue, 8 Sep 2026 18:15:45 +0200 Subject: [PATCH] wip --- CHANGELOG.md | 1 + src/backend/core/services/livekit_events.py | 39 ++++++++++++--- .../tests/services/test_livekit_events.py | 49 ++++++++++++++----- .../notifications/MainNotificationToast.tsx | 4 ++ .../notifications/NotificationType.ts | 4 ++ .../components/ToastAnyRecording.tsx | 8 +++ .../notifications/components/ToastRegion.tsx | 4 ++ .../src/locales/de/notifications.json | 4 ++ .../src/locales/en/notifications.json | 4 ++ .../src/locales/es/notifications.json | 4 ++ .../src/locales/fr/notifications.json | 4 ++ .../src/locales/nl/notifications.json | 4 ++ 12 files changed, 109 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 733a5bfb..42b2b629 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to - 🔒️(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 diff --git a/src/backend/core/services/livekit_events.py b/src/backend/core/services/livekit_events.py index 41a9733b..8b319b57 100644 --- a/src/backend/core/services/livekit_events.py +++ b/src/backend/core/services/livekit_events.py @@ -178,6 +178,31 @@ class LiveKitEventsService: egress_status = data.egress_info.status self.recording_events.handle_update(recording, egress_status) + @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 @@ -231,12 +256,11 @@ class LiveKitEventsService: 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 as e: - raise ActionFailedError( - f"Failed to process aborted event for recording {recording}" - ) from e + except RecordingEventsError: + self._log_notification_failure(recording, "aborted") return # Handle case: EGRESS_FAILED @@ -244,12 +268,11 @@ class LiveKitEventsService: 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 as e: - raise ActionFailedError( - f"Failed to process failed event for recording {recording}" - ) from e + except RecordingEventsError: + self._log_notification_failure(recording, "failed") return # Handle cases: EGRESS_COMPLETE & EGRESS_LIMIT_REACHED diff --git a/src/backend/core/tests/services/test_livekit_events.py b/src/backend/core/tests/services/test_livekit_events.py index 71353fc8..d35b4e00 100644 --- a/src/backend/core/tests/services/test_livekit_events.py +++ b/src/backend/core/tests/services/test_livekit_events.py @@ -3,6 +3,7 @@ Test LiveKitEvents service. """ # pylint: disable=W0621,W0613, W0212, E0611 +import logging import uuid from unittest import mock @@ -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, @@ -412,23 +413,22 @@ def test_handle_egress_ended_unsuccessful_egress( # noqa: PLR0913 @pytest.mark.parametrize( - ("egress_status", "recording_status", "event"), + ("egress_status", "recording_status"), ( - (EgressStatus.EGRESS_ABORTED, "aborted", "aborted"), - (EgressStatus.EGRESS_FAILED, "failed", "failed"), + (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( # noqa: PLR0913 +def test_handle_egress_ended_unsuccessful_egress_notification_fails( mock_update_metadata, mock_notify, egress_status, recording_status, - event, service, ): # pylint: disable=too-many-arguments,too-many-positional-arguments - """Should raise ActionFailedError when notification fails but still flag recording.""" + """Test that notification failure does not disrupt the update.""" mock_notify.side_effect = NotificationError("Error notifying") @@ -437,16 +437,41 @@ def test_handle_egress_ended_unsuccessful_egress_notification_fails( # noqa: PL mock_data.egress_info.egress_id = recording.worker_id mock_data.egress_info.status = egress_status - with pytest.raises( - ActionFailedError, - match=rf"Failed to process {event} event for recording .+", - ): - service._handle_egress_ended(mock_data) + 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", [ diff --git a/src/frontend/src/features/notifications/MainNotificationToast.tsx b/src/frontend/src/features/notifications/MainNotificationToast.tsx index ff80f40c..fe3aa2ff 100644 --- a/src/frontend/src/features/notifications/MainNotificationToast.tsx +++ b/src/frontend/src/features/notifications/MainNotificationToast.tsx @@ -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, diff --git a/src/frontend/src/features/notifications/NotificationType.ts b/src/frontend/src/features/notifications/NotificationType.ts index e42fe7c3..e1a49030 100644 --- a/src/frontend/src/features/notifications/NotificationType.ts +++ b/src/frontend/src/features/notifications/NotificationType.ts @@ -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', diff --git a/src/frontend/src/features/notifications/components/ToastAnyRecording.tsx b/src/frontend/src/features/notifications/components/ToastAnyRecording.tsx index b3063ac2..6200882b 100644 --- a/src/frontend/src/features/notifications/components/ToastAnyRecording.tsx +++ b/src/frontend/src/features/notifications/components/ToastAnyRecording.tsx @@ -22,12 +22,20 @@ export function ToastAnyRecording({ state, ...props }: Readonly) { 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 } diff --git a/src/frontend/src/features/notifications/components/ToastRegion.tsx b/src/frontend/src/features/notifications/components/ToastRegion.tsx index f39c667b..ee9bb205 100644 --- a/src/frontend/src/features/notifications/components/ToastRegion.tsx +++ b/src/frontend/src/features/notifications/components/ToastRegion.tsx @@ -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 case NotificationType.TranscriptionRequested: diff --git a/src/frontend/src/locales/de/notifications.json b/src/frontend/src/locales/de/notifications.json index 41122ae9..6823174d 100644 --- a/src/frontend/src/locales/de/notifications.json +++ b/src/frontend/src/locales/de/notifications.json @@ -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": { diff --git a/src/frontend/src/locales/en/notifications.json b/src/frontend/src/locales/en/notifications.json index f64b19f5..12631db6 100644 --- a/src/frontend/src/locales/en/notifications.json +++ b/src/frontend/src/locales/en/notifications.json @@ -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": { diff --git a/src/frontend/src/locales/es/notifications.json b/src/frontend/src/locales/es/notifications.json index b4e0fd1d..cf8bcf1f 100644 --- a/src/frontend/src/locales/es/notifications.json +++ b/src/frontend/src/locales/es/notifications.json @@ -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": { diff --git a/src/frontend/src/locales/fr/notifications.json b/src/frontend/src/locales/fr/notifications.json index 3d4a1ff7..c4152d6a 100644 --- a/src/frontend/src/locales/fr/notifications.json +++ b/src/frontend/src/locales/fr/notifications.json @@ -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": { diff --git a/src/frontend/src/locales/nl/notifications.json b/src/frontend/src/locales/nl/notifications.json index 6afc63fe..c238c618 100644 --- a/src/frontend/src/locales/nl/notifications.json +++ b/src/frontend/src/locales/nl/notifications.json @@ -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": {