diff --git a/docs/static/frigate-api.yaml b/docs/static/frigate-api.yaml index 80c1955bdb..91b7e1638d 100644 --- a/docs/static/frigate-api.yaml +++ b/docs/static/frigate-api.yaml @@ -7109,7 +7109,9 @@ paths: schema: $ref: '#/components/schemas/DebugReplayStartResponse' '400': - description: Invalid camera, time range, or no recordings + description: Invalid camera or time range + '404': + description: No recordings in the requested time range '409': description: A replay session is already active '422': diff --git a/frigate/api/debug_replay.py b/frigate/api/debug_replay.py index 034da3845d..9973bad04c 100644 --- a/frigate/api/debug_replay.py +++ b/frigate/api/debug_replay.py @@ -13,6 +13,7 @@ from frigate.api.auth import require_role from frigate.api.defs.tags import Tags from frigate.jobs.debug_replay import ( ExportDebugReplaySource, + NoRecordingsError, RecordingDebugReplaySource, start_debug_replay_job, ) @@ -74,7 +75,8 @@ class DebugReplayStopResponse(BaseModel): response_model=DebugReplayStartResponse, status_code=202, responses={ - 400: {"description": "Invalid camera, time range, or no recordings"}, + 400: {"description": "Invalid camera or time range"}, + 404: {"description": "No recordings in the requested time range"}, 409: {"description": "A replay session is already active"}, }, dependencies=[Depends(require_role(["admin"]))], @@ -113,6 +115,14 @@ async def start_debug_replay(request: Request, body: DebugReplayStartBody): }, status_code=409, ) + except NoRecordingsError: + return JSONResponse( + content={ + "success": False, + "message": "No recordings found in the selected time range", + }, + status_code=404, + ) except ValueError: logger.exception("Rejected debug replay start request") return JSONResponse( diff --git a/frigate/jobs/debug_replay.py b/frigate/jobs/debug_replay.py index c5e4ed8761..393211ea99 100644 --- a/frigate/jobs/debug_replay.py +++ b/frigate/jobs/debug_replay.py @@ -115,6 +115,10 @@ def query_recordings(source_camera: str, start_ts: float, end_ts: float) -> Mode return cast(ModelSelect, query) +class NoRecordingsError(ValueError): + """Raised when no recordings exist in the requested time range.""" + + class DebugReplaySource(ABC): """Abstract source for a debug replay session. @@ -187,7 +191,7 @@ class RecordingDebugReplaySource(DebugReplaySource): raise ValueError("End time must be after start time") if not query_recordings(self._camera, self._start_ts, self._end_ts).count(): - raise ValueError( + raise NoRecordingsError( f"No recordings found for camera '{self._camera}' in the specified time range" ) diff --git a/frigate/test/http_api/test_debug_replay_api.py b/frigate/test/http_api/test_debug_replay_api.py index be4e7f496f..ca2edaf36d 100644 --- a/frigate/test/http_api/test_debug_replay_api.py +++ b/frigate/test/http_api/test_debug_replay_api.py @@ -2,6 +2,7 @@ from unittest.mock import patch +from frigate.jobs.debug_replay import NoRecordingsError from frigate.models import Event, Recordings, ReviewSegment from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp @@ -66,6 +67,32 @@ class TestDebugReplayAPI(BaseTestHttp): # (CodeQL: information exposure through an exception). self.assertEqual(body["message"], "Invalid debug replay parameters") + def test_start_returns_404_when_no_recordings(self): + with patch( + "frigate.api.debug_replay.start_debug_replay_job", + side_effect=NoRecordingsError( + "No recordings found for camera 'front' in the specified time range" + ), + ): + with AuthTestClient(self.app) as client: + resp = client.post( + "/debug_replay/start", + json={ + "camera": "front", + "start_time": 100, + "end_time": 200, + }, + ) + + self.assertEqual(resp.status_code, 404) + body = resp.json() + self.assertFalse(body["success"]) + # Message is hard-coded so we don't echo exception text back to clients + # (CodeQL: information exposure through an exception). + self.assertEqual( + body["message"], "No recordings found in the selected time range" + ) + def test_start_returns_409_when_session_already_active(self): with patch( "frigate.api.debug_replay.start_debug_replay_job", diff --git a/frigate/test/test_debug_replay_job.py b/frigate/test/test_debug_replay_job.py index 12c4be82b8..e84a67e1d8 100644 --- a/frigate/test/test_debug_replay_job.py +++ b/frigate/test/test_debug_replay_job.py @@ -9,6 +9,7 @@ from unittest.mock import MagicMock, patch from frigate.debug_replay import DebugReplayManager from frigate.jobs.debug_replay import ( DebugReplayJob, + NoRecordingsError, RecordingDebugReplaySource, cancel_debug_replay_job, get_active_runner, @@ -129,7 +130,7 @@ class TestStartDebugReplayJob(unittest.TestCase): empty_qs = MagicMock() empty_qs.count.return_value = 0 with patch("frigate.jobs.debug_replay.query_recordings", return_value=empty_qs): - with self.assertRaises(ValueError): + with self.assertRaises(NoRecordingsError): start_debug_replay_job( source=RecordingDebugReplaySource( source_camera="front", diff --git a/web/public/locales/en/views/replay.json b/web/public/locales/en/views/replay.json index e8f50d7b7e..dbeaa84a07 100644 --- a/web/public/locales/en/views/replay.json +++ b/web/public/locales/en/views/replay.json @@ -21,6 +21,7 @@ "toast": { "error": "Failed to start debug replay: {{error}}", "alreadyActive": "A replay session is already active", + "noRecordings": "No recordings found in the selected time range", "stopError": "Failed to stop debug replay: {{error}}", "goToReplay": "Go to Replay" } diff --git a/web/src/components/overlay/DebugReplayDialog.tsx b/web/src/components/overlay/DebugReplayDialog.tsx index 3aa4b87e1c..504c8c1b14 100644 --- a/web/src/components/overlay/DebugReplayDialog.tsx +++ b/web/src/components/overlay/DebugReplayDialog.tsx @@ -217,7 +217,11 @@ export default function DebugReplayDialog({ error.response?.data?.detail || "Unknown error"; - if (error.response?.status === 409) { + if (error.response?.status === 404) { + toast.error(t("dialog.toast.noRecordings"), { + position: "top-center", + }); + } else if (error.response?.status === 409) { toast.error(t("dialog.toast.alreadyActive"), { position: "top-center", closeButton: true,