Files
frigate/frigate/jobs/motion_search.py
T
Josh HawkinsandGitHub 796aec7a4a Limit concurrent motion searches (#24717)
* 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
2026-10-19 04:52:15 -10:00

1004 lines
36 KiB
Python

"""Motion search job management with background execution and parallel verification."""
import logging
import os
import threading
import time
from collections.abc import Callable, Generator, Iterable
from concurrent.futures import Future, ThreadPoolExecutor, as_completed
from dataclasses import asdict, dataclass, field
from datetime import datetime
from typing import Any
import cv2
import numpy as np
from frigate.comms.inter_process import InterProcessRequestor
from frigate.config import FrigateConfig
from frigate.const import STREAM_TYPE_MAIN, UPDATE_JOB_STATE
from frigate.jobs.job import Job
from frigate.jobs.motion_search_batch import (
build_segment_time_map,
coalesce_runs,
stream_time_to_absolute,
)
from frigate.jobs.motion_search_decode import (
iter_vod_frames,
keyframe_sampling_eligible,
probe_video_dimensions,
probe_vod_keyframe_pts,
resolve_motion_decode_args,
)
from frigate.models import Recordings
from frigate.types import JobStatusTypesEnum
from frigate.util.recording_coverage import recording_overlap
logger = logging.getLogger(__name__)
# Constants
HEATMAP_GRID_SIZE = 16
# Max wall-clock span of one VOD run request (seconds). Bounds per-request size
# and gives streaming/cancel/early-exit granularity.
MAX_RUN_SECONDS = 600.0
# Treat segments within this many seconds end-to-start as time-contiguous.
RUN_GAP_EPSILON = 1.0
# Longest-side pixels for the ROI downscale before motion detection.
SCALE_TARGET = 400
# Minimum wall seconds between intra-run progress broadcasts.
PROGRESS_BROADCAST_INTERVAL = 1.0
# Output frame rate for the fixed-cadence fallback used on long-GOP cameras
# (where keyframe sampling is too sparse). Keyframe cameras ignore this.
FALLBACK_SAMPLE_FPS = 2.0
# Each parallel job runs up to 4 decode workers, so this bounds total ffmpeg load.
MAX_ACTIVE_JOBS = 3
# Finished jobs stay pollable for this long before eviction (seconds).
FINISHED_JOB_TTL = 600.0
# A cancelled job stops holding a slot after this long, even if its thread is wedged.
CANCEL_GRACE_SECONDS = 60.0
@dataclass
class MotionSearchMetrics:
"""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
def to_dict(self) -> dict[str, Any]:
"""Convert to dictionary."""
return asdict(self)
@dataclass
class MotionSearchResult:
"""A single search result with timestamp and change info."""
timestamp: float
change_percentage: float
def to_dict(self) -> dict[str, Any]:
"""Convert to dictionary."""
return asdict(self)
@dataclass
class MotionSearchJob(Job):
"""Job state for motion search operations."""
job_type: str = "motion_search"
camera: str = ""
start_time_range: float = 0.0
end_time_range: float = 0.0
polygon_points: list[list[float]] = field(default_factory=list)
threshold: int = 30
min_area: float = 5.0
parallel: bool = False
max_results: int = 25
username: str | None = None
cancelled_at: float | None = None
# Track progress
total_frames_processed: int = 0
# Live progress (ride the existing to_dict() websocket broadcast)
scanning_timestamp: float | None = None
progress: float = 0.0
# Metrics for observability
metrics: MotionSearchMetrics | None = None
def to_dict(self) -> dict[str, Any]:
"""Convert to dictionary for WebSocket transmission."""
d = asdict(self)
d.pop("username")
d.pop("cancelled_at")
if self.metrics:
d["metrics"] = self.metrics.to_dict()
return d
def create_polygon_mask(
polygon_points: list[list[float]], frame_width: int, frame_height: int
) -> np.ndarray:
"""Create a binary mask from normalized polygon coordinates."""
motion_points = np.array(
[[int(p[0] * frame_width), int(p[1] * frame_height)] for p in polygon_points],
dtype=np.int32,
)
mask = np.zeros((frame_height, frame_width), dtype=np.uint8)
cv2.fillPoly(mask, [motion_points], (255,))
return mask
def compute_roi_crop_and_scale(
polygon_points: list[list[float]],
frame_width: int,
frame_height: int,
scale_target: int,
) -> tuple[tuple[int, int, int, int], tuple[int, int]]:
"""Compute the ROI crop box and never-upscale scaled dimensions.
Returns ((crop_w, crop_h, crop_x, crop_y), (scaled_w, scaled_h)) in pixels.
The crop is the polygon's bounding box in frame pixels; the scaled size fits
the crop's longest side to ``scale_target`` without ever enlarging it.
"""
xs = [p[0] for p in polygon_points]
ys = [p[1] for p in polygon_points]
# nv12 (4:2:0) hwdownload requires even crop offsets and even crop/scale
# dimensions; otherwise ffmpeg rounds the chroma planes and the raw byte
# stream stops matching the expected frame size. Force even values, and the
# mask is built from these same values so the two stay aligned.
crop_x = int(min(xs) * frame_width)
crop_y = int(min(ys) * frame_height)
crop_x -= crop_x % 2
crop_y -= crop_y % 2
crop_w = max(2, int(max(xs) * frame_width) - crop_x)
crop_h = max(2, int(max(ys) * frame_height) - crop_y)
crop_w -= crop_w % 2
crop_h -= crop_h % 2
longest = max(crop_w, crop_h)
factor = min(1.0, scale_target / longest)
scaled_w = max(2, round(crop_w * factor))
scaled_h = max(2, round(crop_h * factor))
scaled_w -= scaled_w % 2
scaled_h -= scaled_h % 2
return (crop_w, crop_h, crop_x, crop_y), (scaled_w, scaled_h)
def build_scaled_roi_mask(
polygon_points: list[list[float]],
frame_width: int,
frame_height: int,
crop: tuple[int, int, int, int],
scaled: tuple[int, int],
) -> np.ndarray:
"""Rasterize the polygon mask at the scaled ROI size.
Builds the full-resolution mask, crops it to the ROI box, and nearest-
neighbor resizes it to the scaled dimensions so it lines up exactly with the
frames ffmpeg crops and scales.
"""
crop_w, crop_h, crop_x, crop_y = crop
scaled_w, scaled_h = scaled
full_mask = create_polygon_mask(polygon_points, frame_width, frame_height)
cropped = full_mask[crop_y : crop_y + crop_h, crop_x : crop_x + crop_w]
return cv2.resize(cropped, (scaled_w, scaled_h), interpolation=cv2.INTER_NEAREST)
def detect_motion_scaled(
frames: Iterable[tuple[int, np.ndarray]],
mask: np.ndarray,
threshold: int,
min_area: float,
timestamp_fn: Callable[[int], float],
) -> list[MotionSearchResult]:
"""Detect motion across pre-cropped, pre-scaled gray frames.
``frames`` yields (absolute_frame_index, gray_roi_frame); ``mask`` is the
scaled ROI mask. ``min_area`` is a percentage of the masked ROI. Mirrors the
full-res detection math (absdiff -> blur -> threshold -> dilate -> contours)
on the already-reduced frames.
"""
results: list[MotionSearchResult] = []
mask_area = np.count_nonzero(mask)
if mask_area == 0:
return results
min_area_pixels = int((min_area / 100.0) * mask_area)
prev: np.ndarray | None = None
for frame_idx, gray in frames:
masked = cv2.bitwise_and(gray, gray, mask=mask)
if prev is not None:
diff = cv2.absdiff(prev, masked)
diff_blurred = cv2.GaussianBlur(diff, (3, 3), 0)
_, thresh = cv2.threshold(diff_blurred, threshold, 255, cv2.THRESH_BINARY)
thresh_dilated = cv2.dilate(thresh, None, iterations=1) # type: ignore[call-overload]
thresh_masked = cv2.bitwise_and(thresh_dilated, thresh_dilated, mask=mask)
change_pixels = cv2.countNonZero(thresh_masked)
if change_pixels > min_area_pixels:
contours, _ = cv2.findContours(
thresh_masked, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
)
total_change_area = sum(
cv2.contourArea(c)
for c in contours
if cv2.contourArea(c) >= min_area_pixels
)
if total_change_area > 0:
change_percentage = (total_change_area / mask_area) * 100
results.append(
MotionSearchResult(
timestamp=timestamp_fn(frame_idx),
change_percentage=round(change_percentage, 2),
)
)
prev = masked
return results
def compute_roi_bbox_normalized(
polygon_points: list[list[float]],
) -> tuple[float, float, float, float]:
"""Compute the bounding box of the ROI in normalized coordinates (0-1).
Returns (x_min, y_min, x_max, y_max) in normalized coordinates.
"""
if not polygon_points:
return (0.0, 0.0, 1.0, 1.0)
x_coords = [p[0] for p in polygon_points]
y_coords = [p[1] for p in polygon_points]
return (min(x_coords), min(y_coords), max(x_coords), max(y_coords))
def heatmap_overlaps_roi(
heatmap: object, roi_bbox: tuple[float, float, float, float]
) -> bool:
"""Check if a sparse motion heatmap has any overlap with the ROI bounding box.
Args:
heatmap: Sparse dict mapping cell index (str) to intensity (1-255).
roi_bbox: (x_min, y_min, x_max, y_max) in normalized coordinates (0-1).
Returns:
True if there is overlap (any active cell in the ROI region).
"""
if not isinstance(heatmap, dict):
# Invalid heatmap, assume overlap to be safe
return True
x_min, y_min, x_max, y_max = roi_bbox
# Convert normalized coordinates to grid cells (0-15)
grid_x_min = max(0, int(x_min * HEATMAP_GRID_SIZE))
grid_y_min = max(0, int(y_min * HEATMAP_GRID_SIZE))
grid_x_max = min(HEATMAP_GRID_SIZE - 1, int(x_max * HEATMAP_GRID_SIZE))
grid_y_max = min(HEATMAP_GRID_SIZE - 1, int(y_max * HEATMAP_GRID_SIZE))
# Check each cell in the ROI bbox
for y in range(grid_y_min, grid_y_max + 1):
for x in range(grid_x_min, grid_x_max + 1):
idx = str(y * HEATMAP_GRID_SIZE + x)
if idx in heatmap:
return True
return False
def segment_passes_activity_gate(recording: Recordings) -> bool:
"""Check if a segment passes the activity gate.
Returns True if any of motion, objects, or regions is non-zero/non-null.
Returns True if all are null (old segments without data).
"""
motion: Any = recording.motion
objects: Any = recording.objects
regions: Any = recording.regions
# Old segments without metadata - pass through (conservative)
if motion is None and objects is None and regions is None:
return True
# Pass if any activity indicator is positive
return bool(motion) or bool(objects) or bool(regions)
def segment_passes_heatmap_gate(
recording: Recordings, roi_bbox: tuple[float, float, float, float]
) -> bool:
"""Check if a segment passes the heatmap overlap gate.
Returns True if:
- No heatmap is stored (old segments).
- The heatmap overlaps with the ROI bbox.
"""
heatmap = getattr(recording, "motion_heatmap", None)
if heatmap is None:
# No heatmap stored, fall back to activity gate
return True
return heatmap_overlaps_roi(heatmap, roi_bbox)
def resolve_internal_port(config: FrigateConfig) -> int:
"""Return the unauthenticated internal nginx port for VOD requests."""
listen = config.networking.listen.internal
if isinstance(listen, str):
return int(listen.split(":")[-1])
return int(listen)
def build_vod_url(internal_port: int, camera: str, start: float, end: float) -> str:
"""Build the internal VOD HLS URL for a camera time range."""
return (
f"http://127.0.0.1:{internal_port}/vod/{camera}"
f"/start/{start}/end/{end}/index.m3u8"
)
class MotionSearchRunner(threading.Thread):
"""Thread-based runner for motion search jobs with parallel verification."""
def __init__(
self,
job: MotionSearchJob,
config: FrigateConfig,
cancel_event: threading.Event,
) -> None:
super().__init__(daemon=True, name=f"motion_search_{job.id}")
self.job = job
self.config = config
self.cancel_event = cancel_event
self.internal_stop_event = threading.Event()
self.requestor = InterProcessRequestor()
self.metrics = MotionSearchMetrics()
self.job.metrics = self.metrics
# Worker cap: min(4, cpu_count)
cpu_count = os.cpu_count() or 1
self.max_workers = min(4, cpu_count)
# Resolved once per job in _execute_search
self.ffmpeg_path: str = "ffmpeg"
self.ffprobe_path: str = "ffprobe"
self.decode_args: list[str] = []
# Keyframe sampling decision, decided once per job from the first run's
# GOP. The fallback cadence is a fixed rate (see FALLBACK_SAMPLE_FPS).
self.use_keyframe: bool = True
self.fps_rate: float = FALLBACK_SAMPLE_FPS
# ROI crop/scale + scaled mask, computed once from the VOD-stream
# dimensions (which can differ from the detect resolution).
self.crop: tuple[int, int, int, int] = (0, 0, 0, 0)
self.scaled: tuple[int, int] = (0, 0)
self.scaled_mask: np.ndarray = np.zeros((0, 0), dtype=np.uint8)
self.channels: int = 1
self.internal_port: int = 5000
self._last_progress_broadcast: float = 0.0
def run(self) -> None:
"""Execute the motion search job."""
try:
self.job.status = JobStatusTypesEnum.running
self.job.start_time = datetime.now().timestamp()
self._broadcast_status()
results = self._execute_search()
if self.cancel_event.is_set():
self.job.status = JobStatusTypesEnum.cancelled
else:
self.job.status = JobStatusTypesEnum.success
self.job.results = {
"results": [r.to_dict() for r in results],
"total_frames_processed": self.job.total_frames_processed,
}
self.job.end_time = datetime.now().timestamp()
self.metrics.wall_time_seconds = self.job.end_time - self.job.start_time
self.job.metrics = self.metrics
logger.debug(
"Motion search job %s completed: status=%s, results=%d, frames=%d",
self.job.id,
self.job.status,
len(results),
self.job.total_frames_processed,
)
self._broadcast_status()
except Exception as e:
logger.exception("Motion search job %s failed: %s", self.job.id, e)
self.job.status = JobStatusTypesEnum.failed
self.job.error_message = str(e)
self.job.end_time = datetime.now().timestamp()
self.metrics.wall_time_seconds = self.job.end_time - (
self.job.start_time or 0
)
self.job.metrics = self.metrics
self._broadcast_status()
finally:
if self.requestor:
self.requestor.stop()
def _broadcast_status(self) -> None:
"""Broadcast job status update via IPC to WebSocket subscribers."""
if self.job.status == JobStatusTypesEnum.running and self.job.start_time:
self.metrics.wall_time_seconds = (
datetime.now().timestamp() - self.job.start_time
)
try:
self.requestor.send_data(UPDATE_JOB_STATE, self.job.to_dict())
except Exception as e:
logger.warning("Failed to broadcast motion search status: %s", e)
def _should_stop(self) -> bool:
"""Check if processing should stop due to cancellation or internal limits."""
return self.cancel_event.is_set() or self.internal_stop_event.is_set()
def _execute_search(self) -> list[MotionSearchResult]:
"""Main search execution logic."""
camera_name = self.job.camera
camera_config = self.config.cameras.get(camera_name)
if not camera_config:
raise ValueError(f"Camera {camera_name} not found")
frame_width = camera_config.detect.width
frame_height = camera_config.detect.height
if frame_width is None or frame_height is None:
raise ValueError(f"Camera {camera_name} detect dimensions not configured")
self.ffmpeg_path = camera_config.ffmpeg.ffmpeg_path
self.ffprobe_path = camera_config.ffmpeg.ffprobe_path
# Create polygon mask
polygon_mask = create_polygon_mask(
self.job.polygon_points, frame_width, frame_height
)
if np.count_nonzero(polygon_mask) == 0:
logger.warning("Polygon mask is empty for job %s", self.job.id)
return []
# Compute ROI bbox in normalized coordinates for heatmap gate
roi_bbox = compute_roi_bbox_normalized(self.job.polygon_points)
# Query recordings
recordings = list(
Recordings.select()
.where(
recording_overlap(self.job.start_time_range, self.job.end_time_range)
)
.where(Recordings.camera == camera_name)
.where(Recordings.stream_type == STREAM_TYPE_MAIN)
.order_by(Recordings.start_time.asc())
)
if not recordings:
logger.debug("No recordings found for motion search job %s", self.job.id)
return []
logger.debug(
"Motion search job %s: queried %d recording segments for camera %s "
"(range %.1f - %.1f)",
self.job.id,
len(recordings),
camera_name,
self.job.start_time_range,
self.job.end_time_range,
)
self.metrics.segments_scanned = len(recordings)
# Apply activity and heatmap gates
filtered_recordings = []
for recording in recordings:
if not segment_passes_activity_gate(recording):
self.metrics.metadata_inactive_segments += 1
self.metrics.segments_processed += 1
logger.debug(
"Motion search job %s: segment %s skipped by activity gate "
"(motion=%s, objects=%s, regions=%s)",
self.job.id,
recording.id,
recording.motion,
recording.objects,
recording.regions,
)
continue
if not segment_passes_heatmap_gate(recording, roi_bbox):
self.metrics.heatmap_roi_skip_segments += 1
self.metrics.segments_processed += 1
logger.debug(
"Motion search job %s: segment %s skipped by heatmap gate "
"(heatmap present=%s, roi_bbox=%s)",
self.job.id,
recording.id,
recording.motion_heatmap is not None,
roi_bbox,
)
continue
filtered_recordings.append(recording)
self._broadcast_status()
# Fallback: if all segments were filtered out, scan all segments
# This allows motion search to find things the detector missed
if not filtered_recordings and recordings:
logger.info(
"All %d segments filtered by gates, falling back to full scan",
len(recordings),
)
self.metrics.fallback_full_range_segments = len(recordings)
filtered_recordings = recordings
logger.debug(
"Motion search job %s: %d/%d segments passed gates "
"(activity_skipped=%d, heatmap_skipped=%d)",
self.job.id,
len(filtered_recordings),
len(recordings),
self.metrics.metadata_inactive_segments,
self.metrics.heatmap_roi_skip_segments,
)
# Resolve decode backend (allowlisted hwaccel or software), coalesce the
# gate-passing segments into time-contiguous runs, and probe the first
# run's VOD stream once for dimensions + keyframe layout. VOD output is
# what we decode, so crop/scale/mask are computed against it.
self.internal_port = resolve_internal_port(self.config)
self.decode_args = resolve_motion_decode_args(camera_config)
ffprobe_path = self.ffprobe_path
runs = coalesce_runs(filtered_recordings, MAX_RUN_SECONDS, RUN_GAP_EPSILON)
if not runs:
return []
first_run = runs[0]
first_url = build_vod_url(
self.internal_port,
camera_name,
float(first_run[0].start_time),
float(first_run[-1].end_time),
)
dims = probe_video_dimensions(ffprobe_path, first_url)
if dims is None:
raise ValueError(f"Could not probe VOD dimensions for camera {camera_name}")
rec_width, rec_height, _rec_fps = dims
self.crop, self.scaled = compute_roi_crop_and_scale(
self.job.polygon_points, rec_width, rec_height, SCALE_TARGET
)
self.scaled_mask = build_scaled_roi_mask(
self.job.polygon_points, rec_width, rec_height, self.crop, self.scaled
)
self.channels = 1 # always gray output
# Decide keyframe vs fixed-cadence sampling once from the first run's GOP
# (keyframe structure is a per-camera constant).
first_pts = probe_vod_keyframe_pts(ffprobe_path, first_url, self._should_stop)
self.use_keyframe = keyframe_sampling_eligible(first_pts)
logger.debug(
"Motion search job %s: %d runs, sampling=%s, hwaccel=%s, vod=%dx%d",
self.job.id,
len(runs),
"keyframe" if self.use_keyframe else "cadence",
bool(self.decode_args),
rec_width,
rec_height,
)
return self._search_runs(runs)
def _emit_progress(self, abs_ts: float) -> None:
"""Throttled intra-run progress broadcast (scanning cursor)."""
now = time.monotonic()
if now - self._last_progress_broadcast < PROGRESS_BROADCAST_INTERVAL:
return
self._last_progress_broadcast = now
self.job.scanning_timestamp = abs_ts
self._broadcast_status()
def _detect_with_progress(
self,
indexed_frames: list[tuple[int, np.ndarray]],
timestamp_fn: Callable[[int], float],
) -> list[MotionSearchResult]:
"""Run detection while firing throttled progress as frames are scanned."""
def _gen() -> Generator[tuple[int, np.ndarray], None, None]:
for i, frame in indexed_frames:
if not self._should_stop():
self._emit_progress(timestamp_fn(i))
yield i, frame
return detect_motion_scaled(
_gen(),
self.scaled_mask,
self.job.threshold,
self.job.min_area,
timestamp_fn,
)
def _process_run(
self, run: list[Recordings]
) -> tuple[list[MotionSearchResult], int]:
"""Decode one run's VOD stream and detect motion.
Keyframe mode compares every decoded keyframe (free recall, since they
are all decoded anyway) paired with its probed PTS; if the decoded and
probed counts disagree (the decoder ignored ``-skip_frame nokey`` or the
stream is corrupt) this run re-runs in the fixed-cadence fallback.
Returns ``(results, frame_count)``.
"""
run_start: float = run[0].start_time # type: ignore[assignment]
run_end: float = run[-1].end_time # type: ignore[assignment]
vod_url = build_vod_url(self.internal_port, self.job.camera, run_start, run_end)
time_map = build_segment_time_map(run)
if self.use_keyframe:
kf_pts = probe_vod_keyframe_pts(
self.ffprobe_path, vod_url, self._should_stop
)
frames = list(
iter_vod_frames(
self.ffmpeg_path,
vod_url,
self.scaled[0],
self.scaled[1],
self.channels,
self.decode_args,
self.crop,
self.scaled,
True,
self._should_stop,
skip_nonkey=True,
fps_rate=None,
)
)
if kf_pts and len(frames) == len(kf_pts):
abs_times = [stream_time_to_absolute(time_map, p) for p in kf_pts]
indexed = list(enumerate(frames))
def _ts_kf(i: int) -> float:
return abs_times[i]
results = self._detect_with_progress(indexed, _ts_kf)
return results, len(frames)
logger.debug(
"Keyframe count mismatch (%d decoded vs %d probed), using cadence",
len(frames),
len(kf_pts),
)
return self._process_run_cadence(vod_url, time_map)
def _process_run_cadence(
self, vod_url: str, time_map: list[tuple[float, float, float]]
) -> tuple[list[MotionSearchResult], int]:
"""Fixed-cadence fallback: fps-filtered VOD decode, evenly spaced times."""
frames = list(
iter_vod_frames(
self.ffmpeg_path,
vod_url,
self.scaled[0],
self.scaled[1],
self.channels,
self.decode_args,
self.crop,
self.scaled,
True,
self._should_stop,
skip_nonkey=False,
fps_rate=self.fps_rate,
)
)
indexed = list(enumerate(frames))
def _ts_fps(i: int) -> float:
return stream_time_to_absolute(time_map, i / self.fps_rate)
results = self._detect_with_progress(indexed, _ts_fps)
return results, len(frames)
def _merge_run(
self,
run: list[Recordings],
run_results: list[MotionSearchResult],
frames: int,
state: dict[str, Any],
) -> bool:
"""Fold one run's output into the running results; stream + dedup.
Returns True once ``max_results`` deduped hits have accumulated.
"""
state["completed_runs"] += 1
state["all_results"].extend(run_results)
state["total_frames"] += frames
self.job.total_frames_processed = state["total_frames"]
self.metrics.frames_decoded = state["total_frames"]
self.metrics.segments_processed += len(run)
self.job.progress = state["completed_runs"] / state["total_runs"]
state["all_results"].sort(key=lambda r: r.timestamp)
deduped = self._deduplicate_results(state["all_results"])[
: self.job.max_results
]
self.job.results = {
"results": [r.to_dict() for r in deduped],
"total_frames_processed": state["total_frames"],
}
self._broadcast_status()
return len(deduped) >= self.job.max_results
def _search_runs(self, runs: list[list[Recordings]]) -> list[MotionSearchResult]:
"""Decode runs (parallel pool when enabled), merge in order, stream."""
state: dict[str, Any] = {
"all_results": [],
"total_frames": 0,
"completed_runs": 0,
"total_runs": len(runs),
}
self.job.results = {"results": [], "total_frames_processed": 0}
logger.debug(
"Motion search job %s: searching %d runs (parallel=%s, workers=%d)",
self.job.id,
len(runs),
self.job.parallel,
self.max_workers,
)
if self.job.parallel and len(runs) > 1:
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
futures: dict[Future, int] = {}
for idx, run in enumerate(runs):
if self._should_stop():
break
futures[executor.submit(self._process_run, run)] = idx
completed: dict[int, tuple[list[MotionSearchResult], int]] = {}
next_idx = 0
for future in as_completed(futures):
if self._should_stop():
break
run_idx = futures[future]
try:
completed[run_idx] = future.result()
except Exception as e:
self.metrics.segments_with_errors += 1
logger.warning("Error processing run %d: %s", run_idx, e)
completed[run_idx] = ([], 0)
while next_idx in completed:
run_results, frames = completed.pop(next_idx)
if self._merge_run(runs[next_idx], run_results, frames, state):
self.internal_stop_event.set()
break
next_idx += 1
if self.internal_stop_event.is_set():
break
# Queued runs would otherwise still execute when the pool exits.
for pending in futures:
pending.cancel()
else:
for run in runs:
if self._should_stop():
break
try:
run_results, frames = self._process_run(run)
except Exception as e:
self.metrics.segments_with_errors += 1
self.metrics.segments_processed += len(run)
self._broadcast_status()
logger.warning("Error processing run: %s", e)
continue
if self._merge_run(run, run_results, frames, state):
break
all_results: list[MotionSearchResult] = state["all_results"]
self.job.total_frames_processed = state["total_frames"]
self.metrics.frames_decoded = state["total_frames"]
self.job.progress = 1.0
logger.debug(
"Motion search job %s: complete, %d raw results, %d frames, %d errors",
self.job.id,
len(all_results),
state["total_frames"],
self.metrics.segments_with_errors,
)
all_results.sort(key=lambda r: r.timestamp)
return self._deduplicate_results(all_results)[: self.job.max_results]
def _deduplicate_results(
self, results: list[MotionSearchResult], min_gap: float = 1.0
) -> list[MotionSearchResult]:
"""Deduplicate results that are too close together."""
if not results:
return results
deduplicated: list[MotionSearchResult] = []
last_timestamp = 0.0
for result in results:
if result.timestamp - last_timestamp >= min_gap:
deduplicated.append(result)
last_timestamp = result.timestamp
return deduplicated
class MotionSearchUserBusyError(Exception):
"""The user already has an active motion search job."""
class MotionSearchCapacityError(Exception):
"""The global limit of active motion search jobs is reached."""
_motion_search_jobs: dict[str, tuple[MotionSearchJob, threading.Event]] = {}
_jobs_lock = threading.Lock()
def _evict_finished_jobs(now: float) -> None:
"""Drop jobs that finished more than FINISHED_JOB_TTL ago. Caller holds the lock."""
expired = [
job_id
for job_id, (job, _) in _motion_search_jobs.items()
if job.end_time is not None and now - job.end_time > FINISHED_JOB_TTL
]
for job_id in expired:
del _motion_search_jobs[job_id]
def _is_active(job: MotionSearchJob, now: float) -> bool:
"""True while the job's thread may still be running and holding a slot."""
if job.end_time is not None:
return False
return job.cancelled_at is None or now - job.cancelled_at < CANCEL_GRACE_SECONDS
def stop_all_motion_search_jobs() -> None:
"""Cancel all running motion search jobs for clean shutdown."""
with _jobs_lock:
for job_id, (job, cancel_event) in _motion_search_jobs.items():
if job.status in (JobStatusTypesEnum.queued, JobStatusTypesEnum.running):
cancel_event.set()
logger.debug("Signalling motion search job %s to stop", job_id)
def start_motion_search_job(
config: FrigateConfig,
camera_name: str,
start_time: float,
end_time: float,
polygon_points: list[list[float]],
threshold: int = 30,
min_area: float = 5.0,
parallel: bool = False,
max_results: int = 25,
username: str | None = None,
) -> str:
"""Start a new motion search job.
Returns the job ID. Raises MotionSearchUserBusyError when ``username`` already
has an active job and MotionSearchCapacityError when MAX_ACTIVE_JOBS are active.
Pass ``username=None`` when callers share an identity to skip the per-user check.
"""
job = MotionSearchJob(
camera=camera_name,
start_time_range=start_time,
end_time_range=end_time,
polygon_points=polygon_points,
threshold=threshold,
min_area=min_area,
parallel=parallel,
max_results=max_results,
username=username,
)
cancel_event = threading.Event()
with _jobs_lock:
now = datetime.now().timestamp()
_evict_finished_jobs(now)
# A cancelled job keeps its thread until the runner sets end_time, so it
# still counts toward the global cap. Its user may start a new search.
active = [j for j, _ in _motion_search_jobs.values() if _is_active(j, now)]
if username is not None and any(
j.username == username and j.status != JobStatusTypesEnum.cancelled
for j in active
):
raise MotionSearchUserBusyError()
if len(active) >= MAX_ACTIVE_JOBS:
raise MotionSearchCapacityError()
_motion_search_jobs[job.id] = (job, cancel_event)
runner: MotionSearchRunner | None = None
try:
runner = MotionSearchRunner(job, config, cancel_event)
runner.start()
except Exception:
if runner is not None:
runner.requestor.stop()
with _jobs_lock:
del _motion_search_jobs[job.id]
raise
logger.debug(
"Started motion search job %s for camera %s: "
"time_range=%.1f-%.1f, threshold=%d, min_area=%.1f%%, "
"parallel=%s, max_results=%d, polygon_points=%d vertices",
job.id,
camera_name,
start_time,
end_time,
threshold,
min_area,
parallel,
max_results,
len(polygon_points),
)
return job.id
def get_motion_search_job(job_id: str) -> MotionSearchJob | None:
"""Get a motion search job by ID."""
with _jobs_lock:
job_entry = _motion_search_jobs.get(job_id)
return job_entry[0] if job_entry else None
def cancel_motion_search_job(job_id: str) -> bool:
"""Cancel a motion search job.
Returns True if cancellation was initiated, False if job not found.
"""
with _jobs_lock:
job_entry = _motion_search_jobs.get(job_id)
if not job_entry:
return False
job, cancel_event = job_entry
if job.status not in (JobStatusTypesEnum.queued, JobStatusTypesEnum.running):
# Already finished
return True
cancel_event.set()
job.status = JobStatusTypesEnum.cancelled
job.cancelled_at = datetime.now().timestamp()
job_payload = job.to_dict()
logger.info("Cancelled motion search job %s", job_id)
requestor: InterProcessRequestor | None = None
try:
requestor = InterProcessRequestor()
requestor.send_data(UPDATE_JOB_STATE, job_payload)
except Exception as e:
logger.warning(
"Failed to broadcast cancelled motion search job %s: %s", job_id, e
)
finally:
if requestor:
requestor.stop()
return True