mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-21 06:02:49 +03:00
* limit concurrent motion search jobs and search range 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. * show translated errors for motion search limits * release the motion search slot if the runner fails to start * keep wedged or shared-identity motion searches from locking out users A stalled VOD read blocked the runner forever, so a wedged job held its user and global slot until restart, and on installs without per-user identity (internal port, or auth disabled without a proxy user header) everyone shared one per-user slot. VOD ffmpeg and ffprobe inputs now use a 30 s rw_timeout, a cancel also drops queued parallel runs, a cancelled job stops counting after 60 s, and the per-user limit only applies when the request carries a real username. A failed runner start also stops its requestor. * treat timed-out VOD segments as motion search errors With rw_timeout, the HLS demuxer skipped a timed-out segment and exited cleanly, so a run reported success with a hole in it, and when the keyframe probe and the decode lost different segments the equal frame counts paired frames with the wrong timestamps. The keyframe path now requires the probe to cover the whole run without a gap over 5 s, a decode whose stderr shows a skipped segment fails the run, and a cadence decode more than 5 s short of the run's duration fails it too, so these runs land in segments_with_errors. The keyframe probe polls should_stop and is killed on cancel, probe_video_dimensions gets the read timeout, and a user's own cancelled job no longer blocks their next search while it still counts toward the global cap. * drop motion search stall detection and range cap * remove motion search range check from the UI
325 lines
10 KiB
Python
325 lines
10 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.config import FrigateConfig
|
|
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])
|
|
|
|
|
|
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
|
|
|
|
|
|
def _per_user_identity(request: Request, config: FrigateConfig) -> str | None:
|
|
"""Return the requesting user, or None when callers share one identity.
|
|
|
|
Internal-port requests are all "anonymous", and with auth disabled and no
|
|
proxy user header every browser is "viewer".
|
|
"""
|
|
username = request.headers.get("remote-user")
|
|
if not username or username == "anonymous":
|
|
return None
|
|
if config.auth.enabled or config.proxy.header_map.user:
|
|
return username
|
|
return 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,
|
|
)
|
|
|
|
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=_per_user_identity(request, config),
|
|
)
|
|
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,
|
|
)
|