From 77eaf9891e1fe1de2afd7372e242ce8108f38db1 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Mon, 18 May 2026 15:21:31 -0600 Subject: [PATCH] Implement debug reply from export --- frigate/api/debug_replay.py | 91 ++++++++++++++++++++++++++ web/src/components/card/ExportCard.tsx | 72 +++++++++++++++++++- 2 files changed, 162 insertions(+), 1 deletion(-) diff --git a/frigate/api/debug_replay.py b/frigate/api/debug_replay.py index 171bf1b98a..5f0b5f3e87 100644 --- a/frigate/api/debug_replay.py +++ b/frigate/api/debug_replay.py @@ -6,11 +6,14 @@ from datetime import datetime from fastapi import APIRouter, Depends, Request from fastapi.responses import JSONResponse +from peewee import DoesNotExist from pydantic import BaseModel, Field from frigate.api.auth import require_role from frigate.api.defs.tags import Tags from frigate.jobs.debug_replay import start_debug_replay_job +from frigate.models import Export +from frigate.util.services import get_video_properties logger = logging.getLogger(__name__) @@ -25,6 +28,12 @@ class DebugReplayStartBody(BaseModel): end_time: float = Field(title="End timestamp") +class DebugReplayStartFromExportBody(BaseModel): + """Request body for starting a debug replay session from an export.""" + + export_id: str = Field(title="Export id") + + class DebugReplayStartResponse(BaseModel): """Response for starting a debug replay session.""" @@ -112,6 +121,88 @@ async def start_debug_replay(request: Request, body: DebugReplayStartBody): ) +@router.post( + "/debug_replay/start_from_export", + response_model=DebugReplayStartResponse, + status_code=202, + responses={ + 400: {"description": "Invalid export, time range, or no recordings"}, + 404: {"description": "Export not found"}, + 409: {"description": "A replay session is already active"}, + }, + dependencies=[Depends(require_role(["admin"]))], + summary="Start debug replay from an export", + description="Start a debug replay session covering an existing export's " + "time range. The end time is derived from the export's video duration.", +) +async def start_debug_replay_from_export( + request: Request, body: DebugReplayStartFromExportBody +): + """Start a debug replay session from an existing export.""" + try: + export: Export = Export.get(Export.id == body.export_id) + except DoesNotExist: + return JSONResponse( + content={"success": False, "message": "Export not found"}, + status_code=404, + ) + + start_ts = datetime.timestamp(export.date) + properties = await get_video_properties( + request.app.frigate_config.ffmpeg, export.video_path, get_duration=True + ) + duration = properties.get("duration", -1) + + if duration is None or duration <= 0: + return JSONResponse( + content={ + "success": False, + "message": "Could not determine export duration", + }, + status_code=400, + ) + + end_ts = start_ts + duration + replay_manager = request.app.replay_manager + + try: + job_id = await asyncio.to_thread( + start_debug_replay_job, + source_camera=export.camera, + start_ts=start_ts, + end_ts=end_ts, + frigate_config=request.app.frigate_config, + config_publisher=request.app.config_publisher, + replay_manager=replay_manager, + ) + except RuntimeError: + return JSONResponse( + content={ + "success": False, + "message": "A replay session is already active", + }, + status_code=409, + ) + except ValueError: + logger.exception("Rejected debug replay start request") + return JSONResponse( + content={ + "success": False, + "message": "Invalid debug replay parameters", + }, + status_code=400, + ) + + return JSONResponse( + content={ + "success": True, + "replay_camera": replay_manager.replay_camera_name, + "job_id": job_id, + }, + status_code=202, + ) + + @router.get( "/debug_replay/status", response_model=DebugReplayStatusResponse, diff --git a/web/src/components/card/ExportCard.tsx b/web/src/components/card/ExportCard.tsx index 893f251f8f..58600ed3fd 100644 --- a/web/src/components/card/ExportCard.tsx +++ b/web/src/components/card/ExportCard.tsx @@ -32,6 +32,9 @@ import { FaFolder, FaVideo } from "react-icons/fa"; import { HiSquare2Stack } from "react-icons/hi2"; import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name"; import useContextMenu from "@/hooks/use-contextmenu"; +import axios from "axios"; +import { toast } from "sonner"; +import { useNavigate } from "react-router-dom"; type CaseCardProps = { className: string; @@ -123,11 +126,63 @@ export function ExportCard({ onAssignToCase, onRemoveFromCase, }: ExportCardProps) { - const { t } = useTranslation(["views/exports"]); + const { t } = useTranslation(["views/exports", "views/replay"]); + const navigate = useNavigate(); const isAdmin = useIsAdmin(); const [loading, setLoading] = useState( exportedRecording.thumb_path.length > 0, ); + const [isStartingReplay, setIsStartingReplay] = useState(false); + + const handleDebugReplay = useCallback(() => { + setIsStartingReplay(true); + + axios + .post("debug_replay/start_from_export", { + export_id: exportedRecording.id, + }) + .then((response) => { + if (response.status === 202 || response.status === 200) { + navigate("/replay"); + } + }) + .catch((error) => { + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + + if (error.response?.status === 409) { + toast.error(t("dialog.toast.alreadyActive", { ns: "views/replay" }), { + position: "top-center", + closeButton: true, + dismissible: false, + action: ( + + + + ), + }); + } else { + toast.error( + t("dialog.toast.error", { + ns: "views/replay", + error: errorMessage, + }), + { position: "top-center" }, + ); + } + }) + .finally(() => { + setIsStartingReplay(false); + }); + }, [exportedRecording.id, navigate, t]); // Resync the skeleton state whenever the backing export changes. The // list keys by id now, so in practice the component remounts instead @@ -301,6 +356,21 @@ export function ExportCard({ {t("tooltip.downloadVideo")} + {isAdmin && ( + { + e.stopPropagation(); + handleDebugReplay(); + }} + > + {isStartingReplay + ? t("dialog.starting", { ns: "views/replay" }) + : t("title", { ns: "views/replay" })} + + )} {isAdmin && onAssignToCase && (