mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-19 13:12:48 +03:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd08ebe2a9 | ||
|
|
babe15b9e8 | ||
|
|
9d17e55052 | ||
|
|
1683d4a0cc | ||
|
|
1eeb293f65 | ||
|
|
535650092e | ||
|
|
f850269620 |
@@ -9,7 +9,10 @@ from pydantic import BaseModel, Field
|
|||||||
|
|
||||||
from frigate.api.auth import require_camera_access
|
from frigate.api.auth import require_camera_access
|
||||||
from frigate.api.defs.tags import Tags
|
from frigate.api.defs.tags import Tags
|
||||||
|
from frigate.config import FrigateConfig
|
||||||
from frigate.jobs.motion_search import (
|
from frigate.jobs.motion_search import (
|
||||||
|
MotionSearchCapacityError,
|
||||||
|
MotionSearchUserBusyError,
|
||||||
cancel_motion_search_job,
|
cancel_motion_search_job,
|
||||||
get_motion_search_job,
|
get_motion_search_job,
|
||||||
start_motion_search_job,
|
start_motion_search_job,
|
||||||
@@ -95,6 +98,20 @@ class MotionSearchStatusResponse(BaseModel):
|
|||||||
progress: 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(
|
@router.post(
|
||||||
"/{camera_name}/search/motion",
|
"/{camera_name}/search/motion",
|
||||||
response_model=MotionSearchStartResponse,
|
response_model=MotionSearchStartResponse,
|
||||||
@@ -138,18 +155,35 @@ async def start_motion_search(
|
|||||||
status_code=400,
|
status_code=400,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Start the job using the jobs module
|
try:
|
||||||
job_id = start_motion_search_job(
|
job_id = start_motion_search_job(
|
||||||
config=config,
|
config=config,
|
||||||
camera_name=camera_name,
|
camera_name=camera_name,
|
||||||
start_time=body.start_time,
|
start_time=body.start_time,
|
||||||
end_time=body.end_time,
|
end_time=body.end_time,
|
||||||
polygon_points=body.polygon_points,
|
polygon_points=body.polygon_points,
|
||||||
threshold=body.threshold,
|
threshold=body.threshold,
|
||||||
min_area=body.min_area,
|
min_area=body.min_area,
|
||||||
parallel=body.parallel,
|
parallel=body.parallel,
|
||||||
max_results=body.max_results,
|
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(
|
return JSONResponse(
|
||||||
content={
|
content={
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from collections.abc import Callable, Generator, Iterable
|
|||||||
from concurrent.futures import Future, ThreadPoolExecutor, as_completed
|
from concurrent.futures import Future, ThreadPoolExecutor, as_completed
|
||||||
from dataclasses import asdict, dataclass, field
|
from dataclasses import asdict, dataclass, field
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, cast
|
from typing import Any
|
||||||
|
|
||||||
import cv2
|
import cv2
|
||||||
import numpy as np
|
import numpy as np
|
||||||
@@ -17,10 +17,6 @@ from frigate.comms.inter_process import InterProcessRequestor
|
|||||||
from frigate.config import FrigateConfig
|
from frigate.config import FrigateConfig
|
||||||
from frigate.const import STREAM_TYPE_MAIN, UPDATE_JOB_STATE
|
from frigate.const import STREAM_TYPE_MAIN, UPDATE_JOB_STATE
|
||||||
from frigate.jobs.job import Job
|
from frigate.jobs.job import Job
|
||||||
from frigate.jobs.manager import (
|
|
||||||
get_job_by_id,
|
|
||||||
set_current_job,
|
|
||||||
)
|
|
||||||
from frigate.jobs.motion_search_batch import (
|
from frigate.jobs.motion_search_batch import (
|
||||||
build_segment_time_map,
|
build_segment_time_map,
|
||||||
coalesce_runs,
|
coalesce_runs,
|
||||||
@@ -53,6 +49,12 @@ PROGRESS_BROADCAST_INTERVAL = 1.0
|
|||||||
# Output frame rate for the fixed-cadence fallback used on long-GOP cameras
|
# Output frame rate for the fixed-cadence fallback used on long-GOP cameras
|
||||||
# (where keyframe sampling is too sparse). Keyframe cameras ignore this.
|
# (where keyframe sampling is too sparse). Keyframe cameras ignore this.
|
||||||
FALLBACK_SAMPLE_FPS = 2.0
|
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
|
@dataclass
|
||||||
@@ -98,6 +100,8 @@ class MotionSearchJob(Job):
|
|||||||
min_area: float = 5.0
|
min_area: float = 5.0
|
||||||
parallel: bool = False
|
parallel: bool = False
|
||||||
max_results: int = 25
|
max_results: int = 25
|
||||||
|
username: str | None = None
|
||||||
|
cancelled_at: float | None = None
|
||||||
|
|
||||||
# Track progress
|
# Track progress
|
||||||
total_frames_processed: int = 0
|
total_frames_processed: int = 0
|
||||||
@@ -112,6 +116,8 @@ class MotionSearchJob(Job):
|
|||||||
def to_dict(self) -> dict[str, Any]:
|
def to_dict(self) -> dict[str, Any]:
|
||||||
"""Convert to dictionary for WebSocket transmission."""
|
"""Convert to dictionary for WebSocket transmission."""
|
||||||
d = asdict(self)
|
d = asdict(self)
|
||||||
|
d.pop("username")
|
||||||
|
d.pop("cancelled_at")
|
||||||
if self.metrics:
|
if self.metrics:
|
||||||
d["metrics"] = self.metrics.to_dict()
|
d["metrics"] = self.metrics.to_dict()
|
||||||
return d
|
return d
|
||||||
@@ -579,7 +585,7 @@ class MotionSearchRunner(threading.Thread):
|
|||||||
|
|
||||||
# Decide keyframe vs fixed-cadence sampling once from the first run's GOP
|
# Decide keyframe vs fixed-cadence sampling once from the first run's GOP
|
||||||
# (keyframe structure is a per-camera constant).
|
# (keyframe structure is a per-camera constant).
|
||||||
first_pts = probe_vod_keyframe_pts(ffprobe_path, first_url)
|
first_pts = probe_vod_keyframe_pts(ffprobe_path, first_url, self._should_stop)
|
||||||
self.use_keyframe = keyframe_sampling_eligible(first_pts)
|
self.use_keyframe = keyframe_sampling_eligible(first_pts)
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
@@ -641,7 +647,9 @@ class MotionSearchRunner(threading.Thread):
|
|||||||
time_map = build_segment_time_map(run)
|
time_map = build_segment_time_map(run)
|
||||||
|
|
||||||
if self.use_keyframe:
|
if self.use_keyframe:
|
||||||
kf_pts = probe_vod_keyframe_pts(self.ffprobe_path, vod_url)
|
kf_pts = probe_vod_keyframe_pts(
|
||||||
|
self.ffprobe_path, vod_url, self._should_stop
|
||||||
|
)
|
||||||
frames = list(
|
frames = list(
|
||||||
iter_vod_frames(
|
iter_vod_frames(
|
||||||
self.ffmpeg_path,
|
self.ffmpeg_path,
|
||||||
@@ -777,13 +785,15 @@ class MotionSearchRunner(threading.Thread):
|
|||||||
run_results, frames = completed.pop(next_idx)
|
run_results, frames = completed.pop(next_idx)
|
||||||
if self._merge_run(runs[next_idx], run_results, frames, state):
|
if self._merge_run(runs[next_idx], run_results, frames, state):
|
||||||
self.internal_stop_event.set()
|
self.internal_stop_event.set()
|
||||||
for pending in futures:
|
|
||||||
pending.cancel()
|
|
||||||
break
|
break
|
||||||
next_idx += 1
|
next_idx += 1
|
||||||
|
|
||||||
if self.internal_stop_event.is_set():
|
if self.internal_stop_event.is_set():
|
||||||
break
|
break
|
||||||
|
|
||||||
|
# Queued runs would otherwise still execute when the pool exits.
|
||||||
|
for pending in futures:
|
||||||
|
pending.cancel()
|
||||||
else:
|
else:
|
||||||
for run in runs:
|
for run in runs:
|
||||||
if self._should_stop():
|
if self._should_stop():
|
||||||
@@ -833,11 +843,36 @@ class MotionSearchRunner(threading.Thread):
|
|||||||
return deduplicated
|
return deduplicated
|
||||||
|
|
||||||
|
|
||||||
# Module-level state for managing per-camera jobs
|
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]] = {}
|
_motion_search_jobs: dict[str, tuple[MotionSearchJob, threading.Event]] = {}
|
||||||
_jobs_lock = threading.Lock()
|
_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:
|
def stop_all_motion_search_jobs() -> None:
|
||||||
"""Cancel all running motion search jobs for clean shutdown."""
|
"""Cancel all running motion search jobs for clean shutdown."""
|
||||||
with _jobs_lock:
|
with _jobs_lock:
|
||||||
@@ -857,10 +892,13 @@ def start_motion_search_job(
|
|||||||
min_area: float = 5.0,
|
min_area: float = 5.0,
|
||||||
parallel: bool = False,
|
parallel: bool = False,
|
||||||
max_results: int = 25,
|
max_results: int = 25,
|
||||||
|
username: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Start a new motion search job.
|
"""Start a new motion search job.
|
||||||
|
|
||||||
Returns the job ID.
|
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(
|
job = MotionSearchJob(
|
||||||
camera=camera_name,
|
camera=camera_name,
|
||||||
@@ -871,17 +909,38 @@ def start_motion_search_job(
|
|||||||
min_area=min_area,
|
min_area=min_area,
|
||||||
parallel=parallel,
|
parallel=parallel,
|
||||||
max_results=max_results,
|
max_results=max_results,
|
||||||
|
username=username,
|
||||||
)
|
)
|
||||||
|
|
||||||
cancel_event = threading.Event()
|
cancel_event = threading.Event()
|
||||||
|
|
||||||
with _jobs_lock:
|
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)
|
_motion_search_jobs[job.id] = (job, cancel_event)
|
||||||
|
|
||||||
set_current_job(job)
|
runner: MotionSearchRunner | None = None
|
||||||
|
try:
|
||||||
runner = MotionSearchRunner(job, config, cancel_event)
|
runner = MotionSearchRunner(job, config, cancel_event)
|
||||||
runner.start()
|
runner.start()
|
||||||
|
except Exception:
|
||||||
|
if runner is not None:
|
||||||
|
runner.requestor.stop()
|
||||||
|
with _jobs_lock:
|
||||||
|
del _motion_search_jobs[job.id]
|
||||||
|
raise
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Started motion search job %s for camera %s: "
|
"Started motion search job %s for camera %s: "
|
||||||
@@ -904,10 +963,7 @@ def get_motion_search_job(job_id: str) -> MotionSearchJob | None:
|
|||||||
"""Get a motion search job by ID."""
|
"""Get a motion search job by ID."""
|
||||||
with _jobs_lock:
|
with _jobs_lock:
|
||||||
job_entry = _motion_search_jobs.get(job_id)
|
job_entry = _motion_search_jobs.get(job_id)
|
||||||
if job_entry:
|
return job_entry[0] if job_entry else None
|
||||||
return job_entry[0]
|
|
||||||
# Check completed jobs via manager
|
|
||||||
return cast(MotionSearchJob | None, get_job_by_id("motion_search", job_id))
|
|
||||||
|
|
||||||
|
|
||||||
def cancel_motion_search_job(job_id: str) -> bool:
|
def cancel_motion_search_job(job_id: str) -> bool:
|
||||||
@@ -928,6 +984,7 @@ def cancel_motion_search_job(job_id: str) -> bool:
|
|||||||
|
|
||||||
cancel_event.set()
|
cancel_event.set()
|
||||||
job.status = JobStatusTypesEnum.cancelled
|
job.status = JobStatusTypesEnum.cancelled
|
||||||
|
job.cancelled_at = datetime.now().timestamp()
|
||||||
job_payload = job.to_dict()
|
job_payload = job.to_dict()
|
||||||
logger.info("Cancelled motion search job %s", job_id)
|
logger.info("Cancelled motion search job %s", job_id)
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import subprocess as sp
|
import subprocess as sp
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import time
|
||||||
from collections.abc import Callable, Generator
|
from collections.abc import Callable, Generator
|
||||||
from typing import IO
|
from typing import IO
|
||||||
|
|
||||||
@@ -264,11 +265,16 @@ def iter_vod_frames(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def probe_vod_keyframe_pts(ffprobe_path: str, vod_url: str) -> list[float]:
|
KEYFRAME_PROBE_TIMEOUT = 120.0
|
||||||
|
|
||||||
|
|
||||||
|
def probe_vod_keyframe_pts(
|
||||||
|
ffprobe_path: str, vod_url: str, should_stop: Callable[[], bool]
|
||||||
|
) -> list[float]:
|
||||||
"""Return keyframe presentation timestamps (VOD stream time) in order.
|
"""Return keyframe presentation timestamps (VOD stream time) in order.
|
||||||
|
|
||||||
Reads packet flags via ffprobe over the VOD URL (no decode). Returns [] on
|
Reads packet flags via ffprobe over the VOD URL (no decode). Returns [] on
|
||||||
any failure so the caller can fall back.
|
any failure, timeout, or stop request so the caller can fall back.
|
||||||
"""
|
"""
|
||||||
cmd = [
|
cmd = [
|
||||||
ffprobe_path,
|
ffprobe_path,
|
||||||
@@ -286,16 +292,27 @@ def probe_vod_keyframe_pts(ffprobe_path: str, vod_url: str) -> list[float]:
|
|||||||
"json",
|
"json",
|
||||||
]
|
]
|
||||||
try:
|
try:
|
||||||
completed = sp.run(cmd, capture_output=True, text=True, timeout=120)
|
proc = sp.Popen(cmd, stdout=sp.PIPE, stderr=sp.DEVNULL, text=True)
|
||||||
except (OSError, sp.SubprocessError):
|
except OSError:
|
||||||
logger.warning("ffprobe failed for VOD keyframe probe")
|
logger.warning("ffprobe failed for VOD keyframe probe")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
if completed.returncode != 0 or not completed.stdout:
|
deadline = time.monotonic() + KEYFRAME_PROBE_TIMEOUT
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
stdout, _ = proc.communicate(timeout=0.5)
|
||||||
|
break
|
||||||
|
except sp.TimeoutExpired:
|
||||||
|
if should_stop() or time.monotonic() > deadline:
|
||||||
|
proc.kill()
|
||||||
|
proc.communicate()
|
||||||
|
return []
|
||||||
|
|
||||||
|
if proc.returncode != 0 or not stdout:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
packets = json.loads(completed.stdout).get("packets", [])
|
packets = json.loads(stdout).get("packets", [])
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
"""Tests for motion search job limits."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from frigate.jobs import motion_search
|
||||||
|
from frigate.models import Event, Recordings, ReviewSegment
|
||||||
|
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
|
||||||
|
|
||||||
|
URL = "/front_door/search/motion"
|
||||||
|
BODY = {
|
||||||
|
"start_time": 1000,
|
||||||
|
"end_time": 2000,
|
||||||
|
"polygon_points": [[0, 0], [1, 0], [1, 1]],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestHttpMotionSearch(BaseTestHttp):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp([Event, Recordings, ReviewSegment])
|
||||||
|
self.app = self.create_app()
|
||||||
|
|
||||||
|
motion_search._motion_search_jobs.clear()
|
||||||
|
self.addCleanup(motion_search._motion_search_jobs.clear)
|
||||||
|
|
||||||
|
runner_patch = patch("frigate.jobs.motion_search.MotionSearchRunner")
|
||||||
|
self.runner = runner_patch.start()
|
||||||
|
self.addCleanup(runner_patch.stop)
|
||||||
|
|
||||||
|
def start(self, client: AuthTestClient, user: str, body: dict = BODY):
|
||||||
|
return client.post(URL, json=body, headers={"remote-user": user})
|
||||||
|
|
||||||
|
def test_second_job_for_same_user_rejected(self):
|
||||||
|
with AuthTestClient(self.app) as client:
|
||||||
|
self.assertEqual(self.start(client, "alice").status_code, 200)
|
||||||
|
self.assertEqual(self.start(client, "alice").status_code, 409)
|
||||||
|
self.assertEqual(self.start(client, "bob").status_code, 200)
|
||||||
|
|
||||||
|
def test_user_can_start_again_after_job_finishes(self):
|
||||||
|
with AuthTestClient(self.app) as client:
|
||||||
|
job_id = self.start(client, "alice").json()["job_id"]
|
||||||
|
job = motion_search.get_motion_search_job(job_id)
|
||||||
|
job.end_time = datetime.now().timestamp()
|
||||||
|
self.assertEqual(self.start(client, "alice").status_code, 200)
|
||||||
|
self.assertIsNotNone(motion_search.get_motion_search_job(job_id))
|
||||||
|
|
||||||
|
def test_cancelled_job_frees_user_but_holds_global_slot_until_grace(self):
|
||||||
|
with AuthTestClient(self.app) as client:
|
||||||
|
job_id = self.start(client, "alice").json()["job_id"]
|
||||||
|
with patch("frigate.jobs.motion_search.InterProcessRequestor"):
|
||||||
|
self.assertTrue(motion_search.cancel_motion_search_job(job_id))
|
||||||
|
job = motion_search.get_motion_search_job(job_id)
|
||||||
|
self.assertIsNotNone(job.cancelled_at)
|
||||||
|
self.assertIsNone(job.end_time)
|
||||||
|
|
||||||
|
self.assertEqual(self.start(client, "alice").status_code, 200)
|
||||||
|
self.assertEqual(self.start(client, "bob").status_code, 200)
|
||||||
|
self.assertEqual(self.start(client, "carol").status_code, 429)
|
||||||
|
|
||||||
|
job.cancelled_at -= motion_search.CANCEL_GRACE_SECONDS + 1
|
||||||
|
self.assertEqual(self.start(client, "carol").status_code, 200)
|
||||||
|
|
||||||
|
def test_start_failure_releases_slot(self):
|
||||||
|
self.runner.return_value.start.side_effect = RuntimeError("boom")
|
||||||
|
with AuthTestClient(self.app) as client:
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
self.start(client, "alice")
|
||||||
|
self.assertEqual(motion_search._motion_search_jobs, {})
|
||||||
|
self.runner.return_value.requestor.stop.assert_called_once()
|
||||||
|
|
||||||
|
self.runner.return_value.start.side_effect = None
|
||||||
|
self.assertEqual(self.start(client, "alice").status_code, 200)
|
||||||
|
|
||||||
|
def test_shared_identity_skips_per_user_limit(self):
|
||||||
|
with AuthTestClient(self.app) as client:
|
||||||
|
self.assertEqual(self.start(client, "anonymous").status_code, 200)
|
||||||
|
self.assertEqual(self.start(client, "anonymous").status_code, 200)
|
||||||
|
|
||||||
|
motion_search._motion_search_jobs.clear()
|
||||||
|
self.minimal_config["auth"] = {"enabled": False}
|
||||||
|
app = self.create_app()
|
||||||
|
with AuthTestClient(app) as client:
|
||||||
|
self.assertEqual(self.start(client, "viewer").status_code, 200)
|
||||||
|
self.assertEqual(self.start(client, "viewer").status_code, 200)
|
||||||
|
|
||||||
|
def test_proxy_user_header_keeps_per_user_limit_without_auth(self):
|
||||||
|
self.minimal_config["auth"] = {"enabled": False}
|
||||||
|
self.minimal_config["proxy"] = {"header_map": {"user": "x-forwarded-user"}}
|
||||||
|
app = self.create_app()
|
||||||
|
with AuthTestClient(app) as client:
|
||||||
|
self.assertEqual(self.start(client, "alice").status_code, 200)
|
||||||
|
self.assertEqual(self.start(client, "alice").status_code, 409)
|
||||||
|
|
||||||
|
def test_global_cap_enforced(self):
|
||||||
|
with AuthTestClient(self.app) as client:
|
||||||
|
for i in range(motion_search.MAX_ACTIVE_JOBS):
|
||||||
|
self.assertEqual(self.start(client, f"user{i}").status_code, 200)
|
||||||
|
self.assertEqual(self.start(client, "late").status_code, 429)
|
||||||
|
|
||||||
|
def test_finished_jobs_evicted_after_ttl(self):
|
||||||
|
with AuthTestClient(self.app) as client:
|
||||||
|
old_id = self.start(client, "alice").json()["job_id"]
|
||||||
|
recent_id = self.start(client, "bob").json()["job_id"]
|
||||||
|
running_id = self.start(client, "carol").json()["job_id"]
|
||||||
|
|
||||||
|
now = 10_000.0
|
||||||
|
motion_search.get_motion_search_job(old_id).end_time = (
|
||||||
|
now - motion_search.FINISHED_JOB_TTL - 1
|
||||||
|
)
|
||||||
|
motion_search.get_motion_search_job(recent_id).end_time = now - 1
|
||||||
|
motion_search._evict_finished_jobs(now)
|
||||||
|
|
||||||
|
self.assertIsNone(motion_search.get_motion_search_job(old_id))
|
||||||
|
self.assertIsNotNone(motion_search.get_motion_search_job(recent_id))
|
||||||
|
self.assertIsNotNone(motion_search.get_motion_search_job(running_id))
|
||||||
|
|
||||||
|
def test_username_and_cancel_time_not_broadcast(self):
|
||||||
|
job = motion_search.MotionSearchJob(username="alice", cancelled_at=1.0)
|
||||||
|
payload = job.to_dict()
|
||||||
|
self.assertNotIn("username", payload)
|
||||||
|
self.assertNotIn("cancelled_at", payload)
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Tests for the motion search hardware-accelerated decode helpers."""
|
"""Tests for the motion search hardware-accelerated decode helpers."""
|
||||||
|
|
||||||
|
import subprocess as sp
|
||||||
import unittest
|
import unittest
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
@@ -140,6 +141,11 @@ class TestVodDecodeCommand(unittest.TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class TestProbeVodKeyframePts(unittest.TestCase):
|
class TestProbeVodKeyframePts(unittest.TestCase):
|
||||||
|
def _proc(self, stdout="", returncode=0):
|
||||||
|
proc = mock.Mock(returncode=returncode)
|
||||||
|
proc.communicate.return_value = (stdout, None)
|
||||||
|
return proc
|
||||||
|
|
||||||
def test_parses_keyframe_packets(self):
|
def test_parses_keyframe_packets(self):
|
||||||
sample = (
|
sample = (
|
||||||
'{"packets":['
|
'{"packets":['
|
||||||
@@ -147,19 +153,37 @@ class TestProbeVodKeyframePts(unittest.TestCase):
|
|||||||
'{"pts_time":"1.000000","flags":"___"},'
|
'{"pts_time":"1.000000","flags":"___"},'
|
||||||
'{"pts_time":"2.000000","flags":"K__"}]}'
|
'{"pts_time":"2.000000","flags":"K__"}]}'
|
||||||
)
|
)
|
||||||
completed = mock.Mock(stdout=sample, returncode=0)
|
|
||||||
with mock.patch(
|
with mock.patch(
|
||||||
"frigate.jobs.motion_search_decode.sp.run", return_value=completed
|
"frigate.jobs.motion_search_decode.sp.Popen",
|
||||||
|
return_value=self._proc(sample),
|
||||||
):
|
):
|
||||||
pts = probe_vod_keyframe_pts("ffprobe", "http://x/index.m3u8")
|
pts = probe_vod_keyframe_pts(
|
||||||
|
"ffprobe", "http://x/index.m3u8", lambda: False
|
||||||
|
)
|
||||||
self.assertEqual(pts, [0.0, 2.0])
|
self.assertEqual(pts, [0.0, 2.0])
|
||||||
|
|
||||||
def test_returns_empty_on_failure(self):
|
def test_returns_empty_on_failure(self):
|
||||||
with mock.patch(
|
with mock.patch(
|
||||||
"frigate.jobs.motion_search_decode.sp.run",
|
"frigate.jobs.motion_search_decode.sp.Popen",
|
||||||
side_effect=OSError("boom"),
|
side_effect=OSError("boom"),
|
||||||
):
|
):
|
||||||
self.assertEqual(probe_vod_keyframe_pts("ffprobe", "http://x"), [])
|
self.assertEqual(
|
||||||
|
probe_vod_keyframe_pts("ffprobe", "http://x", lambda: False), []
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_stop_request_kills_probe(self):
|
||||||
|
proc = self._proc()
|
||||||
|
proc.communicate.side_effect = [
|
||||||
|
sp.TimeoutExpired("ffprobe", 0.5),
|
||||||
|
("", None),
|
||||||
|
]
|
||||||
|
with mock.patch(
|
||||||
|
"frigate.jobs.motion_search_decode.sp.Popen", return_value=proc
|
||||||
|
):
|
||||||
|
self.assertEqual(
|
||||||
|
probe_vod_keyframe_pts("ffprobe", "http://x", lambda: True), []
|
||||||
|
)
|
||||||
|
proc.kill.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
class TestProbeVideoDimensions(unittest.TestCase):
|
class TestProbeVideoDimensions(unittest.TestCase):
|
||||||
|
|||||||
@@ -56,6 +56,8 @@
|
|||||||
"noROI": "Please draw a region of interest",
|
"noROI": "Please draw a region of interest",
|
||||||
"noTimeRange": "Please select a time range",
|
"noTimeRange": "Please select a time range",
|
||||||
"invalidTimeRange": "End time must be after start time",
|
"invalidTimeRange": "End time must be after start time",
|
||||||
|
"alreadyRunning": "You already have a motion search running. Wait for it to finish or cancel it.",
|
||||||
|
"tooManySearches": "Too many motion searches are running. Try again when one finishes.",
|
||||||
"searchFailed": "Search failed: {{message}}",
|
"searchFailed": "Search failed: {{message}}",
|
||||||
"polygonTooSmall": "Polygon must have at least 3 points",
|
"polygonTooSmall": "Polygon must have at least 3 points",
|
||||||
"unknown": "Unknown error"
|
"unknown": "Unknown error"
|
||||||
|
|||||||
@@ -892,6 +892,20 @@ export default function MotionSearchView({
|
|||||||
setIsSearching(false);
|
setIsSearching(false);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
const status = axios.isAxiosError(error)
|
||||||
|
? error.response?.status
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (status === 409 || status === 429) {
|
||||||
|
toast.error(
|
||||||
|
t(
|
||||||
|
status === 409 ? "errors.alreadyRunning" : "errors.tooManySearches",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setIsSearching(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let errorMessage = t("errors.unknown");
|
let errorMessage = t("errors.unknown");
|
||||||
|
|
||||||
if (axios.isAxiosError<{ message?: string; detail?: string }>(error)) {
|
if (axios.isAxiosError<{ message?: string; detail?: string }>(error)) {
|
||||||
|
|||||||
Reference in New Issue
Block a user