mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-07 15:22:48 +03:00
Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3524d53acb | ||
|
|
bb84f2d009 | ||
|
|
46c23c4064 | ||
|
|
9b65fdf13a | ||
|
|
0a5d9e5126 | ||
|
|
cfda6baa06 | ||
|
|
df5f943e33 | ||
|
|
bcf81e7c65 | ||
|
|
90e56b9137 | ||
|
|
4fb13b72ef | ||
|
|
5aee6861a8 | ||
|
|
591464e82f | ||
|
|
13a7d8ecae | ||
|
|
ac73ad210e | ||
|
|
c6ec533430 | ||
|
|
dcf711d5a0 | ||
|
|
83750a9596 | ||
|
|
83fbce1533 | ||
|
|
19fdc11ab3 | ||
|
|
a85304e26e | ||
|
|
4bfd89fd90 | ||
|
|
811887c70d | ||
|
|
e6812f9282 | ||
|
|
923a0c8310 | ||
|
|
13a1711adf | ||
|
|
6ca5b59281 | ||
|
|
7f929cb0dc | ||
|
|
d99ce0a9ed | ||
|
|
036bae4ea9 | ||
|
|
8384a8c5b3 | ||
|
|
77fc2ce174 | ||
|
|
8425a76558 |
@@ -59,13 +59,17 @@ Running Generative AI models on CPU is not recommended, as high inference times
|
||||
|
||||
### Recommended Local Models
|
||||
|
||||
#### Vision models
|
||||
|
||||
You must use a vision-capable model with Frigate. The following models are recommended for local deployment of the `descriptions` and `chat` roles:
|
||||
|
||||
| Model | Notes |
|
||||
| ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `qwen3-vl` | Strong visual and situational understanding, enhanced ability to identify smaller objects and interactions with object. |
|
||||
| `qwen3.6` | Strong situational understanding, but missing DeepStack from qwen3-vl leading to worse performance for identifying objects in people's hand and other small details. |
|
||||
| `gemma4` | Strong situational understanding, sometimes resorts to more vague terms like 'interacts' instead of assigning a specific action. |
|
||||
| Model | Notes |
|
||||
| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `qwen3-vl` | Strong visual and situational understanding, enhanced ability to identify smaller objects and interactions with object. |
|
||||
| `qwen3.6`/`qwen3.8` | Strong situational understanding, but missing DeepStack from qwen3-vl leading to worse performance for identifying objects in people's hand and other small details. |
|
||||
| `gemma4` | Strong situational understanding, sometimes resorts to more vague terms like 'interacts' instead of assigning a specific action. |
|
||||
|
||||
#### Embedding models
|
||||
|
||||
The `embeddings` role needs a different kind of model. Text queries are matched against the stored image embeddings, so the model must be trained to place images and text into the same vector space. A chat or description model will still return vectors when asked, but those vectors are not trained for retrieval and text searches will return poor matches with no error to indicate why.
|
||||
|
||||
|
||||
@@ -3,7 +3,31 @@ id: cpu
|
||||
title: High CPU Usage
|
||||
---
|
||||
|
||||
High CPU usage can impact Frigate's performance and responsiveness. This guide outlines the most effective configuration changes to help reduce CPU consumption and optimize resource usage.
|
||||
High CPU usage can impact Frigate's performance and responsiveness. This guide explains how to interpret the CPU values Frigate reports and outlines the most effective configuration changes to help reduce CPU consumption and optimize resource usage.
|
||||
|
||||
## Understanding Frigate's Reported CPU Usage
|
||||
|
||||
Frigate's CPU percentages often look much higher than what the host reports. Usually both numbers are correct and are simply measured against different denominators, so confirm you actually have a problem before tuning anything.
|
||||
|
||||
### Per-process values are relative to a single core
|
||||
|
||||
The values Frigate reports for FFmpeg, capture, detect, detector, and other processes follow the same convention as `top`: 100% means one CPU core is fully saturated, not that the whole system is saturated. A multithreaded process such as FFmpeg can legitimately report well over 100%.
|
||||
|
||||
Host and hypervisor tools instead report a percentage of the machine's total capacity across all cores. This includes `docker stats`, the `htop` summary, the Proxmox summary graph, the Unraid dashboard, Synology Resource Monitor, and Home Assistant's system monitor sensors. To reconcile the two:
|
||||
|
||||
```
|
||||
host percentage ≈ (sum of Frigate's process percentages) / (number of cores)
|
||||
```
|
||||
|
||||
On a 4 core system, an FFmpeg process reporting 100% is consuming one quarter of the machine, so the host will show roughly 25 to 30% once the remaining Frigate processes are included. That same 100% on a 16 core system is about 6%. Frigate's own warning thresholds use the per-core convention as well, so an FFmpeg process is flagged at 20% of a single core, not 20% of the system.
|
||||
|
||||
### Instantaneous samples and averages measure different things
|
||||
|
||||
Frigate collects stats every 15 seconds, and the `cpu` value covers only the interval since the previous collection. The `cpu_average` value in the stats API and MQTT payload is the average across the entire life of the process, and it is what the high CPU usage warnings are based on. Host dashboards generally plot data averaged over a longer window, so a single Frigate sample can show a peak that a host graph never displays. A process that has just started, such as FFmpeg after a camera reconnect, reports 0 until it has been sampled twice.
|
||||
|
||||
### The system-wide value depends on what the container can see
|
||||
|
||||
The system CPU value is read from `/proc/stat`. Under Docker that file belongs to the host, so the value covers the entire machine including workloads unrelated to Frigate, and it will not match `docker stats` for the Frigate container. Under an LXC container, lxcfs virtualizes `/proc/stat` and the value reflects only the cores assigned to the container. In a virtual machine, the guest sees only its assigned vCPUs while the hypervisor divides by every physical thread on the node, so guest and host percentages will not agree even when both are accurate.
|
||||
|
||||
## 1. Hardware Acceleration for Video Decoding
|
||||
|
||||
@@ -72,3 +96,19 @@ The model you use significantly impacts detector performance. Frigate provides d
|
||||
- Larger models (640x640): Slower inference, can sometimes have higher accuracy on very large objects that take up a majority of the frame.
|
||||
|
||||
For more detail on picking the right size, see [Choosing a model size](../configuration/object_detectors.md#choosing-a-model-size).
|
||||
|
||||
## 3. Reducing Detector CPU Usage
|
||||
|
||||
**Priority: High**
|
||||
|
||||
The **Detector CPU Usage** metric measures the CPU spent converting frames into the tensor format the model expects and post-processing the model's output. It does not include inference, so this value can be high even when you've configured a GPU, NPU, or Coral for object detection.
|
||||
|
||||
This metric scales with how many detections per second Frigate runs and how expensive each one is to prepare. Tuning [motion detection](../configuration/motion_detection) is usually the first recommendation to reduce the number of detections. Additionally, you can:
|
||||
|
||||
- **Lower `detect -> fps`.** 5 is the recommended value for nearly all cameras. Running at 10 doubles the frames eligible for detection and is one of the largest contributors to this metric.
|
||||
- **Use a 320x320 model.** A 640x640 model has 4 times as many pixels to transpose, convert, and copy on every inference.
|
||||
- **Prefer a model that takes integer input.** Models configured with `input_dtype: float` require each frame to be converted to float32 and normalized on the CPU first. Models taking `int` input, such as the tflite models used by the Edge TPU, skip that step.
|
||||
- **Do not match the detect resolution to the model resolution.** The detect stream should match your camera's aspect ratio, for example `1280x720`, not the model's input size. Frigate crops and scales regions of motion itself, so an oversized detect stream only adds work.
|
||||
- **Tune stationary object behavior.** Objects that never settle into a stationary state are re-detected continuously. Raising `detect -> stationary -> interval` reduces how often detection runs on objects that are already parked. See [stationary objects](../configuration/stationary_objects).
|
||||
|
||||
Adding [more detector instances](#multiple-detector-instances) spreads this work across more CPU cores, but does not reduce the total CPU used.
|
||||
|
||||
@@ -133,6 +133,12 @@ cameras:
|
||||
height: 720
|
||||
```
|
||||
|
||||
### What is the `version` key in my config file?
|
||||
|
||||
`version` records the config format that your config was last migrated to. On startup Frigate compares it against the format the running version expects, and if it is older it copies your config to `/config/backup_config.yaml`, rewrites it to the new format, and updates `version` as the final step. A config with no `version` key is assumed to predate 0.14 and is migrated from there.
|
||||
|
||||
Frigate manages this key for you, so do not set or edit it. Raising it makes Frigate skip migrations your config still needs, and lowering it re-runs migrations against config that has already been converted. Either can leave you with a config that no longer validates.
|
||||
|
||||
### Why does Frigate keep creating new tracked objects for my parked car?
|
||||
|
||||
Stationary tracking is designed to _prevent_ this: a parked car should remain a single tracked object rather than generating new ones. If you're repeatedly getting new tracked objects for the same car, it's likely that Frigate is losing the object and re-detecting it as a new one.
|
||||
|
||||
Vendored
+92
-1
@@ -4053,6 +4053,58 @@ paths:
|
||||
security:
|
||||
- frigateAdminAuth: []
|
||||
x-required-role: admin
|
||||
/hardware/hwaccel:
|
||||
get:
|
||||
tags:
|
||||
- Hardware
|
||||
summary: Hwaccel Recommendation
|
||||
description: |-
|
||||
**Access:** Admin role required.
|
||||
|
||||
Get the hardware decoding this system can do.
|
||||
|
||||
Args:
|
||||
detector: Hardware key of the detection hardware in use, which biases
|
||||
the recommendation toward that hardware's GPU
|
||||
codecs: Comma separated codecs of the streams that will be decoded,
|
||||
used to drop families that cannot decode one of them
|
||||
|
||||
Returns:
|
||||
The recommended family (empty when none fits) and every usable family
|
||||
operationId: hwaccel_recommendation_hardware_hwaccel_get
|
||||
parameters:
|
||||
- name: detector
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: 'null'
|
||||
title: Detector
|
||||
- name: codecs
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: 'null'
|
||||
title: Codecs
|
||||
responses:
|
||||
'200':
|
||||
description: Successful Response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/HwaccelRecommendation'
|
||||
'422':
|
||||
description: Validation Error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/HTTPValidationError'
|
||||
security:
|
||||
- frigateAdminAuth: []
|
||||
x-required-role: admin
|
||||
/events:
|
||||
get:
|
||||
tags:
|
||||
@@ -7306,7 +7358,9 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/DebugReplayStartResponse'
|
||||
'400':
|
||||
description: Invalid camera, time range, or no recordings
|
||||
description: Invalid camera or time range
|
||||
'404':
|
||||
description: No recordings in the requested time range
|
||||
'409':
|
||||
description: A replay session is already active
|
||||
'422':
|
||||
@@ -8668,6 +8722,43 @@ components:
|
||||
- label
|
||||
title: HardwareUnit
|
||||
description: One physical piece of hardware.
|
||||
HwaccelFamily:
|
||||
properties:
|
||||
key:
|
||||
type: string
|
||||
title: Family key
|
||||
description: Stable identifier for this kind of hardware decoding.
|
||||
presets:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
title: Presets
|
||||
description: The ffmpeg preset for each codec this family decodes, or
|
||||
a single 'any' preset when it decodes every codec.
|
||||
type: object
|
||||
required:
|
||||
- key
|
||||
- presets
|
||||
title: HwaccelFamily
|
||||
description: A kind of hardware decoding, and the presets that drive it.
|
||||
HwaccelRecommendation:
|
||||
properties:
|
||||
recommended:
|
||||
type: string
|
||||
title: Recommended family
|
||||
description: Key of the family that fits this system best, or an empty
|
||||
string when none does.
|
||||
available:
|
||||
items:
|
||||
$ref: '#/components/schemas/HwaccelFamily'
|
||||
type: array
|
||||
title: Available families
|
||||
description: Every family this system's hardware can use, best first.
|
||||
type: object
|
||||
required:
|
||||
- recommended
|
||||
title: HwaccelRecommendation
|
||||
description: The hardware decoding this system can do.
|
||||
Last24HoursReview:
|
||||
properties:
|
||||
reviewed_alert:
|
||||
|
||||
@@ -13,6 +13,7 @@ from frigate.api.auth import require_role
|
||||
from frigate.api.defs.tags import Tags
|
||||
from frigate.jobs.debug_replay import (
|
||||
ExportDebugReplaySource,
|
||||
NoRecordingsError,
|
||||
RecordingDebugReplaySource,
|
||||
start_debug_replay_job,
|
||||
)
|
||||
@@ -74,7 +75,8 @@ class DebugReplayStopResponse(BaseModel):
|
||||
response_model=DebugReplayStartResponse,
|
||||
status_code=202,
|
||||
responses={
|
||||
400: {"description": "Invalid camera, time range, or no recordings"},
|
||||
400: {"description": "Invalid camera or time range"},
|
||||
404: {"description": "No recordings in the requested time range"},
|
||||
409: {"description": "A replay session is already active"},
|
||||
},
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
@@ -113,6 +115,14 @@ async def start_debug_replay(request: Request, body: DebugReplayStartBody):
|
||||
},
|
||||
status_code=409,
|
||||
)
|
||||
except NoRecordingsError:
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": "No recordings found in the selected time range",
|
||||
},
|
||||
status_code=404,
|
||||
)
|
||||
except ValueError:
|
||||
logger.exception("Rejected debug replay start request")
|
||||
return JSONResponse(
|
||||
|
||||
+61
-23
@@ -6,11 +6,13 @@ import logging
|
||||
import math
|
||||
import os
|
||||
import subprocess as sp
|
||||
import tempfile
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from enum import Enum
|
||||
from pathlib import Path as FilePath
|
||||
from typing import Any
|
||||
from typing import IO, Any
|
||||
from urllib.parse import unquote
|
||||
|
||||
import cv2
|
||||
@@ -47,6 +49,7 @@ from frigate.const import (
|
||||
from frigate.models import Event, Previews, Recordings, Regions, ReviewSegment
|
||||
from frigate.output.preview import get_most_recent_preview_frame
|
||||
from frigate.track.object_processing import TrackedObjectProcessor
|
||||
from frigate.util.ffmpeg import terminate_ffmpeg_stream
|
||||
from frigate.util.file import (
|
||||
get_event_snapshot_bytes,
|
||||
get_event_snapshot_path,
|
||||
@@ -70,6 +73,12 @@ logger = logging.getLogger(__name__)
|
||||
# normal hour needs ~360, one clip per recording file
|
||||
NGINX_VOD_MAX_CLIPS = 1080
|
||||
|
||||
# tail of ffmpeg's stderr kept for the clip download failure log
|
||||
CLIP_STDERR_LOG_BYTES = 8192
|
||||
|
||||
# how long a drained clip download waits for ffmpeg to exit on its own
|
||||
CLIP_FFMPEG_EXIT_TIMEOUT = 10
|
||||
|
||||
|
||||
class VodStreamPreference(str, Enum):
|
||||
"""Stream pin for the path-segment VOD route.
|
||||
@@ -465,6 +474,53 @@ async def submit_recording_snapshot_to_plus(
|
||||
)
|
||||
|
||||
|
||||
def _read_stderr_tail(stderr_file: IO[bytes]) -> str:
|
||||
"""Read back the last CLIP_STDERR_LOG_BYTES of a captured stderr file."""
|
||||
stderr_file.seek(0, os.SEEK_END)
|
||||
stderr_file.seek(max(0, stderr_file.tell() - CLIP_STDERR_LOG_BYTES))
|
||||
return stderr_file.read().decode("utf-8", "replace")
|
||||
|
||||
|
||||
def _run_clip_download(ffmpeg_cmd: list[str], file_path: str) -> Iterator[bytes]:
|
||||
"""Stream an ffmpeg concat remux to the client, always cleaning up after it."""
|
||||
stderr_file = None
|
||||
ffmpeg = None
|
||||
|
||||
try:
|
||||
stderr_file = tempfile.TemporaryFile()
|
||||
ffmpeg = sp.Popen(ffmpeg_cmd, stdout=sp.PIPE, stderr=stderr_file)
|
||||
|
||||
while True:
|
||||
data = ffmpeg.stdout.read(8192)
|
||||
|
||||
if not data:
|
||||
break
|
||||
|
||||
yield data
|
||||
|
||||
try:
|
||||
# wait rather than signal, so the real exit code survives
|
||||
ffmpeg.wait(timeout=CLIP_FFMPEG_EXIT_TIMEOUT)
|
||||
except sp.TimeoutExpired:
|
||||
pass
|
||||
finally:
|
||||
if ffmpeg is not None:
|
||||
# read before terminating: a None here is our teardown, not a failure
|
||||
exit_code = ffmpeg.poll()
|
||||
terminate_ffmpeg_stream(ffmpeg)
|
||||
|
||||
if exit_code:
|
||||
logger.error(
|
||||
"Failed to generate clip, ffmpeg logs: %s",
|
||||
_read_stderr_tail(stderr_file),
|
||||
)
|
||||
|
||||
if stderr_file is not None:
|
||||
stderr_file.close()
|
||||
|
||||
FilePath(file_path).unlink(missing_ok=True)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{camera_name}/start/{start_ts}/end/{end_ts}/clip.mp4",
|
||||
dependencies=[Depends(require_camera_access)],
|
||||
@@ -476,26 +532,6 @@ async def recording_clip(
|
||||
start_ts: float,
|
||||
end_ts: float,
|
||||
):
|
||||
def run_download(ffmpeg_cmd: list[str], file_path: str):
|
||||
with sp.Popen(
|
||||
ffmpeg_cmd,
|
||||
stderr=sp.PIPE,
|
||||
stdout=sp.PIPE,
|
||||
text=False,
|
||||
) as ffmpeg:
|
||||
while True:
|
||||
data = ffmpeg.stdout.read(8192)
|
||||
if data is not None and len(data) > 0:
|
||||
yield data
|
||||
else:
|
||||
if ffmpeg.returncode and ffmpeg.returncode != 0:
|
||||
logger.error(
|
||||
f"Failed to generate clip, ffmpeg logs: {ffmpeg.stderr.read()}"
|
||||
)
|
||||
else:
|
||||
FilePath(file_path).unlink(missing_ok=True)
|
||||
break
|
||||
|
||||
def get_clip_query(stream_type: str):
|
||||
return (
|
||||
Recordings.select(
|
||||
@@ -529,7 +565,9 @@ async def recording_clip(
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
file_name = sanitize_filename(f"playlist_{camera_name}_{start_ts}-{end_ts}.txt")
|
||||
file_name = sanitize_filename(
|
||||
f"playlist_{camera_name}_{start_ts}-{end_ts}_{os.urandom(4).hex()}.txt"
|
||||
)
|
||||
file_path = os.path.join(CACHE_DIR, file_name)
|
||||
with open(file_path, "w") as file:
|
||||
clip: Recordings
|
||||
@@ -577,7 +615,7 @@ async def recording_clip(
|
||||
]
|
||||
|
||||
return StreamingResponse(
|
||||
run_download(ffmpeg_cmd, file_path),
|
||||
_run_clip_download(ffmpeg_cmd, file_path),
|
||||
media_type="video/mp4",
|
||||
)
|
||||
|
||||
|
||||
@@ -83,7 +83,9 @@ class WebPushClient(Communicator):
|
||||
# notification and auth config updater
|
||||
self.global_config_subscriber = ConfigSubscriber("config/")
|
||||
self.config_subscriber = CameraConfigUpdateSubscriber(
|
||||
self.config, self.config.cameras, [CameraConfigUpdateEnum.notifications]
|
||||
self.config,
|
||||
self.config.cameras,
|
||||
[CameraConfigUpdateEnum.add, CameraConfigUpdateEnum.notifications],
|
||||
)
|
||||
self._refresh_user_cameras()
|
||||
|
||||
@@ -217,6 +219,8 @@ class WebPushClient(Communicator):
|
||||
self.suspended_cameras[camera] = 0
|
||||
self.last_camera_notification_time[camera] = 0
|
||||
|
||||
self._refresh_user_cameras()
|
||||
|
||||
if topic == "reviews":
|
||||
decoded = json.loads(payload)
|
||||
camera = decoded["before"]["camera"]
|
||||
|
||||
@@ -245,7 +245,7 @@ class GeminiClient(GenAIClient):
|
||||
)
|
||||
gemini_messages.append(
|
||||
types.Content(
|
||||
role="function",
|
||||
role="user",
|
||||
parts=[
|
||||
types.Part.from_function_response(
|
||||
name=msg.get("name")
|
||||
@@ -501,7 +501,7 @@ class GeminiClient(GenAIClient):
|
||||
)
|
||||
gemini_messages.append(
|
||||
types.Content(
|
||||
role="function",
|
||||
role="user",
|
||||
parts=[
|
||||
types.Part.from_function_response(
|
||||
name=msg.get("name")
|
||||
|
||||
@@ -115,6 +115,10 @@ def query_recordings(source_camera: str, start_ts: float, end_ts: float) -> Mode
|
||||
return cast(ModelSelect, query)
|
||||
|
||||
|
||||
class NoRecordingsError(ValueError):
|
||||
"""Raised when no recordings exist in the requested time range."""
|
||||
|
||||
|
||||
class DebugReplaySource(ABC):
|
||||
"""Abstract source for a debug replay session.
|
||||
|
||||
@@ -187,7 +191,7 @@ class RecordingDebugReplaySource(DebugReplaySource):
|
||||
raise ValueError("End time must be after start time")
|
||||
|
||||
if not query_recordings(self._camera, self._start_ts, self._end_ts).count():
|
||||
raise ValueError(
|
||||
raise NoRecordingsError(
|
||||
f"No recordings found for camera '{self._camera}' in the specified time range"
|
||||
)
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import numpy as np
|
||||
|
||||
from frigate.config import CameraConfig
|
||||
from frigate.ffmpeg_presets import parse_preset_hardware_acceleration_decode
|
||||
from frigate.util.ffmpeg import terminate_ffmpeg_stream
|
||||
from frigate.util.services import auto_detect_hwaccel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -88,25 +89,6 @@ def _read_exact(stream: IO[bytes], size: int) -> bytes | None:
|
||||
return bytes(buf)
|
||||
|
||||
|
||||
def _terminate(proc: sp.Popen[bytes]) -> None:
|
||||
"""Stop an ffmpeg decode process promptly."""
|
||||
# Close the read end first so a blocked ffmpeg write unblocks (ffmpeg then
|
||||
# sees a broken pipe), then signal it. The resulting ffmpeg write error is
|
||||
# harmless and goes to the captured stderr.
|
||||
if proc.stdout is not None:
|
||||
try:
|
||||
proc.stdout.close()
|
||||
except OSError:
|
||||
pass
|
||||
if proc.poll() is None:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=5)
|
||||
except sp.TimeoutExpired:
|
||||
proc.kill()
|
||||
proc.wait()
|
||||
|
||||
|
||||
KEYFRAME_MAX_GAP_SECONDS = 2.0
|
||||
|
||||
|
||||
@@ -222,7 +204,7 @@ def _run_vod_decode(
|
||||
count += 1
|
||||
yield frame
|
||||
finally:
|
||||
_terminate(proc)
|
||||
terminate_ffmpeg_stream(proc)
|
||||
stderr_file.close()
|
||||
|
||||
if count == 0 and software_retry and not should_stop():
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from frigate.jobs.debug_replay import NoRecordingsError
|
||||
from frigate.models import Event, Recordings, ReviewSegment
|
||||
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
|
||||
|
||||
@@ -66,6 +67,32 @@ class TestDebugReplayAPI(BaseTestHttp):
|
||||
# (CodeQL: information exposure through an exception).
|
||||
self.assertEqual(body["message"], "Invalid debug replay parameters")
|
||||
|
||||
def test_start_returns_404_when_no_recordings(self):
|
||||
with patch(
|
||||
"frigate.api.debug_replay.start_debug_replay_job",
|
||||
side_effect=NoRecordingsError(
|
||||
"No recordings found for camera 'front' in the specified time range"
|
||||
),
|
||||
):
|
||||
with AuthTestClient(self.app) as client:
|
||||
resp = client.post(
|
||||
"/debug_replay/start",
|
||||
json={
|
||||
"camera": "front",
|
||||
"start_time": 100,
|
||||
"end_time": 200,
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 404)
|
||||
body = resp.json()
|
||||
self.assertFalse(body["success"])
|
||||
# Message is hard-coded so we don't echo exception text back to clients
|
||||
# (CodeQL: information exposure through an exception).
|
||||
self.assertEqual(
|
||||
body["message"], "No recordings found in the selected time range"
|
||||
)
|
||||
|
||||
def test_start_returns_409_when_session_already_active(self):
|
||||
with patch(
|
||||
"frigate.api.debug_replay.start_debug_replay_job",
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Tests for the recording clip download stream."""
|
||||
|
||||
import os
|
||||
import subprocess as sp
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from frigate.api.media import _run_clip_download
|
||||
|
||||
# more than the 64 KB a pipe holds, so an undrained stderr blocks ffmpeg
|
||||
STDERR_FLOOD_BYTES = 256 * 1024
|
||||
PAYLOAD = b"0123456789" * 512
|
||||
|
||||
|
||||
def fake_ffmpeg(*statements: str) -> list[str]:
|
||||
"""Build an argv that stands in for ffmpeg, running the given statements."""
|
||||
return [sys.executable, "-c", "\n".join(("import sys, time", *statements))]
|
||||
|
||||
|
||||
class TestRunClipDownload(unittest.TestCase):
|
||||
def setUp(self):
|
||||
handle, self.playlist_path = tempfile.mkstemp(suffix=".txt")
|
||||
os.close(handle)
|
||||
|
||||
def tearDown(self):
|
||||
if os.path.exists(self.playlist_path):
|
||||
os.unlink(self.playlist_path)
|
||||
|
||||
def collect(self, ffmpeg_cmd: list[str], timeout: float = 30.0) -> bytes:
|
||||
"""Drain the generator on a worker thread so a deadlock fails the test."""
|
||||
chunks: list[bytes] = []
|
||||
errors: list[BaseException] = []
|
||||
|
||||
def drain() -> None:
|
||||
try:
|
||||
chunks.extend(_run_clip_download(ffmpeg_cmd, self.playlist_path))
|
||||
except BaseException as err:
|
||||
errors.append(err)
|
||||
|
||||
thread = threading.Thread(target=drain, daemon=True)
|
||||
thread.start()
|
||||
thread.join(timeout)
|
||||
|
||||
self.assertFalse(
|
||||
thread.is_alive(), "clip download did not finish, ffmpeg is deadlocked"
|
||||
)
|
||||
|
||||
if errors:
|
||||
raise errors[0]
|
||||
|
||||
return b"".join(chunks)
|
||||
|
||||
def test_streams_full_clip_when_ffmpeg_floods_stderr(self):
|
||||
"""A warning flood past the pipe buffer must not stall the download."""
|
||||
data = self.collect(
|
||||
fake_ffmpeg(
|
||||
f"sys.stderr.write('w' * {STDERR_FLOOD_BYTES})",
|
||||
"sys.stderr.flush()",
|
||||
f"sys.stdout.buffer.write({PAYLOAD!r})",
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(data, PAYLOAD)
|
||||
self.assertFalse(os.path.exists(self.playlist_path))
|
||||
|
||||
def test_streams_clip_written_before_stderr_flood(self):
|
||||
data = self.collect(
|
||||
fake_ffmpeg(
|
||||
f"sys.stdout.buffer.write({PAYLOAD!r})",
|
||||
"sys.stdout.flush()",
|
||||
f"sys.stderr.write('w' * {STDERR_FLOOD_BYTES})",
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(data, PAYLOAD)
|
||||
|
||||
def test_logs_ffmpeg_output_and_removes_playlist_on_failure(self):
|
||||
with patch("frigate.api.media.logger") as logger:
|
||||
data = self.collect(
|
||||
fake_ffmpeg(
|
||||
"sys.stderr.write('something went wrong')",
|
||||
"sys.exit(1)",
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(data, b"")
|
||||
logger.error.assert_called_once()
|
||||
self.assertIn("something went wrong", logger.error.call_args.args[1])
|
||||
self.assertFalse(os.path.exists(self.playlist_path))
|
||||
|
||||
def test_logs_only_the_tail_of_a_flooded_stderr(self):
|
||||
with patch("frigate.api.media.logger") as logger:
|
||||
self.collect(
|
||||
fake_ffmpeg(
|
||||
f"sys.stderr.write('w' * {STDERR_FLOOD_BYTES})",
|
||||
"sys.exit(1)",
|
||||
)
|
||||
)
|
||||
|
||||
logged = logger.error.call_args.args[1]
|
||||
self.assertLess(len(logged), STDERR_FLOOD_BYTES)
|
||||
|
||||
def test_does_not_log_a_successful_download(self):
|
||||
with patch("frigate.api.media.logger") as logger:
|
||||
self.collect(fake_ffmpeg(f"sys.stdout.buffer.write({PAYLOAD!r})"))
|
||||
|
||||
logger.error.assert_not_called()
|
||||
|
||||
def test_removes_playlist_when_ffmpeg_cannot_start(self):
|
||||
with self.assertRaises(OSError):
|
||||
self.collect(["/nonexistent-ffmpeg-binary"])
|
||||
|
||||
self.assertFalse(os.path.exists(self.playlist_path))
|
||||
|
||||
def test_closes_the_stdout_pipe_after_a_successful_download(self):
|
||||
processes: list[sp.Popen] = []
|
||||
real_popen = sp.Popen
|
||||
|
||||
def spy(*args, **kwargs):
|
||||
process = real_popen(*args, **kwargs)
|
||||
processes.append(process)
|
||||
return process
|
||||
|
||||
with patch("subprocess.Popen", spy):
|
||||
self.collect(fake_ffmpeg(f"sys.stdout.buffer.write({PAYLOAD!r})"))
|
||||
|
||||
self.assertTrue(processes[0].stdout.closed)
|
||||
|
||||
def test_terminating_a_lingering_ffmpeg_is_not_logged_as_a_failure(self):
|
||||
"""A complete download whose ffmpeg overstays is a success, not an error."""
|
||||
lingering = fake_ffmpeg(
|
||||
"import os",
|
||||
f"os.write(1, {PAYLOAD!r})",
|
||||
"os.close(1)",
|
||||
"time.sleep(30)",
|
||||
)
|
||||
|
||||
with patch("frigate.api.media.CLIP_FFMPEG_EXIT_TIMEOUT", 0.5):
|
||||
with patch("frigate.api.media.logger") as logger:
|
||||
data = self.collect(lingering)
|
||||
|
||||
self.assertEqual(data, PAYLOAD)
|
||||
logger.error.assert_not_called()
|
||||
|
||||
def test_client_disconnect_kills_ffmpeg_and_removes_playlist(self):
|
||||
processes: list[sp.Popen] = []
|
||||
real_popen = sp.Popen
|
||||
|
||||
def spy(*args, **kwargs):
|
||||
process = real_popen(*args, **kwargs)
|
||||
processes.append(process)
|
||||
return process
|
||||
|
||||
forever = fake_ffmpeg(
|
||||
"while True:",
|
||||
" sys.stdout.buffer.write(b'x' * 4096)",
|
||||
" sys.stdout.flush()",
|
||||
)
|
||||
|
||||
with patch("subprocess.Popen", spy):
|
||||
stream = _run_clip_download(forever, self.playlist_path)
|
||||
self.assertTrue(next(stream))
|
||||
# Starlette never closes the generator itself, so a real disconnect
|
||||
# reaches this path only once the frame is finalized
|
||||
stream.close()
|
||||
|
||||
self.assertIsNotNone(processes[0].poll(), "ffmpeg outlived the request")
|
||||
self.assertFalse(os.path.exists(self.playlist_path))
|
||||
@@ -9,6 +9,7 @@ from unittest.mock import MagicMock, patch
|
||||
from frigate.debug_replay import DebugReplayManager
|
||||
from frigate.jobs.debug_replay import (
|
||||
DebugReplayJob,
|
||||
NoRecordingsError,
|
||||
RecordingDebugReplaySource,
|
||||
cancel_debug_replay_job,
|
||||
get_active_runner,
|
||||
@@ -129,7 +130,7 @@ class TestStartDebugReplayJob(unittest.TestCase):
|
||||
empty_qs = MagicMock()
|
||||
empty_qs.count.return_value = 0
|
||||
with patch("frigate.jobs.debug_replay.query_recordings", return_value=empty_qs):
|
||||
with self.assertRaises(ValueError):
|
||||
with self.assertRaises(NoRecordingsError):
|
||||
start_debug_replay_job(
|
||||
source=RecordingDebugReplaySource(
|
||||
source_camera="front",
|
||||
|
||||
@@ -24,6 +24,25 @@ def stop_ffmpeg(ffmpeg_process: sp.Popen[Any], logger: logging.Logger):
|
||||
ffmpeg_process = None
|
||||
|
||||
|
||||
def terminate_ffmpeg_stream(proc: sp.Popen[Any]) -> None:
|
||||
"""Stop an ffmpeg process whose stdout is being read over a pipe."""
|
||||
# Close the read end first so a blocked ffmpeg write unblocks (ffmpeg then
|
||||
# sees a broken pipe), then signal it. The resulting ffmpeg write error is
|
||||
# harmless and goes to the captured stderr.
|
||||
if proc.stdout is not None:
|
||||
try:
|
||||
proc.stdout.close()
|
||||
except OSError:
|
||||
pass
|
||||
if proc.poll() is None:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=5)
|
||||
except sp.TimeoutExpired:
|
||||
proc.kill()
|
||||
proc.wait()
|
||||
|
||||
|
||||
def start_or_restart_ffmpeg(
|
||||
ffmpeg_cmd, logger, logpipe: LogPipe, frame_size=None, ffmpeg_process=None
|
||||
) -> sp.Popen[Any]:
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"toast": {
|
||||
"error": "Failed to start debug replay: {{error}}",
|
||||
"alreadyActive": "A replay session is already active",
|
||||
"noRecordings": "No recordings found in the selected time range",
|
||||
"stopError": "Failed to stop debug replay: {{error}}",
|
||||
"goToReplay": "Go to Replay"
|
||||
}
|
||||
|
||||
@@ -163,7 +163,13 @@ export default function ClassificationSelectionDialog({
|
||||
<DropdownMenuLabel>
|
||||
{dialogLabel ?? t("categorizeImageAs")}
|
||||
</DropdownMenuLabel>
|
||||
<div className={cn("flex flex-col", isMobile && "gap-2 pb-4")}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col",
|
||||
isMobile &&
|
||||
"max-h-[40dvh] gap-2 overflow-y-auto overflow-x-hidden pb-4",
|
||||
)}
|
||||
>
|
||||
{filteredClasses
|
||||
.sort((a, b) => {
|
||||
if (a === "none") return 1;
|
||||
|
||||
@@ -217,7 +217,11 @@ export default function DebugReplayDialog({
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
|
||||
if (error.response?.status === 409) {
|
||||
if (error.response?.status === 404) {
|
||||
toast.error(t("dialog.toast.noRecordings"), {
|
||||
position: "top-center",
|
||||
});
|
||||
} else if (error.response?.status === 409) {
|
||||
toast.error(t("dialog.toast.alreadyActive"), {
|
||||
position: "top-center",
|
||||
closeButton: true,
|
||||
|
||||
Reference in New Issue
Block a user