This commit is contained in:
leo
2026-09-08 18:15:45 +02:00
parent 93e0ba945e
commit 1f9f54e896
12 changed files with 109 additions and 20 deletions
+1
View File
@@ -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
+31 -8
View File
@@ -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
@@ -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",
[
@@ -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:
@@ -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": {