mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-07-01 17:41:13 +03:00
Implement debug reply from export
This commit is contained in:
parent
293012fce2
commit
77eaf9891e
@ -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,
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user