mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-20 13:42:47 +03:00
Any viewer could start unlimited motion search jobs over an unbounded time range, and finished jobs were never dropped from memory. Each user now gets one active job (409), at most 3 run at once (429), the range is capped at 48 hours, and finished jobs are evicted 10 minutes after they end. Jobs are no longer filed in the shared job manager, which kept every finished job forever.
322 lines
9.9 KiB
Python
322 lines
9.9 KiB
Python
"""Motion search API for detecting changes within a region of interest."""
|
|
|
|
import logging
|
|
from typing import Any
|
|
|
|
from fastapi import APIRouter, Depends, Request
|
|
from fastapi.responses import JSONResponse
|
|
from pydantic import BaseModel, Field
|
|
|
|
from frigate.api.auth import require_camera_access
|
|
from frigate.api.defs.tags import Tags
|
|
from frigate.jobs.motion_search import (
|
|
MotionSearchCapacityError,
|
|
MotionSearchUserBusyError,
|
|
cancel_motion_search_job,
|
|
get_motion_search_job,
|
|
start_motion_search_job,
|
|
)
|
|
from frigate.types import JobStatusTypesEnum
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(tags=[Tags.motion_search])
|
|
|
|
# Covers the UI's day windows (25 h on a DST fall-back day) with room to spare.
|
|
MAX_SEARCH_RANGE_SECONDS = 48 * 3600
|
|
|
|
|
|
class MotionSearchRequest(BaseModel):
|
|
"""Request body for motion search."""
|
|
|
|
start_time: float = Field(description="Start timestamp for the search range")
|
|
end_time: float = Field(description="End timestamp for the search range")
|
|
polygon_points: list[list[float]] = Field(
|
|
description="List of [x, y] normalized coordinates (0-1) defining the ROI polygon"
|
|
)
|
|
threshold: int = Field(
|
|
default=30,
|
|
ge=1,
|
|
le=255,
|
|
description="Pixel difference threshold (1-255)",
|
|
)
|
|
min_area: float = Field(
|
|
default=5.0,
|
|
ge=0.1,
|
|
le=100.0,
|
|
description="Minimum change area as a percentage of the ROI",
|
|
)
|
|
parallel: bool = Field(
|
|
default=False,
|
|
description="Enable parallel scanning across segments",
|
|
)
|
|
max_results: int = Field(
|
|
default=25,
|
|
ge=1,
|
|
le=200,
|
|
description="Maximum number of search results to return",
|
|
)
|
|
|
|
|
|
class MotionSearchResult(BaseModel):
|
|
"""A single search result with timestamp and change info."""
|
|
|
|
timestamp: float = Field(description="Timestamp where change was detected")
|
|
change_percentage: float = Field(description="Percentage of ROI area that changed")
|
|
|
|
|
|
class MotionSearchMetricsResponse(BaseModel):
|
|
"""Metrics collected during motion search execution."""
|
|
|
|
segments_scanned: int = 0
|
|
segments_processed: int = 0
|
|
metadata_inactive_segments: int = 0
|
|
heatmap_roi_skip_segments: int = 0
|
|
fallback_full_range_segments: int = 0
|
|
frames_decoded: int = 0
|
|
wall_time_seconds: float = 0.0
|
|
segments_with_errors: int = 0
|
|
|
|
|
|
class MotionSearchStartResponse(BaseModel):
|
|
"""Response when motion search job starts."""
|
|
|
|
success: bool
|
|
message: str
|
|
job_id: str
|
|
|
|
|
|
class MotionSearchStatusResponse(BaseModel):
|
|
"""Response containing job status and results."""
|
|
|
|
success: bool
|
|
message: str
|
|
status: str # "queued", "running", "success", "failed", or "cancelled"
|
|
results: list[MotionSearchResult] | None = None
|
|
total_frames_processed: int | None = None
|
|
error_message: str | None = None
|
|
metrics: MotionSearchMetricsResponse | None = None
|
|
scanning_timestamp: float | None = None
|
|
progress: float | None = None
|
|
|
|
|
|
@router.post(
|
|
"/{camera_name}/search/motion",
|
|
response_model=MotionSearchStartResponse,
|
|
dependencies=[Depends(require_camera_access)],
|
|
summary="Start motion search job",
|
|
description="""Starts an asynchronous search for significant motion changes within
|
|
a user-defined Region of Interest (ROI) over a specified time range. Returns a job_id
|
|
that can be used to poll for results.""",
|
|
)
|
|
async def start_motion_search(
|
|
request: Request,
|
|
camera_name: str,
|
|
body: MotionSearchRequest,
|
|
):
|
|
"""Start an async motion search job."""
|
|
config = request.app.frigate_config
|
|
|
|
if camera_name not in config.cameras:
|
|
return JSONResponse(
|
|
content={"success": False, "message": f"Camera {camera_name} not found"},
|
|
status_code=404,
|
|
)
|
|
|
|
# Validate polygon has at least 3 points
|
|
if len(body.polygon_points) < 3:
|
|
return JSONResponse(
|
|
content={
|
|
"success": False,
|
|
"message": "Polygon must have at least 3 points",
|
|
},
|
|
status_code=400,
|
|
)
|
|
|
|
# Validate time range
|
|
if body.start_time >= body.end_time:
|
|
return JSONResponse(
|
|
content={
|
|
"success": False,
|
|
"message": "Start time must be before end time",
|
|
},
|
|
status_code=400,
|
|
)
|
|
|
|
if body.end_time - body.start_time > MAX_SEARCH_RANGE_SECONDS:
|
|
return JSONResponse(
|
|
content={
|
|
"success": False,
|
|
"message": "Time range must be 48 hours or less",
|
|
},
|
|
status_code=400,
|
|
)
|
|
|
|
try:
|
|
job_id = start_motion_search_job(
|
|
config=config,
|
|
camera_name=camera_name,
|
|
start_time=body.start_time,
|
|
end_time=body.end_time,
|
|
polygon_points=body.polygon_points,
|
|
threshold=body.threshold,
|
|
min_area=body.min_area,
|
|
parallel=body.parallel,
|
|
max_results=body.max_results,
|
|
username=request.headers.get("remote-user", "anonymous"),
|
|
)
|
|
except MotionSearchUserBusyError:
|
|
return JSONResponse(
|
|
content={
|
|
"success": False,
|
|
"message": "A motion search is already running for this user",
|
|
},
|
|
status_code=409,
|
|
)
|
|
except MotionSearchCapacityError:
|
|
return JSONResponse(
|
|
content={
|
|
"success": False,
|
|
"message": "Too many motion searches are running",
|
|
},
|
|
status_code=429,
|
|
)
|
|
|
|
return JSONResponse(
|
|
content={
|
|
"success": True,
|
|
"message": "Search job started",
|
|
"job_id": job_id,
|
|
}
|
|
)
|
|
|
|
|
|
@router.get(
|
|
"/{camera_name}/search/motion/{job_id}",
|
|
response_model=MotionSearchStatusResponse,
|
|
dependencies=[Depends(require_camera_access)],
|
|
summary="Get motion search job status",
|
|
description="Returns the status and results (if complete) of a motion search job.",
|
|
)
|
|
async def get_motion_search_status_endpoint(
|
|
request: Request,
|
|
camera_name: str,
|
|
job_id: str,
|
|
):
|
|
"""Get the status of a motion search job."""
|
|
config = request.app.frigate_config
|
|
|
|
if camera_name not in config.cameras:
|
|
return JSONResponse(
|
|
content={"success": False, "message": f"Camera {camera_name} not found"},
|
|
status_code=404,
|
|
)
|
|
|
|
job = get_motion_search_job(job_id)
|
|
if not job or job.camera != camera_name:
|
|
return JSONResponse(
|
|
content={"success": False, "message": "Job not found"},
|
|
status_code=404,
|
|
)
|
|
|
|
api_status = job.status
|
|
|
|
# Build response content
|
|
response_content: dict[str, Any] = {
|
|
"success": api_status != JobStatusTypesEnum.failed,
|
|
"status": api_status,
|
|
}
|
|
|
|
if api_status == JobStatusTypesEnum.failed:
|
|
response_content["message"] = job.error_message or "Search failed"
|
|
response_content["error_message"] = job.error_message
|
|
elif api_status == JobStatusTypesEnum.cancelled:
|
|
response_content["message"] = "Search cancelled"
|
|
response_content["total_frames_processed"] = job.total_frames_processed
|
|
elif api_status == JobStatusTypesEnum.success:
|
|
response_content["message"] = "Search complete"
|
|
if job.results:
|
|
response_content["results"] = job.results.get("results", [])
|
|
response_content["total_frames_processed"] = job.results.get(
|
|
"total_frames_processed", job.total_frames_processed
|
|
)
|
|
else:
|
|
response_content["results"] = []
|
|
response_content["total_frames_processed"] = job.total_frames_processed
|
|
else:
|
|
response_content["message"] = "Job processing"
|
|
response_content["total_frames_processed"] = job.total_frames_processed
|
|
# Include partial results if available (streaming)
|
|
if job.results:
|
|
response_content["results"] = job.results.get("results", [])
|
|
response_content["total_frames_processed"] = job.results.get(
|
|
"total_frames_processed", job.total_frames_processed
|
|
)
|
|
|
|
# Include metrics if available
|
|
if job.metrics:
|
|
response_content["metrics"] = job.metrics.to_dict()
|
|
|
|
response_content["scanning_timestamp"] = job.scanning_timestamp
|
|
response_content["progress"] = job.progress
|
|
|
|
return JSONResponse(content=response_content)
|
|
|
|
|
|
@router.post(
|
|
"/{camera_name}/search/motion/{job_id}/cancel",
|
|
dependencies=[Depends(require_camera_access)],
|
|
summary="Cancel motion search job",
|
|
description="Cancels an active motion search job if it is still processing.",
|
|
)
|
|
async def cancel_motion_search_endpoint(
|
|
request: Request,
|
|
camera_name: str,
|
|
job_id: str,
|
|
):
|
|
"""Cancel an active motion search job."""
|
|
config = request.app.frigate_config
|
|
|
|
if camera_name not in config.cameras:
|
|
return JSONResponse(
|
|
content={"success": False, "message": f"Camera {camera_name} not found"},
|
|
status_code=404,
|
|
)
|
|
|
|
job = get_motion_search_job(job_id)
|
|
if not job or job.camera != camera_name:
|
|
return JSONResponse(
|
|
content={"success": False, "message": "Job not found"},
|
|
status_code=404,
|
|
)
|
|
|
|
# Check if already finished
|
|
api_status = job.status
|
|
if api_status not in (JobStatusTypesEnum.queued, JobStatusTypesEnum.running):
|
|
return JSONResponse(
|
|
content={
|
|
"success": True,
|
|
"message": "Job already finished",
|
|
"status": api_status,
|
|
}
|
|
)
|
|
|
|
# Request cancellation
|
|
cancelled = cancel_motion_search_job(job_id)
|
|
if cancelled:
|
|
return JSONResponse(
|
|
content={
|
|
"success": True,
|
|
"message": "Search cancelled",
|
|
"status": "cancelled",
|
|
}
|
|
)
|
|
|
|
return JSONResponse(
|
|
content={
|
|
"success": False,
|
|
"message": "Failed to cancel job",
|
|
},
|
|
status_code=500,
|
|
)
|