From 6f43ed41f24ac77ead8e0c231888d2491c08384f Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sat, 2 May 2026 23:06:29 -0500 Subject: [PATCH] expose replay state on status endpoint and return 202 from start --- frigate/api/debug_replay.py | 38 +++++----- .../test/http_api/test_debug_replay_api.py | 71 +++++++++++++++++++ 2 files changed, 92 insertions(+), 17 deletions(-) create mode 100644 frigate/test/http_api/test_debug_replay_api.py diff --git a/frigate/api/debug_replay.py b/frigate/api/debug_replay.py index 027d4e50c..c9dcb12e4 100644 --- a/frigate/api/debug_replay.py +++ b/frigate/api/debug_replay.py @@ -29,12 +29,16 @@ class DebugReplayStartResponse(BaseModel): success: bool replay_camera: str + state: str class DebugReplayStatusResponse(BaseModel): """Response for debug replay status.""" active: bool + state: str + progress_percent: float | None = None + error_message: str | None = None replay_camera: str | None = None source_camera: str | None = None start_time: float | None = None @@ -53,10 +57,12 @@ class DebugReplayStopResponse(BaseModel): response_model=DebugReplayStartResponse, dependencies=[Depends(require_role(["admin"]))], summary="Start debug replay", - description="Start a debug replay session from camera recordings.", + description="Start a debug replay session from camera recordings. Returns " + "immediately while clip generation runs asynchronously; poll " + "/debug_replay/status to track progress.", ) async def start_debug_replay(request: Request, body: DebugReplayStartBody): - """Start a debug replay session.""" + """Start a debug replay session asynchronously.""" replay_manager = request.app.replay_manager if replay_manager.active: @@ -77,28 +83,23 @@ async def start_debug_replay(request: Request, body: DebugReplayStartBody): frigate_config=request.app.frigate_config, config_publisher=request.app.config_publisher, ) - except ValueError: - logger.exception("Invalid parameters for debug replay start request") + except ValueError as exc: + logger.info("Rejected debug replay start request: %s", exc) return JSONResponse( content={ "success": False, - "message": "Invalid debug replay request parameters", + "message": str(exc), }, status_code=400, ) - except RuntimeError: - logger.exception("Error while starting debug replay session") - return JSONResponse( - content={ - "success": False, - "message": "An internal error occurred while starting debug replay", - }, - status_code=500, - ) - return DebugReplayStartResponse( - success=True, - replay_camera=replay_camera, + return JSONResponse( + content={ + "success": True, + "replay_camera": replay_camera, + "state": replay_manager.state.value, + }, + status_code=202, ) @@ -132,6 +133,9 @@ def get_debug_replay_status(request: Request): return DebugReplayStatusResponse( active=replay_manager.active, + state=replay_manager.state.value, + progress_percent=replay_manager.progress_percent, + error_message=replay_manager.error_message, replay_camera=replay_camera, source_camera=replay_manager.source_camera, start_time=replay_manager.start_ts, diff --git a/frigate/test/http_api/test_debug_replay_api.py b/frigate/test/http_api/test_debug_replay_api.py new file mode 100644 index 000000000..bb6552786 --- /dev/null +++ b/frigate/test/http_api/test_debug_replay_api.py @@ -0,0 +1,71 @@ +"""Tests for /debug_replay API endpoints.""" + +from unittest.mock import patch + +from frigate.debug_replay import ReplayState +from frigate.models import Event, Recordings, ReviewSegment +from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp + + +class TestDebugReplayAPI(BaseTestHttp): + def setUp(self): + super().setUp([Event, Recordings, ReviewSegment]) + self.app = self.create_app() + + def test_start_returns_202_with_state_preparing_clip(self): + with patch( + "frigate.debug_replay.DebugReplayManager.start", + return_value="_replay_front", + ): + with patch.object( + type(self.app.replay_manager), + "state", + new_callable=lambda: property(lambda s: ReplayState.preparing_clip), + ): + with AuthTestClient(self.app) as client: + resp = client.post( + "/debug_replay/start", + json={ + "camera": "front", + "start_time": 100, + "end_time": 200, + }, + headers={"remote-user": "admin", "remote-role": "admin"}, + ) + + self.assertEqual(resp.status_code, 202) + body = resp.json() + self.assertTrue(body["success"]) + self.assertEqual(body["replay_camera"], "_replay_front") + self.assertEqual(body["state"], "preparing_clip") + + def test_status_returns_state_and_error_message(self): + manager = self.app.replay_manager + manager._set_state(ReplayState.error, error_message="ffmpeg failed: boom") + + with AuthTestClient(self.app) as client: + resp = client.get( + "/debug_replay/status", + headers={"remote-user": "admin", "remote-role": "admin"}, + ) + + self.assertEqual(resp.status_code, 200) + body = resp.json() + self.assertEqual(body["state"], "error") + self.assertEqual(body["error_message"], "ffmpeg failed: boom") + self.assertIsNone(body["progress_percent"]) + self.assertFalse(body["active"]) + + def test_status_returns_progress_percent_during_preparing_clip(self): + manager = self.app.replay_manager + manager._set_state(ReplayState.preparing_clip) + manager.progress_percent = 37.5 + + with AuthTestClient(self.app) as client: + resp = client.get( + "/debug_replay/status", + headers={"remote-user": "admin", "remote-role": "admin"}, + ) + + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.json()["progress_percent"], 37.5)