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 && (