Implement debug reply from export

This commit is contained in:
Nicolas Mowen 2026-05-18 15:21:31 -06:00
parent 293012fce2
commit 77eaf9891e
2 changed files with 162 additions and 1 deletions

View File

@ -6,11 +6,14 @@ from datetime import datetime
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, Depends, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from peewee import DoesNotExist
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from frigate.api.auth import require_role from frigate.api.auth import require_role
from frigate.api.defs.tags import Tags from frigate.api.defs.tags import Tags
from frigate.jobs.debug_replay import start_debug_replay_job 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__) logger = logging.getLogger(__name__)
@ -25,6 +28,12 @@ class DebugReplayStartBody(BaseModel):
end_time: float = Field(title="End timestamp") 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): class DebugReplayStartResponse(BaseModel):
"""Response for starting a debug replay session.""" """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( @router.get(
"/debug_replay/status", "/debug_replay/status",
response_model=DebugReplayStatusResponse, response_model=DebugReplayStatusResponse,

View File

@ -32,6 +32,9 @@ import { FaFolder, FaVideo } from "react-icons/fa";
import { HiSquare2Stack } from "react-icons/hi2"; import { HiSquare2Stack } from "react-icons/hi2";
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name"; import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
import useContextMenu from "@/hooks/use-contextmenu"; import useContextMenu from "@/hooks/use-contextmenu";
import axios from "axios";
import { toast } from "sonner";
import { useNavigate } from "react-router-dom";
type CaseCardProps = { type CaseCardProps = {
className: string; className: string;
@ -123,11 +126,63 @@ export function ExportCard({
onAssignToCase, onAssignToCase,
onRemoveFromCase, onRemoveFromCase,
}: ExportCardProps) { }: ExportCardProps) {
const { t } = useTranslation(["views/exports"]); const { t } = useTranslation(["views/exports", "views/replay"]);
const navigate = useNavigate();
const isAdmin = useIsAdmin(); const isAdmin = useIsAdmin();
const [loading, setLoading] = useState( const [loading, setLoading] = useState(
exportedRecording.thumb_path.length > 0, 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: (
<a
href={`${baseUrl}replay`}
target="_blank"
rel="noopener noreferrer"
>
<Button>
{t("dialog.toast.goToReplay", { ns: "views/replay" })}
</Button>
</a>
),
});
} 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 // Resync the skeleton state whenever the backing export changes. The
// list keys by id now, so in practice the component remounts instead // list keys by id now, so in practice the component remounts instead
@ -301,6 +356,21 @@ export function ExportCard({
{t("tooltip.downloadVideo")} {t("tooltip.downloadVideo")}
</a> </a>
</DropdownMenuItem> </DropdownMenuItem>
{isAdmin && (
<DropdownMenuItem
className="cursor-pointer"
aria-label={t("title", { ns: "views/replay" })}
disabled={isStartingReplay}
onClick={(e) => {
e.stopPropagation();
handleDebugReplay();
}}
>
{isStartingReplay
? t("dialog.starting", { ns: "views/replay" })
: t("title", { ns: "views/replay" })}
</DropdownMenuItem>
)}
{isAdmin && onAssignToCase && ( {isAdmin && onAssignToCase && (
<DropdownMenuItem <DropdownMenuItem
className="cursor-pointer" className="cursor-pointer"