From 001355552867f1327e4abc0c2377b9eda1312e01 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 18 May 2026 10:32:39 -0500 Subject: [PATCH 01/92] Fixes (#23235) * use stable empty object reference for swr metadata default * version bump * Refactor get_min_region_size for dimension normalization Refactor get_min_region_size to normalize dimensions for smaller models and ensure minimum region size is 320 for larger models. * reject restricted go2rtc stream sources when added via api * add env var check function * fix typing --------- Co-authored-by: Nicolas Mowen --- Makefile | 2 +- .../rootfs/usr/local/go2rtc/create_config.py | 35 ++------------ docs/docs/frigate/updating.md | 12 ++--- frigate/api/camera.py | 15 +++++- .../test/http_api/test_http_camera_access.py | 46 +++++++++++++++++++ frigate/util/object.py | 19 ++++---- frigate/util/services.py | 35 ++++++++++++++ web/src/hooks/use-deferred-stream-metadata.ts | 4 +- 8 files changed, 117 insertions(+), 51 deletions(-) diff --git a/Makefile b/Makefile index 51f12f972a..42adb6bacc 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ default_target: local COMMIT_HASH := $(shell git log -1 --pretty=format:"%h"|tail -1) -VERSION = 0.17.1 +VERSION = 0.17.2 IMAGE_REPO ?= ghcr.io/blakeblackshear/frigate GITHUB_REF_NAME ?= $(shell git rev-parse --abbrev-ref HEAD) BOARDS= #Initialized empty diff --git a/docker/main/rootfs/usr/local/go2rtc/create_config.py b/docker/main/rootfs/usr/local/go2rtc/create_config.py index fb701a9b62..96de79f93b 100644 --- a/docker/main/rootfs/usr/local/go2rtc/create_config.py +++ b/docker/main/rootfs/usr/local/go2rtc/create_config.py @@ -17,36 +17,12 @@ from frigate.const import ( ) from frigate.ffmpeg_presets import parse_preset_hardware_acceleration_encode from frigate.util.config import find_config_file +from frigate.util.services import is_restricted_go2rtc_source sys.path.remove("/opt/frigate") yaml = YAML() -# Check if arbitrary exec sources are allowed (defaults to False for security) -allow_arbitrary_exec = None -if "GO2RTC_ALLOW_ARBITRARY_EXEC" in os.environ: - allow_arbitrary_exec = os.environ.get("GO2RTC_ALLOW_ARBITRARY_EXEC") -elif ( - os.path.isdir("/run/secrets") - and os.access("/run/secrets", os.R_OK) - and "GO2RTC_ALLOW_ARBITRARY_EXEC" in os.listdir("/run/secrets") -): - allow_arbitrary_exec = ( - Path(os.path.join("/run/secrets", "GO2RTC_ALLOW_ARBITRARY_EXEC")) - .read_text() - .strip() - ) -# check for the add-on options file -elif os.path.isfile("/data/options.json"): - with open("/data/options.json") as f: - raw_options = f.read() - options = json.loads(raw_options) - allow_arbitrary_exec = options.get("go2rtc_allow_arbitrary_exec") - -ALLOW_ARBITRARY_EXEC = allow_arbitrary_exec is not None and str( - allow_arbitrary_exec -).lower() in ("true", "1", "yes") - FRIGATE_ENV_VARS = {k: v for k, v in os.environ.items() if k.startswith("FRIGATE_")} # read docker secret files as env vars too if os.path.isdir("/run/secrets"): @@ -135,18 +111,13 @@ if LIBAVFORMAT_VERSION_MAJOR < 59: go2rtc_config["ffmpeg"]["rtsp"] = rtsp_args -def is_restricted_source(stream_source: str) -> bool: - """Check if a stream source is restricted (echo, expr, or exec).""" - return stream_source.strip().startswith(("echo:", "expr:", "exec:")) - - for name in list(go2rtc_config.get("streams", {})): stream = go2rtc_config["streams"][name] if isinstance(stream, str): try: formatted_stream = stream.format(**FRIGATE_ENV_VARS) - if not ALLOW_ARBITRARY_EXEC and is_restricted_source(formatted_stream): + if is_restricted_go2rtc_source(formatted_stream): print( f"[ERROR] Stream '{name}' uses a restricted source (echo/expr/exec) which is disabled by default for security. " f"Set GO2RTC_ALLOW_ARBITRARY_EXEC=true to enable arbitrary exec sources." @@ -165,7 +136,7 @@ for name in list(go2rtc_config.get("streams", {})): for i, stream_item in enumerate(stream): try: formatted_stream = stream_item.format(**FRIGATE_ENV_VARS) - if not ALLOW_ARBITRARY_EXEC and is_restricted_source(formatted_stream): + if is_restricted_go2rtc_source(formatted_stream): print( f"[ERROR] Stream '{name}' item {i + 1} uses a restricted source (echo/expr/exec) which is disabled by default for security. " f"Set GO2RTC_ALLOW_ARBITRARY_EXEC=true to enable arbitrary exec sources." diff --git a/docs/docs/frigate/updating.md b/docs/docs/frigate/updating.md index 841a3e2d58..4e3add3733 100644 --- a/docs/docs/frigate/updating.md +++ b/docs/docs/frigate/updating.md @@ -5,7 +5,7 @@ title: Updating # Updating Frigate -The current stable version of Frigate is **0.17.0**. The release notes and any breaking changes for this version can be found on the [Frigate GitHub releases page](https://github.com/blakeblackshear/frigate/releases/tag/v0.17.0). +The current stable version of Frigate is **0.17.2**. The release notes and any breaking changes for this version can be found on the [Frigate GitHub releases page](https://github.com/blakeblackshear/frigate/releases/tag/v0.17.2). Keeping Frigate up to date ensures you benefit from the latest features, performance improvements, and bug fixes. The update process varies slightly depending on your installation method (Docker, Home Assistant App, etc.). Below are instructions for the most common setups. @@ -31,21 +31,21 @@ If you’re running Frigate via Docker (recommended method), follow these steps: 2. **Update and Pull the Latest Image**: - If using Docker Compose: - - Edit your `docker-compose.yml` file to specify the desired version tag (e.g., `0.17.0` instead of `0.16.4`). For example: + - Edit your `docker-compose.yml` file to specify the desired version tag (e.g., `0.17.2` instead of `0.16.4`). For example: ```yaml services: frigate: - image: ghcr.io/blakeblackshear/frigate:0.17.0 + image: ghcr.io/blakeblackshear/frigate:0.17.2 ``` - Then pull the image: ```bash - docker pull ghcr.io/blakeblackshear/frigate:0.17.0 + docker pull ghcr.io/blakeblackshear/frigate:0.17.2 ``` - **Note for `stable` Tag Users**: If your `docker-compose.yml` uses the `stable` tag (e.g., `ghcr.io/blakeblackshear/frigate:stable`), you don’t need to update the tag manually. The `stable` tag always points to the latest stable release after pulling. - If using `docker run`: - - Pull the image with the appropriate tag (e.g., `0.17.0`, `0.17.0-tensorrt`, or `stable`): + - Pull the image with the appropriate tag (e.g., `0.17.2`, `0.17.2-tensorrt`, or `stable`): ```bash - docker pull ghcr.io/blakeblackshear/frigate:0.17.0 + docker pull ghcr.io/blakeblackshear/frigate:0.17.2 ``` 3. **Start the Container**: diff --git a/frigate/api/camera.py b/frigate/api/camera.py index a94486d8c2..1dae5ae31d 100644 --- a/frigate/api/camera.py +++ b/frigate/api/camera.py @@ -24,7 +24,7 @@ from frigate.api.defs.tags import Tags from frigate.config.config import FrigateConfig from frigate.util.builtin import clean_camera_user_pass from frigate.util.image import run_ffmpeg_snapshot -from frigate.util.services import ffprobe_stream +from frigate.util.services import ffprobe_stream, is_restricted_go2rtc_source logger = logging.getLogger(__name__) @@ -111,6 +111,19 @@ def go2rtc_camera_stream(request: Request, stream_name: str): ) def go2rtc_add_stream(request: Request, stream_name: str, src: str = ""): """Add or update a go2rtc stream configuration.""" + if src and is_restricted_go2rtc_source(src): + logger.warning( + "Rejected go2rtc stream '%s' with restricted source type (echo/expr/exec)", + stream_name, + ) + return JSONResponse( + content={ + "success": False, + "message": "Restricted stream source type", + }, + status_code=400, + ) + try: params = {"name": stream_name} if src: diff --git a/frigate/test/http_api/test_http_camera_access.py b/frigate/test/http_api/test_http_camera_access.py index 211c84bb4f..44520d79f5 100644 --- a/frigate/test/http_api/test_http_camera_access.py +++ b/frigate/test/http_api/test_http_camera_access.py @@ -1,3 +1,4 @@ +import os from unittest.mock import patch from fastapi import HTTPException, Request @@ -357,6 +358,51 @@ class TestGo2rtcStreamAccess(BaseTestHttp): f"got {resp.status_code}" ) + def test_add_stream_rejects_restricted_source(self): + """PUT /go2rtc/streams must reject exec:/echo:/expr: sources even for + admins""" + app = self._make_app(_MULTI_CAMERA_CONFIG) + with AuthTestClient(app) as client: + for src in ( + "exec:/tmp/rev.sh", + "echo:foo", + "expr:bar", + " exec:/tmp/rev.sh", + ): + resp = client.put(f"/go2rtc/streams/revshell?src={src}") + assert resp.status_code == 400, ( + f"Expected 400 for restricted src {src!r}; got {resp.status_code}" + ) + assert resp.json().get("success") is False + + def test_add_stream_allows_non_restricted_source(self): + """A normal stream URL should pass the restricted-source check and reach + the (unavailable in tests) go2rtc proxy — so we expect 500, not 400.""" + app = self._make_app(_MULTI_CAMERA_CONFIG) + with AuthTestClient(app) as client: + resp = client.put("/go2rtc/streams/legit?src=rtsp://10.0.0.1:554/video") + assert resp.status_code != 400, ( + f"Non-restricted source should not be rejected with 400; got {resp.status_code}" + ) + + def test_add_stream_allows_restricted_source_when_override_set(self): + """When GO2RTC_ALLOW_ARBITRARY_EXEC is set, the API must defer to operator + intent and forward the request to go2rtc instead of short-circuiting with 400.""" + app = self._make_app(_MULTI_CAMERA_CONFIG) + mock_response = type("R", (), {"ok": True, "status_code": 200, "text": "ok"})() + with patch.dict(os.environ, {"GO2RTC_ALLOW_ARBITRARY_EXEC": "true"}): + with patch( + "frigate.api.camera.requests.put", return_value=mock_response + ) as mock_put: + with AuthTestClient(app) as client: + resp = client.put("/go2rtc/streams/legit?src=exec:/tmp/something") + assert resp.status_code == 200, ( + f"Restricted src should be forwarded when override set; got {resp.status_code}" + ) + mock_put.assert_called_once() + forwarded_src = mock_put.call_args.kwargs["params"]["src"] + assert forwarded_src == "exec:/tmp/something" + def test_stream_alias_blocked_when_owning_camera_disallowed(self): """limited_user cannot access a stream alias that belongs to a camera they are not allowed to see.""" diff --git a/frigate/util/object.py b/frigate/util/object.py index 905745da62..b8f41e2c32 100644 --- a/frigate/util/object.py +++ b/frigate/util/object.py @@ -271,18 +271,17 @@ def get_min_region_size(model_config: ModelConfig) -> int: """Get the min region size.""" largest_dimension = max(model_config.height, model_config.width) - if largest_dimension > 320: - # We originally tested allowing any model to have a region down to half of the model size - # but this led to many false positives. In this case we specifically target larger models - # which can benefit from a smaller region in some cases to detect smaller objects. - half = int(largest_dimension / 2) + # return largest dimension for smaller models, but make sure the dimension is normalized + if largest_dimension < 320: + if largest_dimension % 4 == 0: + return largest_dimension - if half % 4 == 0: - return half + return int((largest_dimension + 3) / 4) * 4 - return int((half + 3) / 4) * 4 - - return largest_dimension + # Any model that is 320 or larger should have a minimum region size of 320 + # this allows larger models to use smaller regions to detect smaller objects + # in the case that the motion area is smaller so that it can be upscaled. + return 320 def create_tensor_input(frame, model_config: ModelConfig, region): diff --git a/frigate/util/services.py b/frigate/util/services.py index 64d83833dc..d366a7390a 100644 --- a/frigate/util/services.py +++ b/frigate/util/services.py @@ -556,6 +556,41 @@ def get_jetson_stats() -> Optional[dict[int, dict]]: return results +def _go2rtc_arbitrary_exec_allowed() -> bool: + """Read the GO2RTC_ALLOW_ARBITRARY_EXEC override from env, docker + secrets, or the Home Assistant add-on options file.""" + raw: Optional[str] = None + if "GO2RTC_ALLOW_ARBITRARY_EXEC" in os.environ: + raw = os.environ.get("GO2RTC_ALLOW_ARBITRARY_EXEC") + elif ( + os.path.isdir("/run/secrets") + and os.access("/run/secrets", os.R_OK) + and "GO2RTC_ALLOW_ARBITRARY_EXEC" in os.listdir("/run/secrets") + ): + try: + with open("/run/secrets/GO2RTC_ALLOW_ARBITRARY_EXEC") as f: + raw = f.read().strip() + except OSError: + raw = None + elif os.path.isfile("/data/options.json"): + try: + with open("/data/options.json") as f: + options = json.loads(f.read()) + raw = options.get("go2rtc_allow_arbitrary_exec") + except (OSError, json.JSONDecodeError): + raw = None + + return raw is not None and str(raw).lower() in ("true", "1", "yes") + + +def is_restricted_go2rtc_source(stream_source: str) -> bool: + """Check if a stream source is a restricted type (echo, expr, or exec) + and the GO2RTC_ALLOW_ARBITRARY_EXEC override is not set.""" + if not stream_source.strip().startswith(("echo:", "expr:", "exec:")): + return False + return not _go2rtc_arbitrary_exec_allowed() + + def ffprobe_stream(ffmpeg, path: str, detailed: bool = False) -> sp.CompletedProcess: """Run ffprobe on stream.""" clean_path = escape_special_characters(path) diff --git a/web/src/hooks/use-deferred-stream-metadata.ts b/web/src/hooks/use-deferred-stream-metadata.ts index 8e68b6a6a3..251dc97345 100644 --- a/web/src/hooks/use-deferred-stream-metadata.ts +++ b/web/src/hooks/use-deferred-stream-metadata.ts @@ -5,6 +5,8 @@ import { LiveStreamMetadata } from "@/types/live"; const FETCH_TIMEOUT_MS = 10000; const DEFER_DELAY_MS = 2000; +const emptyObject: Readonly<{ [key: string]: LiveStreamMetadata }> = + Object.freeze({}); /** * Hook that fetches go2rtc stream metadata with deferred loading. @@ -77,7 +79,7 @@ export default function useDeferredStreamMetadata(streamNames: string[]) { return metadata; }, []); - const { data: metadata = {} } = useSWR<{ + const { data: metadata = emptyObject } = useSWR<{ [key: string]: LiveStreamMetadata; }>(swrKey, fetcher, { revalidateOnFocus: false, From 26d31300e66dda2db6dcd90b84be268dc5e77aef Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Mon, 18 May 2026 09:58:10 -0600 Subject: [PATCH 02/92] Add metadata for creation time to recording segments and exports (#23239) --- frigate/record/export.py | 24 ++++++++++++++++++++++-- frigate/record/maintainer.py | 2 ++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/frigate/record/export.py b/frigate/record/export.py index d4b49bb4b0..3c4f3ba4a5 100644 --- a/frigate/record/export.py +++ b/frigate/record/export.py @@ -235,7 +235,17 @@ class RecordingExporter(threading.Thread): # add metadata title = f"Frigate Recording for {self.camera}, {self.get_datetime_from_timestamp(self.start_time)} - {self.get_datetime_from_timestamp(self.end_time)}" - ffmpeg_cmd.extend(["-metadata", f"title={title}"]) + creation_time = datetime.datetime.fromtimestamp( + self.start_time, tz=datetime.timezone.utc + ).strftime("%Y-%m-%dT%H:%M:%S.%fZ") + ffmpeg_cmd.extend( + [ + "-metadata", + f"title={title}", + "-metadata", + f"creation_time={creation_time}", + ] + ) ffmpeg_cmd.append(video_path) @@ -326,7 +336,17 @@ class RecordingExporter(threading.Thread): # add metadata title = f"Frigate Preview for {self.camera}, {self.get_datetime_from_timestamp(self.start_time)} - {self.get_datetime_from_timestamp(self.end_time)}" - ffmpeg_cmd.extend(["-metadata", f"title={title}"]) + creation_time = datetime.datetime.fromtimestamp( + self.start_time, tz=datetime.timezone.utc + ).strftime("%Y-%m-%dT%H:%M:%S.%fZ") + ffmpeg_cmd.extend( + [ + "-metadata", + f"title={title}", + "-metadata", + f"creation_time={creation_time}", + ] + ) return ffmpeg_cmd, playlist_lines diff --git a/frigate/record/maintainer.py b/frigate/record/maintainer.py index a90d1edc12..e36df78d0b 100644 --- a/frigate/record/maintainer.py +++ b/frigate/record/maintainer.py @@ -547,6 +547,8 @@ class RecordingMaintainer(threading.Thread): "copy", "-movflags", "+faststart", + "-metadata", + f"creation_time={start_time.strftime('%Y-%m-%dT%H:%M:%S.%fZ')}", file_path, stderr=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.DEVNULL, From 06b059c36ae3fd8beebd69bafb57fac05891de59 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Wed, 20 May 2026 07:29:37 -0500 Subject: [PATCH 03/92] fix admin response cache leak to non-admin users via nginx proxy_cache (#23261) --- docker/main/rootfs/usr/local/nginx/conf/nginx.conf | 1 + 1 file changed, 1 insertion(+) diff --git a/docker/main/rootfs/usr/local/nginx/conf/nginx.conf b/docker/main/rootfs/usr/local/nginx/conf/nginx.conf index 46241c5ab1..f6b0928eb9 100644 --- a/docker/main/rootfs/usr/local/nginx/conf/nginx.conf +++ b/docker/main/rootfs/usr/local/nginx/conf/nginx.conf @@ -259,6 +259,7 @@ http { include proxy.conf; proxy_cache api_cache; + proxy_cache_key "$scheme$proxy_host$request_uri|$role|$groups|$user"; proxy_cache_lock on; proxy_cache_use_stale updating; proxy_cache_valid 200 5s; From ef44c18c07999ec649705b5ad1290c91ff40e7be Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 21 May 2026 09:04:41 -0500 Subject: [PATCH 04/92] Docs update (#23280) * stationary car detection troubleshooting tips * tweak --- docs/docs/troubleshooting/faqs.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/docs/troubleshooting/faqs.md b/docs/docs/troubleshooting/faqs.md index ff2379ea72..bba4a1c187 100644 --- a/docs/docs/troubleshooting/faqs.md +++ b/docs/docs/troubleshooting/faqs.md @@ -110,3 +110,19 @@ No. Frigate uses the TCP protocol to connect to your camera's RTSP URL. VLC auto TCP ensures that all data packets arrive in the correct order. This is crucial for video recording, decoding, and stream processing, which is why Frigate enforces a TCP connection. UDP is faster but less reliable, as it does not guarantee packet delivery or order, and VLC does not have the same requirements as Frigate. You can still configure Frigate to use UDP by using ffmpeg input args or the preset `preset-rtsp-udp`. See the [ffmpeg presets](/configuration/ffmpeg_presets) documentation. + +### Why does Frigate keep creating new events for my parked car? + +Stationary tracking is designed to _prevent_ this — a parked car should stay one tracked object and not generate new events. If you're getting repeated events for the same car, it's likely that Frigate is losing the tracked object and re-detecting it as a new one. + +Open one of the events in Explore → **Tracking Details**. If the detection scores are low (< 70% or so), the model isn't confident the parked car is a car. This is common with the free [COCO-trained](https://cocodataset.org/#explore) object detection models on steep/top-down angles, partially occluded cars, foliage, or low-light footage. When detections fall below `min_score` for too many frames the tracker loses the object, and the next confident frame creates a brand new one. + +What helps: + +- **Improve the view** — even a small angle change that gets more of the car visible could lift scores enough to stabilize tracking. +- **Use a more accurate model** — switching from `mobiledet` to `yolov9`, or stepping up to a larger variant like `yolov9-s` over `yolov9-t`, can help (at the cost of inference time, and still on the COCO dataset). The biggest gains usually come from fine-tuning a model on images from your own cameras so it learns your specific scene. [Frigate+](https://frigate.video/plus) is a paid option that does this - models are trained on security-camera footage and can be fine-tuned on images you submit from your own setup. +- **Don't set `detect -> stationary -> max_frames` for `car`** — it artificially ends tracking and forces re-detection as a new object. See [Stationary Objects](../configuration/stationary_objects.md). +- **Restrict alerts to the areas you care about** with `required_zones` — see [Zones](../configuration/zones.md#restricting-alerts-and-detections-to-specific-zones). Make sure those zones use the default `loitering_time: 0` unless you specifically want the review item to stay open until the car leaves. +- **Filter impossible locations** with [object filter masks](../configuration/masks.md#object-filter-masks) if cars are being detected on rooftops, treetops, etc. + +See [Object Filters](../configuration/object_filters.md) for more on tuning `min_score` and `threshold` — note that raising them too high will make this exact problem worse. From 910059281f1cfd5b52f5ee70f63216499bd3e28c Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 21 May 2026 15:00:46 -0500 Subject: [PATCH 05/92] update mask docs for more clarity (#23282) --- docs/docs/configuration/masks.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/docs/docs/configuration/masks.md b/docs/docs/configuration/masks.md index 4a47225863..0fcf366eda 100644 --- a/docs/docs/configuration/masks.md +++ b/docs/docs/configuration/masks.md @@ -3,6 +3,8 @@ id: masks title: Masks --- +Frigate has two kinds of masks: motion masks and object filter masks. Both are narrow tools for fine-tuning, **not for hiding an area from Frigate**. Masks should be used sparingly; in most cases where users reach for one, a [zone](zones.md) with `required_zones` is the right tool instead. See [Which tool do I need?](#which-tool-do-i-need) and [Common mistakes](#common-mistakes) below if you're new to Frigate's mask behavior. + ## Motion masks Motion masks are used to prevent unwanted types of motion from triggering detection. Try watching the Debug feed (Settings --> Debug) with `Motion Boxes` enabled to see what may be regularly detected as motion. For example, you want to mask out your timestamp, the sky, rooftops, etc. Keep in mind that this mask only prevents motion from being detected and does not prevent objects from being detected if object detection was started due to motion in unmasked areas. Motion is also used during object tracking to refine the object detection area in the next frame. _Over-masking will make it more difficult for objects to be tracked._ @@ -17,6 +19,15 @@ Object filter masks can be used to filter out stubborn false positives in fixed ![object mask](/img/bottom-center-mask.jpg) +## Which tool do I need? + +| What you're trying to do | Recommended tool | How it works | +| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Don't get alerts or recordings for activity in an area (e.g., the sidewalk in front of your house) | A [zone](zones.md) combined with `review.alerts.required_zones` (and/or `review.detections.required_zones`) | Frigate keeps detecting and tracking activity in the area, but a review item is only created once the bottom-center of an object's bounding box enters a required zone. | +| Stop a stubborn false positive at a specific fixed spot (e.g., a tree base that keeps being detected as a person) | An **object filter mask** for that object type | Any detection of that object type whose bounding-box bottom-center lands inside the mask is treated as a false positive and discarded. | +| Ignore motion in an area that obviously isn't an object of interest (e.g., the camera timestamp, sky, flags, treetops swaying) | A **motion mask** | Motion inside the mask is ignored when deciding whether to run object detection. Objects can still be detected in a motion masked area if motion elsewhere in the frame triggers detection. | +| Stop tracking an object type altogether on this camera (e.g., you never care about cats) | Remove the object from the camera's [`objects.track`](objects.md) list | Frigate skips this object type entirely on this camera, regardless of where it appears. | + ## Using the mask creator To create a poly mask: @@ -82,3 +93,14 @@ This is what `required_zones` are for. You should define a zone (remember this i > Maybe my specific situation just warrants this. I've just been having a hard time understanding the relevance of this information - it seems to be that it's exactly what would be expected when "masking out" an area of ANY image. That may be the case for you. Frigate will definitely work harder tracking people on the sidewalk to make sure it doesn't miss anyone who steps foot on your stoop. The trade off with the way you have it now is slower recognition of objects and potential misses. That may be acceptable based on your needs. Also, if your resolution is low enough on the detect stream, your regions may already be so big that they grab the entire object anyway. + +## Common mistakes + +**"I added a motion mask to ignore my driveway/sidewalk."** +A motion mask doesn't hide an area from Frigate. Objects can still be detected and tracked inside a masked area. The mask only stops motion _in that area_ from triggering object detection. If you want activity on the sidewalk to never produce a review item, define a [zone](zones.md) over the area you DO care about (your stoop, your driveway) and add it to `review.alerts.required_zones`. Frigate will still see people on the sidewalk, but it won't create an alert until they cross into the zone. + +**"I added an object filter mask because I don't care about cars in my yard."** +Object filter masks are for stubborn false positives at fixed locations, not for filtering whole areas or whole object types. If you only want alerts when a car enters the driveway, use a [zone](zones.md) with `required_zones`. If you don't care about a whole object type on this camera, remove it from [`objects.track`](objects.md). + +**"I masked everything except a thin strip on my stoop."** +Heavy masking hurts tracking. Frigate uses motion near a tracked object's previous bounding box to decide where to look in the next frame; with most of the frame masked, an object walking from an unmasked area into a masked one effectively disappears and gets picked up as a "new" object when it reappears. For example: someone walks down your sidewalk, stops under a tree (masked area) to tie their shoe, then continues. Frigate sees that as two separate people and can create two separate review items. Because Frigate needs several consecutive frames above the confidence threshold to commit to a detection, each re-appearance can also delay or miss alerts. Use `required_zones` for "only alert me about this spot" and leave the surrounding area unmasked so tracking stays intact. From fa07109a855b7e4b9d6538f8b7d3cd9424fbf761 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sat, 23 May 2026 07:47:32 -0500 Subject: [PATCH 06/92] filter motion review by allowed cameras (#23294) --- web/src/views/events/EventView.tsx | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/web/src/views/events/EventView.tsx b/web/src/views/events/EventView.tsx index 70067ff5c0..dd1bb75e4d 100644 --- a/web/src/views/events/EventView.tsx +++ b/web/src/views/events/EventView.tsx @@ -44,6 +44,7 @@ import SummaryTimeline from "@/components/timeline/SummaryTimeline"; import { RecordingStartingPoint } from "@/types/record"; import VideoControls from "@/components/player/VideoControls"; import { TimeRange } from "@/types/timeline"; +import { useAllowedCameras } from "@/hooks/use-allowed-cameras"; import { useCameraMotionNextTimestamp } from "@/hooks/use-camera-activity"; import useOptimisticState from "@/hooks/use-optimistic-state"; import { Skeleton } from "@/components/ui/skeleton"; @@ -918,25 +919,26 @@ function MotionReview({ }: MotionReviewProps) { const segmentDuration = 30; const { data: config } = useSWR("config"); + const allowedCameras = useAllowedCameras(); const reviewCameras = useMemo(() => { if (!config) { return []; } - let cameras; - if (!filter || !filter.cameras) { - cameras = Object.values(config.cameras); - } else { - const filteredCams = filter.cameras; - - cameras = Object.values(config.cameras).filter((cam) => - filteredCams.includes(cam.name), - ); - } + const selectedCams = filter?.cameras; + const cameras = Object.values(config.cameras).filter((cam) => { + if (!allowedCameras.includes(cam.name)) { + return false; + } + if (selectedCams && !selectedCams.includes(cam.name)) { + return false; + } + return true; + }); return cameras.sort((a, b) => a.ui.order - b.ui.order); - }, [config, filter]); + }, [config, filter, allowedCameras]); const videoPlayersRef = useRef<{ [camera: string]: PreviewController }>({}); From 28e3e1ec7434d4068da65ae0e1473c415772cc36 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Mon, 25 May 2026 12:06:16 -0600 Subject: [PATCH 07/92] Add ability to control chapters set on MP4 Export (#23310) --- .../defs/request/export_recordings_body.py | 9 ++ frigate/api/export.py | 2 + frigate/record/export.py | 118 +++++++++++++++++- 3 files changed, 128 insertions(+), 1 deletion(-) diff --git a/frigate/api/defs/request/export_recordings_body.py b/frigate/api/defs/request/export_recordings_body.py index 19fc2f0194..aef5aa9520 100644 --- a/frigate/api/defs/request/export_recordings_body.py +++ b/frigate/api/defs/request/export_recordings_body.py @@ -4,6 +4,7 @@ from pydantic import BaseModel, Field from pydantic.json_schema import SkipJsonSchema from frigate.record.export import ( + ChaptersEnum, PlaybackFactorEnum, PlaybackSourceEnum, ) @@ -18,3 +19,11 @@ class ExportRecordingsBody(BaseModel): ) name: Optional[str] = Field(title="Friendly name", default=None, max_length=256) image_path: Union[str, SkipJsonSchema[None]] = None + chapters: Optional[ChaptersEnum] = Field( + default=None, + title="Chapter mode", + description=( + "Optional chapter metadata to embed in the export. When omitted, " + "no chapter track is added." + ), + ) diff --git a/frigate/api/export.py b/frigate/api/export.py index 24fed93b03..786e046dbb 100644 --- a/frigate/api/export.py +++ b/frigate/api/export.py @@ -31,6 +31,7 @@ from frigate.api.defs.tags import Tags from frigate.const import CLIPS_DIR, EXPORT_DIR from frigate.models import Export, Previews, Recordings from frigate.record.export import ( + ChaptersEnum, PlaybackFactorEnum, PlaybackSourceEnum, RecordingExporter, @@ -161,6 +162,7 @@ def export_recording( if playback_source in PlaybackSourceEnum.__members__.values() else PlaybackSourceEnum.recordings ), + chapters=ChaptersEnum(body.chapters) if body.chapters else None, ) exporter.start() return JSONResponse( diff --git a/frigate/record/export.py b/frigate/record/export.py index 3c4f3ba4a5..d5d5ddb766 100644 --- a/frigate/record/export.py +++ b/frigate/record/export.py @@ -12,6 +12,7 @@ from enum import Enum from pathlib import Path from typing import Optional +import pytz from peewee import DoesNotExist from frigate.config import FfmpegConfig, FrigateConfig @@ -50,6 +51,14 @@ class PlaybackSourceEnum(str, Enum): preview = "preview" +class ChaptersEnum(str, Enum): + # One chapter per recording segment, titled with the segment's + # wallclock start time in strict ISO 8601 form. Lets viewers map + # output playback time back to wallclock without reading a timestamp + # overlay via OCR. + recording_segments = "recording_segments" + + class RecordingExporter(threading.Thread): """Exports a specific set of recordings for a camera to storage as a single file.""" @@ -64,6 +73,7 @@ class RecordingExporter(threading.Thread): end_time: int, playback_factor: PlaybackFactorEnum, playback_source: PlaybackSourceEnum, + chapters: Optional[ChaptersEnum] = None, ) -> None: super().__init__() self.config = config @@ -75,6 +85,7 @@ class RecordingExporter(threading.Thread): self.end_time = end_time self.playback_factor = playback_factor self.playback_source = playback_source + self.chapters = chapters # ensure export thumb dir Path(os.path.join(CLIPS_DIR, "export")).mkdir(exist_ok=True) @@ -83,6 +94,77 @@ class RecordingExporter(threading.Thread): # return in iso format return datetime.datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M:%S") + def _chapter_metadata_path(self) -> str: + return os.path.join(CACHE_DIR, f"export_chapters_{self.export_id}.txt") + + def _build_recording_segment_chapter_metadata_file( + self, recordings: list + ) -> Optional[str]: + """Write an FFmpeg metadata file with one chapter per recording segment. + + Each chapter's title is the segment's wallclock start time in + strict ISO 8601 form so a viewer can map any point in the + export's playback timeline back to real-world time without + OCR-ing a burnt-in timestamp. Chapter offsets are computed in + *output time*: the VOD endpoint concatenates recording clips + back-to-back, so wall-clock gaps between recordings collapse in + the produced video. Returns ``None`` when there are no + recordings or every segment is empty after clipping. + """ + if not recordings: + return None + + tz_name = self.config.ui.timezone + tz: Optional[datetime.tzinfo] = None + if tz_name: + try: + tz = pytz.timezone(tz_name) + except pytz.UnknownTimeZoneError: + tz = None + if tz is None: + tz = datetime.timezone.utc + + chapter_blocks: list[str] = [] + output_offset_ms = 0 + for rec in recordings: + clipped_start = max(float(rec.start_time), float(self.start_time)) + clipped_end = min(float(rec.end_time), float(self.end_time)) + if clipped_end <= clipped_start: + continue + + duration_ms = int(round((clipped_end - clipped_start) * 1000)) + if duration_ms <= 0: + continue + + title = datetime.datetime.fromtimestamp(clipped_start, tz=tz).isoformat( + timespec="seconds" + ) + chapter_blocks.append( + "[CHAPTER]\n" + "TIMEBASE=1/1000\n" + f"START={output_offset_ms}\n" + f"END={output_offset_ms + duration_ms}\n" + f"title={title}" + ) + output_offset_ms += duration_ms + + if not chapter_blocks: + return None + + meta_path = self._chapter_metadata_path() + try: + with open(meta_path, "w", encoding="utf-8") as f: + f.write(";FFMETADATA1\n") + f.write("\n".join(chapter_blocks)) + f.write("\n") + except OSError: + logger.exception( + "Failed to write chapter metadata file for export %s", self.export_id + ) + return None + + return meta_path + def save_thumbnail(self, id: str) -> str: thumb_path = os.path.join(CLIPS_DIR, f"export/{id}.webp") @@ -218,9 +300,41 @@ class RecordingExporter(threading.Thread): ffmpeg_input = "-y -protocol_whitelist pipe,file,http,tcp -f concat -safe 0 -i /dev/stdin" + # When chapters are requested, query the per-segment recording rows + # and write an FFmpeg metadata sidecar. Timelapse playback rescales + # time so chapter offsets would no longer match wallclock — restrict + # chapter injection to realtime playback. + chapter_args = "" + if ( + self.chapters == ChaptersEnum.recording_segments + and self.playback_factor == PlaybackFactorEnum.realtime + ): + recordings = list( + Recordings.select( + Recordings.start_time, + Recordings.end_time, + ) + .where( + Recordings.start_time.between(self.start_time, self.end_time) + | Recordings.end_time.between(self.start_time, self.end_time) + | ( + (self.start_time > Recordings.start_time) + & (self.end_time < Recordings.end_time) + ) + ) + .where(Recordings.camera == self.camera) + .order_by(Recordings.start_time.asc()) + .iterator() + ) + chapters_path = self._build_recording_segment_chapter_metadata_file( + recordings + ) + if chapters_path: + chapter_args = f" -i {chapters_path} -map 0 -dn -map_metadata 1" + if self.playback_factor == PlaybackFactorEnum.realtime: ffmpeg_cmd = ( - f"{self.config.ffmpeg.ffmpeg_path} -hide_banner {ffmpeg_input} -c copy -movflags +faststart" + f"{self.config.ffmpeg.ffmpeg_path} -hide_banner {ffmpeg_input}{chapter_args} -c copy -movflags +faststart" ).split(" ") elif self.playback_factor == PlaybackFactorEnum.timelapse_25x: ffmpeg_cmd = ( @@ -396,6 +510,8 @@ class RecordingExporter(threading.Thread): capture_output=True, ) + Path(self._chapter_metadata_path()).unlink(missing_ok=True) + if p.returncode != 0: logger.error( f"Failed to export {self.playback_source.value} for command {' '.join(ffmpeg_cmd)}" From f3a352ef3f34c77ec19de892a4b2f477403b4be3 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 8 Jun 2026 10:14:16 -0500 Subject: [PATCH 08/92] Miscellaneous fixes (#23413) * update e2e mock data to remove deprecated fields * remove scream audio label scream was never mapped to anything in frigate's custom labelmap, yell is used everywhere * document common audio labels * deprecate ffmpeg 5 * language tweak * add field message to recommend presets instead of manual hwaccel args * add guidance to docs on choosing a detect fps --- docs/docs/configuration/advanced/reference.md | 1 - docs/docs/configuration/audio_detectors.md | 67 ++++++++++++++++++- docs/docs/frigate/camera_setup.md | 40 ++++++++++- frigate/config/camera/audio.py | 4 +- frigate/config/camera/ffmpeg.py | 2 +- web/e2e/fixtures/mock-data/cases.json | 2 +- .../fixtures/mock-data/config-snapshot.json | 2 +- web/e2e/fixtures/mock-data/events.json | 2 +- web/e2e/fixtures/mock-data/exports.json | 2 +- .../fixtures/mock-data/review-summary.json | 2 +- web/e2e/fixtures/mock-data/reviews.json | 2 +- web/public/locales/en/config/cameras.json | 4 +- web/public/locales/en/config/global.json | 4 +- web/public/locales/en/views/settings.json | 3 + .../config-form/section-configs/ffmpeg.ts | 21 ++++++ .../theme/templates/FieldTemplate.tsx | 11 +-- 16 files changed, 148 insertions(+), 21 deletions(-) diff --git a/docs/docs/configuration/advanced/reference.md b/docs/docs/configuration/advanced/reference.md index 5c07e98a96..81feb17db7 100644 --- a/docs/docs/configuration/advanced/reference.md +++ b/docs/docs/configuration/advanced/reference.md @@ -212,7 +212,6 @@ audio: listen: - bark - fire_alarm - - scream - speech - yell # Optional: Filters to configure detection. diff --git a/docs/docs/configuration/audio_detectors.md b/docs/docs/configuration/audio_detectors.md index eba22ec184..c8c3ada9d4 100644 --- a/docs/docs/configuration/audio_detectors.md +++ b/docs/docs/configuration/audio_detectors.md @@ -88,7 +88,7 @@ Volume is considered motion for recordings, this means when the `record -> retai ### Configuring Audio Events -The included audio model has over [500 different types](https://github.com/blakeblackshear/frigate/blob/dev/audio-labelmap.txt) of audio that can be detected, many of which are not practical. By default `bark`, `fire_alarm`, `scream`, `speech`, and `yell` are enabled but these can be customized. +The included audio model has over [500 different types](https://github.com/blakeblackshear/frigate/blob/dev/audio-labelmap.txt) of audio that can be detected, many of which are not practical. By default `bark`, `fire_alarm`, `speech`, and `yell` are enabled but these can be customized. @@ -107,7 +107,6 @@ audio: listen: - bark - fire_alarm - - scream - speech - yell ``` @@ -115,6 +114,70 @@ audio: +### Common Audio Labels + +The labelmap includes hundreds of sound types. The labels below are the ones most users may find practical, grouped by what they're typically used for. Use the exact label string from the left column in your `listen` config, or search for the label in the Frigate UI directly. + +Some labels cover several related sounds: `yell` is triggered by shouting, yelling, children shouting, and screaming; `crying` covers baby cries, sobbing, and whimpering; and `speech` covers ordinary talking and conversation. + +**Safety and security** + +| Label | Detects | +| ---------------- | ---------------------------------- | +| `yell` | Shouting, yelling, screaming | +| `fire_alarm` | Fire and smoke alarm sirens | +| `smoke_detector` | Smoke detector beeps | +| `alarm` | General alarm sounds | +| `car_alarm` | Car alarms | +| `siren` | Emergency vehicle and civil sirens | +| `glass` | Glass clinking | +| `shatter` | Breaking glass | +| `breaking` | Something breaking | +| `gunshot` | Gunshots | +| `explosion` | Explosions | + +**People and activity** + +| Label | Detects | +| ----------- | ------------------------ | +| `speech` | Talking and conversation | +| `laughter` | Laughing | +| `crying` | Baby crying and sobbing | +| `cough` | Coughing | +| `footsteps` | Footsteps and walking | +| `knock` | Knocking on a door | +| `doorbell` | Doorbell | +| `ding-dong` | Doorbell chime | + +**Pets and animals** + +| Label | Detects | +| ---------- | ---------------- | +| `bark` | Dog barking | +| `dog` | Other dog sounds | +| `howl` | Howling | +| `growling` | Growling | +| `meow` | Cat meowing | +| `cat` | Other cat sounds | +| `hiss` | Hissing | + +**Vehicles and driveway** + +| Label | Detects | +| ----------------- | -------------------- | +| `car` | Passing cars | +| `honk` | Car horns | +| `truck` | Trucks | +| `reversing_beeps` | Vehicle backup beeps | +| `motorcycle` | Motorcycles | +| `engine_starting` | Engines starting | + +:::tip + +Frequently-heard labels like `speech` can generate a lot of events, and each event could save a snapshot and recording based on your configuration, so start with a focused set — the defaults (`bark`, `fire_alarm`, `speech`, `yell`) plus a few of the safety labels above cover most needs — and expand from there. See the [full audio labelmap](https://github.com/blakeblackshear/frigate/blob/dev/audio-labelmap.txt) or the Frigate UI for every available type. + +::: + ### Audio Transcription Frigate supports fully local audio transcription using either `sherpa-onnx` or OpenAI's open-source Whisper models via `faster-whisper`. The goal of this feature is to support Semantic Search for `speech` audio events. Frigate is not intended to act as a continuous, fully-automatic speech transcription service — automatically transcribing all speech (or queuing many audio events for transcription) requires substantial CPU (or GPU) resources and is impractical on most systems. For this reason, transcriptions for events are initiated manually from the UI or the API rather than being run continuously in the background. diff --git a/docs/docs/frigate/camera_setup.md b/docs/docs/frigate/camera_setup.md index 4cb56dc508..b402bb2f1a 100644 --- a/docs/docs/frigate/camera_setup.md +++ b/docs/docs/frigate/camera_setup.md @@ -5,7 +5,7 @@ title: Camera setup Cameras configured to output H.264 video and AAC audio will offer the most compatibility with all features of Frigate and Home Assistant. H.265 has better compression, but less compatibility. Firefox 134+/136+/137+ (Windows/Mac/Linux & Android), Chrome 108+, Safari and Edge are the only browsers able to play H.265 and only support a limited number of H.265 profiles. Ideally, cameras should be configured directly for the desired resolutions and frame rates you want to use in Frigate. Reducing frame rates within Frigate will waste CPU resources decoding extra frames that are discarded. There are three different goals that you want to tune your stream configurations around. -- **Detection**: This is the only stream that Frigate will decode for processing. Also, this is the stream where snapshots will be generated from. The resolution for detection should be tuned for the size of the objects you want to detect. See [Choosing a detect resolution](#choosing-a-detect-resolution) for more details. The recommended frame rate is 5fps, but may need to be higher (10fps is the recommended maximum for most users) for very fast moving objects. Higher resolutions and frame rates will drive higher CPU usage on your server. +- **Detection**: This is the only stream that Frigate will decode for processing. Also, this is the stream where snapshots will be generated from. The resolution for detection should be tuned for the size of the objects you want to detect. See [Choosing a detect resolution](#choosing-a-detect-resolution) for more details. The default frame rate of 5fps is correct for almost all cameras and rarely needs to be changed; see [Choosing a detect frame rate](#choosing-a-detect-frame-rate). Higher resolutions and frame rates will drive higher CPU usage on your server. - **Recording**: This stream should be the resolution you wish to store for reference. Typically, this will be the highest resolution your camera supports. I recommend setting this feed in your camera's firmware to 15 fps. @@ -25,6 +25,44 @@ Larger resolutions **do** improve performance if the objects are very small in t ![Resolutions](/img/resolutions-min.jpg) +### Choosing a detect frame rate + +`detect.fps` controls how many times per second Frigate runs object detection — it does **not** need to match your camera's frame rate. The default of **5** is correct for the vast majority of cameras. + +:::warning + +Most users who raise `detect.fps` above the default don't need to. Increasing it consumes more CPU/GPU (detection load scales directly with the frame rate) while providing **no benefit to tracking** once objects are already being followed smoothly. Leave it at **5** unless you have a specific scene that fails the test below, and confirm any change actually helps in the debug view. + +::: + +#### Why 5 is enough for almost everyone + +Frigate follows an object by matching its bounding box from one detection frame to the next, which requires the object to be detected often enough while it is on screen. At 5 fps this is satisfied in normal scenes: an object crossing a yard, porch, driveway, or walkway is in view for several seconds and produces ~15 or more detections, which is more than enough for a reliable track and a good snapshot. This includes fast subjects such as a running person or a bolting pet, which on a wide-angle view remain on screen for several seconds. + +A higher rate helps only when an object crosses the **entire frame in less than two seconds**, which is determined by camera framing rather than object speed - for example, a camera aimed down a street at fast cross-traffic. In those scenes 5 fps may produce too few detections to hold a track. Cameras covering normal approaches and open areas are unaffected. + +#### Checking whether a higher rate is needed + +Estimate how long an object is visible as it crosses the area of interest, aiming for roughly 8–10 detections during the pass: + +> **`detect.fps` ≈ 10 ÷ (seconds the object is in view)** + +Most objects — people walking or running, pets, and vehicles in a yard, driveway, or walkway — stay in view for two seconds or more, so the default of 5 fps is correct. Slowly try raising it to 10 (the recommended maximum) in increments only when objects routinely cross the entire frame in about a second, such as a camera aimed at a street or sidewalk with fast cross-traffic. Objects that transit in under a second cannot be tracked reliably at any practical rate, so reposition the camera instead. + +:::tip + +If the formula calls for more than 10, the fix is **camera placement, not frame rate**. Angle the camera so objects move toward it rather than across the view, or aim it where traffic slows. A higher `detect.fps` increases CPU load proportionally without producing more detections of a too-brief object. + +::: + +#### Verify in the debug view + +Confirm any change in the Debug view or Debug Replay. Watch a typical object cross the scene: if its bounding box follows it smoothly while visible, the rate is sufficient. A box that jumps erratically, drops out, or splits one object into multiple events indicates the rate should be increased one step. + +#### Dedicated LPR cameras + +A dedicated license plate recognition camera is the most common reason to use something higher than 5 fps: the camera is highly zoomed, the plate is small, and it moves at full vehicle speed, so it transits the frame quickly. However, the same ceiling applies: above 10 fps is unnecessary, and **placement matters most**: aim LPR cameras where vehicles slow down, such as gates, driveways, and parking entrances. A tight view of a fast through-road will not likely read plates reliably at any frame rate. See [License Plate Recognition](/configuration/license_plate_recognition) for details. + ### Example Camera Configuration For the Dahua/Loryta 5442 camera, I use the following settings: diff --git a/frigate/config/camera/audio.py b/frigate/config/camera/audio.py index 6028802df9..59a5180bed 100644 --- a/frigate/config/camera/audio.py +++ b/frigate/config/camera/audio.py @@ -9,7 +9,7 @@ from ..base import FrigateBaseModel __all__ = ["AudioConfig", "AudioFilterConfig"] -DEFAULT_LISTEN_AUDIO = ["bark", "fire_alarm", "scream", "speech", "yell"] +DEFAULT_LISTEN_AUDIO = ["bark", "fire_alarm", "speech", "yell"] class AudioFilterConfig(FrigateBaseModel): @@ -41,7 +41,7 @@ class AudioConfig(FrigateBaseModel): listen: list[str] = Field( default=DEFAULT_LISTEN_AUDIO, title="Listen types", - description="List of audio event types to detect (for example: bark, fire_alarm, scream, speech, yell).", + description="List of audio event types to detect (for example: bark, fire_alarm, speech, yell).", ) filters: Optional[dict[str, AudioFilterConfig]] = Field( None, diff --git a/frigate/config/camera/ffmpeg.py b/frigate/config/camera/ffmpeg.py index 6341cbcd13..7e63c4a1ed 100644 --- a/frigate/config/camera/ffmpeg.py +++ b/frigate/config/camera/ffmpeg.py @@ -49,7 +49,7 @@ class FfmpegConfig(FrigateBaseModel): path: str = Field( default="default", title="FFmpeg path", - description='Path to the FFmpeg binary to use or a version alias ("5.0" or "8.0").', + description='Path to the FFmpeg binary to use or a version alias ("7.0" or "8.0").', ) global_args: Union[str, list[str]] = Field( default=FFMPEG_GLOBAL_ARGS_DEFAULT, diff --git a/web/e2e/fixtures/mock-data/cases.json b/web/e2e/fixtures/mock-data/cases.json index 5d0c96b8c1..6174cdebf3 100644 --- a/web/e2e/fixtures/mock-data/cases.json +++ b/web/e2e/fixtures/mock-data/cases.json @@ -1 +1 @@ -[{"id": "case-001", "name": "Package Theft Investigation", "description": "Review of suspicious activity near the front porch", "created_at": 1775407931.3863528, "updated_at": 1775483531.3863528}] \ No newline at end of file +[{"id": "case-001", "name": "Package Theft Investigation", "description": "Review of suspicious activity near the front porch", "created_at": 1780597809.365581, "updated_at": 1780673409.365581}] \ No newline at end of file diff --git a/web/e2e/fixtures/mock-data/config-snapshot.json b/web/e2e/fixtures/mock-data/config-snapshot.json index 6b87982c48..2df402bc99 100644 --- a/web/e2e/fixtures/mock-data/config-snapshot.json +++ b/web/e2e/fixtures/mock-data/config-snapshot.json @@ -1 +1 @@ -{"version": null, "safe_mode": false, "environment_vars": {}, "logger": {"default": "info", "logs": {}}, "auth": {"enabled": true, "reset_admin_password": false, "cookie_name": "frigate_token", "cookie_secure": false, "session_length": 86400, "refresh_time": 1800, "failed_login_rate_limit": null, "trusted_proxies": [], "hash_iterations": 600000, "roles": {"admin": [], "viewer": []}, "admin_first_time_login": false}, "database": {"path": "/config/frigate.db"}, "go2rtc": {}, "mqtt": {"enabled": true, "host": "mqtt", "port": 1883, "topic_prefix": "frigate", "client_id": "frigate", "stats_interval": 60, "user": null, "password": null, "tls_ca_certs": null, "tls_client_cert": null, "tls_client_key": null, "tls_insecure": null, "qos": 0}, "notifications": {"enabled": false, "email": null, "cooldown": 0, "enabled_in_config": false}, "networking": {"ipv6": {"enabled": false}, "listen": {"internal": 5000, "external": 8971}}, "proxy": {"header_map": {"user": null, "role": null, "role_map": {}}, "logout_url": null, "auth_secret": null, "default_role": "viewer", "separator": ","}, "telemetry": {"network_interfaces": [], "stats": {"amd_gpu_stats": true, "intel_gpu_stats": true, "network_bandwidth": false, "intel_gpu_device": null}, "version_check": true}, "tls": {"enabled": true}, "ui": {"timezone": null, "time_format": "browser", "date_style": "short", "time_style": "medium", "unit_system": "metric"}, "detectors": {"cpu": {"type": "cpu", "model": {"path": "/cpu_model.tflite", "labelmap_path": null, "width": 320, "height": 320, "labelmap": {}, "attributes_map": {"person": ["amazon", "face"], "car": ["amazon", "an_post", "canada_post", "dhl", "dpd", "fedex", "gls", "license_plate", "nzpost", "postnl", "postnord", "purolator", "royal_mail", "ups", "usps"], "motorcycle": ["license_plate"]}, "input_tensor": "nhwc", "input_pixel_format": "rgb", "input_dtype": "int", "model_type": "ssd"}, "model_path": null}}, "model": {"path": null, "labelmap_path": null, "width": 320, "height": 320, "labelmap": {}, "attributes_map": {"person": ["amazon", "face"], "car": ["amazon", "an_post", "canada_post", "dhl", "dpd", "fedex", "gls", "license_plate", "nzpost", "postnl", "postnord", "purolator", "royal_mail", "ups", "usps"], "motorcycle": ["license_plate"]}, "input_tensor": "nhwc", "input_pixel_format": "rgb", "input_dtype": "int", "model_type": "ssd", "all_attributes": ["amazon", "an_post", "canada_post", "dhl", "dpd", "face", "fedex", "gls", "license_plate", "nzpost", "postnl", "postnord", "purolator", "royal_mail", "ups", "usps"], "colormap": {}}, "genai": {}, "cameras": {"front_door": {"name": "front_door", "friendly_name": null, "enabled": true, "audio": {"enabled": false, "max_not_heard": 30, "min_volume": 500, "listen": ["bark", "fire_alarm", "scream", "speech", "yell"], "filters": {"a_capella": {"threshold": 0.8}, "accelerating": {"threshold": 0.8}, "accordion": {"threshold": 0.8}, "acoustic_guitar": {"threshold": 0.8}, "afrobeat": {"threshold": 0.8}, "air_brake": {"threshold": 0.8}, "air_conditioning": {"threshold": 0.8}, "air_horn": {"threshold": 0.8}, "aircraft": {"threshold": 0.8}, "aircraft_engine": {"threshold": 0.8}, "alarm": {"threshold": 0.8}, "alarm_clock": {"threshold": 0.8}, "ambient_music": {"threshold": 0.8}, "ambulance": {"threshold": 0.8}, "angry_music": {"threshold": 0.8}, "animal": {"threshold": 0.8}, "applause": {"threshold": 0.8}, "arrow": {"threshold": 0.8}, "artillery_fire": {"threshold": 0.8}, "babbling": {"threshold": 0.8}, "background_music": {"threshold": 0.8}, "bagpipes": {"threshold": 0.8}, "bang": {"threshold": 0.8}, "banjo": {"threshold": 0.8}, "bark": {"threshold": 0.8}, "basketball_bounce": {"threshold": 0.8}, "bass_drum": {"threshold": 0.8}, "bass_guitar": {"threshold": 0.8}, "bathtub": {"threshold": 0.8}, "beatboxing": {"threshold": 0.8}, "beep": {"threshold": 0.8}, "bell": {"threshold": 0.8}, "bellow": {"threshold": 0.8}, "bicycle": {"threshold": 0.8}, "bicycle_bell": {"threshold": 0.8}, "bird": {"threshold": 0.8}, "biting": {"threshold": 0.8}, "bleat": {"threshold": 0.8}, "blender": {"threshold": 0.8}, "bluegrass": {"threshold": 0.8}, "blues": {"threshold": 0.8}, "boat": {"threshold": 0.8}, "boiling": {"threshold": 0.8}, "boing": {"threshold": 0.8}, "boom": {"threshold": 0.8}, "bouncing": {"threshold": 0.8}, "bow-wow": {"threshold": 0.8}, "bowed_string_instrument": {"threshold": 0.8}, "brass_instrument": {"threshold": 0.8}, "breaking": {"threshold": 0.8}, "breathing": {"threshold": 0.8}, "burping": {"threshold": 0.8}, "burst": {"threshold": 0.8}, "bus": {"threshold": 0.8}, "busy_signal": {"threshold": 0.8}, "buzz": {"threshold": 0.8}, "buzzer": {"threshold": 0.8}, "cacophony": {"threshold": 0.8}, "camera": {"threshold": 0.8}, "cap_gun": {"threshold": 0.8}, "car": {"threshold": 0.8}, "car_alarm": {"threshold": 0.8}, "car_passing_by": {"threshold": 0.8}, "carnatic_music": {"threshold": 0.8}, "cash_register": {"threshold": 0.8}, "cat": {"threshold": 0.8}, "caterwaul": {"threshold": 0.8}, "cattle": {"threshold": 0.8}, "caw": {"threshold": 0.8}, "cello": {"threshold": 0.8}, "chainsaw": {"threshold": 0.8}, "change_ringing": {"threshold": 0.8}, "chant": {"threshold": 0.8}, "chatter": {"threshold": 0.8}, "cheering": {"threshold": 0.8}, "chewing": {"threshold": 0.8}, "chicken": {"threshold": 0.8}, "child_singing": {"threshold": 0.8}, "children_playing": {"threshold": 0.8}, "chime": {"threshold": 0.8}, "chink": {"threshold": 0.8}, "chird": {"threshold": 0.8}, "chirp": {"threshold": 0.8}, "chirp_tone": {"threshold": 0.8}, "choir": {"threshold": 0.8}, "chop": {"threshold": 0.8}, "chopping": {"threshold": 0.8}, "chorus_effect": {"threshold": 0.8}, "christian_music": {"threshold": 0.8}, "christmas_music": {"threshold": 0.8}, "church_bell": {"threshold": 0.8}, "civil_defense_siren": {"threshold": 0.8}, "clang": {"threshold": 0.8}, "clapping": {"threshold": 0.8}, "clarinet": {"threshold": 0.8}, "classical_music": {"threshold": 0.8}, "clatter": {"threshold": 0.8}, "clickety-clack": {"threshold": 0.8}, "clicking": {"threshold": 0.8}, "clip-clop": {"threshold": 0.8}, "clock": {"threshold": 0.8}, "cluck": {"threshold": 0.8}, "cock-a-doodle-doo": {"threshold": 0.8}, "coin": {"threshold": 0.8}, "computer_keyboard": {"threshold": 0.8}, "coo": {"threshold": 0.8}, "cough": {"threshold": 0.8}, "country": {"threshold": 0.8}, "cowbell": {"threshold": 0.8}, "crack": {"threshold": 0.8}, "crackle": {"threshold": 0.8}, "creak": {"threshold": 0.8}, "cricket": {"threshold": 0.8}, "croak": {"threshold": 0.8}, "crow": {"threshold": 0.8}, "crowd": {"threshold": 0.8}, "crumpling": {"threshold": 0.8}, "crunch": {"threshold": 0.8}, "crushing": {"threshold": 0.8}, "crying": {"threshold": 0.8}, "cupboard_open_or_close": {"threshold": 0.8}, "cutlery": {"threshold": 0.8}, "cymbal": {"threshold": 0.8}, "dance_music": {"threshold": 0.8}, "dental_drill's_drill": {"threshold": 0.8}, "dial_tone": {"threshold": 0.8}, "didgeridoo": {"threshold": 0.8}, "ding": {"threshold": 0.8}, "ding-dong": {"threshold": 0.8}, "disco": {"threshold": 0.8}, "dishes": {"threshold": 0.8}, "distortion": {"threshold": 0.8}, "dog": {"threshold": 0.8}, "dogs": {"threshold": 0.8}, "door": {"threshold": 0.8}, "doorbell": {"threshold": 0.8}, "double_bass": {"threshold": 0.8}, "drawer_open_or_close": {"threshold": 0.8}, "drill": {"threshold": 0.8}, "drip": {"threshold": 0.8}, "drum": {"threshold": 0.8}, "drum_and_bass": {"threshold": 0.8}, "drum_kit": {"threshold": 0.8}, "drum_machine": {"threshold": 0.8}, "drum_roll": {"threshold": 0.8}, "dubstep": {"threshold": 0.8}, "duck": {"threshold": 0.8}, "echo": {"threshold": 0.8}, "effects_unit": {"threshold": 0.8}, "electric_guitar": {"threshold": 0.8}, "electric_piano": {"threshold": 0.8}, "electric_shaver": {"threshold": 0.8}, "electric_toothbrush": {"threshold": 0.8}, "electronic_dance_music": {"threshold": 0.8}, "electronic_music": {"threshold": 0.8}, "electronic_organ": {"threshold": 0.8}, "electronic_tuner": {"threshold": 0.8}, "electronica": {"threshold": 0.8}, "emergency_vehicle": {"threshold": 0.8}, "engine": {"threshold": 0.8}, "engine_knocking": {"threshold": 0.8}, "engine_starting": {"threshold": 0.8}, "environmental_noise": {"threshold": 0.8}, "eruption": {"threshold": 0.8}, "exciting_music": {"threshold": 0.8}, "explosion": {"threshold": 0.8}, "fart": {"threshold": 0.8}, "field_recording": {"threshold": 0.8}, "filing": {"threshold": 0.8}, "fill": {"threshold": 0.8}, "finger_snapping": {"threshold": 0.8}, "fire": {"threshold": 0.8}, "fire_alarm": {"threshold": 0.8}, "fire_engine": {"threshold": 0.8}, "firecracker": {"threshold": 0.8}, "fireworks": {"threshold": 0.8}, "fixed-wing_aircraft": {"threshold": 0.8}, "flamenco": {"threshold": 0.8}, "flap": {"threshold": 0.8}, "flapping_wings": {"threshold": 0.8}, "flute": {"threshold": 0.8}, "fly": {"threshold": 0.8}, "foghorn": {"threshold": 0.8}, "folk_music": {"threshold": 0.8}, "footsteps": {"threshold": 0.8}, "fowl": {"threshold": 0.8}, "french_horn": {"threshold": 0.8}, "frog": {"threshold": 0.8}, "frying": {"threshold": 0.8}, "funk": {"threshold": 0.8}, "fusillade": {"threshold": 0.8}, "gargling": {"threshold": 0.8}, "gasp": {"threshold": 0.8}, "gears": {"threshold": 0.8}, "glass": {"threshold": 0.8}, "glockenspiel": {"threshold": 0.8}, "goat": {"threshold": 0.8}, "gobble": {"threshold": 0.8}, "gong": {"threshold": 0.8}, "goose": {"threshold": 0.8}, "gospel_music": {"threshold": 0.8}, "groan": {"threshold": 0.8}, "growling": {"threshold": 0.8}, "grunge": {"threshold": 0.8}, "grunt": {"threshold": 0.8}, "guitar": {"threshold": 0.8}, "gunshot": {"threshold": 0.8}, "gurgling": {"threshold": 0.8}, "gush": {"threshold": 0.8}, "hair_dryer": {"threshold": 0.8}, "hammer": {"threshold": 0.8}, "hammond_organ": {"threshold": 0.8}, "hands": {"threshold": 0.8}, "happy_music": {"threshold": 0.8}, "harmonic": {"threshold": 0.8}, "harmonica": {"threshold": 0.8}, "harp": {"threshold": 0.8}, "harpsichord": {"threshold": 0.8}, "heart_murmur": {"threshold": 0.8}, "heartbeat": {"threshold": 0.8}, "heavy_engine": {"threshold": 0.8}, "heavy_metal": {"threshold": 0.8}, "helicopter": {"threshold": 0.8}, "hi-hat": {"threshold": 0.8}, "hiccup": {"threshold": 0.8}, "hip_hop_music": {"threshold": 0.8}, "hiss": {"threshold": 0.8}, "honk": {"threshold": 0.8}, "hoot": {"threshold": 0.8}, "horse": {"threshold": 0.8}, "house_music": {"threshold": 0.8}, "howl": {"threshold": 0.8}, "hum": {"threshold": 0.8}, "humming": {"threshold": 0.8}, "ice_cream_truck": {"threshold": 0.8}, "idling": {"threshold": 0.8}, "independent_music": {"threshold": 0.8}, "insect": {"threshold": 0.8}, "inside": {"threshold": 0.8}, "jackhammer": {"threshold": 0.8}, "jazz": {"threshold": 0.8}, "jet_engine": {"threshold": 0.8}, "jingle": {"threshold": 0.8}, "jingle_bell": {"threshold": 0.8}, "keyboard": {"threshold": 0.8}, "keys_jangling": {"threshold": 0.8}, "knock": {"threshold": 0.8}, "laughter": {"threshold": 0.8}, "lawn_mower": {"threshold": 0.8}, "light_engine": {"threshold": 0.8}, "liquid": {"threshold": 0.8}, "livestock": {"threshold": 0.8}, "lullaby": {"threshold": 0.8}, "machine_gun": {"threshold": 0.8}, "mains_hum": {"threshold": 0.8}, "mallet_percussion": {"threshold": 0.8}, "mandolin": {"threshold": 0.8}, "mantra": {"threshold": 0.8}, "maraca": {"threshold": 0.8}, "marimba": {"threshold": 0.8}, "mechanical_fan": {"threshold": 0.8}, "mechanisms": {"threshold": 0.8}, "medium_engine": {"threshold": 0.8}, "meow": {"threshold": 0.8}, "microwave_oven": {"threshold": 0.8}, "middle_eastern_music": {"threshold": 0.8}, "moo": {"threshold": 0.8}, "mosquito": {"threshold": 0.8}, "motor_vehicle": {"threshold": 0.8}, "motorboat": {"threshold": 0.8}, "motorcycle": {"threshold": 0.8}, "mouse": {"threshold": 0.8}, "music": {"threshold": 0.8}, "music_for_children": {"threshold": 0.8}, "music_of_africa": {"threshold": 0.8}, "music_of_asia": {"threshold": 0.8}, "music_of_bollywood": {"threshold": 0.8}, "music_of_latin_america": {"threshold": 0.8}, "musical_instrument": {"threshold": 0.8}, "neigh": {"threshold": 0.8}, "new-age_music": {"threshold": 0.8}, "noise": {"threshold": 0.8}, "ocean": {"threshold": 0.8}, "oink": {"threshold": 0.8}, "opera": {"threshold": 0.8}, "orchestra": {"threshold": 0.8}, "organ": {"threshold": 0.8}, "outside": {"threshold": 0.8}, "owl": {"threshold": 0.8}, "pant": {"threshold": 0.8}, "patter": {"threshold": 0.8}, "percussion": {"threshold": 0.8}, "pets": {"threshold": 0.8}, "piano": {"threshold": 0.8}, "pig": {"threshold": 0.8}, "pigeon": {"threshold": 0.8}, "ping": {"threshold": 0.8}, "pink_noise": {"threshold": 0.8}, "pizzicato": {"threshold": 0.8}, "plop": {"threshold": 0.8}, "plucked_string_instrument": {"threshold": 0.8}, "police_car": {"threshold": 0.8}, "pop_music": {"threshold": 0.8}, "pour": {"threshold": 0.8}, "power_tool": {"threshold": 0.8}, "power_windows": {"threshold": 0.8}, "printer": {"threshold": 0.8}, "progressive_rock": {"threshold": 0.8}, "propeller": {"threshold": 0.8}, "psychedelic_rock": {"threshold": 0.8}, "pulleys": {"threshold": 0.8}, "pulse": {"threshold": 0.8}, "pump": {"threshold": 0.8}, "punk_rock": {"threshold": 0.8}, "purr": {"threshold": 0.8}, "quack": {"threshold": 0.8}, "race_car": {"threshold": 0.8}, "radio": {"threshold": 0.8}, "rail_transport": {"threshold": 0.8}, "railroad_car": {"threshold": 0.8}, "rain": {"threshold": 0.8}, "rain_on_surface": {"threshold": 0.8}, "raindrop": {"threshold": 0.8}, "rapping": {"threshold": 0.8}, "ratchet": {"threshold": 0.8}, "rats": {"threshold": 0.8}, "rattle": {"threshold": 0.8}, "reggae": {"threshold": 0.8}, "reverberation": {"threshold": 0.8}, "reversing_beeps": {"threshold": 0.8}, "rhythm_and_blues": {"threshold": 0.8}, "rimshot": {"threshold": 0.8}, "ringtone": {"threshold": 0.8}, "roar": {"threshold": 0.8}, "roaring_cats": {"threshold": 0.8}, "rock_and_roll": {"threshold": 0.8}, "rock_music": {"threshold": 0.8}, "roll": {"threshold": 0.8}, "rowboat": {"threshold": 0.8}, "rub": {"threshold": 0.8}, "rumble": {"threshold": 0.8}, "run": {"threshold": 0.8}, "rustle": {"threshold": 0.8}, "rustling_leaves": {"threshold": 0.8}, "sad_music": {"threshold": 0.8}, "sailboat": {"threshold": 0.8}, "salsa_music": {"threshold": 0.8}, "sampler": {"threshold": 0.8}, "sanding": {"threshold": 0.8}, "sawing": {"threshold": 0.8}, "saxophone": {"threshold": 0.8}, "scary_music": {"threshold": 0.8}, "scissors": {"threshold": 0.8}, "scrape": {"threshold": 0.8}, "scratch": {"threshold": 0.8}, "scratching": {"threshold": 0.8}, "sewing_machine": {"threshold": 0.8}, "shatter": {"threshold": 0.8}, "sheep": {"threshold": 0.8}, "ship": {"threshold": 0.8}, "shofar": {"threshold": 0.8}, "shuffle": {"threshold": 0.8}, "shuffling_cards": {"threshold": 0.8}, "sidetone": {"threshold": 0.8}, "sigh": {"threshold": 0.8}, "silence": {"threshold": 0.8}, "sine_wave": {"threshold": 0.8}, "singing": {"threshold": 0.8}, "singing_bowl": {"threshold": 0.8}, "single-lens_reflex_camera": {"threshold": 0.8}, "sink": {"threshold": 0.8}, "siren": {"threshold": 0.8}, "sitar": {"threshold": 0.8}, "sizzle": {"threshold": 0.8}, "ska": {"threshold": 0.8}, "skateboard": {"threshold": 0.8}, "skidding": {"threshold": 0.8}, "slam": {"threshold": 0.8}, "slap": {"threshold": 0.8}, "sliding_door": {"threshold": 0.8}, "slosh": {"threshold": 0.8}, "smash": {"threshold": 0.8}, "smoke_detector": {"threshold": 0.8}, "snake": {"threshold": 0.8}, "snare_drum": {"threshold": 0.8}, "sneeze": {"threshold": 0.8}, "snicker": {"threshold": 0.8}, "sniff": {"threshold": 0.8}, "snoring": {"threshold": 0.8}, "snort": {"threshold": 0.8}, "sodeling": {"threshold": 0.8}, "sonar": {"threshold": 0.8}, "song": {"threshold": 0.8}, "soul_music": {"threshold": 0.8}, "sound_effect": {"threshold": 0.8}, "soundtrack_music": {"threshold": 0.8}, "speech": {"threshold": 0.8}, "splash": {"threshold": 0.8}, "splinter": {"threshold": 0.8}, "spray": {"threshold": 0.8}, "squawk": {"threshold": 0.8}, "squeak": {"threshold": 0.8}, "squeal": {"threshold": 0.8}, "squish": {"threshold": 0.8}, "static": {"threshold": 0.8}, "steam": {"threshold": 0.8}, "steam_whistle": {"threshold": 0.8}, "steel_guitar": {"threshold": 0.8}, "steelpan": {"threshold": 0.8}, "stir": {"threshold": 0.8}, "stomach_rumble": {"threshold": 0.8}, "stream": {"threshold": 0.8}, "string_section": {"threshold": 0.8}, "strum": {"threshold": 0.8}, "subway": {"threshold": 0.8}, "swing_music": {"threshold": 0.8}, "synthesizer": {"threshold": 0.8}, "synthetic_singing": {"threshold": 0.8}, "tabla": {"threshold": 0.8}, "tambourine": {"threshold": 0.8}, "tap": {"threshold": 0.8}, "tapping": {"threshold": 0.8}, "tearing": {"threshold": 0.8}, "techno": {"threshold": 0.8}, "telephone": {"threshold": 0.8}, "telephone_bell_ringing": {"threshold": 0.8}, "telephone_dialing": {"threshold": 0.8}, "television": {"threshold": 0.8}, "tender_music": {"threshold": 0.8}, "theme_music": {"threshold": 0.8}, "theremin": {"threshold": 0.8}, "throat_clearing": {"threshold": 0.8}, "throbbing": {"threshold": 0.8}, "thump": {"threshold": 0.8}, "thunder": {"threshold": 0.8}, "thunderstorm": {"threshold": 0.8}, "thunk": {"threshold": 0.8}, "tick": {"threshold": 0.8}, "tick-tock": {"threshold": 0.8}, "timpani": {"threshold": 0.8}, "tire_squeal": {"threshold": 0.8}, "toilet_flush": {"threshold": 0.8}, "tools": {"threshold": 0.8}, "toot": {"threshold": 0.8}, "toothbrush": {"threshold": 0.8}, "traditional_music": {"threshold": 0.8}, "traffic_noise": {"threshold": 0.8}, "train": {"threshold": 0.8}, "train_horn": {"threshold": 0.8}, "train_wheels_squealing": {"threshold": 0.8}, "train_whistle": {"threshold": 0.8}, "trance_music": {"threshold": 0.8}, "trickle": {"threshold": 0.8}, "trombone": {"threshold": 0.8}, "truck": {"threshold": 0.8}, "trumpet": {"threshold": 0.8}, "tubular_bells": {"threshold": 0.8}, "tuning_fork": {"threshold": 0.8}, "turkey": {"threshold": 0.8}, "typewriter": {"threshold": 0.8}, "typing": {"threshold": 0.8}, "ukulele": {"threshold": 0.8}, "vacuum_cleaner": {"threshold": 0.8}, "vehicle": {"threshold": 0.8}, "vibraphone": {"threshold": 0.8}, "vibration": {"threshold": 0.8}, "video_game_music": {"threshold": 0.8}, "violin": {"threshold": 0.8}, "vocal_music": {"threshold": 0.8}, "water": {"threshold": 0.8}, "water_tap": {"threshold": 0.8}, "waterfall": {"threshold": 0.8}, "waves": {"threshold": 0.8}, "wedding_music": {"threshold": 0.8}, "whack": {"threshold": 0.8}, "whale_vocalization": {"threshold": 0.8}, "wheeze": {"threshold": 0.8}, "whimper_dog": {"threshold": 0.8}, "whip": {"threshold": 0.8}, "whir": {"threshold": 0.8}, "whispering": {"threshold": 0.8}, "whistle": {"threshold": 0.8}, "whistling": {"threshold": 0.8}, "white_noise": {"threshold": 0.8}, "whoop": {"threshold": 0.8}, "whoosh": {"threshold": 0.8}, "wild_animals": {"threshold": 0.8}, "wind": {"threshold": 0.8}, "wind_chime": {"threshold": 0.8}, "wind_instrument": {"threshold": 0.8}, "wind_noise": {"threshold": 0.8}, "wood": {"threshold": 0.8}, "wood_block": {"threshold": 0.8}, "writing": {"threshold": 0.8}, "yell": {"threshold": 0.8}, "yip": {"threshold": 0.8}, "zing": {"threshold": 0.8}, "zipper": {"threshold": 0.8}, "zither": {"threshold": 0.8}}, "enabled_in_config": false, "num_threads": 2}, "audio_transcription": {"enabled": false, "enabled_in_config": false, "live_enabled": false}, "birdseye": {"enabled": true, "mode": "objects", "order": 0}, "detect": {"enabled": false, "height": 720, "width": 1280, "fps": 5, "min_initialized": 2, "max_disappeared": 25, "stationary": {"interval": 50, "threshold": 50, "max_frames": {"default": null, "objects": {}}, "classifier": true}, "annotation_offset": 0}, "face_recognition": {"enabled": false, "min_area": 750}, "ffmpeg": {"path": "default", "global_args": ["-hide_banner", "-loglevel", "warning", "-threads", "2"], "hwaccel_args": "preset-vaapi", "input_args": "preset-rtsp-generic", "output_args": {"detect": ["-threads", "2", "-f", "rawvideo", "-pix_fmt", "yuv420p"], "record": "preset-record-generic-audio-aac"}, "retry_interval": 10.0, "apple_compatibility": false, "gpu": 0, "inputs": [{"path": "rtsp://10.0.0.1:554/video", "roles": ["record", "detect"], "global_args": [], "hwaccel_args": [], "input_args": []}]}, "live": {"streams": {"front_door": "front_door"}, "height": 720, "quality": 8}, "lpr": {"enabled": false, "expire_time": 3, "min_area": 1000, "enhancement": 0}, "motion": {"enabled": true, "threshold": 30, "lightning_threshold": 0.8, "skip_motion_threshold": null, "improve_contrast": true, "contour_area": 10, "delta_alpha": 0.2, "frame_alpha": 0.01, "frame_height": 100, "mask": {}, "mqtt_off_delay": 30, "enabled_in_config": null}, "objects": {"track": ["person"], "filters": {"person": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.5, "mask": {}}}, "mask": {}, "genai": {"enabled": false, "use_snapshot": false, "prompt": "Analyze the sequence of images containing the {label}. Focus on the likely intent or behavior of the {label} based on its actions and movement, rather than describing its appearance or the surroundings. Consider what the {label} is doing, why, and what it might do next.", "object_prompts": {}, "objects": [], "required_zones": [], "debug_save_thumbnails": false, "send_triggers": {"tracked_object_end": true, "after_significant_updates": null}, "enabled_in_config": false}}, "record": {"enabled": false, "expire_interval": 60, "continuous": {"days": 0}, "motion": {"days": 0}, "detections": {"pre_capture": 5, "post_capture": 5, "retain": {"days": 10, "mode": "motion"}}, "alerts": {"pre_capture": 5, "post_capture": 5, "retain": {"days": 10, "mode": "motion"}}, "export": {"hwaccel_args": "preset-vaapi"}, "preview": {"quality": "medium"}, "enabled_in_config": false}, "review": {"alerts": {"enabled": true, "labels": ["person", "car"], "required_zones": [], "enabled_in_config": true, "cutoff_time": 40}, "detections": {"enabled": true, "labels": null, "required_zones": [], "cutoff_time": 30, "enabled_in_config": true}, "genai": {"enabled": false, "alerts": true, "detections": false, "image_source": "preview", "additional_concerns": [], "debug_save_thumbnails": false, "enabled_in_config": false, "preferred_language": null, "activity_context_prompt": "### Normal Activity Indicators (Level 0)\n- Known/verified people in any zone at any time\n- People with pets in residential areas\n- Routine residential vehicle access during daytime/evening (6 AM - 10 PM): entering, exiting, loading/unloading items \u2014 normal commute and travel patterns\n- Deliveries or services during daytime/evening (6 AM - 10 PM): carrying packages to doors/porches, placing items, leaving\n- Services/maintenance workers with visible tools, uniforms, or service vehicles during daytime\n- Activity confined to public areas only (sidewalks, streets) without entering property at any time\n\n### Suspicious Activity Indicators (Level 1)\n- **Checking or probing vehicle/building access**: trying handles without entering, peering through windows, examining multiple vehicles, or possessing break-in tools \u2014 Level 1\n- **Unidentified person in private areas (driveways, near vehicles/buildings) during late night/early morning (11 PM - 5 AM)** \u2014 ALWAYS Level 1 regardless of activity or duration\n- Taking items that don't belong to them (packages, objects from porches/driveways)\n- Climbing or jumping fences/barriers to access property\n- Attempting to conceal actions or items from view\n- Prolonged loitering: remaining in same area without visible purpose throughout most of the sequence\n\n### Critical Threat Indicators (Level 2)\n- Holding break-in tools (crowbars, pry bars, bolt cutters)\n- Weapons visible (guns, knives, bats used aggressively)\n- Forced entry in progress\n- Physical aggression or violence\n- Active property damage or theft in progress\n\n### Assessment Guidance\nEvaluate in this order:\n\n1. **If person is verified/known** \u2192 Level 0 regardless of time or activity\n2. **If person is unidentified:**\n - Check time: If late night/early morning (11 PM - 5 AM) AND in private areas (driveways, near vehicles/buildings) \u2192 Level 1\n - Check actions: If probing access (trying handles without entering, checking multiple vehicles), taking items, climbing \u2192 Level 1\n - Otherwise, if daytime/evening (6 AM - 10 PM) with clear legitimate purpose (delivery, service, routine vehicle access) \u2192 Level 0\n3. **Escalate to Level 2 if:** Weapons, break-in tools, forced entry in progress, violence, or active property damage visible (escalates from Level 0 or 1)\n\nThe mere presence of an unidentified person in private areas during late night hours is inherently suspicious and warrants human review, regardless of what activity they appear to be doing or how brief the sequence is."}}, "semantic_search": {"triggers": {}}, "snapshots": {"enabled": false, "timestamp": false, "bounding_box": true, "crop": false, "required_zones": [], "height": null, "retain": {"default": 10, "mode": "motion", "objects": {}}, "quality": 60}, "timestamp_style": {"position": "tl", "format": "%m/%d/%Y %H:%M:%S", "color": {"red": 255, "green": 255, "blue": 255}, "thickness": 2, "effect": null}, "best_image_timeout": 60, "mqtt": {"enabled": true, "timestamp": true, "bounding_box": true, "crop": true, "height": 270, "required_zones": [], "quality": 70}, "notifications": {"enabled": false, "email": null, "cooldown": 0, "enabled_in_config": false}, "onvif": {"host": "", "port": 8000, "user": null, "password": null, "tls_insecure": false, "profile": null, "autotracking": {"enabled": false, "calibrate_on_startup": false, "zooming": "disabled", "zoom_factor": 0.3, "track": ["person"], "required_zones": [], "return_preset": "home", "timeout": 10, "movement_weights": [], "enabled_in_config": false}, "ignore_time_mismatch": false}, "type": "generic", "ui": {"order": 0, "dashboard": true}, "webui_url": null, "profiles": {}, "zones": {}, "enabled_in_config": true}, "backyard": {"name": "backyard", "friendly_name": null, "enabled": true, "audio": {"enabled": false, "max_not_heard": 30, "min_volume": 500, "listen": ["bark", "fire_alarm", "scream", "speech", "yell"], "filters": {"a_capella": {"threshold": 0.8}, "accelerating": {"threshold": 0.8}, "accordion": {"threshold": 0.8}, "acoustic_guitar": {"threshold": 0.8}, "afrobeat": {"threshold": 0.8}, "air_brake": {"threshold": 0.8}, "air_conditioning": {"threshold": 0.8}, "air_horn": {"threshold": 0.8}, "aircraft": {"threshold": 0.8}, "aircraft_engine": {"threshold": 0.8}, "alarm": {"threshold": 0.8}, "alarm_clock": {"threshold": 0.8}, "ambient_music": {"threshold": 0.8}, "ambulance": {"threshold": 0.8}, "angry_music": {"threshold": 0.8}, "animal": {"threshold": 0.8}, "applause": {"threshold": 0.8}, "arrow": {"threshold": 0.8}, "artillery_fire": {"threshold": 0.8}, "babbling": {"threshold": 0.8}, "background_music": {"threshold": 0.8}, "bagpipes": {"threshold": 0.8}, "bang": {"threshold": 0.8}, "banjo": {"threshold": 0.8}, "bark": {"threshold": 0.8}, "basketball_bounce": {"threshold": 0.8}, "bass_drum": {"threshold": 0.8}, "bass_guitar": {"threshold": 0.8}, "bathtub": {"threshold": 0.8}, "beatboxing": {"threshold": 0.8}, "beep": {"threshold": 0.8}, "bell": {"threshold": 0.8}, "bellow": {"threshold": 0.8}, "bicycle": {"threshold": 0.8}, "bicycle_bell": {"threshold": 0.8}, "bird": {"threshold": 0.8}, "biting": {"threshold": 0.8}, "bleat": {"threshold": 0.8}, "blender": {"threshold": 0.8}, "bluegrass": {"threshold": 0.8}, "blues": {"threshold": 0.8}, "boat": {"threshold": 0.8}, "boiling": {"threshold": 0.8}, "boing": {"threshold": 0.8}, "boom": {"threshold": 0.8}, "bouncing": {"threshold": 0.8}, "bow-wow": {"threshold": 0.8}, "bowed_string_instrument": {"threshold": 0.8}, "brass_instrument": {"threshold": 0.8}, "breaking": {"threshold": 0.8}, "breathing": {"threshold": 0.8}, "burping": {"threshold": 0.8}, "burst": {"threshold": 0.8}, "bus": {"threshold": 0.8}, "busy_signal": {"threshold": 0.8}, "buzz": {"threshold": 0.8}, "buzzer": {"threshold": 0.8}, "cacophony": {"threshold": 0.8}, "camera": {"threshold": 0.8}, "cap_gun": {"threshold": 0.8}, "car": {"threshold": 0.8}, "car_alarm": {"threshold": 0.8}, "car_passing_by": {"threshold": 0.8}, "carnatic_music": {"threshold": 0.8}, "cash_register": {"threshold": 0.8}, "cat": {"threshold": 0.8}, "caterwaul": {"threshold": 0.8}, "cattle": {"threshold": 0.8}, "caw": {"threshold": 0.8}, "cello": {"threshold": 0.8}, "chainsaw": {"threshold": 0.8}, "change_ringing": {"threshold": 0.8}, "chant": {"threshold": 0.8}, "chatter": {"threshold": 0.8}, "cheering": {"threshold": 0.8}, "chewing": {"threshold": 0.8}, "chicken": {"threshold": 0.8}, "child_singing": {"threshold": 0.8}, "children_playing": {"threshold": 0.8}, "chime": {"threshold": 0.8}, "chink": {"threshold": 0.8}, "chird": {"threshold": 0.8}, "chirp": {"threshold": 0.8}, "chirp_tone": {"threshold": 0.8}, "choir": {"threshold": 0.8}, "chop": {"threshold": 0.8}, "chopping": {"threshold": 0.8}, "chorus_effect": {"threshold": 0.8}, "christian_music": {"threshold": 0.8}, "christmas_music": {"threshold": 0.8}, "church_bell": {"threshold": 0.8}, "civil_defense_siren": {"threshold": 0.8}, "clang": {"threshold": 0.8}, "clapping": {"threshold": 0.8}, "clarinet": {"threshold": 0.8}, "classical_music": {"threshold": 0.8}, "clatter": {"threshold": 0.8}, "clickety-clack": {"threshold": 0.8}, "clicking": {"threshold": 0.8}, "clip-clop": {"threshold": 0.8}, "clock": {"threshold": 0.8}, "cluck": {"threshold": 0.8}, "cock-a-doodle-doo": {"threshold": 0.8}, "coin": {"threshold": 0.8}, "computer_keyboard": {"threshold": 0.8}, "coo": {"threshold": 0.8}, "cough": {"threshold": 0.8}, "country": {"threshold": 0.8}, "cowbell": {"threshold": 0.8}, "crack": {"threshold": 0.8}, "crackle": {"threshold": 0.8}, "creak": {"threshold": 0.8}, "cricket": {"threshold": 0.8}, "croak": {"threshold": 0.8}, "crow": {"threshold": 0.8}, "crowd": {"threshold": 0.8}, "crumpling": {"threshold": 0.8}, "crunch": {"threshold": 0.8}, "crushing": {"threshold": 0.8}, "crying": {"threshold": 0.8}, "cupboard_open_or_close": {"threshold": 0.8}, "cutlery": {"threshold": 0.8}, "cymbal": {"threshold": 0.8}, "dance_music": {"threshold": 0.8}, "dental_drill's_drill": {"threshold": 0.8}, "dial_tone": {"threshold": 0.8}, "didgeridoo": {"threshold": 0.8}, "ding": {"threshold": 0.8}, "ding-dong": {"threshold": 0.8}, "disco": {"threshold": 0.8}, "dishes": {"threshold": 0.8}, "distortion": {"threshold": 0.8}, "dog": {"threshold": 0.8}, "dogs": {"threshold": 0.8}, "door": {"threshold": 0.8}, "doorbell": {"threshold": 0.8}, "double_bass": {"threshold": 0.8}, "drawer_open_or_close": {"threshold": 0.8}, "drill": {"threshold": 0.8}, "drip": {"threshold": 0.8}, "drum": {"threshold": 0.8}, "drum_and_bass": {"threshold": 0.8}, "drum_kit": {"threshold": 0.8}, "drum_machine": {"threshold": 0.8}, "drum_roll": {"threshold": 0.8}, "dubstep": {"threshold": 0.8}, "duck": {"threshold": 0.8}, "echo": {"threshold": 0.8}, "effects_unit": {"threshold": 0.8}, "electric_guitar": {"threshold": 0.8}, "electric_piano": {"threshold": 0.8}, "electric_shaver": {"threshold": 0.8}, "electric_toothbrush": {"threshold": 0.8}, "electronic_dance_music": {"threshold": 0.8}, "electronic_music": {"threshold": 0.8}, "electronic_organ": {"threshold": 0.8}, "electronic_tuner": {"threshold": 0.8}, "electronica": {"threshold": 0.8}, "emergency_vehicle": {"threshold": 0.8}, "engine": {"threshold": 0.8}, "engine_knocking": {"threshold": 0.8}, "engine_starting": {"threshold": 0.8}, "environmental_noise": {"threshold": 0.8}, "eruption": {"threshold": 0.8}, "exciting_music": {"threshold": 0.8}, "explosion": {"threshold": 0.8}, "fart": {"threshold": 0.8}, "field_recording": {"threshold": 0.8}, "filing": {"threshold": 0.8}, "fill": {"threshold": 0.8}, "finger_snapping": {"threshold": 0.8}, "fire": {"threshold": 0.8}, "fire_alarm": {"threshold": 0.8}, "fire_engine": {"threshold": 0.8}, "firecracker": {"threshold": 0.8}, "fireworks": {"threshold": 0.8}, "fixed-wing_aircraft": {"threshold": 0.8}, "flamenco": {"threshold": 0.8}, "flap": {"threshold": 0.8}, "flapping_wings": {"threshold": 0.8}, "flute": {"threshold": 0.8}, "fly": {"threshold": 0.8}, "foghorn": {"threshold": 0.8}, "folk_music": {"threshold": 0.8}, "footsteps": {"threshold": 0.8}, "fowl": {"threshold": 0.8}, "french_horn": {"threshold": 0.8}, "frog": {"threshold": 0.8}, "frying": {"threshold": 0.8}, "funk": {"threshold": 0.8}, "fusillade": {"threshold": 0.8}, "gargling": {"threshold": 0.8}, "gasp": {"threshold": 0.8}, "gears": {"threshold": 0.8}, "glass": {"threshold": 0.8}, "glockenspiel": {"threshold": 0.8}, "goat": {"threshold": 0.8}, "gobble": {"threshold": 0.8}, "gong": {"threshold": 0.8}, "goose": {"threshold": 0.8}, "gospel_music": {"threshold": 0.8}, "groan": {"threshold": 0.8}, "growling": {"threshold": 0.8}, "grunge": {"threshold": 0.8}, "grunt": {"threshold": 0.8}, "guitar": {"threshold": 0.8}, "gunshot": {"threshold": 0.8}, "gurgling": {"threshold": 0.8}, "gush": {"threshold": 0.8}, "hair_dryer": {"threshold": 0.8}, "hammer": {"threshold": 0.8}, "hammond_organ": {"threshold": 0.8}, "hands": {"threshold": 0.8}, "happy_music": {"threshold": 0.8}, "harmonic": {"threshold": 0.8}, "harmonica": {"threshold": 0.8}, "harp": {"threshold": 0.8}, "harpsichord": {"threshold": 0.8}, "heart_murmur": {"threshold": 0.8}, "heartbeat": {"threshold": 0.8}, "heavy_engine": {"threshold": 0.8}, "heavy_metal": {"threshold": 0.8}, "helicopter": {"threshold": 0.8}, "hi-hat": {"threshold": 0.8}, "hiccup": {"threshold": 0.8}, "hip_hop_music": {"threshold": 0.8}, "hiss": {"threshold": 0.8}, "honk": {"threshold": 0.8}, "hoot": {"threshold": 0.8}, "horse": {"threshold": 0.8}, "house_music": {"threshold": 0.8}, "howl": {"threshold": 0.8}, "hum": {"threshold": 0.8}, "humming": {"threshold": 0.8}, "ice_cream_truck": {"threshold": 0.8}, "idling": {"threshold": 0.8}, "independent_music": {"threshold": 0.8}, "insect": {"threshold": 0.8}, "inside": {"threshold": 0.8}, "jackhammer": {"threshold": 0.8}, "jazz": {"threshold": 0.8}, "jet_engine": {"threshold": 0.8}, "jingle": {"threshold": 0.8}, "jingle_bell": {"threshold": 0.8}, "keyboard": {"threshold": 0.8}, "keys_jangling": {"threshold": 0.8}, "knock": {"threshold": 0.8}, "laughter": {"threshold": 0.8}, "lawn_mower": {"threshold": 0.8}, "light_engine": {"threshold": 0.8}, "liquid": {"threshold": 0.8}, "livestock": {"threshold": 0.8}, "lullaby": {"threshold": 0.8}, "machine_gun": {"threshold": 0.8}, "mains_hum": {"threshold": 0.8}, "mallet_percussion": {"threshold": 0.8}, "mandolin": {"threshold": 0.8}, "mantra": {"threshold": 0.8}, "maraca": {"threshold": 0.8}, "marimba": {"threshold": 0.8}, "mechanical_fan": {"threshold": 0.8}, "mechanisms": {"threshold": 0.8}, "medium_engine": {"threshold": 0.8}, "meow": {"threshold": 0.8}, "microwave_oven": {"threshold": 0.8}, "middle_eastern_music": {"threshold": 0.8}, "moo": {"threshold": 0.8}, "mosquito": {"threshold": 0.8}, "motor_vehicle": {"threshold": 0.8}, "motorboat": {"threshold": 0.8}, "motorcycle": {"threshold": 0.8}, "mouse": {"threshold": 0.8}, "music": {"threshold": 0.8}, "music_for_children": {"threshold": 0.8}, "music_of_africa": {"threshold": 0.8}, "music_of_asia": {"threshold": 0.8}, "music_of_bollywood": {"threshold": 0.8}, "music_of_latin_america": {"threshold": 0.8}, "musical_instrument": {"threshold": 0.8}, "neigh": {"threshold": 0.8}, "new-age_music": {"threshold": 0.8}, "noise": {"threshold": 0.8}, "ocean": {"threshold": 0.8}, "oink": {"threshold": 0.8}, "opera": {"threshold": 0.8}, "orchestra": {"threshold": 0.8}, "organ": {"threshold": 0.8}, "outside": {"threshold": 0.8}, "owl": {"threshold": 0.8}, "pant": {"threshold": 0.8}, "patter": {"threshold": 0.8}, "percussion": {"threshold": 0.8}, "pets": {"threshold": 0.8}, "piano": {"threshold": 0.8}, "pig": {"threshold": 0.8}, "pigeon": {"threshold": 0.8}, "ping": {"threshold": 0.8}, "pink_noise": {"threshold": 0.8}, "pizzicato": {"threshold": 0.8}, "plop": {"threshold": 0.8}, "plucked_string_instrument": {"threshold": 0.8}, "police_car": {"threshold": 0.8}, "pop_music": {"threshold": 0.8}, "pour": {"threshold": 0.8}, "power_tool": {"threshold": 0.8}, "power_windows": {"threshold": 0.8}, "printer": {"threshold": 0.8}, "progressive_rock": {"threshold": 0.8}, "propeller": {"threshold": 0.8}, "psychedelic_rock": {"threshold": 0.8}, "pulleys": {"threshold": 0.8}, "pulse": {"threshold": 0.8}, "pump": {"threshold": 0.8}, "punk_rock": {"threshold": 0.8}, "purr": {"threshold": 0.8}, "quack": {"threshold": 0.8}, "race_car": {"threshold": 0.8}, "radio": {"threshold": 0.8}, "rail_transport": {"threshold": 0.8}, "railroad_car": {"threshold": 0.8}, "rain": {"threshold": 0.8}, "rain_on_surface": {"threshold": 0.8}, "raindrop": {"threshold": 0.8}, "rapping": {"threshold": 0.8}, "ratchet": {"threshold": 0.8}, "rats": {"threshold": 0.8}, "rattle": {"threshold": 0.8}, "reggae": {"threshold": 0.8}, "reverberation": {"threshold": 0.8}, "reversing_beeps": {"threshold": 0.8}, "rhythm_and_blues": {"threshold": 0.8}, "rimshot": {"threshold": 0.8}, "ringtone": {"threshold": 0.8}, "roar": {"threshold": 0.8}, "roaring_cats": {"threshold": 0.8}, "rock_and_roll": {"threshold": 0.8}, "rock_music": {"threshold": 0.8}, "roll": {"threshold": 0.8}, "rowboat": {"threshold": 0.8}, "rub": {"threshold": 0.8}, "rumble": {"threshold": 0.8}, "run": {"threshold": 0.8}, "rustle": {"threshold": 0.8}, "rustling_leaves": {"threshold": 0.8}, "sad_music": {"threshold": 0.8}, "sailboat": {"threshold": 0.8}, "salsa_music": {"threshold": 0.8}, "sampler": {"threshold": 0.8}, "sanding": {"threshold": 0.8}, "sawing": {"threshold": 0.8}, "saxophone": {"threshold": 0.8}, "scary_music": {"threshold": 0.8}, "scissors": {"threshold": 0.8}, "scrape": {"threshold": 0.8}, "scratch": {"threshold": 0.8}, "scratching": {"threshold": 0.8}, "sewing_machine": {"threshold": 0.8}, "shatter": {"threshold": 0.8}, "sheep": {"threshold": 0.8}, "ship": {"threshold": 0.8}, "shofar": {"threshold": 0.8}, "shuffle": {"threshold": 0.8}, "shuffling_cards": {"threshold": 0.8}, "sidetone": {"threshold": 0.8}, "sigh": {"threshold": 0.8}, "silence": {"threshold": 0.8}, "sine_wave": {"threshold": 0.8}, "singing": {"threshold": 0.8}, "singing_bowl": {"threshold": 0.8}, "single-lens_reflex_camera": {"threshold": 0.8}, "sink": {"threshold": 0.8}, "siren": {"threshold": 0.8}, "sitar": {"threshold": 0.8}, "sizzle": {"threshold": 0.8}, "ska": {"threshold": 0.8}, "skateboard": {"threshold": 0.8}, "skidding": {"threshold": 0.8}, "slam": {"threshold": 0.8}, "slap": {"threshold": 0.8}, "sliding_door": {"threshold": 0.8}, "slosh": {"threshold": 0.8}, "smash": {"threshold": 0.8}, "smoke_detector": {"threshold": 0.8}, "snake": {"threshold": 0.8}, "snare_drum": {"threshold": 0.8}, "sneeze": {"threshold": 0.8}, "snicker": {"threshold": 0.8}, "sniff": {"threshold": 0.8}, "snoring": {"threshold": 0.8}, "snort": {"threshold": 0.8}, "sodeling": {"threshold": 0.8}, "sonar": {"threshold": 0.8}, "song": {"threshold": 0.8}, "soul_music": {"threshold": 0.8}, "sound_effect": {"threshold": 0.8}, "soundtrack_music": {"threshold": 0.8}, "speech": {"threshold": 0.8}, "splash": {"threshold": 0.8}, "splinter": {"threshold": 0.8}, "spray": {"threshold": 0.8}, "squawk": {"threshold": 0.8}, "squeak": {"threshold": 0.8}, "squeal": {"threshold": 0.8}, "squish": {"threshold": 0.8}, "static": {"threshold": 0.8}, "steam": {"threshold": 0.8}, "steam_whistle": {"threshold": 0.8}, "steel_guitar": {"threshold": 0.8}, "steelpan": {"threshold": 0.8}, "stir": {"threshold": 0.8}, "stomach_rumble": {"threshold": 0.8}, "stream": {"threshold": 0.8}, "string_section": {"threshold": 0.8}, "strum": {"threshold": 0.8}, "subway": {"threshold": 0.8}, "swing_music": {"threshold": 0.8}, "synthesizer": {"threshold": 0.8}, "synthetic_singing": {"threshold": 0.8}, "tabla": {"threshold": 0.8}, "tambourine": {"threshold": 0.8}, "tap": {"threshold": 0.8}, "tapping": {"threshold": 0.8}, "tearing": {"threshold": 0.8}, "techno": {"threshold": 0.8}, "telephone": {"threshold": 0.8}, "telephone_bell_ringing": {"threshold": 0.8}, "telephone_dialing": {"threshold": 0.8}, "television": {"threshold": 0.8}, "tender_music": {"threshold": 0.8}, "theme_music": {"threshold": 0.8}, "theremin": {"threshold": 0.8}, "throat_clearing": {"threshold": 0.8}, "throbbing": {"threshold": 0.8}, "thump": {"threshold": 0.8}, "thunder": {"threshold": 0.8}, "thunderstorm": {"threshold": 0.8}, "thunk": {"threshold": 0.8}, "tick": {"threshold": 0.8}, "tick-tock": {"threshold": 0.8}, "timpani": {"threshold": 0.8}, "tire_squeal": {"threshold": 0.8}, "toilet_flush": {"threshold": 0.8}, "tools": {"threshold": 0.8}, "toot": {"threshold": 0.8}, "toothbrush": {"threshold": 0.8}, "traditional_music": {"threshold": 0.8}, "traffic_noise": {"threshold": 0.8}, "train": {"threshold": 0.8}, "train_horn": {"threshold": 0.8}, "train_wheels_squealing": {"threshold": 0.8}, "train_whistle": {"threshold": 0.8}, "trance_music": {"threshold": 0.8}, "trickle": {"threshold": 0.8}, "trombone": {"threshold": 0.8}, "truck": {"threshold": 0.8}, "trumpet": {"threshold": 0.8}, "tubular_bells": {"threshold": 0.8}, "tuning_fork": {"threshold": 0.8}, "turkey": {"threshold": 0.8}, "typewriter": {"threshold": 0.8}, "typing": {"threshold": 0.8}, "ukulele": {"threshold": 0.8}, "vacuum_cleaner": {"threshold": 0.8}, "vehicle": {"threshold": 0.8}, "vibraphone": {"threshold": 0.8}, "vibration": {"threshold": 0.8}, "video_game_music": {"threshold": 0.8}, "violin": {"threshold": 0.8}, "vocal_music": {"threshold": 0.8}, "water": {"threshold": 0.8}, "water_tap": {"threshold": 0.8}, "waterfall": {"threshold": 0.8}, "waves": {"threshold": 0.8}, "wedding_music": {"threshold": 0.8}, "whack": {"threshold": 0.8}, "whale_vocalization": {"threshold": 0.8}, "wheeze": {"threshold": 0.8}, "whimper_dog": {"threshold": 0.8}, "whip": {"threshold": 0.8}, "whir": {"threshold": 0.8}, "whispering": {"threshold": 0.8}, "whistle": {"threshold": 0.8}, "whistling": {"threshold": 0.8}, "white_noise": {"threshold": 0.8}, "whoop": {"threshold": 0.8}, "whoosh": {"threshold": 0.8}, "wild_animals": {"threshold": 0.8}, "wind": {"threshold": 0.8}, "wind_chime": {"threshold": 0.8}, "wind_instrument": {"threshold": 0.8}, "wind_noise": {"threshold": 0.8}, "wood": {"threshold": 0.8}, "wood_block": {"threshold": 0.8}, "writing": {"threshold": 0.8}, "yell": {"threshold": 0.8}, "yip": {"threshold": 0.8}, "zing": {"threshold": 0.8}, "zipper": {"threshold": 0.8}, "zither": {"threshold": 0.8}}, "enabled_in_config": false, "num_threads": 2}, "audio_transcription": {"enabled": false, "enabled_in_config": false, "live_enabled": false}, "birdseye": {"enabled": true, "mode": "objects", "order": 0}, "detect": {"enabled": false, "height": 720, "width": 1280, "fps": 5, "min_initialized": 2, "max_disappeared": 25, "stationary": {"interval": 50, "threshold": 50, "max_frames": {"default": null, "objects": {}}, "classifier": true}, "annotation_offset": 0}, "face_recognition": {"enabled": false, "min_area": 750}, "ffmpeg": {"path": "default", "global_args": ["-hide_banner", "-loglevel", "warning", "-threads", "2"], "hwaccel_args": "preset-vaapi", "input_args": "preset-rtsp-generic", "output_args": {"detect": ["-threads", "2", "-f", "rawvideo", "-pix_fmt", "yuv420p"], "record": "preset-record-generic-audio-aac"}, "retry_interval": 10.0, "apple_compatibility": false, "gpu": 0, "inputs": [{"path": "rtsp://10.0.0.2:554/video", "roles": ["record", "detect"], "global_args": [], "hwaccel_args": [], "input_args": []}]}, "live": {"streams": {"backyard": "backyard"}, "height": 720, "quality": 8}, "lpr": {"enabled": false, "expire_time": 3, "min_area": 1000, "enhancement": 0}, "motion": {"enabled": true, "threshold": 30, "lightning_threshold": 0.8, "skip_motion_threshold": null, "improve_contrast": true, "contour_area": 10, "delta_alpha": 0.2, "frame_alpha": 0.01, "frame_height": 100, "mask": {}, "mqtt_off_delay": 30, "enabled_in_config": null}, "objects": {"track": ["person"], "filters": {"person": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.5, "mask": {}}}, "mask": {}, "genai": {"enabled": false, "use_snapshot": false, "prompt": "Analyze the sequence of images containing the {label}. Focus on the likely intent or behavior of the {label} based on its actions and movement, rather than describing its appearance or the surroundings. Consider what the {label} is doing, why, and what it might do next.", "object_prompts": {}, "objects": [], "required_zones": [], "debug_save_thumbnails": false, "send_triggers": {"tracked_object_end": true, "after_significant_updates": null}, "enabled_in_config": false}}, "record": {"enabled": false, "expire_interval": 60, "continuous": {"days": 0}, "motion": {"days": 0}, "detections": {"pre_capture": 5, "post_capture": 5, "retain": {"days": 10, "mode": "motion"}}, "alerts": {"pre_capture": 5, "post_capture": 5, "retain": {"days": 10, "mode": "motion"}}, "export": {"hwaccel_args": "preset-vaapi"}, "preview": {"quality": "medium"}, "enabled_in_config": false}, "review": {"alerts": {"enabled": true, "labels": ["person", "car"], "required_zones": [], "enabled_in_config": true, "cutoff_time": 40}, "detections": {"enabled": true, "labels": null, "required_zones": [], "cutoff_time": 30, "enabled_in_config": true}, "genai": {"enabled": false, "alerts": true, "detections": false, "image_source": "preview", "additional_concerns": [], "debug_save_thumbnails": false, "enabled_in_config": false, "preferred_language": null, "activity_context_prompt": "### Normal Activity Indicators (Level 0)\n- Known/verified people in any zone at any time\n- People with pets in residential areas\n- Routine residential vehicle access during daytime/evening (6 AM - 10 PM): entering, exiting, loading/unloading items \u2014 normal commute and travel patterns\n- Deliveries or services during daytime/evening (6 AM - 10 PM): carrying packages to doors/porches, placing items, leaving\n- Services/maintenance workers with visible tools, uniforms, or service vehicles during daytime\n- Activity confined to public areas only (sidewalks, streets) without entering property at any time\n\n### Suspicious Activity Indicators (Level 1)\n- **Checking or probing vehicle/building access**: trying handles without entering, peering through windows, examining multiple vehicles, or possessing break-in tools \u2014 Level 1\n- **Unidentified person in private areas (driveways, near vehicles/buildings) during late night/early morning (11 PM - 5 AM)** \u2014 ALWAYS Level 1 regardless of activity or duration\n- Taking items that don't belong to them (packages, objects from porches/driveways)\n- Climbing or jumping fences/barriers to access property\n- Attempting to conceal actions or items from view\n- Prolonged loitering: remaining in same area without visible purpose throughout most of the sequence\n\n### Critical Threat Indicators (Level 2)\n- Holding break-in tools (crowbars, pry bars, bolt cutters)\n- Weapons visible (guns, knives, bats used aggressively)\n- Forced entry in progress\n- Physical aggression or violence\n- Active property damage or theft in progress\n\n### Assessment Guidance\nEvaluate in this order:\n\n1. **If person is verified/known** \u2192 Level 0 regardless of time or activity\n2. **If person is unidentified:**\n - Check time: If late night/early morning (11 PM - 5 AM) AND in private areas (driveways, near vehicles/buildings) \u2192 Level 1\n - Check actions: If probing access (trying handles without entering, checking multiple vehicles), taking items, climbing \u2192 Level 1\n - Otherwise, if daytime/evening (6 AM - 10 PM) with clear legitimate purpose (delivery, service, routine vehicle access) \u2192 Level 0\n3. **Escalate to Level 2 if:** Weapons, break-in tools, forced entry in progress, violence, or active property damage visible (escalates from Level 0 or 1)\n\nThe mere presence of an unidentified person in private areas during late night hours is inherently suspicious and warrants human review, regardless of what activity they appear to be doing or how brief the sequence is."}}, "semantic_search": {"triggers": {}}, "snapshots": {"enabled": false, "timestamp": false, "bounding_box": true, "crop": false, "required_zones": [], "height": null, "retain": {"default": 10, "mode": "motion", "objects": {}}, "quality": 60}, "timestamp_style": {"position": "tl", "format": "%m/%d/%Y %H:%M:%S", "color": {"red": 255, "green": 255, "blue": 255}, "thickness": 2, "effect": null}, "best_image_timeout": 60, "mqtt": {"enabled": true, "timestamp": true, "bounding_box": true, "crop": true, "height": 270, "required_zones": [], "quality": 70}, "notifications": {"enabled": false, "email": null, "cooldown": 0, "enabled_in_config": false}, "onvif": {"host": "", "port": 8000, "user": null, "password": null, "tls_insecure": false, "profile": null, "autotracking": {"enabled": false, "calibrate_on_startup": false, "zooming": "disabled", "zoom_factor": 0.3, "track": ["person"], "required_zones": [], "return_preset": "home", "timeout": 10, "movement_weights": [], "enabled_in_config": false}, "ignore_time_mismatch": false}, "type": "generic", "ui": {"order": 0, "dashboard": true}, "webui_url": null, "profiles": {}, "zones": {}, "enabled_in_config": true}, "garage": {"name": "garage", "friendly_name": null, "enabled": true, "audio": {"enabled": false, "max_not_heard": 30, "min_volume": 500, "listen": ["bark", "fire_alarm", "scream", "speech", "yell"], "filters": {"a_capella": {"threshold": 0.8}, "accelerating": {"threshold": 0.8}, "accordion": {"threshold": 0.8}, "acoustic_guitar": {"threshold": 0.8}, "afrobeat": {"threshold": 0.8}, "air_brake": {"threshold": 0.8}, "air_conditioning": {"threshold": 0.8}, "air_horn": {"threshold": 0.8}, "aircraft": {"threshold": 0.8}, "aircraft_engine": {"threshold": 0.8}, "alarm": {"threshold": 0.8}, "alarm_clock": {"threshold": 0.8}, "ambient_music": {"threshold": 0.8}, "ambulance": {"threshold": 0.8}, "angry_music": {"threshold": 0.8}, "animal": {"threshold": 0.8}, "applause": {"threshold": 0.8}, "arrow": {"threshold": 0.8}, "artillery_fire": {"threshold": 0.8}, "babbling": {"threshold": 0.8}, "background_music": {"threshold": 0.8}, "bagpipes": {"threshold": 0.8}, "bang": {"threshold": 0.8}, "banjo": {"threshold": 0.8}, "bark": {"threshold": 0.8}, "basketball_bounce": {"threshold": 0.8}, "bass_drum": {"threshold": 0.8}, "bass_guitar": {"threshold": 0.8}, "bathtub": {"threshold": 0.8}, "beatboxing": {"threshold": 0.8}, "beep": {"threshold": 0.8}, "bell": {"threshold": 0.8}, "bellow": {"threshold": 0.8}, "bicycle": {"threshold": 0.8}, "bicycle_bell": {"threshold": 0.8}, "bird": {"threshold": 0.8}, "biting": {"threshold": 0.8}, "bleat": {"threshold": 0.8}, "blender": {"threshold": 0.8}, "bluegrass": {"threshold": 0.8}, "blues": {"threshold": 0.8}, "boat": {"threshold": 0.8}, "boiling": {"threshold": 0.8}, "boing": {"threshold": 0.8}, "boom": {"threshold": 0.8}, "bouncing": {"threshold": 0.8}, "bow-wow": {"threshold": 0.8}, "bowed_string_instrument": {"threshold": 0.8}, "brass_instrument": {"threshold": 0.8}, "breaking": {"threshold": 0.8}, "breathing": {"threshold": 0.8}, "burping": {"threshold": 0.8}, "burst": {"threshold": 0.8}, "bus": {"threshold": 0.8}, "busy_signal": {"threshold": 0.8}, "buzz": {"threshold": 0.8}, "buzzer": {"threshold": 0.8}, "cacophony": {"threshold": 0.8}, "camera": {"threshold": 0.8}, "cap_gun": {"threshold": 0.8}, "car": {"threshold": 0.8}, "car_alarm": {"threshold": 0.8}, "car_passing_by": {"threshold": 0.8}, "carnatic_music": {"threshold": 0.8}, "cash_register": {"threshold": 0.8}, "cat": {"threshold": 0.8}, "caterwaul": {"threshold": 0.8}, "cattle": {"threshold": 0.8}, "caw": {"threshold": 0.8}, "cello": {"threshold": 0.8}, "chainsaw": {"threshold": 0.8}, "change_ringing": {"threshold": 0.8}, "chant": {"threshold": 0.8}, "chatter": {"threshold": 0.8}, "cheering": {"threshold": 0.8}, "chewing": {"threshold": 0.8}, "chicken": {"threshold": 0.8}, "child_singing": {"threshold": 0.8}, "children_playing": {"threshold": 0.8}, "chime": {"threshold": 0.8}, "chink": {"threshold": 0.8}, "chird": {"threshold": 0.8}, "chirp": {"threshold": 0.8}, "chirp_tone": {"threshold": 0.8}, "choir": {"threshold": 0.8}, "chop": {"threshold": 0.8}, "chopping": {"threshold": 0.8}, "chorus_effect": {"threshold": 0.8}, "christian_music": {"threshold": 0.8}, "christmas_music": {"threshold": 0.8}, "church_bell": {"threshold": 0.8}, "civil_defense_siren": {"threshold": 0.8}, "clang": {"threshold": 0.8}, "clapping": {"threshold": 0.8}, "clarinet": {"threshold": 0.8}, "classical_music": {"threshold": 0.8}, "clatter": {"threshold": 0.8}, "clickety-clack": {"threshold": 0.8}, "clicking": {"threshold": 0.8}, "clip-clop": {"threshold": 0.8}, "clock": {"threshold": 0.8}, "cluck": {"threshold": 0.8}, "cock-a-doodle-doo": {"threshold": 0.8}, "coin": {"threshold": 0.8}, "computer_keyboard": {"threshold": 0.8}, "coo": {"threshold": 0.8}, "cough": {"threshold": 0.8}, "country": {"threshold": 0.8}, "cowbell": {"threshold": 0.8}, "crack": {"threshold": 0.8}, "crackle": {"threshold": 0.8}, "creak": {"threshold": 0.8}, "cricket": {"threshold": 0.8}, "croak": {"threshold": 0.8}, "crow": {"threshold": 0.8}, "crowd": {"threshold": 0.8}, "crumpling": {"threshold": 0.8}, "crunch": {"threshold": 0.8}, "crushing": {"threshold": 0.8}, "crying": {"threshold": 0.8}, "cupboard_open_or_close": {"threshold": 0.8}, "cutlery": {"threshold": 0.8}, "cymbal": {"threshold": 0.8}, "dance_music": {"threshold": 0.8}, "dental_drill's_drill": {"threshold": 0.8}, "dial_tone": {"threshold": 0.8}, "didgeridoo": {"threshold": 0.8}, "ding": {"threshold": 0.8}, "ding-dong": {"threshold": 0.8}, "disco": {"threshold": 0.8}, "dishes": {"threshold": 0.8}, "distortion": {"threshold": 0.8}, "dog": {"threshold": 0.8}, "dogs": {"threshold": 0.8}, "door": {"threshold": 0.8}, "doorbell": {"threshold": 0.8}, "double_bass": {"threshold": 0.8}, "drawer_open_or_close": {"threshold": 0.8}, "drill": {"threshold": 0.8}, "drip": {"threshold": 0.8}, "drum": {"threshold": 0.8}, "drum_and_bass": {"threshold": 0.8}, "drum_kit": {"threshold": 0.8}, "drum_machine": {"threshold": 0.8}, "drum_roll": {"threshold": 0.8}, "dubstep": {"threshold": 0.8}, "duck": {"threshold": 0.8}, "echo": {"threshold": 0.8}, "effects_unit": {"threshold": 0.8}, "electric_guitar": {"threshold": 0.8}, "electric_piano": {"threshold": 0.8}, "electric_shaver": {"threshold": 0.8}, "electric_toothbrush": {"threshold": 0.8}, "electronic_dance_music": {"threshold": 0.8}, "electronic_music": {"threshold": 0.8}, "electronic_organ": {"threshold": 0.8}, "electronic_tuner": {"threshold": 0.8}, "electronica": {"threshold": 0.8}, "emergency_vehicle": {"threshold": 0.8}, "engine": {"threshold": 0.8}, "engine_knocking": {"threshold": 0.8}, "engine_starting": {"threshold": 0.8}, "environmental_noise": {"threshold": 0.8}, "eruption": {"threshold": 0.8}, "exciting_music": {"threshold": 0.8}, "explosion": {"threshold": 0.8}, "fart": {"threshold": 0.8}, "field_recording": {"threshold": 0.8}, "filing": {"threshold": 0.8}, "fill": {"threshold": 0.8}, "finger_snapping": {"threshold": 0.8}, "fire": {"threshold": 0.8}, "fire_alarm": {"threshold": 0.8}, "fire_engine": {"threshold": 0.8}, "firecracker": {"threshold": 0.8}, "fireworks": {"threshold": 0.8}, "fixed-wing_aircraft": {"threshold": 0.8}, "flamenco": {"threshold": 0.8}, "flap": {"threshold": 0.8}, "flapping_wings": {"threshold": 0.8}, "flute": {"threshold": 0.8}, "fly": {"threshold": 0.8}, "foghorn": {"threshold": 0.8}, "folk_music": {"threshold": 0.8}, "footsteps": {"threshold": 0.8}, "fowl": {"threshold": 0.8}, "french_horn": {"threshold": 0.8}, "frog": {"threshold": 0.8}, "frying": {"threshold": 0.8}, "funk": {"threshold": 0.8}, "fusillade": {"threshold": 0.8}, "gargling": {"threshold": 0.8}, "gasp": {"threshold": 0.8}, "gears": {"threshold": 0.8}, "glass": {"threshold": 0.8}, "glockenspiel": {"threshold": 0.8}, "goat": {"threshold": 0.8}, "gobble": {"threshold": 0.8}, "gong": {"threshold": 0.8}, "goose": {"threshold": 0.8}, "gospel_music": {"threshold": 0.8}, "groan": {"threshold": 0.8}, "growling": {"threshold": 0.8}, "grunge": {"threshold": 0.8}, "grunt": {"threshold": 0.8}, "guitar": {"threshold": 0.8}, "gunshot": {"threshold": 0.8}, "gurgling": {"threshold": 0.8}, "gush": {"threshold": 0.8}, "hair_dryer": {"threshold": 0.8}, "hammer": {"threshold": 0.8}, "hammond_organ": {"threshold": 0.8}, "hands": {"threshold": 0.8}, "happy_music": {"threshold": 0.8}, "harmonic": {"threshold": 0.8}, "harmonica": {"threshold": 0.8}, "harp": {"threshold": 0.8}, "harpsichord": {"threshold": 0.8}, "heart_murmur": {"threshold": 0.8}, "heartbeat": {"threshold": 0.8}, "heavy_engine": {"threshold": 0.8}, "heavy_metal": {"threshold": 0.8}, "helicopter": {"threshold": 0.8}, "hi-hat": {"threshold": 0.8}, "hiccup": {"threshold": 0.8}, "hip_hop_music": {"threshold": 0.8}, "hiss": {"threshold": 0.8}, "honk": {"threshold": 0.8}, "hoot": {"threshold": 0.8}, "horse": {"threshold": 0.8}, "house_music": {"threshold": 0.8}, "howl": {"threshold": 0.8}, "hum": {"threshold": 0.8}, "humming": {"threshold": 0.8}, "ice_cream_truck": {"threshold": 0.8}, "idling": {"threshold": 0.8}, "independent_music": {"threshold": 0.8}, "insect": {"threshold": 0.8}, "inside": {"threshold": 0.8}, "jackhammer": {"threshold": 0.8}, "jazz": {"threshold": 0.8}, "jet_engine": {"threshold": 0.8}, "jingle": {"threshold": 0.8}, "jingle_bell": {"threshold": 0.8}, "keyboard": {"threshold": 0.8}, "keys_jangling": {"threshold": 0.8}, "knock": {"threshold": 0.8}, "laughter": {"threshold": 0.8}, "lawn_mower": {"threshold": 0.8}, "light_engine": {"threshold": 0.8}, "liquid": {"threshold": 0.8}, "livestock": {"threshold": 0.8}, "lullaby": {"threshold": 0.8}, "machine_gun": {"threshold": 0.8}, "mains_hum": {"threshold": 0.8}, "mallet_percussion": {"threshold": 0.8}, "mandolin": {"threshold": 0.8}, "mantra": {"threshold": 0.8}, "maraca": {"threshold": 0.8}, "marimba": {"threshold": 0.8}, "mechanical_fan": {"threshold": 0.8}, "mechanisms": {"threshold": 0.8}, "medium_engine": {"threshold": 0.8}, "meow": {"threshold": 0.8}, "microwave_oven": {"threshold": 0.8}, "middle_eastern_music": {"threshold": 0.8}, "moo": {"threshold": 0.8}, "mosquito": {"threshold": 0.8}, "motor_vehicle": {"threshold": 0.8}, "motorboat": {"threshold": 0.8}, "motorcycle": {"threshold": 0.8}, "mouse": {"threshold": 0.8}, "music": {"threshold": 0.8}, "music_for_children": {"threshold": 0.8}, "music_of_africa": {"threshold": 0.8}, "music_of_asia": {"threshold": 0.8}, "music_of_bollywood": {"threshold": 0.8}, "music_of_latin_america": {"threshold": 0.8}, "musical_instrument": {"threshold": 0.8}, "neigh": {"threshold": 0.8}, "new-age_music": {"threshold": 0.8}, "noise": {"threshold": 0.8}, "ocean": {"threshold": 0.8}, "oink": {"threshold": 0.8}, "opera": {"threshold": 0.8}, "orchestra": {"threshold": 0.8}, "organ": {"threshold": 0.8}, "outside": {"threshold": 0.8}, "owl": {"threshold": 0.8}, "pant": {"threshold": 0.8}, "patter": {"threshold": 0.8}, "percussion": {"threshold": 0.8}, "pets": {"threshold": 0.8}, "piano": {"threshold": 0.8}, "pig": {"threshold": 0.8}, "pigeon": {"threshold": 0.8}, "ping": {"threshold": 0.8}, "pink_noise": {"threshold": 0.8}, "pizzicato": {"threshold": 0.8}, "plop": {"threshold": 0.8}, "plucked_string_instrument": {"threshold": 0.8}, "police_car": {"threshold": 0.8}, "pop_music": {"threshold": 0.8}, "pour": {"threshold": 0.8}, "power_tool": {"threshold": 0.8}, "power_windows": {"threshold": 0.8}, "printer": {"threshold": 0.8}, "progressive_rock": {"threshold": 0.8}, "propeller": {"threshold": 0.8}, "psychedelic_rock": {"threshold": 0.8}, "pulleys": {"threshold": 0.8}, "pulse": {"threshold": 0.8}, "pump": {"threshold": 0.8}, "punk_rock": {"threshold": 0.8}, "purr": {"threshold": 0.8}, "quack": {"threshold": 0.8}, "race_car": {"threshold": 0.8}, "radio": {"threshold": 0.8}, "rail_transport": {"threshold": 0.8}, "railroad_car": {"threshold": 0.8}, "rain": {"threshold": 0.8}, "rain_on_surface": {"threshold": 0.8}, "raindrop": {"threshold": 0.8}, "rapping": {"threshold": 0.8}, "ratchet": {"threshold": 0.8}, "rats": {"threshold": 0.8}, "rattle": {"threshold": 0.8}, "reggae": {"threshold": 0.8}, "reverberation": {"threshold": 0.8}, "reversing_beeps": {"threshold": 0.8}, "rhythm_and_blues": {"threshold": 0.8}, "rimshot": {"threshold": 0.8}, "ringtone": {"threshold": 0.8}, "roar": {"threshold": 0.8}, "roaring_cats": {"threshold": 0.8}, "rock_and_roll": {"threshold": 0.8}, "rock_music": {"threshold": 0.8}, "roll": {"threshold": 0.8}, "rowboat": {"threshold": 0.8}, "rub": {"threshold": 0.8}, "rumble": {"threshold": 0.8}, "run": {"threshold": 0.8}, "rustle": {"threshold": 0.8}, "rustling_leaves": {"threshold": 0.8}, "sad_music": {"threshold": 0.8}, "sailboat": {"threshold": 0.8}, "salsa_music": {"threshold": 0.8}, "sampler": {"threshold": 0.8}, "sanding": {"threshold": 0.8}, "sawing": {"threshold": 0.8}, "saxophone": {"threshold": 0.8}, "scary_music": {"threshold": 0.8}, "scissors": {"threshold": 0.8}, "scrape": {"threshold": 0.8}, "scratch": {"threshold": 0.8}, "scratching": {"threshold": 0.8}, "sewing_machine": {"threshold": 0.8}, "shatter": {"threshold": 0.8}, "sheep": {"threshold": 0.8}, "ship": {"threshold": 0.8}, "shofar": {"threshold": 0.8}, "shuffle": {"threshold": 0.8}, "shuffling_cards": {"threshold": 0.8}, "sidetone": {"threshold": 0.8}, "sigh": {"threshold": 0.8}, "silence": {"threshold": 0.8}, "sine_wave": {"threshold": 0.8}, "singing": {"threshold": 0.8}, "singing_bowl": {"threshold": 0.8}, "single-lens_reflex_camera": {"threshold": 0.8}, "sink": {"threshold": 0.8}, "siren": {"threshold": 0.8}, "sitar": {"threshold": 0.8}, "sizzle": {"threshold": 0.8}, "ska": {"threshold": 0.8}, "skateboard": {"threshold": 0.8}, "skidding": {"threshold": 0.8}, "slam": {"threshold": 0.8}, "slap": {"threshold": 0.8}, "sliding_door": {"threshold": 0.8}, "slosh": {"threshold": 0.8}, "smash": {"threshold": 0.8}, "smoke_detector": {"threshold": 0.8}, "snake": {"threshold": 0.8}, "snare_drum": {"threshold": 0.8}, "sneeze": {"threshold": 0.8}, "snicker": {"threshold": 0.8}, "sniff": {"threshold": 0.8}, "snoring": {"threshold": 0.8}, "snort": {"threshold": 0.8}, "sodeling": {"threshold": 0.8}, "sonar": {"threshold": 0.8}, "song": {"threshold": 0.8}, "soul_music": {"threshold": 0.8}, "sound_effect": {"threshold": 0.8}, "soundtrack_music": {"threshold": 0.8}, "speech": {"threshold": 0.8}, "splash": {"threshold": 0.8}, "splinter": {"threshold": 0.8}, "spray": {"threshold": 0.8}, "squawk": {"threshold": 0.8}, "squeak": {"threshold": 0.8}, "squeal": {"threshold": 0.8}, "squish": {"threshold": 0.8}, "static": {"threshold": 0.8}, "steam": {"threshold": 0.8}, "steam_whistle": {"threshold": 0.8}, "steel_guitar": {"threshold": 0.8}, "steelpan": {"threshold": 0.8}, "stir": {"threshold": 0.8}, "stomach_rumble": {"threshold": 0.8}, "stream": {"threshold": 0.8}, "string_section": {"threshold": 0.8}, "strum": {"threshold": 0.8}, "subway": {"threshold": 0.8}, "swing_music": {"threshold": 0.8}, "synthesizer": {"threshold": 0.8}, "synthetic_singing": {"threshold": 0.8}, "tabla": {"threshold": 0.8}, "tambourine": {"threshold": 0.8}, "tap": {"threshold": 0.8}, "tapping": {"threshold": 0.8}, "tearing": {"threshold": 0.8}, "techno": {"threshold": 0.8}, "telephone": {"threshold": 0.8}, "telephone_bell_ringing": {"threshold": 0.8}, "telephone_dialing": {"threshold": 0.8}, "television": {"threshold": 0.8}, "tender_music": {"threshold": 0.8}, "theme_music": {"threshold": 0.8}, "theremin": {"threshold": 0.8}, "throat_clearing": {"threshold": 0.8}, "throbbing": {"threshold": 0.8}, "thump": {"threshold": 0.8}, "thunder": {"threshold": 0.8}, "thunderstorm": {"threshold": 0.8}, "thunk": {"threshold": 0.8}, "tick": {"threshold": 0.8}, "tick-tock": {"threshold": 0.8}, "timpani": {"threshold": 0.8}, "tire_squeal": {"threshold": 0.8}, "toilet_flush": {"threshold": 0.8}, "tools": {"threshold": 0.8}, "toot": {"threshold": 0.8}, "toothbrush": {"threshold": 0.8}, "traditional_music": {"threshold": 0.8}, "traffic_noise": {"threshold": 0.8}, "train": {"threshold": 0.8}, "train_horn": {"threshold": 0.8}, "train_wheels_squealing": {"threshold": 0.8}, "train_whistle": {"threshold": 0.8}, "trance_music": {"threshold": 0.8}, "trickle": {"threshold": 0.8}, "trombone": {"threshold": 0.8}, "truck": {"threshold": 0.8}, "trumpet": {"threshold": 0.8}, "tubular_bells": {"threshold": 0.8}, "tuning_fork": {"threshold": 0.8}, "turkey": {"threshold": 0.8}, "typewriter": {"threshold": 0.8}, "typing": {"threshold": 0.8}, "ukulele": {"threshold": 0.8}, "vacuum_cleaner": {"threshold": 0.8}, "vehicle": {"threshold": 0.8}, "vibraphone": {"threshold": 0.8}, "vibration": {"threshold": 0.8}, "video_game_music": {"threshold": 0.8}, "violin": {"threshold": 0.8}, "vocal_music": {"threshold": 0.8}, "water": {"threshold": 0.8}, "water_tap": {"threshold": 0.8}, "waterfall": {"threshold": 0.8}, "waves": {"threshold": 0.8}, "wedding_music": {"threshold": 0.8}, "whack": {"threshold": 0.8}, "whale_vocalization": {"threshold": 0.8}, "wheeze": {"threshold": 0.8}, "whimper_dog": {"threshold": 0.8}, "whip": {"threshold": 0.8}, "whir": {"threshold": 0.8}, "whispering": {"threshold": 0.8}, "whistle": {"threshold": 0.8}, "whistling": {"threshold": 0.8}, "white_noise": {"threshold": 0.8}, "whoop": {"threshold": 0.8}, "whoosh": {"threshold": 0.8}, "wild_animals": {"threshold": 0.8}, "wind": {"threshold": 0.8}, "wind_chime": {"threshold": 0.8}, "wind_instrument": {"threshold": 0.8}, "wind_noise": {"threshold": 0.8}, "wood": {"threshold": 0.8}, "wood_block": {"threshold": 0.8}, "writing": {"threshold": 0.8}, "yell": {"threshold": 0.8}, "yip": {"threshold": 0.8}, "zing": {"threshold": 0.8}, "zipper": {"threshold": 0.8}, "zither": {"threshold": 0.8}}, "enabled_in_config": false, "num_threads": 2}, "audio_transcription": {"enabled": false, "enabled_in_config": false, "live_enabled": false}, "birdseye": {"enabled": true, "mode": "objects", "order": 0}, "detect": {"enabled": false, "height": 720, "width": 1280, "fps": 5, "min_initialized": 2, "max_disappeared": 25, "stationary": {"interval": 50, "threshold": 50, "max_frames": {"default": null, "objects": {}}, "classifier": true}, "annotation_offset": 0}, "face_recognition": {"enabled": false, "min_area": 750}, "ffmpeg": {"path": "default", "global_args": ["-hide_banner", "-loglevel", "warning", "-threads", "2"], "hwaccel_args": "preset-vaapi", "input_args": "preset-rtsp-generic", "output_args": {"detect": ["-threads", "2", "-f", "rawvideo", "-pix_fmt", "yuv420p"], "record": "preset-record-generic-audio-aac"}, "retry_interval": 10.0, "apple_compatibility": false, "gpu": 0, "inputs": [{"path": "rtsp://10.0.0.3:554/video", "roles": ["record", "detect"], "global_args": [], "hwaccel_args": [], "input_args": []}]}, "live": {"streams": {"garage": "garage"}, "height": 720, "quality": 8}, "lpr": {"enabled": false, "expire_time": 3, "min_area": 1000, "enhancement": 0}, "motion": {"enabled": true, "threshold": 30, "lightning_threshold": 0.8, "skip_motion_threshold": null, "improve_contrast": true, "contour_area": 10, "delta_alpha": 0.2, "frame_alpha": 0.01, "frame_height": 100, "mask": {}, "mqtt_off_delay": 30, "enabled_in_config": null}, "objects": {"track": ["person"], "filters": {"person": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.5, "mask": {}}}, "mask": {}, "genai": {"enabled": false, "use_snapshot": false, "prompt": "Analyze the sequence of images containing the {label}. Focus on the likely intent or behavior of the {label} based on its actions and movement, rather than describing its appearance or the surroundings. Consider what the {label} is doing, why, and what it might do next.", "object_prompts": {}, "objects": [], "required_zones": [], "debug_save_thumbnails": false, "send_triggers": {"tracked_object_end": true, "after_significant_updates": null}, "enabled_in_config": false}}, "record": {"enabled": false, "expire_interval": 60, "continuous": {"days": 0}, "motion": {"days": 0}, "detections": {"pre_capture": 5, "post_capture": 5, "retain": {"days": 10, "mode": "motion"}}, "alerts": {"pre_capture": 5, "post_capture": 5, "retain": {"days": 10, "mode": "motion"}}, "export": {"hwaccel_args": "preset-vaapi"}, "preview": {"quality": "medium"}, "enabled_in_config": false}, "review": {"alerts": {"enabled": true, "labels": ["person", "car"], "required_zones": [], "enabled_in_config": true, "cutoff_time": 40}, "detections": {"enabled": true, "labels": null, "required_zones": [], "cutoff_time": 30, "enabled_in_config": true}, "genai": {"enabled": false, "alerts": true, "detections": false, "image_source": "preview", "additional_concerns": [], "debug_save_thumbnails": false, "enabled_in_config": false, "preferred_language": null, "activity_context_prompt": "### Normal Activity Indicators (Level 0)\n- Known/verified people in any zone at any time\n- People with pets in residential areas\n- Routine residential vehicle access during daytime/evening (6 AM - 10 PM): entering, exiting, loading/unloading items \u2014 normal commute and travel patterns\n- Deliveries or services during daytime/evening (6 AM - 10 PM): carrying packages to doors/porches, placing items, leaving\n- Services/maintenance workers with visible tools, uniforms, or service vehicles during daytime\n- Activity confined to public areas only (sidewalks, streets) without entering property at any time\n\n### Suspicious Activity Indicators (Level 1)\n- **Checking or probing vehicle/building access**: trying handles without entering, peering through windows, examining multiple vehicles, or possessing break-in tools \u2014 Level 1\n- **Unidentified person in private areas (driveways, near vehicles/buildings) during late night/early morning (11 PM - 5 AM)** \u2014 ALWAYS Level 1 regardless of activity or duration\n- Taking items that don't belong to them (packages, objects from porches/driveways)\n- Climbing or jumping fences/barriers to access property\n- Attempting to conceal actions or items from view\n- Prolonged loitering: remaining in same area without visible purpose throughout most of the sequence\n\n### Critical Threat Indicators (Level 2)\n- Holding break-in tools (crowbars, pry bars, bolt cutters)\n- Weapons visible (guns, knives, bats used aggressively)\n- Forced entry in progress\n- Physical aggression or violence\n- Active property damage or theft in progress\n\n### Assessment Guidance\nEvaluate in this order:\n\n1. **If person is verified/known** \u2192 Level 0 regardless of time or activity\n2. **If person is unidentified:**\n - Check time: If late night/early morning (11 PM - 5 AM) AND in private areas (driveways, near vehicles/buildings) \u2192 Level 1\n - Check actions: If probing access (trying handles without entering, checking multiple vehicles), taking items, climbing \u2192 Level 1\n - Otherwise, if daytime/evening (6 AM - 10 PM) with clear legitimate purpose (delivery, service, routine vehicle access) \u2192 Level 0\n3. **Escalate to Level 2 if:** Weapons, break-in tools, forced entry in progress, violence, or active property damage visible (escalates from Level 0 or 1)\n\nThe mere presence of an unidentified person in private areas during late night hours is inherently suspicious and warrants human review, regardless of what activity they appear to be doing or how brief the sequence is."}}, "semantic_search": {"triggers": {}}, "snapshots": {"enabled": false, "timestamp": false, "bounding_box": true, "crop": false, "required_zones": [], "height": null, "retain": {"default": 10, "mode": "motion", "objects": {}}, "quality": 60}, "timestamp_style": {"position": "tl", "format": "%m/%d/%Y %H:%M:%S", "color": {"red": 255, "green": 255, "blue": 255}, "thickness": 2, "effect": null}, "best_image_timeout": 60, "mqtt": {"enabled": true, "timestamp": true, "bounding_box": true, "crop": true, "height": 270, "required_zones": [], "quality": 70}, "notifications": {"enabled": false, "email": null, "cooldown": 0, "enabled_in_config": false}, "onvif": {"host": "", "port": 8000, "user": null, "password": null, "tls_insecure": false, "profile": null, "autotracking": {"enabled": false, "calibrate_on_startup": false, "zooming": "disabled", "zoom_factor": 0.3, "track": ["person"], "required_zones": [], "return_preset": "home", "timeout": 10, "movement_weights": [], "enabled_in_config": false}, "ignore_time_mismatch": false}, "type": "generic", "ui": {"order": 0, "dashboard": true}, "webui_url": null, "profiles": {}, "zones": {}, "enabled_in_config": true}}, "audio": {"enabled": false, "max_not_heard": 30, "min_volume": 500, "listen": ["bark", "fire_alarm", "scream", "speech", "yell"], "filters": {"a_capella": {"threshold": 0.8}, "accelerating": {"threshold": 0.8}, "accordion": {"threshold": 0.8}, "acoustic_guitar": {"threshold": 0.8}, "afrobeat": {"threshold": 0.8}, "air_brake": {"threshold": 0.8}, "air_conditioning": {"threshold": 0.8}, "air_horn": {"threshold": 0.8}, "aircraft": {"threshold": 0.8}, "aircraft_engine": {"threshold": 0.8}, "alarm": {"threshold": 0.8}, "alarm_clock": {"threshold": 0.8}, "ambient_music": {"threshold": 0.8}, "ambulance": {"threshold": 0.8}, "angry_music": {"threshold": 0.8}, "animal": {"threshold": 0.8}, "applause": {"threshold": 0.8}, "arrow": {"threshold": 0.8}, "artillery_fire": {"threshold": 0.8}, "babbling": {"threshold": 0.8}, "background_music": {"threshold": 0.8}, "bagpipes": {"threshold": 0.8}, "bang": {"threshold": 0.8}, "banjo": {"threshold": 0.8}, "bark": {"threshold": 0.8}, "basketball_bounce": {"threshold": 0.8}, "bass_drum": {"threshold": 0.8}, "bass_guitar": {"threshold": 0.8}, "bathtub": {"threshold": 0.8}, "beatboxing": {"threshold": 0.8}, "beep": {"threshold": 0.8}, "bell": {"threshold": 0.8}, "bellow": {"threshold": 0.8}, "bicycle": {"threshold": 0.8}, "bicycle_bell": {"threshold": 0.8}, "bird": {"threshold": 0.8}, "biting": {"threshold": 0.8}, "bleat": {"threshold": 0.8}, "blender": {"threshold": 0.8}, "bluegrass": {"threshold": 0.8}, "blues": {"threshold": 0.8}, "boat": {"threshold": 0.8}, "boiling": {"threshold": 0.8}, "boing": {"threshold": 0.8}, "boom": {"threshold": 0.8}, "bouncing": {"threshold": 0.8}, "bow-wow": {"threshold": 0.8}, "bowed_string_instrument": {"threshold": 0.8}, "brass_instrument": {"threshold": 0.8}, "breaking": {"threshold": 0.8}, "breathing": {"threshold": 0.8}, "burping": {"threshold": 0.8}, "burst": {"threshold": 0.8}, "bus": {"threshold": 0.8}, "busy_signal": {"threshold": 0.8}, "buzz": {"threshold": 0.8}, "buzzer": {"threshold": 0.8}, "cacophony": {"threshold": 0.8}, "camera": {"threshold": 0.8}, "cap_gun": {"threshold": 0.8}, "car": {"threshold": 0.8}, "car_alarm": {"threshold": 0.8}, "car_passing_by": {"threshold": 0.8}, "carnatic_music": {"threshold": 0.8}, "cash_register": {"threshold": 0.8}, "cat": {"threshold": 0.8}, "caterwaul": {"threshold": 0.8}, "cattle": {"threshold": 0.8}, "caw": {"threshold": 0.8}, "cello": {"threshold": 0.8}, "chainsaw": {"threshold": 0.8}, "change_ringing": {"threshold": 0.8}, "chant": {"threshold": 0.8}, "chatter": {"threshold": 0.8}, "cheering": {"threshold": 0.8}, "chewing": {"threshold": 0.8}, "chicken": {"threshold": 0.8}, "child_singing": {"threshold": 0.8}, "children_playing": {"threshold": 0.8}, "chime": {"threshold": 0.8}, "chink": {"threshold": 0.8}, "chird": {"threshold": 0.8}, "chirp": {"threshold": 0.8}, "chirp_tone": {"threshold": 0.8}, "choir": {"threshold": 0.8}, "chop": {"threshold": 0.8}, "chopping": {"threshold": 0.8}, "chorus_effect": {"threshold": 0.8}, "christian_music": {"threshold": 0.8}, "christmas_music": {"threshold": 0.8}, "church_bell": {"threshold": 0.8}, "civil_defense_siren": {"threshold": 0.8}, "clang": {"threshold": 0.8}, "clapping": {"threshold": 0.8}, "clarinet": {"threshold": 0.8}, "classical_music": {"threshold": 0.8}, "clatter": {"threshold": 0.8}, "clickety-clack": {"threshold": 0.8}, "clicking": {"threshold": 0.8}, "clip-clop": {"threshold": 0.8}, "clock": {"threshold": 0.8}, "cluck": {"threshold": 0.8}, "cock-a-doodle-doo": {"threshold": 0.8}, "coin": {"threshold": 0.8}, "computer_keyboard": {"threshold": 0.8}, "coo": {"threshold": 0.8}, "cough": {"threshold": 0.8}, "country": {"threshold": 0.8}, "cowbell": {"threshold": 0.8}, "crack": {"threshold": 0.8}, "crackle": {"threshold": 0.8}, "creak": {"threshold": 0.8}, "cricket": {"threshold": 0.8}, "croak": {"threshold": 0.8}, "crow": {"threshold": 0.8}, "crowd": {"threshold": 0.8}, "crumpling": {"threshold": 0.8}, "crunch": {"threshold": 0.8}, "crushing": {"threshold": 0.8}, "crying": {"threshold": 0.8}, "cupboard_open_or_close": {"threshold": 0.8}, "cutlery": {"threshold": 0.8}, "cymbal": {"threshold": 0.8}, "dance_music": {"threshold": 0.8}, "dental_drill's_drill": {"threshold": 0.8}, "dial_tone": {"threshold": 0.8}, "didgeridoo": {"threshold": 0.8}, "ding": {"threshold": 0.8}, "ding-dong": {"threshold": 0.8}, "disco": {"threshold": 0.8}, "dishes": {"threshold": 0.8}, "distortion": {"threshold": 0.8}, "dog": {"threshold": 0.8}, "dogs": {"threshold": 0.8}, "door": {"threshold": 0.8}, "doorbell": {"threshold": 0.8}, "double_bass": {"threshold": 0.8}, "drawer_open_or_close": {"threshold": 0.8}, "drill": {"threshold": 0.8}, "drip": {"threshold": 0.8}, "drum": {"threshold": 0.8}, "drum_and_bass": {"threshold": 0.8}, "drum_kit": {"threshold": 0.8}, "drum_machine": {"threshold": 0.8}, "drum_roll": {"threshold": 0.8}, "dubstep": {"threshold": 0.8}, "duck": {"threshold": 0.8}, "echo": {"threshold": 0.8}, "effects_unit": {"threshold": 0.8}, "electric_guitar": {"threshold": 0.8}, "electric_piano": {"threshold": 0.8}, "electric_shaver": {"threshold": 0.8}, "electric_toothbrush": {"threshold": 0.8}, "electronic_dance_music": {"threshold": 0.8}, "electronic_music": {"threshold": 0.8}, "electronic_organ": {"threshold": 0.8}, "electronic_tuner": {"threshold": 0.8}, "electronica": {"threshold": 0.8}, "emergency_vehicle": {"threshold": 0.8}, "engine": {"threshold": 0.8}, "engine_knocking": {"threshold": 0.8}, "engine_starting": {"threshold": 0.8}, "environmental_noise": {"threshold": 0.8}, "eruption": {"threshold": 0.8}, "exciting_music": {"threshold": 0.8}, "explosion": {"threshold": 0.8}, "fart": {"threshold": 0.8}, "field_recording": {"threshold": 0.8}, "filing": {"threshold": 0.8}, "fill": {"threshold": 0.8}, "finger_snapping": {"threshold": 0.8}, "fire": {"threshold": 0.8}, "fire_alarm": {"threshold": 0.8}, "fire_engine": {"threshold": 0.8}, "firecracker": {"threshold": 0.8}, "fireworks": {"threshold": 0.8}, "fixed-wing_aircraft": {"threshold": 0.8}, "flamenco": {"threshold": 0.8}, "flap": {"threshold": 0.8}, "flapping_wings": {"threshold": 0.8}, "flute": {"threshold": 0.8}, "fly": {"threshold": 0.8}, "foghorn": {"threshold": 0.8}, "folk_music": {"threshold": 0.8}, "footsteps": {"threshold": 0.8}, "fowl": {"threshold": 0.8}, "french_horn": {"threshold": 0.8}, "frog": {"threshold": 0.8}, "frying": {"threshold": 0.8}, "funk": {"threshold": 0.8}, "fusillade": {"threshold": 0.8}, "gargling": {"threshold": 0.8}, "gasp": {"threshold": 0.8}, "gears": {"threshold": 0.8}, "glass": {"threshold": 0.8}, "glockenspiel": {"threshold": 0.8}, "goat": {"threshold": 0.8}, "gobble": {"threshold": 0.8}, "gong": {"threshold": 0.8}, "goose": {"threshold": 0.8}, "gospel_music": {"threshold": 0.8}, "groan": {"threshold": 0.8}, "growling": {"threshold": 0.8}, "grunge": {"threshold": 0.8}, "grunt": {"threshold": 0.8}, "guitar": {"threshold": 0.8}, "gunshot": {"threshold": 0.8}, "gurgling": {"threshold": 0.8}, "gush": {"threshold": 0.8}, "hair_dryer": {"threshold": 0.8}, "hammer": {"threshold": 0.8}, "hammond_organ": {"threshold": 0.8}, "hands": {"threshold": 0.8}, "happy_music": {"threshold": 0.8}, "harmonic": {"threshold": 0.8}, "harmonica": {"threshold": 0.8}, "harp": {"threshold": 0.8}, "harpsichord": {"threshold": 0.8}, "heart_murmur": {"threshold": 0.8}, "heartbeat": {"threshold": 0.8}, "heavy_engine": {"threshold": 0.8}, "heavy_metal": {"threshold": 0.8}, "helicopter": {"threshold": 0.8}, "hi-hat": {"threshold": 0.8}, "hiccup": {"threshold": 0.8}, "hip_hop_music": {"threshold": 0.8}, "hiss": {"threshold": 0.8}, "honk": {"threshold": 0.8}, "hoot": {"threshold": 0.8}, "horse": {"threshold": 0.8}, "house_music": {"threshold": 0.8}, "howl": {"threshold": 0.8}, "hum": {"threshold": 0.8}, "humming": {"threshold": 0.8}, "ice_cream_truck": {"threshold": 0.8}, "idling": {"threshold": 0.8}, "independent_music": {"threshold": 0.8}, "insect": {"threshold": 0.8}, "inside": {"threshold": 0.8}, "jackhammer": {"threshold": 0.8}, "jazz": {"threshold": 0.8}, "jet_engine": {"threshold": 0.8}, "jingle": {"threshold": 0.8}, "jingle_bell": {"threshold": 0.8}, "keyboard": {"threshold": 0.8}, "keys_jangling": {"threshold": 0.8}, "knock": {"threshold": 0.8}, "laughter": {"threshold": 0.8}, "lawn_mower": {"threshold": 0.8}, "light_engine": {"threshold": 0.8}, "liquid": {"threshold": 0.8}, "livestock": {"threshold": 0.8}, "lullaby": {"threshold": 0.8}, "machine_gun": {"threshold": 0.8}, "mains_hum": {"threshold": 0.8}, "mallet_percussion": {"threshold": 0.8}, "mandolin": {"threshold": 0.8}, "mantra": {"threshold": 0.8}, "maraca": {"threshold": 0.8}, "marimba": {"threshold": 0.8}, "mechanical_fan": {"threshold": 0.8}, "mechanisms": {"threshold": 0.8}, "medium_engine": {"threshold": 0.8}, "meow": {"threshold": 0.8}, "microwave_oven": {"threshold": 0.8}, "middle_eastern_music": {"threshold": 0.8}, "moo": {"threshold": 0.8}, "mosquito": {"threshold": 0.8}, "motor_vehicle": {"threshold": 0.8}, "motorboat": {"threshold": 0.8}, "motorcycle": {"threshold": 0.8}, "mouse": {"threshold": 0.8}, "music": {"threshold": 0.8}, "music_for_children": {"threshold": 0.8}, "music_of_africa": {"threshold": 0.8}, "music_of_asia": {"threshold": 0.8}, "music_of_bollywood": {"threshold": 0.8}, "music_of_latin_america": {"threshold": 0.8}, "musical_instrument": {"threshold": 0.8}, "neigh": {"threshold": 0.8}, "new-age_music": {"threshold": 0.8}, "noise": {"threshold": 0.8}, "ocean": {"threshold": 0.8}, "oink": {"threshold": 0.8}, "opera": {"threshold": 0.8}, "orchestra": {"threshold": 0.8}, "organ": {"threshold": 0.8}, "outside": {"threshold": 0.8}, "owl": {"threshold": 0.8}, "pant": {"threshold": 0.8}, "patter": {"threshold": 0.8}, "percussion": {"threshold": 0.8}, "pets": {"threshold": 0.8}, "piano": {"threshold": 0.8}, "pig": {"threshold": 0.8}, "pigeon": {"threshold": 0.8}, "ping": {"threshold": 0.8}, "pink_noise": {"threshold": 0.8}, "pizzicato": {"threshold": 0.8}, "plop": {"threshold": 0.8}, "plucked_string_instrument": {"threshold": 0.8}, "police_car": {"threshold": 0.8}, "pop_music": {"threshold": 0.8}, "pour": {"threshold": 0.8}, "power_tool": {"threshold": 0.8}, "power_windows": {"threshold": 0.8}, "printer": {"threshold": 0.8}, "progressive_rock": {"threshold": 0.8}, "propeller": {"threshold": 0.8}, "psychedelic_rock": {"threshold": 0.8}, "pulleys": {"threshold": 0.8}, "pulse": {"threshold": 0.8}, "pump": {"threshold": 0.8}, "punk_rock": {"threshold": 0.8}, "purr": {"threshold": 0.8}, "quack": {"threshold": 0.8}, "race_car": {"threshold": 0.8}, "radio": {"threshold": 0.8}, "rail_transport": {"threshold": 0.8}, "railroad_car": {"threshold": 0.8}, "rain": {"threshold": 0.8}, "rain_on_surface": {"threshold": 0.8}, "raindrop": {"threshold": 0.8}, "rapping": {"threshold": 0.8}, "ratchet": {"threshold": 0.8}, "rats": {"threshold": 0.8}, "rattle": {"threshold": 0.8}, "reggae": {"threshold": 0.8}, "reverberation": {"threshold": 0.8}, "reversing_beeps": {"threshold": 0.8}, "rhythm_and_blues": {"threshold": 0.8}, "rimshot": {"threshold": 0.8}, "ringtone": {"threshold": 0.8}, "roar": {"threshold": 0.8}, "roaring_cats": {"threshold": 0.8}, "rock_and_roll": {"threshold": 0.8}, "rock_music": {"threshold": 0.8}, "roll": {"threshold": 0.8}, "rowboat": {"threshold": 0.8}, "rub": {"threshold": 0.8}, "rumble": {"threshold": 0.8}, "run": {"threshold": 0.8}, "rustle": {"threshold": 0.8}, "rustling_leaves": {"threshold": 0.8}, "sad_music": {"threshold": 0.8}, "sailboat": {"threshold": 0.8}, "salsa_music": {"threshold": 0.8}, "sampler": {"threshold": 0.8}, "sanding": {"threshold": 0.8}, "sawing": {"threshold": 0.8}, "saxophone": {"threshold": 0.8}, "scary_music": {"threshold": 0.8}, "scissors": {"threshold": 0.8}, "scrape": {"threshold": 0.8}, "scratch": {"threshold": 0.8}, "scratching": {"threshold": 0.8}, "sewing_machine": {"threshold": 0.8}, "shatter": {"threshold": 0.8}, "sheep": {"threshold": 0.8}, "ship": {"threshold": 0.8}, "shofar": {"threshold": 0.8}, "shuffle": {"threshold": 0.8}, "shuffling_cards": {"threshold": 0.8}, "sidetone": {"threshold": 0.8}, "sigh": {"threshold": 0.8}, "silence": {"threshold": 0.8}, "sine_wave": {"threshold": 0.8}, "singing": {"threshold": 0.8}, "singing_bowl": {"threshold": 0.8}, "single-lens_reflex_camera": {"threshold": 0.8}, "sink": {"threshold": 0.8}, "siren": {"threshold": 0.8}, "sitar": {"threshold": 0.8}, "sizzle": {"threshold": 0.8}, "ska": {"threshold": 0.8}, "skateboard": {"threshold": 0.8}, "skidding": {"threshold": 0.8}, "slam": {"threshold": 0.8}, "slap": {"threshold": 0.8}, "sliding_door": {"threshold": 0.8}, "slosh": {"threshold": 0.8}, "smash": {"threshold": 0.8}, "smoke_detector": {"threshold": 0.8}, "snake": {"threshold": 0.8}, "snare_drum": {"threshold": 0.8}, "sneeze": {"threshold": 0.8}, "snicker": {"threshold": 0.8}, "sniff": {"threshold": 0.8}, "snoring": {"threshold": 0.8}, "snort": {"threshold": 0.8}, "sodeling": {"threshold": 0.8}, "sonar": {"threshold": 0.8}, "song": {"threshold": 0.8}, "soul_music": {"threshold": 0.8}, "sound_effect": {"threshold": 0.8}, "soundtrack_music": {"threshold": 0.8}, "speech": {"threshold": 0.8}, "splash": {"threshold": 0.8}, "splinter": {"threshold": 0.8}, "spray": {"threshold": 0.8}, "squawk": {"threshold": 0.8}, "squeak": {"threshold": 0.8}, "squeal": {"threshold": 0.8}, "squish": {"threshold": 0.8}, "static": {"threshold": 0.8}, "steam": {"threshold": 0.8}, "steam_whistle": {"threshold": 0.8}, "steel_guitar": {"threshold": 0.8}, "steelpan": {"threshold": 0.8}, "stir": {"threshold": 0.8}, "stomach_rumble": {"threshold": 0.8}, "stream": {"threshold": 0.8}, "string_section": {"threshold": 0.8}, "strum": {"threshold": 0.8}, "subway": {"threshold": 0.8}, "swing_music": {"threshold": 0.8}, "synthesizer": {"threshold": 0.8}, "synthetic_singing": {"threshold": 0.8}, "tabla": {"threshold": 0.8}, "tambourine": {"threshold": 0.8}, "tap": {"threshold": 0.8}, "tapping": {"threshold": 0.8}, "tearing": {"threshold": 0.8}, "techno": {"threshold": 0.8}, "telephone": {"threshold": 0.8}, "telephone_bell_ringing": {"threshold": 0.8}, "telephone_dialing": {"threshold": 0.8}, "television": {"threshold": 0.8}, "tender_music": {"threshold": 0.8}, "theme_music": {"threshold": 0.8}, "theremin": {"threshold": 0.8}, "throat_clearing": {"threshold": 0.8}, "throbbing": {"threshold": 0.8}, "thump": {"threshold": 0.8}, "thunder": {"threshold": 0.8}, "thunderstorm": {"threshold": 0.8}, "thunk": {"threshold": 0.8}, "tick": {"threshold": 0.8}, "tick-tock": {"threshold": 0.8}, "timpani": {"threshold": 0.8}, "tire_squeal": {"threshold": 0.8}, "toilet_flush": {"threshold": 0.8}, "tools": {"threshold": 0.8}, "toot": {"threshold": 0.8}, "toothbrush": {"threshold": 0.8}, "traditional_music": {"threshold": 0.8}, "traffic_noise": {"threshold": 0.8}, "train": {"threshold": 0.8}, "train_horn": {"threshold": 0.8}, "train_wheels_squealing": {"threshold": 0.8}, "train_whistle": {"threshold": 0.8}, "trance_music": {"threshold": 0.8}, "trickle": {"threshold": 0.8}, "trombone": {"threshold": 0.8}, "truck": {"threshold": 0.8}, "trumpet": {"threshold": 0.8}, "tubular_bells": {"threshold": 0.8}, "tuning_fork": {"threshold": 0.8}, "turkey": {"threshold": 0.8}, "typewriter": {"threshold": 0.8}, "typing": {"threshold": 0.8}, "ukulele": {"threshold": 0.8}, "vacuum_cleaner": {"threshold": 0.8}, "vehicle": {"threshold": 0.8}, "vibraphone": {"threshold": 0.8}, "vibration": {"threshold": 0.8}, "video_game_music": {"threshold": 0.8}, "violin": {"threshold": 0.8}, "vocal_music": {"threshold": 0.8}, "water": {"threshold": 0.8}, "water_tap": {"threshold": 0.8}, "waterfall": {"threshold": 0.8}, "waves": {"threshold": 0.8}, "wedding_music": {"threshold": 0.8}, "whack": {"threshold": 0.8}, "whale_vocalization": {"threshold": 0.8}, "wheeze": {"threshold": 0.8}, "whimper_dog": {"threshold": 0.8}, "whip": {"threshold": 0.8}, "whir": {"threshold": 0.8}, "whispering": {"threshold": 0.8}, "whistle": {"threshold": 0.8}, "whistling": {"threshold": 0.8}, "white_noise": {"threshold": 0.8}, "whoop": {"threshold": 0.8}, "whoosh": {"threshold": 0.8}, "wild_animals": {"threshold": 0.8}, "wind": {"threshold": 0.8}, "wind_chime": {"threshold": 0.8}, "wind_instrument": {"threshold": 0.8}, "wind_noise": {"threshold": 0.8}, "wood": {"threshold": 0.8}, "wood_block": {"threshold": 0.8}, "writing": {"threshold": 0.8}, "yell": {"threshold": 0.8}, "yip": {"threshold": 0.8}, "zing": {"threshold": 0.8}, "zipper": {"threshold": 0.8}, "zither": {"threshold": 0.8}}, "enabled_in_config": null, "num_threads": 2}, "birdseye": {"enabled": true, "mode": "objects", "restream": false, "width": 1280, "height": 720, "quality": 8, "inactivity_threshold": 30, "layout": {"scaling_factor": 2.0, "max_cameras": null}, "idle_heartbeat_fps": 0.0}, "detect": {"enabled": false, "height": null, "width": null, "fps": 5, "min_initialized": null, "max_disappeared": null, "stationary": {"interval": null, "threshold": null, "max_frames": {"default": null, "objects": {}}, "classifier": true}, "annotation_offset": 0}, "ffmpeg": {"path": "default", "global_args": ["-hide_banner", "-loglevel", "warning", "-threads", "2"], "hwaccel_args": "preset-vaapi", "input_args": "preset-rtsp-generic", "output_args": {"detect": ["-threads", "2", "-f", "rawvideo", "-pix_fmt", "yuv420p"], "record": "preset-record-generic-audio-aac"}, "retry_interval": 10.0, "apple_compatibility": false, "gpu": 0}, "live": {"streams": [], "height": 720, "quality": 8}, "motion": null, "objects": {"track": ["person"], "filters": {"amazon": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "dpd": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "an_post": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "royal_mail": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "ups": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "nzpost": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "license_plate": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "canada_post": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "postnord": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "postnl": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "face": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "dhl": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "fedex": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "usps": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "gls": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "purolator": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}}, "mask": {}, "genai": {"enabled": false, "use_snapshot": false, "prompt": "Analyze the sequence of images containing the {label}. Focus on the likely intent or behavior of the {label} based on its actions and movement, rather than describing its appearance or the surroundings. Consider what the {label} is doing, why, and what it might do next.", "object_prompts": {}, "objects": [], "required_zones": [], "debug_save_thumbnails": false, "send_triggers": {"tracked_object_end": true, "after_significant_updates": null}, "enabled_in_config": null}}, "record": {"enabled": false, "expire_interval": 60, "continuous": {"days": 0}, "motion": {"days": 0}, "detections": {"pre_capture": 5, "post_capture": 5, "retain": {"days": 10, "mode": "motion"}}, "alerts": {"pre_capture": 5, "post_capture": 5, "retain": {"days": 10, "mode": "motion"}}, "export": {"hwaccel_args": "auto"}, "preview": {"quality": "medium"}, "enabled_in_config": null}, "review": {"alerts": {"enabled": true, "labels": ["person", "car"], "required_zones": [], "enabled_in_config": null, "cutoff_time": 40}, "detections": {"enabled": true, "labels": null, "required_zones": [], "cutoff_time": 30, "enabled_in_config": null}, "genai": {"enabled": false, "alerts": true, "detections": false, "image_source": "preview", "additional_concerns": [], "debug_save_thumbnails": false, "enabled_in_config": null, "preferred_language": null, "activity_context_prompt": "### Normal Activity Indicators (Level 0)\n- Known/verified people in any zone at any time\n- People with pets in residential areas\n- Routine residential vehicle access during daytime/evening (6 AM - 10 PM): entering, exiting, loading/unloading items \u2014 normal commute and travel patterns\n- Deliveries or services during daytime/evening (6 AM - 10 PM): carrying packages to doors/porches, placing items, leaving\n- Services/maintenance workers with visible tools, uniforms, or service vehicles during daytime\n- Activity confined to public areas only (sidewalks, streets) without entering property at any time\n\n### Suspicious Activity Indicators (Level 1)\n- **Checking or probing vehicle/building access**: trying handles without entering, peering through windows, examining multiple vehicles, or possessing break-in tools \u2014 Level 1\n- **Unidentified person in private areas (driveways, near vehicles/buildings) during late night/early morning (11 PM - 5 AM)** \u2014 ALWAYS Level 1 regardless of activity or duration\n- Taking items that don't belong to them (packages, objects from porches/driveways)\n- Climbing or jumping fences/barriers to access property\n- Attempting to conceal actions or items from view\n- Prolonged loitering: remaining in same area without visible purpose throughout most of the sequence\n\n### Critical Threat Indicators (Level 2)\n- Holding break-in tools (crowbars, pry bars, bolt cutters)\n- Weapons visible (guns, knives, bats used aggressively)\n- Forced entry in progress\n- Physical aggression or violence\n- Active property damage or theft in progress\n\n### Assessment Guidance\nEvaluate in this order:\n\n1. **If person is verified/known** \u2192 Level 0 regardless of time or activity\n2. **If person is unidentified:**\n - Check time: If late night/early morning (11 PM - 5 AM) AND in private areas (driveways, near vehicles/buildings) \u2192 Level 1\n - Check actions: If probing access (trying handles without entering, checking multiple vehicles), taking items, climbing \u2192 Level 1\n - Otherwise, if daytime/evening (6 AM - 10 PM) with clear legitimate purpose (delivery, service, routine vehicle access) \u2192 Level 0\n3. **Escalate to Level 2 if:** Weapons, break-in tools, forced entry in progress, violence, or active property damage visible (escalates from Level 0 or 1)\n\nThe mere presence of an unidentified person in private areas during late night hours is inherently suspicious and warrants human review, regardless of what activity they appear to be doing or how brief the sequence is."}}, "snapshots": {"enabled": false, "timestamp": false, "bounding_box": true, "crop": false, "required_zones": [], "height": null, "retain": {"default": 10, "mode": "motion", "objects": {}}, "quality": 60}, "timestamp_style": {"position": "tl", "format": "%m/%d/%Y %H:%M:%S", "color": {"red": 255, "green": 255, "blue": 255}, "thickness": 2, "effect": null}, "audio_transcription": {"enabled": false, "language": "en", "device": "CPU", "model_size": "small", "live_enabled": false}, "classification": {"bird": {"enabled": false, "threshold": 0.9}, "custom": {}}, "semantic_search": {"enabled": false, "reindex": false, "model": "jinav1", "model_size": "small", "device": null}, "face_recognition": {"enabled": false, "model_size": "small", "unknown_score": 0.8, "detection_threshold": 0.7, "recognition_threshold": 0.9, "min_area": 750, "min_faces": 1, "save_attempts": 200, "blur_confidence_filter": true, "device": null}, "lpr": {"enabled": false, "model_size": "small", "detection_threshold": 0.7, "min_area": 1000, "recognition_threshold": 0.9, "min_plate_length": 4, "format": null, "match_distance": 1, "known_plates": {}, "enhancement": 0, "debug_save_plates": false, "device": null, "replace_rules": []}, "camera_groups": {"default": {"cameras": ["front_door", "backyard", "garage"], "icon": "generic", "order": 0}, "outdoor": {"cameras": ["front_door", "backyard"], "icon": "generic", "order": 1}}, "profiles": {}} \ No newline at end of file +{"version": null, "safe_mode": false, "environment_vars": {}, "logger": {"default": "info", "logs": {}}, "auth": {"enabled": true, "reset_admin_password": false, "cookie_name": "frigate_token", "cookie_secure": false, "session_length": 86400, "refresh_time": 1800, "failed_login_rate_limit": null, "trusted_proxies": [], "hash_iterations": 600000, "roles": {"admin": [], "viewer": []}, "admin_first_time_login": false}, "database": {"path": "/config/frigate.db"}, "go2rtc": {}, "mqtt": {"enabled": true, "host": "mqtt", "port": 1883, "topic_prefix": "frigate", "client_id": "frigate", "stats_interval": 60, "user": null, "password": null, "tls_ca_certs": null, "tls_client_cert": null, "tls_client_key": null, "tls_insecure": null, "qos": 0}, "notifications": {"enabled": false, "email": null, "cooldown": 0, "enabled_in_config": false}, "networking": {"ipv6": {"enabled": false}, "listen": {"internal": 5000, "external": 8971}}, "proxy": {"header_map": {"user": null, "role": null, "role_map": {}}, "logout_url": null, "auth_secret": null, "default_role": "viewer", "separator": ","}, "telemetry": {"network_interfaces": [], "stats": {"amd_gpu_stats": true, "intel_gpu_stats": true, "network_bandwidth": false, "intel_gpu_device": null}, "version_check": true}, "tls": {"enabled": true}, "ui": {"timezone": null, "time_format": "browser", "unit_system": "metric"}, "detectors": {"cpu": {"type": "cpu", "model": {"path": "/cpu_model.tflite", "labelmap_path": null, "width": 320, "height": 320, "labelmap": {}, "attributes_map": {"person": ["amazon", "face"], "car": ["amazon", "an_post", "canada_post", "dhl", "dpd", "fedex", "gls", "license_plate", "nzpost", "postnl", "postnord", "purolator", "royal_mail", "ups", "usps"], "motorcycle": ["license_plate"]}, "input_tensor": "nhwc", "input_pixel_format": "rgb", "input_dtype": "int", "model_type": "ssd"}, "model_path": null}}, "model": {"path": null, "labelmap_path": null, "width": 320, "height": 320, "labelmap": {}, "attributes_map": {"person": ["amazon", "face"], "car": ["amazon", "an_post", "canada_post", "dhl", "dpd", "fedex", "gls", "license_plate", "nzpost", "postnl", "postnord", "purolator", "royal_mail", "ups", "usps"], "motorcycle": ["license_plate"]}, "input_tensor": "nhwc", "input_pixel_format": "rgb", "input_dtype": "int", "model_type": "ssd", "all_attributes": ["amazon", "an_post", "canada_post", "dhl", "dpd", "face", "fedex", "gls", "license_plate", "nzpost", "postnl", "postnord", "purolator", "royal_mail", "ups", "usps"], "colormap": {}}, "genai": {}, "cameras": {"front_door": {"name": "front_door", "friendly_name": null, "enabled": true, "audio": {"enabled": false, "max_not_heard": 30, "min_volume": 500, "listen": ["bark", "fire_alarm", "speech", "yell"], "filters": {"bark": {"threshold": 0.8}, "fire_alarm": {"threshold": 0.8}, "speech": {"threshold": 0.8}, "yell": {"threshold": 0.8}}, "enabled_in_config": false, "num_threads": 2}, "audio_transcription": {"enabled": false, "enabled_in_config": false, "live_enabled": false}, "birdseye": {"enabled": true, "mode": "objects", "order": 0}, "detect": {"enabled": false, "height": 720, "width": 1280, "fps": 5, "min_initialized": 2, "max_disappeared": 25, "stationary": {"interval": 50, "threshold": 50, "max_frames": {"default": null, "objects": {}}, "classifier": true}, "annotation_offset": 0}, "face_recognition": {"enabled": false, "min_area": 750}, "ffmpeg": {"path": "default", "global_args": ["-hide_banner", "-loglevel", "warning", "-threads", "2"], "hwaccel_args": "preset-vaapi", "input_args": "preset-rtsp-generic", "output_args": {"detect": ["-threads", "2", "-f", "rawvideo", "-pix_fmt", "yuv420p"], "record": "preset-record-generic-audio-aac"}, "retry_interval": 10.0, "apple_compatibility": false, "gpu": 0, "inputs": [{"path": "rtsp://10.0.0.1:554/video", "roles": ["record", "detect"], "global_args": [], "hwaccel_args": [], "input_args": []}]}, "live": {"streams": {"front_door": "front_door"}, "height": 720, "quality": 8}, "lpr": {"enabled": false, "expire_time": 3, "min_area": 1000, "enhancement": 0}, "motion": {"enabled": true, "threshold": 30, "lightning_threshold": 0.8, "skip_motion_threshold": null, "improve_contrast": true, "contour_area": 10, "delta_alpha": 0.2, "frame_alpha": 0.01, "frame_height": 100, "mask": {}, "mqtt_off_delay": 30, "enabled_in_config": null}, "objects": {"track": ["person"], "filters": {"person": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.5, "mask": {}}}, "mask": {}, "genai": {"enabled": false, "use_snapshot": false, "prompt": "Analyze the sequence of images containing the {label}. Focus on the likely intent or behavior of the {label} based on its actions and movement, rather than describing its appearance or the surroundings. Consider what the {label} is doing, why, and what it might do next.", "object_prompts": {}, "objects": [], "required_zones": [], "debug_save_thumbnails": false, "send_triggers": {"tracked_object_end": true, "after_significant_updates": null}, "enabled_in_config": false}}, "record": {"enabled": false, "expire_interval": 60, "continuous": {"days": 0}, "motion": {"days": 0}, "detections": {"pre_capture": 5, "post_capture": 5, "retain": {"days": 10, "mode": "motion"}}, "alerts": {"pre_capture": 5, "post_capture": 5, "retain": {"days": 10, "mode": "motion"}}, "export": {"hwaccel_args": "preset-vaapi", "max_concurrent": 3}, "preview": {"quality": "medium"}, "enabled_in_config": false}, "review": {"alerts": {"enabled": true, "labels": ["person", "car"], "required_zones": [], "enabled_in_config": true, "cutoff_time": 40}, "detections": {"enabled": true, "labels": null, "required_zones": [], "cutoff_time": 30, "enabled_in_config": true}, "genai": {"enabled": false, "alerts": true, "detections": false, "image_source": "preview", "additional_concerns": [], "debug_save_thumbnails": false, "enabled_in_config": false, "preferred_language": null, "activity_context_prompt": "### Normal Activity Indicators (Level 0)\n- Known/verified people in any zone at any time\n- People with pets in residential areas\n- Routine residential vehicle access during daytime/evening (6 AM - 10 PM): entering, exiting, loading/unloading items \u2014 normal commute and travel patterns\n- Deliveries or services during daytime/evening (6 AM - 10 PM): carrying packages to doors/porches, placing items, leaving\n- Services/maintenance workers with visible tools, uniforms, or service vehicles during daytime\n- Activity confined to public areas only (sidewalks, streets) without entering property at any time\n\n### Suspicious Activity Indicators (Level 1)\n- **Checking or probing vehicle/building access**: trying handles without entering, peering through windows, examining multiple vehicles, or possessing break-in tools \u2014 Level 1\n- **Unidentified person in private areas (driveways, near vehicles/buildings) during late night/early morning (11 PM - 5 AM)** \u2014 ALWAYS Level 1 regardless of activity or duration\n- Taking items that don't belong to them (packages, objects from porches/driveways)\n- Climbing or jumping fences/barriers to access property\n- Attempting to conceal actions or items from view\n- Prolonged loitering: remaining in same area without visible purpose throughout most of the sequence\n\n### Critical Threat Indicators (Level 2)\n- Holding break-in tools (crowbars, pry bars, bolt cutters)\n- Weapons visible (guns, knives, bats used aggressively)\n- Forced entry in progress\n- Physical aggression or violence\n- Active property damage or theft in progress\n\n### Assessment Guidance\nEvaluate in this order:\n\n1. **If person is verified/known** \u2192 Level 0 regardless of time or activity\n2. **If person is unidentified:**\n - Check time: If late night/early morning (11 PM - 5 AM) AND in private areas (driveways, near vehicles/buildings) \u2192 Level 1\n - Check actions: If probing access (trying handles without entering, checking multiple vehicles), taking items, climbing \u2192 Level 1\n - Otherwise, if daytime/evening (6 AM - 10 PM) with clear legitimate purpose (delivery, service, routine vehicle access) \u2192 Level 0\n3. **Escalate to Level 2 if:** Weapons, break-in tools, forced entry in progress, violence, or active property damage visible (escalates from Level 0 or 1)\n\nThe mere presence of an unidentified person in private areas during late night hours is inherently suspicious and warrants human review, regardless of what activity they appear to be doing or how brief the sequence is."}}, "semantic_search": {"triggers": {}}, "snapshots": {"enabled": false, "timestamp": false, "bounding_box": true, "crop": false, "required_zones": [], "height": null, "retain": {"default": 10, "mode": "motion", "objects": {}}, "quality": 60}, "timestamp_style": {"position": "tl", "format": "%m/%d/%Y %H:%M:%S", "color": {"red": 255, "green": 255, "blue": 255}, "thickness": 2, "effect": null}, "best_image_timeout": 60, "mqtt": {"enabled": true, "timestamp": true, "bounding_box": true, "crop": true, "height": 270, "required_zones": [], "quality": 70}, "notifications": {"enabled": false, "email": null, "cooldown": 0, "enabled_in_config": false}, "onvif": {"host": "", "port": 8000, "user": null, "password": null, "tls_insecure": false, "profile": null, "autotracking": {"enabled": false, "calibrate_on_startup": false, "zooming": "disabled", "zoom_factor": 0.3, "track": ["person"], "required_zones": [], "return_preset": "home", "timeout": 10, "movement_weights": [], "enabled_in_config": false}, "ignore_time_mismatch": false}, "type": "generic", "ui": {"order": 0, "dashboard": true, "review": true}, "webui_url": null, "profiles": {}, "zones": {}, "enabled_in_config": true}, "backyard": {"name": "backyard", "friendly_name": null, "enabled": true, "audio": {"enabled": false, "max_not_heard": 30, "min_volume": 500, "listen": ["bark", "fire_alarm", "speech", "yell"], "filters": {"bark": {"threshold": 0.8}, "fire_alarm": {"threshold": 0.8}, "speech": {"threshold": 0.8}, "yell": {"threshold": 0.8}}, "enabled_in_config": false, "num_threads": 2}, "audio_transcription": {"enabled": false, "enabled_in_config": false, "live_enabled": false}, "birdseye": {"enabled": true, "mode": "objects", "order": 0}, "detect": {"enabled": false, "height": 720, "width": 1280, "fps": 5, "min_initialized": 2, "max_disappeared": 25, "stationary": {"interval": 50, "threshold": 50, "max_frames": {"default": null, "objects": {}}, "classifier": true}, "annotation_offset": 0}, "face_recognition": {"enabled": false, "min_area": 750}, "ffmpeg": {"path": "default", "global_args": ["-hide_banner", "-loglevel", "warning", "-threads", "2"], "hwaccel_args": "preset-vaapi", "input_args": "preset-rtsp-generic", "output_args": {"detect": ["-threads", "2", "-f", "rawvideo", "-pix_fmt", "yuv420p"], "record": "preset-record-generic-audio-aac"}, "retry_interval": 10.0, "apple_compatibility": false, "gpu": 0, "inputs": [{"path": "rtsp://10.0.0.2:554/video", "roles": ["record", "detect"], "global_args": [], "hwaccel_args": [], "input_args": []}]}, "live": {"streams": {"backyard": "backyard"}, "height": 720, "quality": 8}, "lpr": {"enabled": false, "expire_time": 3, "min_area": 1000, "enhancement": 0}, "motion": {"enabled": true, "threshold": 30, "lightning_threshold": 0.8, "skip_motion_threshold": null, "improve_contrast": true, "contour_area": 10, "delta_alpha": 0.2, "frame_alpha": 0.01, "frame_height": 100, "mask": {}, "mqtt_off_delay": 30, "enabled_in_config": null}, "objects": {"track": ["person"], "filters": {"person": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.5, "mask": {}}}, "mask": {}, "genai": {"enabled": false, "use_snapshot": false, "prompt": "Analyze the sequence of images containing the {label}. Focus on the likely intent or behavior of the {label} based on its actions and movement, rather than describing its appearance or the surroundings. Consider what the {label} is doing, why, and what it might do next.", "object_prompts": {}, "objects": [], "required_zones": [], "debug_save_thumbnails": false, "send_triggers": {"tracked_object_end": true, "after_significant_updates": null}, "enabled_in_config": false}}, "record": {"enabled": false, "expire_interval": 60, "continuous": {"days": 0}, "motion": {"days": 0}, "detections": {"pre_capture": 5, "post_capture": 5, "retain": {"days": 10, "mode": "motion"}}, "alerts": {"pre_capture": 5, "post_capture": 5, "retain": {"days": 10, "mode": "motion"}}, "export": {"hwaccel_args": "preset-vaapi", "max_concurrent": 3}, "preview": {"quality": "medium"}, "enabled_in_config": false}, "review": {"alerts": {"enabled": true, "labels": ["person", "car"], "required_zones": [], "enabled_in_config": true, "cutoff_time": 40}, "detections": {"enabled": true, "labels": null, "required_zones": [], "cutoff_time": 30, "enabled_in_config": true}, "genai": {"enabled": false, "alerts": true, "detections": false, "image_source": "preview", "additional_concerns": [], "debug_save_thumbnails": false, "enabled_in_config": false, "preferred_language": null, "activity_context_prompt": "### Normal Activity Indicators (Level 0)\n- Known/verified people in any zone at any time\n- People with pets in residential areas\n- Routine residential vehicle access during daytime/evening (6 AM - 10 PM): entering, exiting, loading/unloading items \u2014 normal commute and travel patterns\n- Deliveries or services during daytime/evening (6 AM - 10 PM): carrying packages to doors/porches, placing items, leaving\n- Services/maintenance workers with visible tools, uniforms, or service vehicles during daytime\n- Activity confined to public areas only (sidewalks, streets) without entering property at any time\n\n### Suspicious Activity Indicators (Level 1)\n- **Checking or probing vehicle/building access**: trying handles without entering, peering through windows, examining multiple vehicles, or possessing break-in tools \u2014 Level 1\n- **Unidentified person in private areas (driveways, near vehicles/buildings) during late night/early morning (11 PM - 5 AM)** \u2014 ALWAYS Level 1 regardless of activity or duration\n- Taking items that don't belong to them (packages, objects from porches/driveways)\n- Climbing or jumping fences/barriers to access property\n- Attempting to conceal actions or items from view\n- Prolonged loitering: remaining in same area without visible purpose throughout most of the sequence\n\n### Critical Threat Indicators (Level 2)\n- Holding break-in tools (crowbars, pry bars, bolt cutters)\n- Weapons visible (guns, knives, bats used aggressively)\n- Forced entry in progress\n- Physical aggression or violence\n- Active property damage or theft in progress\n\n### Assessment Guidance\nEvaluate in this order:\n\n1. **If person is verified/known** \u2192 Level 0 regardless of time or activity\n2. **If person is unidentified:**\n - Check time: If late night/early morning (11 PM - 5 AM) AND in private areas (driveways, near vehicles/buildings) \u2192 Level 1\n - Check actions: If probing access (trying handles without entering, checking multiple vehicles), taking items, climbing \u2192 Level 1\n - Otherwise, if daytime/evening (6 AM - 10 PM) with clear legitimate purpose (delivery, service, routine vehicle access) \u2192 Level 0\n3. **Escalate to Level 2 if:** Weapons, break-in tools, forced entry in progress, violence, or active property damage visible (escalates from Level 0 or 1)\n\nThe mere presence of an unidentified person in private areas during late night hours is inherently suspicious and warrants human review, regardless of what activity they appear to be doing or how brief the sequence is."}}, "semantic_search": {"triggers": {}}, "snapshots": {"enabled": false, "timestamp": false, "bounding_box": true, "crop": false, "required_zones": [], "height": null, "retain": {"default": 10, "mode": "motion", "objects": {}}, "quality": 60}, "timestamp_style": {"position": "tl", "format": "%m/%d/%Y %H:%M:%S", "color": {"red": 255, "green": 255, "blue": 255}, "thickness": 2, "effect": null}, "best_image_timeout": 60, "mqtt": {"enabled": true, "timestamp": true, "bounding_box": true, "crop": true, "height": 270, "required_zones": [], "quality": 70}, "notifications": {"enabled": false, "email": null, "cooldown": 0, "enabled_in_config": false}, "onvif": {"host": "", "port": 8000, "user": null, "password": null, "tls_insecure": false, "profile": null, "autotracking": {"enabled": false, "calibrate_on_startup": false, "zooming": "disabled", "zoom_factor": 0.3, "track": ["person"], "required_zones": [], "return_preset": "home", "timeout": 10, "movement_weights": [], "enabled_in_config": false}, "ignore_time_mismatch": false}, "type": "generic", "ui": {"order": 0, "dashboard": true, "review": true}, "webui_url": null, "profiles": {}, "zones": {}, "enabled_in_config": true}, "garage": {"name": "garage", "friendly_name": null, "enabled": true, "audio": {"enabled": false, "max_not_heard": 30, "min_volume": 500, "listen": ["bark", "fire_alarm", "speech", "yell"], "filters": {"bark": {"threshold": 0.8}, "fire_alarm": {"threshold": 0.8}, "speech": {"threshold": 0.8}, "yell": {"threshold": 0.8}}, "enabled_in_config": false, "num_threads": 2}, "audio_transcription": {"enabled": false, "enabled_in_config": false, "live_enabled": false}, "birdseye": {"enabled": true, "mode": "objects", "order": 0}, "detect": {"enabled": false, "height": 720, "width": 1280, "fps": 5, "min_initialized": 2, "max_disappeared": 25, "stationary": {"interval": 50, "threshold": 50, "max_frames": {"default": null, "objects": {}}, "classifier": true}, "annotation_offset": 0}, "face_recognition": {"enabled": false, "min_area": 750}, "ffmpeg": {"path": "default", "global_args": ["-hide_banner", "-loglevel", "warning", "-threads", "2"], "hwaccel_args": "preset-vaapi", "input_args": "preset-rtsp-generic", "output_args": {"detect": ["-threads", "2", "-f", "rawvideo", "-pix_fmt", "yuv420p"], "record": "preset-record-generic-audio-aac"}, "retry_interval": 10.0, "apple_compatibility": false, "gpu": 0, "inputs": [{"path": "rtsp://10.0.0.3:554/video", "roles": ["record", "detect"], "global_args": [], "hwaccel_args": [], "input_args": []}]}, "live": {"streams": {"garage": "garage"}, "height": 720, "quality": 8}, "lpr": {"enabled": false, "expire_time": 3, "min_area": 1000, "enhancement": 0}, "motion": {"enabled": true, "threshold": 30, "lightning_threshold": 0.8, "skip_motion_threshold": null, "improve_contrast": true, "contour_area": 10, "delta_alpha": 0.2, "frame_alpha": 0.01, "frame_height": 100, "mask": {}, "mqtt_off_delay": 30, "enabled_in_config": null}, "objects": {"track": ["person"], "filters": {"person": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.5, "mask": {}}}, "mask": {}, "genai": {"enabled": false, "use_snapshot": false, "prompt": "Analyze the sequence of images containing the {label}. Focus on the likely intent or behavior of the {label} based on its actions and movement, rather than describing its appearance or the surroundings. Consider what the {label} is doing, why, and what it might do next.", "object_prompts": {}, "objects": [], "required_zones": [], "debug_save_thumbnails": false, "send_triggers": {"tracked_object_end": true, "after_significant_updates": null}, "enabled_in_config": false}}, "record": {"enabled": false, "expire_interval": 60, "continuous": {"days": 0}, "motion": {"days": 0}, "detections": {"pre_capture": 5, "post_capture": 5, "retain": {"days": 10, "mode": "motion"}}, "alerts": {"pre_capture": 5, "post_capture": 5, "retain": {"days": 10, "mode": "motion"}}, "export": {"hwaccel_args": "preset-vaapi", "max_concurrent": 3}, "preview": {"quality": "medium"}, "enabled_in_config": false}, "review": {"alerts": {"enabled": true, "labels": ["person", "car"], "required_zones": [], "enabled_in_config": true, "cutoff_time": 40}, "detections": {"enabled": true, "labels": null, "required_zones": [], "cutoff_time": 30, "enabled_in_config": true}, "genai": {"enabled": false, "alerts": true, "detections": false, "image_source": "preview", "additional_concerns": [], "debug_save_thumbnails": false, "enabled_in_config": false, "preferred_language": null, "activity_context_prompt": "### Normal Activity Indicators (Level 0)\n- Known/verified people in any zone at any time\n- People with pets in residential areas\n- Routine residential vehicle access during daytime/evening (6 AM - 10 PM): entering, exiting, loading/unloading items \u2014 normal commute and travel patterns\n- Deliveries or services during daytime/evening (6 AM - 10 PM): carrying packages to doors/porches, placing items, leaving\n- Services/maintenance workers with visible tools, uniforms, or service vehicles during daytime\n- Activity confined to public areas only (sidewalks, streets) without entering property at any time\n\n### Suspicious Activity Indicators (Level 1)\n- **Checking or probing vehicle/building access**: trying handles without entering, peering through windows, examining multiple vehicles, or possessing break-in tools \u2014 Level 1\n- **Unidentified person in private areas (driveways, near vehicles/buildings) during late night/early morning (11 PM - 5 AM)** \u2014 ALWAYS Level 1 regardless of activity or duration\n- Taking items that don't belong to them (packages, objects from porches/driveways)\n- Climbing or jumping fences/barriers to access property\n- Attempting to conceal actions or items from view\n- Prolonged loitering: remaining in same area without visible purpose throughout most of the sequence\n\n### Critical Threat Indicators (Level 2)\n- Holding break-in tools (crowbars, pry bars, bolt cutters)\n- Weapons visible (guns, knives, bats used aggressively)\n- Forced entry in progress\n- Physical aggression or violence\n- Active property damage or theft in progress\n\n### Assessment Guidance\nEvaluate in this order:\n\n1. **If person is verified/known** \u2192 Level 0 regardless of time or activity\n2. **If person is unidentified:**\n - Check time: If late night/early morning (11 PM - 5 AM) AND in private areas (driveways, near vehicles/buildings) \u2192 Level 1\n - Check actions: If probing access (trying handles without entering, checking multiple vehicles), taking items, climbing \u2192 Level 1\n - Otherwise, if daytime/evening (6 AM - 10 PM) with clear legitimate purpose (delivery, service, routine vehicle access) \u2192 Level 0\n3. **Escalate to Level 2 if:** Weapons, break-in tools, forced entry in progress, violence, or active property damage visible (escalates from Level 0 or 1)\n\nThe mere presence of an unidentified person in private areas during late night hours is inherently suspicious and warrants human review, regardless of what activity they appear to be doing or how brief the sequence is."}}, "semantic_search": {"triggers": {}}, "snapshots": {"enabled": false, "timestamp": false, "bounding_box": true, "crop": false, "required_zones": [], "height": null, "retain": {"default": 10, "mode": "motion", "objects": {}}, "quality": 60}, "timestamp_style": {"position": "tl", "format": "%m/%d/%Y %H:%M:%S", "color": {"red": 255, "green": 255, "blue": 255}, "thickness": 2, "effect": null}, "best_image_timeout": 60, "mqtt": {"enabled": true, "timestamp": true, "bounding_box": true, "crop": true, "height": 270, "required_zones": [], "quality": 70}, "notifications": {"enabled": false, "email": null, "cooldown": 0, "enabled_in_config": false}, "onvif": {"host": "", "port": 8000, "user": null, "password": null, "tls_insecure": false, "profile": null, "autotracking": {"enabled": false, "calibrate_on_startup": false, "zooming": "disabled", "zoom_factor": 0.3, "track": ["person"], "required_zones": [], "return_preset": "home", "timeout": 10, "movement_weights": [], "enabled_in_config": false}, "ignore_time_mismatch": false}, "type": "generic", "ui": {"order": 0, "dashboard": true, "review": true}, "webui_url": null, "profiles": {}, "zones": {}, "enabled_in_config": true}}, "audio": {"enabled": false, "max_not_heard": 30, "min_volume": 500, "listen": ["bark", "fire_alarm", "speech", "yell"], "filters": {"bark": {"threshold": 0.8}, "fire_alarm": {"threshold": 0.8}, "speech": {"threshold": 0.8}, "yell": {"threshold": 0.8}}, "enabled_in_config": null, "num_threads": 2}, "birdseye": {"enabled": true, "mode": "objects", "restream": false, "width": 1280, "height": 720, "quality": 8, "inactivity_threshold": 30, "layout": {"scaling_factor": 2.0, "max_cameras": null}, "idle_heartbeat_fps": 0.0}, "detect": {"enabled": false, "height": null, "width": null, "fps": 5, "min_initialized": null, "max_disappeared": null, "stationary": {"interval": null, "threshold": null, "max_frames": {"default": null, "objects": {}}, "classifier": true}, "annotation_offset": 0}, "ffmpeg": {"path": "default", "global_args": ["-hide_banner", "-loglevel", "warning", "-threads", "2"], "hwaccel_args": "preset-vaapi", "input_args": "preset-rtsp-generic", "output_args": {"detect": ["-threads", "2", "-f", "rawvideo", "-pix_fmt", "yuv420p"], "record": "preset-record-generic-audio-aac"}, "retry_interval": 10.0, "apple_compatibility": false, "gpu": 0}, "live": {"streams": [], "height": 720, "quality": 8}, "motion": null, "objects": {"track": ["person"], "filters": {"royal_mail": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "an_post": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "ups": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "postnord": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "dhl": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "postnl": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "usps": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "face": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "license_plate": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "dpd": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "amazon": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "fedex": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "canada_post": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "nzpost": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "gls": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}, "purolator": {"min_area": 0, "max_area": 24000000, "min_ratio": 0, "max_ratio": 24000000, "threshold": 0.7, "min_score": 0.7, "mask": {}}}, "mask": {}, "genai": {"enabled": false, "use_snapshot": false, "prompt": "Analyze the sequence of images containing the {label}. Focus on the likely intent or behavior of the {label} based on its actions and movement, rather than describing its appearance or the surroundings. Consider what the {label} is doing, why, and what it might do next.", "object_prompts": {}, "objects": [], "required_zones": [], "debug_save_thumbnails": false, "send_triggers": {"tracked_object_end": true, "after_significant_updates": null}, "enabled_in_config": null}}, "record": {"enabled": false, "expire_interval": 60, "continuous": {"days": 0}, "motion": {"days": 0}, "detections": {"pre_capture": 5, "post_capture": 5, "retain": {"days": 10, "mode": "motion"}}, "alerts": {"pre_capture": 5, "post_capture": 5, "retain": {"days": 10, "mode": "motion"}}, "export": {"hwaccel_args": "preset-vaapi", "max_concurrent": 3}, "preview": {"quality": "medium"}, "enabled_in_config": null}, "review": {"alerts": {"enabled": true, "labels": ["person", "car"], "required_zones": [], "enabled_in_config": null, "cutoff_time": 40}, "detections": {"enabled": true, "labels": null, "required_zones": [], "cutoff_time": 30, "enabled_in_config": null}, "genai": {"enabled": false, "alerts": true, "detections": false, "image_source": "preview", "additional_concerns": [], "debug_save_thumbnails": false, "enabled_in_config": null, "preferred_language": null, "activity_context_prompt": "### Normal Activity Indicators (Level 0)\n- Known/verified people in any zone at any time\n- People with pets in residential areas\n- Routine residential vehicle access during daytime/evening (6 AM - 10 PM): entering, exiting, loading/unloading items \u2014 normal commute and travel patterns\n- Deliveries or services during daytime/evening (6 AM - 10 PM): carrying packages to doors/porches, placing items, leaving\n- Services/maintenance workers with visible tools, uniforms, or service vehicles during daytime\n- Activity confined to public areas only (sidewalks, streets) without entering property at any time\n\n### Suspicious Activity Indicators (Level 1)\n- **Checking or probing vehicle/building access**: trying handles without entering, peering through windows, examining multiple vehicles, or possessing break-in tools \u2014 Level 1\n- **Unidentified person in private areas (driveways, near vehicles/buildings) during late night/early morning (11 PM - 5 AM)** \u2014 ALWAYS Level 1 regardless of activity or duration\n- Taking items that don't belong to them (packages, objects from porches/driveways)\n- Climbing or jumping fences/barriers to access property\n- Attempting to conceal actions or items from view\n- Prolonged loitering: remaining in same area without visible purpose throughout most of the sequence\n\n### Critical Threat Indicators (Level 2)\n- Holding break-in tools (crowbars, pry bars, bolt cutters)\n- Weapons visible (guns, knives, bats used aggressively)\n- Forced entry in progress\n- Physical aggression or violence\n- Active property damage or theft in progress\n\n### Assessment Guidance\nEvaluate in this order:\n\n1. **If person is verified/known** \u2192 Level 0 regardless of time or activity\n2. **If person is unidentified:**\n - Check time: If late night/early morning (11 PM - 5 AM) AND in private areas (driveways, near vehicles/buildings) \u2192 Level 1\n - Check actions: If probing access (trying handles without entering, checking multiple vehicles), taking items, climbing \u2192 Level 1\n - Otherwise, if daytime/evening (6 AM - 10 PM) with clear legitimate purpose (delivery, service, routine vehicle access) \u2192 Level 0\n3. **Escalate to Level 2 if:** Weapons, break-in tools, forced entry in progress, violence, or active property damage visible (escalates from Level 0 or 1)\n\nThe mere presence of an unidentified person in private areas during late night hours is inherently suspicious and warrants human review, regardless of what activity they appear to be doing or how brief the sequence is."}}, "snapshots": {"enabled": false, "timestamp": false, "bounding_box": true, "crop": false, "required_zones": [], "height": null, "retain": {"default": 10, "mode": "motion", "objects": {}}, "quality": 60}, "timestamp_style": {"position": "tl", "format": "%m/%d/%Y %H:%M:%S", "color": {"red": 255, "green": 255, "blue": 255}, "thickness": 2, "effect": null}, "audio_transcription": {"enabled": false, "language": "en", "device": "CPU", "model_size": "small", "live_enabled": false}, "classification": {"bird": {"enabled": false, "threshold": 0.9}, "custom": {}}, "semantic_search": {"enabled": false, "reindex": false, "model": "jinav1", "model_size": "small", "device": null}, "face_recognition": {"enabled": false, "model_size": "small", "unknown_score": 0.8, "detection_threshold": 0.7, "recognition_threshold": 0.9, "min_area": 750, "min_faces": 1, "save_attempts": 200, "blur_confidence_filter": true, "device": null}, "lpr": {"enabled": false, "model_size": "small", "detection_threshold": 0.7, "min_area": 1000, "recognition_threshold": 0.9, "min_plate_length": 4, "format": null, "match_distance": 1, "known_plates": {}, "enhancement": 0, "debug_save_plates": false, "device": null, "replace_rules": []}, "camera_groups": {"default": {"cameras": ["front_door", "backyard", "garage"], "icon": "generic", "order": 0}, "outdoor": {"cameras": ["front_door", "backyard"], "icon": "generic", "order": 1}}, "profiles": {}} \ No newline at end of file diff --git a/web/e2e/fixtures/mock-data/events.json b/web/e2e/fixtures/mock-data/events.json index a50c1d7bc7..fa698a9b41 100644 --- a/web/e2e/fixtures/mock-data/events.json +++ b/web/e2e/fixtures/mock-data/events.json @@ -1 +1 @@ -[{"id": "event-person-001", "label": "person", "sub_label": null, "camera": "front_door", "start_time": 1775487131.3863528, "end_time": 1775487161.3863528, "false_positive": false, "zones": ["front_yard"], "thumbnail": null, "has_clip": true, "has_snapshot": true, "retain_indefinitely": false, "plus_id": null, "model_hash": "abc123", "detector_type": "cpu", "model_type": "ssd", "data": {"top_score": 0.92, "score": 0.92, "region": [0.1, 0.1, 0.5, 0.8], "box": [0.2, 0.15, 0.45, 0.75], "area": 0.18, "ratio": 0.6, "type": "object", "description": "A person walking toward the front door", "average_estimated_speed": 1.2, "velocity_angle": 45.0, "path_data": [[[0.2, 0.5], 0.0], [[0.3, 0.5], 1.0]]}}, {"id": "event-car-001", "label": "car", "sub_label": null, "camera": "backyard", "start_time": 1775483531.3863528, "end_time": 1775483576.3863528, "false_positive": false, "zones": ["driveway"], "thumbnail": null, "has_clip": true, "has_snapshot": true, "retain_indefinitely": false, "plus_id": null, "model_hash": "def456", "detector_type": "cpu", "model_type": "ssd", "data": {"top_score": 0.87, "score": 0.87, "region": [0.3, 0.2, 0.9, 0.7], "box": [0.35, 0.25, 0.85, 0.65], "area": 0.2, "ratio": 1.25, "type": "object", "description": "A car parked in the driveway", "average_estimated_speed": 0.0, "velocity_angle": 0.0, "path_data": []}}, {"id": "event-person-002", "label": "person", "sub_label": null, "camera": "garage", "start_time": 1775479931.3863528, "end_time": 1775479951.3863528, "false_positive": false, "zones": [], "thumbnail": null, "has_clip": false, "has_snapshot": true, "retain_indefinitely": false, "plus_id": null, "model_hash": "ghi789", "detector_type": "cpu", "model_type": "ssd", "data": {"top_score": 0.78, "score": 0.78, "region": [0.0, 0.0, 0.6, 0.9], "box": [0.1, 0.05, 0.5, 0.85], "area": 0.32, "ratio": 0.5, "type": "object", "description": null, "average_estimated_speed": 0.5, "velocity_angle": 90.0, "path_data": [[[0.1, 0.4], 0.0]]}}] \ No newline at end of file +[{"id": "event-person-001", "label": "person", "sub_label": null, "camera": "front_door", "start_time": 1780677009.365581, "end_time": 1780677039.365581, "false_positive": false, "zones": ["front_yard"], "thumbnail": null, "has_clip": true, "has_snapshot": true, "retain_indefinitely": false, "plus_id": null, "model_hash": "abc123", "detector_type": "cpu", "model_type": "ssd", "data": {"top_score": 0.92, "score": 0.92, "region": [0.1, 0.1, 0.5, 0.8], "box": [0.2, 0.15, 0.45, 0.75], "area": 0.18, "ratio": 0.6, "type": "object", "description": "A person walking toward the front door", "average_estimated_speed": 1.2, "velocity_angle": 45.0, "path_data": [[[0.2, 0.5], 0.0], [[0.3, 0.5], 1.0]]}}, {"id": "event-car-001", "label": "car", "sub_label": null, "camera": "backyard", "start_time": 1780673409.365581, "end_time": 1780673454.365581, "false_positive": false, "zones": ["driveway"], "thumbnail": null, "has_clip": true, "has_snapshot": true, "retain_indefinitely": false, "plus_id": null, "model_hash": "def456", "detector_type": "cpu", "model_type": "ssd", "data": {"top_score": 0.87, "score": 0.87, "region": [0.3, 0.2, 0.9, 0.7], "box": [0.35, 0.25, 0.85, 0.65], "area": 0.2, "ratio": 1.25, "type": "object", "description": "A car parked in the driveway", "average_estimated_speed": 0.0, "velocity_angle": 0.0, "path_data": []}}, {"id": "event-person-002", "label": "person", "sub_label": null, "camera": "garage", "start_time": 1780669809.365581, "end_time": 1780669829.365581, "false_positive": false, "zones": [], "thumbnail": null, "has_clip": false, "has_snapshot": true, "retain_indefinitely": false, "plus_id": null, "model_hash": "ghi789", "detector_type": "cpu", "model_type": "ssd", "data": {"top_score": 0.78, "score": 0.78, "region": [0.0, 0.0, 0.6, 0.9], "box": [0.1, 0.05, 0.5, 0.85], "area": 0.32, "ratio": 0.5, "type": "object", "description": null, "average_estimated_speed": 0.5, "velocity_angle": 90.0, "path_data": [[[0.1, 0.4], 0.0]]}}] \ No newline at end of file diff --git a/web/e2e/fixtures/mock-data/exports.json b/web/e2e/fixtures/mock-data/exports.json index 9af04f45a8..7e6c787088 100644 --- a/web/e2e/fixtures/mock-data/exports.json +++ b/web/e2e/fixtures/mock-data/exports.json @@ -1 +1 @@ -[{"id": "export-001", "camera": "front_door", "name": "Front Door - Person Alert", "date": 1775490731.3863528, "video_path": "/exports/export-001.mp4", "thumb_path": "/exports/export-001-thumb.jpg", "in_progress": false, "export_case_id": null}, {"id": "export-002", "camera": "backyard", "name": "Backyard - Car Detection", "date": 1775483531.3863528, "video_path": "/exports/export-002.mp4", "thumb_path": "/exports/export-002-thumb.jpg", "in_progress": false, "export_case_id": "case-001"}, {"id": "export-003", "camera": "garage", "name": "Garage - In Progress", "date": 1775492531.3863528, "video_path": "/exports/export-003.mp4", "thumb_path": "/exports/export-003-thumb.jpg", "in_progress": true, "export_case_id": null}] \ No newline at end of file +[{"id": "export-001", "camera": "front_door", "name": "Front Door - Person Alert", "date": 1780680609.365581, "video_path": "/exports/export-001.mp4", "thumb_path": "/exports/export-001-thumb.jpg", "in_progress": false, "export_case_id": null}, {"id": "export-002", "camera": "backyard", "name": "Backyard - Car Detection", "date": 1780673409.365581, "video_path": "/exports/export-002.mp4", "thumb_path": "/exports/export-002-thumb.jpg", "in_progress": false, "export_case_id": "case-001"}, {"id": "export-003", "camera": "garage", "name": "Garage - In Progress", "date": 1780682409.365581, "video_path": "/exports/export-003.mp4", "thumb_path": "/exports/export-003-thumb.jpg", "in_progress": true, "export_case_id": null}] \ No newline at end of file diff --git a/web/e2e/fixtures/mock-data/review-summary.json b/web/e2e/fixtures/mock-data/review-summary.json index ba54df37c9..bb3afc2ea3 100644 --- a/web/e2e/fixtures/mock-data/review-summary.json +++ b/web/e2e/fixtures/mock-data/review-summary.json @@ -1 +1 @@ -{"2026-04-06": {"day": "2026-04-06", "reviewed_alert": 1, "reviewed_detection": 0, "total_alert": 2, "total_detection": 2}, "2026-04-05": {"day": "2026-04-05", "reviewed_alert": 3, "reviewed_detection": 2, "total_alert": 3, "total_detection": 4}} \ No newline at end of file +{"2026-06-05": {"day": "2026-06-05", "reviewed_alert": 1, "reviewed_detection": 0, "total_alert": 2, "total_detection": 2}, "2026-06-04": {"day": "2026-06-04", "reviewed_alert": 3, "reviewed_detection": 2, "total_alert": 3, "total_detection": 4}} \ No newline at end of file diff --git a/web/e2e/fixtures/mock-data/reviews.json b/web/e2e/fixtures/mock-data/reviews.json index 4930f01598..0b60850cfa 100644 --- a/web/e2e/fixtures/mock-data/reviews.json +++ b/web/e2e/fixtures/mock-data/reviews.json @@ -1 +1 @@ -[{"id": "review-alert-001", "camera": "front_door", "start_time": "2026-04-06T09:52:11.386353", "end_time": "2026-04-06T09:52:41.386353", "has_been_reviewed": false, "severity": "alert", "thumb_path": "/clips/front_door/review-alert-001-thumb.jpg", "data": {"audio": [], "detections": ["person-abc123"], "objects": ["person"], "sub_labels": [], "significant_motion_areas": [], "zones": ["front_yard"]}}, {"id": "review-alert-002", "camera": "backyard", "start_time": "2026-04-06T08:52:11.386353", "end_time": "2026-04-06T08:52:56.386353", "has_been_reviewed": true, "severity": "alert", "thumb_path": "/clips/backyard/review-alert-002-thumb.jpg", "data": {"audio": [], "detections": ["car-def456"], "objects": ["car"], "sub_labels": [], "significant_motion_areas": [], "zones": ["driveway"]}}, {"id": "review-detect-001", "camera": "garage", "start_time": "2026-04-06T07:52:11.386353", "end_time": "2026-04-06T07:52:31.386353", "has_been_reviewed": false, "severity": "detection", "thumb_path": "/clips/garage/review-detect-001-thumb.jpg", "data": {"audio": [], "detections": ["person-ghi789"], "objects": ["person"], "sub_labels": [], "significant_motion_areas": [], "zones": []}}, {"id": "review-detect-002", "camera": "front_door", "start_time": "2026-04-06T06:52:11.386353", "end_time": "2026-04-06T06:52:26.386353", "has_been_reviewed": false, "severity": "detection", "thumb_path": "/clips/front_door/review-detect-002-thumb.jpg", "data": {"audio": [], "detections": ["car-jkl012"], "objects": ["car"], "sub_labels": [], "significant_motion_areas": [], "zones": ["front_yard"]}}] \ No newline at end of file +[{"id": "review-alert-001", "camera": "front_door", "start_time": "2026-06-05T11:30:09.365581", "end_time": "2026-06-05T11:30:39.365581", "has_been_reviewed": false, "severity": "alert", "thumb_path": "/clips/front_door/review-alert-001-thumb.jpg", "data": {"audio": [], "detections": ["person-abc123"], "objects": ["person"], "sub_labels": [], "significant_motion_areas": [], "zones": ["front_yard"]}}, {"id": "review-alert-002", "camera": "backyard", "start_time": "2026-06-05T10:30:09.365581", "end_time": "2026-06-05T10:30:54.365581", "has_been_reviewed": true, "severity": "alert", "thumb_path": "/clips/backyard/review-alert-002-thumb.jpg", "data": {"audio": [], "detections": ["car-def456"], "objects": ["car"], "sub_labels": [], "significant_motion_areas": [], "zones": ["driveway"]}}, {"id": "review-detect-001", "camera": "garage", "start_time": "2026-06-05T09:30:09.365581", "end_time": "2026-06-05T09:30:29.365581", "has_been_reviewed": false, "severity": "detection", "thumb_path": "/clips/garage/review-detect-001-thumb.jpg", "data": {"audio": [], "detections": ["person-ghi789"], "objects": ["person"], "sub_labels": [], "significant_motion_areas": [], "zones": []}}, {"id": "review-detect-002", "camera": "front_door", "start_time": "2026-06-05T08:30:09.365581", "end_time": "2026-06-05T08:30:24.365581", "has_been_reviewed": false, "severity": "detection", "thumb_path": "/clips/front_door/review-detect-002-thumb.jpg", "data": {"audio": [], "detections": ["car-jkl012"], "objects": ["car"], "sub_labels": [], "significant_motion_areas": [], "zones": ["front_yard"]}}] \ No newline at end of file diff --git a/web/public/locales/en/config/cameras.json b/web/public/locales/en/config/cameras.json index 98e625abf7..dd7c7e0a58 100644 --- a/web/public/locales/en/config/cameras.json +++ b/web/public/locales/en/config/cameras.json @@ -29,7 +29,7 @@ }, "listen": { "label": "Listen types", - "description": "List of audio event types to detect (for example: bark, fire_alarm, scream, speech, yell)." + "description": "List of audio event types to detect (for example: bark, fire_alarm, speech, yell)." }, "filters": { "label": "Audio filters", @@ -156,7 +156,7 @@ "description": "FFmpeg settings including binary path, args, hwaccel options, and per-role output args.", "path": { "label": "FFmpeg path", - "description": "Path to the FFmpeg binary to use or a version alias (\"5.0\" or \"7.0\")." + "description": "Path to the FFmpeg binary to use or a version alias (\"7.0\" or \"8.0\")." }, "global_args": { "label": "FFmpeg global arguments", diff --git a/web/public/locales/en/config/global.json b/web/public/locales/en/config/global.json index 119d384e43..09facf0da3 100644 --- a/web/public/locales/en/config/global.json +++ b/web/public/locales/en/config/global.json @@ -547,7 +547,7 @@ }, "listen": { "label": "Listen types", - "description": "List of audio event types to detect (for example: bark, fire_alarm, scream, speech, yell)." + "description": "List of audio event types to detect (for example: bark, fire_alarm, speech, yell)." }, "filters": { "label": "Audio filters", @@ -683,7 +683,7 @@ "description": "FFmpeg settings including binary path, args, hwaccel options, and per-role output args.", "path": { "label": "FFmpeg path", - "description": "Path to the FFmpeg binary to use or a version alias (\"5.0\" or \"7.0\")." + "description": "Path to the FFmpeg binary to use or a version alias (\"7.0\" or \"8.0\")." }, "global_args": { "label": "FFmpeg global arguments", diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index a5edab9788..a40ce797dc 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -1914,6 +1914,9 @@ "resolutionHigh": "This detect resolution is higher than recommended and may cause increased resource usage without improving detection accuracy. A detect resolution at or below 1080p is recommended for most cameras.", "globalResolutionMultipleCameras": "A global detect resolution is set while multiple cameras are configured. Unless all cameras share the same resolution and aspect ratio, the detect width and height should be defined per camera to match each camera's native aspect ratio." }, + "ffmpeg": { + "hwaccelManualNotRecommended": "Manual hardware acceleration arguments are not recommended. Unless a specific requirement exists, select the preset that matches your hardware." + }, "objects": { "genaiNoDescriptionsProvider": "You must configure a GenAI provider with the 'descriptions' role for descriptions to be generated." }, diff --git a/web/src/components/config-form/section-configs/ffmpeg.ts b/web/src/components/config-form/section-configs/ffmpeg.ts index ee73f2e2bf..6f9cffad9e 100644 --- a/web/src/components/config-form/section-configs/ffmpeg.ts +++ b/web/src/components/config-form/section-configs/ffmpeg.ts @@ -22,6 +22,27 @@ const ffmpegArgsWidget = ( const ffmpeg: SectionConfigOverrides = { base: { sectionDocs: "/configuration/ffmpeg_presets", + fieldMessages: [ + { + key: "hwaccel-manual-not-recommended", + field: "hwaccel_args", + position: "after", + messageKey: "configMessages.ffmpeg.hwaccelManualNotRecommended", + severity: "warning", + condition: (ctx) => { + // Manual mode is active when hwaccel_args is an explicit args list + // or a non-preset string + const value = ctx.formData?.hwaccel_args; + if (Array.isArray(value)) { + return value.length > 0; + } + if (typeof value === "string") { + return !value.startsWith("preset-"); + } + return false; + }, + }, + ], fieldDocs: { hwaccel_args: "/configuration/ffmpeg_presets#hwaccel-presets", "inputs.hwaccel_args": "/configuration/ffmpeg_presets#hwaccel-presets", diff --git a/web/src/components/config-form/theme/templates/FieldTemplate.tsx b/web/src/components/config-form/theme/templates/FieldTemplate.tsx index 2ee91ae033..d9df9e8e0a 100644 --- a/web/src/components/config-form/theme/templates/FieldTemplate.tsx +++ b/web/src/components/config-form/theme/templates/FieldTemplate.tsx @@ -386,11 +386,14 @@ export function FieldTemplate(props: FieldTemplateProps) { const beforeContent = renderCustom(beforeSpec); const afterContent = renderCustom(afterSpec); - // Read field-level conditional messages from FieldMessagesContext + // Read field-level conditional messages from FieldMessagesContext. + // For multi-schema fields (anyOf/oneOf), FieldTemplate renders twice for + // the same path (wrapper + inner branch); skip the wrapper pass so the + // message isn't shown twice, mirroring how labels/descriptions dedupe. const fieldPathStr = pathSegments.join("."); - const fieldMessageSpecs = allFieldMessages.filter( - (m) => m.field === fieldPathStr, - ); + const fieldMessageSpecs = isMultiSchemaWrapper + ? [] + : allFieldMessages.filter((m) => m.field === fieldPathStr); const beforeMessages = fieldMessageSpecs.filter( (m) => (m.position ?? "before") === "before", ); From 06e3d0ac5dcf2f8d156c571c6317f6547dfc3b73 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Tue, 9 Jun 2026 09:07:42 -0600 Subject: [PATCH 09/92] Chapter tweaks (#23440) * Add camera metadata and fix preview chapters * Add config option for chapters --- frigate/api/export.py | 11 +++++++++-- frigate/config/camera/record.py | 10 ++++++++++ frigate/record/export.py | 19 +++++++++---------- 3 files changed, 28 insertions(+), 12 deletions(-) diff --git a/frigate/api/export.py b/frigate/api/export.py index 786e046dbb..614ac78db6 100644 --- a/frigate/api/export.py +++ b/frigate/api/export.py @@ -31,7 +31,6 @@ from frigate.api.defs.tags import Tags from frigate.const import CLIPS_DIR, EXPORT_DIR from frigate.models import Export, Previews, Recordings from frigate.record.export import ( - ChaptersEnum, PlaybackFactorEnum, PlaybackSourceEnum, RecordingExporter, @@ -94,6 +93,14 @@ def export_recording( friendly_name = body.name existing_image = sanitize_filepath(body.image_path) if body.image_path else None + # a chapters value in the request body overrides the camera's export config + camera_config = request.app.frigate_config.cameras[camera_name] + chapters = ( + body.chapters + if body.chapters is not None + else camera_config.record.export.chapters + ) + # Ensure that existing_image is a valid path if existing_image and not existing_image.startswith(CLIPS_DIR): return JSONResponse( @@ -162,7 +169,7 @@ def export_recording( if playback_source in PlaybackSourceEnum.__members__.values() else PlaybackSourceEnum.recordings ), - chapters=ChaptersEnum(body.chapters) if body.chapters else None, + chapters=chapters, ) exporter.start() return JSONResponse( diff --git a/frigate/config/camera/record.py b/frigate/config/camera/record.py index 09a7a84d5b..4c8f873568 100644 --- a/frigate/config/camera/record.py +++ b/frigate/config/camera/record.py @@ -9,6 +9,7 @@ from frigate.review.types import SeverityEnum from ..base import FrigateBaseModel __all__ = [ + "ChaptersEnum", "RecordConfig", "RecordExportConfig", "RecordPreviewConfig", @@ -66,10 +67,19 @@ class RecordPreviewConfig(FrigateBaseModel): ) +class ChaptersEnum(str, Enum): + none = "none" + recording_segments = "recording_segments" + + class RecordExportConfig(FrigateBaseModel): timelapse_args: str = Field( default=DEFAULT_TIME_LAPSE_FFMPEG_ARGS, title="Timelapse Args" ) + chapters: ChaptersEnum = Field( + default=ChaptersEnum.none, + title="Chapter metadata to embed in exported recordings", + ) class RecordConfig(FrigateBaseModel): diff --git a/frigate/record/export.py b/frigate/record/export.py index d5d5ddb766..28a72d05ea 100644 --- a/frigate/record/export.py +++ b/frigate/record/export.py @@ -16,6 +16,7 @@ import pytz from peewee import DoesNotExist from frigate.config import FfmpegConfig, FrigateConfig +from frigate.config.camera.record import ChaptersEnum from frigate.const import ( CACHE_DIR, CLIPS_DIR, @@ -51,14 +52,6 @@ class PlaybackSourceEnum(str, Enum): preview = "preview" -class ChaptersEnum(str, Enum): - # One chapter per recording segment, titled with the segment's - # wallclock start time in strict ISO 8601 form. Lets viewers map - # output playback time back to wallclock without reading a timestamp - # overlay via OCR. - recording_segments = "recording_segments" - - class RecordingExporter(threading.Thread): """Exports a specific set of recordings for a camera to storage as a single file.""" @@ -358,6 +351,8 @@ class RecordingExporter(threading.Thread): f"title={title}", "-metadata", f"creation_time={creation_time}", + "-metadata", + f"comment=Camera: {self.camera}", ] ) @@ -435,7 +430,7 @@ class RecordingExporter(threading.Thread): if self.playback_factor == PlaybackFactorEnum.realtime: ffmpeg_cmd = ( - f"{self.config.ffmpeg.ffmpeg_path} -hide_banner {ffmpeg_input} {codec} -movflags +faststart {video_path}" + f"{self.config.ffmpeg.ffmpeg_path} -hide_banner {ffmpeg_input} {codec} -movflags +faststart" ).split(" ") elif self.playback_factor == PlaybackFactorEnum.timelapse_25x: ffmpeg_cmd = ( @@ -443,7 +438,7 @@ class RecordingExporter(threading.Thread): self.config.ffmpeg.ffmpeg_path, self.config.ffmpeg.hwaccel_args, f"{TIMELAPSE_DATA_INPUT_ARGS} {ffmpeg_input}", - f"{self.config.cameras[self.camera].record.export.timelapse_args} -movflags +faststart {video_path}", + f"{self.config.cameras[self.camera].record.export.timelapse_args} -movflags +faststart", EncodeTypeEnum.timelapse, ) ).split(" ") @@ -459,9 +454,13 @@ class RecordingExporter(threading.Thread): f"title={title}", "-metadata", f"creation_time={creation_time}", + "-metadata", + f"comment=Camera: {self.camera}", ] ) + ffmpeg_cmd.append(video_path) + return ffmpeg_cmd, playlist_lines def run(self) -> None: From efe585a9207aba7515bb1e1143bcca9709c9226d Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 11 Jun 2026 06:36:30 -0500 Subject: [PATCH 10/92] Miscellaneous fixes (#23445) * keep global camera config subscribers broad when only one camera exists at startup * update glossary --- docs/docs/frigate/glossary.md | 60 ++++++++++++++++++++++++-------- frigate/config/camera/updater.py | 7 +++- 2 files changed, 52 insertions(+), 15 deletions(-) diff --git a/docs/docs/frigate/glossary.md b/docs/docs/frigate/glossary.md index 5bfbfafa5a..eda7ce6d11 100644 --- a/docs/docs/frigate/glossary.md +++ b/docs/docs/frigate/glossary.md @@ -5,20 +5,40 @@ title: Glossary The glossary explains terms commonly used in Frigate's documentation. +## Alert + +The higher-priority of the two [review item](#review-item) severities, the other being a [detection](#detection). By default a review item is an alert when it involves a `person` or `car`; the qualifying [labels](#label) and [zones](#zone) can be configured. [See the review docs for more info](/configuration/review) + +## Attribute + +A property detected on an [object](#object) that exists alongside its [label](#label). Unlike a [sub label](#sub-label), an object can carry several attributes at once. Some attributes come directly from the object detection [model](#model) — for example `face`, `license_plate`, or delivery carrier logos such as `amazon`, `ups`, and `fedex` — while others come from a [custom object classification model](/configuration/custom_classification/object_classification) configured with the `attribute` type. Attributes are visible in the Tracked Object Details pane in Explore, in `frigate/events` MQTT messages, and through the HTTP API. + ## Bounding Box -A box returned from the object detection model that outlines an object in the frame. These have multiple colors depending on object type in the debug live view. +A box returned by the object detection [model](#model) that outlines a detected [object](#object) in the frame. In the Debug view, bounding boxes are colored by object [label](#label). ### Bounding Box Colors - At startup different colors will be assigned to each object label - A dark blue thin line indicates that object is not detected at this current point in time - A gray thin line indicates that object is detected as being stationary -- A thick line indicates that object is the subject of autotracking (when enabled). +- A thick line indicates that object is the subject of autotracking (when enabled) + +## Class + +The categories a classification [model](#model) is trained to distinguish between. Each class is a distinct visual category the model predicts, plus a `none` class for inputs that don't fit any category. For example, a custom object classification model for `person` objects might use the classes `delivery_person`, `resident`, and `none`. The predicted class is applied to the [object](#object) as either a [sub label](#sub-label) or an [attribute](#attribute), depending on the model's configuration. [See the object classification docs for more info](/configuration/custom_classification/object_classification) + +## Detection + +The lower-priority of the two [review item](#review-item) severities, the other being an [alert](#alert). By default, any review item that does not qualify as an alert is a detection; the qualifying [labels](#label) and [zones](#zone) can be configured. Despite the name, a detection is a category of review item — not the same as the object detection performed by the [model](#model). [See the review docs for more info](/configuration/review) ## False Positive -An incorrect detection of an object type. For example a dog being detected as a person, a chair being detected as a dog, etc. A person being detected in an area you want to ignore is not a false positive. +An incorrect result from the object detection [model](#model), where it assigns the wrong [label](#label) to something in the frame — for example a dog identified as a person, or a chair identified as a dog. A person correctly identified in an area you want to ignore is not a false positive. + +## Label + +The type assigned to a detected [object](#object) by the object detection [model](#model), drawn from the model's labelmap — for example `person`, `car`, or `dog`. Frigate tracks `person` by default; additional labels are tracked by adding them to the objects configuration. [See the available objects docs for the full list](/configuration/objects) ## Mask @@ -26,44 +46,56 @@ There are two types of masks in Frigate. [See the mask docs for more info](/conf ### Motion Mask -Motion masks prevent detection of [motion](#motion) in masked areas from triggering Frigate to run object detection, but do not prevent objects from being detected if object detection runs due to motion in nearby areas. For example: camera timestamps, skies, the tops of trees, etc. +A motion mask stops [motion](#motion) in the masked area from triggering object detection. It does not stop an object from being detected when object detection runs because of motion in a nearby area. Use motion masks for parts of the frame that change constantly but never contain objects you care about — camera timestamps, the sky, the tops of trees, and so on. ### Object Mask -Object filter masks drop any bounding boxes where the bottom center (overlap doesn't matter) is in the masked area. It forces them to be considered a [false positive](#false-positive) so that they are ignored. +An object filter mask drops any [bounding box](#bounding-box) whose bottom center falls inside the masked area (overlap elsewhere doesn't matter). The object is forced to be treated as a [false positive](#false-positive) and ignored. ## Min Score -The lowest score that an object can be detected with during tracking, any detection with a lower score will be assumed to be a false positive +The lowest score a detected object can have to be kept during tracking. Anything scoring below the minimum is assumed to be a [false positive](#false-positive) and discarded. + +## Model + +A machine learning model that Frigate uses to detect or classify objects. The object detection model locates [objects](#object) in each frame and returns their [labels](#label) and [bounding boxes](#bounding-box). Additional enrichment models run on tracked objects to add detail: face recognition, license plate recognition, bird classification, custom object and state classification, and the embedding models used for semantic search. [See the object detectors docs for more info](/configuration/object_detectors) ## Motion -When pixels in the current camera frame are different than previous frames. When many nearby pixels are different in the current frame they grouped together and indicated with a red motion box in the live debug view. [See the motion detection docs for more info](/configuration/motion_detection) +A change in pixels between the current camera frame and previous frames. When many nearby pixels change together, they are grouped and shown as a red motion box in the debug live view. [See the motion detection docs for more info](/configuration/motion_detection) + +## Object + +Something Frigate can detect and follow in a camera frame, identified by its [label](#label) (for example a person or a car). The object types Frigate watches for are set in the `objects` configuration. Once an object is detected and followed across frames it becomes a [tracked object](#tracked-object-event-in-previous-versions), which may also carry a [sub label](#sub-label) and [attributes](#attribute). [See the available objects docs for more info](/configuration/objects) ## Region -A portion of the camera frame that is sent to object detection, regions can be sent due to motion, active objects, or occasionally for stationary objects. These are represented by green boxes in the debug live view. +A portion of the camera frame sent to the object detection [model](#model). Regions are selected because of [motion](#motion), active objects, or occasionally to recheck stationary objects, and are shown as green boxes in the debug live view. ## Review Item -A review item is a time period where any number of events/tracked objects were active. [See the review docs for more info](/configuration/review) +A period of time during which one or more [tracked objects](#tracked-object-event-in-previous-versions) were active, grouped together for review. Each review item is categorized as either an [alert](#alert) or a [detection](#detection). [See the review docs for more info](/configuration/review) ## Snapshot Score -The score shown in a snapshot is the score of that object at that specific moment in time. +The object's score at the specific moment the snapshot was captured. + +## Sub Label + +A more specific identity assigned to a [tracked object](#tracked-object-event-in-previous-versions) in addition to its [label](#label). A `person` may get the name of a recognized face, a `car` may get the name of a known license plate, and a `bird` may get its species. An object can have only one sub label at a time. Sub labels are produced by face recognition, license plate recognition, bird classification, custom object classification configured with the `sub label` type, and semantic search triggers. ## Threshold -The threshold is the median score that an object must reach in order to be considered a true positive. +The median score an object must reach to be considered a true positive. ## Top Score -The top score for an object is the highest median score for an object. +The highest median score an object reached over its lifetime. ## Tracked Object ("event" in previous versions) -The time period starting when a tracked object entered the frame and ending when it left the frame, including any time that the object remained still. Tracked objects are saved when it is considered a [true positive](#threshold) and meets the requirements for a snapshot or recording to be saved. +An [object](#object) followed from the moment it enters the frame until it leaves, including any time it stays still. A tracked object is saved once it is considered a [true positive](#threshold) and meets the requirements for a snapshot or recording. ## Zone -Zones are areas of interest, zones can be used for notifications and for limiting the areas where Frigate will create a [review item](#review-item). [See the zone docs for more info](/configuration/zones) +A user-defined area of interest within the camera frame. Zones can be used for notifications and to limit where Frigate creates a [review item](#review-item). [See the zone docs for more info](/configuration/zones) diff --git a/frigate/config/camera/updater.py b/frigate/config/camera/updater.py index b475f42157..afa501904a 100644 --- a/frigate/config/camera/updater.py +++ b/frigate/config/camera/updater.py @@ -73,7 +73,12 @@ class CameraConfigUpdateSubscriber: base_topic = "config/cameras" - if len(self.camera_configs) == 1: + # global subscribers must hear every camera; only narrow per-camera workers + is_global_subscriber = ( + CameraConfigUpdateEnum.add in self.topics + or CameraConfigUpdateEnum.remove in self.topics + ) + if not is_global_subscriber and len(self.camera_configs) == 1: base_topic += f"/{list(self.camera_configs.keys())[0]}" self.subscriber = ConfigSubscriber( From e6601d50a613719adb361a4c2a715d4bc045a047 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 11 Jun 2026 15:16:41 -0500 Subject: [PATCH 11/92] Add recording keyframe analysis to camera probe dialog (#23453) * backend: endpoint and util funcs * tests * frontend and i18n * update openapi spec * add tip to docs --- docs/docs/troubleshooting/recordings.md | 6 + docs/static/frigate-api.yaml | 29 +++ frigate/api/camera.py | 50 ++++- .../http_api/test_http_keyframe_analysis.py | 58 ++++++ frigate/test/test_keyframe_analysis.py | 111 ++++++++++ frigate/util/builtin.py | 23 ++- frigate/util/services.py | 125 ++++++++++++ frigate/video/ffmpeg.py | 21 +- web/public/locales/en/views/system.json | 15 ++ .../components/overlay/CameraInfoDialog.tsx | 17 +- .../overlay/KeyframeAnalysisSection.tsx | 193 ++++++++++++++++++ web/src/types/stats.ts | 19 ++ 12 files changed, 642 insertions(+), 25 deletions(-) create mode 100644 frigate/test/http_api/test_http_keyframe_analysis.py create mode 100644 frigate/test/test_keyframe_analysis.py create mode 100644 web/src/components/overlay/KeyframeAnalysisSection.tsx diff --git a/docs/docs/troubleshooting/recordings.md b/docs/docs/troubleshooting/recordings.md index 2425e653a4..83f00d1ad0 100644 --- a/docs/docs/troubleshooting/recordings.md +++ b/docs/docs/troubleshooting/recordings.md @@ -121,6 +121,12 @@ If segments are only ~1 second instead of ~10 seconds, the camera is sending cor - **Changing codec, bitrate, or resolution mid-stream** — Any encoding changes during an active stream can cause unpredictable segment splitting. - **Camera firmware bugs** — Check for firmware updates from your camera manufacturer. +:::tip + +You don't have to run `ffprobe` by hand to catch this. Open a camera's **Camera Probe Info** dialog (the info icon on the System → Metrics → Cameras page) and check the **Keyframe analysis** section. It probes the record stream and flags sparse or variable keyframes, which is what smart/"+" codecs (H.264+/H.265+) and long keyframe intervals produce. + +::: + ### Step 4: Check for a stuck detector If the detect stream is not processing frames, segments will accumulate. Common causes: diff --git a/docs/static/frigate-api.yaml b/docs/static/frigate-api.yaml index 146fce4e64..2c6109b985 100644 --- a/docs/static/frigate-api.yaml +++ b/docs/static/frigate-api.yaml @@ -400,6 +400,35 @@ paths: application/json: schema: $ref: "#/components/schemas/HTTPValidationError" + /keyframe_analysis: + get: + tags: + - Camera + summary: Keyframe Analysis + description: >- + Probe a camera's record stream and classify its keyframe spacing. + Detects smart/+ codecs and long/variable GOPs that degrade recording. + operationId: keyframe_analysis_keyframe_analysis_get + parameters: + - name: camera + in: query + required: false + schema: + type: string + default: "" + title: Camera + responses: + "200": + description: Successful Response + content: + application/json: + schema: {} + "422": + description: Validation Error + content: + application/json: + schema: + $ref: "#/components/schemas/HTTPValidationError" /ffprobe/snapshot: get: tags: diff --git a/frigate/api/camera.py b/frigate/api/camera.py index 339ee33a16..f54b859454 100644 --- a/frigate/api/camera.py +++ b/frigate/api/camera.py @@ -34,11 +34,15 @@ from frigate.config.camera.updater import ( ) from frigate.config.env import substitute_frigate_vars from frigate.models import User -from frigate.util.builtin import clean_camera_user_pass +from frigate.util.builtin import clean_camera_user_pass, get_record_segment_time from frigate.util.camera_cleanup import cleanup_camera_db, cleanup_camera_files from frigate.util.config import find_config_file from frigate.util.image import run_ffmpeg_snapshot -from frigate.util.services import ffprobe_stream, is_restricted_go2rtc_source +from frigate.util.services import ( + analyze_record_keyframes, + ffprobe_stream, + is_restricted_go2rtc_source, +) logger = logging.getLogger(__name__) @@ -362,6 +366,48 @@ def ffprobe(request: Request, paths: str = "", detailed: bool = False): return JSONResponse(content=output) +@router.get("/keyframe_analysis", dependencies=[Depends(require_role(["admin"]))]) +async def keyframe_analysis(request: Request, camera: str = ""): + """Probe a camera's record stream and classify its keyframe spacing. + + Detects smart/+ codecs and long/variable GOPs that degrade recording. + """ + config: FrigateConfig = request.app.frigate_config + + if camera not in config.cameras: + return JSONResponse( + content={"success": False, "message": f"{camera} is not a valid camera."}, + status_code=404, + ) + + camera_config = config.cameras[camera] + + if not camera_config.enabled: + return JSONResponse( + content={"success": False, "message": f"{camera} is not enabled."}, + status_code=404, + ) + + # keyframe spacing only matters when this camera is recording + if not camera_config.record.enabled: + return JSONResponse(content={"severity": "record_disabled"}) + + # recording guarantees an input carries the record role; its index matches + # the "Stream N" numbering the ffprobe endpoint surfaces (same input order) + record_index, record_input = next( + (idx, i) + for idx, i in enumerate(camera_config.ffmpeg.inputs) + if "record" in i.roles + ) + + segment_time = get_record_segment_time(camera_config) + result = await analyze_record_keyframes( + config.ffmpeg, record_input.path, segment_time + ) + result["stream_index"] = record_index + return JSONResponse(content=result) + + @router.get("/ffprobe/snapshot", dependencies=[Depends(require_role(["admin"]))]) def ffprobe_snapshot(request: Request, url: str = "", timeout: int = 10): """Get a snapshot from a stream URL using ffmpeg.""" diff --git a/frigate/test/http_api/test_http_keyframe_analysis.py b/frigate/test/http_api/test_http_keyframe_analysis.py new file mode 100644 index 0000000000..6a49e105ec --- /dev/null +++ b/frigate/test/http_api/test_http_keyframe_analysis.py @@ -0,0 +1,58 @@ +from unittest.mock import AsyncMock, patch + +from frigate.models import Event, Recordings, ReviewSegment +from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp + + +class TestHttpKeyframeAnalysis(BaseTestHttp): + def setUp(self): + super().setUp([Event, Recordings, ReviewSegment]) + + def test_invalid_camera_returns_404(self): + app = super().create_app() + with AuthTestClient(app) as client: + response = client.get("/keyframe_analysis?camera=does_not_exist") + assert response.status_code == 404 + + def test_record_disabled_returns_neutral(self): + # default minimal_config has recording disabled + app = super().create_app() + with AuthTestClient(app) as client: + response = client.get("/keyframe_analysis?camera=front_door") + assert response.status_code == 200 + assert response.json()["severity"] == "record_disabled" + + def test_probes_record_input_and_returns_severity(self): + self.minimal_config["cameras"]["front_door"]["ffmpeg"]["inputs"] = [ + { + "path": "rtsp://10.0.0.1:554/record", + "roles": ["detect", "record"], + } + ] + self.minimal_config["cameras"]["front_door"]["record"] = {"enabled": True} + app = super().create_app() + + canned = { + "severity": "ok", + "keyframe_count": 5, + "max_gap": 1.0, + "mean_gap": 1.0, + "min_gap": 1.0, + "segment_time": 10, + "duration_observed": 4.0, + "thresholds": {"warning": 4.0, "error": 10}, + } + + with patch( + "frigate.api.camera.analyze_record_keyframes", + AsyncMock(return_value=canned), + ) as mock_probe: + with AuthTestClient(app) as client: + response = client.get("/keyframe_analysis?camera=front_door") + + assert response.status_code == 200 + assert response.json()["severity"] == "ok" + # index matches the input carrying the record role ("Stream 1") + assert response.json()["stream_index"] == 0 + # the record-role input path was probed + assert mock_probe.await_args.args[1] == "rtsp://10.0.0.1:554/record" diff --git a/frigate/test/test_keyframe_analysis.py b/frigate/test/test_keyframe_analysis.py new file mode 100644 index 0000000000..85302b5659 --- /dev/null +++ b/frigate/test/test_keyframe_analysis.py @@ -0,0 +1,111 @@ +"""Tests for keyframe-spacing analysis used to detect smart/+ codecs.""" + +import asyncio +import unittest +from unittest.mock import AsyncMock, MagicMock, patch + +from frigate.util.services import ( + analyze_record_keyframes, + classify_keyframe_gaps, + parse_keyframe_packets, +) + + +class TestClassifyKeyframeGaps(unittest.TestCase): + def test_ok_when_gaps_small(self): + # keyframes every ~1s + pts = [0.0, 1.0, 2.0, 3.0, 4.0] + result = classify_keyframe_gaps(pts, segment_time=10) + self.assertEqual(result["severity"], "ok") + self.assertEqual(result["max_gap"], 1.0) + self.assertEqual(result["keyframe_count"], 5) + self.assertEqual(result["thresholds"], {"warning": 4.0, "error": 10}) + + def test_warning_when_gap_exceeds_four_seconds(self): + pts = [0.0, 1.0, 6.5] # 5.5s gap + result = classify_keyframe_gaps(pts, segment_time=10) + self.assertEqual(result["severity"], "warning") + self.assertEqual(result["max_gap"], 5.5) + + def test_error_when_gap_exceeds_segment_time(self): + pts = [0.0, 12.0] # 12s gap > 10s segment + result = classify_keyframe_gaps(pts, segment_time=10) + self.assertEqual(result["severity"], "error") + + def test_error_threshold_tracks_segment_time(self): + pts = [0.0, 6.0] # 6s gap, segment_time=5 -> error + result = classify_keyframe_gaps(pts, segment_time=5) + self.assertEqual(result["severity"], "error") + + def test_unknown_with_single_keyframe(self): + result = classify_keyframe_gaps([1.0], segment_time=10) + self.assertEqual(result["severity"], "unknown") + self.assertIsNone(result["max_gap"]) + self.assertEqual(result["keyframe_count"], 1) + + def test_unknown_with_no_keyframes(self): + result = classify_keyframe_gaps([], segment_time=10) + self.assertEqual(result["severity"], "unknown") + self.assertEqual(result["keyframe_count"], 0) + + +class TestParseKeyframePackets(unittest.TestCase): + def test_extracts_keyframe_pts_and_max(self): + output = "0.000000,K__\n0.033333,___\n1.000000,K__\n1.500000,___\n" + keyframe_pts, max_pts = parse_keyframe_packets(output) + self.assertEqual(keyframe_pts, [0.0, 1.0]) + self.assertEqual(max_pts, 1.5) + + def test_skips_unparseable_and_empty_lines(self): + output = "N/A,K__\n\n2.0,K__\nbad line\n" + keyframe_pts, max_pts = parse_keyframe_packets(output) + self.assertEqual(keyframe_pts, [2.0]) + self.assertEqual(max_pts, 2.0) + + def test_empty_output(self): + keyframe_pts, max_pts = parse_keyframe_packets("") + self.assertEqual(keyframe_pts, []) + self.assertIsNone(max_pts) + + +class TestAnalyzeRecordKeyframes(unittest.IsolatedAsyncioTestCase): + async def test_merges_duration_and_classification(self): + csv = b"0.0,K__\n1.0,___\n6.0,K__\n7.0,___\n" + proc = MagicMock() + proc.communicate = AsyncMock(return_value=(csv, b"")) + ffmpeg = MagicMock() + ffmpeg.ffprobe_path = "/usr/bin/ffprobe" + + with patch( + "frigate.util.services.asyncio.create_subprocess_exec", + AsyncMock(return_value=proc), + ): + result = await analyze_record_keyframes( + ffmpeg, "rtsp://cam/stream", segment_time=10 + ) + + self.assertEqual(result["severity"], "warning") # 6s gap > 4s + self.assertEqual(result["max_gap"], 6.0) + self.assertEqual(result["duration_observed"], 7.0) + + async def test_timeout_returns_unknown(self): + proc = MagicMock() + proc.communicate = AsyncMock(side_effect=asyncio.TimeoutError()) + proc.kill = MagicMock() + ffmpeg = MagicMock() + ffmpeg.ffprobe_path = "/usr/bin/ffprobe" + + with patch( + "frigate.util.services.asyncio.create_subprocess_exec", + AsyncMock(return_value=proc), + ): + result = await analyze_record_keyframes( + ffmpeg, "rtsp://cam/stream", segment_time=10 + ) + + self.assertEqual(result["severity"], "unknown") + proc.kill.assert_called_once() + + +if __name__ == "__main__": + unittest.main() diff --git a/frigate/util/builtin.py b/frigate/util/builtin.py index bd45a4a1f1..c1c9d9d6ea 100644 --- a/frigate/util/builtin.py +++ b/frigate/util/builtin.py @@ -14,13 +14,16 @@ import urllib.parse from collections.abc import Mapping from multiprocessing.managers import ValueProxy from pathlib import Path -from typing import Any, Dict, Optional, Tuple, Union +from typing import TYPE_CHECKING, Any, Dict, Optional, Tuple, Union import numpy as np from ruamel.yaml import YAML from frigate.const import REGEX_HTTP_CAMERA_USER_PASS, REGEX_RTSP_CAMERA_USER_PASS +if TYPE_CHECKING: + from frigate.config import CameraConfig + logger = logging.getLogger(__name__) @@ -132,6 +135,24 @@ def get_ffmpeg_arg_list(arg: Any) -> list: return arg if isinstance(arg, list) else shlex.split(arg) +# all built-in record presets use this segment_time +DEFAULT_RECORD_SEGMENT_TIME = 10 + + +def get_record_segment_time(config: "CameraConfig") -> int: + """Extract -segment_time from the camera's record output args.""" + record_args = get_ffmpeg_arg_list(config.ffmpeg.output_args.record) + + if record_args and record_args[0].startswith("preset"): + return DEFAULT_RECORD_SEGMENT_TIME + + try: + idx = record_args.index("-segment_time") + return int(record_args[idx + 1]) + except (ValueError, IndexError): + return DEFAULT_RECORD_SEGMENT_TIME + + def load_labels( path: Optional[str], encoding="utf-8", prefill=91, indexed: bool | None = None ): diff --git a/frigate/util/services.py b/frigate/util/services.py index 0445053b8a..3938dff884 100644 --- a/frigate/util/services.py +++ b/frigate/util/services.py @@ -879,6 +879,131 @@ def ffprobe_stream(ffmpeg, path: str, detailed: bool = False) -> sp.CompletedPro return result +KEYFRAME_PROBE_WINDOW_SECONDS = 20 +KEYFRAME_GAP_WARNING_SECONDS = 4.0 + + +def parse_keyframe_packets(output: str) -> Tuple[List[float], Optional[float]]: + """Parse ffprobe CSV `pts_time,flags` output. + + Returns the presentation timestamps of keyframes (flags containing "K") + and the maximum timestamp observed across all packets. + """ + keyframe_pts: List[float] = [] + max_pts: Optional[float] = None + + for line in output.splitlines(): + parts = line.split(",") + if len(parts) < 2: + continue + try: + pts = float(parts[0]) + except ValueError: + continue + if max_pts is None or pts > max_pts: + max_pts = pts + if "K" in parts[1]: + keyframe_pts.append(pts) + + return keyframe_pts, max_pts + + +def classify_keyframe_gaps( + keyframe_pts: List[float], segment_time: int +) -> dict[str, Any]: + """Classify keyframe spacing for recording suitability. + + A camera using a smart/+ codec or a long/variable GOP produces large or + irregular gaps between keyframes, which breaks time-based recording + segmentation. Severity: + - "unknown" when fewer than two keyframes were observed + - "error" when the longest gap exceeds the record segment length + - "warning" when the longest gap exceeds the warning threshold + - "ok" otherwise + """ + thresholds = { + "warning": KEYFRAME_GAP_WARNING_SECONDS, + "error": segment_time, + } + + if len(keyframe_pts) < 2: + return { + "keyframe_count": len(keyframe_pts), + "max_gap": None, + "mean_gap": None, + "min_gap": None, + "segment_time": segment_time, + "severity": "unknown", + "thresholds": thresholds, + } + + gaps = [b - a for a, b in zip(keyframe_pts, keyframe_pts[1:])] + max_gap = max(gaps) + + if max_gap > segment_time: + severity = "error" + elif max_gap > KEYFRAME_GAP_WARNING_SECONDS: + severity = "warning" + else: + severity = "ok" + + return { + "keyframe_count": len(keyframe_pts), + "max_gap": round(max_gap, 2), + "mean_gap": round(sum(gaps) / len(gaps), 2), + "min_gap": round(min(gaps), 2), + "segment_time": segment_time, + "severity": severity, + "thresholds": thresholds, + } + + +async def analyze_record_keyframes( + ffmpeg, url: str, segment_time: int, window: int = KEYFRAME_PROBE_WINDOW_SECONDS +) -> dict[str, Any]: + """Probe a stream for ~`window` seconds and classify its keyframe spacing. + + Reads video packet flags via ffprobe to find keyframes, then measures the + gaps between them. On timeout or failure returns an "unknown" result rather + than a false all-clear. + """ + clean_url = escape_special_characters(url) + cmd = [ + ffmpeg.ffprobe_path, + "-v", + "error", + "-select_streams", + "v:0", + "-read_intervals", + f"%+{window}", + "-show_entries", + "packet=pts_time,flags", + "-of", + "csv=p=0", + clean_url, + ] + + try: + proc = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=window + 15) + except asyncio.TimeoutError: + logger.warning("Keyframe probe timed out for record stream") + proc.kill() + return classify_keyframe_gaps([], segment_time) + except OSError as err: + logger.error("Keyframe probe failed: %s", err) + return classify_keyframe_gaps([], segment_time) + + keyframe_pts, max_pts = parse_keyframe_packets(stdout.decode("utf-8", "replace")) + result = classify_keyframe_gaps(keyframe_pts, segment_time) + result["duration_observed"] = round(max_pts, 2) if max_pts is not None else None + return result + + def vainfo_hwaccel(device_name: Optional[str] = None) -> sp.CompletedProcess: """Run vainfo.""" if not device_name: diff --git a/frigate/video/ffmpeg.py b/frigate/video/ffmpeg.py index e77c03b5e5..40b18b7da3 100644 --- a/frigate/video/ffmpeg.py +++ b/frigate/video/ffmpeg.py @@ -24,7 +24,7 @@ from frigate.config.camera.updater import ( ) from frigate.const import PROCESS_PRIORITY_HIGH from frigate.log import LogPipe -from frigate.util.builtin import EventsPerSecond, get_ffmpeg_arg_list +from frigate.util.builtin import EventsPerSecond, get_record_segment_time from frigate.util.ffmpeg import start_or_restart_ffmpeg, stop_ffmpeg from frigate.util.image import ( FrameManager, @@ -34,23 +34,6 @@ from frigate.util.process import FrigateProcess logger = logging.getLogger(__name__) -# all built-in record presets use this segment_time -DEFAULT_RECORD_SEGMENT_TIME = 10 - - -def _get_record_segment_time(config: CameraConfig) -> int: - """Extract -segment_time from the camera's record output args.""" - record_args = get_ffmpeg_arg_list(config.ffmpeg.output_args.record) - - if record_args and record_args[0].startswith("preset"): - return DEFAULT_RECORD_SEGMENT_TIME - - try: - idx = record_args.index("-segment_time") - return int(record_args[idx + 1]) - except (ValueError, IndexError): - return DEFAULT_RECORD_SEGMENT_TIME - def capture_frames( ffmpeg_process: sp.Popen[Any], @@ -185,7 +168,7 @@ class CameraWatchdog(threading.Thread): # `valid` segments are published with the segment's start time, so the # gap between consecutive publishes can reach 2 * segment_time. Pad the # staleness threshold so it's never tighter than that worst case. - segment_time = _get_record_segment_time(self.config) + segment_time = get_record_segment_time(self.config) self.record_stale_threshold = max(120, 2 * segment_time + 30) # Stall tracking (based on last processed frame) diff --git a/web/public/locales/en/views/system.json b/web/public/locales/en/views/system.json index b824e0749c..9f387a7f30 100644 --- a/web/public/locales/en/views/system.json +++ b/web/public/locales/en/views/system.json @@ -174,6 +174,21 @@ "error": "Error: {{error}}", "tips": { "title": "Camera Probe Info" + }, + "keyframes": { + "title": "Keyframe analysis", + "analyzing": "Analyzing keyframes... {{seconds}} seconds remaining", + "stillAnalyzing": "Still analyzing keyframes...", + "recordStream": "Record stream:", + "keyframeCount": "Keyframes observed:", + "observedDuration": "Observed duration:", + "gap": "Keyframe gap (min / avg / max):", + "segmentLength": "Recording segment length:", + "ok": "Keyframes every ~{{seconds}}s, good for recording and playback.", + "warning": "Sparse or variable keyframes (longest gap ~{{seconds}}s), likely a smart codec (H.264+/H.265+), this is not recommended.", + "error": "Keyframe gap (~{{seconds}}s) exceeds the recording segment length ({{segmentTime}}s). Some segments may have no keyframe, which breaks playback. Disable the smart/+ codec on the camera or shorten its keyframe interval.", + "unknown": "Couldn't determine keyframe spacing.", + "recordDisabled": "Recording is disabled for this camera." } }, "framesAndDetections": "Frames / Detections", diff --git a/web/src/components/overlay/CameraInfoDialog.tsx b/web/src/components/overlay/CameraInfoDialog.tsx index fce1f6fd02..14755a2015 100644 --- a/web/src/components/overlay/CameraInfoDialog.tsx +++ b/web/src/components/overlay/CameraInfoDialog.tsx @@ -7,7 +7,8 @@ import { DialogTitle, } from "../ui/dialog"; import ActivityIndicator from "../indicators/activity-indicator"; -import { Ffprobe } from "@/types/stats"; +import KeyframeAnalysisSection from "./KeyframeAnalysisSection"; +import { Ffprobe, KeyframeAnalysis } from "@/types/stats"; import { Button } from "../ui/button"; import copy from "copy-to-clipboard"; import { CameraConfig } from "@/types/frigateConfig"; @@ -30,6 +31,7 @@ export default function CameraInfoDialog({ }: CameraInfoDialogProps) { const { t } = useTranslation(["views/system"]); const [ffprobeInfo, setFfprobeInfo] = useState(); + const [keyframeInfo, setKeyframeInfo] = useState(); useEffect(() => { axios @@ -67,7 +69,12 @@ export default function CameraInfoDialog({ }, []); const onCopyFfprobe = async () => { - copy(JSON.stringify(ffprobeInfo)); + copy( + JSON.stringify({ + ffprobe: ffprobeInfo, + keyframe_analysis: keyframeInfo, + }), + ); toast.success(t("cameras.toast.success.copyToClipboard")); }; @@ -96,7 +103,7 @@ export default function CameraInfoDialog({ cameras.info.streamDataFromFFPROBE -
+
{ffprobeInfo ? (
{ffprobeInfo.map((stream, idx) => ( @@ -184,6 +191,10 @@ export default function CameraInfoDialog({ )}
))} +
) : (
diff --git a/web/src/components/overlay/KeyframeAnalysisSection.tsx b/web/src/components/overlay/KeyframeAnalysisSection.tsx new file mode 100644 index 0000000000..299c2468e4 --- /dev/null +++ b/web/src/components/overlay/KeyframeAnalysisSection.tsx @@ -0,0 +1,193 @@ +import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import axios from "axios"; +import { FaCircleCheck, FaTriangleExclamation } from "react-icons/fa6"; +import { LuX } from "react-icons/lu"; +import ActivityIndicator from "../indicators/activity-indicator"; +import { KeyframeAnalysis } from "@/types/stats"; + +const PROBE_WINDOW_SECONDS = 20; + +type KeyframeAnalysisSectionProps = { + cameraName: string; + onResult?: (analysis: KeyframeAnalysis) => void; +}; + +export default function KeyframeAnalysisSection({ + cameraName, + onResult, +}: KeyframeAnalysisSectionProps) { + const { t } = useTranslation(["views/system"]); + const [analysis, setAnalysis] = useState(); + const [failed, setFailed] = useState(false); + const [secondsRemaining, setSecondsRemaining] = + useState(PROBE_WINDOW_SECONDS); + + // fire the probe once on mount + useEffect(() => { + let active = true; + axios + .get("keyframe_analysis", { params: { camera: cameraName } }) + .then((res) => { + if (active) { + setAnalysis(res.data); + onResult?.(res.data); + } + }) + .catch(() => { + if (active) { + setFailed(true); + } + }); + return () => { + active = false; + }; + // re-probing only depends on the camera; onResult is a stable setter + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [cameraName]); + + // countdown while waiting for the probe to return + useEffect(() => { + if (analysis || failed) { + return; + } + const interval = setInterval(() => { + setSecondsRemaining((s) => (s > 0 ? s - 1 : 0)); + }, 1000); + return () => clearInterval(interval); + }, [analysis, failed]); + + const content = useMemo(() => { + if (failed) { + return {t("cameras.info.keyframes.unknown")}; + } + + if (!analysis) { + return ( +
+ + + {secondsRemaining > 0 + ? t("cameras.info.keyframes.analyzing", { + seconds: secondsRemaining, + }) + : t("cameras.info.keyframes.stillAnalyzing")} + +
+ ); + } + + let summary; + switch (analysis.severity) { + case "ok": + summary = ( + + {t("cameras.info.keyframes.ok", { seconds: analysis.mean_gap })} + + ); + break; + case "warning": + summary = ( + + {t("cameras.info.keyframes.warning", { seconds: analysis.max_gap })} + + ); + break; + case "error": + summary = ( + + {t("cameras.info.keyframes.error", { + seconds: analysis.max_gap, + segmentTime: analysis.segment_time, + })} + + ); + break; + case "record_disabled": + summary = ( + {t("cameras.info.keyframes.recordDisabled")} + ); + break; + default: + summary = ( + {t("cameras.info.keyframes.unknown")} + ); + } + + // gap statistics are only meaningful once at least two keyframes were seen + const hasStats = analysis.max_gap != null; + const hasDetails = hasStats || analysis.stream_index != null; + + return ( +
+ {analysis.stream_index != null && ( +
+ {t("cameras.info.keyframes.recordStream")}{" "} + + {t("cameras.info.stream", { idx: analysis.stream_index + 1 })} + +
+ )} + {hasStats && ( +
+
+ {t("cameras.info.keyframes.keyframeCount")}{" "} + {analysis.keyframe_count} +
+
+ {t("cameras.info.keyframes.observedDuration")}{" "} + + {analysis.duration_observed}s + +
+
+ {t("cameras.info.keyframes.gap")}{" "} + + {analysis.min_gap}s / {analysis.mean_gap}s / {analysis.max_gap}s + +
+
+ {t("cameras.info.keyframes.segmentLength")}{" "} + {analysis.segment_time}s +
+
+ )} +
{summary}
+
+ ); + }, [analysis, failed, secondsRemaining, t]); + + return ( +
+
+ {t("cameras.info.keyframes.title")} +
+
{content}
+
+ ); +} + +type RowProps = { + icon: "ok" | "warning" | "error" | "unknown"; + children: React.ReactNode; +}; + +function Row({ icon, children }: RowProps) { + return ( +
+ {icon === "ok" && ( + + )} + {icon === "warning" && ( + + )} + {icon === "error" && ( + + )} + {icon === "unknown" && ( + + )} + {children} +
+ ); +} diff --git a/web/src/types/stats.ts b/web/src/types/stats.ts index 0ebac9ebd3..151e705004 100644 --- a/web/src/types/stats.ts +++ b/web/src/types/stats.ts @@ -135,3 +135,22 @@ export type Ffprobe = { }[]; }; }; + +export type KeyframeSeverity = + | "ok" + | "warning" + | "error" + | "unknown" + | "record_disabled"; + +export type KeyframeAnalysis = { + severity: KeyframeSeverity; + stream_index?: number; + keyframe_count?: number; + max_gap?: number | null; + mean_gap?: number | null; + min_gap?: number | null; + duration_observed?: number | null; + segment_time?: number; + thresholds?: { warning: number; error: number }; +}; From d7ad3ba6992651bfb427bd1f031c11f2771bbb7a Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Fri, 12 Jun 2026 06:48:43 -0600 Subject: [PATCH 12/92] Fix chat tool calling and prompt breaking (#23457) * Implement tool call history keeping * Refactor to match single message implementation * Simplify data representation * Cleanup chat page rendering * Include system message to not break cache * Formatting * Update tests and update .gitignore --- frigate/api/chat.py | 189 ++++++++------- frigate/api/defs/request/chat_body.py | 20 +- frigate/api/defs/response/chat_response.py | 9 + web/.gitignore | 4 + web/e2e/specs/chat.spec.ts | 57 ++++- web/src/pages/Chat.tsx | 267 ++++++++++++--------- web/src/types/chat.ts | 29 ++- web/src/utils/chatUtil.ts | 150 +++++------- 8 files changed, 430 insertions(+), 295 deletions(-) diff --git a/frigate/api/chat.py b/frigate/api/chat.py index 4e6bdbd3b4..f523d9991c 100644 --- a/frigate/api/chat.py +++ b/frigate/api/chat.py @@ -7,7 +7,7 @@ import operator import time from datetime import datetime from functools import reduce -from typing import Any, Dict, List, Optional +from typing import Any, Optional import cv2 from fastapi import APIRouter, Body, Depends, HTTPException, Request @@ -59,7 +59,7 @@ class ToolExecuteRequest(BaseModel): """Request model for tool execution.""" tool_name: str - arguments: Dict[str, Any] + arguments: dict[str, Any] class VLMMonitorRequest(BaseModel): @@ -68,8 +68,8 @@ class VLMMonitorRequest(BaseModel): camera: str condition: str max_duration_minutes: int = 60 - labels: List[str] = [] - zones: List[str] = [] + labels: list[str] = [] + zones: list[str] = [] @router.get( @@ -91,10 +91,10 @@ def get_tools(request: Request) -> JSONResponse: def _resolve_zones( - zones: List[str], + zones: list[str], config: FrigateConfig, - target_cameras: List[str], -) -> List[str]: + target_cameras: list[str], +) -> list[str]: """Map zone names to their canonical config keys, case-insensitively. LLMs frequently echo a user's casing ("Front Yard") instead of the @@ -107,7 +107,7 @@ def _resolve_zones( if not zones: return zones - lookup: Dict[str, str] = {} + lookup: dict[str, str] = {} for camera_id in target_cameras: camera_config = config.cameras.get(camera_id) if camera_config is None: @@ -120,8 +120,8 @@ def _resolve_zones( async def _execute_search_objects( request: Request, - arguments: Dict[str, Any], - allowed_cameras: List[str], + arguments: dict[str, Any], + allowed_cameras: list[str], ) -> JSONResponse: """ Execute the search_objects tool. @@ -213,8 +213,8 @@ async def _execute_search_objects( async def _execute_search_objects_semantic( request: Request, - arguments: Dict[str, Any], - allowed_cameras: List[str], + arguments: dict[str, Any], + allowed_cameras: list[str], semantic_query: str, ) -> JSONResponse: """Search objects via fused thumbnail + description embeddings. @@ -263,8 +263,8 @@ async def _execute_search_objects_semantic( limit = int(arguments.get("limit", 25)) limit = max(1, min(limit, 100)) - visual_distances: Dict[str, float] = {} - description_distances: Dict[str, float] = {} + visual_distances: dict[str, float] = {} + description_distances: dict[str, float] = {} try: rows = context.search_thumbnail(semantic_query) visual_distances = {row[0]: row[1] for row in rows} @@ -305,7 +305,7 @@ async def _execute_search_objects_semantic( eligible = {e.id: e for e in Event.select().where(reduce(operator.and_, clauses))} - scored: List[tuple[str, float]] = [] + scored: list[tuple[str, float]] = [] for eid in eligible: v_score = ( distance_to_score(visual_distances[eid], context.thumb_stats) @@ -331,9 +331,9 @@ async def _execute_search_objects_semantic( async def _execute_find_similar_objects( request: Request, - arguments: Dict[str, Any], - allowed_cameras: List[str], -) -> Dict[str, Any]: + arguments: dict[str, Any], + allowed_cameras: list[str], +) -> dict[str, Any]: """Execute the find_similar_objects tool. Returns a plain dict (not JSONResponse) so the chat loop can embed it @@ -403,8 +403,8 @@ async def _execute_find_similar_objects( # version (see frigate/embeddings/__init__.py). Mirror the pattern used by # frigate/api/event.py events_search: fetch top-k globally, then intersect # with the structured filters via Peewee. - visual_distances: Dict[str, float] = {} - description_distances: Dict[str, float] = {} + visual_distances: dict[str, float] = {} + description_distances: dict[str, float] = {} try: if similarity_mode in ("visual", "fused"): @@ -462,7 +462,7 @@ async def _execute_find_similar_objects( eligible = {e.id: e for e in Event.select().where(reduce(operator.and_, clauses))} # 6. Fuse and rank. - scored: List[tuple[str, float]] = [] + scored: list[tuple[str, float]] = [] for eid in eligible: v_score = ( distance_to_score(visual_distances[eid], context.thumb_stats) @@ -503,7 +503,7 @@ async def _execute_find_similar_objects( async def execute_tool( request: Request, body: ToolExecuteRequest = Body(...), - allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter), + allowed_cameras: list[str] = Depends(get_allowed_cameras_for_filter), ) -> JSONResponse: """ Execute a tool function call. @@ -545,8 +545,8 @@ async def execute_tool( async def _execute_get_live_context( request: Request, camera: str, - allowed_cameras: List[str], -) -> Dict[str, Any]: + allowed_cameras: list[str], +) -> dict[str, Any]: # Reject wildcards explicitly so models retry with a real camera name # instead of silently fanning out across every camera. if camera in ("*", "all"): @@ -593,7 +593,7 @@ async def _execute_get_live_context( "stationary": obj_dict.get("stationary", False), } - result: Dict[str, Any] = { + result: dict[str, Any] = { "camera": camera, "timestamp": frame_time, "detections": list(tracked_objects_dict.values()), @@ -620,7 +620,7 @@ async def _execute_get_live_context( async def _get_live_frame_image_url( request: Request, camera: str, - allowed_cameras: List[str], + allowed_cameras: list[str], ) -> Optional[str]: """ Fetch the current live frame for a camera as a base64 data URL. @@ -659,8 +659,8 @@ async def _get_live_frame_image_url( async def _execute_set_camera_state( request: Request, - arguments: Dict[str, Any], -) -> Dict[str, Any]: + arguments: dict[str, Any], +) -> dict[str, Any]: role = request.headers.get("remote-role", "") if "admin" not in [r.strip() for r in role.split(",")]: return {"error": "Admin privileges required to change camera settings."} @@ -699,10 +699,10 @@ async def _execute_set_camera_state( async def _execute_tool_internal( tool_name: str, - arguments: Dict[str, Any], + arguments: dict[str, Any], request: Request, - allowed_cameras: List[str], -) -> Dict[str, Any]: + allowed_cameras: list[str], +) -> dict[str, Any]: """ Internal helper to execute a tool and return the result as a dict. @@ -763,8 +763,8 @@ async def _execute_tool_internal( async def _execute_start_camera_watch( request: Request, - arguments: Dict[str, Any], -) -> Dict[str, Any]: + arguments: dict[str, Any], +) -> dict[str, Any]: camera = arguments.get("camera", "").strip() condition = arguments.get("condition", "").strip() max_duration_minutes = int(arguments.get("max_duration_minutes", 60)) @@ -814,14 +814,14 @@ async def _execute_start_camera_watch( } -def _execute_stop_camera_watch() -> Dict[str, Any]: +def _execute_stop_camera_watch() -> dict[str, Any]: cancelled = stop_vlm_watch_job() if cancelled: return {"success": True, "message": "Watch job cancelled."} return {"success": False, "message": "No active watch job to cancel."} -def _execute_get_profile_status(request: Request) -> Dict[str, Any]: +def _execute_get_profile_status(request: Request) -> dict[str, Any]: """Return profile status including active profile and activation timestamps.""" profile_manager = getattr(request.app, "profile_manager", None) if profile_manager is None: @@ -846,9 +846,9 @@ def _execute_get_profile_status(request: Request) -> Dict[str, Any]: def _execute_get_recap( - arguments: Dict[str, Any], - allowed_cameras: List[str], -) -> Dict[str, Any]: + arguments: dict[str, Any], + allowed_cameras: list[str], +) -> dict[str, Any]: """Fetch review segments with GenAI metadata for a time period.""" from functools import reduce @@ -909,7 +909,7 @@ def _execute_get_recap( .iterator() ) - events: List[Dict[str, Any]] = [] + events: list[dict[str, Any]] = [] for row in rows: data = row.get("data") or {} @@ -920,7 +920,7 @@ def _execute_get_recap( data = {} camera = row["camera"] - event: Dict[str, Any] = { + event: dict[str, Any] = { "camera": camera.replace("_", " ").title(), "severity": row.get("severity", "detection"), } @@ -984,10 +984,10 @@ def _execute_get_recap( async def _execute_pending_tools( - pending_tool_calls: List[Dict[str, Any]], + pending_tool_calls: list[dict[str, Any]], request: Request, - allowed_cameras: List[str], -) -> tuple[List[ToolCall], List[Dict[str, Any]], List[Dict[str, Any]]]: + allowed_cameras: list[str], +) -> tuple[list[ToolCall], list[dict[str, Any]], list[dict[str, Any]]]: """ Execute a list of tool calls. @@ -996,9 +996,9 @@ async def _execute_pending_tools( tool result dicts for conversation, extra messages to inject after tool results — e.g. user messages with images) """ - tool_calls_out: List[ToolCall] = [] - tool_results: List[Dict[str, Any]] = [] - extra_messages: List[Dict[str, Any]] = [] + tool_calls_out: list[ToolCall] = [] + tool_results: list[dict[str, Any]] = [] + extra_messages: list[dict[str, Any]] = [] for tool_call in pending_tool_calls: tool_name = tool_call["name"] tool_args = tool_call.get("arguments") or {} @@ -1106,7 +1106,7 @@ async def _execute_pending_tools( async def chat_completion( request: Request, body: ChatCompletionRequest = Body(...), - allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter), + allowed_cameras: list[str] = Depends(get_allowed_cameras_for_filter), ): """ Chat completion endpoint with tool calling support. @@ -1138,19 +1138,23 @@ async def chat_completion( ) conversation = [] - system_prompt = build_chat_system_prompt( - config=config, - allowed_cameras=allowed_cameras, - semantic_search_enabled=semantic_search_enabled, - attribute_classifications=attribute_classifications, - ) - - conversation.append( - { - "role": "system", - "content": system_prompt, - } - ) + # Build the system message only when the client hasn't already pinned one. + # The first turn has no system message; we generate it (with the current + # timestamp) and return the whole chain so the client persists it. Later + # turns send it back verbatim, freezing the timestamp so the prompt prefix + # stays byte-identical and the model server's prompt cache keeps hitting. + if not body.messages or body.messages[0].role != "system": + conversation.append( + { + "role": "system", + "content": build_chat_system_prompt( + config=config, + allowed_cameras=allowed_cameras, + semantic_search_enabled=semantic_search_enabled, + attribute_classifications=attribute_classifications, + ), + } + ) for msg in body.messages: msg_dict = { @@ -1161,11 +1165,13 @@ async def chat_completion( msg_dict["tool_call_id"] = msg.tool_call_id if msg.name: msg_dict["name"] = msg.name + if msg.tool_calls is not None: + msg_dict["tool_calls"] = msg.tool_calls conversation.append(msg_dict) tool_iterations = 0 - tool_calls: List[ToolCall] = [] + tool_calls: list[ToolCall] = [] max_iterations = body.max_tool_iterations logger.debug( @@ -1175,11 +1181,20 @@ async def chat_completion( # True LLM streaming when client supports it and stream requested if body.stream and hasattr(genai_client, "chat_with_tools_stream"): - stream_tool_calls: List[ToolCall] = [] stream_iterations = 0 async def stream_body_llm(): - nonlocal conversation, stream_tool_calls, stream_iterations + nonlocal conversation, stream_iterations + + def _emit_chain(extra: Optional[list[dict[str, Any]]] = None): + # Return the full conversation (including the system message) so + # the client persists and replays it verbatim next turn. + chain = conversation + (extra or []) + return ( + json.dumps({"type": "messages", "messages": chain}).encode("utf-8") + + b"\n" + ) + while stream_iterations < max_iterations: if await request.is_disconnected(): logger.debug("Client disconnected, stopping chat stream") @@ -1244,31 +1259,33 @@ async def chat_completion( ) return ( - executed_calls, + _executed_calls, tool_results, extra_msgs, ) = await _execute_pending_tools( pending, request, allowed_cameras ) - stream_tool_calls.extend(executed_calls) conversation.extend(tool_results) conversation.extend(extra_msgs) - yield ( - json.dumps( - { - "type": "tool_calls", - "tool_calls": [ - tc.model_dump() for tc in stream_tool_calls - ], - } - ).encode("utf-8") - + b"\n" - ) + # Emit the running chain so the client can render tool + # calls live and replay them verbatim next turn. + yield _emit_chain() break else: + # Streaming never appends the final assistant message + # to the conversation, so add it to the chain. + yield _emit_chain( + extra=[ + { + "role": "assistant", + "content": msg.get("content"), + } + ] + ) yield (json.dumps({"type": "done"}).encode("utf-8") + b"\n") return else: + yield _emit_chain() yield json.dumps({"type": "done"}).encode("utf-8") + b"\n" return StreamingResponse( @@ -1315,19 +1332,15 @@ async def chat_completion( if body.stream: final_reasoning = response.get("reasoning") + chain = list(conversation) + async def stream_body() -> Any: - if tool_calls: - yield ( - json.dumps( - { - "type": "tool_calls", - "tool_calls": [ - tc.model_dump() for tc in tool_calls - ], - } - ).encode("utf-8") - + b"\n" + yield ( + json.dumps({"type": "messages", "messages": chain}).encode( + "utf-8" ) + + b"\n" + ) # Emit the full reasoning trace up front when the # underlying client did not stream it if final_reasoning: @@ -1363,6 +1376,7 @@ async def chat_completion( finish_reason=response.get("finish_reason", "stop"), tool_iterations=tool_iterations, tool_calls=tool_calls, + messages=list(conversation), ).model_dump(), ) @@ -1395,6 +1409,7 @@ async def chat_completion( finish_reason="length", tool_iterations=tool_iterations, tool_calls=tool_calls, + messages=list(conversation), ).model_dump(), ) diff --git a/frigate/api/defs/request/chat_body.py b/frigate/api/defs/request/chat_body.py index 228781c80b..04b168b9fa 100644 --- a/frigate/api/defs/request/chat_body.py +++ b/frigate/api/defs/request/chat_body.py @@ -1,6 +1,6 @@ """Chat API request models.""" -from typing import Optional +from typing import Any, Optional from pydantic import BaseModel, Field @@ -11,13 +11,29 @@ class ChatMessage(BaseModel): role: str = Field( description="Message role: 'user', 'assistant', 'system', or 'tool'" ) - content: str = Field(description="Message content") + content: Optional[Any] = Field( + default=None, + description=( + "Message content. Usually a string, but may be a multimodal content " + "list (e.g. text + image_url) or null for assistant turns that only " + "request tool calls." + ), + ) tool_call_id: Optional[str] = Field( default=None, description="For tool messages, the ID of the tool call" ) name: Optional[str] = Field( default=None, description="For tool messages, the tool name" ) + tool_calls: Optional[list[dict[str, Any]]] = Field( + default=None, + description=( + "For assistant messages replayed from prior turns, the OpenAI-format " + "tool calls the model previously requested. Replaying these verbatim " + "keeps the conversation prefix byte-for-byte identical so the model " + "server's prompt cache hits on follow-up turns." + ), + ) class ChatCompletionRequest(BaseModel): diff --git a/frigate/api/defs/response/chat_response.py b/frigate/api/defs/response/chat_response.py index c2b3e6b1f2..59c8549e73 100644 --- a/frigate/api/defs/response/chat_response.py +++ b/frigate/api/defs/response/chat_response.py @@ -56,3 +56,12 @@ class ChatCompletionResponse(BaseModel): default_factory=list, description="List of tool calls that were executed during this completion", ) + messages: list[dict[str, Any]] = Field( + default_factory=list, + description=( + "The full conversation chain, including the system message. Persist " + "and replay this verbatim on the next request so the prompt prefix " + "stays byte-identical and the model server's prompt cache keeps " + "hitting." + ), + ) diff --git a/web/.gitignore b/web/.gitignore index 1cac5597ea..ca98c7b96a 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -12,6 +12,10 @@ dist dist-ssr *.local +# Playwright +playwright-report +test-results + # Editor directories and files .vscode/* !.vscode/extensions.json diff --git a/web/e2e/specs/chat.spec.ts b/web/e2e/specs/chat.spec.ts index fcd10782c1..cab49828ab 100644 --- a/web/e2e/specs/chat.spec.ts +++ b/web/e2e/specs/chat.spec.ts @@ -92,6 +92,15 @@ test.describe("Chat — streaming @medium", () => { await installChatStreamOverride(frigateApp, [ { type: "content", delta: "Hel" }, { type: "content", delta: "lo" }, + { + type: "messages", + messages: [ + { role: "system", content: "sys" }, + { role: "user", content: "hello chat" }, + { role: "assistant", content: "Hello" }, + ], + }, + { type: "done" }, ]); await frigateApp.goto("/chat"); const input = frigateApp.page.getByPlaceholder(/ask/i); @@ -137,6 +146,15 @@ test.describe("Chat — streaming @medium", () => { { type: "content", delta: "Hel" }, { type: "content", delta: "lo, " }, { type: "content", delta: "world!" }, + { + type: "messages", + messages: [ + { role: "system", content: "sys" }, + { role: "user", content: "greet me" }, + { role: "assistant", content: "Hello, world!" }, + ], + }, + { type: "done" }, ], { chunkDelayMs: 50 }, ); @@ -151,19 +169,39 @@ test.describe("Chat — streaming @medium", () => { }); }); - test("tool_calls chunks render a ToolCallsGroup", async ({ frigateApp }) => { - await installChatStreamOverride(frigateApp, [ + test("tool calls in the chain render a ToolCallsGroup", async ({ + frigateApp, + }) => { + const toolTurn = [ + { role: "system", content: "sys" }, + { role: "user", content: "find people" }, { - type: "tool_calls", + role: "assistant", + content: null, tool_calls: [ { id: "call_1", - name: "search_objects", - arguments: { label: "person" }, + type: "function", + function: { + name: "search_objects", + arguments: '{"label":"person"}', + }, }, ], }, + { role: "tool", tool_call_id: "call_1", content: "[]" }, + ]; + await installChatStreamOverride(frigateApp, [ + { type: "messages", messages: toolTurn }, { type: "content", delta: "Searching for people." }, + { + type: "messages", + messages: [ + ...toolTurn, + { role: "assistant", content: "Searching for people." }, + ], + }, + { type: "done" }, ]); await frigateApp.goto("/chat"); const input = frigateApp.page.getByPlaceholder(/ask/i); @@ -253,6 +291,15 @@ test.describe("Chat — attachment chip @medium", () => { // We use the stream override so the first message completes quickly. await installChatStreamOverride(frigateApp, [ { type: "content", delta: "Done." }, + { + type: "messages", + messages: [ + { role: "system", content: "sys" }, + { role: "user", content: "hello" }, + { role: "assistant", content: "Done." }, + ], + }, + { type: "done" }, ]); await frigateApp.goto("/chat"); diff --git a/web/src/pages/Chat.tsx b/web/src/pages/Chat.tsx index 7103a189d1..bd92097873 100644 --- a/web/src/pages/Chat.tsx +++ b/web/src/pages/Chat.tsx @@ -13,6 +13,7 @@ import { ChatComposer } from "@/components/chat/ChatComposer"; import ChatSettings from "@/components/chat/ChatSettings"; import type { ChatMessage, + ChatStats, GenAIModelsResponse, ShowStatsMode, } from "@/types/chat"; @@ -22,12 +23,28 @@ import { getFindSimilarObjectsFromToolCalls, prependAttachment, streamChatCompletion, + toolCallsForMessage, + toolResponsesById, } from "@/utils/chatUtil"; +type StreamingTurn = { + content: string; + reasoning: string; + chain: ChatMessage[]; + stats?: ChatStats; +}; + +const hasText = (content: unknown): content is string => + typeof content === "string" && content.trim().length > 0; + +const toWire = (messages: ChatMessage[]): ChatMessage[] => + messages.map(({ reasoning: _r, stats: _s, ...rest }) => rest); + export default function ChatPage() { const { t } = useTranslation(["views/chat"]); const [input, setInput] = useState(""); const [messages, setMessages] = useState([]); + const [streaming, setStreaming] = useState(null); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const [attachedEventId, setAttachedEventId] = useState(null); @@ -72,28 +89,19 @@ export default function ChatPage() { if (isNearBottom) { el.scrollTo({ top: el.scrollHeight, behavior: "smooth" }); } - }, [messages, autoScroll]); + }, [messages, streaming, autoScroll]); const submitConversation = useCallback( async (messagesToSend: ChatMessage[]) => { if (isLoading) return; const last = messagesToSend[messagesToSend.length - 1]; - if (!last || last.role !== "user" || !last.content.trim()) return; + if (!last || last.role !== "user" || !hasText(last.content)) return; setError(null); - const assistantPlaceholder: ChatMessage = { - role: "assistant", - content: "", - toolCalls: undefined, - }; - setMessages([...messagesToSend, assistantPlaceholder]); + setMessages(messagesToSend); + setStreaming({ content: "", reasoning: "", chain: [] }); setIsLoading(true); - const apiMessages = messagesToSend.map((m) => ({ - role: m.role, - content: m.content, - })); - const baseURL = axios.defaults.baseURL ?? ""; const url = `${baseURL}chat/completion`; const headers: Record = { @@ -104,16 +112,50 @@ export default function ChatPage() { const controller = new AbortController(); abortRef.current = controller; + let chain: ChatMessage[] = []; + let stats: ChatStats | undefined; + let reasoning = ""; + let hadError = false; + await streamChatCompletion( url, headers, - apiMessages, + toWire(messagesToSend), { - updateMessages: (updater) => setMessages(updater), - onError: (message) => setError(message), + onContentDelta: (delta) => + setStreaming((s) => (s ? { ...s, content: s.content + delta } : s)), + onReasoningDelta: (delta) => { + reasoning += delta; + setStreaming((s) => + s ? { ...s, reasoning: s.reasoning + delta } : s, + ); + }, + onChain: (fullChain) => { + chain = fullChain; + setStreaming((s) => (s ? { ...s, chain: fullChain } : s)); + }, + onStats: (s) => { + stats = s; + setStreaming((cur) => (cur ? { ...cur, stats: s } : cur)); + }, + onError: (message) => { + hadError = true; + setError(message); + }, onDone: () => { abortRef.current = null; setIsLoading(false); + setStreaming(null); + const lastMsg = chain[chain.length - 1]; + if (!hadError && lastMsg?.role === "assistant") { + setMessages( + chain.map((m, i) => + i === chain.length - 1 + ? { ...m, reasoning: reasoning || undefined, stats } + : m, + ), + ); + } }, defaultErrorMessage: t("error"), }, @@ -125,12 +167,14 @@ export default function ChatPage() { ); const recentEventIds = useMemo(() => { + const responses = toolResponsesById(messages); for (let i = messages.length - 1; i >= 0; i--) { const msg = messages[i]; - if (msg.role !== "assistant" || !msg.toolCalls) continue; - const similar = getFindSimilarObjectsFromToolCalls(msg.toolCalls); + if (msg.role !== "assistant" || !msg.tool_calls?.length) continue; + const calls = toolCallsForMessage(msg, responses); + const similar = getFindSimilarObjectsFromToolCalls(calls); if (similar) return similar.results.map((e) => e.id); - const events = getEventIdsFromSearchObjectsToolCalls(msg.toolCalls); + const events = getEventIdsFromSearchObjectsToolCalls(calls); if (events.length > 0) return events.map((e) => e.id); } return []; @@ -154,12 +198,14 @@ export default function ChatPage() { abortRef.current?.abort(); abortRef.current = null; setIsLoading(false); + setStreaming(null); }, []); const startNewChat = useCallback(() => { abortRef.current?.abort(); abortRef.current = null; setIsLoading(false); + setStreaming(null); setMessages([]); setInput(""); setAttachedEventId(null); @@ -181,7 +227,83 @@ export default function ChatPage() { setAttachedEventId(null); }, []); - const hasStarted = messages.length > 0; + const hasStarted = messages.length > 0 || streaming != null; + + // While streaming, the backend's in-flight chain is the source of truth; + // otherwise the committed conversation is. + const renderList = + streaming && streaming.chain.length ? streaming.chain : messages; + const responses = toolResponsesById(renderList); + const renderTail = renderList[renderList.length - 1]; + const finalShown = + renderTail?.role === "assistant" && hasText(renderTail.content); + + const renderMessage = (msg: ChatMessage, i: number) => { + if (msg.role === "system" || msg.role === "tool") return null; + + if (msg.role === "user") { + if (!hasText(msg.content)) return null; + return ( +
+ +
+ ); + } + + const calls = toolCallsForMessage(msg, responses); + const contentText = hasText(msg.content) ? msg.content : ""; + const similar = getFindSimilarObjectsFromToolCalls(calls); + const events = similar ? [] : getEventIdsFromSearchObjectsToolCalls(calls); + + return ( +
+ {calls.length > 0 && } + {hasText(msg.reasoning) && ( + + )} + {contentText && ( + + )} + {similar ? ( + + ) : ( + + )} +
+ ); + }; + + const processingDots = ( +
+ + + +
+ ); return (
@@ -212,102 +334,31 @@ export default function ChatPage() {
{hasStarted ? (
- {messages.map((msg, i) => { - const isLastAssistant = - i === messages.length - 1 && msg.role === "assistant"; - const isComplete = - msg.role === "user" || !isLoading || !isLastAssistant; - const hasToolCalls = - msg.toolCalls && msg.toolCalls.length > 0; - const hasContent = !!msg.content?.trim(); - const hasReasoning = !!msg.reasoning?.trim(); - const showProcessing = - isLastAssistant && - isLoading && - !hasContent && - !hasReasoning; - - // Hide empty placeholder only when there are no tool calls - // and no reasoning streaming yet - if ( - isLastAssistant && - isLoading && - !hasContent && - !hasToolCalls && - !hasReasoning - ) - return ( -
- - - -
- ); - - return ( -
- {msg.role === "assistant" && hasToolCalls && ( - - )} - {msg.role === "assistant" && hasReasoning && ( + {renderList.map((msg, i) => renderMessage(msg, i))} + {streaming && + !finalShown && + (streaming.content || streaming.reasoning ? ( +
+ {hasText(streaming.reasoning) && ( )} - {showProcessing ? ( -
- - - -
- ) : msg.role === "assistant" && - !hasContent && - hasReasoning && - !isComplete ? null : ( + {streaming.content && ( )} - {msg.role === "assistant" && - isComplete && - (() => { - const similar = getFindSimilarObjectsFromToolCalls( - msg.toolCalls, - ); - if (similar) { - return ( - - ); - } - const events = getEventIdsFromSearchObjectsToolCalls( - msg.toolCalls, - ); - return ( - - ); - })()}
- ); - })} + ) : ( + processingDots + ))} {error && (

; response?: string; }; -export type ChatMessage = { - role: "user" | "assistant"; - content: string; - reasoning?: string; - toolCalls?: ToolCall[]; - stats?: ChatStats; -}; - export type StartingRequest = { label: string; prompt: string; diff --git a/web/src/utils/chatUtil.ts b/web/src/utils/chatUtil.ts index 73e5c213b6..b7aeb8088d 100644 --- a/web/src/utils/chatUtil.ts +++ b/web/src/utils/chatUtil.ts @@ -1,16 +1,20 @@ import type { ChatMessage, ChatStats, ToolCall } from "@/types/chat"; export type StreamChatCallbacks = { - /** Update the messages array (e.g. pass to setState). */ - updateMessages: (updater: (prev: ChatMessage[]) => ChatMessage[]) => void; + /** Streamed delta of the assistant's final answer text. */ + onContentDelta: (delta: string) => void; + /** Streamed delta of the assistant's reasoning trace. */ + onReasoningDelta: (delta: string) => void; + /** The full conversation chain so far (system message, history, this turn's + * tool-call turns, tool results, and — on the final emission — the final + * assistant message). */ + onChain: (chain: ChatMessage[]) => void; + /** Token/timing stats for the turn. */ + onStats: (stats: ChatStats) => void; /** Called when the stream sends an error or fetch fails. */ onError: (message: string) => void; /** Called when the stream finishes (success or error). */ onDone: () => void; - /** Called when the stream emits token/timing stats. The stats are also - * attached to the last assistant message in updateMessages, so consumers - * can usually rely on the message itself rather than wiring this up. */ - onStats?: (stats: ChatStats) => void; /** Message used when fetch throws and no server error is available. */ defaultErrorMessage?: string; }; @@ -25,7 +29,7 @@ type StatsChunk = { type StreamChunk = | { type: "error"; error: string } - | { type: "tool_calls"; tool_calls: ToolCall[] } + | { type: "messages"; messages: ChatMessage[] } | { type: "content"; delta: string } | { type: "reasoning"; delta: string } | StatsChunk; @@ -41,16 +45,18 @@ export type StreamChatOptions = { export async function streamChatCompletion( url: string, headers: Record, - apiMessages: { role: string; content: string }[], + apiMessages: ChatMessage[], callbacks: StreamChatCallbacks, signal?: AbortSignal, options: StreamChatOptions = {}, ): Promise { const { - updateMessages, + onContentDelta, + onReasoningDelta, + onChain, + onStats, onError, onDone, - onStats, defaultErrorMessage = "Something went wrong. Please try again.", } = callbacks; @@ -91,65 +97,27 @@ export async function streamChatCompletion( const applyChunk = (data: StreamChunk) => { if (data.type === "error") { onError(data.error); - updateMessages((prev) => - prev.filter((m) => !(m.role === "assistant" && m.content === "")), - ); return "break"; } - if (data.type === "tool_calls" && data.tool_calls?.length) { - updateMessages((prev) => { - const next = [...prev]; - const lastMsg = next[next.length - 1]; - if (lastMsg?.role === "assistant") - next[next.length - 1] = { - ...lastMsg, - toolCalls: data.tool_calls, - }; - return next; - }); + if (data.type === "messages") { + onChain(data.messages ?? []); return "continue"; } if (data.type === "content" && data.delta !== undefined) { - updateMessages((prev) => { - const next = [...prev]; - const lastMsg = next[next.length - 1]; - if (lastMsg?.role === "assistant") - next[next.length - 1] = { - ...lastMsg, - content: lastMsg.content + data.delta, - }; - return next; - }); + onContentDelta(data.delta); return "continue"; } if (data.type === "reasoning" && data.delta !== undefined) { - updateMessages((prev) => { - const next = [...prev]; - const lastMsg = next[next.length - 1]; - if (lastMsg?.role === "assistant") - next[next.length - 1] = { - ...lastMsg, - reasoning: (lastMsg.reasoning ?? "") + data.delta, - }; - return next; - }); + onReasoningDelta(data.delta); return "continue"; } if (data.type === "stats") { - const stats: ChatStats = { + onStats({ promptTokens: data.prompt_tokens, completionTokens: data.completion_tokens, completionDurationMs: data.completion_duration_ms, tokensPerSecond: data.tokens_per_second, - }; - updateMessages((prev) => { - const next = [...prev]; - const lastMsg = next[next.length - 1]; - if (lastMsg?.role === "assistant") - next[next.length - 1] = { ...lastMsg, stats }; - return next; }); - onStats?.(stats); return "continue"; } return "continue"; @@ -165,9 +133,8 @@ export async function streamChatCompletion( const trimmed = line.trim(); if (!trimmed) continue; try { - const data = JSON.parse(trimmed) as StreamChunk & { type: string }; - const result = applyChunk(data as StreamChunk); - if (result === "break") { + const data = JSON.parse(trimmed) as StreamChunk; + if (applyChunk(data) === "break") { hadStreamError = true; break; } @@ -181,50 +148,63 @@ export async function streamChatCompletion( // Flush remaining buffer if (!hadStreamError && buffer.trim()) { try { - const data = JSON.parse(buffer.trim()) as StreamChunk & { - type: string; - delta?: string; - }; - if (data.type === "content" && data.delta !== undefined) { - updateMessages((prev) => { - const next = [...prev]; - const lastMsg = next[next.length - 1]; - if (lastMsg?.role === "assistant") - next[next.length - 1] = { - ...lastMsg, - content: lastMsg.content + data.delta!, - }; - return next; - }); - } + const data = JSON.parse(buffer.trim()) as StreamChunk; + applyChunk(data); } catch { // ignore final malformed chunk } } - - if (!hadStreamError) { - updateMessages((prev) => { - const next = [...prev]; - const lastMsg = next[next.length - 1]; - if (lastMsg?.role === "assistant" && lastMsg.content === "") - next[next.length - 1] = { ...lastMsg, content: " " }; - return next; - }); - } } catch (err) { if (err instanceof DOMException && err.name === "AbortError") { // User stopped generation — not an error } else { onError(defaultErrorMessage); - updateMessages((prev) => - prev.filter((m) => !(m.role === "assistant" && m.content === "")), - ); } } finally { onDone(); } } +/** Map each tool result message to its tool_call_id for response lookup. */ +export function toolResponsesById( + messages: ChatMessage[], +): Map { + const map = new Map(); + for (const m of messages) { + if (m.role === "tool" && typeof m.tool_call_id === "string") { + map.set( + m.tool_call_id, + typeof m.content === "string" ? m.content : JSON.stringify(m.content), + ); + } + } + return map; +} + +/** Derive the display tool calls for one assistant message. */ +export function toolCallsForMessage( + message: ChatMessage, + responses: Map, +): ToolCall[] { + if (!message.tool_calls?.length) return []; + return message.tool_calls.map((tc) => { + let args: Record | undefined; + const raw = tc.function?.arguments; + if (typeof raw === "string") { + try { + args = JSON.parse(raw) as Record; + } catch { + args = undefined; + } + } + return { + name: tc.function?.name ?? "", + arguments: args, + response: responses.get(tc.id), + }; + }); +} + /** * Parse search_objects tool call response(s) into event ids for thumbnails. */ From 8be7a97fa608f97a40767345f45c8006e7919c89 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sat, 13 Jun 2026 16:23:30 -0500 Subject: [PATCH 13/92] guard norfair distance against non-finite and zero-area boxes that could crash autotracking cameras (#23475) --- frigate/ptz/autotrack.py | 29 +++++++++ frigate/test/test_norfair_distance.py | 91 +++++++++++++++++++++++++++ frigate/track/norfair_tracker.py | 11 ++++ 3 files changed, 131 insertions(+) create mode 100644 frigate/test/test_norfair_distance.py diff --git a/frigate/ptz/autotrack.py b/frigate/ptz/autotrack.py index fb76f6718d..55c8e6ecf8 100644 --- a/frigate/ptz/autotrack.py +++ b/frigate/ptz/autotrack.py @@ -48,6 +48,22 @@ def ptz_moving_at_frame_time(frame_time, ptz_start_time, ptz_stop_time): ) +def transform_is_finite(coord_transformations) -> bool: + """Return True if a norfair coordinate transform contains only finite values. + + A near-singular homography (common when the motion estimator can't find + enough stable features during zoom on a low-texture scene) can produce + inf/nan matrix entries. norfair accumulates the homography across frames, so + a single bad transform poisons every subsequent one and propagates nan into + the tracker's distance function, crashing the camera process. + """ + for attr in ("homography_matrix", "inverse_homography_matrix", "movement_vector"): + value = getattr(coord_transformations, attr, None) + if value is not None and not np.all(np.isfinite(value)): + return False + return True + + class PtzMotionEstimator: def __init__(self, config: CameraConfig, ptz_metrics: PTZMetrics) -> None: self.frame_manager = SharedMemoryFrameManager() @@ -135,6 +151,19 @@ class PtzMotionEstimator: ) self.coord_transformations = None + # A degenerate homography can yield non-finite transform values that + # norfair would accumulate and feed to the tracker as nan estimates. + # Drop the bad transform and request a reset so the estimator rebuilds + # a fresh reference frame instead of poisoning every following frame. + if self.coord_transformations is not None and not transform_is_finite( + self.coord_transformations + ): + logger.warning( + f"Autotracker: motion estimator produced a non-finite transform for {camera} at frame time {frame_time}, resetting" + ) + self.coord_transformations = None + self.ptz_metrics.reset.set() + try: logger.debug( f"{camera}: Motion estimator transformation: {self.coord_transformations.rel_to_abs([[0, 0]])}" diff --git a/frigate/test/test_norfair_distance.py b/frigate/test/test_norfair_distance.py new file mode 100644 index 0000000000..a79b91c3cf --- /dev/null +++ b/frigate/test/test_norfair_distance.py @@ -0,0 +1,91 @@ +import math +import unittest + +import numpy as np +from norfair.camera_motion import ( + HomographyTransformation, + TranslationTransformation, +) + +from frigate.ptz.autotrack import transform_is_finite +from frigate.track.norfair_tracker import distance + + +class TestNorfairDistance(unittest.TestCase): + """Regression tests for the tracker distance guard. + + norfair raises a hard ValueError on any nan distance, which kills the camera + process. During autotracking, an ill-conditioned homography can hand the + tracker a non-finite or degenerate estimate box, so distance() must never + return nan for any input. + """ + + def setUp(self) -> None: + # boxes are [[x1, y1], [x2, y2]] + self.detection = np.array([[805.0, 402.0], [864.0, 521.0]]) + self.estimate = np.array([[800.0, 400.0], [860.0, 520.0]]) + + def test_finite_boxes_give_finite_distance(self) -> None: + d = distance(self.detection, self.estimate) + self.assertTrue(math.isfinite(d)) + + def test_inf_estimate_corner_does_not_return_nan(self) -> None: + estimate = np.array([[np.inf, 400.0], [860.0, 520.0]]) + d = distance(self.detection, estimate) + self.assertFalse(math.isnan(d)) + self.assertEqual(d, float("inf")) + + def test_nan_estimate_corner_does_not_return_nan(self) -> None: + # the actual autotracking crash: a positive-only guard would miss this + # because nan <= 0 is False + estimate = np.array([[np.nan, 400.0], [860.0, 520.0]]) + d = distance(self.detection, estimate) + self.assertFalse(math.isnan(d)) + self.assertEqual(d, float("inf")) + + def test_zero_area_estimate_does_not_return_nan(self) -> None: + estimate = np.array([[900.0, 500.0], [900.0, 500.0]]) + d = distance(self.detection, estimate) + self.assertFalse(math.isnan(d)) + self.assertEqual(d, float("inf")) + + def test_zero_area_detection_does_not_return_nan(self) -> None: + detection = np.array([[805.0, 402.0], [805.0, 521.0]]) + d = distance(detection, self.estimate) + self.assertFalse(math.isnan(d)) + self.assertEqual(d, float("inf")) + + def test_inverted_estimate_corners_do_not_return_nan(self) -> None: + # Kalman estimates can occasionally cross corners (x2 < x1) + estimate = np.array([[860.0, 520.0], [800.0, 400.0]]) + d = distance(self.detection, estimate) + self.assertFalse(math.isnan(d)) + self.assertEqual(d, float("inf")) + + +class TestTransformIsFinite(unittest.TestCase): + def test_finite_homography_is_finite(self) -> None: + matrix = np.array([[1.0, 0.0, 5.0], [0.0, 1.0, 3.0], [0.0, 0.0, 1.0]]) + self.assertTrue(transform_is_finite(HomographyTransformation(matrix))) + + def test_finite_translation_is_finite(self) -> None: + self.assertTrue( + transform_is_finite(TranslationTransformation(np.array([12.0, -4.0]))) + ) + + def test_non_finite_homography_is_not_finite(self) -> None: + transform = HomographyTransformation(np.eye(3)) + # simulate accumulation overflowing to a non-finite matrix + transform.homography_matrix = np.array( + [[1.0, 0.0, np.inf], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]] + ) + self.assertFalse(transform_is_finite(transform)) + + def test_nan_translation_is_not_finite(self) -> None: + self.assertFalse( + transform_is_finite(TranslationTransformation(np.array([np.nan, 0.0]))) + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/frigate/track/norfair_tracker.py b/frigate/track/norfair_tracker.py index 84a0f390a3..3f11823572 100644 --- a/frigate/track/norfair_tracker.py +++ b/frigate/track/norfair_tracker.py @@ -45,6 +45,17 @@ def distance(detection: np.ndarray, estimate: np.ndarray) -> float: estimate_dim = np.diff(estimate, axis=0).flatten() detection_dim = np.diff(detection, axis=0).flatten() + # Guard against degenerate or non-finite boxes + if ( + not np.all(np.isfinite(estimate_dim)) + or not np.all(np.isfinite(detection_dim)) + or estimate_dim[0] <= 0 + or estimate_dim[1] <= 0 + or detection_dim[0] <= 0 + or detection_dim[1] <= 0 + ): + return float("inf") + # get bottom center positions detection_position = np.array( [np.average(detection[:, 0]), np.max(detection[:, 1])] From b79ad9871afc06e6507eeaef0ca82431399154a2 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sat, 13 Jun 2026 17:04:22 -0500 Subject: [PATCH 14/92] generate the API docs OpenAPI spec from the app with per-endpoint auth requirements (#23476) --- .github/workflows/pull_request.yml | 2 + AGENTS.md | 10 + docs/static/frigate-api.yaml | 4105 ++++++++++++++++++---------- generate_api_auth_spec.py | 606 ++++ 4 files changed, 3257 insertions(+), 1466 deletions(-) create mode 100644 generate_api_auth_spec.py diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 516b55e89b..d2e279966e 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -125,5 +125,7 @@ jobs: run: devcontainer up --workspace-folder . - name: Run mypy in devcontainer run: devcontainer exec --workspace-folder . bash -lc "python3 -u -m mypy --config-file frigate/mypy.ini frigate" + - name: Check API spec is up to date + run: devcontainer exec --workspace-folder . bash -lc "python3 generate_api_auth_spec.py --check" - name: Run unit tests in devcontainer run: devcontainer exec --workspace-folder . bash -lc "python3 -u -m unittest" diff --git a/AGENTS.md b/AGENTS.md index 61b8373a82..d078c2fdba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -235,6 +235,14 @@ ruff check frigate/ # Type check python3 -u -m mypy --config-file frigate/mypy.ini frigate + +# Regenerate the OpenAPI spec after adding, changing, or removing an API +# endpoint or its auth dependency — outputs docs/static/frigate-api.yaml, +# annotated with each endpoint's auth requirement (admin / any / camera / +# public). NEVER edit that file by hand. CI runs the --check variant and fails +# if it is out of date. (from repo root) +python3 generate_api_auth_spec.py +python3 generate_api_auth_spec.py --check ``` ### Frontend (from web/ directory) @@ -316,6 +324,8 @@ async def get_events(request: Request, limit: int = 100): # Implementation ``` +After adding, changing, or removing an endpoint (or its auth dependency), regenerate the OpenAPI spec with `python3 generate_api_auth_spec.py` so `docs/static/frigate-api.yaml` stays in sync and the endpoint's auth requirement is documented. CI enforces this via the `--check` variant; never edit that file by hand. + ### Configuration Access ```python diff --git a/docs/static/frigate-api.yaml b/docs/static/frigate-api.yaml index 2c6109b985..0ee41a0feb 100644 --- a/docs/static/frigate-api.yaml +++ b/docs/static/frigate-api.yaml @@ -1,14 +1,14 @@ +# Generated by generate_api_auth_spec.py — do not edit by hand. +# Regenerate with: python3 generate_api_auth_spec.py +# The empty info.title is intentional: a docusaurus-openapi-docs convention +# that suppresses the generated API introduction page. openapi: 3.1.0 info: - # To avoid the introduction page we set the title to empty string - # https://github.com/PaloAltoNetworks/docusaurus-openapi-docs/blob/4e771d309f6defe395449b26cc3c65814d72cbcc/packages/docusaurus-plugin-openapi-docs/src/openapi/openapi.ts#L92-L129 - title: "" + title: '' version: 0.1.0 - servers: - url: https://demo.frigate.video/api - url: http://localhost:5001/api - paths: /auth/first_time_login: get: @@ -16,30 +16,33 @@ paths: - Auth summary: First Time Login description: |- + **Access:** Public — no authentication required. + Return whether the admin first-time login help flag is set in config. This endpoint is intentionally unauthenticated so the login page can query it before a user is authenticated. operationId: first_time_login_auth_first_time_login_get responses: - "200": + '200': description: Successful Response content: application/json: schema: {} + security: [] + x-required-role: public /auth: get: tags: - Auth summary: Authenticate request - description: >- - Authenticates the current request based on proxy headers or JWT token. - This endpoint verifies authentication credentials and manages JWT token - refresh. On success, no JSON body is returned; authentication state is - communicated via response headers and cookies. + description: |- + **Access:** Public — no authentication required. + + Authenticates the current request based on proxy headers or JWT token. This endpoint verifies authentication credentials and manages JWT token refresh. On success, no JSON body is returned; authentication state is communicated via response headers and cookies. operationId: auth_auth_get responses: - "202": + '202': description: Authentication Accepted (no response body) content: application/json: @@ -50,65 +53,67 @@ paths: schema: type: string remote-role: - description: "Resolved role (e.g., admin, viewer, or custom)" + description: Resolved role (e.g., admin, viewer, or custom) schema: type: string Set-Cookie: description: May include refreshed JWT cookie when applicable schema: type: string - "401": + '401': description: Authentication Failed + security: [] + x-required-role: public /profile: get: tags: - Auth summary: Get user profile - description: >- - Returns the current authenticated user's profile including username, - role, and allowed cameras. This endpoint requires authentication and - returns information about the user's permissions. + description: |- + **Access:** Any authenticated user. + + Returns the current authenticated user's profile including username, role, and allowed cameras. This endpoint requires authentication and returns information about the user's permissions. operationId: profile_profile_get responses: - "200": + '200': description: Successful Response content: application/json: schema: {} + security: + - frigateUserAuth: [] + x-required-role: any /logout: get: tags: - Auth summary: Logout user - description: >- - Logs out the current user by clearing the session cookie. After logout, - subsequent requests will require re-authentication. + description: |- + **Access:** Public — no authentication required. + + Logs out the current user by clearing the session cookie. After logout, subsequent requests will require re-authentication. operationId: logout_logout_get responses: - "200": + '200': description: Successful Response content: application/json: schema: {} + security: [] + x-required-role: public /login: post: tags: - Auth summary: Login with credentials - description: >- - Authenticates a user with username and password. Returns a JWT token as - a secure HTTP-only cookie that can be used for subsequent API requests. - The JWT token can also be retrieved from the response and used as a - Bearer token in the Authorization header. + description: |- + **Access:** Public — no authentication required. + Authenticates a user with username and password. Returns a JWT token as a secure HTTP-only cookie that can be used for subsequent API requests. The JWT token can also be retrieved from the response and used as a Bearer token in the Authorization header. Example using Bearer token: - ``` - - curl -H "Authorization: Bearer " - https://frigate_ip:8971/api/profile - + curl -H "Authorization: Bearer " https://frigate_ip:8971/api/profile ``` operationId: login_login_post requestBody: @@ -116,68 +121,79 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/AppPostLoginBody" + $ref: '#/components/schemas/AppPostLoginBody' responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: [] + x-required-role: public /users: get: tags: - Auth summary: Get all users - description: >- - Returns a list of all users with their usernames and roles. Requires - admin role. Each user object contains the username and assigned role. + description: |- + **Access:** Admin role required. + + Returns a list of all users with their usernames and roles. Requires admin role. Each user object contains the username and assigned role. operationId: get_users_users_get responses: - "200": + '200': description: Successful Response content: application/json: schema: {} + security: + - frigateAdminAuth: [] + x-required-role: admin post: tags: - Auth summary: Create new user - description: >- - Creates a new user with the specified username, password, and role. - Requires admin role. Password must be at least 12 characters long. + description: |- + **Access:** Admin role required. + + Creates a new user with the specified username, password, and role. Requires admin role. Password must be at least 12 characters long. operationId: create_user_users_post requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/AppPostUsersBody" + $ref: '#/components/schemas/AppPostUsersBody' responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - "/users/{username}": + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /users/{username}: delete: tags: - Auth summary: Delete user - description: >- - Deletes a user by username. The built-in admin user cannot be deleted. - Requires admin role. Returns success message or error if user not found. + description: |- + **Access:** Admin role required. + + Deletes a user by username. The built-in admin user cannot be deleted. Requires admin role. Returns success message or error if user not found. operationId: delete_user_users__username__delete parameters: - name: username @@ -187,28 +203,29 @@ paths: type: string title: Username responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - "/users/{username}/password": + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /users/{username}/password: put: tags: - Auth summary: Update user password - description: >- - Updates a user's password. Users can only change their own password - unless they have admin role. Requires the current password to verify - identity for non-admin users. Password must be at least 12 characters - long. If user changes their own password, a new JWT cookie is - automatically issued. + description: |- + **Access:** Any authenticated user. + + Updates a user's password. Users can only change their own password unless they have admin role. Requires the current password to verify identity for non-admin users. Password must be at least 12 characters long. If user changes their own password, a new JWT cookie is automatically issued. operationId: update_password_users__username__password_put parameters: - name: username @@ -222,28 +239,31 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/AppPutPasswordBody" + $ref: '#/components/schemas/AppPutPasswordBody' responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - "/users/{username}/role": + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: any + /users/{username}/role: put: tags: - Auth summary: Update user role - description: >- - Updates a user's role. The built-in admin user's role cannot be - modified. Requires admin role. Valid roles are defined in the - configuration. + description: |- + **Access:** Admin role required. + + Updates a user's role. The built-in admin user's role cannot be modified. Requires admin role. Valid roles are defined in the configuration. operationId: update_role_users__username__role_put parameters: - name: username @@ -257,19 +277,22 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/AppPutRoleBody" + $ref: '#/components/schemas/AppPutRoleBody' responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin /go2rtc/streams: get: tags: @@ -277,44 +300,54 @@ paths: summary: Go2Rtc Streams operationId: go2rtc_streams_go2rtc_streams_get responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "/go2rtc/streams/{camera_name}": + security: + - frigateUserAuth: [] + x-required-role: any + description: '**Access:** Any authenticated user.' + /go2rtc/streams/{stream_name}: get: tags: - Camera summary: Go2Rtc Camera Stream - operationId: go2rtc_camera_stream_go2rtc_streams__camera_name__get + operationId: go2rtc_camera_stream_go2rtc_streams__stream_name__get parameters: - - name: camera_name + - name: stream_name in: path required: true schema: anyOf: - type: string - - type: "null" - title: Camera Name + - type: 'null' + title: Stream Name responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - "/go2rtc/streams/{stream_name}": + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera + description: '**Access:** Authenticated user with access to the referenced camera.' put: tags: - Camera summary: Go2Rtc Add Stream - description: Add or update a go2rtc stream configuration. + description: |- + **Access:** Admin role required. + + Add or update a go2rtc stream configuration. operationId: go2rtc_add_stream_go2rtc_streams__stream_name__put parameters: - name: stream_name @@ -328,25 +361,31 @@ paths: required: false schema: type: string - default: "" + default: '' title: Src responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin delete: tags: - Camera summary: Go2Rtc Delete Stream - description: Delete a go2rtc stream. + description: |- + **Access:** Admin role required. + + Delete a go2rtc stream. operationId: go2rtc_delete_stream_go2rtc_streams__stream_name__delete parameters: - name: stream_name @@ -356,17 +395,20 @@ paths: type: string title: Stream Name responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin /ffprobe: get: tags: @@ -379,7 +421,7 @@ paths: required: false schema: type: string - default: "" + default: '' title: Paths - name: detailed in: query @@ -389,24 +431,31 @@ paths: default: false title: Detailed responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + description: '**Access:** Admin role required.' /keyframe_analysis: get: tags: - Camera summary: Keyframe Analysis - description: >- + description: |- + **Access:** Admin role required. + Probe a camera's record stream and classify its keyframe spacing. + Detects smart/+ codecs and long/variable GOPs that degrade recording. operationId: keyframe_analysis_keyframe_analysis_get parameters: @@ -415,26 +464,32 @@ paths: required: false schema: type: string - default: "" + default: '' title: Camera responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin /ffprobe/snapshot: get: tags: - Camera summary: Ffprobe Snapshot - description: Get a snapshot from a stream URL using ffmpeg. + description: |- + **Access:** Admin role required. + + Get a snapshot from a stream URL using ffmpeg. operationId: ffprobe_snapshot_ffprobe_snapshot_get parameters: - name: url @@ -442,7 +497,7 @@ paths: required: false schema: type: string - default: "" + default: '' title: Url - name: timeout in: query @@ -452,30 +507,32 @@ paths: default: 10 title: Timeout responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin /reolink/detect: get: tags: - Camera summary: Reolink Detect - description: >- + description: |- + **Access:** Admin role required. + Detect Reolink camera capabilities and recommend optimal protocol. - Queries the Reolink camera API to determine the camera's resolution - - and recommends either http-flv (for 5MP and below) or rtsp (for higher - resolutions). + and recommends either http-flv (for 5MP and below) or rtsp (for higher resolutions). operationId: reolink_detect_reolink_detect_get parameters: - name: host @@ -483,44 +540,46 @@ paths: required: false schema: type: string - default: "" + default: '' title: Host - name: username in: query required: false schema: type: string - default: "" + default: '' title: Username - name: password in: query required: false schema: type: string - default: "" + default: '' title: Password responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin /onvif/probe: get: tags: - Camera summary: Probe ONVIF device - description: >- - Probe an ONVIF device to determine capabilities and optionally test - available stream URIs. Query params: host (required), port (default 80), - username, password, test (boolean), auth_type (basic or digest, default - basic). + description: |- + **Access:** Admin role required. + + Probe an ONVIF device to determine capabilities and optionally test available stream URIs. Query params: host (required), port (default 80), username, password, test (boolean), auth_type (basic or digest, default basic). operationId: onvif_probe_onvif_probe_get parameters: - name: host @@ -541,14 +600,14 @@ paths: required: false schema: type: string - default: "" + default: '' title: Username - name: password in: query required: false schema: type: string - default: "" + default: '' title: Password - name: test in: query @@ -565,23 +624,28 @@ paths: default: basic title: Auth Type responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - "/cameras/{camera_name}": + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /cameras/{camera_name}: delete: tags: - Camera summary: Delete Camera description: |- + **Access:** Admin role required. + Delete a camera and all its associated data. Removes the camera from config, stops processes, and cleans up @@ -606,24 +670,31 @@ paths: default: false title: Delete Exports responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - "/camera/{camera_name}/set/{feature}/{sub_command}": + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /camera/{camera_name}/set/{feature}/{sub_command}: put: tags: - Camera summary: Camera Set - description: Set a camera feature state. Use camera_name='*' to target all cameras. - operationId: camera_set_camera__camera_name__set__feature___sub_command__put + description: |- + **Access:** Admin role required. + + Set a camera feature state. Use camera_name='*' to target all cameras. + operationId: + camera_set_camera__camera_name__set__feature___sub_command__put parameters: - name: camera_name in: path @@ -643,32 +714,38 @@ paths: schema: anyOf: - type: string - - type: "null" + - type: 'null' title: Sub Command requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/CameraSetBody" + $ref: '#/components/schemas/CameraSetBody' responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - "/camera/{camera_name}/set/{feature}": + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /camera/{camera_name}/set/{feature}: put: tags: - Camera summary: Camera Set - description: Set a camera feature state. Use camera_name='*' to target all cameras. + description: |- + **Access:** Admin role required. + + Set a camera feature state. Use camera_name='*' to target all cameras. operationId: camera_set_camera__camera_name__set__feature__put parameters: - name: camera_name @@ -689,116 +766,208 @@ paths: schema: anyOf: - type: string - - type: "null" + - type: 'null' title: Sub Command requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/CameraSetBody" + $ref: '#/components/schemas/CameraSetBody' responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin /chat/tools: get: tags: - Chat summary: Get available tools - description: Returns OpenAI-compatible tool definitions for function calling. + description: |- + **Access:** Admin role required. + + Returns OpenAI-compatible tool definitions for function calling. operationId: get_tools_chat_tools_get responses: - "200": + '200': description: Successful Response content: application/json: schema: {} + security: + - frigateAdminAuth: [] + x-required-role: admin /chat/execute: post: tags: - Chat summary: Execute a tool - description: Execute a tool function call from an LLM. + description: |- + **Access:** Admin role required. + + Execute a tool function call from an LLM. operationId: execute_tool_chat_execute_post requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/ToolExecuteRequest" + $ref: '#/components/schemas/ToolExecuteRequest' responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin /chat/completion: post: tags: - Chat summary: Chat completion with tool calling - description: >- - Send a chat message to the configured GenAI provider with tool calling - support. The LLM can call Frigate tools to answer questions about your - cameras and events. + description: |- + **Access:** Admin role required. + + Send a chat message to the configured GenAI provider with tool calling support. The LLM can call Frigate tools to answer questions about your cameras and events. operationId: chat_completion_chat_completion_post requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/ChatCompletionRequest" + $ref: '#/components/schemas/ChatCompletionRequest' responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /vlm/monitor: + post: + tags: + - Chat + summary: Start a VLM watch job + description: |- + **Access:** Admin role required. + + Start monitoring a camera with the vision provider. The VLM analyzes live frames until the specified condition is met, then sends a notification. Only one watch job can run at a time. + operationId: start_vlm_monitor_vlm_monitor_post + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/VLMMonitorRequest' + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + get: + tags: + - Chat + summary: Get current VLM watch job + description: |- + **Access:** Admin role required. + + Returns the current (or most recently completed) VLM watch job. + operationId: get_vlm_monitor_vlm_monitor_get + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + security: + - frigateAdminAuth: [] + x-required-role: admin + delete: + tags: + - Chat + summary: Cancel the current VLM watch job + description: |- + **Access:** Admin role required. + + Cancels the running watch job if one exists. + operationId: cancel_vlm_monitor_vlm_monitor_delete + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + security: + - frigateAdminAuth: [] + x-required-role: admin /faces: get: tags: - Classification summary: Get all registered faces description: |- + **Access:** Admin role required. + Returns a dictionary mapping face names to lists of image filenames. Each key represents a registered face name, and the value is a list of image files associated with that face. Supported image formats include .webp, .png, .jpg, and .jpeg. operationId: get_faces_faces_get responses: - "200": + '200': description: Successful Response content: application/json: schema: - $ref: "#/components/schemas/FacesResponse" + $ref: '#/components/schemas/FacesResponse' + security: + - frigateAdminAuth: [] + x-required-role: admin /faces/reprocess: post: tags: - Classification summary: Reprocess a face training image description: |- + **Access:** Admin role required. + Reprocesses a face training image to update the prediction. Requires face recognition to be enabled in the configuration. The training file must exist in the faces/train directory. Returns a success response or an error @@ -811,23 +980,28 @@ paths: type: object title: Body responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - "/faces/train/{name}/classify": + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /faces/train/{name}/classify: post: tags: - Classification summary: Classify and save a face training image description: |- + **Access:** Admin role required. + Adds a training image to a specific face name for face recognition. Accepts either a training file from the train directory or an event_id to extract the face from. The image is saved to the face's directory and the face classifier @@ -849,24 +1023,29 @@ paths: type: object title: Body responses: - "200": + '200': description: Successful Response content: application/json: schema: - $ref: "#/components/schemas/GenericResponse" - "422": + $ref: '#/components/schemas/GenericResponse' + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - "/faces/{name}/create": + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /faces/{name}/create: post: tags: - Classification summary: Create a new face name description: |- + **Access:** Admin role required. + Creates a new folder for a face name in the faces directory. This is used to organize face training images. The face name is sanitized and spaces are replaced with underscores. Returns a success message or an error if @@ -880,26 +1059,30 @@ paths: type: string title: Name responses: - "200": + '200': description: Successful Response content: application/json: schema: - $ref: "#/components/schemas/GenericResponse" - "422": + $ref: '#/components/schemas/GenericResponse' + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - "/faces/{name}/register": + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /faces/{name}/register: post: tags: - Classification summary: Register a face image - description: >- - Registers a face image for a specific face name by uploading an image - file. + description: |- + **Access:** Admin role required. + + Registers a face image for a specific face name by uploading an image file. The uploaded image is processed and added to the face recognition system. Returns a success response with details about the registration, or an error if face recognition is not enabled or the image cannot be processed. @@ -916,27 +1099,31 @@ paths: content: multipart/form-data: schema: - $ref: >- - #/components/schemas/Body_register_face_faces__name__register_post + $ref: '#/components/schemas/Body_register_face_faces__name__register_post' responses: - "200": + '200': description: Successful Response content: application/json: schema: - $ref: "#/components/schemas/GenericResponse" - "422": + $ref: '#/components/schemas/GenericResponse' + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin /faces/recognize: post: tags: - Classification summary: Recognize a face from an uploaded image description: |- + **Access:** Admin role required. + Recognizes a face from an uploaded image file by comparing it against registered faces in the system. Returns the recognized face name and confidence score, or an error if face recognition is not enabled or the image cannot be processed. @@ -946,28 +1133,74 @@ paths: content: multipart/form-data: schema: - $ref: "#/components/schemas/Body_recognize_face_faces_recognize_post" + $ref: '#/components/schemas/Body_recognize_face_faces_recognize_post' responses: - "200": + '200': description: Successful Response content: application/json: schema: - $ref: "#/components/schemas/FaceRecognitionResponse" - "422": + $ref: '#/components/schemas/FaceRecognitionResponse' + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - "/faces/{name}/delete": + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /faces/{name}/reclassify: + post: + tags: + - Classification + summary: Reclassify a face image to a different name + description: |- + **Access:** Admin role required. + + Moves a single face image from one person's folder to another. + The image is moved and renamed, and the face classifier is cleared to + incorporate the change. Returns a success message or an error if the + image or target name is invalid. + operationId: reclassify_face_image_faces__name__reclassify_post + parameters: + - name: name + in: path + required: true + schema: + type: string + title: Name + requestBody: + content: + application/json: + schema: + type: object + title: Body + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/GenericResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /faces/{name}/delete: post: tags: - Classification summary: Delete face images - description: >- - Deletes specific face images for a given face name. The image IDs must - belong + description: |- + **Access:** Admin role required. + + Deletes specific face images for a given face name. The image IDs must belong to the specified face folder. To delete an entire face folder, all image IDs in that folder must be sent. Returns a success message or an error if face recognition is not enabled. operationId: deregister_faces_faces__name__delete_post @@ -983,26 +1216,31 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/DeleteFaceImagesBody" + $ref: '#/components/schemas/DeleteFaceImagesBody' responses: - "200": + '200': description: Successful Response content: application/json: schema: - $ref: "#/components/schemas/GenericResponse" - "422": + $ref: '#/components/schemas/GenericResponse' + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - "/faces/{old_name}/rename": + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /faces/{old_name}/rename: put: tags: - Classification summary: Rename a face name description: |- + **Access:** Admin role required. + Renames a face name in the system. The old name must exist and the new name must be valid. Returns a success message or an error if face recognition is not enabled. operationId: rename_face_faces__old_name__rename_put @@ -1018,26 +1256,31 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/RenameFaceBody" + $ref: '#/components/schemas/RenameFaceBody' responses: - "200": + '200': description: Successful Response content: application/json: schema: - $ref: "#/components/schemas/GenericResponse" - "422": + $ref: '#/components/schemas/GenericResponse' + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin /lpr/reprocess: put: tags: - Classification summary: Reprocess a license plate description: |- + **Access:** Admin role required. + Reprocesses a license plate image to update the plate. Requires license plate recognition to be enabled in the configuration. The event_id must exist in the database. Returns a success message or an error if license plate @@ -1051,39 +1294,49 @@ paths: type: string title: Event Id responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin /reindex: put: tags: - Classification summary: Reindex embeddings description: |- + **Access:** Admin role required. + Reindexes the embeddings for all tracked objects. Requires semantic search to be enabled in the configuration. Returns a success message or an error if semantic search is not enabled. operationId: reindex_embeddings_reindex_put responses: - "200": + '200': description: Successful Response content: application/json: schema: - $ref: "#/components/schemas/GenericResponse" + $ref: '#/components/schemas/GenericResponse' + security: + - frigateAdminAuth: [] + x-required-role: admin /audio/transcribe: put: tags: - Classification summary: Transcribe audio description: |- + **Access:** Admin role required. + Transcribes audio from a specific event. Requires audio transcription to be enabled in the configuration. The event_id must exist in the database. Returns a success message or an error if audio transcription is not enabled or the event_id is invalid. @@ -1093,26 +1346,31 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/AudioTranscriptionBody" + $ref: '#/components/schemas/AudioTranscriptionBody' responses: - "200": + '200': description: Successful Response content: application/json: schema: - $ref: "#/components/schemas/GenericResponse" - "422": + $ref: '#/components/schemas/GenericResponse' + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - "/classification/{name}/dataset": + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /classification/{name}/dataset: get: tags: - Classification summary: Get classification dataset description: |- + **Access:** Admin role required. + Gets the dataset for a specific classification model. The name must exist in the classification models. Returns a success message or an error if the name is invalid. operationId: get_classification_dataset_classification__name__dataset_get @@ -1124,23 +1382,28 @@ paths: type: string title: Name responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin /classification/attributes: get: tags: - Classification summary: Get custom classification attributes description: |- + **Access:** Admin role required. + Returns custom classification attributes for a given object type. Only includes models with classification_type set to 'attribute'. By default returns a flat sorted list of all attribute labels. @@ -1161,23 +1424,28 @@ paths: default: false title: Group By Model responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - "/classification/{name}/train": + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /classification/{name}/train: get: tags: - Classification summary: Get classification train images description: |- + **Access:** Admin role required. + Gets the train images for a specific classification model. The name must exist in the classification models. Returns a success message or an error if the name is invalid. operationId: get_classification_images_classification__name__train_get @@ -1189,22 +1457,27 @@ paths: type: string title: Name responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin post: tags: - Classification summary: Train a classification model description: |- + **Access:** Admin role required. + Trains a specific classification model. The name must exist in the classification models. Returns a success message or an error if the name is invalid. operationId: train_configured_model_classification__name__train_post @@ -1216,28 +1489,32 @@ paths: type: string title: Name responses: - "200": + '200': description: Successful Response content: application/json: schema: - $ref: "#/components/schemas/GenericResponse" - "422": + $ref: '#/components/schemas/GenericResponse' + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - "/classification/{name}/dataset/{category}/delete": + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /classification/{name}/dataset/{category}/delete: post: tags: - Classification summary: Delete classification dataset images - description: >- - Deletes specific dataset images for a given classification model and - category. + description: |- + **Access:** Admin role required. + + Deletes specific dataset images for a given classification model and category. The image IDs must belong to the specified category. Returns a success message or an error if the name or category is invalid. - operationId: >- + operationId: delete_classification_dataset_images_classification__name__dataset__category__delete_post parameters: - name: name @@ -1259,27 +1536,79 @@ paths: type: object title: Body responses: - "200": + '200': description: Successful Response content: application/json: schema: - $ref: "#/components/schemas/GenericResponse" - "422": + $ref: '#/components/schemas/GenericResponse' + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - "/classification/{name}/dataset/{old_category}/rename": + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /classification/{name}/dataset/{category}/reclassify: + post: + tags: + - Classification + summary: Reclassify a dataset image to a different category + description: |- + **Access:** Admin role required. + + Moves a single dataset image from one category to another. + The image is re-saved as PNG in the target category and removed from the source. + operationId: + reclassify_classification_image_classification__name__dataset__category__reclassify_post + parameters: + - name: name + in: path + required: true + schema: + type: string + title: Name + - name: category + in: path + required: true + schema: + type: string + title: Category + requestBody: + content: + application/json: + schema: + type: object + title: Body + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/GenericResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /classification/{name}/dataset/{old_category}/rename: put: tags: - Classification summary: Rename a classification category description: |- + **Access:** Admin role required. + Renames a classification category for a given classification model. The old category must exist and the new name must be valid. Returns a success message or an error if the name is invalid. - operationId: >- + operationId: rename_classification_category_classification__name__dataset__old_category__rename_put parameters: - name: name @@ -1301,28 +1630,32 @@ paths: type: object title: Body responses: - "200": + '200': description: Successful Response content: application/json: schema: - $ref: "#/components/schemas/GenericResponse" - "422": + $ref: '#/components/schemas/GenericResponse' + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - "/classification/{name}/dataset/categorize": + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /classification/{name}/dataset/categorize: post: tags: - Classification summary: Categorize a classification image - description: >- - Categorizes a specific classification image for a given classification - model and category. + description: |- + **Access:** Admin role required. + + Categorizes a specific classification image for a given classification model and category. The image must exist in the specified category. Returns a success message or an error if the name or category is invalid. - operationId: >- + operationId: categorize_classification_image_classification__name__dataset_categorize_post parameters: - name: name @@ -1338,28 +1671,33 @@ paths: type: object title: Body responses: - "200": + '200': description: Successful Response content: application/json: schema: - $ref: "#/components/schemas/GenericResponse" - "422": + $ref: '#/components/schemas/GenericResponse' + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - "/classification/{name}/dataset/{category}/create": + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /classification/{name}/dataset/{category}/create: post: tags: - Classification summary: Create an empty classification category folder description: |- + **Access:** Admin role required. + Creates an empty folder for a classification category. This is used to create folders for categories that don't have images yet. Returns a success message or an error if the name is invalid. - operationId: >- + operationId: create_classification_category_classification__name__dataset__category__create_post parameters: - name: name @@ -1375,27 +1713,32 @@ paths: type: string title: Category responses: - "200": + '200': description: Successful Response content: application/json: schema: - $ref: "#/components/schemas/GenericResponse" - "422": + $ref: '#/components/schemas/GenericResponse' + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - "/classification/{name}/train/delete": + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /classification/{name}/train/delete: post: tags: - Classification summary: Delete classification train images description: |- + **Access:** Admin role required. + Deletes specific train images for a given classification model. The image IDs must belong to the specified train folder. Returns a success message or an error if the name is invalid. - operationId: >- + operationId: delete_classification_train_images_classification__name__train_delete_post parameters: - name: name @@ -1411,76 +1754,95 @@ paths: type: object title: Body responses: - "200": + '200': description: Successful Response content: application/json: schema: - $ref: "#/components/schemas/GenericResponse" - "422": + $ref: '#/components/schemas/GenericResponse' + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin /classification/generate_examples/state: post: tags: - Classification summary: Generate state classification examples - description: Generate examples for state classification. - operationId: generate_state_examples_classification_generate_examples_state_post + description: |- + **Access:** Admin role required. + + Generate examples for state classification. + operationId: + generate_state_examples_classification_generate_examples_state_post requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/GenerateStateExamplesBody" + $ref: '#/components/schemas/GenerateStateExamplesBody' responses: - "200": + '200': description: Successful Response content: application/json: schema: - $ref: "#/components/schemas/GenericResponse" - "422": + $ref: '#/components/schemas/GenericResponse' + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin /classification/generate_examples/object: post: tags: - Classification summary: Generate object classification examples - description: Generate examples for object classification. - operationId: generate_object_examples_classification_generate_examples_object_post + description: |- + **Access:** Admin role required. + + Generate examples for object classification. + operationId: + generate_object_examples_classification_generate_examples_object_post requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/GenerateObjectExamplesBody" + $ref: '#/components/schemas/GenerateObjectExamplesBody' responses: - "200": + '200': description: Successful Response content: application/json: schema: - $ref: "#/components/schemas/GenericResponse" - "422": + $ref: '#/components/schemas/GenericResponse' + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - "/classification/{name}": + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /classification/{name}: delete: tags: - Classification summary: Delete a classification model description: |- + **Access:** Admin role required. + Deletes a specific classification model and all its associated data. Works even if the model is not in the config (e.g., partially created during wizard). Returns a success message. @@ -1493,18 +1855,21 @@ paths: type: string title: Name responses: - "200": + '200': description: Successful Response content: application/json: schema: - $ref: "#/components/schemas/GenericResponse" - "422": + $ref: '#/components/schemas/GenericResponse' + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin /review: get: tags: @@ -1549,7 +1914,7 @@ paths: in: query required: false schema: - $ref: "#/components/schemas/SeverityEnum" + $ref: '#/components/schemas/SeverityEnum' - name: before in: query required: false @@ -1563,21 +1928,25 @@ paths: type: number title: After responses: - "200": + '200': description: Successful Response content: application/json: schema: type: array items: - $ref: "#/components/schemas/ReviewSegmentResponse" + $ref: '#/components/schemas/ReviewSegmentResponse' title: Response Review Review Get - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: any + description: '**Access:** Any authenticated user.' /review_ids: get: tags: @@ -1592,21 +1961,25 @@ paths: type: string title: Ids responses: - "200": + '200': description: Successful Response content: application/json: schema: type: array items: - $ref: "#/components/schemas/ReviewSegmentResponse" + $ref: '#/components/schemas/ReviewSegmentResponse' title: Response Review Ids Review Ids Get - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera + description: '**Access:** Authenticated user with access to the referenced camera.' /review/summary: get: tags: @@ -1643,18 +2016,22 @@ paths: default: utc title: Timezone responses: - "200": + '200': description: Successful Response content: application/json: schema: - $ref: "#/components/schemas/ReviewSummaryResponse" - "422": + $ref: '#/components/schemas/ReviewSummaryResponse' + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: any + description: '**Access:** Any authenticated user.' /reviews/viewed: post: tags: @@ -1666,20 +2043,24 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/ReviewModifyMultipleBody" + $ref: '#/components/schemas/ReviewModifyMultipleBody' responses: - "200": + '200': description: Successful Response content: application/json: schema: - $ref: "#/components/schemas/GenericResponse" - "422": + $ref: '#/components/schemas/GenericResponse' + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera + description: '**Access:** Authenticated user with access to the referenced camera.' /reviews/delete: post: tags: @@ -1691,26 +2072,33 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/ReviewModifyMultipleBody" + $ref: '#/components/schemas/ReviewModifyMultipleBody' responses: - "200": + '200': description: Successful Response content: application/json: schema: - $ref: "#/components/schemas/GenericResponse" - "422": + $ref: '#/components/schemas/GenericResponse' + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + description: '**Access:** Admin role required.' /review/activity/motion: get: tags: - Review summary: Motion Activity - description: Get motion and audio activity. + description: |- + **Access:** Any authenticated user. + + Get motion and audio activity. operationId: motion_activity_review_activity_motion_get parameters: - name: cameras @@ -1740,22 +2128,25 @@ paths: default: 30 title: Scale responses: - "200": + '200': description: Successful Response content: application/json: schema: type: array items: - $ref: "#/components/schemas/ReviewActivityMotionResponse" + $ref: '#/components/schemas/ReviewActivityMotionResponse' title: Response Motion Activity Review Activity Motion Get - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - "/review/event/{event_id}": + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: any + /review/event/{event_id}: get: tags: - Review @@ -1769,19 +2160,23 @@ paths: type: string title: Event Id responses: - "200": + '200': description: Successful Response content: application/json: schema: - $ref: "#/components/schemas/ReviewSegmentResponse" - "422": + $ref: '#/components/schemas/ReviewSegmentResponse' + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - "/review/{review_id}": + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera + description: '**Access:** Authenticated user with access to the referenced camera.' + /review/{review_id}: get: tags: - Review @@ -1795,19 +2190,23 @@ paths: type: string title: Review Id responses: - "200": + '200': description: Successful Response content: application/json: schema: - $ref: "#/components/schemas/ReviewSegmentResponse" - "422": + $ref: '#/components/schemas/ReviewSegmentResponse' + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - "/review/{review_id}/viewed": + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera + description: '**Access:** Authenticated user with access to the referenced camera.' + /review/{review_id}/viewed: delete: tags: - Review @@ -1821,25 +2220,32 @@ paths: type: string title: Review Id responses: - "200": + '200': description: Successful Response content: application/json: schema: - $ref: "#/components/schemas/GenericResponse" - "422": + $ref: '#/components/schemas/GenericResponse' + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - "/review/summarize/start/{start_ts}/end/{end_ts}": + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: any + description: '**Access:** Any authenticated user.' + /review/summarize/start/{start_ts}/end/{end_ts}: post: tags: - Review summary: Generate Review Summary - description: Use GenAI to summarize review items over a period of time. - operationId: >- + description: |- + **Access:** Admin role required. + + Use GenAI to summarize review items over a period of time. + operationId: generate_review_summary_review_summarize_start__start_ts__end__end_ts__post parameters: - name: start_ts @@ -1855,17 +2261,20 @@ paths: type: number title: End Ts responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin /: get: tags: @@ -1873,12 +2282,15 @@ paths: summary: Is Healthy operationId: is_healthy__get responses: - "200": + '200': description: Successful Response content: text/plain: schema: type: string + security: [] + x-required-role: public + description: '**Access:** Public — no authentication required.' /config/schema.json: get: tags: @@ -1886,11 +2298,14 @@ paths: summary: Config Schema operationId: config_schema_config_schema_json_get responses: - "200": + '200': description: Successful Response content: application/json: schema: {} + security: [] + x-required-role: public + description: '**Access:** Public — no authentication required.' /version: get: tags: @@ -1898,12 +2313,15 @@ paths: summary: Version operationId: version_version_get responses: - "200": + '200': description: Successful Response content: text/plain: schema: type: string + security: [] + x-required-role: public + description: '**Access:** Public — no authentication required.' /stats: get: tags: @@ -1911,11 +2329,15 @@ paths: summary: Stats operationId: stats_stats_get responses: - "200": + '200': description: Successful Response content: application/json: schema: {} + security: + - frigateUserAuth: [] + x-required-role: any + description: '**Access:** Any authenticated user.' /stats/history: get: tags: @@ -1930,30 +2352,90 @@ paths: type: string title: Keys responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + description: '**Access:** Admin role required.' /metrics: get: tags: - App summary: Metrics - description: Expose Prometheus metrics endpoint and update metrics with latest stats + description: |- + **Access:** Any authenticated user. + + Expose Prometheus metrics endpoint and update metrics with latest stats operationId: metrics_metrics_get responses: - "200": + '200': description: Successful Response content: application/json: schema: {} + security: + - frigateUserAuth: [] + x-required-role: any + /genai/models: + get: + tags: + - App + summary: List available GenAI models + description: |- + **Access:** Admin role required. + + Returns available models for each configured GenAI provider. + operationId: genai_models_genai_models_get + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + security: + - frigateAdminAuth: [] + x-required-role: admin + /genai/probe: + post: + tags: + - App + summary: Probe a GenAI provider without saving config + description: |- + **Access:** Admin role required. + + Builds a transient client from the request body and returns its available models. Used to validate provider credentials in the UI before saving the configuration. + operationId: genai_probe_genai_probe_post + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/GenAIProbeBody' + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin /config: get: tags: @@ -1961,65 +2443,91 @@ paths: summary: Config operationId: config_config_get responses: - "200": + '200': description: Successful Response content: application/json: schema: {} + security: + - frigateUserAuth: [] + x-required-role: any + description: '**Access:** Any authenticated user.' /profiles: get: tags: - App summary: Get Profiles - description: List all available profiles and the currently active profile. + description: |- + **Access:** Any authenticated user. + + List all available profiles and the currently active profile. operationId: get_profiles_profiles_get responses: - "200": + '200': description: Successful Response content: application/json: schema: {} + security: + - frigateUserAuth: [] + x-required-role: any /profile/active: get: tags: - App summary: Get Active Profile - description: Get the currently active profile. + description: |- + **Access:** Admin role required. + + Get the currently active profile. operationId: get_active_profile_profile_active_get responses: - "200": + '200': description: Successful Response content: application/json: schema: {} + security: + - frigateAdminAuth: [] + x-required-role: admin /ffmpeg/presets: get: tags: - App summary: Ffmpeg Presets - description: Return available ffmpeg preset keys for config UI usage. + description: |- + **Access:** Admin role required. + + Return available ffmpeg preset keys for config UI usage. operationId: ffmpeg_presets_ffmpeg_presets_get responses: - "200": + '200': description: Successful Response content: application/json: schema: {} + security: + - frigateAdminAuth: [] + x-required-role: admin /config/raw_paths: get: tags: - App summary: Config Raw Paths - description: >- - Admin-only endpoint that returns camera paths and go2rtc streams without - credential masking. + description: |- + **Access:** Admin role required. + + Admin-only endpoint that returns camera paths and go2rtc streams without credential masking. operationId: config_raw_paths_config_raw_paths_get responses: - "200": + '200': description: Successful Response content: application/json: schema: {} + security: + - frigateAdminAuth: [] + x-required-role: admin /config/raw: get: tags: @@ -2027,11 +2535,15 @@ paths: summary: Config Raw operationId: config_raw_config_raw_get responses: - "200": + '200': description: Successful Response content: application/json: schema: {} + security: + - frigateAdminAuth: [] + x-required-role: admin + description: '**Access:** Admin role required.' /config/save: post: tags: @@ -2052,17 +2564,21 @@ paths: schema: title: Body responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + description: '**Access:** Admin role required.' /config/set: put: tags: @@ -2074,60 +2590,23 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/AppConfigSetBody" + $ref: '#/components/schemas/AppConfigSetBody' responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - /genai/models: - get: - tags: - - App - summary: List available GenAI models - description: Returns available models for each configured GenAI provider. - operationId: genai_models_genai_models_get - responses: - "200": - description: Successful Response - content: - application/json: - schema: {} - /genai/probe: - post: - tags: - - App - summary: Probe a GenAI provider without saving config - description: >- - Builds a transient client from the request body and returns its - available models. Used to validate provider credentials in the UI - before saving the configuration. Requires admin role. - operationId: genai_probe_genai_probe_post - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/GenAIProbeBody" - responses: - "200": - description: Successful Response - content: - application/json: - schema: {} - "422": - description: Validation Error - content: - application/json: - schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + description: '**Access:** Admin role required.' /vainfo: get: tags: @@ -2135,11 +2614,15 @@ paths: summary: Vainfo operationId: vainfo_vainfo_get responses: - "200": + '200': description: Successful Response content: application/json: schema: {} + security: + - frigateUserAuth: [] + x-required-role: any + description: '**Access:** Any authenticated user.' /nvinfo: get: tags: @@ -2147,18 +2630,25 @@ paths: summary: Nvinfo operationId: nvinfo_nvinfo_get responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "/logs/{service}": + security: + - frigateUserAuth: [] + x-required-role: any + description: '**Access:** Any authenticated user.' + /logs/{service}: get: tags: - App - Logs summary: Logs - description: Get logs for the requested service (frigate/nginx/go2rtc) + description: |- + **Access:** Admin role required. + + Get logs for the requested service (frigate/nginx/go2rtc) operationId: logs_logs__service__get parameters: - name: service @@ -2177,7 +2667,7 @@ paths: schema: anyOf: - type: string - - type: "null" + - type: 'null' title: Download - name: stream in: query @@ -2185,7 +2675,7 @@ paths: schema: anyOf: - type: boolean - - type: "null" + - type: 'null' default: false title: Stream - name: start @@ -2194,7 +2684,7 @@ paths: schema: anyOf: - type: integer - - type: "null" + - type: 'null' default: 0 title: Start - name: end @@ -2203,20 +2693,23 @@ paths: schema: anyOf: - type: integer - - type: "null" + - type: 'null' title: End responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin /restart: post: tags: @@ -2224,19 +2717,24 @@ paths: summary: Restart operationId: restart_restart_post responses: - "200": + '200': description: Successful Response content: application/json: schema: {} + security: + - frigateAdminAuth: [] + x-required-role: admin + description: '**Access:** Admin role required.' /media/sync: post: tags: - App summary: Start media sync job - description: >- - Start an asynchronous media sync job to find and (optionally) remove - orphaned media files. + description: |- + **Access:** Admin role required. + + Start an asynchronous media sync job to find and (optionally) remove orphaned media files. Returns 202 with job details when queued, or 409 if a job is already running. operationId: sync_media_media_sync_post requestBody: @@ -2244,43 +2742,51 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/MediaSyncBody" + $ref: '#/components/schemas/MediaSyncBody' responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin /media/sync/current: get: tags: - App summary: Get current media sync job - description: >- - Retrieve the current running media sync job, if any. Returns the job - details + description: |- + **Access:** Admin role required. + + Retrieve the current running media sync job, if any. Returns the job details or null when no job is active. operationId: get_media_sync_current_media_sync_current_get responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "/media/sync/status/{job_id}": + security: + - frigateAdminAuth: [] + x-required-role: admin + /media/sync/status/{job_id}: get: tags: - App summary: Get media sync job status - description: >- - Get status and results for the specified media sync job id. Returns 200 - with + description: |- + **Access:** Admin role required. + + Get status and results for the specified media sync job id. Returns 200 with job details including results, or 404 if the job is not found. operationId: get_media_sync_status_media_sync_status__job_id__get parameters: @@ -2291,17 +2797,20 @@ paths: type: string title: Job Id responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin /labels: get: tags: @@ -2314,20 +2823,24 @@ paths: required: false schema: type: string - default: "" + default: '' title: Camera responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: any + description: '**Access:** Any authenticated user.' /sub_labels: get: tags: @@ -2341,20 +2854,24 @@ paths: schema: anyOf: - type: integer - - type: "null" + - type: 'null' title: Split Joined responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: any + description: '**Access:** Any authenticated user.' /audio_labels: get: tags: @@ -2362,11 +2879,15 @@ paths: summary: Get Audio Labels operationId: get_audio_labels_audio_labels_get responses: - "200": + '200': description: Successful Response content: application/json: schema: {} + security: + - frigateAdminAuth: [] + x-required-role: admin + description: '**Access:** Admin role required.' /plus/models: get: tags: @@ -2382,17 +2903,21 @@ paths: default: false title: Filterbycurrentmodeldetector responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: any + description: '**Access:** Any authenticated user.' /recognized_license_plates: get: tags: @@ -2406,20 +2931,24 @@ paths: schema: anyOf: - type: integer - - type: "null" + - type: 'null' title: Split Joined responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: any + description: '**Access:** Any authenticated user.' /timeline: get: tags: @@ -2447,26 +2976,33 @@ paths: schema: anyOf: - type: string - - type: "null" + - type: 'null' title: Source Id responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: any + description: '**Access:** Any authenticated user.' /timeline/hourly: get: tags: - App summary: Hourly Timeline - description: Get hourly summary for timeline. + description: |- + **Access:** Any authenticated user. + + Get hourly summary for timeline. operationId: hourly_timeline_timeline_hourly_get parameters: - name: cameras @@ -2475,7 +3011,7 @@ paths: schema: anyOf: - type: string - - type: "null" + - type: 'null' default: all title: Cameras - name: labels @@ -2484,7 +3020,7 @@ paths: schema: anyOf: - type: string - - type: "null" + - type: 'null' default: all title: Labels - name: after @@ -2493,7 +3029,7 @@ paths: schema: anyOf: - type: number - - type: "null" + - type: 'null' title: After - name: before in: query @@ -2501,7 +3037,7 @@ paths: schema: anyOf: - type: number - - type: "null" + - type: 'null' title: Before - name: limit in: query @@ -2509,7 +3045,7 @@ paths: schema: anyOf: - type: integer - - type: "null" + - type: 'null' default: 200 title: Limit - name: timezone @@ -2518,32 +3054,38 @@ paths: schema: anyOf: - type: string - - type: "null" + - type: 'null' default: utc title: Timezone responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - "/preview/{camera_name}/start/{start_ts}/end/{end_ts}": + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: any + /preview/{camera_name}/start/{start_ts}/end/{end_ts}: get: tags: - Preview summary: Get preview clips for time range description: |- + **Access:** Any authenticated user. + Gets all preview clips for a specified camera and time range. Returns a list of preview video clips that overlap with the requested time period, ordered by start time. Use camera_name='all' to get previews from all cameras. Returns an error if no previews are found. - operationId: preview_ts_preview__camera_name__start__start_ts__end__end_ts__get + operationId: + preview_ts_preview__camera_name__start__start_ts__end__end_ts__get parameters: - name: camera_name in: path @@ -2564,34 +3106,38 @@ paths: type: number title: End Ts responses: - "200": + '200': description: Successful Response content: application/json: schema: type: array items: - $ref: "#/components/schemas/PreviewModel" - title: >- - Response Preview Ts Preview Camera Name Start Start Ts + $ref: '#/components/schemas/PreviewModel' + title: Response Preview Ts Preview Camera Name Start Start Ts End End Ts Get - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - "/preview/{year_month}/{day}/{hour}/{camera_name}/{tz_name}": + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: any + /preview/{year_month}/{day}/{hour}/{camera_name}/{tz_name}: get: tags: - Preview summary: Get preview clips for specific hour description: |- + **Access:** Any authenticated user. + Gets all preview clips for a specific hour in a given timezone. Converts the provided date/time from the specified timezone to UTC and retrieves all preview clips for that hour. Use camera_name='all' to get previews from all cameras. The tz_name should be a timezone like 'America/New_York' (use commas instead of slashes). - operationId: >- + operationId: preview_hour_preview__year_month___day___hour___camera_name___tz_name__get parameters: - name: year_month @@ -2625,34 +3171,37 @@ paths: type: string title: Tz Name responses: - "200": + '200': description: Successful Response content: application/json: schema: type: array items: - $ref: "#/components/schemas/PreviewModel" - title: >- - Response Preview Hour Preview Year Month Day Hour + $ref: '#/components/schemas/PreviewModel' + title: Response Preview Hour Preview Year Month Day Hour Camera Name Tz Name Get - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - "/preview/{camera_name}/start/{start_ts}/end/{end_ts}/frames": + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: any + /preview/{camera_name}/start/{start_ts}/end/{end_ts}/frames: get: tags: - Preview summary: Get cached preview frame filenames - description: >- - Gets a list of cached preview frame filenames for a specific camera and - time range. + description: |- + **Access:** Authenticated user with access to the referenced camera. + + Gets a list of cached preview frame filenames for a specific camera and time range. Returns an array of filenames for preview frames that fall within the specified time period, sorted in chronological order. These are individual frame images cached for quick preview display. - operationId: >- + operationId: get_preview_frames_from_cache_preview__camera_name__start__start_ts__end__end_ts__frames_get parameters: - name: camera_name @@ -2661,7 +3210,7 @@ paths: schema: anyOf: - type: string - - type: "null" + - type: 'null' title: Camera Name - name: start_ts in: path @@ -2676,7 +3225,7 @@ paths: type: number title: End Ts responses: - "200": + '200': description: Successful Response content: application/json: @@ -2684,36 +3233,45 @@ paths: type: array items: type: string - title: >- - Response Get Preview Frames From Cache Preview Camera Name - Start Start Ts End End Ts Frames Get - "422": + title: Response Get Preview Frames From Cache Preview Camera + Name Start Start Ts End End Ts Frames Get + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera /notifications/pubkey: get: tags: - Notifications summary: Get VAPID public key description: |- + **Access:** Any authenticated user. + Gets the VAPID public key for the notifications. Returns the public key or an error if notifications are not enabled. operationId: get_vapid_pub_key_notifications_pubkey_get responses: - "200": + '200': description: Successful Response content: application/json: schema: {} + security: + - frigateUserAuth: [] + x-required-role: any /notifications/register: post: tags: - Notifications summary: Register notifications description: |- + **Access:** Any authenticated user. + Registers a notifications subscription. Returns a success message or an error if the subscription is not provided. operationId: register_notifications_notifications_register_post @@ -2724,23 +3282,28 @@ paths: type: object title: Body responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: any /exports: get: tags: - Export summary: Get exports description: |- + **Access:** Any authenticated user. + Gets all exports from the database for cameras the user has access to. Returns a list of exports ordered by date (most recent first). operationId: get_exports_exports_get @@ -2751,7 +3314,7 @@ paths: schema: anyOf: - type: string - - type: "null" + - type: 'null' title: Export Case Id - name: cameras in: query @@ -2759,7 +3322,7 @@ paths: schema: anyOf: - type: string - - type: "null" + - type: 'null' default: all title: Cameras - name: start_date @@ -2768,7 +3331,7 @@ paths: schema: anyOf: - type: number - - type: "null" + - type: 'null' title: Start Date - name: end_date in: query @@ -2776,201 +3339,90 @@ paths: schema: anyOf: - type: number - - type: "null" + - type: 'null' title: End Date responses: - "200": + '200': description: Successful Response content: application/json: schema: type: array items: - $ref: "#/components/schemas/ExportModel" + $ref: '#/components/schemas/ExportModel' title: Response Get Exports Exports Get - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - /exports/batch: - post: - tags: - - Export - summary: Start recording export batch - description: >- - Starts recording exports for a batch of items, each with its own camera - and time range. Optionally assigns them to a new or existing export case. - When neither export_case_id nor new_case_name is provided, exports are - added as uncategorized. Attaching to an existing case is admin-only. - operationId: export_recordings_batch_exports_batch_post - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/BatchExportBody" - responses: - "202": - description: Successful Response - content: - application/json: - schema: - $ref: "#/components/schemas/BatchExportResponse" - "400": - description: Bad Request - content: - application/json: - schema: - $ref: "#/components/schemas/GenericResponse" - "403": - description: Forbidden - content: - application/json: - schema: - $ref: "#/components/schemas/GenericResponse" - "404": - description: Not Found - content: - application/json: - schema: - $ref: "#/components/schemas/GenericResponse" - "503": - description: Service Unavailable - content: - application/json: - schema: - $ref: "#/components/schemas/GenericResponse" - "422": - description: Validation Error - content: - application/json: - schema: - $ref: "#/components/schemas/HTTPValidationError" - /exports/delete: - post: - tags: - - Export - summary: Bulk delete exports - description: >- - Deletes one or more exports by ID. All IDs must exist and none can be - in-progress. Admin-only. - operationId: bulk_delete_exports_exports_delete_post - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/ExportBulkDeleteBody" - responses: - "200": - description: Successful Response - content: - application/json: - schema: - $ref: "#/components/schemas/GenericResponse" - "400": - description: Bad Request - one or more exports are in-progress - content: - application/json: - schema: - $ref: "#/components/schemas/GenericResponse" - "404": - description: Not Found - one or more export IDs do not exist - content: - application/json: - schema: - $ref: "#/components/schemas/GenericResponse" - "422": - description: Validation Error - content: - application/json: - schema: - $ref: "#/components/schemas/HTTPValidationError" - /exports/reassign: - post: - tags: - - Export - summary: Bulk reassign exports to a case - description: >- - Assigns or unassigns one or more exports to/from a case. All IDs must - exist. Pass export_case_id as null to unassign (move to uncategorized). - Admin-only. - operationId: bulk_reassign_exports_exports_reassign_post - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/ExportBulkReassignBody" - responses: - "200": - description: Successful Response - content: - application/json: - schema: - $ref: "#/components/schemas/GenericResponse" - "404": - description: Not Found - one or more export IDs or the target case do not exist - content: - application/json: - schema: - $ref: "#/components/schemas/GenericResponse" - "422": - description: Validation Error - content: - application/json: - schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: any /cases: get: tags: - Export summary: Get export cases - description: Gets all export cases from the database. + description: |- + **Access:** Any authenticated user. + + Gets all export cases from the database. operationId: get_export_cases_cases_get responses: - "200": + '200': description: Successful Response content: application/json: schema: type: array items: - $ref: "#/components/schemas/ExportCaseModel" + $ref: '#/components/schemas/ExportCaseModel' title: Response Get Export Cases Cases Get + security: + - frigateUserAuth: [] + x-required-role: any post: tags: - Export summary: Create export case - description: Creates a new export case. + description: |- + **Access:** Admin role required. + + Creates a new export case. operationId: create_export_case_cases_post requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/ExportCaseCreateBody" + $ref: '#/components/schemas/ExportCaseCreateBody' responses: - "200": + '200': description: Successful Response content: application/json: schema: - $ref: "#/components/schemas/ExportCaseModel" - "422": + $ref: '#/components/schemas/ExportCaseModel' + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - "/cases/{case_id}": + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /cases/{case_id}: get: tags: - Export summary: Get a single export case - description: Gets a specific export case by ID. + description: |- + **Access:** Any authenticated user. + + Gets a specific export case by ID. operationId: get_export_case_cases__case_id__get parameters: - name: case_id @@ -2980,23 +3432,29 @@ paths: type: string title: Case Id responses: - "200": + '200': description: Successful Response content: application/json: schema: - $ref: "#/components/schemas/ExportCaseModel" - "422": + $ref: '#/components/schemas/ExportCaseModel' + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: any patch: tags: - Export summary: Update export case - description: Updates an existing export case. + description: |- + **Access:** Admin role required. + + Updates an existing export case. operationId: update_export_case_cases__case_id__patch parameters: - name: case_id @@ -3010,25 +3468,30 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/ExportCaseUpdateBody" + $ref: '#/components/schemas/ExportCaseUpdateBody' responses: - "200": + '200': description: Successful Response content: application/json: schema: - $ref: "#/components/schemas/GenericResponse" - "422": + $ref: '#/components/schemas/GenericResponse' + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin delete: tags: - Export summary: Delete export case description: |- + **Access:** Admin role required. + Deletes an export case. Exports that reference this case will have their export_case set to null. operationId: delete_export_case_cases__case_id__delete @@ -3039,30 +3502,162 @@ paths: schema: type: string title: Case Id + - name: delete_exports + in: query + required: false + schema: + type: boolean + default: false + title: Delete Exports responses: - "200": + '200': description: Successful Response content: application/json: schema: - $ref: "#/components/schemas/GenericResponse" - "422": + $ref: '#/components/schemas/GenericResponse' + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - "/export/{camera_name}/start/{start_time}/end/{end_time}": + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /cases/{case_id}/download: + get: + tags: + - Export + summary: Download export case as zip + description: |- + **Access:** Any authenticated user. + + Streams a zip archive containing every completed export's mp4 for the given case. + operationId: download_export_case_cases__case_id__download_get + parameters: + - name: case_id + in: path + required: true + schema: + type: string + title: Case Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: any + /jobs/export: + get: + tags: + - Export + summary: Get active export jobs + description: |- + **Access:** Any authenticated user. + + Gets queued and running export jobs. + operationId: get_active_export_jobs_jobs_export_get + responses: + '200': + description: Successful Response + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/ExportJobModel' + title: Response Get Active Export Jobs Jobs Export Get + security: + - frigateUserAuth: [] + x-required-role: any + /jobs/export/{export_id}: + get: + tags: + - Export + summary: Get export job status + description: |- + **Access:** Authenticated user with access to the referenced camera. + + Gets queued, running, or completed status for a specific export job. + operationId: get_export_job_status_jobs_export__export_id__get + parameters: + - name: export_id + in: path + required: true + schema: + type: string + title: Export Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/ExportJobModel' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera + /exports/batch: + post: + tags: + - Export + summary: Start recording export batch + description: |- + **Access:** Any authenticated user. + + Starts recording exports for a batch of items, each with its own camera and time range, and assigns them to a single export case. Attaching to an existing case is temporarily admin-only until case-level ACLs exist. + operationId: export_recordings_batch_exports_batch_post + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BatchExportBody' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/BatchExportResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: any + /export/{camera_name}/start/{start_time}/end/{end_time}: post: tags: - Export summary: Start recording export description: |- + **Access:** Authenticated user with access to the referenced camera. + Starts an export of a recording for the specified time range. The export can be from recordings or preview footage. Returns the export ID if successful, or an error message if the camera is invalid or no recordings/previews are found for the time range. - operationId: >- + operationId: export_recording_export__camera_name__start__start_time__end__end_time__post parameters: - name: camera_name @@ -3071,7 +3666,7 @@ paths: schema: anyOf: - type: string - - type: "null" + - type: 'null' title: Camera Name - name: start_time in: path @@ -3090,26 +3685,31 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/ExportRecordingsBody" + $ref: '#/components/schemas/ExportRecordingsBody' responses: - "200": + '200': description: Successful Response content: application/json: schema: - $ref: "#/components/schemas/StartExportResponse" - "422": + $ref: '#/components/schemas/StartExportResponse' + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - "/export/{event_id}/rename": + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera + /export/{event_id}/rename: patch: tags: - Export summary: Rename export description: |- + **Access:** Admin role required. + Renames an export. NOTE: This changes the friendly name of the export, not the filename. operationId: export_rename_export__event_id__rename_patch @@ -3125,33 +3725,37 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/ExportRenameBody" + $ref: '#/components/schemas/ExportRenameBody' responses: - "200": + '200': description: Successful Response content: application/json: schema: - $ref: "#/components/schemas/GenericResponse" - "422": + $ref: '#/components/schemas/GenericResponse' + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - "/export/custom/{camera_name}/start/{start_time}/end/{end_time}": + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /export/custom/{camera_name}/start/{start_time}/end/{end_time}: post: tags: - Export summary: Start custom recording export - description: >- - Starts an export of a recording for the specified time range using - custom FFmpeg arguments. + description: |- + **Access:** Authenticated user with access to the referenced camera. + + Starts an export of a recording for the specified time range using custom FFmpeg arguments. The export can be from recordings or preview footage. Returns the export ID if successful, or an error message if the camera is invalid or no recordings/previews are found for the time range. If ffmpeg_input_args and ffmpeg_output_args are not provided, defaults to timelapse export settings. - operationId: >- + operationId: export_recording_custom_export_custom__camera_name__start__start_time__end__end_time__post parameters: - name: camera_name @@ -3160,7 +3764,7 @@ paths: schema: anyOf: - type: string - - type: "null" + - type: 'null' title: Camera Name - name: start_time in: path @@ -3179,26 +3783,31 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/ExportRecordingsCustomBody" + $ref: '#/components/schemas/ExportRecordingsCustomBody' responses: - "200": + '200': description: Successful Response content: application/json: schema: - $ref: "#/components/schemas/StartExportResponse" - "422": + $ref: '#/components/schemas/StartExportResponse' + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - "/exports/{export_id}": + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera + /exports/{export_id}: get: tags: - Export summary: Get a single export description: |- + **Access:** Authenticated user with access to the referenced camera. + Gets a specific export by ID. The user must have access to the camera associated with the export. operationId: get_export_exports__export_id__get @@ -3210,24 +3819,94 @@ paths: type: string title: Export Id responses: - "200": + '200': description: Successful Response content: application/json: schema: - $ref: "#/components/schemas/ExportModel" - "422": + $ref: '#/components/schemas/ExportModel' + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera + /exports/delete: + post: + tags: + - Export + summary: Bulk delete exports + description: |- + **Access:** Admin role required. + + Deletes one or more exports by ID. All IDs must exist and none can be in-progress. + operationId: bulk_delete_exports_exports_delete_post + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ExportBulkDeleteBody' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/GenericResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /exports/reassign: + post: + tags: + - Export + summary: Bulk reassign exports to a case + description: |- + **Access:** Admin role required. + + Assigns or unassigns one or more exports to/from a case. All IDs must exist. + operationId: bulk_reassign_exports_exports_reassign_post + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ExportBulkReassignBody' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/GenericResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin /events: get: tags: - Events summary: Get events - description: Returns a list of events. + description: |- + **Access:** Any authenticated user. + + Returns a list of events. operationId: events_events_get parameters: - name: camera @@ -3236,7 +3915,7 @@ paths: schema: anyOf: - type: string - - type: "null" + - type: 'null' default: all title: Camera - name: cameras @@ -3245,7 +3924,7 @@ paths: schema: anyOf: - type: string - - type: "null" + - type: 'null' default: all title: Cameras - name: label @@ -3254,7 +3933,7 @@ paths: schema: anyOf: - type: string - - type: "null" + - type: 'null' default: all title: Label - name: labels @@ -3263,7 +3942,7 @@ paths: schema: anyOf: - type: string - - type: "null" + - type: 'null' default: all title: Labels - name: sub_label @@ -3272,7 +3951,7 @@ paths: schema: anyOf: - type: string - - type: "null" + - type: 'null' default: all title: Sub Label - name: sub_labels @@ -3281,7 +3960,7 @@ paths: schema: anyOf: - type: string - - type: "null" + - type: 'null' default: all title: Sub Labels - name: attributes @@ -3290,7 +3969,7 @@ paths: schema: anyOf: - type: string - - type: "null" + - type: 'null' default: all title: Attributes - name: zone @@ -3299,7 +3978,7 @@ paths: schema: anyOf: - type: string - - type: "null" + - type: 'null' default: all title: Zone - name: zones @@ -3308,7 +3987,7 @@ paths: schema: anyOf: - type: string - - type: "null" + - type: 'null' default: all title: Zones - name: limit @@ -3317,7 +3996,7 @@ paths: schema: anyOf: - type: integer - - type: "null" + - type: 'null' default: 100 title: Limit - name: after @@ -3326,7 +4005,7 @@ paths: schema: anyOf: - type: number - - type: "null" + - type: 'null' title: After - name: before in: query @@ -3334,7 +4013,7 @@ paths: schema: anyOf: - type: number - - type: "null" + - type: 'null' title: Before - name: time_range in: query @@ -3342,8 +4021,8 @@ paths: schema: anyOf: - type: string - - type: "null" - default: "00:00,24:00" + - type: 'null' + default: 00:00,24:00 title: Time Range - name: has_clip in: query @@ -3351,7 +4030,7 @@ paths: schema: anyOf: - type: integer - - type: "null" + - type: 'null' title: Has Clip - name: has_snapshot in: query @@ -3359,7 +4038,7 @@ paths: schema: anyOf: - type: integer - - type: "null" + - type: 'null' title: Has Snapshot - name: in_progress in: query @@ -3367,7 +4046,7 @@ paths: schema: anyOf: - type: integer - - type: "null" + - type: 'null' title: In Progress - name: include_thumbnails in: query @@ -3375,7 +4054,7 @@ paths: schema: anyOf: - type: integer - - type: "null" + - type: 'null' default: 1 title: Include Thumbnails - name: favorites @@ -3384,7 +4063,7 @@ paths: schema: anyOf: - type: integer - - type: "null" + - type: 'null' title: Favorites - name: min_score in: query @@ -3392,7 +4071,7 @@ paths: schema: anyOf: - type: number - - type: "null" + - type: 'null' title: Min Score - name: max_score in: query @@ -3400,7 +4079,7 @@ paths: schema: anyOf: - type: number - - type: "null" + - type: 'null' title: Max Score - name: min_speed in: query @@ -3408,7 +4087,7 @@ paths: schema: anyOf: - type: number - - type: "null" + - type: 'null' title: Min Speed - name: max_speed in: query @@ -3416,7 +4095,7 @@ paths: schema: anyOf: - type: number - - type: "null" + - type: 'null' title: Max Speed - name: recognized_license_plate in: query @@ -3424,7 +4103,7 @@ paths: schema: anyOf: - type: string - - type: "null" + - type: 'null' default: all title: Recognized License Plate - name: is_submitted @@ -3433,7 +4112,7 @@ paths: schema: anyOf: - type: integer - - type: "null" + - type: 'null' title: Is Submitted - name: min_length in: query @@ -3441,7 +4120,7 @@ paths: schema: anyOf: - type: number - - type: "null" + - type: 'null' title: Min Length - name: max_length in: query @@ -3449,7 +4128,7 @@ paths: schema: anyOf: - type: number - - type: "null" + - type: 'null' title: Max Length - name: event_id in: query @@ -3457,7 +4136,7 @@ paths: schema: anyOf: - type: string - - type: "null" + - type: 'null' title: Event Id - name: sort in: query @@ -3465,7 +4144,7 @@ paths: schema: anyOf: - type: string - - type: "null" + - type: 'null' title: Sort - name: timezone in: query @@ -3473,31 +4152,36 @@ paths: schema: anyOf: - type: string - - type: "null" + - type: 'null' default: utc title: Timezone responses: - "200": + '200': description: Successful Response content: application/json: schema: type: array items: - $ref: "#/components/schemas/EventResponse" + $ref: '#/components/schemas/EventResponse' title: Response Events Events Get - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: any /events/explore: get: tags: - Events summary: Get summary of objects description: |- + **Access:** Any authenticated user. + Gets a summary of objects from the database. Returns a list of objects with a max of `limit` objects for each label. operationId: events_explore_events_explore_get @@ -3510,27 +4194,32 @@ paths: default: 10 title: Limit responses: - "200": + '200': description: Successful Response content: application/json: schema: type: array items: - $ref: "#/components/schemas/EventResponse" + $ref: '#/components/schemas/EventResponse' title: Response Events Explore Events Explore Get - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: any /event_ids: get: tags: - Events summary: Get events by ids description: |- + **Access:** Authenticated user with access to the referenced camera. + Gets events by a list of ids. Returns a list of events. operationId: event_ids_event_ids_get @@ -3542,27 +4231,32 @@ paths: type: string title: Ids responses: - "200": + '200': description: Successful Response content: application/json: schema: type: array items: - $ref: "#/components/schemas/EventResponse" + $ref: '#/components/schemas/EventResponse' title: Response Event Ids Event Ids Get - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera /events/search: get: tags: - Events summary: Search events description: |- + **Access:** Any authenticated user. + Searches for events in the database. Returns a list of events. operationId: events_search_events_search_get @@ -3573,7 +4267,7 @@ paths: schema: anyOf: - type: string - - type: "null" + - type: 'null' title: Query - name: event_id in: query @@ -3581,7 +4275,7 @@ paths: schema: anyOf: - type: string - - type: "null" + - type: 'null' title: Event Id - name: search_type in: query @@ -3589,7 +4283,7 @@ paths: schema: anyOf: - type: string - - type: "null" + - type: 'null' default: thumbnail title: Search Type - name: include_thumbnails @@ -3598,7 +4292,7 @@ paths: schema: anyOf: - type: integer - - type: "null" + - type: 'null' default: 1 title: Include Thumbnails - name: limit @@ -3607,7 +4301,7 @@ paths: schema: anyOf: - type: integer - - type: "null" + - type: 'null' default: 50 title: Limit - name: cameras @@ -3616,7 +4310,7 @@ paths: schema: anyOf: - type: string - - type: "null" + - type: 'null' default: all title: Cameras - name: labels @@ -3625,7 +4319,7 @@ paths: schema: anyOf: - type: string - - type: "null" + - type: 'null' default: all title: Labels - name: sub_labels @@ -3634,7 +4328,7 @@ paths: schema: anyOf: - type: string - - type: "null" + - type: 'null' default: all title: Sub Labels - name: attributes @@ -3643,7 +4337,7 @@ paths: schema: anyOf: - type: string - - type: "null" + - type: 'null' default: all title: Attributes - name: zones @@ -3652,7 +4346,7 @@ paths: schema: anyOf: - type: string - - type: "null" + - type: 'null' default: all title: Zones - name: after @@ -3661,7 +4355,7 @@ paths: schema: anyOf: - type: number - - type: "null" + - type: 'null' title: After - name: before in: query @@ -3669,7 +4363,7 @@ paths: schema: anyOf: - type: number - - type: "null" + - type: 'null' title: Before - name: time_range in: query @@ -3677,8 +4371,8 @@ paths: schema: anyOf: - type: string - - type: "null" - default: "00:00,24:00" + - type: 'null' + default: 00:00,24:00 title: Time Range - name: has_clip in: query @@ -3686,7 +4380,7 @@ paths: schema: anyOf: - type: boolean - - type: "null" + - type: 'null' title: Has Clip - name: has_snapshot in: query @@ -3694,7 +4388,7 @@ paths: schema: anyOf: - type: boolean - - type: "null" + - type: 'null' title: Has Snapshot - name: is_submitted in: query @@ -3702,7 +4396,7 @@ paths: schema: anyOf: - type: boolean - - type: "null" + - type: 'null' title: Is Submitted - name: timezone in: query @@ -3710,7 +4404,7 @@ paths: schema: anyOf: - type: string - - type: "null" + - type: 'null' default: utc title: Timezone - name: min_score @@ -3719,7 +4413,7 @@ paths: schema: anyOf: - type: number - - type: "null" + - type: 'null' title: Min Score - name: max_score in: query @@ -3727,7 +4421,7 @@ paths: schema: anyOf: - type: number - - type: "null" + - type: 'null' title: Max Score - name: min_speed in: query @@ -3735,7 +4429,7 @@ paths: schema: anyOf: - type: number - - type: "null" + - type: 'null' title: Min Speed - name: max_speed in: query @@ -3743,7 +4437,7 @@ paths: schema: anyOf: - type: number - - type: "null" + - type: 'null' title: Max Speed - name: recognized_license_plate in: query @@ -3751,7 +4445,7 @@ paths: schema: anyOf: - type: string - - type: "null" + - type: 'null' default: all title: Recognized License Plate - name: sort @@ -3760,20 +4454,23 @@ paths: schema: anyOf: - type: string - - type: "null" + - type: 'null' title: Sort responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: any /events/summary: get: tags: @@ -3787,7 +4484,7 @@ paths: schema: anyOf: - type: string - - type: "null" + - type: 'null' default: utc title: Timezone - name: has_clip @@ -3796,7 +4493,7 @@ paths: schema: anyOf: - type: integer - - type: "null" + - type: 'null' title: Has Clip - name: has_snapshot in: query @@ -3804,26 +4501,33 @@ paths: schema: anyOf: - type: integer - - type: "null" + - type: 'null' title: Has Snapshot responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - "/events/{event_id}": + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: any + description: '**Access:** Any authenticated user.' + /events/{event_id}: get: tags: - Events summary: Get event by id - description: Gets an event by its id. + description: |- + **Access:** Authenticated user with access to the referenced camera. + + Gets an event by its id. operationId: event_events__event_id__get parameters: - name: event_id @@ -3833,23 +4537,28 @@ paths: type: string title: Event Id responses: - "200": + '200': description: Successful Response content: application/json: schema: - $ref: "#/components/schemas/EventResponse" - "422": + $ref: '#/components/schemas/EventResponse' + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera delete: tags: - Events summary: Delete event description: |- + **Access:** Admin role required. + Deletes an event from the database. Returns a success message or an error if the event is not found. operationId: delete_event_events__event_id__delete @@ -3861,24 +4570,29 @@ paths: type: string title: Event Id responses: - "200": + '200': description: Successful Response content: application/json: schema: - $ref: "#/components/schemas/GenericResponse" - "422": + $ref: '#/components/schemas/GenericResponse' + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - "/events/{event_id}/retain": + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /events/{event_id}/retain: post: tags: - Events summary: Set event retain indefinitely. description: |- + **Access:** Admin role required. + Sets an event to retain indefinitely. Returns a success message or an error if the event is not found. NOTE: This is a legacy endpoint and is not supported in the frontend. @@ -3891,23 +4605,28 @@ paths: type: string title: Event Id responses: - "200": + '200': description: Successful Response content: application/json: schema: - $ref: "#/components/schemas/GenericResponse" - "422": + $ref: '#/components/schemas/GenericResponse' + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin delete: tags: - Events summary: Stop event from being retained indefinitely description: |- + **Access:** Admin role required. + Stops an event from being retained indefinitely. Returns a success message or an error if the event is not found. NOTE: This is a legacy endpoint and is not supported in the frontend. @@ -3920,24 +4639,29 @@ paths: type: string title: Event Id responses: - "200": + '200': description: Successful Response content: application/json: schema: - $ref: "#/components/schemas/GenericResponse" - "422": + $ref: '#/components/schemas/GenericResponse' + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - "/events/{event_id}/plus": + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /events/{event_id}/plus: post: tags: - Events summary: Send event to Frigate+ description: |- + **Access:** Admin role required. + Sends an event to Frigate+. Returns a success message or an error if the event is not found. operationId: send_to_plus_events__event_id__plus_post @@ -3952,26 +4676,31 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/SubmitPlusBody" + $ref: '#/components/schemas/SubmitPlusBody' responses: - "200": + '200': description: Successful Response content: application/json: schema: - $ref: "#/components/schemas/EventUploadPlusResponse" - "422": + $ref: '#/components/schemas/EventUploadPlusResponse' + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - "/events/{event_id}/false_positive": + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /events/{event_id}/false_positive: put: tags: - Events summary: Submit false positive to Frigate+ description: |- + **Access:** Admin role required. + Submit an event as a false positive to Frigate+. This endpoint is the same as the standard Frigate+ submission endpoint, but is specifically for marking an event as a false positive. @@ -3984,24 +4713,29 @@ paths: type: string title: Event Id responses: - "200": + '200': description: Successful Response content: application/json: schema: - $ref: "#/components/schemas/EventUploadPlusResponse" - "422": + $ref: '#/components/schemas/EventUploadPlusResponse' + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - "/events/{event_id}/sub_label": + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /events/{event_id}/sub_label: post: tags: - Events summary: Set event sub label description: |- + **Access:** Admin role required. + Sets an event's sub label. Returns a success message or an error if the event is not found. operationId: set_sub_label_events__event_id__sub_label_post @@ -4017,26 +4751,31 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/EventsSubLabelBody" + $ref: '#/components/schemas/EventsSubLabelBody' responses: - "200": + '200': description: Successful Response content: application/json: schema: - $ref: "#/components/schemas/GenericResponse" - "422": + $ref: '#/components/schemas/GenericResponse' + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - "/events/{event_id}/recognized_license_plate": + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /events/{event_id}/recognized_license_plate: post: tags: - Events summary: Set event license plate description: |- + **Access:** Admin role required. + Sets an event's license plate. Returns a success message or an error if the event is not found. operationId: set_plate_events__event_id__recognized_license_plate_post @@ -4052,28 +4791,32 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/EventsLPRBody" + $ref: '#/components/schemas/EventsLPRBody' responses: - "200": + '200': description: Successful Response content: application/json: schema: - $ref: "#/components/schemas/GenericResponse" - "422": + $ref: '#/components/schemas/GenericResponse' + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - "/events/{event_id}/attributes": + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /events/{event_id}/attributes: post: tags: - Events summary: Set custom classification attributes - description: >- - Sets an event's custom classification attributes for all attribute-type - models that apply to the event's object type. + description: |- + **Access:** Admin role required. + + Sets an event's custom classification attributes for all attribute-type models that apply to the event's object type. operationId: set_attributes_events__event_id__attributes_post parameters: - name: event_id @@ -4087,26 +4830,31 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/EventsAttributesBody" + $ref: '#/components/schemas/EventsAttributesBody' responses: - "200": + '200': description: Successful Response content: application/json: schema: - $ref: "#/components/schemas/GenericResponse" - "422": + $ref: '#/components/schemas/GenericResponse' + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - "/events/{event_id}/description": + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /events/{event_id}/description: post: tags: - Events summary: Set event description description: |- + **Access:** Admin role required. + Sets an event's description. Returns a success message or an error if the event is not found. operationId: set_description_events__event_id__description_post @@ -4122,29 +4870,35 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/EventsDescriptionBody" + $ref: '#/components/schemas/EventsDescriptionBody' responses: - "200": + '200': description: Successful Response content: application/json: schema: - $ref: "#/components/schemas/GenericResponse" - "422": + $ref: '#/components/schemas/GenericResponse' + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - "/events/{event_id}/description/regenerate": + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /events/{event_id}/description/regenerate: put: tags: - Events summary: Regenerate event description description: |- + **Access:** Admin role required. + Regenerates an event's description. Returns a success message or an error if the event is not found. - operationId: regenerate_description_events__event_id__description_regenerate_put + operationId: + regenerate_description_events__event_id__description_regenerate_put parameters: - name: event_id in: path @@ -4157,8 +4911,8 @@ paths: required: false schema: anyOf: - - $ref: "#/components/schemas/RegenerateDescriptionEnum" - - type: "null" + - $ref: '#/components/schemas/RegenerateDescriptionEnum' + - type: 'null' default: thumbnails title: Source - name: force @@ -4167,28 +4921,33 @@ paths: schema: anyOf: - type: boolean - - type: "null" + - type: 'null' default: false title: Force responses: - "200": + '200': description: Successful Response content: application/json: schema: - $ref: "#/components/schemas/GenericResponse" - "422": + $ref: '#/components/schemas/GenericResponse' + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin /description/generate: post: tags: - Events summary: Generate description embedding description: |- + **Access:** Admin role required. + Generates an embedding for an event's description. Returns a success message or an error if the event is not found. operationId: generate_description_embedding_description_generate_post @@ -4197,26 +4956,31 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/EventsDescriptionBody" + $ref: '#/components/schemas/EventsDescriptionBody' responses: - "200": + '200': description: Successful Response content: application/json: schema: - $ref: "#/components/schemas/GenericResponse" - "422": + $ref: '#/components/schemas/GenericResponse' + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin /events/: delete: tags: - Events summary: Delete events description: |- + **Access:** Admin role required. + Deletes a list of events from the database. Returns a success message or an error if the events are not found. operationId: delete_events_events__delete @@ -4225,26 +4989,31 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/EventsDeleteBody" + $ref: '#/components/schemas/EventsDeleteBody' responses: - "200": + '200': description: Successful Response content: application/json: schema: - $ref: "#/components/schemas/EventMultiDeleteResponse" - "422": + $ref: '#/components/schemas/EventMultiDeleteResponse' + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - "/events/{camera_name}/{label}/create": + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /events/{camera_name}/{label}/create: post: tags: - Events summary: Create manual event description: |- + **Access:** Admin role required. + Creates a manual event in the database. Returns a success message or an error if the event is not found. NOTES: @@ -4268,31 +5037,36 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/EventsCreateBody" + $ref: '#/components/schemas/EventsCreateBody' default: - score: 0 + score: 0.0 duration: 30 include_recording: true draw: {} responses: - "200": + '200': description: Successful Response content: application/json: schema: - $ref: "#/components/schemas/EventCreateResponse" - "422": + $ref: '#/components/schemas/EventCreateResponse' + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - "/events/{event_id}/end": + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /events/{event_id}/end: put: tags: - Events summary: End manual event description: |- + **Access:** Admin role required. + Ends a manual event. Returns a success message or an error if the event is not found. NOTE: This should only be used for manual events. @@ -4309,26 +5083,31 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/EventsEndBody" + $ref: '#/components/schemas/EventsEndBody' responses: - "200": + '200': description: Successful Response content: application/json: schema: - $ref: "#/components/schemas/GenericResponse" - "422": + $ref: '#/components/schemas/GenericResponse' + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin /trigger/embedding: post: tags: - Events summary: Create trigger embedding description: |- + **Access:** Admin role required. + Creates a trigger embedding for a specific trigger. Returns a success message or an error if the trigger is not found. operationId: create_trigger_embedding_trigger_embedding_post @@ -4350,30 +5129,36 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/TriggerEmbeddingBody" + $ref: '#/components/schemas/TriggerEmbeddingBody' responses: - "200": + '200': description: Successful Response content: application/json: schema: type: object title: Response Create Trigger Embedding Trigger Embedding Post - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - "/trigger/embedding/{camera_name}/{name}": + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /trigger/embedding/{camera_name}/{name}: put: tags: - Events summary: Update trigger embedding description: |- + **Access:** Admin role required. + Updates a trigger embedding for a specific trigger. Returns a success message or an error if the trigger is not found. - operationId: update_trigger_embedding_trigger_embedding__camera_name___name__put + operationId: + update_trigger_embedding_trigger_embedding__camera_name___name__put parameters: - name: camera_name in: path @@ -4392,31 +5177,36 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/TriggerEmbeddingBody" + $ref: '#/components/schemas/TriggerEmbeddingBody' responses: - "200": + '200': description: Successful Response content: application/json: schema: type: object - title: >- - Response Update Trigger Embedding Trigger Embedding Camera - Name Name Put - "422": + title: Response Update Trigger Embedding Trigger Embedding + Camera Name Name Put + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin delete: tags: - Events summary: Delete trigger embedding description: |- + **Access:** Admin role required. + Deletes a trigger embedding for a specific trigger. Returns a success message or an error if the trigger is not found. - operationId: delete_trigger_embedding_trigger_embedding__camera_name___name__delete + operationId: + delete_trigger_embedding_trigger_embedding__camera_name___name__delete parameters: - name: camera_name in: path @@ -4431,27 +5221,31 @@ paths: type: string title: Name responses: - "200": + '200': description: Successful Response content: application/json: schema: type: object - title: >- - Response Delete Trigger Embedding Trigger Embedding Camera - Name Name Delete - "422": + title: Response Delete Trigger Embedding Trigger Embedding + Camera Name Name Delete + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - "/triggers/status/{camera_name}": + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /triggers/status/{camera_name}: get: tags: - Events summary: Get triggers status description: |- + **Access:** Admin role required. + Gets the status of all triggers for a specific camera. Returns a success message or an error if the camera is not found. operationId: get_triggers_status_triggers_status__camera_name__get @@ -4463,20 +5257,24 @@ paths: type: string title: Camera Name responses: - "200": + '200': description: Successful Response content: application/json: schema: type: object - title: Response Get Triggers Status Triggers Status Camera Name Get - "422": + title: Response Get Triggers Status Triggers Status Camera Name + Get + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - "/{camera_name}": + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /{camera_name}: get: tags: - Media @@ -4489,7 +5287,7 @@ paths: schema: anyOf: - type: string - - type: "null" + - type: 'null' title: Camera Name - name: fps in: query @@ -4511,7 +5309,7 @@ paths: schema: anyOf: - type: integer - - type: "null" + - type: 'null' title: Bbox - name: timestamp in: query @@ -4519,7 +5317,7 @@ paths: schema: anyOf: - type: integer - - type: "null" + - type: 'null' title: Timestamp - name: zones in: query @@ -4527,7 +5325,7 @@ paths: schema: anyOf: - type: integer - - type: "null" + - type: 'null' title: Zones - name: mask in: query @@ -4535,7 +5333,7 @@ paths: schema: anyOf: - type: integer - - type: "null" + - type: 'null' title: Mask - name: motion in: query @@ -4543,7 +5341,7 @@ paths: schema: anyOf: - type: integer - - type: "null" + - type: 'null' title: Motion - name: regions in: query @@ -4551,21 +5349,25 @@ paths: schema: anyOf: - type: integer - - type: "null" + - type: 'null' title: Regions responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - "/{camera_name}/ptz/info": + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera + description: '**Access:** Authenticated user with access to the referenced camera.' + /{camera_name}/ptz/info: get: tags: - Media @@ -4578,29 +5380,33 @@ paths: schema: anyOf: - type: string - - type: "null" + - type: 'null' title: Camera Name responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - "/{camera_name}/latest.{extension}": + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera + description: '**Access:** Authenticated user with access to the referenced camera.' + /{camera_name}/latest.{extension}: get: tags: - Media summary: Latest Frame - description: >- - Returns the latest frame from the specified camera in the requested - format (jpg, png, webp). Falls back to preview frames if the camera is - offline. + description: |- + **Access:** Authenticated user with access to the referenced camera. + + Returns the latest frame from the specified camera in the requested format (jpg, png, webp). Falls back to preview frames if the camera is offline. operationId: latest_frame__camera_name__latest__extension__get parameters: - name: camera_name @@ -4609,20 +5415,20 @@ paths: schema: anyOf: - type: string - - type: "null" + - type: 'null' title: Camera Name - name: extension in: path required: true schema: - $ref: "#/components/schemas/Extension" + $ref: '#/components/schemas/Extension' - name: bbox in: query required: false schema: anyOf: - type: integer - - type: "null" + - type: 'null' title: Bbox - name: timestamp in: query @@ -4630,7 +5436,7 @@ paths: schema: anyOf: - type: integer - - type: "null" + - type: 'null' title: Timestamp - name: zones in: query @@ -4638,7 +5444,7 @@ paths: schema: anyOf: - type: integer - - type: "null" + - type: 'null' title: Zones - name: mask in: query @@ -4646,7 +5452,7 @@ paths: schema: anyOf: - type: integer - - type: "null" + - type: 'null' title: Mask - name: motion in: query @@ -4654,7 +5460,7 @@ paths: schema: anyOf: - type: integer - - type: "null" + - type: 'null' title: Motion - name: paths in: query @@ -4662,7 +5468,7 @@ paths: schema: anyOf: - type: integer - - type: "null" + - type: 'null' title: Paths - name: regions in: query @@ -4670,7 +5476,7 @@ paths: schema: anyOf: - type: integer - - type: "null" + - type: 'null' title: Regions - name: quality in: query @@ -4678,7 +5484,7 @@ paths: schema: anyOf: - type: integer - - type: "null" + - type: 'null' default: 70 title: Quality - name: height @@ -4687,7 +5493,7 @@ paths: schema: anyOf: - type: integer - - type: "null" + - type: 'null' title: Height - name: store in: query @@ -4695,26 +5501,29 @@ paths: schema: anyOf: - type: integer - - type: "null" + - type: 'null' title: Store responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - "/{camera_name}/recordings/{frame_time}/snapshot.{format}": + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera + /{camera_name}/recordings/{frame_time}/snapshot.{format}: get: tags: - Media summary: Get Snapshot From Recording - operationId: >- + operationId: get_snapshot_from_recording__camera_name__recordings__frame_time__snapshot__format__get parameters: - name: camera_name @@ -4723,7 +5532,7 @@ paths: schema: anyOf: - type: string - - type: "null" + - type: 'null' title: Camera Name - name: frame_time in: path @@ -4747,23 +5556,28 @@ paths: type: integer title: Height responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - "/{camera_name}/plus/{frame_time}": + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera + description: '**Access:** Authenticated user with access to the referenced camera.' + /{camera_name}/plus/{frame_time}: post: tags: - Media summary: Submit Recording Snapshot To Plus - operationId: submit_recording_snapshot_to_plus__camera_name__plus__frame_time__post + operationId: + submit_recording_snapshot_to_plus__camera_name__plus__frame_time__post parameters: - name: camera_name in: path @@ -4771,7 +5585,7 @@ paths: schema: anyOf: - type: string - - type: "null" + - type: 'null' title: Camera Name - name: frame_time in: path @@ -4780,26 +5594,32 @@ paths: type: string title: Frame Time responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - "/{camera_name}/start/{start_ts}/end/{end_ts}/clip.mp4": + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera + description: '**Access:** Authenticated user with access to the referenced camera.' + /{camera_name}/start/{start_ts}/end/{end_ts}/clip.mp4: get: tags: - Media summary: Recording Clip - description: >- - For iOS devices, use the master.m3u8 HLS link instead of clip.mp4. - Safari does not reliably process progressive mp4 files. - operationId: recording_clip__camera_name__start__start_ts__end__end_ts__clip_mp4_get + description: |- + **Access:** Authenticated user with access to the referenced camera. + + For iOS devices, use the master.m3u8 HLS link instead of clip.mp4. Safari does not reliably process progressive mp4 files. + operationId: + recording_clip__camera_name__start__start_ts__end__end_ts__clip_mp4_get parameters: - name: camera_name in: path @@ -4807,7 +5627,7 @@ paths: schema: anyOf: - type: string - - type: "null" + - type: 'null' title: Camera Name - name: start_ts in: path @@ -4822,25 +5642,29 @@ paths: type: number title: End Ts responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - "/vod/{camera_name}/start/{start_ts}/end/{end_ts}": + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera + /vod/{camera_name}/start/{start_ts}/end/{end_ts}: get: tags: - Media summary: Vod Ts - description: >- - Returns an HLS playlist for the specified timestamp-range on the - specified camera. Append /master.m3u8 or /index.m3u8 for HLS playback. + description: |- + **Access:** Authenticated user with access to the referenced camera. + + Returns an HLS playlist for the specified timestamp-range on the specified camera. Append /master.m3u8 or /index.m3u8 for HLS playback. operationId: vod_ts_vod__camera_name__start__start_ts__end__end_ts__get parameters: - name: camera_name @@ -4849,7 +5673,7 @@ paths: schema: anyOf: - type: string - - type: "null" + - type: 'null' title: Camera Name - name: start_ts in: path @@ -4871,26 +5695,31 @@ paths: default: false title: Force Discontinuity responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - "/vod/{year_month}/{day}/{hour}/{camera_name}": + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera + /vod/{year_month}/{day}/{hour}/{camera_name}: get: tags: - Media summary: Vod Hour No Timezone - description: >- - Returns an HLS playlist for the specified date-time on the specified - camera. Append /master.m3u8 or /index.m3u8 for HLS playback. - operationId: vod_hour_no_timezone_vod__year_month___day___hour___camera_name__get + description: |- + **Access:** Authenticated user with access to the referenced camera. + + Returns an HLS playlist for the specified date-time on the specified camera. Append /master.m3u8 or /index.m3u8 for HLS playback. + operationId: + vod_hour_no_timezone_vod__year_month___day___hour___camera_name__get parameters: - name: year_month in: path @@ -4916,30 +5745,34 @@ paths: schema: anyOf: - type: string - - type: "null" + - type: 'null' title: Camera Name responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - "/vod/{year_month}/{day}/{hour}/{camera_name}/{tz_name}": + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera + /vod/{year_month}/{day}/{hour}/{camera_name}/{tz_name}: get: tags: - Media summary: Vod Hour - description: >- - Returns an HLS playlist for the specified date-time (with timezone) on - the specified camera. Append /master.m3u8 or /index.m3u8 for HLS - playback. - operationId: vod_hour_vod__year_month___day___hour___camera_name___tz_name__get + description: |- + **Access:** Authenticated user with access to the referenced camera. + + Returns an HLS playlist for the specified date-time (with timezone) on the specified camera. Append /master.m3u8 or /index.m3u8 for HLS playback. + operationId: + vod_hour_vod__year_month___day___hour___camera_name___tz_name__get parameters: - name: year_month in: path @@ -4965,7 +5798,7 @@ paths: schema: anyOf: - type: string - - type: "null" + - type: 'null' title: Camera Name - name: tz_name in: path @@ -4974,25 +5807,29 @@ paths: type: string title: Tz Name responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - "/vod/event/{event_id}": + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera + /vod/event/{event_id}: get: tags: - Media summary: Vod Event - description: >- - Returns an HLS playlist for the specified object. Append /master.m3u8 or - /index.m3u8 for HLS playback. + description: |- + **Access:** Authenticated user with access to the referenced camera. + + Returns an HLS playlist for the specified object. Append /master.m3u8 or /index.m3u8 for HLS playback. operationId: vod_event_vod_event__event_id__get parameters: - name: event_id @@ -5011,26 +5848,31 @@ paths: title: Padding description: Padding to apply to the vod. responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - "/vod/clip/{camera_name}/start/{start_ts}/end/{end_ts}": + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera + /vod/clip/{camera_name}/start/{start_ts}/end/{end_ts}: get: tags: - Media summary: Vod Clip - description: >- - Returns an HLS playlist for a timestamp range with HLS discontinuity - enabled. Append /master.m3u8 or /index.m3u8 for HLS playback. - operationId: vod_clip_vod_clip__camera_name__start__start_ts__end__end_ts__get + description: |- + **Access:** Authenticated user with access to the referenced camera. + + Returns an HLS playlist for a timestamp range with HLS discontinuity enabled. Append /master.m3u8 or /index.m3u8 for HLS playback. + operationId: + vod_clip_vod_clip__camera_name__start__start_ts__end__end_ts__get parameters: - name: camera_name in: path @@ -5038,7 +5880,7 @@ paths: schema: anyOf: - type: string - - type: "null" + - type: 'null' title: Camera Name - name: start_ts in: path @@ -5053,23 +5895,29 @@ paths: type: number title: End Ts responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - "/events/{event_id}/snapshot.jpg": + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera + /events/{event_id}/snapshot.jpg: get: tags: - Media summary: Event Snapshot - description: Returns a snapshot image for the specified object id. + description: |- + **Access:** Authenticated user with access to the referenced camera. + + Returns a snapshot image for the specified object id. operationId: event_snapshot_events__event_id__snapshot_jpg_get parameters: - name: event_id @@ -5084,7 +5932,7 @@ paths: schema: anyOf: - type: boolean - - type: "null" + - type: 'null' default: false title: Download - name: timestamp @@ -5093,7 +5941,7 @@ paths: schema: anyOf: - type: integer - - type: "null" + - type: 'null' title: Timestamp - name: bbox in: query @@ -5101,7 +5949,7 @@ paths: schema: anyOf: - type: integer - - type: "null" + - type: 'null' title: Bbox - name: crop in: query @@ -5109,7 +5957,7 @@ paths: schema: anyOf: - type: integer - - type: "null" + - type: 'null' title: Crop - name: height in: query @@ -5117,7 +5965,7 @@ paths: schema: anyOf: - type: integer - - type: "null" + - type: 'null' title: Height - name: quality in: query @@ -5125,21 +5973,24 @@ paths: schema: anyOf: - type: integer - - type: "null" + - type: 'null' title: Quality responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - "/events/{event_id}/thumbnail.{extension}": + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera + /events/{event_id}/thumbnail.{extension}: get: tags: - Media @@ -5156,7 +6007,7 @@ paths: in: path required: true schema: - $ref: "#/components/schemas/Extension" + $ref: '#/components/schemas/Extension' - name: max_cache_age in: query required: false @@ -5176,27 +6027,23 @@ paths: - android default: ios title: Format - - name: camera_name - in: query - required: false - schema: - anyOf: - - type: string - - type: "null" - title: Camera Name responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - "/{camera_name}/grid.jpg": + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera + description: '**Access:** Authenticated user with access to the referenced camera.' + /{camera_name}/grid.jpg: get: tags: - Media @@ -5209,7 +6056,7 @@ paths: schema: anyOf: - type: string - - type: "null" + - type: 'null' title: Camera Name - name: color in: query @@ -5226,23 +6073,30 @@ paths: default: 0.5 title: Font Scale responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - "/{camera_name}/region_grid": + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera + description: '**Access:** Authenticated user with access to the referenced camera.' + /{camera_name}/region_grid: delete: tags: - Media summary: Clear Region Grid - description: Clear the region grid for a camera. + description: |- + **Access:** Admin role required. + + Clear the region grid for a camera. operationId: clear_region_grid__camera_name__region_grid_delete parameters: - name: camera_name @@ -5252,23 +6106,27 @@ paths: type: string title: Camera Name responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - "/events/{event_id}/snapshot-clean.webp": + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /events/{event_id}/snapshot-clean.webp: get: tags: - Media summary: Event Snapshot Clean - operationId: event_snapshot_clean_events__event_id__snapshot_clean_webp_get + operationId: + event_snapshot_clean_events__event_id__snapshot_clean_webp_get parameters: - name: event_id in: path @@ -5283,27 +6141,23 @@ paths: type: boolean default: false title: Download - - name: camera_name - in: query - required: false - schema: - anyOf: - - type: string - - type: "null" - title: Camera Name responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - "/events/{event_id}/clip.mp4": + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera + description: '**Access:** Authenticated user with access to the referenced camera.' + /events/{event_id}/clip.mp4: get: tags: - Media @@ -5325,27 +6179,61 @@ paths: default: 0 title: Padding description: Padding to apply to clip. - - name: camera_name - in: query - required: false - schema: - anyOf: - - type: string - - type: "null" - title: Camera Name responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - "/events/{event_id}/preview.gif": + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera + description: '**Access:** Authenticated user with access to the referenced camera.' + /review/{review_id}/clip.mp4: + get: + tags: + - Media + summary: Review Clip + operationId: review_clip_review__review_id__clip_mp4_get + parameters: + - name: review_id + in: path + required: true + schema: + type: string + title: Review Id + - name: padding + in: query + required: false + schema: + type: integer + description: Padding to apply to clip. + default: 0 + title: Padding + description: Padding to apply to clip. + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera + description: '**Access:** Authenticated user with access to the referenced camera.' + /events/{event_id}/preview.gif: get: tags: - Media @@ -5358,32 +6246,29 @@ paths: schema: type: string title: Event Id - - name: camera_name - in: query - required: false - schema: - anyOf: - - type: string - - type: "null" - title: Camera Name responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - "/{camera_name}/start/{start_ts}/end/{end_ts}/preview.gif": + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera + description: '**Access:** Authenticated user with access to the referenced camera.' + /{camera_name}/start/{start_ts}/end/{end_ts}/preview.gif: get: tags: - Media summary: Preview Gif - operationId: preview_gif__camera_name__start__start_ts__end__end_ts__preview_gif_get + operationId: + preview_gif__camera_name__start__start_ts__end__end_ts__preview_gif_get parameters: - name: camera_name in: path @@ -5391,7 +6276,7 @@ paths: schema: anyOf: - type: string - - type: "null" + - type: 'null' title: Camera Name - name: start_ts in: path @@ -5415,23 +6300,28 @@ paths: title: Max Cache Age description: Max cache age in seconds. Default 30 days in seconds. responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - "/{camera_name}/start/{start_ts}/end/{end_ts}/preview.mp4": + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera + description: '**Access:** Authenticated user with access to the referenced camera.' + /{camera_name}/start/{start_ts}/end/{end_ts}/preview.mp4: get: tags: - Media summary: Preview Mp4 - operationId: preview_mp4__camera_name__start__start_ts__end__end_ts__preview_mp4_get + operationId: + preview_mp4__camera_name__start__start_ts__end__end_ts__preview_mp4_get parameters: - name: camera_name in: path @@ -5439,7 +6329,7 @@ paths: schema: anyOf: - type: string - - type: "null" + - type: 'null' title: Camera Name - name: start_ts in: path @@ -5463,18 +6353,22 @@ paths: title: Max Cache Age description: Max cache age in seconds. Default 7 days in seconds. responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - "/review/{event_id}/preview": + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera + description: '**Access:** Authenticated user with access to the referenced camera.' + /review/{event_id}/preview: get: tags: - Media @@ -5497,32 +6391,31 @@ paths: - mp4 default: gif title: Format - - name: camera_name - in: query - required: false - schema: - anyOf: - - type: string - - type: "null" - title: Camera Name responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - "/preview/{file_name}/thumbnail.webp": + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera + description: '**Access:** Authenticated user with access to the referenced camera.' + /preview/{file_name}/thumbnail.webp: get: tags: - Media summary: Preview Thumbnail - description: Get a thumbnail from the cached preview frames. + description: |- + **Access:** Authenticated user with access to the referenced camera. + + Get a thumbnail from the cached preview frames. operationId: preview_thumbnail_preview__file_name__thumbnail_webp_get parameters: - name: file_name @@ -5531,32 +6424,30 @@ paths: schema: type: string title: File Name - - name: camera_name - in: query - required: false - schema: - anyOf: - - type: string - - type: "null" - title: Camera Name responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - "/preview/{file_name}/thumbnail.jpg": + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera + /preview/{file_name}/thumbnail.jpg: get: tags: - Media summary: Preview Thumbnail - description: Get a thumbnail from the cached preview frames. + description: |- + **Access:** Authenticated user with access to the referenced camera. + + Get a thumbnail from the cached preview frames. operationId: preview_thumbnail_preview__file_name__thumbnail_jpg_get parameters: - name: file_name @@ -5565,27 +6456,22 @@ paths: schema: type: string title: File Name - - name: camera_name - in: query - required: false - schema: - anyOf: - - type: string - - type: "null" - title: Camera Name responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - "/{camera_name}/{label}/thumbnail.jpg": + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera + /{camera_name}/{label}/thumbnail.jpg: get: tags: - Media @@ -5598,7 +6484,7 @@ paths: schema: anyOf: - type: string - - type: "null" + - type: 'null' title: Camera Name - name: label in: path @@ -5607,18 +6493,22 @@ paths: type: string title: Label responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - "/{camera_name}/{label}/best.jpg": + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera + description: '**Access:** Authenticated user with access to the referenced camera.' + /{camera_name}/{label}/best.jpg: get: tags: - Media @@ -5631,7 +6521,7 @@ paths: schema: anyOf: - type: string - - type: "null" + - type: 'null' title: Camera Name - name: label in: path @@ -5640,18 +6530,22 @@ paths: type: string title: Label responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - "/{camera_name}/{label}/clip.mp4": + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera + description: '**Access:** Authenticated user with access to the referenced camera.' + /{camera_name}/{label}/clip.mp4: get: tags: - Media @@ -5664,7 +6558,7 @@ paths: schema: anyOf: - type: string - - type: "null" + - type: 'null' title: Camera Name - name: label in: path @@ -5673,25 +6567,30 @@ paths: type: string title: Label responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - "/{camera_name}/{label}/snapshot.jpg": + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera + description: '**Access:** Authenticated user with access to the referenced camera.' + /{camera_name}/{label}/snapshot.jpg: get: tags: - Media summary: Label Snapshot - description: >- - Returns the snapshot image from the latest event for the given camera - and label combo + description: |- + **Access:** Authenticated user with access to the referenced camera. + + Returns the snapshot image from the latest event for the given camera and label combo operationId: label_snapshot__camera_name___label__snapshot_jpg_get parameters: - name: camera_name @@ -5700,7 +6599,7 @@ paths: schema: anyOf: - type: string - - type: "null" + - type: 'null' title: Camera Name - name: label in: path @@ -5709,23 +6608,28 @@ paths: type: string title: Label responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - "/{camera_name}/search/motion": + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera + /{camera_name}/search/motion: post: tags: - Motion Search summary: Start motion search job description: |- + **Access:** Authenticated user with access to the referenced camera. + Starts an asynchronous search for significant motion changes within a user-defined Region of Interest (ROI) over a specified time range. Returns a job_id that can be used to poll for results. @@ -5737,34 +6641,40 @@ paths: schema: anyOf: - type: string - - type: "null" + - type: 'null' title: Camera Name requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/MotionSearchRequest" + $ref: '#/components/schemas/MotionSearchRequest' responses: - "200": + '200': description: Successful Response content: application/json: schema: - $ref: "#/components/schemas/MotionSearchStartResponse" - "422": + $ref: '#/components/schemas/MotionSearchStartResponse' + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - "/{camera_name}/search/motion/{job_id}": + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera + /{camera_name}/search/motion/{job_id}: get: tags: - Motion Search summary: Get motion search job status - description: Returns the status and results (if complete) of a motion search job. - operationId: >- + description: |- + **Access:** Authenticated user with access to the referenced camera. + + Returns the status and results (if complete) of a motion search job. + operationId: get_motion_search_status_endpoint__camera_name__search_motion__job_id__get parameters: - name: camera_name @@ -5773,7 +6683,7 @@ paths: schema: anyOf: - type: string - - type: "null" + - type: 'null' title: Camera Name - name: job_id in: path @@ -5782,25 +6692,31 @@ paths: type: string title: Job Id responses: - "200": + '200': description: Successful Response content: application/json: schema: - $ref: "#/components/schemas/MotionSearchStatusResponse" - "422": + $ref: '#/components/schemas/MotionSearchStatusResponse' + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - "/{camera_name}/search/motion/{job_id}/cancel": + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera + /{camera_name}/search/motion/{job_id}/cancel: post: tags: - Motion Search summary: Cancel motion search job - description: Cancels an active motion search job if it is still processing. - operationId: >- + description: |- + **Access:** Authenticated user with access to the referenced camera. + + Cancels an active motion search job if it is still processing. + operationId: cancel_motion_search_endpoint__camera_name__search_motion__job_id__cancel_post parameters: - name: camera_name @@ -5809,7 +6725,7 @@ paths: schema: anyOf: - type: string - - type: "null" + - type: 'null' title: Camera Name - name: job_id in: path @@ -5818,17 +6734,20 @@ paths: type: string title: Job Id responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera /recordings/storage: get: tags: @@ -5836,17 +6755,24 @@ paths: summary: Get Recordings Storage Usage operationId: get_recordings_storage_usage_recordings_storage_get responses: - "200": + '200': description: Successful Response content: application/json: schema: {} + security: + - frigateAdminAuth: [] + x-required-role: admin + description: '**Access:** Admin role required.' /recordings/summary: get: tags: - Recordings summary: All Recordings Summary - description: Returns true/false by day indicating if recordings exist + description: |- + **Access:** Any authenticated user. + + Returns true/false by day indicating if recordings exist operationId: all_recordings_summary_recordings_summary_get parameters: - name: timezone @@ -5862,27 +6788,33 @@ paths: schema: anyOf: - type: string - - type: "null" + - type: 'null' default: all title: Cameras responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - "/{camera_name}/recordings/summary": + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: any + /{camera_name}/recordings/summary: get: tags: - Recordings summary: Recordings Summary - description: Returns hourly summary for recordings of given camera + description: |- + **Access:** Authenticated user with access to the referenced camera. + + Returns hourly summary for recordings of given camera operationId: recordings_summary__camera_name__recordings_summary_get parameters: - name: camera_name @@ -5891,7 +6823,7 @@ paths: schema: anyOf: - type: string - - type: "null" + - type: 'null' title: Camera Name - name: timezone in: query @@ -5901,25 +6833,29 @@ paths: default: utc title: Timezone responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - "/{camera_name}/recordings": + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera + /{camera_name}/recordings: get: tags: - Recordings summary: Recordings - description: >- - Return specific camera recordings between the given 'after'/'end' times. - If not provided the last hour will be used + description: |- + **Access:** Authenticated user with access to the referenced camera. + + Return specific camera recordings between the given 'after'/'end' times. If not provided the last hour will be used operationId: recordings__camera_name__recordings_get parameters: - name: camera_name @@ -5928,40 +6864,44 @@ paths: schema: anyOf: - type: string - - type: "null" + - type: 'null' title: Camera Name - name: after in: query required: false schema: type: number - default: 1774023877.74743 title: After - name: before in: query required: false schema: type: number - default: 1774027477.74744 title: Before responses: - "200": + '200': description: Successful Response content: application/json: schema: {} - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: camera /recordings/unavailable: get: tags: - Recordings summary: No Recordings - description: Get time ranges with no recordings. + description: |- + **Access:** Any authenticated user. + + Get time ranges with no recordings. operationId: no_recordings_recordings_unavailable_get parameters: - name: cameras @@ -5991,7 +6931,7 @@ paths: default: 30 title: Scale responses: - "200": + '200': description: Successful Response content: application/json: @@ -6000,18 +6940,23 @@ paths: items: type: object title: Response No Recordings Recordings Unavailable Get - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" - "/recordings/start/{start}/end/{end}": + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateUserAuth: [] + x-required-role: any + /recordings/start/{start}/end/{end}: delete: tags: - Recordings summary: Delete recordings description: |- + **Access:** Admin role required. + Deletes recordings within the specified time range. Recordings can be filtered by cameras and kept based on motion, objects, or audio attributes. operationId: delete_recordings_recordings_start__start__end__end__delete @@ -6038,7 +6983,7 @@ paths: schema: anyOf: - type: string - - type: "null" + - type: 'null' title: Keep - name: cameras in: query @@ -6046,83 +6991,140 @@ paths: schema: anyOf: - type: string - - type: "null" + - type: 'null' default: all title: Cameras responses: - "200": + '200': description: Successful Response content: application/json: schema: - $ref: "#/components/schemas/GenericResponse" - "422": + $ref: '#/components/schemas/GenericResponse' + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin /debug_replay/start: post: tags: - App summary: Start debug replay - description: - Start a debug replay session from camera recordings. Returns - immediately while clip generation runs as a background job; subscribe - to the 'debug_replay' job_state WS topic to track progress. + description: |- + **Access:** Admin role required. + + Start a debug replay session from camera recordings. Returns immediately while clip generation runs as a background job; subscribe to the 'debug_replay' job_state WS topic to track progress. operationId: start_debug_replay_debug_replay_start_post requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/DebugReplayStartBody" + $ref: '#/components/schemas/DebugReplayStartBody' responses: - "202": + '202': description: Successful Response content: application/json: schema: - $ref: "#/components/schemas/DebugReplayStartResponse" - "400": + $ref: '#/components/schemas/DebugReplayStartResponse' + '400': description: Invalid camera, time range, or no recordings - "409": + '409': description: A replay session is already active - "422": + '422': description: Validation Error content: application/json: schema: - $ref: "#/components/schemas/HTTPValidationError" + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /debug_replay/start_from_export: + post: + tags: + - App + summary: Start debug replay from an export + description: |- + **Access:** Admin role required. + + Start a debug replay session covering an existing export's time range. The end time is derived from the export's video duration. + operationId: + start_debug_replay_from_export_debug_replay_start_from_export_post + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DebugReplayStartFromExportBody' + responses: + '202': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/DebugReplayStartResponse' + '400': + description: Invalid export, time range, or no recordings + '404': + description: Export not found + '409': + description: A replay session is already active + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin /debug_replay/status: get: tags: - App summary: Get debug replay status - description: Get the status of the current debug replay session. + description: |- + **Access:** Admin role required. + + Get the status of the current debug replay session. operationId: get_debug_replay_status_debug_replay_status_get responses: - "200": + '200': description: Successful Response content: application/json: schema: - $ref: "#/components/schemas/DebugReplayStatusResponse" + $ref: '#/components/schemas/DebugReplayStatusResponse' + security: + - frigateAdminAuth: [] + x-required-role: admin /debug_replay/stop: post: tags: - App summary: Stop debug replay - description: Stop the active debug replay session and clean up all artifacts. + description: |- + **Access:** Admin role required. + + Stop the active debug replay session and clean up all artifacts. operationId: stop_debug_replay_debug_replay_stop_post responses: - "200": + '200': description: Successful Response content: application/json: schema: - $ref: "#/components/schemas/DebugReplayStopResponse" + $ref: '#/components/schemas/DebugReplayStopResponse' + security: + - frigateAdminAuth: [] + x-required-role: admin components: schemas: AppConfigSetBody: @@ -6134,12 +7136,12 @@ components: update_topic: anyOf: - type: string - - type: "null" + - type: 'null' title: Update Topic config_data: anyOf: - type: object - - type: "null" + - type: 'null' title: Config Data skip_save: type: boolean @@ -6171,7 +7173,7 @@ components: role: anyOf: - type: string - - type: "null" + - type: 'null' title: Role default: viewer type: object @@ -6187,7 +7189,7 @@ components: old_password: anyOf: - type: string - - type: "null" + - type: 'null' title: Old Password type: object required: @@ -6212,6 +7214,155 @@ components: required: - event_id title: AudioTranscriptionBody + BatchExportBody: + properties: + items: + items: + $ref: '#/components/schemas/BatchExportItem' + type: array + maxItems: 50 + minItems: 1 + title: Items + description: List of export items. Each item has its own camera and + time range. + export_case_id: + anyOf: + - type: string + maxLength: 30 + - type: 'null' + title: Export case ID + description: Existing export case ID to assign all exports to. + Attaching to an existing case is temporarily admin-only until + case-level ACLs exist. + new_case_name: + anyOf: + - type: string + maxLength: 100 + - type: 'null' + title: New case name + description: Name of a new export case to create when export_case_id + is omitted + new_case_description: + anyOf: + - type: string + - type: 'null' + title: New case description + description: Optional description for a newly created export case + type: object + required: + - items + title: BatchExportBody + BatchExportItem: + properties: + camera: + type: string + title: Camera name + start_time: + type: number + title: Start time + end_time: + type: number + title: End time + image_path: + anyOf: + - type: string + - type: 'null' + title: Existing thumbnail path + description: Optional existing image to use as the export thumbnail + friendly_name: + anyOf: + - type: string + maxLength: 256 + - type: 'null' + title: Friendly name + description: Optional friendly name for this specific export item + client_item_id: + anyOf: + - type: string + maxLength: 128 + - type: 'null' + title: Client item ID + description: Optional opaque client identifier echoed back in results + type: object + required: + - camera + - start_time + - end_time + title: BatchExportItem + BatchExportResponse: + properties: + export_case_id: + anyOf: + - type: string + - type: 'null' + title: Export Case Id + description: Export case ID associated with the batch + export_ids: + items: + type: string + type: array + title: Export Ids + description: Export IDs successfully queued + results: + items: + $ref: '#/components/schemas/BatchExportResultModel' + type: array + title: Results + description: Per-item batch export results + type: object + required: + - export_ids + - results + title: BatchExportResponse + description: Response model for starting an export batch. + BatchExportResultModel: + properties: + camera: + type: string + title: Camera + description: Camera name for this export attempt + export_id: + anyOf: + - type: string + - type: 'null' + title: Export Id + description: The export ID when the export was successfully queued + success: + type: boolean + title: Success + description: Whether the export was successfully queued + status: + anyOf: + - type: string + - type: 'null' + title: Status + description: Queue status for this camera export + error: + anyOf: + - type: string + - type: 'null' + title: Error + description: Validation or queueing error for this item, if any + item_index: + anyOf: + - type: integer + - type: 'null' + title: Item Index + description: Zero-based index of this result within the request items + list + client_item_id: + anyOf: + - type: string + - type: 'null' + title: Client Item Id + description: Opaque client-supplied item identifier echoed from the + request + type: object + required: + - camera + - success + title: BatchExportResultModel + description: Per-item result for a batch export request. Body_recognize_face_faces_recognize_post: properties: file: @@ -6246,24 +7397,31 @@ components: properties: messages: items: - $ref: "#/components/schemas/ChatMessage" + $ref: '#/components/schemas/ChatMessage' type: array title: Messages description: List of messages in the conversation max_tool_iterations: type: integer - maximum: 10 - minimum: 1 + maximum: 10.0 + minimum: 1.0 title: Max Tool Iterations - description: "Maximum number of tool call iterations (default: 5)" + description: 'Maximum number of tool call iterations (default: 5)' default: 5 stream: type: boolean title: Stream - description: >- - If true, stream the final assistant response in the body as - newline-delimited JSON. + description: If true, stream the final assistant response in the body + as newline-delimited JSON. default: false + enable_thinking: + anyOf: + - type: boolean + - type: 'null' + title: Enable Thinking + description: Per-request thinking toggle. None means use the provider + default. Ignored by providers that do not expose a per-request + thinking switch. type: object required: - messages @@ -6276,25 +7434,39 @@ components: title: Role description: "Message role: 'user', 'assistant', 'system', or 'tool'" content: - type: string + anyOf: + - {} + - type: 'null' title: Content - description: Message content + description: Message content. Usually a string, but may be a + multimodal content list (e.g. text + image_url) or null for + assistant turns that only request tool calls. tool_call_id: anyOf: - type: string - - type: "null" + - type: 'null' title: Tool Call Id - description: "For tool messages, the ID of the tool call" + description: For tool messages, the ID of the tool call name: anyOf: - type: string - - type: "null" + - type: 'null' title: Name - description: "For tool messages, the tool name" + description: For tool messages, the tool name + tool_calls: + anyOf: + - items: + type: object + type: array + - type: 'null' + title: Tool Calls + description: For assistant messages replayed from prior turns, the + OpenAI-format tool calls the model previously requested. Replaying + these verbatim keeps the conversation prefix byte-for-byte identical + so the model server's prompt cache hits on follow-up turns. type: object required: - role - - content title: ChatMessage description: A single message in a chat conversation. DayReview: @@ -6341,6 +7513,17 @@ components: - end_time title: DebugReplayStartBody description: Request body for starting a debug replay session. + DebugReplayStartFromExportBody: + properties: + export_id: + type: string + title: Export id + type: object + required: + - export_id + title: DebugReplayStartFromExportBody + description: Request body for starting a debug replay session from an + export. DebugReplayStartResponse: properties: success: @@ -6367,22 +7550,22 @@ components: replay_camera: anyOf: - type: string - - type: "null" + - type: 'null' title: Replay Camera source_camera: anyOf: - type: string - - type: "null" + - type: 'null' title: Source Camera start_time: anyOf: - type: number - - type: "null" + - type: 'null' title: Start Time end_time: anyOf: - type: number - - type: "null" + - type: 'null' title: End Time live_ready: type: boolean @@ -6392,7 +7575,13 @@ components: required: - active title: DebugReplayStatusResponse - description: Response for debug replay status. + description: |- + Response for debug replay status. + + Returns only session-presence fields. Startup progress and error + details flow through the job_state WebSocket topic via the + debug_replay job (see frigate.jobs.debug_replay); the + Replay page subscribes there with useJobStatus("debug_replay"). DebugReplayStopResponse: properties: success: @@ -6464,7 +7653,7 @@ components: sub_label: anyOf: - type: string - - type: "null" + - type: 'null' title: Sub Label camera: type: string @@ -6475,12 +7664,12 @@ components: end_time: anyOf: - type: number - - type: "null" + - type: 'null' title: End Time false_positive: anyOf: - type: boolean - - type: "null" + - type: 'null' title: False Positive zones: items: @@ -6490,7 +7679,7 @@ components: thumbnail: anyOf: - type: string - - type: "null" + - type: 'null' title: Thumbnail has_clip: type: boolean @@ -6504,22 +7693,22 @@ components: plus_id: anyOf: - type: string - - type: "null" + - type: 'null' title: Plus Id model_hash: anyOf: - type: string - - type: "null" + - type: 'null' title: Model Hash detector_type: anyOf: - type: string - - type: "null" + - type: 'null' title: Detector Type model_type: anyOf: - type: string - - type: "null" + - type: 'null' title: Model Type data: type: object @@ -6571,36 +7760,36 @@ components: sub_label: anyOf: - type: string - - type: "null" + - type: 'null' title: Sub Label score: anyOf: - type: number - - type: "null" + - type: 'null' title: Score default: 0 duration: anyOf: - type: integer - - type: "null" + - type: 'null' title: Duration default: 30 include_recording: anyOf: - type: boolean - - type: "null" + - type: 'null' title: Include Recording default: true draw: anyOf: - type: object - - type: "null" + - type: 'null' title: Draw default: {} pre_capture: anyOf: - type: integer - - type: "null" + - type: 'null' title: Pre Capture type: object title: EventsCreateBody @@ -6620,7 +7809,7 @@ components: description: anyOf: - type: string - - type: "null" + - type: 'null' title: The description of the event type: object required: @@ -6631,7 +7820,7 @@ components: end_time: anyOf: - type: number - - type: "null" + - type: 'null' title: End Time type: object title: EventsEndBody @@ -6644,157 +7833,14 @@ components: recognizedLicensePlateScore: anyOf: - type: number - maximum: 1 - exclusiveMinimum: 0 - - type: "null" + maximum: 1.0 + exclusiveMinimum: 0.0 + - type: 'null' title: Score for recognized license plate type: object required: - recognizedLicensePlate title: EventsLPRBody - BatchExportBody: - properties: - items: - items: - $ref: "#/components/schemas/BatchExportItem" - type: array - minItems: 1 - maxItems: 50 - title: Items - description: List of export items. Each item has its own camera and time range. - export_case_id: - anyOf: - - type: string - maxLength: 30 - - type: "null" - title: Export case ID - description: Existing export case ID to assign all exports to. Attaching to an existing case is temporarily admin-only until case-level ACLs exist. - new_case_name: - anyOf: - - type: string - maxLength: 100 - - type: "null" - title: New case name - description: Name of a new export case to create when export_case_id is omitted - new_case_description: - anyOf: - - type: string - - type: "null" - title: New case description - description: Optional description for a newly created export case - type: object - required: - - items - title: BatchExportBody - BatchExportItem: - properties: - camera: - type: string - title: Camera name - start_time: - type: number - title: Start time - end_time: - type: number - title: End time - image_path: - anyOf: - - type: string - - type: "null" - title: Existing thumbnail path - description: Optional existing image to use as the export thumbnail - friendly_name: - anyOf: - - type: string - maxLength: 256 - - type: "null" - title: Friendly name - description: Optional friendly name for this specific export item - client_item_id: - anyOf: - - type: string - maxLength: 128 - - type: "null" - title: Client item ID - description: Optional opaque client identifier echoed back in results - type: object - required: - - camera - - start_time - - end_time - title: BatchExportItem - BatchExportResponse: - properties: - export_case_id: - anyOf: - - type: string - - type: "null" - title: Export Case Id - description: Export case ID associated with the batch - export_ids: - items: - type: string - type: array - title: Export Ids - description: Export IDs successfully queued - results: - items: - $ref: "#/components/schemas/BatchExportResultModel" - type: array - title: Results - description: Per-item batch export results - type: object - required: - - export_ids - - results - title: BatchExportResponse - description: Response model for starting an export batch. - BatchExportResultModel: - properties: - camera: - type: string - title: Camera - description: Camera name for this export attempt - export_id: - anyOf: - - type: string - - type: "null" - title: Export Id - description: The export ID when the export was successfully queued - success: - type: boolean - title: Success - description: Whether the export was successfully queued - status: - anyOf: - - type: string - - type: "null" - title: Status - description: Queue status for this camera export - error: - anyOf: - - type: string - - type: "null" - title: Error - description: Validation or queueing error for this item, if any - item_index: - anyOf: - - type: integer - - type: "null" - title: Item Index - description: Zero-based index of this result within the request items list - client_item_id: - anyOf: - - type: string - - type: "null" - title: Client Item Id - description: Opaque client-supplied item identifier echoed from the request - type: object - required: - - camera - - success - title: BatchExportResultModel - description: Per-item result for a batch export request. EventsSubLabelBody: properties: subLabel: @@ -6804,14 +7850,14 @@ components: subLabelScore: anyOf: - type: number - maximum: 1 - exclusiveMinimum: 0 - - type: "null" + maximum: 1.0 + exclusiveMinimum: 0.0 + - type: 'null' title: Score for sub label camera: anyOf: - type: string - - type: "null" + - type: 'null' title: Camera this object is detected on. type: object required: @@ -6844,9 +7890,10 @@ components: anyOf: - type: string maxLength: 30 - - type: "null" + - type: 'null' title: Export Case Id - description: "Case ID to assign to, or null to unassign from current case" + description: Case ID to assign to, or null to unassign from current + case type: object required: - ids @@ -6862,7 +7909,7 @@ components: description: anyOf: - type: string - - type: "null" + - type: 'null' title: Description description: Optional description of the export case type: object @@ -6883,7 +7930,7 @@ components: description: anyOf: - type: string - - type: "null" + - type: 'null' title: Description description: Optional description of the export case created_at: @@ -6908,18 +7955,101 @@ components: anyOf: - type: string maxLength: 100 - - type: "null" + - type: 'null' title: Name description: Updated friendly name of the export case description: anyOf: - type: string - - type: "null" + - type: 'null' title: Description description: Updated description of the export case type: object title: ExportCaseUpdateBody description: Request body for updating an existing export case. + ExportJobModel: + properties: + id: + type: string + title: Id + description: Unique identifier for the export job + job_type: + type: string + title: Job Type + description: Job type + status: + type: string + title: Status + description: Current job status + camera: + type: string + title: Camera + description: Camera associated with this export job + name: + anyOf: + - type: string + - type: 'null' + title: Name + description: Friendly name for the export + export_case_id: + anyOf: + - type: string + - type: 'null' + title: Export Case Id + description: ID of the export case this export belongs to + request_start_time: + type: number + title: Request Start Time + description: Requested export start time + request_end_time: + type: number + title: Request End Time + description: Requested export end time + start_time: + anyOf: + - type: number + - type: 'null' + title: Start Time + description: Unix timestamp when execution started + end_time: + anyOf: + - type: number + - type: 'null' + title: End Time + description: Unix timestamp when execution completed + error_message: + anyOf: + - type: string + - type: 'null' + title: Error Message + description: Error message for failed jobs + results: + anyOf: + - type: object + - type: 'null' + title: Results + description: Result metadata for completed jobs + current_step: + type: string + title: Current Step + description: Current execution step (queued, preparing, encoding, + encoding_retry, finalizing) + default: queued + progress_percent: + type: number + title: Progress Percent + description: Progress percentage of the current step (0.0 - 100.0) + default: 0.0 + type: object + required: + - id + - job_type + - status + - camera + - request_start_time + - request_end_time + title: ExportJobModel + description: Model representing a queued or running export job. ExportModel: properties: id: @@ -6953,7 +8083,7 @@ components: export_case_id: anyOf: - type: string - - type: "null" + - type: 'null' title: Export Case Id description: ID of the export case this export belongs to type: object @@ -6970,14 +8100,14 @@ components: ExportRecordingsBody: properties: source: - $ref: "#/components/schemas/PlaybackSourceEnum" + $ref: '#/components/schemas/PlaybackSourceEnum' title: Playback source default: recordings name: anyOf: - type: string maxLength: 256 - - type: "null" + - type: 'null' title: Friendly name image_path: type: string @@ -6986,7 +8116,7 @@ components: anyOf: - type: string maxLength: 30 - - type: "null" + - type: 'null' title: Export case ID description: ID of the export case to assign this export to type: object @@ -6994,7 +8124,7 @@ components: ExportRecordingsCustomBody: properties: source: - $ref: "#/components/schemas/PlaybackSourceEnum" + $ref: '#/components/schemas/PlaybackSourceEnum' title: Playback source default: recordings name: @@ -7008,31 +8138,28 @@ components: anyOf: - type: string maxLength: 30 - - type: "null" + - type: 'null' title: Export case ID description: ID of the export case to assign this export to ffmpeg_input_args: anyOf: - type: string - - type: "null" + - type: 'null' title: FFmpeg input arguments - description: >- - Custom FFmpeg input arguments. If not provided, defaults to - timelapse input args. + description: Custom FFmpeg input arguments. If not provided, defaults + to timelapse input args. ffmpeg_output_args: anyOf: - type: string - - type: "null" + - type: 'null' title: FFmpeg output arguments - description: >- - Custom FFmpeg output arguments. If not provided, defaults to - timelapse output args. + description: Custom FFmpeg output arguments. If not provided, defaults + to timelapse output args. cpu_fallback: type: boolean title: CPU Fallback - description: >- - If true, retry export without hardware acceleration if the initial - export fails. + description: If true, retry export without hardware acceleration if + the initial export fails. default: false type: object title: ExportRecordingsCustomBody @@ -7063,25 +8190,23 @@ components: score: anyOf: - type: number - - type: "null" + - type: 'null' title: Score description: Confidence score of the recognition (0-1) face_name: anyOf: - type: string - - type: "null" + - type: 'null' title: Face Name description: The recognized face name if successful type: object required: - success title: FaceRecognitionResponse - description: >- + description: |- Response model for face recognition endpoint. - - Returns the result of attempting to recognize a face from an uploaded - image. + Returns the result of attempting to recognize a face from an uploaded image. FacesResponse: additionalProperties: items: @@ -7104,36 +8229,33 @@ components: GenAIProbeBody: properties: provider: - type: string - enum: - - openai - - azure_openai - - gemini - - ollama - - llamacpp - title: Provider - description: GenAI provider to probe + $ref: '#/components/schemas/GenAIProviderEnum' api_key: anyOf: - type: string - - type: "null" - title: API Key - description: API key for the provider (when applicable) + - type: 'null' + title: Api Key base_url: anyOf: - type: string - - type: "null" - title: Base URL - description: Base URL for self-hosted or compatible providers + - type: 'null' + title: Base Url provider_options: type: object title: Provider Options - description: Additional provider-specific options - default: {} type: object required: - provider title: GenAIProbeBody + GenAIProviderEnum: + type: string + enum: + - openai + - azure_openai + - gemini + - ollama + - llamacpp + title: GenAIProviderEnum GenerateObjectExamplesBody: properties: model_name: @@ -7143,7 +8265,8 @@ components: label: type: string title: Label - description: "Object label to collect examples for (e.g., 'person', 'car')" + description: Object label to collect examples for (e.g., 'person', + 'car') type: object required: - model_name @@ -7167,9 +8290,8 @@ components: minItems: 4 type: object title: Cameras - description: >- - Dictionary mapping camera names to normalized crop coordinates in - [x1, y1, x2, y2] format (values 0-1) + description: Dictionary mapping camera names to normalized crop + coordinates in [x1, y1, x2, y2] format (values 0-1) type: object required: - model_name @@ -7192,7 +8314,7 @@ components: properties: detail: items: - $ref: "#/components/schemas/ValidationError" + $ref: '#/components/schemas/ValidationError' type: array title: Detail type: object @@ -7223,28 +8345,26 @@ components: dry_run: type: boolean title: Dry Run - description: "If True, only report orphans without deleting them" + description: If True, only report orphans without deleting them default: true media_types: items: type: string type: array title: Media Types - description: >- - Types of media to sync: 'all', 'event_snapshots', - 'event_thumbnails', 'review_thumbnails', 'previews', 'exports', - 'recordings' + description: "Types of media to sync: 'all', 'event_snapshots', 'event_thumbnails', + 'review_thumbnails', 'previews', 'exports', 'recordings'" default: - all force: type: boolean title: Force - description: "If True, bypass safety threshold checks" + description: If True, bypass safety threshold checks default: false verbose: type: boolean title: Verbose - description: "If True, write full orphan file list to /config/media_sync/.txt" + description: If True, write full orphan file list to disk default: false type: object title: MediaSyncBody @@ -7277,7 +8397,7 @@ components: wall_time_seconds: type: number title: Wall Time Seconds - default: 0 + default: 0.0 segments_with_errors: type: integer title: Segments With Errors @@ -7302,21 +8422,22 @@ components: type: array type: array title: Polygon Points - description: "List of [x, y] normalized coordinates (0-1) defining the ROI polygon" + description: List of [x, y] normalized coordinates (0-1) defining the + ROI polygon threshold: type: integer - maximum: 255 - minimum: 1 + maximum: 255.0 + minimum: 1.0 title: Threshold description: Pixel difference threshold (1-255) default: 30 min_area: type: number - maximum: 100 + maximum: 100.0 minimum: 0.1 title: Min Area description: Minimum change area as a percentage of the ROI - default: 5 + default: 5.0 parallel: type: boolean title: Parallel @@ -7324,8 +8445,8 @@ components: default: false max_results: type: integer - maximum: 200 - minimum: 1 + maximum: 200.0 + minimum: 1.0 title: Max Results description: Maximum number of search results to return default: 25 @@ -7384,33 +8505,33 @@ components: results: anyOf: - items: - $ref: "#/components/schemas/MotionSearchResult" + $ref: '#/components/schemas/MotionSearchResult' type: array - - type: "null" + - type: 'null' title: Results total_frames_processed: anyOf: - type: integer - - type: "null" + - type: 'null' title: Total Frames Processed error_message: anyOf: - type: string - - type: "null" + - type: 'null' title: Error Message metrics: anyOf: - - $ref: "#/components/schemas/MotionSearchMetricsResponse" - - type: "null" + - $ref: '#/components/schemas/MotionSearchMetricsResponse' + - type: 'null' scanning_timestamp: anyOf: - type: number - - type: "null" + - type: 'null' title: Scanning Timestamp progress: anyOf: - type: number - - type: "null" + - type: 'null' title: Progress type: object required: @@ -7526,7 +8647,7 @@ components: type: boolean title: Has Been Reviewed severity: - $ref: "#/components/schemas/SeverityEnum" + $ref: '#/components/schemas/SeverityEnum' thumb_path: type: string title: Thumb Path @@ -7546,10 +8667,10 @@ components: ReviewSummaryResponse: properties: last24Hours: - $ref: "#/components/schemas/Last24HoursReview" + $ref: '#/components/schemas/Last24HoursReview' root: additionalProperties: - $ref: "#/components/schemas/DayReview" + $ref: '#/components/schemas/DayReview' type: object title: Root type: object @@ -7576,9 +8697,15 @@ components: export_id: anyOf: - type: string - - type: "null" + - type: 'null' title: Export Id description: The export ID if successfully started + status: + anyOf: + - type: string + - type: 'null' + title: Status + description: Queue status for the export job type: object required: - success @@ -7610,14 +8737,14 @@ components: TriggerEmbeddingBody: properties: type: - $ref: "#/components/schemas/TriggerType" + $ref: '#/components/schemas/TriggerType' data: type: string title: Data threshold: type: number - maximum: 1 - minimum: 0 + maximum: 1.0 + minimum: 0.0 title: Threshold default: 0.5 type: object @@ -7631,6 +8758,36 @@ components: - thumbnail - description title: TriggerType + VLMMonitorRequest: + properties: + camera: + type: string + title: Camera + condition: + type: string + title: Condition + max_duration_minutes: + type: integer + title: Max Duration Minutes + default: 60 + labels: + items: + type: string + type: array + title: Labels + default: [] + zones: + items: + type: string + type: array + title: Zones + default: [] + type: object + required: + - camera + - condition + title: VLMMonitorRequest + description: Request model for starting a VLM watch job. ValidationError: properties: loc: @@ -7652,3 +8809,19 @@ components: - msg - type title: ValidationError + securitySchemes: + frigateAdminAuth: + type: apiKey + in: cookie + name: frigate_token + description: Authenticated session whose resolved role is 'admin'. The + session is established via the JWT cookie issued by POST /login, or via + proxy auth headers (remote-user / remote-role) when Frigate runs behind + an authenticating reverse proxy. + frigateUserAuth: + type: apiKey + in: cookie + name: frigate_token + description: Any authenticated session (role 'viewer' or higher), + established via the JWT cookie issued by POST /login, or via proxy auth + headers when Frigate runs behind an authenticating reverse proxy. diff --git a/generate_api_auth_spec.py b/generate_api_auth_spec.py new file mode 100644 index 0000000000..5768a6db64 --- /dev/null +++ b/generate_api_auth_spec.py @@ -0,0 +1,606 @@ +"""Generate the OpenAPI spec from the app, annotated with auth requirements. + +This generator builds the FastAPI application, exports its OpenAPI document via +``app.openapi()``, and enriches every operation with authentication metadata: + + * a ``components.securitySchemes`` block, + * a per-operation ``security`` requirement (so the docs render a lock badge), + * an ``x-required-role`` extension for machine readers, and + * a short bold ``Access:`` note prepended to each operation description. + +The committed docs/static/frigate-api.yaml is the output of this script. It is +generated rather than hand-maintained so it stays complete and current; the docs +build (docusaurus-plugin-openapi-docs) consumes it as-is. + +The access level for an endpoint is determined by BOTH its route-level +dependency (``require_role``/``allow_any_authenticated``/``allow_public``/ +``require_camera_access``) AND the global "secure by default" admin dependency, +which is bypassed only for the paths listed in ``require_admin_by_default``. +Those exempt lists are read directly from the function's closure so this script +stays in lockstep with ``frigate/api/auth.py`` instead of duplicating them. + +Many handlers enforce per-camera access by calling ``require_camera_access`` +inside the handler body rather than as a route dependency, which dependency +introspection cannot see. We recover those from the handler's bytecode (see +``_handler_enforces_camera``) and promote an otherwise "any authenticated" +operation to camera-scoped. + +Usage (from the repository root): + + python3 generate_api_auth_spec.py # write the spec + python3 generate_api_auth_spec.py --check # CI guard: fail if stale + +The process exits non-zero if the generated document fails structural +validation, or (in --check mode) if the committed spec is out of date. +""" + +import argparse +import difflib +import inspect +import io +import logging +import sys +from pathlib import Path + +from fastapi import FastAPI +from fastapi.routing import APIRoute +from ruamel.yaml import YAML +from ruamel.yaml.scalarstring import LiteralScalarString + +from frigate.api import app as main_app +from frigate.api import ( + auth, + camera, + chat, + classification, + debug_replay, + event, + export, + media, + motion_search, + notification, + preview, + record, + review, +) +from frigate.api.auth import require_admin_by_default + +logging.basicConfig(level=logging.INFO, format="%(message)s") +logger = logging.getLogger("generate_api_auth_spec") + +REPO_ROOT = Path(__file__).resolve().parent +OUTPUT_SPEC = REPO_ROOT / "docs" / "static" / "frigate-api.yaml" + +HTTP_METHODS = {"get", "post", "put", "delete", "patch"} + +# Banner written at the top of the generated spec. +HEADER = ( + "# Generated by generate_api_auth_spec.py — do not edit by hand.\n" + "# Regenerate with: python3 generate_api_auth_spec.py\n" + "# The empty info.title is intentional: a docusaurus-openapi-docs convention\n" + "# that suppresses the generated API introduction page.\n" +) + +# Post-processing applied on top of the raw app.openapi() export. These live +# only in the published spec, not in the app, so they are reproduced here. +SPEC_TITLE = "" +SPEC_SERVERS = [ + {"url": "https://demo.frigate.video/api"}, + {"url": "http://localhost:5001/api"}, +] + +# Access levels, ordered from least to most privileged. The string values are +# also what we emit as ``x-required-role``. +PUBLIC = "public" +AUTHENTICATED = "any" +CAMERA = "camera" +ADMIN = "admin" + +ADMIN_SCHEME = "frigateAdminAuth" +USER_SCHEME = "frigateUserAuth" + +SECURITY_SCHEMES = { + ADMIN_SCHEME: { + "type": "apiKey", + "in": "cookie", + "name": "frigate_token", + "description": ( + "Authenticated session whose resolved role is 'admin'. The session " + "is established via the JWT cookie issued by POST /login, or via " + "proxy auth headers (remote-user / remote-role) when Frigate runs " + "behind an authenticating reverse proxy." + ), + }, + USER_SCHEME: { + "type": "apiKey", + "in": "cookie", + "name": "frigate_token", + "description": ( + "Any authenticated session (role 'viewer' or higher), established " + "via the JWT cookie issued by POST /login, or via proxy auth " + "headers when Frigate runs behind an authenticating reverse proxy." + ), + }, +} + +# How each access level maps to a rendered note. +ACCESS_NOTES = { + PUBLIC: "**Access:** Public — no authentication required.", + AUTHENTICATED: "**Access:** Any authenticated user.", + CAMERA: "**Access:** Authenticated user with access to the referenced camera.", + ADMIN: "**Access:** Admin role required.", +} + + +def build_app() -> FastAPI: + """Build a bare app with every router mounted. + + This mirrors the router set wired up in frigate.api.fastapi_app. It omits + the global admin dependency and all runtime state; the OpenAPI route table + and the per-route dependencies are all we need to export and classify. + """ + app = FastAPI() + routers = [ + auth.router, + camera.router, + chat.router, + classification.router, + review.router, + main_app.router, + preview.router, + notification.router, + export.router, + event.router, + media.router, + motion_search.router, + record.router, + debug_replay.router, + ] + for router in routers: + app.include_router(router) + return app + + +def read_exempt_rules() -> tuple[set[str], tuple[str, ...]]: + """Read the admin-exemption lists straight from the auth dependency closure. + + Reading them here (rather than copying) keeps this generator in sync with + frigate/api/auth.py automatically. + """ + closure = inspect.getclosurevars(require_admin_by_default()).nonlocals + exempt_paths = set(closure["EXEMPT_PATHS"]) + exempt_prefixes = tuple(closure["EXEMPT_PREFIXES"]) + return exempt_paths, exempt_prefixes + + +def _first_segment(path: str) -> str: + return path.split("/", 2)[1] if path.startswith("/") and len(path) > 1 else "" + + +def _route_markers(route: APIRoute) -> tuple[set[str], list[str] | None]: + """Return the set of recognized auth markers on a route's dependencies.""" + markers: set[str] = set() + admin_roles: list[str] | None = None + + for dep in route.dependant.dependencies: + call = dep.call + qualname = getattr(call, "__qualname__", "") or "" + name = getattr(call, "__name__", "") or "" + + if "role_checker" in qualname: + markers.add(ADMIN) + try: + roles = inspect.getclosurevars(call).nonlocals.get("required_roles") + if roles: + admin_roles = list(roles) + except (TypeError, ValueError): + pass + elif name in ("require_camera_access", "require_go2rtc_stream_access"): + markers.add(CAMERA) + elif "auth_checker" in qualname: + markers.add(AUTHENTICATED) + elif "public_checker" in qualname: + markers.add(PUBLIC) + + return markers, admin_roles + + +def _handler_enforces_camera(route: APIRoute) -> bool: + """True if the route handler calls require_camera_access in its body. + + Such calls are invisible to dependency introspection. We detect them from + the handler's compiled bytecode: a global name referenced anywhere in the + function appears in ``__code__.co_names``. This catches direct calls (all of + them, currently); a call hidden behind a helper function would be missed. + """ + code = getattr(route.endpoint, "__code__", None) + return bool(code and "require_camera_access" in code.co_names) + + +def classify_route( + route: APIRoute, + exempt_paths: set[str], + exempt_prefixes: tuple[str, ...], +) -> tuple[str, list[str] | None, str | None]: + """Resolve the effective access level for a route. + + Returns (access_level, roles, flag). ``flag`` is a human-readable note when + the result needed inference or revealed a possible inconsistency. + """ + level, roles, flag = _classify_base(route, exempt_paths, exempt_prefixes) + + # In-body require_camera_access enforcement is invisible to dependency + # introspection. When the effective access would otherwise be "any + # authenticated", the handler's per-camera check is the real constraint, so + # promote it to camera-scoped. Admin/public are left alone: for admin the + # role is the binding requirement and the camera check is only defensive. + if level == AUTHENTICATED and _handler_enforces_camera(route): + return CAMERA, None, None + + return level, roles, flag + + +def _classify_base( + route: APIRoute, + exempt_paths: set[str], + exempt_prefixes: tuple[str, ...], +) -> tuple[str, list[str] | None, str | None]: + """Resolve the access level from route-level dependencies and exempt rules.""" + markers, admin_roles = _route_markers(route) + path = route.path + is_camera_path = _first_segment(path) == "{camera_name}" + exempt = path in exempt_paths or path.startswith(exempt_prefixes) or is_camera_path + + # Explicit route-level markers win, in order of specificity. + if ADMIN in markers: + return ADMIN, admin_roles or ["admin"], None + if CAMERA in markers: + return CAMERA, None, None + if AUTHENTICATED in markers: + if exempt: + return AUTHENTICATED, None, None + # The route opts in to any-authenticated, but the global admin check is + # not bypassed for this path, so admin is what actually gets enforced. + return ( + ADMIN, + ["admin"], + ( + "route declares allow_any_authenticated but path is not exempt from " + "the global admin check; admin is effectively enforced" + ), + ) + if PUBLIC in markers: + if exempt: + return PUBLIC, None, None + return ( + ADMIN, + ["admin"], + ( + "route declares allow_public but path is not exempt from the global " + "admin check; admin is effectively enforced" + ), + ) + + # No explicit auth marker: governed purely by the global default. + if not exempt: + return ADMIN, ["admin"], None + + # Exempt with no route dependency: the global admin check is bypassed and + # there is no route-level gate, so authorization (if any) happens inside the + # handler. Infer from the path shape and flag for confirmation. + if is_camera_path: + return ( + CAMERA, + None, + ( + "no route-level dependency; camera-scoped path, authorization " + "assumed to be enforced in the handler" + ), + ) + return ( + AUTHENTICATED, + None, + ( + "path is exempt from the global admin check but has no route-level " + "dependency; confirm authorization is enforced in the handler" + ), + ) + + +def build_access_map( + app: FastAPI, + exempt_paths: set[str], + exempt_prefixes: tuple[str, ...], +) -> dict[tuple[str, str], dict]: + """Map (path, lowercase method) -> classification details.""" + access_map: dict[tuple[str, str], dict] = {} + for route in app.routes: + if not isinstance(route, APIRoute): + continue + level, roles, flag = classify_route(route, exempt_paths, exempt_prefixes) + for method in route.methods: + if method in ("HEAD", "OPTIONS"): + continue + access_map[(route.path, method.lower())] = { + "level": level, + "roles": roles, + "flag": flag, + "path": route.path, + "method": method, + } + return access_map + + +def security_for(level: str) -> list: + """Build the OpenAPI ``security`` value for an access level.""" + if level == PUBLIC: + return [] + if level == ADMIN: + return [{ADMIN_SCHEME: []}] + # AUTHENTICATED and CAMERA both require any authenticated session; the + # camera-specific scoping is conveyed in the note and x-required-role. + return [{USER_SCHEME: []}] + + +def required_role_value(level: str, roles: list[str] | None): + if level == ADMIN and roles and roles != ["admin"]: + return roles + return level + + +def annotate_description(operation: dict, note: str) -> None: + existing = operation.get("description") + if not existing: + operation["description"] = note + return + operation["description"] = LiteralScalarString( + f"{note}\n\n{str(existing).rstrip()}" + ) + + +def base_document(raw: dict) -> dict: + """Apply the docs pipeline post-processing with a stable top-level order.""" + info = dict(raw.get("info", {})) + info["title"] = SPEC_TITLE + return { + "openapi": raw["openapi"], + "info": info, + "servers": [dict(server) for server in SPEC_SERVERS], + "paths": raw["paths"], + "components": raw.get("components", {}), + } + + +def enrich(spec: dict, access_map: dict) -> tuple[dict, list, list]: + """Add security schemes and per-operation auth metadata in place.""" + components = spec.setdefault("components", {}) + components["securitySchemes"] = dict(SECURITY_SCHEMES) + + counts: dict[str, int] = {} + flagged: list[dict] = [] + unmatched: list[tuple[str, str]] = [] + + for path, path_item in spec["paths"].items(): + for method, operation in path_item.items(): + if method.lower() not in HTTP_METHODS: + continue + details = access_map.get((path, method.lower())) + if details is None: + unmatched.append((method.upper(), path)) + continue + + level = details["level"] + counts[level] = counts.get(level, 0) + 1 + operation["security"] = security_for(level) + operation["x-required-role"] = required_role_value(level, details["roles"]) + annotate_description(operation, ACCESS_NOTES[level]) + + if details["flag"]: + flagged.append(details) + + return counts, flagged, unmatched + + +# Numeric defaults at or above this magnitude are treated as live Unix +# timestamps baked into the schema at import time (e.g. the /{camera_name} +# /recordings after/before params default to datetime.now()). They make the +# export non-deterministic and document a meaningless frozen epoch, so they are +# stripped. The proper fix is to default those route params to None and resolve +# "now" inside the handler. +VOLATILE_DEFAULT_THRESHOLD = 1_000_000_000 + + +def strip_volatile_defaults(node, trail: str = "") -> list[tuple[str, float]]: + """Remove epoch-like numeric ``default`` values so the export is stable. + + Returns the (location, value) pairs that were removed, for reporting. + """ + removed: list[tuple[str, float]] = [] + if isinstance(node, dict): + default = node.get("default") + if ( + isinstance(default, (int, float)) + and not isinstance(default, bool) + and default >= VOLATILE_DEFAULT_THRESHOLD + ): + removed.append((trail, default)) + del node["default"] + for key, value in node.items(): + removed.extend(strip_volatile_defaults(value, f"{trail}/{key}")) + elif isinstance(node, list): + for index, value in enumerate(node): + removed.extend(strip_volatile_defaults(value, f"{trail}[{index}]")) + return removed + + +def to_block_scalars(node): + """Recursively render multi-line strings as literal block scalars. + + Produces readable, deterministic YAML (``|-`` blocks) instead of long + double-quoted lines with escaped newlines. + """ + if isinstance(node, dict): + return {key: to_block_scalars(value) for key, value in node.items()} + if isinstance(node, list): + return [to_block_scalars(value) for value in node] + if isinstance(node, str) and "\n" in node: + return LiteralScalarString(node) + return node + + +def _iter_refs(node): + if isinstance(node, dict): + for key, value in node.items(): + if key == "$ref" and isinstance(value, str): + yield value + else: + yield from _iter_refs(value) + elif isinstance(node, list): + for value in node: + yield from _iter_refs(value) + + +def validate(spec: dict) -> list[str]: + """Structural sanity checks on the generated document.""" + problems: list[str] = [] + schemas = set(spec.get("components", {}).get("schemas", {})) + defined_schemes = set(spec.get("components", {}).get("securitySchemes", {})) + + for ref in _iter_refs(spec): + if ref.startswith("#/components/schemas/"): + name = ref.rsplit("/", 1)[-1] + if name not in schemas: + problems.append(f"dangling $ref: {ref}") + + for path, path_item in spec.get("paths", {}).items(): + for method, operation in path_item.items(): + if method.lower() not in HTTP_METHODS or not isinstance(operation, dict): + continue + location = f"{method.upper()} {path}" + if "x-required-role" not in operation: + problems.append(f"missing x-required-role: {location}") + if "security" not in operation: + problems.append(f"missing security: {location}") + continue + for requirement in operation["security"]: + for scheme in requirement: + if scheme not in defined_schemes: + problems.append( + f"undefined security scheme {scheme}: {location}" + ) + + return sorted(set(problems)) + + +def render(spec: dict) -> str: + """Serialize the spec to the canonical YAML string (with the header).""" + yaml = YAML() + yaml.width = 80 + yaml.indent(mapping=2, sequence=4, offset=2) + stream = io.StringIO() + yaml.dump(spec, stream) + return HEADER + stream.getvalue() + + +def build_spec() -> tuple[dict, dict, list, list, list]: + app = build_app() + exempt_paths, exempt_prefixes = read_exempt_rules() + access_map = build_access_map(app, exempt_paths, exempt_prefixes) + + spec = base_document(app.openapi()) + normalized = strip_volatile_defaults(spec) + counts, flagged, unmatched = enrich(spec, access_map) + spec = to_block_scalars(spec) + return spec, counts, flagged, unmatched, normalized + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Generate the annotated OpenAPI spec.") + parser.add_argument( + "--check", + action="store_true", + help="verify the committed spec is up to date without writing; " + "exit non-zero if it would change", + ) + args = parser.parse_args(argv) + + spec, counts, flagged, unmatched, normalized = build_spec() + problems = validate(spec) + rendered = render(spec) + + if args.check: + return _check(rendered, problems) + + if problems: + logger.error("Refusing to write — generated spec failed validation:") + for problem in problems: + logger.error(" %s", problem) + return 1 + + OUTPUT_SPEC.write_text(rendered) + _report(counts, flagged, unmatched, normalized) + logger.info("\nWrote %s", OUTPUT_SPEC.relative_to(REPO_ROOT)) + return 0 + + +def _check(rendered: str, problems: list[str]) -> int: + name = OUTPUT_SPEC.relative_to(REPO_ROOT) + if problems: + logger.error("Generated spec failed validation:") + for problem in problems: + logger.error(" %s", problem) + return 1 + + current = OUTPUT_SPEC.read_text() if OUTPUT_SPEC.exists() else "" + if current == rendered: + logger.info("%s is up to date", name) + return 0 + + logger.error( + "%s is out of date. Regenerate with: python3 %s", + name, + Path(__file__).name, + ) + diff = difflib.unified_diff( + current.splitlines(), + rendered.splitlines(), + fromfile=f"{name} (committed)", + tofile=f"{name} (generated)", + lineterm="", + n=2, + ) + for shown, line in enumerate(diff): + if shown >= 60: + logger.error(" ... (diff truncated)") + break + logger.error(" %s", line) + return 1 + + +def _report(counts, flagged, unmatched, normalized) -> None: + logger.info("Access levels applied:") + for level in (PUBLIC, AUTHENTICATED, CAMERA, ADMIN): + logger.info(" %-14s %d", level, counts.get(level, 0)) + logger.info(" %-14s %d", "total", sum(counts.values())) + + if normalized: + logger.info("\nStripped volatile timestamp defaults (%d):", len(normalized)) + for location, value in normalized: + logger.info(" %s = %s", location.lstrip("/"), value) + + if flagged: + logger.info("\nFlagged for manual confirmation (%d):", len(flagged)) + for item in flagged: + logger.info(" %-6s %s", item["method"], item["path"]) + logger.info(" -> %s (%s)", item["level"], item["flag"]) + + if unmatched: + logger.info( + "\nOperations with no classification (%d) [unexpected]:", len(unmatched) + ) + for method, path in unmatched: + logger.info(" %-6s %s", method, path) + + +if __name__ == "__main__": + sys.exit(main()) From bc816926a524d7c0c2bbe4249831f965f0a72007 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sat, 13 Jun 2026 17:43:22 -0500 Subject: [PATCH 15/92] Replace export ffmpeg argument blocklist with a structural allowlist (#23478) * use allowlist for custom export ffmpeg args * reject brackets in export filtergraph validation instead of stripping link labels --- frigate/record/export.py | 169 ++++++++++++++++++++++++++++-------- frigate/test/test_export.py | 132 ++++++++++++++++++++++++++++ 2 files changed, 264 insertions(+), 37 deletions(-) create mode 100644 frigate/test/test_export.py diff --git a/frigate/record/export.py b/frigate/record/export.py index e89742b1ab..5da5e818ff 100644 --- a/frigate/record/export.py +++ b/frigate/record/export.py @@ -42,33 +42,118 @@ TIMELAPSE_DATA_INPUT_ARGS = "-an -skip_frame nokey" # Captures the floating-point factor so we can scale expected duration. SETPTS_FACTOR_RE = re.compile(r"setpts=([0-9]*\.?[0-9]+)\*PTS") -# ffmpeg flags that can read from or write to arbitrary files -BLOCKED_FFMPEG_ARGS = frozenset( +# Allowlisted flags that take no value. +_VALUELESS_FLAGS = frozenset({"-an", "-sn", "-dn"}) + +# Allowlisted filter flags. Their value is validated as a filtergraph and may +# only reference filters in _SAFE_FILTERS. +_FILTER_FLAGS = frozenset({"-vf", "-af", "-filter"}) + +# Allowlisted flags that take exactly one value (encoder / muxer-safe options). +_VALUE_FLAGS = frozenset( { - "-i", - "-filter_script", - "-filter_complex", - "-lavfi", - "-vf", - "-af", - "-filter", - "-vstats_file", - "-passlogfile", - "-sdp_file", - "-dump_attachment", - "-attach", + "-c", + "-codec", + "-b", + "-crf", + "-qp", + "-q", + "-qscale", + "-preset", + "-tune", + "-profile", + "-level", + "-pix_fmt", + "-r", + "-g", + "-keyint_min", + "-sc_threshold", + "-bf", + "-refs", + "-qmin", + "-qmax", + "-maxrate", + "-minrate", + "-bufsize", + "-movflags", + "-threads", + "-aspect", + "-fps_mode", + "-vsync", + "-skip_frame", } ) +_ALLOWED_FLAGS = _VALUELESS_FLAGS | _FILTER_FLAGS | _VALUE_FLAGS + +# Filters that cannot read files, load plugins, or open network sources. +_SAFE_FILTERS = frozenset( + { + "setpts", + "fps", + "scale", + "format", + "transpose", + "hflip", + "vflip", + "crop", + "pad", + "setsar", + "setdar", + } +) + +# Conservative shape for a non-filter flag value. Excludes "/" (paths / +# filtergraph division), whitespace, brackets, and a leading "-" so a value +# can never be a path or swallow a following flag. ":" is permitted for values +# like "16:9". +_SAFE_VALUE_RE = re.compile(r"^[A-Za-z0-9_.:+][A-Za-z0-9_.:+-]*$") + +# Substrings inside a filtergraph that indicate a file-reading filter option. +# "movie=" also matches "amovie=" as a substring. +_BLOCKED_FILTER_VALUE_MARKERS = ("movie=", "textfile=", "filename=", "fontfile=") + + +def _base_flag(token: str) -> str: + """Return a flag's base name, lowercased and without its stream specifier. + + e.g. "-c:v" -> "-c", "-filter:a:0" -> "-filter". + """ + return token.lower().split(":", 1)[0] + + +def _validate_filtergraph(value: str) -> tuple[bool, str]: + """Validate a filtergraph value, allowing only filters in _SAFE_FILTERS.""" + # None of the safe filters need any of these + if any(token in value for token in ("://", "..", "[", "]")): + return False, "Invalid filter graph in custom ffmpeg arguments" + + lowered = value.lower() + if any(marker in lowered for marker in _BLOCKED_FILTER_VALUE_MARKERS): + return False, "File-reading filters are not allowed in custom ffmpeg arguments" + + # Filters are separated by "," within a chain and ";" between chains. Safe + # filters never use unescaped "," or ";" in their arguments, so splitting on + # them to recover filter names cannot hide a disallowed filter. + for spec in re.split(r"[;,]", value): + spec = spec.strip() + if not spec: + continue + + name = spec.split("=", 1)[0].strip().lower() + if name not in _SAFE_FILTERS: + return False, f"Filter not allowed in custom ffmpeg arguments: {name}" + + return True, "" + def validate_ffmpeg_args(args: str) -> tuple[bool, str]: - """Validate that user-provided ffmpeg args don't allow input/output injection. + """Validate user-provided custom export ffmpeg args with an allowlist. - Blocks: - - The -i flag and other flags that read/write arbitrary files - - Filter flags (can read files via movie=/amovie= source filters) - - Absolute/relative file paths (potential extra outputs) - - URLs and ffmpeg protocol references (data exfiltration) + Every token must be an allowlisted flag or the value of one; filter values + may only reference safe filters; and no token may become a bare input or + output URL. This structurally prevents arbitrary file read/write, network + exfiltration/SSRF, and resource-exhaustion via the export endpoint. Admin users skip this validation entirely since they are trusted. """ @@ -76,26 +161,36 @@ def validate_ffmpeg_args(args: str) -> tuple[bool, str]: return True, "" tokens = args.split() - for token in tokens: - # Block flags that could inject inputs or write to arbitrary files - if token.lower() in BLOCKED_FFMPEG_ARGS: + i = 0 + while i < len(tokens): + token = tokens[i] + + # A bare (non-flag) token here would be parsed by ffmpeg as an input or + # output URL. Only the server sets inputs/outputs, never the user. + if not token.startswith("-"): + return False, f"Unexpected argument in custom ffmpeg arguments: {token}" + + base = _base_flag(token) + if base not in _ALLOWED_FLAGS: return False, f"Forbidden ffmpeg argument: {token}" - # Block tokens that look like file paths (potential output injection) - if ( - token.startswith("/") - or token.startswith("./") - or token.startswith("../") - or token.startswith("~") - ): - return False, "File paths are not allowed in custom ffmpeg arguments" + if base in _VALUELESS_FLAGS: + i += 1 + continue - # Block URLs and ffmpeg protocol references (e.g. http://, tcp://, pipe:, file:) - if "://" in token or token.startswith("pipe:") or token.startswith("file:"): - return ( - False, - "Protocol references are not allowed in custom ffmpeg arguments", - ) + # Remaining flags consume exactly one value. + if i + 1 >= len(tokens): + return False, f"Missing value for ffmpeg argument: {token}" + + value = tokens[i + 1] + if base in _FILTER_FLAGS: + valid, message = _validate_filtergraph(value) + if not valid: + return False, message + elif not _SAFE_VALUE_RE.match(value): + return False, f"Invalid value for {token}: {value}" + + i += 2 return True, "" diff --git a/frigate/test/test_export.py b/frigate/test/test_export.py new file mode 100644 index 0000000000..7612a4144f --- /dev/null +++ b/frigate/test/test_export.py @@ -0,0 +1,132 @@ +import unittest + +from frigate.record.export import validate_ffmpeg_args + + +class TestValidateFfmpegArgs(unittest.TestCase): + """Tests for the non-admin custom export ffmpeg arg validator. + + The validator uses a structural allowlist: every token must be an + allowlisted flag or the value of one, filter values are restricted to a + safe set of filters, and no token may become a bare input/output URL. + """ + + def assertRejected(self, args: str) -> None: + valid, message = validate_ffmpeg_args(args) + self.assertFalse(valid, f"expected {args!r} to be rejected") + self.assertNotEqual(message, "") + + def assertAllowed(self, args: str) -> None: + valid, message = validate_ffmpeg_args(args) + self.assertTrue(valid, f"expected {args!r} to be allowed, got: {message}") + self.assertEqual(message, "") + + # --- legitimate use cases must keep working --------------------------- + + def test_timelapse_setpts_allowed(self): + # The whole reason -vf cannot simply be blocked: timelapse exports. + self.assertAllowed("-vf setpts=PTS/60 -r 25") + self.assertAllowed("-vf setpts=0.04*PTS -r 30") # server default + self.assertAllowed("-filter:v setpts=PTS/60 -r 25") + + def test_default_input_args_allowed(self): + self.assertAllowed("") + self.assertAllowed("-an -skip_frame nokey") + + def test_encoding_args_allowed(self): + self.assertAllowed("-c:v libx264 -crf 23 -preset fast") + self.assertAllowed("-c:v copy -c:a copy") + self.assertAllowed("-c:v libx264 -b:v 2M -maxrate 2M -bufsize 4M") + self.assertAllowed("-movflags +faststart") + self.assertAllowed("-pix_fmt yuv420p -r 30 -g 30") + + def test_safe_filters_allowed(self): + self.assertAllowed("-vf scale=640:480") + self.assertAllowed("-vf scale=640:480,setpts=0.5*PTS") + self.assertAllowed("-vf format=yuv420p") + self.assertAllowed("-vf transpose=1") + self.assertAllowed("-vf hflip") + self.assertAllowed("-vf fps=15") + self.assertAllowed("-vf setsar=1 -an") + self.assertAllowed("-vf setdar=16/9") + + # --- the reported advisory and file-read class ------------------------ + + def test_reported_advisory_rejected(self): + self.assertRejected( + "-filter:v drawtext=textfile=/etc/passwd:fontcolor=white:fontsize=20" + ) + + def test_file_reading_filters_rejected(self): + self.assertRejected("-vf movie=/etc/passwd") + self.assertRejected("-vf drawtext=textfile=/etc/passwd") + self.assertRejected("-vf subtitles=/etc/passwd") + # marker embedded as an option of an otherwise-allowed filter name + self.assertRejected("-vf scale=movie=/etc/passwd") + + def test_filtergraph_brackets_rejected(self): + # link labels aren't needed for safe filters; rejecting "[" / "]" keeps + # filtergraph validation linear (no ReDoS on attacker input) + self.assertRejected("-vf [in]scale=640:480[out]") + self.assertRejected("-vf " + "[" * 5000) + + def test_preset_file_read_rejected(self): + # cwd-anchored traversal slipped past the old startswith() path check + self.assertRejected("-fpre frigate/../../../etc/passwd") + self.assertRejected("-fpre evil.preset") + self.assertRejected("-vpre x") + self.assertRejected("-apre x") + self.assertRejected("-pre x") + + def test_slash_option_file_read_rejected(self): + # ffmpeg "-/option file" reads the option value from a file + self.assertRejected("-/filter:v graph.txt") + self.assertRejected("-/filter_complex graph.txt") + + # --- network / SSRF class --------------------------------------------- + + def test_schemeless_protocol_rejected(self): + self.assertRejected("-f mpegts tcp:10.0.0.5:4444") + self.assertRejected("tcp:10.0.0.5:4444") + self.assertRejected("udp:10.0.0.5:4444") + self.assertRejected("-progress http:attacker.example.com:80/p") + + # --- file-write class -------------------------------------------------- + + def test_tee_write_rejected(self): + self.assertRejected("-c:v libx264 -map 0 -f tee [f=mpegts]/tmp/owned.ts") + self.assertRejected("-f tee [f=mpegts]/etc/frigate/x.ts") + self.assertRejected("tee:/tmp/x") + + def test_bare_output_token_rejected(self): + self.assertRejected("evil.mp4") + self.assertRejected("-c copy evil.mp4") + self.assertRejected("x/../escaped.mkv") + + def test_file_producing_muxers_rejected(self): + self.assertRejected("-f hls -hls_segment_filename pwn%03d.ts out.m3u8") + self.assertRejected("-f md5 victim.txt") + self.assertRejected("-f segment seg%03d.ts") + + def test_write_flags_rejected(self): + self.assertRejected("-progress evil.log") + self.assertRejected("-stats_enc_pre evil.csv") + self.assertRejected("-report") + + # --- resource exhaustion / misc --------------------------------------- + + def test_dos_input_flags_rejected(self): + self.assertRejected("-stream_loop -1") + self.assertRejected("-readrate 0.001") + + def test_disallowed_flags_rejected(self): + self.assertRejected("-map 0") + self.assertRejected("-i /etc/passwd") + self.assertRejected("-attach evil.bin") + self.assertRejected("-dump_attachment evil.bin") + self.assertRejected("/etc/passwd") + self.assertRejected("-metadata comment=x") + + +if __name__ == "__main__": + unittest.main() From 32e433cafc86b126dd3dd1ebde861c48abe6e9eb Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Sun, 14 Jun 2026 10:40:33 -0600 Subject: [PATCH 16/92] Allow GenAI providers to be initialized lazily (#23482) * allow GenAI providers to be initialized even if they failed on previous attempts * mypy --- frigate/genai/__init__.py | 33 +++++++++++++++++++++++++++++++++ frigate/genai/manager.py | 6 ++++-- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/frigate/genai/__init__.py b/frigate/genai/__init__.py index bca5e6d691..e1f91e31d7 100644 --- a/frigate/genai/__init__.py +++ b/frigate/genai/__init__.py @@ -5,6 +5,7 @@ import json import logging import os import re +import time from typing import Any, AsyncGenerator, Callable, Optional import numpy as np @@ -50,6 +51,10 @@ def register_genai_provider(key: GenAIProviderEnum) -> Callable: class GenAIClient: """Generative AI client for Frigate.""" + # Minimum seconds between re-initialization attempts when the provider was + # offline at startup + REINIT_INTERVAL = 60.0 + def __init__( self, genai_config: GenAIConfig, @@ -60,6 +65,34 @@ class GenAIClient: self.timeout = timeout self.validate_model = validate_model self.provider = self._init_provider() + self._last_init_attempt = time.monotonic() + + def ensure_provider(self) -> bool: + """Ensure a provider is available, retrying initialization if needed. + + Providers can fail to initialize at startup when their backing service + isn't online yet (common when both are started together). This retries + ``_init_provider`` lazily — throttled to ``REINIT_INTERVAL`` — so the + client recovers on its own once the service is reachable, without a + config reload. + + Returns True if a provider is available. + """ + if self.provider is not None: + return True + + now = time.monotonic() + if now - self._last_init_attempt < self.REINIT_INTERVAL: + return False + + self._last_init_attempt = now + self.provider = self._init_provider() + if self.provider is not None: + logger.info( + "GenAI provider %s is now available", + self.genai_config.provider, + ) + return self.provider is not None def generate_review_description( self, diff --git a/frigate/genai/manager.py b/frigate/genai/manager.py index a1325d3279..a545f81f4a 100644 --- a/frigate/genai/manager.py +++ b/frigate/genai/manager.py @@ -62,7 +62,9 @@ class GenAIClientManager: def _get_client(self, name: str) -> "Optional[GenAIClient]": """Return the client for *name*, creating it on first access.""" if name in self._clients: - return self._clients[name] + client = self._clients[name] + client.ensure_provider() + return client from frigate.genai import PROVIDERS @@ -78,7 +80,7 @@ class GenAIClientManager: return None try: - client: "GenAIClient" = provider_cls(genai_cfg) + client = provider_cls(genai_cfg) except Exception as e: logger.exception( "Failed to create GenAI client for provider %s: %s", From ba29e141da0fffd8f58ae2f8a39f936366d312e0 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 15 Jun 2026 08:03:37 -0500 Subject: [PATCH 17/92] Docs tweaks (#23487) * docs tweaks * tweak * title tweak --- docs/docs/configuration/birdseye.md | 34 +++++++++------ docs/docs/configuration/config.md | 47 ++++++++++++++++++++- docs/docs/configuration/live.md | 2 +- docs/docs/configuration/object_detectors.md | 7 ++- docs/docs/configuration/snapshots.md | 12 ++++-- docs/docs/usage/history.md | 6 +++ docs/docs/usage/live.md | 4 +- docs/docs/usage/review.md | 4 +- 8 files changed, 89 insertions(+), 27 deletions(-) diff --git a/docs/docs/configuration/birdseye.md b/docs/docs/configuration/birdseye.md index 8104494787..bf6ac085fa 100644 --- a/docs/docs/configuration/birdseye.md +++ b/docs/docs/configuration/birdseye.md @@ -6,10 +6,16 @@ import NavPath from "@site/src/components/NavPath"; In addition to Frigate's Live camera dashboard, Birdseye allows a portable heads-up view of your cameras to see what is going on around your property / space without having to watch all cameras that may have nothing happening. Birdseye allows specific modes that intelligently show and disappear based on what you care about. -Birdseye can be viewed by adding the "Birdseye" camera to a Camera Group in the Web UI. Add a Camera Group by pressing the "+" icon on the Live page, and choose "Birdseye" as one of the cameras. +Birdseye can be viewed by adding the "Birdseye" camera to a Camera Group in the Web UI. Add a Camera Group by pressing the pencil icon in the sidebar on the Live page, and choose "Birdseye" as one of the cameras. Birdseye can also be used in Home Assistant dashboards, cast to media devices, etc. +:::note + +Each camera tile in Birdseye is composed from the frames of the stream assigned the `detect` role, so a camera's image quality in Birdseye matches its detect stream resolution rather than a higher-resolution recording stream. If a camera looks low quality in Birdseye, increasing the detect width and height (or assigning the `detect` role to a higher-resolution stream) is what affects it. See [setting up camera inputs](./cameras.md#setting-up-camera-inputs) for how roles are assigned. + +::: + ## Birdseye Behavior ### Birdseye Modes @@ -35,10 +41,10 @@ To include a camera in Birdseye view only for specific circumstances, or exclude **Per-camera overrides:** Navigate to to override the mode or disable Birdseye for a specific camera. -| Field | Description | -|-------|-------------| -| **Enable Birdseye** | Whether this camera appears in Birdseye view | -| **Tracking mode** | When to show the camera: `continuous`, `motion`, or `objects` | +| Field | Description | +| ------------------- | ------------------------------------------------------------- | +| **Enable Birdseye** | Whether this camera appears in Birdseye view | +| **Tracking mode** | When to show the camera: `continuous`, `motion`, or `objects` | @@ -72,8 +78,8 @@ By default birdseye shows all cameras that have had the configured activity in t Navigate to . -| Field | Description | -|-------|-------------| +| Field | Description | +| ------------------------ | --------------------------------------------------------------------------- | | **Inactivity threshold** | Seconds of inactivity before a camera is hidden from Birdseye (default: 30) | @@ -100,9 +106,9 @@ The resolution and aspect ratio of birdseye can be configured. Resolution will i Navigate to . -| Field | Description | -|-------|-------------| -| **Width** | Birdseye output width in pixels (default: 1280) | +| Field | Description | +| ---------- | ----------------------------------------------- | +| **Width** | Birdseye output width in pixels (default: 1280) | | **Height** | Birdseye output height in pixels (default: 720) | @@ -161,8 +167,8 @@ It is possible to limit the number of cameras shown on birdseye at one time. Whe Navigate to . -| Field | Description | -|-------|-------------| +| Field | Description | +| ------------------------ | ----------------------------------------------------------------------------------- | | **Layout > Max cameras** | Maximum number of cameras shown at once (e.g., `1` for only the most active camera) | @@ -187,8 +193,8 @@ By default birdseye tries to fit 2 cameras in each row and then double in size u Navigate to . -| Field | Description | -|-------|-------------| +| Field | Description | +| --------------------------- | -------------------------------------------------------- | | **Layout > Scaling factor** | Camera scaling factor between 1.0 and 5.0 (default: 2.0) | diff --git a/docs/docs/configuration/config.md b/docs/docs/configuration/config.md index b2572efda7..44b67e4f42 100644 --- a/docs/docs/configuration/config.md +++ b/docs/docs/configuration/config.md @@ -9,11 +9,54 @@ import NavPath from "@site/src/components/NavPath"; Frigate can be configured through the **Settings UI** or by editing the YAML configuration file directly. The Settings UI is the recommended approach — it provides validation and a guided experience for all configuration options. -It is recommended to start with a minimal configuration and add to it as described in [the getting started guide](../guides/getting_started.md). +## Using the Settings UI + +The Settings UI groups every configuration option into sections that are listed in the left-hand menu. Each section presents a guided form with validation, so you don't need to remember the structure of the YAML or look up option names by hand. + +### Global vs. camera-level configuration + +Settings are organized into two scopes: + +- **Global configuration** — values under apply to every camera by default. This is where you set the baseline behavior for object detection, recording, snapshots, motion, and so on. +- **Camera configuration** — values under apply to a single camera. Use the camera selector button at the top of these pages to choose which camera you are editing. + +When a camera-level section is left untouched, the camera simply inherits the global values. Changing a value on a camera page **overrides** the global value for that camera only — the global setting and every other camera are unaffected. This mirrors how the YAML works, where a value set under `cameras.` takes precedence over the same value set at the top level. + +To undo an override and go back to inheriting from the parent scope, use the reset button at the bottom of the section: + +- On a camera section, the button is labeled **Reset to Global** and restores the camera to the global value. +- On a global section, the button is labeled **Reset to Default** and restores Frigate's built-in default. + +Resetting asks for confirmation and cannot be undone once applied. + +### Saving changes and the Save All button + +Edits are not applied until you save them. As soon as you change a value, the UI tracks it as a pending change: + +- The edited section shows a **Modified** badge, and the changed fields are highlighted. +- A **You have unsaved changes** notice appears above the section's **Save** and **Undo** buttons. **Save** commits just that section; **Undo** discards its pending edits. + +Because pending changes can span multiple sections — and multiple cameras — the header provides a **Save All** button that writes every pending change at once. Next to it, **Review pending changes** opens a summary that lists each pending edit with its scope (Global or a specific camera), the affected field, and the new value, so you can confirm exactly what will be written before committing. **Undo All** discards every pending change across all sections. + +### Restart-required indicators + +Most settings take effect immediately, but some require Frigate to restart before they apply. Fields that require a restart are marked with a small restart icon and a **Restart required** tooltip next to the field label. + +When you save a change that touches one of these fields, Frigate confirms the save and reminds you that a restart is needed (for example, _"Settings saved successfully. Restart Frigate to apply your changes."_). The notification includes a one-click **Restart Frigate** action so you can apply the change right away, or you can continue editing and restart later. + +### The colored dots in the camera configuration menu + +When you are working under , small colored dots can appear next to a section's name in the menu. They give you an at-a-glance summary of that section's state for the selected camera: + +- **Blue dot** — this section **overrides the global configuration**. One or more values in the section have been set specifically for this camera and differ from the global defaults. +- **Profile-colored dot** — when you are viewing a [camera profile](./profiles.md), a dot in that profile's assigned color indicates the section is **overridden by that profile**. Each profile is given its own distinct color so you can tell at a glance which sections it changes. +- **Amber dot** — this section has **unsaved changes**. It appears alongside the **Modified** badge whenever you have pending edits in the section that haven't been saved yet. + +Hover over any dot to see a tooltip describing what it means. Open a section to see exactly which fields are overridden — the section header indicates how many fields differ from the global (or base) configuration. ## Configuration File Location -For users who prefer to edit the YAML configuration file directly: +For users who prefer to edit the YAML configuration file directly, it is recommended to start with a minimal configuration and add to it as described in [the getting started guide](../guides/getting_started.md). - **Home Assistant App:** `/addon_configs//config.yml` — see [directory list](#accessing-app-config-dir) - **All other installations:** Map to `/config/config.yml` inside the container diff --git a/docs/docs/configuration/live.md b/docs/docs/configuration/live.md index effe89de3c..c761214b2a 100644 --- a/docs/docs/configuration/live.md +++ b/docs/docs/configuration/live.md @@ -371,7 +371,7 @@ When your browser runs into problems playing back your camera streams, it will l - Verify your camera's H.264/AAC settings (see [Frigate's camera settings recommendations](#camera-settings-recommendations)). - Check go2rtc configuration for transcoding (e.g., audio to AAC/OPUS). - Test with a different stream via the UI dropdown (if `live -> streams` is configured). - - For WebRTC-specific issues, ensure port 8555 is forwarded and candidates are set (see (WebRTC Extra Configuration)(#webrtc-extra-configuration)). + - For WebRTC-specific issues, ensure port 8555 is forwarded and candidates are set (see [WebRTC Extra Configuration](#webrtc-extra-configuration)). - If your cameras are streaming at a high resolution, your browser may be struggling to load all of the streams before the buffering timeout occurs. Frigate prioritizes showing a true live view as quickly as possible. If the fallback occurs often, change your live view settings to use a lower bandwidth substream. 3. **It doesn't seem like my cameras are streaming on the Live dashboard. Why?** diff --git a/docs/docs/configuration/object_detectors.md b/docs/docs/configuration/object_detectors.md index e4a5082322..4a197da451 100644 --- a/docs/docs/configuration/object_detectors.md +++ b/docs/docs/configuration/object_detectors.md @@ -8,10 +8,13 @@ import ConfigTabs from "@site/src/components/ConfigTabs"; import TabItem from "@theme/TabItem"; import NavPath from "@site/src/components/NavPath"; -# Supported Hardware +### Supported hardware + +Object detection is what allows Frigate to identify _what_ is in your camera's view — people, cars, animals, and more — rather than just reacting to pixel changes. When Frigate's motion detection finds activity in a frame, that region is sent to an **object detector**, which returns the objects it recognizes along with their location and a confidence score. These detections are what drive tracked objects, alerts, detections, and notifications. + +Object detection is computationally intensive, so Frigate is designed to run it on a dedicated AI accelerator or GPU rather than the CPU. A **detector** is the specific hardware-and-model backend Frigate uses to run inference. Choosing a detector that matches your hardware is one of the most important steps in getting good performance, and the right choice depends on what device Frigate is running on. :::info - Frigate supports multiple different detectors that work on different types of hardware: **Most Hardware** diff --git a/docs/docs/configuration/snapshots.md b/docs/docs/configuration/snapshots.md index 675e68a9ca..84516ce685 100644 --- a/docs/docs/configuration/snapshots.md +++ b/docs/docs/configuration/snapshots.md @@ -7,13 +7,17 @@ import ConfigTabs from "@site/src/components/ConfigTabs"; import TabItem from "@theme/TabItem"; import NavPath from "@site/src/components/NavPath"; -Frigate can save a snapshot image to `/media/frigate/clips` for each object that is detected named as `--clean.webp`. They are also accessible [via the api](../integrations/api/event-snapshot-events-event-id-snapshot-jpg-get.api.mdx) +A snapshot is a single still image that captures a tracked object at its best moment — the clearest frame Frigate saw while following that object across the scene. Unlike a [recording](./record.md), which is continuous video, a snapshot is one representative image saved per tracked object once tracking ends. -Snapshots are accessible in the UI in the Explore pane. This allows for quick submission to the Frigate+ service. +When snapshots are enabled, Frigate saves one image to `/media/frigate/clips` for each tracked object, named `--clean.webp`. A clean image is always stored without any annotations (no timestamp, bounding boxes, or cropping) so you have an unmodified copy of the original frame. Annotations like bounding boxes and timestamps are applied on demand when a snapshot is requested [via the HTTP API](../integrations/api/event-snapshot-events-event-id-snapshot-jpg-get.api.mdx) — see [Rendering](#rendering) below. -To only save snapshots for objects that enter a specific zone, [see the zone docs](./zones.md#restricting-snapshots-to-specific-zones) +A few things to keep in mind: -Snapshots sent via MQTT are configured separately under the camera MQTT settings, not here. +- Snapshots are saved per tracked object, so a camera with no detected objects produces no snapshots even if recording is enabled. +- Snapshots and recordings are configured and retained independently — enabling one does not enable the other. +- Snapshots are accessible in the UI in the Explore pane, which allows for quick submission to the Frigate+ service. +- To only save snapshots for objects that enter a specific zone, [see the zone docs](./zones.md#restricting-snapshots-to-specific-zones). +- Snapshots sent via MQTT are configured separately under the camera MQTT settings, not here. ## Enabling Snapshots diff --git a/docs/docs/usage/history.md b/docs/docs/usage/history.md index 457f9f951b..809d818ab2 100644 --- a/docs/docs/usage/history.md +++ b/docs/docs/usage/history.md @@ -19,6 +19,12 @@ You can open History from several places: Use the **Back** button to return where you came from, or the **Live** button to jump to the current camera's live view. +:::tip + +If you see **"No recordings found for this time"**, the most common causes are: recording was not enabled for that camera at the time of the event; the retention window has since expired and those segments were removed; or storage ran low and Frigate deleted them early to free space. See [Recording](/configuration/record) to verify your retention settings. + +::: + ## Timeline, Events, and Detail A toggle (a drawer on mobile) switches the side panel between three modes: diff --git a/docs/docs/usage/live.md b/docs/docs/usage/live.md index 2cd3da01ed..e4ff034bd6 100644 --- a/docs/docs/usage/live.md +++ b/docs/docs/usage/live.md @@ -11,7 +11,7 @@ This page describes how to _use_ the Live view. For how to _configure_ live stre ## The dashboard at a glance -The default **All Cameras** dashboard shows every camera, with a filmstrip of recent **alerts** scrolling across the top. Clicking an alert opens it in [Review](/usage/review); each card also has a check button to mark it reviewed without leaving the dashboard. +The default **All Cameras** dashboard shows every camera, with a filmstrip of recent **alerts** scrolling across the top. Clicking an alert opens it in [Review](/usage/review); each card also has a check button to mark it reviewed without leaving the dashboard. Only **alerts** appear in the filmstrip — to suppress a label or zone from showing there, configure it as a detection instead (see [Alerts and Detections](/configuration/review#alerts-and-detections)). By default Frigate uses **smart streaming**: a camera's image updates roughly once per minute while nothing is happening, and switches to a full live stream the moment activity is detected. This conserves bandwidth and resources. You can change this per camera or per group (see [Streaming settings](#streaming-settings-and-the-right-click-menu) below), and the behavior is explained in detail under [Live view technologies](/configuration/live#live-view-technologies). @@ -58,7 +58,7 @@ You can optionally overlay live streaming statistics (stream type, bandwidth, la ## Streaming settings and the right-click menu -Right-clicking (or long-pressing) a camera tile opens a context menu with quick controls: an **audio volume** control for streams that support audio, **Mute / Unmute all cameras**, **show or hide streaming statistics**, the **debug view**, **notification** options, and — for admins — turning the camera on or off. +Right-clicking (or long-pressing) a camera tile opens a context menu with quick controls: an **audio volume** control for streams that support audio, **Mute / Unmute all cameras**, **show or hide streaming statistics**, the **debug view**, **notification** options, and — for admins — turning the camera on or off. If the audio control doesn't appear, see [Audio Support](/configuration/live#audio-support) — audio requires go2rtc configured with a compatible codec. A **Low-bandwidth mode** notice may also appear in the context menu with a **Reset** option appears when Frigate has fallen back to the lower-quality jsmpeg stream — see the [Live view FAQ](/configuration/live#live-view-faq) for why this happens. diff --git a/docs/docs/usage/review.md b/docs/docs/usage/review.md index 6a5f05b265..b578038860 100644 --- a/docs/docs/usage/review.md +++ b/docs/docs/usage/review.md @@ -11,7 +11,7 @@ This page describes how to _use_ the Review view. For how alerts and detections :::info -Review items are only created for a camera when **recording is enabled** for that camera. See [Recording](/configuration/record). +Review items are only created for a camera when **object tracking and recording are enabled** for that camera. See [Recording](/configuration/record). ::: @@ -39,7 +39,7 @@ Review items are shown as a grid of thumbnail cards next to a vertical activity - The object chip on each card is **gray** when the item is unreviewed and turns **green** once it has been reviewed. - The **Mark these items as reviewed** button marks everything currently shown as reviewed at once. -Reviewed state is tracked per user, so marking an item reviewed does not hide it for other users. +Reviewed state is tracked per user, so marking an item reviewed does not hide it for other users. Marking an item reviewed does not delete anything — the footage and the review item itself remain until they expire via retention. ## Selecting and acting on multiple items From e84a89ef3ebe11b848393ab7b627dbef4ce41da5 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 15 Jun 2026 08:26:19 -0500 Subject: [PATCH 18/92] fix camera audio availability detection on mobile live grid (#23488) --- web/src/views/live/LiveDashboardView.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/web/src/views/live/LiveDashboardView.tsx b/web/src/views/live/LiveDashboardView.tsx index e935148151..975d97444e 100644 --- a/web/src/views/live/LiveDashboardView.tsx +++ b/web/src/views/live/LiveDashboardView.tsx @@ -592,6 +592,7 @@ export default function LiveDashboardView({ resetPreferredLiveMode(camera.name) } config={config} + streamMetadata={streamMetadata} > Date: Tue, 16 Jun 2026 08:56:52 -0500 Subject: [PATCH 19/92] UI tweaks (#23492) * slightly darken bg-card * change menu label * move snapshot retain out of advanced fields * add new ui options for collapsibles * backend title and description * remove unused snapshot retention field * update reference config * remove further references to snapshots retain.mode --- docs/docs/configuration/advanced/reference.md | 5 - docs/docs/configuration/snapshots.md | 2 - frigate/config/camera/camera.py | 4 +- frigate/config/camera/snapshots.py | 6 - web/public/locales/en/config/cameras.json | 8 +- web/public/locales/en/config/global.json | 4 - web/public/locales/en/views/settings.json | 2 +- .../config-form/section-configs/record.ts | 16 +- .../config-form/section-configs/snapshots.ts | 8 +- .../theme/templates/ObjectFieldTemplate.tsx | 137 ++++++++++-------- web/themes/theme-default.css | 4 +- 11 files changed, 99 insertions(+), 97 deletions(-) diff --git a/docs/docs/configuration/advanced/reference.md b/docs/docs/configuration/advanced/reference.md index 81feb17db7..660c003192 100644 --- a/docs/docs/configuration/advanced/reference.md +++ b/docs/docs/configuration/advanced/reference.md @@ -655,11 +655,6 @@ snapshots: retain: # Required: Default retention days (default: shown below) default: 10 - # Optional: Mode for retention. (default: shown below) - # all - save all snapshots regardless of activity - # motion - save snapshots for any detected motion - # active_objects - save snapshots for active/moving objects - mode: motion # Optional: Per object retention days objects: person: 15 diff --git a/docs/docs/configuration/snapshots.md b/docs/docs/configuration/snapshots.md index 84516ce685..0d674b61ac 100644 --- a/docs/docs/configuration/snapshots.md +++ b/docs/docs/configuration/snapshots.md @@ -111,7 +111,6 @@ Navigate to . | Field | Description | | -------------------------------------------------- | ----------------------------------------------------------------------------------- | | **Snapshot retention > Default retention** | Number of days to retain snapshots (default: 10) | -| **Snapshot retention > Retention mode** | Retention mode: `all`, `motion`, or `active_objects` | | **Snapshot retention > Object retention > Person** | Per-object overrides for retention days (e.g., keep `person` snapshots for 15 days) | @@ -122,7 +121,6 @@ snapshots: enabled: True retain: default: 10 - mode: motion objects: person: 15 ``` diff --git a/frigate/config/camera/camera.py b/frigate/config/camera/camera.py index 01092d4f18..247b1d1168 100644 --- a/frigate/config/camera/camera.py +++ b/frigate/config/camera/camera.py @@ -100,8 +100,8 @@ class CameraConfig(FrigateBaseModel): description="Settings for face detection and recognition for this camera.", ) ffmpeg: CameraFfmpegConfig = Field( - title="FFmpeg", - description="FFmpeg settings including binary path, args, hwaccel options, and per-role output args.", + title="Streams (FFmpeg)", + description="Camera stream inputs and FFmpeg options, including binary path, args, hwaccel, and per-role output args.", ) live: CameraLiveConfig = Field( default_factory=CameraLiveConfig, diff --git a/frigate/config/camera/snapshots.py b/frigate/config/camera/snapshots.py index 63bcba2267..b6bccfc40c 100644 --- a/frigate/config/camera/snapshots.py +++ b/frigate/config/camera/snapshots.py @@ -3,7 +3,6 @@ from typing import Optional from pydantic import Field from ..base import FrigateBaseModel -from .record import RetainModeEnum __all__ = ["SnapshotsConfig", "RetainConfig"] @@ -14,11 +13,6 @@ class RetainConfig(FrigateBaseModel): title="Default retention", description="Default number of days to retain snapshots.", ) - mode: RetainModeEnum = Field( - default=RetainModeEnum.motion, - title="Retention mode", - description="Mode for retention: all (save all segments), motion (save segments with motion), or active_objects (save segments with active objects).", - ) objects: dict[str, float] = Field( default_factory=dict, title="Object retention", diff --git a/web/public/locales/en/config/cameras.json b/web/public/locales/en/config/cameras.json index dd7c7e0a58..7a0651c50a 100644 --- a/web/public/locales/en/config/cameras.json +++ b/web/public/locales/en/config/cameras.json @@ -152,8 +152,8 @@ } }, "ffmpeg": { - "label": "FFmpeg", - "description": "FFmpeg settings including binary path, args, hwaccel options, and per-role output args.", + "label": "Streams (FFmpeg)", + "description": "Camera stream inputs and FFmpeg options, including binary path, args, hwaccel, and per-role output args.", "path": { "label": "FFmpeg path", "description": "Path to the FFmpeg binary to use or a version alias (\"7.0\" or \"8.0\")." @@ -666,10 +666,6 @@ "label": "Default retention", "description": "Default number of days to retain snapshots." }, - "mode": { - "label": "Retention mode", - "description": "Mode for retention: all (save all segments), motion (save segments with motion), or active_objects (save segments with active objects)." - }, "objects": { "label": "Object retention", "description": "Per-object overrides for snapshot retention days." diff --git a/web/public/locales/en/config/global.json b/web/public/locales/en/config/global.json index 09facf0da3..56ec0c3650 100644 --- a/web/public/locales/en/config/global.json +++ b/web/public/locales/en/config/global.json @@ -1176,10 +1176,6 @@ "label": "Default retention", "description": "Default number of days to retain snapshots." }, - "mode": { - "label": "Retention mode", - "description": "Mode for retention: all (save all segments), motion (save segments with motion), or active_objects (save segments with active objects)." - }, "objects": { "label": "Object retention", "description": "Per-object overrides for snapshot retention days." diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index a40ce797dc..9680f774e2 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -85,7 +85,7 @@ "integrationObjectClassification": "Object classification", "integrationAudioTranscription": "Audio transcription", "cameraDetect": "Object detection", - "cameraFfmpeg": "FFmpeg", + "cameraFfmpeg": "Streams (FFmpeg)", "cameraRecording": "Recording", "cameraSnapshots": "Snapshots", "cameraMotion": "Motion detection", diff --git a/web/src/components/config-form/section-configs/record.ts b/web/src/components/config-form/section-configs/record.ts index d1c2a94e64..d4dd481b86 100644 --- a/web/src/components/config-form/section-configs/record.ts +++ b/web/src/components/config-form/section-configs/record.ts @@ -44,7 +44,14 @@ const record: SectionConfigOverrides = { hiddenFields: ["enabled_in_config", "sync_recordings"], advancedFields: ["expire_interval", "preview", "export"], uiSchema: { + continuous: { + "ui:options": { defaultOpen: true, disableCollapsible: true }, + }, + motion: { + "ui:options": { defaultOpen: true, disableCollapsible: true }, + }, export: { + "ui:options": { defaultOpen: true, disableCollapsible: true }, hwaccel_args: { "ui:widget": "FfmpegArgsWidget", "ui:options": { @@ -59,9 +66,12 @@ const record: SectionConfigOverrides = { "detections.retain.mode": { "ui:options": { enumI18nPrefix: "retainMode" }, }, - "preview.quality": { - "ui:options": { - enumI18nPrefix: "previewQuality", + preview: { + "ui:options": { defaultOpen: true, disableCollapsible: true }, + quality: { + "ui:options": { + enumI18nPrefix: "previewQuality", + }, }, }, }, diff --git a/web/src/components/config-form/section-configs/snapshots.ts b/web/src/components/config-form/section-configs/snapshots.ts index 94bfd9dc8f..e5b5caa208 100644 --- a/web/src/components/config-form/section-configs/snapshots.ts +++ b/web/src/components/config-form/section-configs/snapshots.ts @@ -21,13 +21,14 @@ const snapshots: SectionConfigOverrides = { "crop", "quality", "timestamp", + "required_zones", "retain", ], fieldGroups: { display: ["bounding_box", "crop", "quality", "timestamp"], }, hiddenFields: ["enabled_in_config"], - advancedFields: ["height", "quality", "retain"], + advancedFields: ["height", "quality"], uiSchema: { required_zones: { "ui:widget": "zoneNames", @@ -35,11 +36,6 @@ const snapshots: SectionConfigOverrides = { suppressMultiSchema: true, }, }, - "retain.mode": { - "ui:options": { - enumI18nPrefix: "retainMode", - }, - }, }, }, global: { diff --git a/web/src/components/config-form/theme/templates/ObjectFieldTemplate.tsx b/web/src/components/config-form/theme/templates/ObjectFieldTemplate.tsx index 9d703b3c8d..009a53b588 100644 --- a/web/src/components/config-form/theme/templates/ObjectFieldTemplate.tsx +++ b/web/src/components/config-form/theme/templates/ObjectFieldTemplate.tsx @@ -156,7 +156,8 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) { }; const hasModifiedDescendants = checkSubtreeModified(fieldPath); - const [isOpen, setIsOpen] = useState(hasModifiedDescendants); + const defaultOpen = uiSchema?.["ui:options"]?.defaultOpen === true; + const [isOpen, setIsOpen] = useState(hasModifiedDescendants || defaultOpen); const resetKey = `${formContext?.level ?? "global"}::${ formContext?.cameraName ?? "global" }`; @@ -192,6 +193,8 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) { (uiSchema?.["ui:groups"] as Record | undefined) || {}; const disableNestedCard = uiSchema?.["ui:options"]?.disableNestedCard === true; + const disableCollapsible = + uiSchema?.["ui:options"]?.disableCollapsible === true; const isHiddenProp = (prop: (typeof properties)[number]) => (prop.content.props as RjsfElementProps).uiSchema?.["ui:widget"] === @@ -228,10 +231,10 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) { useEffect(() => { if (lastResetKeyRef.current !== resetKey) { lastResetKeyRef.current = resetKey; - setIsOpen(hasModifiedDescendants); + setIsOpen(hasModifiedDescendants || defaultOpen); setShowAdvanced(hasModifiedAdvanced); } - }, [resetKey, hasModifiedDescendants, hasModifiedAdvanced]); + }, [resetKey, hasModifiedDescendants, hasModifiedAdvanced, defaultOpen]); const { children } = props as ObjectFieldTemplateProps & { children?: ReactNode; }; @@ -458,6 +461,75 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) { ); } + // Label/description/docs header shared by the collapsible and static layouts. + const cardHeaderContent = ( +

+ + {inferredLabel} + {objectRequiresRestart && } + + {inferredDescription && ( +

+ {inferredDescription} +

+ )} + {fieldDocsUrl && ( +
+ e.stopPropagation()} + > + {t("readTheDocumentation", { ns: "common" })} + + +
+ )} +
+ ); + + // Body shared by the collapsible and static layouts. + const cardBody = hasCustomChildren ? ( + children + ) : ( + <> + {renderGroupedFields(regularProps)} + + + + {renderGroupedFields(advancedProps)} + + + ); + + // Static (non-collapsible) card: keep the labeled header, always show content. + if (disableCollapsible) { + return ( + + {cardHeaderContent} + {cardBody} + + ); + } + // Nested objects render as collapsible cards return ( @@ -465,38 +537,7 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
-
- - {inferredLabel} - {objectRequiresRestart && ( - - )} - - {inferredDescription && ( -

- {inferredDescription} -

- )} - {fieldDocsUrl && ( -
- e.stopPropagation()} - > - {t("readTheDocumentation", { ns: "common" })} - - -
- )} -
+ {cardHeaderContent} {isOpen ? ( ) : ( @@ -506,31 +547,7 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) { - - {hasCustomChildren ? ( - children - ) : ( - <> - {renderGroupedFields(regularProps)} - - - - {renderGroupedFields(advancedProps)} - - - )} - + {cardBody} diff --git a/web/themes/theme-default.css b/web/themes/theme-default.css index b96f2ecca8..98de4bf0ef 100644 --- a/web/themes/theme-default.css +++ b/web/themes/theme-default.css @@ -113,8 +113,8 @@ --foreground: hsl(0, 0%, 100%); --foreground: 0, 0%, 100%; - --card: hsl(0, 0%, 15%); - --card: 0, 0%, 15%; + --card: hsl(0, 0%, 12%); + --card: 0, 0%, 12%; --card-foreground: hsl(210, 40%, 98%); --card-foreground: 210 40% 98%; From b3ce4486b9535491f3498aee339695e77e6a44a4 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Tue, 16 Jun 2026 08:07:12 -0600 Subject: [PATCH 20/92] Catch edge cases in security protections (#23493) * Fix go2rtc nested key dict * Don't allow path traversal --- .../rootfs/usr/local/go2rtc/create_config.py | 19 ++++++++++++++++++- frigate/api/export.py | 10 ++++++++++ frigate/util/services.py | 4 ++-- 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/docker/main/rootfs/usr/local/go2rtc/create_config.py b/docker/main/rootfs/usr/local/go2rtc/create_config.py index 96de79f93b..dfd8b722ca 100644 --- a/docker/main/rootfs/usr/local/go2rtc/create_config.py +++ b/docker/main/rootfs/usr/local/go2rtc/create_config.py @@ -17,7 +17,10 @@ from frigate.const import ( ) from frigate.ffmpeg_presets import parse_preset_hardware_acceleration_encode from frigate.util.config import find_config_file -from frigate.util.services import is_restricted_go2rtc_source +from frigate.util.services import ( + is_go2rtc_arbitrary_exec_allowed, + is_restricted_go2rtc_source, +) sys.path.remove("/opt/frigate") @@ -159,6 +162,20 @@ for name in list(go2rtc_config.get("streams", {})): ) del go2rtc_config["streams"][name] + elif isinstance(stream, dict): + # The map form ({"url": ...}) lets go2rtc resolve the source + # recursively, so it is effectively a dynamic way to generate the URL + # for a stream. That can only be backed by an exec source, so it cannot + # be allowed unless arbitrary exec is explicitly enabled. When it is + # enabled, leave the map untouched for go2rtc to resolve. + if not is_go2rtc_arbitrary_exec_allowed(): + print( + f"[ERROR] Stream '{name}' uses a dynamic source format which is disabled by default for security. " + f"Set GO2RTC_ALLOW_ARBITRARY_EXEC=true to enable arbitrary exec sources." + ) + del go2rtc_config["streams"][name] + continue + # add birdseye restream stream if enabled if config.get("birdseye", {}).get("restream", False): birdseye: dict[str, Any] = config.get("birdseye") diff --git a/frigate/api/export.py b/frigate/api/export.py index 614ac78db6..8f916eaf17 100644 --- a/frigate/api/export.py +++ b/frigate/api/export.py @@ -91,6 +91,16 @@ def export_recording( playback_factor = body.playback playback_source = body.source friendly_name = body.name + + # sanitize_filepath normalizes "\" to "/" but leaves ".." intact, so a path + # like "clips\..\..\etc/passwd" passes the CLIPS_DIR prefix check yet still + # escapes the directory once resolved. A valid snapshot path never uses "..". + if body.image_path and ".." in body.image_path: + return JSONResponse( + content=({"success": False, "message": "Invalid image path"}), + status_code=400, + ) + existing_image = sanitize_filepath(body.image_path) if body.image_path else None # a chapters value in the request body overrides the camera's export config diff --git a/frigate/util/services.py b/frigate/util/services.py index d366a7390a..5bf958198c 100644 --- a/frigate/util/services.py +++ b/frigate/util/services.py @@ -556,7 +556,7 @@ def get_jetson_stats() -> Optional[dict[int, dict]]: return results -def _go2rtc_arbitrary_exec_allowed() -> bool: +def is_go2rtc_arbitrary_exec_allowed() -> bool: """Read the GO2RTC_ALLOW_ARBITRARY_EXEC override from env, docker secrets, or the Home Assistant add-on options file.""" raw: Optional[str] = None @@ -588,7 +588,7 @@ def is_restricted_go2rtc_source(stream_source: str) -> bool: and the GO2RTC_ALLOW_ARBITRARY_EXEC override is not set.""" if not stream_source.strip().startswith(("echo:", "expr:", "exec:")): return False - return not _go2rtc_arbitrary_exec_allowed() + return not is_go2rtc_arbitrary_exec_allowed() def ffprobe_stream(ffmpeg, path: str, detailed: bool = False) -> sp.CompletedProcess: From a7df17cc61fb161e6f3ef3358e6ec2c7be863d60 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Tue, 16 Jun 2026 10:59:25 -0500 Subject: [PATCH 21/92] update ffmpeg navpath title (#23494) --- docs/docs/configuration/audio_detectors.md | 2 +- docs/docs/configuration/cameras.md | 2 +- docs/docs/configuration/ffmpeg_presets.md | 2 +- .../hardware_acceleration_video.md | 18 +++++++++--------- .../configuration/license_plate_recognition.md | 4 ++-- docs/docs/configuration/restream.md | 4 ++-- docs/docs/guides/getting_started.md | 2 +- 7 files changed, 17 insertions(+), 17 deletions(-) diff --git a/docs/docs/configuration/audio_detectors.md b/docs/docs/configuration/audio_detectors.md index c8c3ada9d4..0780b727f0 100644 --- a/docs/docs/configuration/audio_detectors.md +++ b/docs/docs/configuration/audio_detectors.md @@ -54,7 +54,7 @@ The ffmpeg process for capturing audio will be a separate connection to the came -Navigate to and add an input with the `audio` role pointing to a stream that includes audio. +Navigate to and add an input with the `audio` role pointing to a stream that includes audio. diff --git a/docs/docs/configuration/cameras.md b/docs/docs/configuration/cameras.md index e711c8ad56..5d45179280 100644 --- a/docs/docs/configuration/cameras.md +++ b/docs/docs/configuration/cameras.md @@ -24,7 +24,7 @@ Each role can only be assigned to one input per camera. The options for roles ar -Navigate to . +Navigate to . | Field | Description | | ----------------- | ------------------------------------------------------------------- | diff --git a/docs/docs/configuration/ffmpeg_presets.md b/docs/docs/configuration/ffmpeg_presets.md index 3333882801..99fb91d26f 100644 --- a/docs/docs/configuration/ffmpeg_presets.md +++ b/docs/docs/configuration/ffmpeg_presets.md @@ -33,7 +33,7 @@ Select the appropriate hwaccel preset for your hardware. 1. Navigate to and set **Hardware acceleration arguments** to the appropriate preset for your hardware. -2. To override for a specific camera, navigate to and set **Hardware acceleration arguments** for that camera. +2. To override for a specific camera, navigate to and set **Hardware acceleration arguments** for that camera. diff --git a/docs/docs/configuration/hardware_acceleration_video.md b/docs/docs/configuration/hardware_acceleration_video.md index 66ee65545f..3f2a223673 100644 --- a/docs/docs/configuration/hardware_acceleration_video.md +++ b/docs/docs/configuration/hardware_acceleration_video.md @@ -85,7 +85,7 @@ VAAPI supports automatic profile selection so it will work automatically with bo -Navigate to and set **Hardware acceleration arguments** to `VAAPI (Intel/AMD GPU)`. For per-camera overrides, navigate to . +Navigate to and set **Hardware acceleration arguments** to `VAAPI (Intel/AMD GPU)`. For per-camera overrides, navigate to . @@ -105,7 +105,7 @@ ffmpeg: -Navigate to and set **Hardware acceleration arguments** to `Intel QuickSync (H.264)`. For per-camera overrides, navigate to . +Navigate to and set **Hardware acceleration arguments** to `Intel QuickSync (H.264)`. For per-camera overrides, navigate to . @@ -123,7 +123,7 @@ ffmpeg: -Navigate to and set **Hardware acceleration arguments** to `Intel QuickSync (H.265)`. For per-camera overrides, navigate to . +Navigate to and set **Hardware acceleration arguments** to `Intel QuickSync (H.265)`. For per-camera overrides, navigate to . @@ -178,7 +178,7 @@ VAAPI supports automatic profile selection so it will work automatically with bo -Navigate to and set **Hardware acceleration arguments** to `VAAPI (Intel/AMD GPU)`. For per-camera overrides, navigate to . +Navigate to and set **Hardware acceleration arguments** to `VAAPI (Intel/AMD GPU)`. For per-camera overrides, navigate to . @@ -237,7 +237,7 @@ Using `preset-nvidia` ffmpeg will automatically select the necessary profile for -Navigate to and set **Hardware acceleration arguments** to `NVIDIA GPU`. For per-camera overrides, navigate to . +Navigate to and set **Hardware acceleration arguments** to `NVIDIA GPU`. For per-camera overrides, navigate to . @@ -300,7 +300,7 @@ If you are using the HA App, you may need to use the full access variant and tur -Navigate to and set **Hardware acceleration arguments** to `Raspberry Pi (H.264)` (for H.264 streams) or `Raspberry Pi (H.265)` (for H.265/HEVC streams). For per-camera overrides, navigate to . +Navigate to and set **Hardware acceleration arguments** to `Raspberry Pi (H.264)` (for H.264 streams) or `Raspberry Pi (H.265)` (for H.265/HEVC streams). For per-camera overrides, navigate to . @@ -420,7 +420,7 @@ For example, for H264 video, you'll select `preset-jetson-h264`. -Navigate to and set **Hardware acceleration arguments** to `NVIDIA Jetson (H.264)` (or `NVIDIA Jetson (H.265)` for HEVC streams). For per-camera overrides, navigate to . +Navigate to and set **Hardware acceleration arguments** to `NVIDIA Jetson (H.264)` (or `NVIDIA Jetson (H.265)` for HEVC streams). For per-camera overrides, navigate to . @@ -452,7 +452,7 @@ Set the FFmpeg hwaccel preset to enable hardware video processing. -Navigate to and set **Hardware acceleration arguments** to `Rockchip RKMPP`. For per-camera overrides, navigate to . +Navigate to and set **Hardware acceleration arguments** to `Rockchip RKMPP`. For per-camera overrides, navigate to . @@ -519,7 +519,7 @@ Set the FFmpeg hwaccel args to enable hardware video processing. -Navigate to and configure the hardware acceleration args and input args manually for Synaptics hardware. For per-camera overrides, navigate to . +Navigate to and configure the hardware acceleration args and input args manually for Synaptics hardware. For per-camera overrides, navigate to . diff --git a/docs/docs/configuration/license_plate_recognition.md b/docs/docs/configuration/license_plate_recognition.md index c60618fd43..45670b43aa 100644 --- a/docs/docs/configuration/license_plate_recognition.md +++ b/docs/docs/configuration/license_plate_recognition.md @@ -363,7 +363,7 @@ An example configuration for a dedicated LPR camera using a `license_plate`-dete Navigate to and set **Enable LPR** to on. Set **Device** to `CPU` (can also be `GPU` if available). -Navigate to and add your camera streams. +Navigate to and add your camera streams. Navigate to . @@ -475,7 +475,7 @@ Navigate to Camera configuration > FFmpeg" /> and add your camera streams. +Navigate to and add your camera streams. Navigate to . diff --git a/docs/docs/configuration/restream.md b/docs/docs/configuration/restream.md index d488c54104..8a5d1b76ec 100644 --- a/docs/docs/configuration/restream.md +++ b/docs/docs/configuration/restream.md @@ -61,7 +61,7 @@ Configure the go2rtc stream and point the camera inputs at the local restream. -Navigate to and add stream entries for each camera. Then navigate to for each camera and set the input paths to use the local restream URL (`rtsp://127.0.0.1:8554/`). +Navigate to and add stream entries for each camera. Then navigate to for each camera and set the input paths to use the local restream URL (`rtsp://127.0.0.1:8554/`). @@ -111,7 +111,7 @@ Two connections are made to the camera. One for the sub stream, one for the rest -Navigate to and add stream entries for each camera and its sub stream. Then navigate to for each camera and configure separate inputs for the main and sub streams using the local restream URLs. +Navigate to and add stream entries for each camera and its sub stream. Then navigate to for each camera and configure separate inputs for the main and sub streams using the local restream URLs. diff --git a/docs/docs/guides/getting_started.md b/docs/docs/guides/getting_started.md index aa52a3e3e5..9a775abe80 100644 --- a/docs/docs/guides/getting_started.md +++ b/docs/docs/guides/getting_started.md @@ -348,7 +348,7 @@ In order to review activity in the Frigate UI, recordings need to be enabled. -1. If you have separate streams for detect and record, navigate to , select your camera, and add a second input with the `record` role pointing to your high-resolution stream +1. If you have separate streams for detect and record, navigate to , select your camera, and add a second input with the `record` role pointing to your high-resolution stream 2. Navigate to (or for a specific camera) and set **Enable recording** to on From 282e70d4bf2bf0cc1782b61d95bd302992c73f05 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Tue, 16 Jun 2026 17:12:39 -0500 Subject: [PATCH 22/92] Add go2rtc stream selection to camera configuration (#23496) * add go2rtc stream selection to camera ffmpeg config * i18n * add config-schema.json to generated e2e mock data * e2e test * docs * fix test --- docs/docs/configuration/cameras.md | 2 + docs/docs/configuration/restream.md | 4 +- web/e2e/fixtures/mock-data/config-schema.json | 1 + .../fixtures/mock-data/generate-mock-data.py | 13 ++ .../settings/camera-ffmpeg-streams.spec.ts | 203 ++++++++++++++++ web/public/locales/en/views/settings.json | 12 +- .../theme/fields/CameraInputsField.tsx | 124 +++++++++- .../theme/fields/StreamSourceSelector.tsx | 217 ++++++++++++++++++ .../config-form/theme/fields/streamSource.ts | 33 +++ 9 files changed, 594 insertions(+), 15 deletions(-) create mode 100644 web/e2e/fixtures/mock-data/config-schema.json create mode 100644 web/e2e/specs/settings/camera-ffmpeg-streams.spec.ts create mode 100644 web/src/components/config-form/theme/fields/StreamSourceSelector.tsx create mode 100644 web/src/components/config-form/theme/fields/streamSource.ts diff --git a/docs/docs/configuration/cameras.md b/docs/docs/configuration/cameras.md index 5d45179280..6e86606175 100644 --- a/docs/docs/configuration/cameras.md +++ b/docs/docs/configuration/cameras.md @@ -30,6 +30,8 @@ Navigate to ` path and `preset-rtsp-restream` input args for that input automatically), or **Manual input path** to type the stream URL directly. + Navigate to . | Field | Description | diff --git a/docs/docs/configuration/restream.md b/docs/docs/configuration/restream.md index 8a5d1b76ec..b307154bc2 100644 --- a/docs/docs/configuration/restream.md +++ b/docs/docs/configuration/restream.md @@ -61,7 +61,7 @@ Configure the go2rtc stream and point the camera inputs at the local restream. -Navigate to and add stream entries for each camera. Then navigate to for each camera and set the input paths to use the local restream URL (`rtsp://127.0.0.1:8554/`). +Navigate to and add stream entries for each camera. Then navigate to for each camera. For each input, choose **Restream (go2rtc)** and pick the matching stream from the dropdown — Frigate uses the local restream URL (`rtsp://127.0.0.1:8554/`) and the `preset-rtsp-restream` input args for that input automatically. (Choose **Manual input path** instead to type a URL directly.) @@ -111,7 +111,7 @@ Two connections are made to the camera. One for the sub stream, one for the rest -Navigate to and add stream entries for each camera and its sub stream. Then navigate to for each camera and configure separate inputs for the main and sub streams using the local restream URLs. +Navigate to and add stream entries for each camera and its sub stream. Then navigate to for each camera and add separate inputs for the main and sub streams. Set each input's source to **Restream (go2rtc)** and pick the matching stream from the dropdown — Frigate uses the local restream URL and the `preset-rtsp-restream` input args for that input automatically. diff --git a/web/e2e/fixtures/mock-data/config-schema.json b/web/e2e/fixtures/mock-data/config-schema.json new file mode 100644 index 0000000000..04d72b1e5a --- /dev/null +++ b/web/e2e/fixtures/mock-data/config-schema.json @@ -0,0 +1 @@ +{"$defs": {"AlertsConfig": {"additionalProperties": false, "description": "Configure alerts", "properties": {"enabled": {"default": true, "description": "Enable or disable alert generation for all cameras; can be overridden per-camera.", "title": "Enable alerts", "type": "boolean"}, "labels": {"default": ["person", "car"], "description": "List of object labels that qualify as alerts (for example: car, person).", "items": {"type": "string"}, "title": "Alert labels", "type": "array"}, "required_zones": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "description": "Zones that an object must enter to be considered an alert; leave empty to allow any zone.", "title": "Required zones"}, "enabled_in_config": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "description": "Tracks whether alerts were originally enabled in the static configuration.", "title": "Original alerts state"}, "cutoff_time": {"default": 40, "description": "Seconds to wait after no alert-causing activity before cutting off an alert.", "title": "Alerts cutoff time", "type": "integer"}}, "title": "AlertsConfig", "type": "object"}, "AudioConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable audio event detection for all cameras; can be overridden per-camera.", "title": "Enable audio detection", "type": "boolean"}, "max_not_heard": {"default": 30, "description": "Amount of seconds without the configured audio type before the audio event is ended.", "title": "End timeout", "type": "integer"}, "min_volume": {"default": 500, "description": "Minimum RMS volume threshold required to run audio detection; lower values increase sensitivity (e.g., 200 high, 500 medium, 1000 low).", "title": "Minimum volume", "type": "integer"}, "listen": {"default": ["bark", "fire_alarm", "speech", "yell"], "description": "List of audio event types to detect (for example: bark, fire_alarm, speech, yell).", "items": {"type": "string"}, "title": "Listen types", "type": "array"}, "filters": {"anyOf": [{"additionalProperties": {"$ref": "#/$defs/AudioFilterConfig"}, "type": "object"}, {"type": "null"}], "default": null, "description": "Per-audio-type filter settings such as confidence thresholds used to reduce false positives.", "title": "Audio filters"}, "enabled_in_config": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "description": "Indicates whether audio detection was originally enabled in the static config file.", "title": "Original audio state"}, "num_threads": {"default": 2, "description": "Number of threads to use for audio detection processing.", "minimum": 1, "title": "Detection threads", "type": "integer"}}, "title": "AudioConfig", "type": "object"}, "AudioFilterConfig": {"additionalProperties": false, "properties": {"threshold": {"default": 0.8, "description": "Minimum confidence threshold for the audio event to be counted.", "exclusiveMaximum": 1.0, "minimum": 0.5, "title": "Minimum audio confidence", "type": "number"}}, "title": "AudioFilterConfig", "type": "object"}, "AudioTranscriptionConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable automatic audio transcription for all cameras; can be overridden per-camera.", "title": "Enable audio transcription", "type": "boolean"}, "language": {"default": "en", "description": "Language code used for transcription/translation (for example 'en' for English). See https://whisper-api.com/docs/languages/ for supported language codes.", "title": "Transcription language", "type": "string"}, "device": {"$ref": "#/$defs/EnrichmentsDeviceEnum", "default": "CPU", "description": "Device key (CPU/GPU) to run the transcription model on. Only NVIDIA CUDA GPUs are currently supported for transcription.", "title": "Transcription device"}, "model_size": {"$ref": "#/$defs/ModelSizeEnum", "default": "small", "description": "Model size to use for offline audio event transcription.", "title": "Model size"}, "live_enabled": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": false, "description": "Enable streaming live transcription for audio as it is received.", "title": "Live transcription"}}, "title": "AudioTranscriptionConfig", "type": "object"}, "AuthConfig": {"additionalProperties": false, "properties": {"enabled": {"default": true, "description": "Enable native authentication for the Frigate UI.", "title": "Enable authentication", "type": "boolean"}, "reset_admin_password": {"default": false, "description": "If true, reset the admin user's password on startup and print the new password in logs.", "title": "Reset admin password", "type": "boolean"}, "cookie_name": {"default": "frigate_token", "description": "Name of the cookie used to store the JWT token for native authentication.", "pattern": "^[a-z_]+$", "title": "JWT cookie name", "type": "string"}, "cookie_secure": {"default": false, "description": "Set the secure flag on the auth cookie; should be true when using TLS.", "title": "Secure cookie flag", "type": "boolean"}, "session_length": {"default": 86400, "description": "Session duration in seconds for JWT-based sessions.", "minimum": 60, "title": "Session length", "type": "integer"}, "refresh_time": {"default": 1800, "description": "When a session is within this many seconds of expiring, refresh it back to full length.", "minimum": 30, "title": "Session refresh window", "type": "integer"}, "failed_login_rate_limit": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Rate limiting rules for failed login attempts to reduce brute-force attacks.", "title": "Failed login limits"}, "trusted_proxies": {"default": [], "description": "List of trusted proxy IPs used when determining client IP for rate limiting.", "items": {"type": "string"}, "title": "Trusted proxies", "type": "array"}, "hash_iterations": {"default": 600000, "description": "Number of PBKDF2-SHA256 iterations to use when hashing user passwords.", "title": "Hash iterations", "type": "integer"}, "roles": {"additionalProperties": {"items": {"type": "string"}, "type": "array"}, "description": "Map roles to camera lists. An empty list grants access to all cameras for the role.", "title": "Role mappings", "type": "object"}, "admin_first_time_login": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": false, "description": "When true the UI may show a help link on the login page informing users how to sign in after an admin password reset. ", "title": "First-time admin flag"}}, "title": "AuthConfig", "type": "object"}, "BaseDetectorConfig": {"additionalProperties": true, "properties": {"type": {"default": "cpu", "description": "Type of detector to use for object detection (for example 'cpu', 'edgetpu', 'openvino').", "title": "Detector Type", "type": "string"}, "model": {"anyOf": [{"$ref": "#/$defs/ModelConfig"}, {"type": "null"}], "default": null, "description": "Detector-specific model configuration options (path, input size, etc.).", "title": "Detector specific model configuration"}, "model_path": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "File path to the detector model binary if required by the chosen detector.", "title": "Detector specific model path"}}, "title": "BaseDetectorConfig", "type": "object"}, "BirdClassificationConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable bird classification.", "title": "Bird classification", "type": "boolean"}, "threshold": {"default": 0.9, "description": "Minimum classification score required to accept a bird classification.", "exclusiveMinimum": 0.0, "maximum": 1.0, "title": "Minimum score", "type": "number"}}, "title": "BirdClassificationConfig", "type": "object"}, "BirdseyeCameraConfig": {"properties": {"enabled": {"default": true, "description": "Enable or disable the Birdseye view feature.", "title": "Enable Birdseye", "type": "boolean"}, "mode": {"$ref": "#/$defs/BirdseyeModeEnum", "default": "objects", "description": "Mode for including cameras in Birdseye: 'objects', 'motion', or 'continuous'.", "title": "Tracking mode"}, "order": {"default": 0, "description": "Numeric position controlling the camera's ordering in the Birdseye layout.", "title": "Position", "type": "integer"}}, "title": "BirdseyeCameraConfig", "type": "object"}, "BirdseyeConfig": {"additionalProperties": false, "properties": {"enabled": {"default": true, "description": "Enable or disable the Birdseye view feature.", "title": "Enable Birdseye", "type": "boolean"}, "mode": {"$ref": "#/$defs/BirdseyeModeEnum", "default": "objects", "description": "Mode for including cameras in Birdseye: 'objects', 'motion', or 'continuous'.", "title": "Tracking mode"}, "restream": {"default": false, "description": "Re-stream the Birdseye output as an RTSP feed; enabling this will keep Birdseye running continuously.", "title": "Restream RTSP", "type": "boolean"}, "width": {"default": 1280, "description": "Output width (pixels) of the composed Birdseye frame.", "title": "Width", "type": "integer"}, "height": {"default": 720, "description": "Output height (pixels) of the composed Birdseye frame.", "title": "Height", "type": "integer"}, "quality": {"default": 8, "description": "Encoding quality for the Birdseye mpeg1 feed (1 highest quality, 31 lowest).", "maximum": 31, "minimum": 1, "title": "Encoding quality", "type": "integer"}, "inactivity_threshold": {"default": 30, "description": "Seconds of inactivity after which a camera will stop being shown in Birdseye.", "exclusiveMinimum": 0, "title": "Inactivity threshold", "type": "integer"}, "layout": {"$ref": "#/$defs/BirdseyeLayoutConfig", "description": "Layout options for the Birdseye composition.", "title": "Layout"}, "idle_heartbeat_fps": {"default": 0.0, "description": "Frames-per-second to resend the last composed Birdseye frame when idle; set to 0 to disable.", "maximum": 10.0, "minimum": 0.0, "title": "Idle heartbeat FPS", "type": "number"}}, "title": "BirdseyeConfig", "type": "object"}, "BirdseyeLayoutConfig": {"additionalProperties": false, "properties": {"scaling_factor": {"default": 2.0, "description": "Scaling factor used by the layout calculator (range 1.0 to 5.0).", "maximum": 5.0, "minimum": 1.0, "title": "Scaling factor", "type": "number"}, "max_cameras": {"anyOf": [{"type": "integer"}, {"type": "null"}], "default": null, "description": "Maximum number of cameras to display at once in Birdseye; shows the most recent cameras.", "title": "Max cameras"}}, "title": "BirdseyeLayoutConfig", "type": "object"}, "BirdseyeModeEnum": {"enum": ["objects", "motion", "continuous"], "title": "BirdseyeModeEnum", "type": "string"}, "CameraAudioTranscriptionConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable manually triggered audio event transcription.", "title": "Enable transcription", "type": "boolean"}, "enabled_in_config": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "title": "Original transcription state"}, "live_enabled": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": false, "description": "Enable streaming live transcription for audio as it is received.", "title": "Live transcription"}}, "title": "CameraAudioTranscriptionConfig", "type": "object"}, "CameraConfig": {"additionalProperties": false, "properties": {"name": {"anyOf": [{"pattern": "^[a-zA-Z0-9_-]+$", "type": "string"}, {"type": "null"}], "default": null, "description": "Camera name is required", "title": "Camera name"}, "friendly_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Camera friendly name used in the Frigate UI", "title": "Friendly name"}, "enabled": {"default": true, "description": "Enabled", "title": "Enabled", "type": "boolean"}, "audio": {"$ref": "#/$defs/AudioConfig", "description": "Settings for audio-based event detection for this camera.", "title": "Audio detection"}, "audio_transcription": {"$ref": "#/$defs/CameraAudioTranscriptionConfig", "description": "Settings for live and speech audio transcription used for events and live captions.", "title": "Audio transcription"}, "birdseye": {"$ref": "#/$defs/BirdseyeCameraConfig", "description": "Settings for the Birdseye composite view that composes multiple camera feeds into a single layout.", "title": "Birdseye"}, "detect": {"$ref": "#/$defs/DetectConfig", "description": "Settings for the detection/detect role used to run object detection and initialize trackers.", "title": "Object Detection"}, "face_recognition": {"$ref": "#/$defs/CameraFaceRecognitionConfig", "description": "Settings for face detection and recognition for this camera.", "title": "Face recognition"}, "ffmpeg": {"$ref": "#/$defs/CameraFfmpegConfig", "description": "Camera stream inputs and FFmpeg options, including binary path, args, hwaccel, and per-role output args.", "title": "Streams (FFmpeg)"}, "live": {"$ref": "#/$defs/CameraLiveConfig", "description": "Settings used by the Web UI to control live stream selection, resolution and quality.", "title": "Live playback"}, "lpr": {"$ref": "#/$defs/CameraLicensePlateRecognitionConfig", "description": "License plate recognition settings including detection thresholds, formatting, and known plates.", "title": "License Plate Recognition"}, "motion": {"$ref": "#/$defs/MotionConfig", "default": null, "description": "Default motion detection settings for this camera.", "title": "Motion detection"}, "objects": {"$ref": "#/$defs/ObjectConfig", "description": "Object tracking defaults including which labels to track and per-object filters.", "title": "Objects"}, "record": {"$ref": "#/$defs/RecordConfig", "description": "Recording and retention settings for this camera.", "title": "Recording"}, "review": {"$ref": "#/$defs/ReviewConfig", "description": "Settings that control alerts, detections, and GenAI review summaries used by the UI and storage for this camera.", "title": "Review"}, "semantic_search": {"$ref": "#/$defs/CameraSemanticSearchConfig", "description": "Settings for semantic search which builds and queries object embeddings to find similar items.", "title": "Semantic Search"}, "snapshots": {"$ref": "#/$defs/SnapshotsConfig", "description": "Settings for API-generated snapshots of tracked objects for this camera.", "title": "Snapshots"}, "timestamp_style": {"$ref": "#/$defs/TimestampStyleConfig", "description": "Styling options for timestamps applied to snapshots and Debug view.", "title": "Timestamp style"}, "best_image_timeout": {"default": 60, "description": "How long to wait for the image with the highest confidence score.", "title": "Best image timeout", "type": "integer"}, "mqtt": {"$ref": "#/$defs/CameraMqttConfig", "description": "MQTT image publishing settings.", "title": "MQTT"}, "notifications": {"$ref": "#/$defs/NotificationConfig", "description": "Settings to enable and control notifications for this camera.", "title": "Notifications"}, "onvif": {"$ref": "#/$defs/OnvifConfig", "description": "ONVIF connection and PTZ autotracking settings for this camera.", "title": "ONVIF"}, "type": {"$ref": "#/$defs/CameraTypeEnum", "default": "generic", "description": "Camera Type", "title": "Camera type"}, "ui": {"$ref": "#/$defs/CameraUiConfig", "description": "Display ordering and visibility for this camera in the UI. Ordering affects the default dashboard. For more granular control, use camera groups.", "title": "Camera UI"}, "webui_url": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "URL to visit the camera directly from system page", "title": "Camera URL"}, "profiles": {"additionalProperties": {"$ref": "#/$defs/CameraProfileConfig"}, "description": "Named config profiles with partial overrides that can be activated at runtime.", "title": "Profiles", "type": "object"}, "zones": {"additionalProperties": {"$ref": "#/$defs/ZoneConfig"}, "description": "Zones allow you to define a specific area of the frame so you can determine whether or not an object is within a particular area.", "title": "Zones", "type": "object"}, "enabled_in_config": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "description": "Keep track of original state of camera.", "title": "Original camera state"}}, "required": ["ffmpeg"], "title": "CameraConfig", "type": "object"}, "CameraFaceRecognitionConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable face recognition.", "title": "Enable face recognition", "type": "boolean"}, "min_area": {"default": 750, "description": "Minimum area (pixels) of a detected face box required to attempt recognition.", "title": "Minimum face area", "type": "integer"}}, "title": "CameraFaceRecognitionConfig", "type": "object"}, "CameraFfmpegConfig": {"additionalProperties": false, "properties": {"path": {"default": "default", "description": "Path to the FFmpeg binary to use or a version alias (\"7.0\" or \"8.0\").", "title": "FFmpeg path", "type": "string"}, "global_args": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "default": ["-hide_banner", "-loglevel", "warning", "-threads", "2"], "description": "Global arguments passed to FFmpeg processes.", "title": "FFmpeg global arguments"}, "hwaccel_args": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "default": "auto", "description": "Hardware acceleration arguments for FFmpeg. Provider-specific presets are recommended.", "title": "Hardware acceleration arguments"}, "input_args": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "default": "preset-rtsp-generic", "description": "Input arguments applied to FFmpeg input streams.", "title": "Input arguments"}, "output_args": {"$ref": "#/$defs/FfmpegOutputArgsConfig", "description": "Default output arguments used for different FFmpeg roles such as detect and record.", "title": "Output arguments"}, "retry_interval": {"default": 10.0, "description": "Seconds to wait before attempting to reconnect a camera stream after failure. Default is 10.", "exclusiveMinimum": 0.0, "title": "FFmpeg retry time", "type": "number"}, "apple_compatibility": {"default": false, "description": "Enable HEVC tagging for better Apple player compatibility when recording H.265.", "title": "Apple compatibility", "type": "boolean"}, "gpu": {"default": 0, "description": "Default GPU index used for hardware acceleration if available.", "title": "GPU index", "type": "integer"}, "inputs": {"description": "List of input stream definitions (paths and roles) for this camera.", "items": {"$ref": "#/$defs/CameraInput"}, "title": "Camera inputs", "type": "array"}}, "required": ["inputs"], "title": "CameraFfmpegConfig", "type": "object"}, "CameraGroupConfig": {"additionalProperties": false, "properties": {"cameras": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "description": "Array of camera names included in this group.", "title": "Camera list"}, "icon": {"default": "generic", "description": "Icon used to represent the camera group in the UI.", "title": "Group icon", "type": "string"}, "order": {"default": 0, "description": "Numeric order used to sort camera groups in the UI; larger numbers appear later.", "title": "Sort order", "type": "integer"}}, "title": "CameraGroupConfig", "type": "object"}, "CameraInput": {"additionalProperties": false, "properties": {"path": {"description": "Camera input stream URL or path.", "title": "Input path", "type": "string"}, "roles": {"description": "Roles for this input stream.", "items": {"$ref": "#/$defs/CameraRoleEnum"}, "title": "Input roles", "type": "array"}, "global_args": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "description": "FFmpeg global arguments for this input stream.", "title": "FFmpeg global arguments"}, "hwaccel_args": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "description": "Hardware acceleration arguments for this input stream.", "title": "Hardware acceleration arguments"}, "input_args": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "description": "Input arguments specific to this stream.", "title": "Input arguments"}}, "required": ["path", "roles"], "title": "CameraInput", "type": "object"}, "CameraLicensePlateRecognitionConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable LPR on this camera.", "title": "Enable LPR", "type": "boolean"}, "expire_time": {"default": 3, "description": "Time in seconds after which an unseen plate is expired from the tracker (for dedicated LPR cameras only).", "exclusiveMinimum": 0, "title": "Expire seconds", "type": "integer"}, "min_area": {"default": 1000, "description": "Minimum plate area (pixels) required to attempt recognition.", "title": "Minimum plate area", "type": "integer"}, "enhancement": {"default": 0, "description": "Enhancement level (0-10) to apply to plate crops prior to OCR; higher values may not always improve results, levels above 5 may only work with night time plates and should be used with caution.", "maximum": 10, "minimum": 0, "title": "Enhancement level", "type": "integer"}}, "title": "CameraLicensePlateRecognitionConfig", "type": "object"}, "CameraLiveConfig": {"additionalProperties": false, "properties": {"streams": {"additionalProperties": {"type": "string"}, "description": "Mapping of configured stream names to restream/go2rtc names used for live playback.", "title": "Live stream names", "type": "object"}, "height": {"default": 720, "description": "Height (pixels) to render the jsmpeg live stream in the Web UI; must be <= detect stream height.", "title": "Live height", "type": "integer"}, "quality": {"default": 8, "description": "Encoding quality for the jsmpeg stream (1 highest, 31 lowest).", "maximum": 31, "minimum": 1, "title": "Live quality", "type": "integer"}}, "title": "CameraLiveConfig", "type": "object"}, "CameraMqttConfig": {"additionalProperties": false, "properties": {"enabled": {"default": true, "description": "Enable publishing image snapshots for objects to MQTT topics for this camera.", "title": "Send image", "type": "boolean"}, "timestamp": {"default": true, "description": "Overlay a timestamp on images published to MQTT.", "title": "Add timestamp", "type": "boolean"}, "bounding_box": {"default": true, "description": "Draw bounding boxes on images published over MQTT.", "title": "Add bounding box", "type": "boolean"}, "crop": {"default": true, "description": "Crop images published to MQTT to the detected object's bounding box.", "title": "Crop image", "type": "boolean"}, "height": {"default": 270, "description": "Height (pixels) to resize images published over MQTT.", "title": "Image height", "type": "integer"}, "required_zones": {"description": "Zones that an object must enter for an MQTT image to be published.", "items": {"type": "string"}, "title": "Required zones", "type": "array"}, "quality": {"default": 70, "description": "JPEG quality for images published to MQTT (0-100).", "maximum": 100, "minimum": 0, "title": "JPEG quality", "type": "integer"}}, "title": "CameraMqttConfig", "type": "object"}, "CameraProfileConfig": {"additionalProperties": false, "description": "A named profile containing partial camera config overrides.\n\nSections set to None inherit from the camera's base config.\nSections that are defined get Pydantic-validated, then only\nexplicitly-set fields are used as overrides via exclude_unset.", "properties": {"enabled": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "title": "Enabled"}, "audio": {"anyOf": [{"$ref": "#/$defs/AudioConfig"}, {"type": "null"}], "default": null}, "birdseye": {"anyOf": [{"$ref": "#/$defs/BirdseyeCameraConfig"}, {"type": "null"}], "default": null}, "detect": {"anyOf": [{"$ref": "#/$defs/DetectConfig"}, {"type": "null"}], "default": null}, "face_recognition": {"anyOf": [{"$ref": "#/$defs/CameraFaceRecognitionConfig"}, {"type": "null"}], "default": null}, "lpr": {"anyOf": [{"$ref": "#/$defs/CameraLicensePlateRecognitionConfig"}, {"type": "null"}], "default": null}, "motion": {"anyOf": [{"$ref": "#/$defs/MotionConfig"}, {"type": "null"}], "default": null}, "notifications": {"anyOf": [{"$ref": "#/$defs/NotificationConfig"}, {"type": "null"}], "default": null}, "objects": {"anyOf": [{"$ref": "#/$defs/ObjectConfig"}, {"type": "null"}], "default": null}, "record": {"anyOf": [{"$ref": "#/$defs/RecordConfig"}, {"type": "null"}], "default": null}, "review": {"anyOf": [{"$ref": "#/$defs/ReviewConfig"}, {"type": "null"}], "default": null}, "snapshots": {"anyOf": [{"$ref": "#/$defs/SnapshotsConfig"}, {"type": "null"}], "default": null}, "zones": {"anyOf": [{"additionalProperties": {"$ref": "#/$defs/ZoneConfig"}, "type": "object"}, {"type": "null"}], "default": null, "title": "Zones"}}, "title": "CameraProfileConfig", "type": "object"}, "CameraRoleEnum": {"enum": ["audio", "record", "detect"], "title": "CameraRoleEnum", "type": "string"}, "CameraSemanticSearchConfig": {"additionalProperties": false, "properties": {"triggers": {"additionalProperties": {"$ref": "#/$defs/TriggerConfig"}, "default": {}, "description": "Actions and matching criteria for camera-specific semantic search triggers.", "title": "Triggers", "type": "object"}}, "title": "CameraSemanticSearchConfig", "type": "object"}, "CameraTypeEnum": {"enum": ["generic", "lpr"], "title": "CameraTypeEnum", "type": "string"}, "CameraUiConfig": {"additionalProperties": false, "properties": {"order": {"default": 0, "description": "Numeric order used to sort the camera in the UI (default dashboard and lists); larger numbers appear later.", "title": "UI order", "type": "integer"}, "dashboard": {"default": true, "description": "Toggle whether this camera is visible everywhere in the Frigate UI. Disabling this will require manually editing the config to view this camera in the UI again.", "title": "Show in UI", "type": "boolean"}, "review": {"default": true, "description": "Toggle whether this camera is visible in review (the review page and its camera filter, motion review, and the history view).", "title": "Show in review", "type": "boolean"}}, "title": "CameraUiConfig", "type": "object"}, "ClassificationConfig": {"additionalProperties": false, "properties": {"bird": {"$ref": "#/$defs/BirdClassificationConfig", "description": "Settings specific to bird classification models.", "title": "Bird classification config"}, "custom": {"additionalProperties": {"$ref": "#/$defs/CustomClassificationConfig"}, "default": {}, "description": "Configuration for custom classification models used for objects or state detection.", "title": "Custom Classification Models", "type": "object"}}, "title": "ClassificationConfig", "type": "object"}, "ColorConfig": {"additionalProperties": false, "properties": {"red": {"default": 255, "description": "Red component (0-255) for timestamp color.", "maximum": 255, "minimum": 0, "title": "Red", "type": "integer"}, "green": {"default": 255, "description": "Green component (0-255) for timestamp color.", "maximum": 255, "minimum": 0, "title": "Green", "type": "integer"}, "blue": {"default": 255, "description": "Blue component (0-255) for timestamp color.", "maximum": 255, "minimum": 0, "title": "Blue", "type": "integer"}}, "title": "ColorConfig", "type": "object"}, "CustomClassificationConfig": {"additionalProperties": false, "properties": {"enabled": {"default": true, "description": "Enable or disable the custom classification model.", "title": "Enable model", "type": "boolean"}, "name": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Identifier for the custom classification model to use.", "title": "Model name"}, "threshold": {"default": 0.8, "description": "Score threshold used to change the classification state.", "title": "Score threshold", "type": "number"}, "save_attempts": {"anyOf": [{"minimum": 0, "type": "integer"}, {"type": "null"}], "default": null, "description": "How many classification attempts to save for recent classifications UI.", "title": "Save attempts"}, "object_config": {"anyOf": [{"$ref": "#/$defs/CustomClassificationObjectConfig"}, {"type": "null"}], "default": null}, "state_config": {"anyOf": [{"$ref": "#/$defs/CustomClassificationStateConfig"}, {"type": "null"}], "default": null}}, "title": "CustomClassificationConfig", "type": "object"}, "CustomClassificationObjectConfig": {"additionalProperties": false, "properties": {"objects": {"description": "List of object types to run object classification on.", "items": {"type": "string"}, "title": "Classify objects", "type": "array"}, "classification_type": {"$ref": "#/$defs/ObjectClassificationType", "default": "sub_label", "description": "Classification type applied: 'sub_label' (adds sub_label) or other supported types.", "title": "Classification type"}}, "title": "CustomClassificationObjectConfig", "type": "object"}, "CustomClassificationStateCameraConfig": {"additionalProperties": false, "properties": {"crop": {"description": "Crop coordinates to use for running classification on this camera.", "items": {"type": "number"}, "title": "Classification crop", "type": "array"}}, "required": ["crop"], "title": "CustomClassificationStateCameraConfig", "type": "object"}, "CustomClassificationStateConfig": {"additionalProperties": false, "properties": {"cameras": {"additionalProperties": {"$ref": "#/$defs/CustomClassificationStateCameraConfig"}, "description": "Per-camera crop and settings for running state classification.", "title": "Classification cameras", "type": "object"}, "motion": {"default": false, "description": "If true, run classification when motion is detected within the specified crop.", "title": "Run on motion", "type": "boolean"}, "interval": {"anyOf": [{"exclusiveMinimum": 0, "type": "integer"}, {"type": "null"}], "default": null, "description": "Interval (seconds) between periodic classification runs for state classification.", "title": "Classification interval"}}, "required": ["cameras"], "title": "CustomClassificationStateConfig", "type": "object"}, "DatabaseConfig": {"additionalProperties": false, "properties": {"path": {"default": "/config/frigate.db", "description": "Filesystem path where the Frigate SQLite database file will be stored.", "title": "Database path", "type": "string"}}, "title": "DatabaseConfig", "type": "object"}, "DetectConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable object detection for all cameras; can be overridden per-camera.", "title": "Enable object detection", "type": "boolean"}, "height": {"anyOf": [{"type": "integer"}, {"type": "null"}], "default": null, "description": "Height (pixels) of frames used for the detect stream; leave empty to use the native stream resolution.", "title": "Detect height"}, "width": {"anyOf": [{"type": "integer"}, {"type": "null"}], "default": null, "description": "Width (pixels) of frames used for the detect stream; leave empty to use the native stream resolution.", "title": "Detect width"}, "fps": {"default": 5, "description": "Desired frames per second to run detection on; lower values reduce CPU usage (recommended value is 5, only set higher - at most 10 - if tracking extremely fast moving objects).", "title": "Detect FPS", "type": "integer"}, "min_initialized": {"anyOf": [{"minimum": 2, "type": "integer"}, {"type": "null"}], "default": null, "description": "Number of consecutive detection hits required before creating a tracked object. Increase to reduce false initializations. Default value is fps divided by 2.", "title": "Minimum initialization frames"}, "max_disappeared": {"anyOf": [{"type": "integer"}, {"type": "null"}], "default": null, "description": "Number of frames without a detection before a tracked object is considered gone.", "title": "Maximum disappeared frames"}, "stationary": {"$ref": "#/$defs/StationaryConfig", "description": "Settings to detect and manage objects that remain stationary for a period of time.", "title": "Stationary objects config"}, "annotation_offset": {"default": 0, "description": "Milliseconds to shift detect annotations to better align timeline bounding boxes with recordings; can be positive or negative.", "title": "Annotation offset", "type": "integer"}}, "title": "DetectConfig", "type": "object"}, "DetectionsConfig": {"additionalProperties": false, "description": "Configure detections", "properties": {"enabled": {"default": true, "description": "Enable or disable detection events for all cameras; can be overridden per-camera.", "title": "Enable detections", "type": "boolean"}, "labels": {"anyOf": [{"items": {"type": "string"}, "type": "array"}, {"type": "null"}], "default": null, "description": "List of object labels that qualify as detection events.", "title": "Detection labels"}, "required_zones": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "description": "Zones that an object must enter to be considered a detection; leave empty to allow any zone.", "title": "Required zones"}, "cutoff_time": {"default": 30, "description": "Seconds to wait after no detection-causing activity before cutting off a detection.", "title": "Detections cutoff time", "type": "integer"}, "enabled_in_config": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "description": "Tracks whether detections were originally enabled in the static configuration.", "title": "Original detections state"}}, "title": "DetectionsConfig", "type": "object"}, "EnrichmentsDeviceEnum": {"enum": ["GPU", "CPU"], "title": "EnrichmentsDeviceEnum", "type": "string"}, "EventsConfig": {"additionalProperties": false, "properties": {"pre_capture": {"default": 5, "description": "Number of seconds before the detection event to include in the recording.", "maximum": 60, "minimum": 0, "title": "Pre-capture seconds", "type": "integer"}, "post_capture": {"default": 5, "description": "Number of seconds after the detection event to include in the recording.", "minimum": 0, "title": "Post-capture seconds", "type": "integer"}, "retain": {"$ref": "#/$defs/ReviewRetainConfig", "description": "Retention settings for recordings of detection events.", "title": "Event retention"}}, "title": "EventsConfig", "type": "object"}, "FaceRecognitionConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable face recognition for all cameras; can be overridden per-camera.", "title": "Enable face recognition", "type": "boolean"}, "model_size": {"$ref": "#/$defs/ModelSizeEnum", "default": "small", "description": "Model size to use for face embeddings (small/large); larger may require GPU.", "title": "Model size"}, "unknown_score": {"default": 0.8, "description": "Distance threshold below which a face is considered a potential match (higher = stricter).", "exclusiveMinimum": 0.0, "maximum": 1.0, "title": "Unknown score threshold", "type": "number"}, "detection_threshold": {"default": 0.7, "description": "Minimum detection confidence required to consider a face detection valid.", "exclusiveMinimum": 0.0, "maximum": 1.0, "title": "Detection threshold", "type": "number"}, "recognition_threshold": {"default": 0.9, "description": "Face embedding distance threshold to consider two faces a match.", "exclusiveMinimum": 0.0, "maximum": 1.0, "title": "Recognition threshold", "type": "number"}, "min_area": {"default": 750, "description": "Minimum area (pixels) of a detected face box required to attempt recognition.", "title": "Minimum face area", "type": "integer"}, "min_faces": {"default": 1, "description": "Minimum number of face recognitions required before applying a recognized sub-label to a person.", "exclusiveMinimum": 0, "maximum": 6, "title": "Minimum faces", "type": "integer"}, "save_attempts": {"default": 200, "description": "Number of face recognition attempts to retain for recent recognition UI.", "minimum": 0, "title": "Save attempts", "type": "integer"}, "blur_confidence_filter": {"default": true, "description": "Adjust confidence scores based on image blur to reduce false positives for poor quality faces.", "title": "Blur confidence filter", "type": "boolean"}, "device": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "This is an override, to target a specific device. See https://onnxruntime.ai/docs/execution-providers/ for more information", "title": "Device"}}, "title": "FaceRecognitionConfig", "type": "object"}, "FfmpegConfig": {"additionalProperties": false, "properties": {"path": {"default": "default", "description": "Path to the FFmpeg binary to use or a version alias (\"7.0\" or \"8.0\").", "title": "FFmpeg path", "type": "string"}, "global_args": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "default": ["-hide_banner", "-loglevel", "warning", "-threads", "2"], "description": "Global arguments passed to FFmpeg processes.", "title": "FFmpeg global arguments"}, "hwaccel_args": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "default": "auto", "description": "Hardware acceleration arguments for FFmpeg. Provider-specific presets are recommended.", "title": "Hardware acceleration arguments"}, "input_args": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "default": "preset-rtsp-generic", "description": "Input arguments applied to FFmpeg input streams.", "title": "Input arguments"}, "output_args": {"$ref": "#/$defs/FfmpegOutputArgsConfig", "description": "Default output arguments used for different FFmpeg roles such as detect and record.", "title": "Output arguments"}, "retry_interval": {"default": 10.0, "description": "Seconds to wait before attempting to reconnect a camera stream after failure. Default is 10.", "exclusiveMinimum": 0.0, "title": "FFmpeg retry time", "type": "number"}, "apple_compatibility": {"default": false, "description": "Enable HEVC tagging for better Apple player compatibility when recording H.265.", "title": "Apple compatibility", "type": "boolean"}, "gpu": {"default": 0, "description": "Default GPU index used for hardware acceleration if available.", "title": "GPU index", "type": "integer"}}, "title": "FfmpegConfig", "type": "object"}, "FfmpegOutputArgsConfig": {"additionalProperties": false, "properties": {"detect": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "default": ["-threads", "2", "-f", "rawvideo", "-pix_fmt", "yuv420p"], "description": "Default output arguments for detect role streams.", "title": "Detect output arguments"}, "record": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "default": "preset-record-generic-audio-aac", "description": "Default output arguments for record role streams.", "title": "Record output arguments"}}, "title": "FfmpegOutputArgsConfig", "type": "object"}, "FilterConfig": {"additionalProperties": false, "properties": {"min_area": {"anyOf": [{"type": "integer"}, {"type": "number"}], "default": 0, "description": "Minimum bounding box area (pixels or percentage) required for this object type. Can be pixels (int) or percentage (float between 0.000001 and 0.99).", "title": "Minimum object area"}, "max_area": {"anyOf": [{"type": "integer"}, {"type": "number"}], "default": 24000000, "description": "Maximum bounding box area (pixels or percentage) allowed for this object type. Can be pixels (int) or percentage (float between 0.000001 and 0.99).", "title": "Maximum object area"}, "min_ratio": {"default": 0, "description": "Minimum width/height ratio required for the bounding box to qualify.", "title": "Minimum aspect ratio", "type": "number"}, "max_ratio": {"default": 24000000, "description": "Maximum width/height ratio allowed for the bounding box to qualify.", "title": "Maximum aspect ratio", "type": "number"}, "threshold": {"default": 0.7, "description": "Average detection confidence threshold required for the object to be considered a true positive.", "title": "Confidence threshold", "type": "number"}, "min_score": {"default": 0.5, "description": "Minimum single-frame detection confidence required for the object to be counted.", "title": "Minimum confidence", "type": "number"}, "mask": {"additionalProperties": {"anyOf": [{"$ref": "#/$defs/ObjectMaskConfig"}, {"type": "null"}]}, "description": "Polygon coordinates defining where this filter applies within the frame.", "title": "Filter mask", "type": "object"}, "raw_mask": {"additionalProperties": {"anyOf": [{"$ref": "#/$defs/ObjectMaskConfig"}, {"type": "null"}]}, "title": "Raw Mask", "type": "object"}}, "title": "FilterConfig", "type": "object"}, "GenAIConfig": {"additionalProperties": false, "description": "Primary GenAI Config to define GenAI Provider.", "properties": {"api_key": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "API key required by some providers (can also be set via environment variables).", "title": "API key"}, "base_url": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Base URL for self-hosted or compatible providers (for example an Ollama instance).", "title": "Base URL"}, "model": {"default": "", "description": "The model to use from the provider for generating descriptions or summaries.", "title": "Model", "type": "string"}, "provider": {"$ref": "#/$defs/GenAIProviderEnum", "description": "The GenAI provider to use (for example: ollama, gemini, openai).", "title": "Provider"}, "roles": {"description": "GenAI roles (chat, descriptions, embeddings); one provider per role.", "items": {"$ref": "#/$defs/GenAIRoleEnum"}, "title": "Roles", "type": "array"}, "provider_options": {"additionalProperties": {}, "default": {}, "description": "Additional provider-specific options to pass to the GenAI client.", "title": "Provider options", "type": "object"}, "runtime_options": {"additionalProperties": {}, "default": {}, "description": "Runtime options passed to the provider for each inference call.", "title": "Runtime options", "type": "object"}}, "required": ["provider"], "title": "GenAIConfig", "type": "object"}, "GenAIObjectConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable GenAI generation of descriptions for tracked objects by default.", "title": "Enable GenAI", "type": "boolean"}, "use_snapshot": {"default": false, "description": "Use object snapshots instead of thumbnails for GenAI description generation.", "title": "Use snapshots", "type": "boolean"}, "prompt": {"default": "Analyze the sequence of images containing the {label}. Focus on the likely intent or behavior of the {label} based on its actions and movement, rather than describing its appearance or the surroundings. Consider what the {label} is doing, why, and what it might do next.", "description": "Default prompt template used when generating descriptions with GenAI.", "title": "Caption prompt", "type": "string"}, "object_prompts": {"additionalProperties": {"type": "string"}, "description": "Per-object prompts to customize GenAI outputs for specific labels.", "title": "Object prompts", "type": "object"}, "objects": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "description": "List of object labels to send to GenAI by default.", "title": "GenAI objects"}, "required_zones": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "description": "Zones that must be entered for objects to qualify for GenAI description generation.", "title": "Required zones"}, "debug_save_thumbnails": {"default": false, "description": "Save thumbnails sent to GenAI for debugging and review.", "title": "Save thumbnails", "type": "boolean"}, "send_triggers": {"$ref": "#/$defs/GenAIObjectTriggerConfig", "description": "Defines when frames should be sent to GenAI (on end, after updates, etc.).", "title": "GenAI triggers"}, "enabled_in_config": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "description": "Indicates whether GenAI was enabled in the original static config.", "title": "Original GenAI state"}}, "title": "GenAIObjectConfig", "type": "object"}, "GenAIObjectTriggerConfig": {"additionalProperties": false, "properties": {"tracked_object_end": {"default": true, "description": "Send a request to GenAI when the tracked object ends.", "title": "Send on end", "type": "boolean"}, "after_significant_updates": {"anyOf": [{"minimum": 1, "type": "integer"}, {"type": "null"}], "default": null, "description": "Send a request to GenAI after a specified number of significant updates for the tracked object.", "title": "Early GenAI trigger"}}, "title": "GenAIObjectTriggerConfig", "type": "object"}, "GenAIProviderEnum": {"enum": ["openai", "azure_openai", "gemini", "ollama", "llamacpp"], "title": "GenAIProviderEnum", "type": "string"}, "GenAIReviewConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable GenAI-generated descriptions and summaries for review items.", "title": "Enable GenAI descriptions", "type": "boolean"}, "alerts": {"default": true, "description": "Use GenAI to generate descriptions for alert items.", "title": "Enable GenAI for alerts", "type": "boolean"}, "detections": {"default": false, "description": "Use GenAI to generate descriptions for detection items.", "title": "Enable GenAI for detections", "type": "boolean"}, "image_source": {"$ref": "#/$defs/ImageSourceEnum", "default": "preview", "description": "Source of images sent to GenAI ('preview' or 'recordings'); 'recordings' uses higher quality frames but more tokens.", "title": "Review image source"}, "additional_concerns": {"default": [], "description": "A list of additional concerns or notes the GenAI should consider when evaluating activity on this camera.", "items": {"type": "string"}, "title": "Additional concerns", "type": "array"}, "debug_save_thumbnails": {"default": false, "description": "Save thumbnails that are sent to the GenAI provider for debugging and review.", "title": "Save thumbnails", "type": "boolean"}, "enabled_in_config": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "description": "Tracks whether GenAI review was originally enabled in the static configuration.", "title": "Original GenAI state"}, "preferred_language": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Preferred language to request from the GenAI provider for generated responses.", "title": "Preferred language"}, "activity_context_prompt": {"default": "### Normal Activity Indicators (Level 0)\n- Known/verified people in any zone at any time\n- People with pets in residential areas\n- Routine residential vehicle access during daytime/evening (6 AM - 10 PM): entering, exiting, loading/unloading items \u2014 normal commute and travel patterns\n- Deliveries or services during daytime/evening (6 AM - 10 PM): carrying packages to doors/porches, placing items, leaving\n- Services/maintenance workers with visible tools, uniforms, or service vehicles during daytime\n- Activity confined to public areas only (sidewalks, streets) without entering property at any time\n\n### Suspicious Activity Indicators (Level 1)\n- **Checking or probing vehicle/building access**: trying handles without entering, peering through windows, examining multiple vehicles, or possessing break-in tools \u2014 Level 1\n- **Unidentified person in private areas (driveways, near vehicles/buildings) during late night/early morning (11 PM - 5 AM)** \u2014 ALWAYS Level 1 regardless of activity or duration\n- Taking items that don't belong to them (packages, objects from porches/driveways)\n- Climbing or jumping fences/barriers to access property\n- Attempting to conceal actions or items from view\n- Prolonged loitering: remaining in same area without visible purpose throughout most of the sequence\n\n### Critical Threat Indicators (Level 2)\n- Holding break-in tools (crowbars, pry bars, bolt cutters)\n- Weapons visible (guns, knives, bats used aggressively)\n- Forced entry in progress\n- Physical aggression or violence\n- Active property damage or theft in progress\n\n### Assessment Guidance\nEvaluate in this order:\n\n1. **If person is verified/known** \u2192 Level 0 regardless of time or activity\n2. **If person is unidentified:**\n - Check time: If late night/early morning (11 PM - 5 AM) AND in private areas (driveways, near vehicles/buildings) \u2192 Level 1\n - Check actions: If probing access (trying handles without entering, checking multiple vehicles), taking items, climbing \u2192 Level 1\n - Otherwise, if daytime/evening (6 AM - 10 PM) with clear legitimate purpose (delivery, service, routine vehicle access) \u2192 Level 0\n3. **Escalate to Level 2 if:** Weapons, break-in tools, forced entry in progress, violence, or active property damage visible (escalates from Level 0 or 1)\n\nThe mere presence of an unidentified person in private areas during late night hours is inherently suspicious and warrants human review, regardless of what activity they appear to be doing or how brief the sequence is.", "description": "Custom prompt describing what is and is not suspicious activity to provide context for GenAI summaries.", "title": "Activity context prompt", "type": "string"}}, "title": "GenAIReviewConfig", "type": "object"}, "GenAIRoleEnum": {"enum": ["chat", "descriptions", "embeddings"], "title": "GenAIRoleEnum", "type": "string"}, "HeaderMappingConfig": {"additionalProperties": false, "properties": {"user": {"default": null, "description": "Header containing the authenticated username provided by the upstream proxy.", "title": "User header", "type": "string"}, "role": {"default": null, "description": "Header containing the authenticated user's role or groups from the upstream proxy.", "title": "Role header", "type": "string"}, "role_map": {"anyOf": [{"additionalProperties": {"items": {"type": "string"}, "type": "array"}, "type": "object"}, {"type": "null"}], "description": "Map upstream group values to Frigate roles (for example map admin groups to the admin role).", "title": "Role mapping"}}, "title": "HeaderMappingConfig", "type": "object"}, "IPv6Config": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable IPv6 support for Frigate services (API and UI) where applicable.", "title": "Enable IPv6", "type": "boolean"}}, "title": "IPv6Config", "type": "object"}, "ImageSourceEnum": {"description": "Image source options for GenAI Review.", "enum": ["preview", "recordings"], "title": "ImageSourceEnum", "type": "string"}, "InputDTypeEnum": {"enum": ["float", "float_denorm", "int"], "title": "InputDTypeEnum", "type": "string"}, "InputTensorEnum": {"enum": ["nchw", "nhwc", "hwnc", "hwcn"], "title": "InputTensorEnum", "type": "string"}, "LicensePlateRecognitionConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable license plate recognition for all cameras; can be overridden per-camera.", "title": "Enable LPR", "type": "boolean"}, "model_size": {"$ref": "#/$defs/ModelSizeEnum", "default": "small", "description": "Model size used for text detection/recognition. Most users should use 'small'.", "title": "Model size"}, "detection_threshold": {"default": 0.7, "description": "Detection confidence threshold to begin running OCR on a suspected plate.", "exclusiveMinimum": 0.0, "maximum": 1.0, "title": "Detection threshold", "type": "number"}, "min_area": {"default": 1000, "description": "Minimum plate area (pixels) required to attempt recognition.", "title": "Minimum plate area", "type": "integer"}, "recognition_threshold": {"default": 0.9, "description": "Confidence threshold required for recognized plate text to be attached as a sub-label.", "exclusiveMinimum": 0.0, "maximum": 1.0, "title": "Recognition threshold", "type": "number"}, "min_plate_length": {"default": 4, "description": "Minimum number of characters a recognized plate must contain to be considered valid.", "title": "Min plate length", "type": "integer"}, "format": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Optional regex to validate recognized plate strings against an expected format.", "title": "Plate format regex"}, "match_distance": {"default": 1, "description": "Number of character mismatches allowed when comparing detected plates to known plates.", "minimum": 0, "title": "Match distance", "type": "integer"}, "known_plates": {"anyOf": [{"additionalProperties": {"items": {"type": "string"}, "type": "array"}, "type": "object"}, {"type": "null"}], "default": {}, "description": "List of plates or regexes to specially track or alert on.", "title": "Known plates"}, "enhancement": {"default": 0, "description": "Enhancement level (0-10) to apply to plate crops prior to OCR; higher values may not always improve results, levels above 5 may only work with night time plates and should be used with caution.", "maximum": 10, "minimum": 0, "title": "Enhancement level", "type": "integer"}, "debug_save_plates": {"default": false, "description": "Save plate crop images for debugging LPR performance.", "title": "Save debug plates", "type": "boolean"}, "device": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "This is an override, to target a specific device. See https://onnxruntime.ai/docs/execution-providers/ for more information", "title": "Device"}, "replace_rules": {"description": "Regex replacement rules used to normalize detected plate strings before matching.", "items": {"$ref": "#/$defs/ReplaceRule"}, "title": "Replacement rules", "type": "array"}}, "title": "LicensePlateRecognitionConfig", "type": "object"}, "ListenConfig": {"additionalProperties": false, "properties": {"internal": {"anyOf": [{"type": "integer"}, {"type": "string"}], "default": 5000, "description": "Internal listening port for Frigate (default 5000).", "title": "Internal port"}, "external": {"anyOf": [{"type": "integer"}, {"type": "string"}], "default": 8971, "description": "External listening port for Frigate (default 8971).", "title": "External port"}}, "title": "ListenConfig", "type": "object"}, "LogLevel": {"enum": ["debug", "info", "warning", "error", "critical"], "title": "LogLevel", "type": "string"}, "LoggerConfig": {"additionalProperties": false, "properties": {"default": {"$ref": "#/$defs/LogLevel", "default": "info", "title": "Logging level", "description": "Default global log verbosity (debug, info, warning, error)."}, "logs": {"additionalProperties": {"$ref": "#/$defs/LogLevel"}, "description": "Per-component log level overrides to increase or decrease verbosity for specific modules.", "title": "Per-process log level", "type": "object"}}, "title": "LoggerConfig", "type": "object"}, "ModelConfig": {"additionalProperties": false, "properties": {"path": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Path to a custom detection model file (or plus:// for Frigate+ models).", "title": "Custom object detector model path"}, "labelmap_path": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Path to a labelmap file that maps numeric classes to string labels for the detector.", "title": "Label map for custom object detector"}, "width": {"default": 320, "description": "Width of the model input tensor in pixels.", "title": "Object detection model input width", "type": "integer"}, "height": {"default": 320, "description": "Height of the model input tensor in pixels.", "title": "Object detection model input height", "type": "integer"}, "labelmap": {"additionalProperties": {"type": "string"}, "description": "Overrides or remapping entries to merge into the standard labelmap.", "title": "Labelmap customization", "type": "object"}, "attributes_map": {"additionalProperties": {"items": {"type": "string"}, "type": "array"}, "default": {"person": ["amazon", "face"], "car": ["amazon", "an_post", "canada_post", "dhl", "dpd", "fedex", "gls", "license_plate", "nzpost", "postnl", "postnord", "purolator", "royal_mail", "ups", "usps"], "motorcycle": ["license_plate"]}, "description": "Mapping from object labels to attribute labels used to attach metadata (for example 'car' -> ['license_plate']).", "title": "Map of object labels to their attribute labels", "type": "object"}, "input_tensor": {"$ref": "#/$defs/InputTensorEnum", "default": "nhwc", "description": "Tensor format expected by the model: 'nhwc' or 'nchw'.", "title": "Model Input Tensor Shape"}, "input_pixel_format": {"$ref": "#/$defs/PixelFormatEnum", "default": "rgb", "description": "Pixel colorspace expected by the model: 'rgb', 'bgr', or 'yuv'.", "title": "Model Input Pixel Color Format"}, "input_dtype": {"$ref": "#/$defs/InputDTypeEnum", "default": "int", "description": "Data type of the model input tensor (for example 'float32').", "title": "Model Input D Type"}, "model_type": {"$ref": "#/$defs/ModelTypeEnum", "default": "ssd", "description": "Detector model architecture type (ssd, yolox, yolonas) used by some detectors for optimization.", "title": "Object Detection Model Type"}}, "title": "ModelConfig", "type": "object"}, "ModelSizeEnum": {"enum": ["small", "large"], "title": "ModelSizeEnum", "type": "string"}, "ModelTypeEnum": {"enum": ["dfine", "rfdetr", "ssd", "yolox", "yolonas", "yolo-generic"], "title": "ModelTypeEnum", "type": "string"}, "MotionConfig": {"additionalProperties": false, "properties": {"enabled": {"default": true, "description": "Enable or disable motion detection for all cameras; can be overridden per-camera.", "title": "Enable motion detection", "type": "boolean"}, "threshold": {"default": 30, "description": "Pixel difference threshold used by the motion detector; higher values reduce sensitivity (range 1-255).", "maximum": 255, "minimum": 1, "title": "Motion threshold", "type": "integer"}, "lightning_threshold": {"default": 0.8, "description": "Threshold to detect and ignore brief lighting spikes (lower is more sensitive, values between 0.3 and 1.0). This does not prevent motion detection entirely; it merely causes the detector to stop analyzing additional frames once the threshold is exceeded. Motion-based recordings are still created during these events.", "maximum": 1.0, "minimum": 0.3, "title": "Lightning threshold", "type": "number"}, "skip_motion_threshold": {"anyOf": [{"maximum": 1.0, "minimum": 0.0, "type": "number"}, {"type": "null"}], "default": null, "description": "If set to a value between 0.0 and 1.0, and more than this fraction of the image changes in a single frame, the detector will return no motion boxes and immediately recalibrate. This can save CPU and reduce false positives during lightning, storms, etc., but may miss real events such as a PTZ camera auto\u2011tracking an object. The trade\u2011off is between dropping a few megabytes of recordings versus reviewing a couple short clips. Leave unset (None) to disable this feature.", "title": "Skip motion threshold"}, "improve_contrast": {"default": true, "description": "Apply contrast improvement to frames before motion analysis to help detection.", "title": "Improve contrast", "type": "boolean"}, "contour_area": {"anyOf": [{"type": "integer"}, {"type": "null"}], "default": 10, "description": "Minimum contour area in pixels required for a motion contour to be counted.", "title": "Contour area"}, "delta_alpha": {"default": 0.2, "description": "Alpha blending factor used in frame differencing for motion calculation.", "title": "Delta alpha", "type": "number"}, "frame_alpha": {"default": 0.01, "description": "Alpha value used when blending frames for motion preprocessing.", "title": "Frame alpha", "type": "number"}, "frame_height": {"anyOf": [{"type": "integer"}, {"type": "null"}], "default": 100, "description": "Height in pixels to scale frames to when computing motion.", "title": "Frame height"}, "mask": {"additionalProperties": {"anyOf": [{"$ref": "#/$defs/MotionMaskConfig"}, {"type": "null"}]}, "description": "Ordered x,y coordinates defining the motion mask polygon used to include/exclude areas.", "title": "Mask coordinates", "type": "object"}, "mqtt_off_delay": {"default": 30, "description": "Seconds to wait after last motion before publishing an MQTT 'off' state.", "title": "MQTT off delay", "type": "integer"}, "enabled_in_config": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "description": "Indicates whether motion detection was enabled in the original static configuration.", "title": "Original motion state"}, "raw_mask": {"additionalProperties": {"anyOf": [{"$ref": "#/$defs/MotionMaskConfig"}, {"type": "null"}]}, "title": "Raw Mask", "type": "object"}}, "title": "MotionConfig", "type": "object"}, "MotionMaskConfig": {"additionalProperties": false, "description": "Configuration for a single motion mask.", "properties": {"friendly_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "A friendly name for this motion mask used in the Frigate UI", "title": "Friendly name"}, "enabled": {"default": true, "description": "Enable or disable this motion mask", "title": "Enabled", "type": "boolean"}, "coordinates": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "default": "", "description": "Ordered x,y coordinates defining the motion mask polygon used to include/exclude areas.", "title": "Coordinates"}, "raw_coordinates": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "default": "", "title": "Raw Coordinates"}, "enabled_in_config": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "title": "Keep track of original state of motion mask."}}, "title": "MotionMaskConfig", "type": "object"}, "MqttConfig": {"additionalProperties": false, "properties": {"enabled": {"default": true, "description": "Enable or disable MQTT integration for state, events, and snapshots.", "title": "Enable MQTT", "type": "boolean"}, "host": {"default": "", "description": "Hostname or IP address of the MQTT broker.", "title": "MQTT host", "type": "string"}, "port": {"default": 1883, "description": "Port of the MQTT broker (usually 1883 for plain MQTT).", "title": "MQTT port", "type": "integer"}, "topic_prefix": {"default": "frigate", "description": "MQTT topic prefix for all Frigate topics; must be unique if running multiple instances.", "title": "Topic prefix", "type": "string"}, "client_id": {"default": "frigate", "description": "Client identifier used when connecting to the MQTT broker; should be unique per instance.", "title": "Client ID", "type": "string"}, "stats_interval": {"default": 60, "description": "Interval in seconds for publishing system and camera stats to MQTT.", "minimum": 15, "title": "Stats interval", "type": "integer"}, "user": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Optional MQTT username; can be provided via environment variables or secrets.", "title": "MQTT username"}, "password": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Optional MQTT password; can be provided via environment variables or secrets.", "title": "MQTT password"}, "tls_ca_certs": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Path to CA certificate for TLS connections to the broker (for self-signed certs).", "title": "TLS CA certs"}, "tls_client_cert": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Client certificate path for TLS mutual authentication; do not set user/password when using client certs.", "title": "Client cert"}, "tls_client_key": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Private key path for the client certificate.", "title": "Client key"}, "tls_insecure": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "description": "Allow insecure TLS connections by skipping hostname verification (not recommended).", "title": "TLS insecure"}, "qos": {"default": 0, "description": "Quality of Service level for MQTT publishes/subscriptions (0, 1, or 2).", "title": "MQTT QoS", "type": "integer"}}, "title": "MqttConfig", "type": "object"}, "NetworkingConfig": {"additionalProperties": false, "properties": {"ipv6": {"$ref": "#/$defs/IPv6Config", "description": "IPv6-specific settings for Frigate network services.", "title": "IPv6 configuration"}, "listen": {"$ref": "#/$defs/ListenConfig", "description": "Configuration for internal and external listening ports. This is for advanced users. For the majority of use cases it's recommended to change the ports section of your Docker compose file.", "title": "Listening ports configuration"}}, "title": "NetworkingConfig", "type": "object"}, "NotificationConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable notifications for all cameras; can be overridden per-camera.", "title": "Enable notifications", "type": "boolean"}, "email": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Email address used for push notifications or required by certain notification providers.", "title": "Notification email"}, "cooldown": {"default": 0, "description": "Cooldown (seconds) between notifications to avoid spamming recipients.", "minimum": 0, "title": "Cooldown period", "type": "integer"}, "enabled_in_config": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "description": "Indicates whether notifications were enabled in the original static configuration.", "title": "Original notifications state"}}, "title": "NotificationConfig", "type": "object"}, "ObjectClassificationType": {"enum": ["sub_label", "attribute"], "title": "ObjectClassificationType", "type": "string"}, "ObjectConfig": {"additionalProperties": false, "properties": {"track": {"default": ["person"], "description": "List of object labels to track for all cameras; can be overridden per-camera.", "items": {"type": "string"}, "title": "Objects to track", "type": "array"}, "filters": {"additionalProperties": {"$ref": "#/$defs/FilterConfig"}, "description": "Filters applied to detected objects to reduce false positives (area, ratio, confidence).", "title": "Object filters", "type": "object"}, "mask": {"additionalProperties": {"anyOf": [{"$ref": "#/$defs/ObjectMaskConfig"}, {"type": "null"}]}, "description": "Mask polygon used to prevent object detection in specified areas.", "title": "Object mask", "type": "object"}, "raw_mask": {"additionalProperties": {"anyOf": [{"$ref": "#/$defs/ObjectMaskConfig"}, {"type": "null"}]}, "title": "Raw Mask", "type": "object"}, "genai": {"$ref": "#/$defs/GenAIObjectConfig", "description": "GenAI options for describing tracked objects and sending frames for generation.", "title": "GenAI object config"}}, "title": "ObjectConfig", "type": "object"}, "ObjectMaskConfig": {"additionalProperties": false, "description": "Configuration for a single object mask.", "properties": {"friendly_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "A friendly name for this object mask used in the Frigate UI", "title": "Friendly name"}, "enabled": {"default": true, "description": "Enable or disable this object mask", "title": "Enabled", "type": "boolean"}, "coordinates": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "default": "", "description": "Ordered x,y coordinates defining the object mask polygon used to include/exclude areas.", "title": "Coordinates"}, "raw_coordinates": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "default": "", "title": "Raw Coordinates"}, "enabled_in_config": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "title": "Keep track of original state of object mask."}}, "title": "ObjectMaskConfig", "type": "object"}, "OnvifConfig": {"additionalProperties": false, "properties": {"host": {"default": "", "description": "Host (and optional scheme) for the ONVIF service for this camera.", "title": "ONVIF host", "type": "string"}, "port": {"default": 8000, "description": "Port number for the ONVIF service.", "title": "ONVIF port", "type": "integer"}, "user": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Username for ONVIF authentication; some devices require admin user for ONVIF.", "title": "ONVIF username"}, "password": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Password for ONVIF authentication.", "title": "ONVIF password"}, "tls_insecure": {"default": false, "description": "Skip TLS verification and disable digest auth for ONVIF (unsafe; use in safe networks only).", "title": "Disable TLS verify", "type": "boolean"}, "profile": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Specific ONVIF media profile to use for PTZ control, matched by token or name. If not set, the first profile with valid PTZ configuration is selected automatically.", "title": "ONVIF profile"}, "autotracking": {"$ref": "#/$defs/PtzAutotrackConfig", "description": "Automatically track moving objects and keep them centered in the frame using PTZ camera movements.", "title": "Autotracking"}, "ignore_time_mismatch": {"default": false, "description": "Ignore time synchronization differences between camera and Frigate server for ONVIF communication.", "title": "Ignore time mismatch", "type": "boolean"}}, "title": "OnvifConfig", "type": "object"}, "PixelFormatEnum": {"enum": ["rgb", "bgr", "yuv"], "title": "PixelFormatEnum", "type": "string"}, "ProfileDefinitionConfig": {"additionalProperties": false, "description": "Defines a named profile with a human-readable display name.\n\nThe dict key is the machine name used internally; friendly_name\nis the label shown in the UI and API responses.", "properties": {"friendly_name": {"description": "Display name for this profile shown in the UI.", "title": "Friendly name", "type": "string"}}, "required": ["friendly_name"], "title": "ProfileDefinitionConfig", "type": "object"}, "ProxyConfig": {"additionalProperties": false, "properties": {"header_map": {"$ref": "#/$defs/HeaderMappingConfig", "description": "Map incoming proxy headers to Frigate user and role fields for proxy-based auth.", "title": "Header mapping"}, "logout_url": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "URL to redirect users to when logging out via the proxy.", "title": "Logout URL"}, "auth_secret": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Optional secret checked against the X-Proxy-Secret header to verify trusted proxies.", "title": "Proxy secret"}, "default_role": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": "viewer", "description": "Default role assigned to proxy-authenticated users when no role mapping applies.", "title": "Default role"}, "separator": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": ",", "description": "Character used to split multiple values provided in proxy headers.", "title": "Separator character"}}, "title": "ProxyConfig", "type": "object"}, "PtzAutotrackConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable automatic PTZ camera tracking of detected objects.", "title": "Enable Autotracking", "type": "boolean"}, "calibrate_on_startup": {"default": false, "description": "Measure PTZ motor speeds on startup to improve tracking accuracy. Frigate will update config with movement_weights after calibration.", "title": "Calibrate on start", "type": "boolean"}, "zooming": {"$ref": "#/$defs/ZoomingModeEnum", "default": "disabled", "description": "Control zoom behavior: disabled (pan/tilt only), absolute (most compatible), or relative (concurrent pan/tilt/zoom).", "title": "Zoom mode"}, "zoom_factor": {"default": 0.3, "description": "Control zoom level on tracked objects. Lower values keep more scene in view; higher values zoom in closer but may lose tracking. Values between 0.1 and 0.75.", "maximum": 0.75, "minimum": 0.1, "title": "Zoom factor", "type": "number"}, "track": {"default": ["person"], "description": "List of object types that should trigger autotracking.", "items": {"type": "string"}, "title": "Tracked objects", "type": "array"}, "required_zones": {"description": "Objects must enter one of these zones before autotracking begins.", "items": {"type": "string"}, "title": "Required zones", "type": "array"}, "return_preset": {"default": "home", "description": "ONVIF preset name configured in camera firmware to return to after tracking ends.", "title": "Return preset", "type": "string"}, "timeout": {"default": 10, "description": "Wait this many seconds after losing tracking before returning camera to preset position.", "title": "Return timeout", "type": "integer"}, "movement_weights": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}, {"type": "null"}], "description": "Calibration values automatically generated by camera calibration. Do not modify manually.", "title": "Movement weights"}, "enabled_in_config": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "description": "Internal field to track whether autotracking was enabled in configuration.", "title": "Original autotrack state"}}, "title": "PtzAutotrackConfig", "type": "object"}, "RecordConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable recording for all cameras; can be overridden per-camera.", "title": "Enable recording", "type": "boolean"}, "expire_interval": {"default": 60, "description": "Minutes between cleanup passes that remove expired recording segments.", "title": "Record cleanup interval", "type": "integer"}, "continuous": {"$ref": "#/$defs/RecordRetainConfig", "description": "Number of days to retain recordings regardless of tracked objects or motion. Set to 0 if you only want to retain recordings of alerts and detections.", "title": "Continuous retention"}, "motion": {"$ref": "#/$defs/RecordRetainConfig", "description": "Number of days to retain recordings triggered by motion regardless of tracked objects. Set to 0 if you only want to retain recordings of alerts and detections.", "title": "Motion retention"}, "detections": {"$ref": "#/$defs/EventsConfig", "description": "Recording retention settings for detection events including pre/post capture durations.", "title": "Detection retention"}, "alerts": {"$ref": "#/$defs/EventsConfig", "description": "Recording retention settings for alert events including pre/post capture durations.", "title": "Alert retention"}, "export": {"$ref": "#/$defs/RecordExportConfig", "description": "Settings used when exporting recordings such as timelapse and hardware acceleration.", "title": "Export config"}, "preview": {"$ref": "#/$defs/RecordPreviewConfig", "description": "Settings controlling the quality of recording previews shown in the UI.", "title": "Preview config"}, "enabled_in_config": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "description": "Indicates whether recording was enabled in the original static configuration.", "title": "Original recording state"}}, "title": "RecordConfig", "type": "object"}, "RecordExportConfig": {"additionalProperties": false, "properties": {"hwaccel_args": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "default": "auto", "description": "Hardware acceleration args to use for export/transcode operations.", "title": "Export hwaccel args"}, "max_concurrent": {"default": 3, "description": "Maximum number of export jobs to process at the same time.", "minimum": 1, "title": "Maximum concurrent exports", "type": "integer"}}, "title": "RecordExportConfig", "type": "object"}, "RecordPreviewConfig": {"additionalProperties": false, "properties": {"quality": {"$ref": "#/$defs/RecordQualityEnum", "default": "medium", "description": "Preview quality level (very_low, low, medium, high, very_high).", "title": "Preview quality"}}, "title": "RecordPreviewConfig", "type": "object"}, "RecordQualityEnum": {"enum": ["very_low", "low", "medium", "high", "very_high"], "title": "RecordQualityEnum", "type": "string"}, "RecordRetainConfig": {"additionalProperties": false, "properties": {"days": {"default": 0, "description": "Days to retain recordings.", "minimum": 0.0, "title": "Retention days", "type": "number"}}, "title": "RecordRetainConfig", "type": "object"}, "ReplaceRule": {"additionalProperties": false, "properties": {"pattern": {"title": "Regex pattern", "type": "string"}, "replacement": {"title": "Replacement string", "type": "string"}}, "required": ["pattern", "replacement"], "title": "ReplaceRule", "type": "object"}, "RestreamConfig": {"additionalProperties": true, "properties": {}, "title": "RestreamConfig", "type": "object"}, "RetainConfig": {"additionalProperties": false, "properties": {"default": {"type": "number", "default": 10, "title": "Default retention", "description": "Default number of days to retain snapshots."}, "objects": {"additionalProperties": {"type": "number"}, "description": "Per-object overrides for snapshot retention days.", "title": "Object retention", "type": "object"}}, "title": "RetainConfig", "type": "object"}, "RetainModeEnum": {"enum": ["all", "motion", "active_objects"], "title": "RetainModeEnum", "type": "string"}, "ReviewConfig": {"additionalProperties": false, "properties": {"alerts": {"$ref": "#/$defs/AlertsConfig", "description": "Settings for which tracked objects generate alerts and how alerts are retained.", "title": "Alerts config"}, "detections": {"$ref": "#/$defs/DetectionsConfig", "description": "Settings for which tracked objects generate detections (non-alert) and how detections are retained.", "title": "Detections config"}, "genai": {"$ref": "#/$defs/GenAIReviewConfig", "description": "Controls use of generative AI for producing descriptions and summaries of review items.", "title": "GenAI config"}}, "title": "ReviewConfig", "type": "object"}, "ReviewRetainConfig": {"additionalProperties": false, "properties": {"days": {"default": 10, "description": "Number of days to retain recordings of detection events.", "minimum": 0.0, "title": "Retention days", "type": "number"}, "mode": {"$ref": "#/$defs/RetainModeEnum", "default": "motion", "description": "Mode for retention: all (save all segments), motion (save segments with motion), or active_objects (save segments with active objects).", "title": "Retention mode"}}, "title": "ReviewRetainConfig", "type": "object"}, "SemanticSearchConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable the semantic search feature.", "title": "Enable semantic search", "type": "boolean"}, "reindex": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": false, "description": "Trigger a full reindex of historical tracked objects into the embeddings database.", "title": "Reindex on startup"}, "model": {"anyOf": [{"$ref": "#/$defs/SemanticSearchModelEnum"}, {"type": "string"}, {"type": "null"}], "default": "jinav1", "description": "The embeddings model to use for semantic search (for example 'jinav1'), or the name of a GenAI provider with the embeddings role.", "title": "Semantic search model or GenAI provider name"}, "model_size": {"$ref": "#/$defs/ModelSizeEnum", "default": "small", "description": "Select model size; 'small' runs on CPU and 'large' typically requires GPU.", "title": "Model size"}, "device": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "This is an override, to target a specific device. See https://onnxruntime.ai/docs/execution-providers/ for more information", "title": "Device"}}, "title": "SemanticSearchConfig", "type": "object"}, "SemanticSearchModelEnum": {"enum": ["jinav1", "jinav2"], "title": "SemanticSearchModelEnum", "type": "string"}, "SnapshotsConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable or disable saving snapshots for all cameras; can be overridden per-camera.", "title": "Enable snapshots", "type": "boolean"}, "timestamp": {"default": false, "description": "Overlay a timestamp on snapshots from API.", "title": "Timestamp overlay", "type": "boolean"}, "bounding_box": {"default": true, "description": "Draw bounding boxes for tracked objects on snapshots from API.", "title": "Bounding box overlay", "type": "boolean"}, "crop": {"default": false, "description": "Crop snapshots from API to the detected object's bounding box.", "title": "Crop snapshot", "type": "boolean"}, "required_zones": {"description": "Zones an object must enter for a snapshot to be saved.", "items": {"type": "string"}, "title": "Required zones", "type": "array"}, "height": {"anyOf": [{"type": "integer"}, {"type": "null"}], "default": null, "description": "Height (pixels) to resize snapshots from API to; leave empty to preserve original size.", "title": "Snapshot height"}, "retain": {"$ref": "#/$defs/RetainConfig", "description": "Retention settings for snapshots including default days and per-object overrides.", "title": "Snapshot retention"}, "quality": {"default": 60, "description": "Encode quality for saved snapshots (0-100).", "maximum": 100, "minimum": 0, "title": "Snapshot quality", "type": "integer"}}, "title": "SnapshotsConfig", "type": "object"}, "StationaryConfig": {"additionalProperties": false, "properties": {"interval": {"anyOf": [{"exclusiveMinimum": 0, "type": "integer"}, {"type": "null"}], "default": null, "description": "How often (in frames) to run a detection check to confirm a stationary object.", "title": "Stationary interval"}, "threshold": {"anyOf": [{"minimum": 1, "type": "integer"}, {"type": "null"}], "default": null, "description": "Number of frames with no position change required to mark an object as stationary.", "title": "Stationary threshold"}, "max_frames": {"$ref": "#/$defs/StationaryMaxFramesConfig", "description": "Limits how long stationary objects are tracked before being discarded.", "title": "Max frames"}, "classifier": {"default": true, "description": "Use a visual classifier to detect truly stationary objects even when bounding boxes jitter.", "title": "Enable visual classifier", "type": "boolean"}}, "title": "StationaryConfig", "type": "object"}, "StationaryMaxFramesConfig": {"additionalProperties": false, "properties": {"default": {"anyOf": [{"minimum": 1, "type": "integer"}, {"type": "null"}], "default": null, "title": "Default max frames", "description": "Default maximum frames to track a stationary object before stopping."}, "objects": {"additionalProperties": {"type": "integer"}, "description": "Per-object overrides for maximum frames to track stationary objects.", "title": "Object max frames", "type": "object"}}, "title": "StationaryMaxFramesConfig", "type": "object"}, "StatsConfig": {"additionalProperties": false, "properties": {"amd_gpu_stats": {"default": true, "description": "Enable collection of AMD GPU statistics if an AMD GPU is present.", "title": "AMD GPU stats", "type": "boolean"}, "intel_gpu_stats": {"default": true, "description": "Enable collection of Intel GPU statistics if an Intel GPU is present.", "title": "Intel GPU stats", "type": "boolean"}, "network_bandwidth": {"default": false, "description": "Enable per-process network bandwidth monitoring for camera ffmpeg processes and detectors (requires capabilities).", "title": "Network bandwidth", "type": "boolean"}, "intel_gpu_device": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "PCI bus address or DRM device path (e.g. /dev/dri/card1) used to pin Intel GPU stats to a specific device when multiple are present.", "title": "Intel GPU device"}}, "title": "StatsConfig", "type": "object"}, "TelemetryConfig": {"additionalProperties": false, "properties": {"network_interfaces": {"default": [], "description": "List of network interface name prefixes to monitor for bandwidth statistics.", "items": {"type": "string"}, "title": "Network interfaces", "type": "array"}, "stats": {"$ref": "#/$defs/StatsConfig", "description": "Options to enable/disable collection of various system and GPU statistics.", "title": "System stats"}, "version_check": {"default": true, "description": "Enable an outbound check to detect if a newer Frigate version is available.", "title": "Version check", "type": "boolean"}}, "title": "TelemetryConfig", "type": "object"}, "TimeFormatEnum": {"enum": ["browser", "12hour", "24hour"], "title": "TimeFormatEnum", "type": "string"}, "TimestampEffectEnum": {"enum": ["solid", "shadow"], "title": "TimestampEffectEnum", "type": "string"}, "TimestampPositionEnum": {"enum": ["tl", "tr", "bl", "br"], "title": "TimestampPositionEnum", "type": "string"}, "TimestampStyleConfig": {"additionalProperties": false, "properties": {"position": {"$ref": "#/$defs/TimestampPositionEnum", "default": "tl", "description": "Position of the timestamp on the image (tl/tr/bl/br).", "title": "Timestamp position"}, "format": {"default": "%m/%d/%Y %H:%M:%S", "description": "Datetime format string used for timestamps (Python datetime format codes).", "title": "Timestamp format", "type": "string"}, "color": {"$ref": "#/$defs/ColorConfig", "description": "RGB color values for the timestamp text (all values 0-255).", "title": "Timestamp color"}, "thickness": {"default": 2, "description": "Line thickness of the timestamp text.", "title": "Timestamp thickness", "type": "integer"}, "effect": {"anyOf": [{"$ref": "#/$defs/TimestampEffectEnum"}, {"type": "null"}], "default": null, "description": "Visual effect for the timestamp text (none, solid, shadow).", "title": "Timestamp effect"}}, "title": "TimestampStyleConfig", "type": "object"}, "TlsConfig": {"additionalProperties": false, "properties": {"enabled": {"default": true, "description": "Enable TLS for Frigate's web UI and API on the configured TLS port.", "title": "Enable TLS", "type": "boolean"}}, "title": "TlsConfig", "type": "object"}, "TriggerAction": {"enum": ["notification", "sub_label", "attribute"], "title": "TriggerAction", "type": "string"}, "TriggerConfig": {"additionalProperties": false, "properties": {"friendly_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Optional friendly name displayed in the UI for this trigger.", "title": "Friendly name"}, "enabled": {"default": true, "description": "Enable or disable this semantic search trigger.", "title": "Enable this trigger", "type": "boolean"}, "type": {"$ref": "#/$defs/TriggerType", "default": "description", "description": "Type of trigger: 'thumbnail' (match against image) or 'description' (match against text).", "title": "Trigger type"}, "data": {"description": "Text phrase or thumbnail ID to match against tracked objects.", "title": "Trigger content", "type": "string"}, "threshold": {"default": 0.8, "description": "Minimum similarity score (0-1) required to activate this trigger.", "exclusiveMinimum": 0.0, "maximum": 1.0, "title": "Trigger threshold", "type": "number"}, "actions": {"default": [], "description": "List of actions to execute when trigger matches (notification, sub_label, attribute).", "items": {"$ref": "#/$defs/TriggerAction"}, "title": "Trigger actions", "type": "array"}}, "required": ["data"], "title": "TriggerConfig", "type": "object"}, "TriggerType": {"enum": ["thumbnail", "description"], "title": "TriggerType", "type": "string"}, "UIConfig": {"additionalProperties": false, "properties": {"timezone": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Optional timezone to display across the UI (defaults to browser local time if unset).", "title": "Timezone"}, "time_format": {"$ref": "#/$defs/TimeFormatEnum", "default": "browser", "description": "Time format to use in the UI (browser, 12hour, or 24hour).", "title": "Time format"}, "unit_system": {"$ref": "#/$defs/UnitSystemEnum", "default": "metric", "description": "Unit system for display (metric or imperial) used in the UI and MQTT.", "title": "Unit system"}}, "title": "UIConfig", "type": "object"}, "UnitSystemEnum": {"enum": ["imperial", "metric"], "title": "UnitSystemEnum", "type": "string"}, "ZoneConfig": {"properties": {"friendly_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "A user-friendly name for the zone, displayed in the Frigate UI. If not set, a formatted version of the zone name will be used.", "title": "Zone name"}, "enabled": {"default": true, "description": "Enable or disable this zone. Disabled zones are ignored at runtime.", "title": "Enabled", "type": "boolean"}, "enabled_in_config": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "default": null, "title": "Keep track of original state of zone."}, "filters": {"additionalProperties": {"$ref": "#/$defs/FilterConfig"}, "description": "Filters to apply to objects within this zone. Used to reduce false positives or restrict which objects are considered present in the zone.", "title": "Zone filters", "type": "object"}, "coordinates": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "description": "Polygon coordinates that define the zone area. Can be a comma-separated string or a list of coordinate strings. Coordinates should be relative (0-1) or absolute (legacy).", "title": "Coordinates"}, "distances": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}, {"type": "null"}], "description": "Optional real-world distances for each side of the zone quadrilateral, used for speed or distance calculations. Must have exactly 4 values if set.", "title": "Real-world distances"}, "inertia": {"default": 3, "description": "Number of consecutive frames an object must be detected in the zone before it is considered present. Helps filter out transient detections.", "exclusiveMinimum": 0, "title": "Inertia frames", "type": "integer"}, "loitering_time": {"default": 0, "description": "Number of seconds an object must remain in the zone to be considered as loitering. Set to 0 to disable loitering detection.", "minimum": 0, "title": "Loitering seconds", "type": "integer"}, "speed_threshold": {"anyOf": [{"minimum": 0.1, "type": "number"}, {"type": "null"}], "default": null, "description": "Minimum speed (in real-world units if distances are set) required for an object to be considered present in the zone. Used for speed-based zone triggers.", "title": "Minimum speed"}, "objects": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "description": "List of object types (from labelmap) that can trigger this zone. Can be a string or a list of strings. If empty, all objects are considered.", "title": "Trigger objects"}}, "required": ["coordinates"], "title": "ZoneConfig", "type": "object"}, "ZoomingModeEnum": {"enum": ["disabled", "absolute", "relative"], "title": "ZoomingModeEnum", "type": "string"}}, "additionalProperties": false, "properties": {"version": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Numeric or string version of the active configuration to help detect migrations or format changes.", "title": "Current config version"}, "safe_mode": {"default": false, "description": "When enabled, start Frigate in safe mode with reduced features for troubleshooting.", "title": "Safe mode", "type": "boolean"}, "environment_vars": {"additionalProperties": {"type": "string"}, "description": "Key/value pairs of environment variables to set for the Frigate process in Home Assistant OS. Non-HAOS users must use Docker environment variable configuration instead.", "title": "Environment variables", "type": "object"}, "logger": {"$ref": "#/$defs/LoggerConfig", "description": "Controls default log verbosity and per-component log level overrides.", "title": "Logging"}, "auth": {"$ref": "#/$defs/AuthConfig", "description": "Authentication and session-related settings including cookie and rate limit options.", "title": "Authentication"}, "database": {"$ref": "#/$defs/DatabaseConfig", "description": "Settings for the SQLite database used by Frigate to store tracked object and recording metadata.", "title": "Database"}, "go2rtc": {"$ref": "#/$defs/RestreamConfig", "description": "Settings for the integrated go2rtc restreaming service used for live stream relaying and translation.", "title": "go2rtc"}, "mqtt": {"$ref": "#/$defs/MqttConfig", "description": "Settings for connecting and publishing telemetry, snapshots, and event details to an MQTT broker.", "title": "MQTT"}, "notifications": {"$ref": "#/$defs/NotificationConfig", "description": "Settings to enable and control notifications for all cameras; can be overridden per-camera.", "title": "Notifications"}, "networking": {"$ref": "#/$defs/NetworkingConfig", "description": "Network-related settings such as IPv6 enablement for Frigate endpoints.", "title": "Networking"}, "proxy": {"$ref": "#/$defs/ProxyConfig", "description": "Settings for integrating Frigate behind a reverse proxy that passes authenticated user headers.", "title": "Proxy"}, "telemetry": {"$ref": "#/$defs/TelemetryConfig", "description": "System telemetry and stats options including GPU and network bandwidth monitoring.", "title": "Telemetry"}, "tls": {"$ref": "#/$defs/TlsConfig", "description": "TLS settings for Frigate's web endpoints (port 8971).", "title": "TLS"}, "ui": {"$ref": "#/$defs/UIConfig", "description": "User interface preferences such as timezone, time/date formatting, and units.", "title": "UI"}, "detectors": {"additionalProperties": {"$ref": "#/$defs/BaseDetectorConfig"}, "default": {"cpu": {"type": "cpu"}}, "description": "Configuration for object detectors (CPU, GPU, ONNX backends) and any detector-specific model settings.", "title": "Detector hardware", "type": "object"}, "model": {"$ref": "#/$defs/ModelConfig", "description": "Settings to configure a custom object detection model and its input shape.", "title": "Detection model"}, "genai": {"additionalProperties": {"$ref": "#/$defs/GenAIConfig"}, "description": "Settings for integrated generative AI providers used to generate object descriptions and review summaries.", "title": "Generative AI configuration", "type": "object"}, "cameras": {"additionalProperties": {"$ref": "#/$defs/CameraConfig"}, "description": "Cameras", "title": "Cameras", "type": "object"}, "audio": {"$ref": "#/$defs/AudioConfig", "description": "Settings for audio-based event detection for all cameras; can be overridden per-camera.", "title": "Audio detection"}, "birdseye": {"$ref": "#/$defs/BirdseyeConfig", "description": "Settings for the Birdseye composite view that composes multiple camera feeds into a single layout.", "title": "Birdseye"}, "detect": {"$ref": "#/$defs/DetectConfig", "description": "Settings for the detection/detect role used to run object detection and initialize trackers.", "title": "Object Detection"}, "ffmpeg": {"$ref": "#/$defs/FfmpegConfig", "description": "FFmpeg settings including binary path, args, hwaccel options, and per-role output args.", "title": "FFmpeg"}, "live": {"$ref": "#/$defs/CameraLiveConfig", "description": "Settings to control the jsmpeg live stream resolution and quality. This does not affect restreamed cameras that use go2rtc for live view.", "title": "Live playback"}, "motion": {"anyOf": [{"$ref": "#/$defs/MotionConfig"}, {"type": "null"}], "default": null, "description": "Default motion detection settings applied to cameras unless overridden per-camera.", "title": "Motion detection"}, "objects": {"$ref": "#/$defs/ObjectConfig", "description": "Object tracking defaults including which labels to track and per-object filters.", "title": "Objects"}, "record": {"$ref": "#/$defs/RecordConfig", "description": "Recording and retention settings applied to cameras unless overridden per-camera.", "title": "Recording"}, "review": {"$ref": "#/$defs/ReviewConfig", "description": "Settings that control alerts, detections, and GenAI review summaries used by the UI and storage.", "title": "Review"}, "snapshots": {"$ref": "#/$defs/SnapshotsConfig", "description": "Settings for API-generated snapshots of tracked objects for all cameras; can be overridden per-camera.", "title": "Snapshots"}, "timestamp_style": {"$ref": "#/$defs/TimestampStyleConfig", "description": "Styling options for in-feed timestamps applied to debug view and snapshots.", "title": "Timestamp style"}, "audio_transcription": {"$ref": "#/$defs/AudioTranscriptionConfig", "description": "Settings for live and speech audio transcription used for events and live captions.", "title": "Audio transcription"}, "classification": {"$ref": "#/$defs/ClassificationConfig", "description": "Settings for classification models used to refine object labels or state classification.", "title": "Object classification"}, "semantic_search": {"$ref": "#/$defs/SemanticSearchConfig", "description": "Settings for Semantic Search which builds and queries object embeddings to find similar items.", "title": "Semantic Search"}, "face_recognition": {"$ref": "#/$defs/FaceRecognitionConfig", "description": "Settings for face detection and recognition for all cameras; can be overridden per-camera.", "title": "Face recognition"}, "lpr": {"$ref": "#/$defs/LicensePlateRecognitionConfig", "description": "License plate recognition settings including detection thresholds, formatting, and known plates.", "title": "License Plate Recognition"}, "camera_groups": {"additionalProperties": {"$ref": "#/$defs/CameraGroupConfig"}, "description": "Configuration for named camera groups used to organize cameras in the UI.", "title": "Camera groups", "type": "object"}, "profiles": {"additionalProperties": {"$ref": "#/$defs/ProfileDefinitionConfig"}, "description": "Named profile definitions with friendly names. Camera profiles must reference names defined here.", "title": "Profiles", "type": "object"}, "active_profile": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "Currently active profile name. Runtime-only, not persisted in YAML.", "title": "Active profile"}}, "required": ["mqtt", "cameras"], "title": "FrigateConfig", "type": "object"} \ No newline at end of file diff --git a/web/e2e/fixtures/mock-data/generate-mock-data.py b/web/e2e/fixtures/mock-data/generate-mock-data.py index aa96494b92..bba488d3d9 100644 --- a/web/e2e/fixtures/mock-data/generate-mock-data.py +++ b/web/e2e/fixtures/mock-data/generate-mock-data.py @@ -111,6 +111,18 @@ def generate_config(): return snapshot +def generate_config_schema(): + """Generate the JSON Schema for FrigateConfig from the backend model. + + This is what the app fetches from /api/config/schema.json to drive the + RJSF-based config form. Generating it here keeps the e2e fixture in sync + with the backend whenever config models change. + """ + from frigate.config import FrigateConfig + + return FrigateConfig.model_json_schema() + + def generate_reviews(): """Generate ReviewSegmentResponse[] validated against Pydantic + Peewee.""" from frigate.api.defs.response.review_response import ReviewSegmentResponse @@ -411,6 +423,7 @@ def main(): print() write_json("config-snapshot.json", generate_config()) + write_json("config-schema.json", generate_config_schema()) write_json("reviews.json", generate_reviews()) write_json("events.json", generate_events()) write_json("exports.json", generate_exports()) diff --git a/web/e2e/specs/settings/camera-ffmpeg-streams.spec.ts b/web/e2e/specs/settings/camera-ffmpeg-streams.spec.ts new file mode 100644 index 0000000000..38b0f5c4a6 --- /dev/null +++ b/web/e2e/specs/settings/camera-ffmpeg-streams.spec.ts @@ -0,0 +1,203 @@ +/** + * Camera ffmpeg streams settings tests -- MEDIUM tier. + * + * Covers the input-path source toggle: each ffmpeg input can either point at a + * go2rtc restream (picked from a dropdown, which writes the rtsp://127.0.0.1:8554 + * path plus the preset-rtsp-restream input_args) or use a manually typed path. + */ + +import { readFileSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { test, expect } from "../../fixtures/frigate-test"; +import type { Page } from "@playwright/test"; +import { configFactory } from "../../fixtures/mock-data/config"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const CONFIG_SCHEMA = JSON.parse( + readFileSync( + resolve(__dirname, "../../fixtures/mock-data/config-schema.json"), + "utf-8", + ), +); + +const GO2RTC_STREAMS = { + dome_main: ["rtsp://user:pass@192.168.0.20:554/Stream1"], + dome_sub: ["rtsp://user:pass@192.168.0.20:554/Stream2"], +}; + +type CameraInput = { + path: string; + roles: string[]; + input_args?: string; +}; + +async function installRoutes(page: Page, frontDoorInputs: CameraInput[]) { + const config = configFactory({ + go2rtc: { streams: GO2RTC_STREAMS }, + cameras: { + front_door: { + ffmpeg: { inputs: frontDoorInputs }, + }, + }, + }); + + let lastSavedConfig: unknown = null; + + await page.route("**/api/config/schema.json", (route) => + route.fulfill({ json: CONFIG_SCHEMA }), + ); + await page.route("**/api/config", (route) => { + if (route.request().method() === "GET") { + return route.fulfill({ json: config }); + } + return route.fulfill({ json: { success: true } }); + }); + await page.route("**/api/config/raw_paths", (route) => + route.fulfill({ + json: { + cameras: { front_door: { ffmpeg: { inputs: frontDoorInputs } } }, + go2rtc: { streams: GO2RTC_STREAMS }, + }, + }), + ); + await page.route("**/api/config/set", async (route) => { + lastSavedConfig = route.request().postDataJSON(); + await route.fulfill({ json: { success: true, require_restart: false } }); + }); + await page.route("**/api/ffmpeg/presets", (route) => + route.fulfill({ + json: { + hwaccel_args: [], + input_args: ["preset-rtsp-restream", "preset-rtsp-generic"], + output_args: { record: [], detect: [] }, + }, + }), + ); + + return { capturedConfig: () => lastSavedConfig }; +} + +const RESTREAM_RADIO = "Restream (go2rtc)"; +const MANUAL_RADIO = "Manual input path"; + +test.describe("camera ffmpeg input source toggle @medium", () => { + test("manual input defaults to the manual text field", async ({ + frigateApp, + }) => { + await installRoutes(frigateApp.page, [ + { path: "rtsp://10.0.0.1:554/video", roles: ["detect"] }, + ]); + await frigateApp.goto("/settings?page=cameraFfmpeg&camera=front_door"); + + await expect( + frigateApp.page.getByRole("radio", { name: MANUAL_RADIO }), + ).toBeChecked(); + await expect( + frigateApp.page.getByRole("textbox", { name: "Input path" }), + ).toHaveValue("rtsp://10.0.0.1:554/video"); + }); + + test("an existing restream path auto-detects into restream mode", async ({ + frigateApp, + }) => { + await installRoutes(frigateApp.page, [ + { + path: "rtsp://127.0.0.1:8554/dome_main", + roles: ["detect"], + input_args: "preset-rtsp-restream", + }, + ]); + await frigateApp.goto("/settings?page=cameraFfmpeg&camera=front_door"); + + await expect( + frigateApp.page.getByRole("radio", { name: RESTREAM_RADIO }), + ).toBeChecked(); + // The dropdown is preselected to the matching go2rtc stream. + await expect( + frigateApp.page.getByRole("combobox", { name: /go2rtc stream/i }), + ).toContainText("dome_main"); + }); + + test("selecting a restream writes the path and preset", async ({ + frigateApp, + }) => { + const capture = await installRoutes(frigateApp.page, [ + { path: "rtsp://10.0.0.1:554/video", roles: ["detect"] }, + ]); + await frigateApp.goto("/settings?page=cameraFfmpeg&camera=front_door"); + + await frigateApp.page.getByRole("radio", { name: RESTREAM_RADIO }).click(); + await frigateApp.page + .getByRole("combobox", { name: /go2rtc stream/i }) + .click(); + + // The dropdown is searchable: typing narrows the list to matches only, + // with no option to enter a custom stream name. + await frigateApp.page.getByPlaceholder("Search streams...").fill("sub"); + await expect( + frigateApp.page.getByRole("option", { name: "dome_main" }), + ).toBeHidden(); + await frigateApp.page.getByRole("option", { name: "dome_sub" }).click(); + + await frigateApp.page.getByRole("button", { name: "Save" }).click(); + + await expect + .poll(() => capture.capturedConfig(), { timeout: 5_000 }) + .toMatchObject({ + config_data: { + cameras: { + front_door: { + ffmpeg: { + inputs: [ + { + path: "rtsp://127.0.0.1:8554/dome_sub", + input_args: "preset-rtsp-restream", + }, + ], + }, + }, + }, + }, + }); + }); + + test("switching a restream back to manual reverts the preset", async ({ + frigateApp, + }) => { + const capture = await installRoutes(frigateApp.page, [ + { + path: "rtsp://127.0.0.1:8554/dome_main", + roles: ["detect"], + input_args: "preset-rtsp-restream", + }, + ]); + await frigateApp.goto("/settings?page=cameraFfmpeg&camera=front_door"); + + await frigateApp.page.getByRole("radio", { name: MANUAL_RADIO }).click(); + + // The restream path stays editable in the manual text field. + await expect( + frigateApp.page.getByRole("textbox", { name: "Input path" }), + ).toHaveValue("rtsp://127.0.0.1:8554/dome_main"); + + await frigateApp.page.getByRole("button", { name: "Save" }).click(); + + await expect + .poll(() => capture.capturedConfig(), { timeout: 5_000 }) + .not.toBeNull(); + + const payload = capture.capturedConfig() as { + config_data?: { + cameras?: { + front_door?: { + ffmpeg?: { inputs?: Array<{ input_args?: unknown }> }; + }; + }; + }; + }; + const input = + payload?.config_data?.cameras?.front_door?.ffmpeg?.inputs?.[0]; + expect(input?.input_args).not.toBe("preset-rtsp-restream"); + }); +}); diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index 9680f774e2..e4bd7ae517 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -1553,7 +1553,17 @@ } }, "cameraInputs": { - "itemTitle": "Stream {{index}}" + "itemTitle": "Stream {{index}}", + "sourceMode": { + "restream": "Restream (go2rtc)", + "manual": "Manual input path", + "go2rtcStreamLabel": "go2rtc stream", + "go2rtcStreamPlaceholder": "Select a go2rtc stream", + "noGo2rtcStreams": "No go2rtc streams configured", + "go2rtcStreamSearch": "Search streams...", + "availableStreams": "Available streams", + "noMatchingStreams": "No matching streams" + } }, "restartRequiredField": "Restart required", "restartRequiredFooter": "Configuration changed - Restart required", diff --git a/web/src/components/config-form/theme/fields/CameraInputsField.tsx b/web/src/components/config-form/theme/fields/CameraInputsField.tsx index ee19dbc95d..205e888c9e 100644 --- a/web/src/components/config-form/theme/fields/CameraInputsField.tsx +++ b/web/src/components/config-form/theme/fields/CameraInputsField.tsx @@ -29,11 +29,19 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; +import { StreamSourceSelector } from "./StreamSourceSelector"; +import { + buildRestreamPath, + parseRestreamStreamName, + RESTREAM_PRESET, + type StreamSourceMode, +} from "./streamSource"; type FfmpegInput = { path?: string; roles?: string[]; hwaccel_args?: unknown; + input_args?: unknown; }; const asInputList = (formData: unknown): FfmpegInput[] => { @@ -137,7 +145,30 @@ export function CameraInputsField(props: FieldProps) { ); const SchemaField = registry.fields.SchemaField; + const go2rtcStreamNames = useMemo(() => { + const streams = formContext?.fullConfig?.go2rtc?.streams; + if (!streams || typeof streams !== "object") { + return []; + } + return Object.keys(streams).sort(); + }, [formContext?.fullConfig?.go2rtc?.streams]); + const [openByIndex, setOpenByIndex] = useState>({}); + const [sourceModeByIndex, setSourceModeByIndex] = useState< + Record + >({}); + + // Detect whether an existing input path points at a known go2rtc restream so + // the source toggle can default to the right mode for existing configs. + const detectMode = useCallback( + (path: string | undefined): StreamSourceMode => { + const streamName = parseRestreamStreamName(path); + return streamName && go2rtcStreamNames.includes(streamName) + ? "restream" + : "manual"; + }, + [go2rtcStreamNames], + ); useEffect(() => { setOpenByIndex((previous) => { @@ -171,6 +202,55 @@ export function CameraInputsField(props: FieldProps) { [fieldPathId.path, inputs, onChange], ); + // Update several fields of one input in a single change so that path and + // input_args never race on a stale snapshot of inputs. + const handleFieldValuesChange = useCallback( + (index: number, partial: Record) => { + const nextInputs = cloneDeep(inputs); + const item = + (nextInputs[index] as Record | undefined) ?? + ({} as Record); + + Object.assign(item, partial); + nextInputs[index] = item; + + onChange(normalizeNonDetectHwaccel(nextInputs), fieldPathId.path); + }, + [fieldPathId.path, inputs, onChange], + ); + + const handleSourceModeChange = useCallback( + (index: number, nextMode: StreamSourceMode) => { + const input = inputs[index]; + const currentPath = + typeof input?.path === "string" ? input.path : undefined; + + if (nextMode === "manual") { + // Only revert the preset we set ourselves; never clobber custom args. + if (input?.input_args === RESTREAM_PRESET) { + handleFieldValuesChange(index, { input_args: undefined }); + } + } else if (!parseRestreamStreamName(currentPath)) { + // Entering restream with a non-restream path: clear it so the dropdown + // shows its placeholder until a stream is chosen. + handleFieldValuesChange(index, { path: undefined }); + } + + setSourceModeByIndex((previous) => ({ ...previous, [index]: nextMode })); + }, + [inputs, handleFieldValuesChange], + ); + + const handleSelectRestreamStream = useCallback( + (index: number, streamName: string) => { + handleFieldValuesChange(index, { + path: buildRestreamPath(streamName), + input_args: RESTREAM_PRESET, + }); + }, + [handleFieldValuesChange], + ); + const handleAddInput = useCallback(() => { const base = itemSchema ? (applySchemaDefaults(itemSchema) as FfmpegInput) @@ -186,8 +266,9 @@ export function CameraInputsField(props: FieldProps) { (_, currentIndex) => currentIndex !== index, ); onChange(nextInputs, fieldPathId.path); - setOpenByIndex((previous) => { - const next: Record = {}; + + const reindex = (previous: Record): Record => { + const next: Record = {}; Object.entries(previous).forEach(([key, value]) => { const current = Number(key); if (Number.isNaN(current) || current === index) { @@ -197,7 +278,10 @@ export function CameraInputsField(props: FieldProps) { next[current > index ? current - 1 : current] = value; }); return next; - }); + }; + + setOpenByIndex(reindex); + setSourceModeByIndex(reindex); }, [fieldPathId.path, inputs, onChange], ); @@ -354,16 +438,32 @@ export function CameraInputsField(props: FieldProps) {
- {renderField(index, "path", { - extraUiSchema: { - "ui:widget": "CameraPathWidget", - "ui:options": { - size: "full", - splitLayout: false, + + handleSourceModeChange(index, nextMode) + } + streamNames={go2rtcStreamNames} + selectedStreamName={ + parseRestreamStreamName(input.path) ?? "" + } + onSelectStream={(streamName) => + handleSelectRestreamStream(index, streamName) + } + manualField={renderField(index, "path", { + extraUiSchema: { + "ui:widget": "CameraPathWidget", + "ui:options": { + size: "full", + splitLayout: false, + }, }, - }, - showSchemaDescription: true, - })} + showSchemaDescription: true, + })} + disabled={disabled} + readonly={readonly} + />
{renderField(index, "roles")}
diff --git a/web/src/components/config-form/theme/fields/StreamSourceSelector.tsx b/web/src/components/config-form/theme/fields/StreamSourceSelector.tsx new file mode 100644 index 0000000000..4bcfeac58c --- /dev/null +++ b/web/src/components/config-form/theme/fields/StreamSourceSelector.tsx @@ -0,0 +1,217 @@ +import type { ReactNode } from "react"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { cn } from "@/lib/utils"; +import { Check, ChevronsUpDown } from "lucide-react"; +import type { StreamSourceMode } from "./streamSource"; + +type Go2rtcStreamComboboxProps = { + id: string; + value: string; + options: string[]; + disabled?: boolean; + onSelect: (streamName: string) => void; +}; + +// Searchable dropdown of existing go2rtc streams +function Go2rtcStreamCombobox({ + id, + value, + options, + disabled, + onSelect, +}: Go2rtcStreamComboboxProps) { + const { t } = useTranslation(["views/settings", "common"]); + const [open, setOpen] = useState(false); + const [searchValue, setSearchValue] = useState(""); + + const commit = (next: string) => { + onSelect(next); + setSearchValue(""); + setOpen(false); + }; + + return ( + { + setOpen(next); + if (!next) setSearchValue(""); + }} + > + + + + + + + + + {t("configForm.cameraInputs.sourceMode.noMatchingStreams")} + + + {options.map((option) => ( + commit(option)} + > + + {option} + + ))} + + + + + + ); +} + +type StreamSourceSelectorProps = { + idPrefix: string; + mode: StreamSourceMode; + onModeChange: (mode: StreamSourceMode) => void; + streamNames: string[]; + selectedStreamName: string; + onSelectStream: (streamName: string) => void; + manualField: ReactNode; + disabled?: boolean; + readonly?: boolean; +}; + +export function StreamSourceSelector({ + idPrefix, + mode, + onModeChange, + streamNames, + selectedStreamName, + onSelectStream, + manualField, + disabled, + readonly, +}: StreamSourceSelectorProps) { + const { t } = useTranslation(["views/settings", "common"]); + + const restreamId = `${idPrefix}-source-restream`; + const manualId = `${idPrefix}-source-manual`; + const selectId = `${idPrefix}-restream-select`; + + const hasStreams = streamNames.length > 0; + const isDisabled = disabled || readonly; + + return ( +
+ onModeChange(value as StreamSourceMode)} + className="flex flex-col gap-2 sm:flex-row sm:gap-6" + disabled={isDisabled} + > +
+ + +
+
+ + +
+
+ + {mode === "restream" ? ( +
+ + {hasStreams ? ( + + ) : ( +

+ {t("configForm.cameraInputs.sourceMode.noGo2rtcStreams")} +

+ )} +
+ ) : ( + manualField + )} +
+ ); +} + +export default StreamSourceSelector; diff --git a/web/src/components/config-form/theme/fields/streamSource.ts b/web/src/components/config-form/theme/fields/streamSource.ts new file mode 100644 index 0000000000..142f2b2ac9 --- /dev/null +++ b/web/src/components/config-form/theme/fields/streamSource.ts @@ -0,0 +1,33 @@ +export type StreamSourceMode = "restream" | "manual"; + +// The literal go2rtc restream prefix matches what the camera wizard inlines +// when it builds a restreamed input path. Only this exact host:port is treated +// as a restream so manually typed URLs (including localhost) stay manual. +export const RESTREAM_PREFIX = "rtsp://127.0.0.1:8554/"; +export const RESTREAM_PRESET = "preset-rtsp-restream"; + +/** Build the restream input path for a given go2rtc stream name. */ +export function buildRestreamPath(streamName: string): string { + return `${RESTREAM_PREFIX}${streamName}`; +} + +/** + * Extract the go2rtc stream name from a restream input path. + * + * Returns the stream name when the path is a well-formed restream URL with no + * extra path segments or query, otherwise undefined. + */ +export function parseRestreamStreamName( + path: string | undefined, +): string | undefined { + if (typeof path !== "string" || !path.startsWith(RESTREAM_PREFIX)) { + return undefined; + } + + const name = path.slice(RESTREAM_PREFIX.length); + if (name.length === 0 || /[/?#]/.test(name)) { + return undefined; + } + + return name; +} From 8203e39b7f3408e348e84d717b98bf6f9b40f6d9 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Wed, 17 Jun 2026 09:10:23 -0500 Subject: [PATCH 23/92] add go2rtc settings section to the save all flow (#23501) --- web/src/pages/Settings.tsx | 106 ++++++- .../settings/Go2RtcStreamsSettingsView.tsx | 258 +++++++++++------- 2 files changed, 259 insertions(+), 105 deletions(-) diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index f49c657b40..6f445f0c47 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -34,6 +34,8 @@ import { isMobile } from "react-device-detect"; import { FaVideo } from "react-icons/fa"; import { CameraConfig, FrigateConfig } from "@/types/frigateConfig"; import type { ConfigSectionData, JsonObject } from "@/types/configForm"; +import isEqual from "lodash/isEqual"; +import { maskCredentials } from "@/utils/credentialMask"; import useSWR from "swr"; import FilterSwitch from "@/components/filter/FilterSwitch"; import { ZoneMaskFilterButton } from "@/components/filter/ZoneMaskFilter"; @@ -660,6 +662,11 @@ export default function Settings() { const isAdmin = useIsAdmin(); + // for unmasked go2rtc stream sources + const { data: rawPaths } = useSWR<{ + go2rtc: { streams: Record }; + }>(isAdmin ? "config/raw_paths" : null); + const visibleSettingsViews = !isAdmin ? ALLOWED_VIEWS_FOR_VIEWER : allSettingsViews; @@ -788,6 +795,40 @@ export default function Settings() { }, ); + // go2rtc streams aren't schema-backed, so build their preview items directly + if ("go2rtc_streams" in pendingDataBySection) { + const live = + (pendingDataBySection["go2rtc_streams"] as Record) ?? + {}; + const saved: Record = {}; + for (const [name, urls] of Object.entries( + rawPaths?.go2rtc?.streams ?? {}, + )) { + saved[name] = Array.isArray(urls) ? urls : [urls]; + } + + // Added or changed streams + for (const [name, urls] of Object.entries(live)) { + if (name in saved && isEqual(urls, saved[name])) continue; + const masked = urls.map((url) => maskCredentials(url)); + items.push({ + scope: "global", + fieldPath: `go2rtc.streams.${name}`, + value: masked.length === 1 ? masked[0] : masked, + }); + } + + // Deleted streams (present in saved config, absent from pending) + for (const name of Object.keys(saved)) { + if (name in live) continue; + items.push({ + scope: "global", + fieldPath: `go2rtc.streams.${name}`, + value: "", + }); + } + } + return items.sort((left, right) => { const scopeCompare = left.scope.localeCompare(right.scope); if (scopeCompare !== 0) return scopeCompare; @@ -797,7 +838,13 @@ export default function Settings() { if (cameraCompare !== 0) return cameraCompare; return left.fieldPath.localeCompare(right.fieldPath); }); - }, [config, fullSchema, pendingDataBySection, profileFriendlyNames]); + }, [ + config, + fullSchema, + pendingDataBySection, + profileFriendlyNames, + rawPaths, + ]); // Map a pendingDataKey to SettingsType menu key for clearing section status const pendingKeyToMenuKey = useCallback( @@ -869,10 +916,7 @@ export default function Settings() { // after `mutate("config")` resolves const keysToClear: string[] = []; - // `detectors` and `model` are owned by DetectorsAndModelSettingsView, - // which saves them atomically (single combined PUT with a pre-clear when - // detector keys change or the Plus/Custom tab flips). Doing the same here - // keeps Save All consistent with the page's own Save button + // `detectors` and `model` are owned by DetectorsAndModelSettingsView const hasPendingDetectors = "detectors" in pendingDataBySection; const hasPendingModel = "model" in pendingDataBySection; if (hasPendingDetectors || hasPendingModel) { @@ -975,8 +1019,58 @@ export default function Settings() { } } + // go2rtc streams are owned by Go2RtcStreamsSettingsView + if ("go2rtc_streams" in pendingDataBySection) { + try { + const liveStreams = + (pendingDataBySection["go2rtc_streams"] as Record< + string, + string[] + >) ?? {}; + const streamsPayload: Record = { + ...liveStreams, + }; + const deletedStreamNames = Object.keys( + config.go2rtc?.streams ?? {}, + ).filter((name) => !(name in liveStreams)); + for (const deleted of deletedStreamNames) { + streamsPayload[deleted] = ""; + } + + await axios.put("config/set", { + requires_restart: 0, + config_data: { go2rtc: { streams: streamsPayload } }, + }); + + // Update the running go2rtc instance to match + const go2rtcUpdates: Promise[] = []; + for (const [streamName, urls] of Object.entries(liveStreams)) { + if (urls[0]) { + go2rtcUpdates.push( + axios.put( + `go2rtc/streams/${streamName}?src=${encodeURIComponent(urls[0])}`, + ), + ); + } + } + for (const deleted of deletedStreamNames) { + go2rtcUpdates.push(axios.delete(`go2rtc/streams/${deleted}`)); + } + await Promise.allSettled(go2rtcUpdates); + + keysToClear.push("go2rtc_streams"); + savedKeys.push("go2rtc_streams"); + successCount++; + } catch (error) { + // eslint-disable-next-line no-console + console.error("Save All – error saving go2rtc streams", error); + failCount++; + } + } + const pendingKeys = Object.keys(pendingDataBySection).filter( - (key) => key !== "detectors" && key !== "model", + (key) => + key !== "detectors" && key !== "model" && key !== "go2rtc_streams", ); for (const key of pendingKeys) { diff --git a/web/src/views/settings/Go2RtcStreamsSettingsView.tsx b/web/src/views/settings/Go2RtcStreamsSettingsView.tsx index 0dabbebc05..49d3539713 100644 --- a/web/src/views/settings/Go2RtcStreamsSettingsView.tsx +++ b/web/src/views/settings/Go2RtcStreamsSettingsView.tsx @@ -58,8 +58,13 @@ import { DialogTitle, } from "@/components/ui/dialog"; import ActivityIndicator from "@/components/indicators/activity-indicator"; +import SaveAllPreviewPopover, { + type SaveAllPreviewItem, +} from "@/components/overlay/detail/SaveAllPreviewPopover"; import { useDocDomain } from "@/hooks/use-doc-domain"; import { FrigateConfig } from "@/types/frigateConfig"; +import type { SettingsPageProps } from "@/views/settings/SingleSectionPage"; +import type { ConfigSectionData } from "@/types/configForm"; import { cn } from "@/lib/utils"; import { isMaskedPath, @@ -85,18 +90,8 @@ type RawPathsResponse = { go2rtc: { streams: Record }; }; -type Go2RtcStreamsSettingsViewProps = { - setUnsavedChanges: React.Dispatch>; - onSectionStatusChange?: ( - sectionKey: string, - level: "global" | "camera", - status: { - hasChanges: boolean; - isOverridden: boolean; - hasValidationErrors: boolean; - }, - ) => void; -}; +const SECTION_KEY = "go2rtc_streams"; +const EMPTY_PENDING: Record = {}; const STREAM_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/; @@ -114,7 +109,11 @@ function normalizeStreams( export default function Go2RtcStreamsSettingsView({ setUnsavedChanges, onSectionStatusChange, -}: Go2RtcStreamsSettingsViewProps) { + pendingDataBySection, + onPendingDataChange, + isSavingAll, + onSectionSavingChange, +}: SettingsPageProps) { const { t } = useTranslation(["views/settings", "common"]); const { getLocaleDocUrl } = useDocDomain(); const { data: config, mutate: updateConfig } = @@ -122,13 +121,6 @@ export default function Go2RtcStreamsSettingsView({ const { data: rawPaths, mutate: updateRawPaths } = useSWR("config/raw_paths"); - const [editedStreams, setEditedStreams] = useState>( - {}, - ); - const [serverStreams, setServerStreams] = useState>( - {}, - ); - const [initialized, setInitialized] = useState(false); const [isLoading, setIsLoading] = useState(false); const [credentialVisibility, setCredentialVisibility] = useState< Record @@ -138,34 +130,51 @@ export default function Go2RtcStreamsSettingsView({ const [addStreamDialogOpen, setAddStreamDialogOpen] = useState(false); const [newlyAdded, setNewlyAdded] = useState>(new Set()); - // Initialize from config — wait for both config and rawPaths to avoid - // a mismatch when rawPaths arrives after config with different data - useEffect(() => { - if (!config || !rawPaths) return; + const childPending = pendingDataBySection ?? EMPTY_PENDING; - // Always use rawPaths for go2rtc streams — the /config endpoint masks - // credentials, so using config.go2rtc.streams would save masked values - const normalized = normalizeStreams(rawPaths.go2rtc?.streams); + // Saved/server state. Always read from rawPaths + const serverStreams = useMemo>( + () => normalizeStreams(rawPaths?.go2rtc?.streams), + [rawPaths], + ); - setServerStreams(normalized); - if (!initialized) { - setEditedStreams(normalized); - setInitialized(true); - } - }, [config, rawPaths, initialized]); + // Pending edits live in the parent's store so they survive navigation; fall back to saved state + const liveStreams = useMemo>( + () => + (childPending[SECTION_KEY] as Record | undefined) ?? + serverStreams, + [childPending, serverStreams], + ); + + // Persist edits to the parent store, clearing the entry when an edit returns + // the section to its saved state so Save All and the sidebar dot reset cleanly. + const commitStreams = useCallback( + (next: Record) => { + if (isEqual(next, serverStreams)) { + onPendingDataChange?.(SECTION_KEY, undefined, null); + } else { + onPendingDataChange?.( + SECTION_KEY, + undefined, + next as ConfigSectionData, + ); + } + }, + [serverStreams, onPendingDataChange], + ); // Track unsaved changes const hasChanges = useMemo( - () => initialized && !isEqual(editedStreams, serverStreams), - [editedStreams, serverStreams, initialized], + () => !isEqual(liveStreams, serverStreams), + [liveStreams, serverStreams], ); useEffect(() => { - setUnsavedChanges(hasChanges); + setUnsavedChanges?.(hasChanges); }, [hasChanges, setUnsavedChanges]); const hasValidationErrors = useMemo(() => { - const names = Object.keys(editedStreams); + const names = Object.keys(liveStreams); const seenNames = new Set(); for (const name of names) { @@ -173,13 +182,43 @@ export default function Go2RtcStreamsSettingsView({ if (seenNames.has(name)) return true; seenNames.add(name); - const urls = editedStreams[name]; + const urls = liveStreams[name]; if (!urls || urls.length === 0 || urls.every((u) => !u.trim())) return true; } return false; - }, [editedStreams]); + }, [liveStreams]); + + // Pending changes for this section's Save All preview popover. Diff the + // pending streams against the saved state and mask credentials for display. + const sectionPreviewItems = useMemo(() => { + if (!hasChanges) return []; + const items: SaveAllPreviewItem[] = []; + + // Added or changed streams + for (const [name, urls] of Object.entries(liveStreams)) { + if (name in serverStreams && isEqual(urls, serverStreams[name])) continue; + const masked = urls.map((url) => maskCredentials(url)); + items.push({ + scope: "global", + fieldPath: `go2rtc.streams.${name}`, + value: masked.length === 1 ? masked[0] : masked, + }); + } + + // Deleted streams (present in saved config, absent from pending) + for (const name of Object.keys(serverStreams)) { + if (name in liveStreams) continue; + items.push({ + scope: "global", + fieldPath: `go2rtc.streams.${name}`, + value: "", + }); + } + + return items; + }, [hasChanges, liveStreams, serverStreams]); // Report status to parent for sidebar red dot useEffect(() => { @@ -193,13 +232,14 @@ export default function Go2RtcStreamsSettingsView({ // Save handler const saveToConfig = useCallback(async () => { setIsLoading(true); + onSectionSavingChange?.(true); try { const streamsPayload: Record = { - ...editedStreams, + ...liveStreams, }; const deletedStreamNames = Object.keys(serverStreams).filter( - (name) => !(name in editedStreams), + (name) => !(name in liveStreams), ); for (const deleted of deletedStreamNames) { streamsPayload[deleted] = ""; @@ -212,7 +252,7 @@ export default function Go2RtcStreamsSettingsView({ // Update running go2rtc instance const go2rtcUpdates: Promise[] = []; - for (const [streamName, urls] of Object.entries(editedStreams)) { + for (const [streamName, urls] of Object.entries(liveStreams)) { if (urls[0]) { go2rtcUpdates.push( axios.put( @@ -233,9 +273,9 @@ export default function Go2RtcStreamsSettingsView({ }), ); - setServerStreams(editedStreams); - updateConfig(); - updateRawPaths(); + await updateConfig(); + await updateRawPaths(); + onPendingDataChange?.(SECTION_KEY, undefined, null); } catch { toast.error( t("toast.error", { @@ -245,74 +285,86 @@ export default function Go2RtcStreamsSettingsView({ ); } finally { setIsLoading(false); + onSectionSavingChange?.(false); } - }, [editedStreams, serverStreams, t, updateConfig, updateRawPaths]); + }, [ + liveStreams, + serverStreams, + t, + updateConfig, + updateRawPaths, + onPendingDataChange, + onSectionSavingChange, + ]); // Reset handler const onReset = useCallback(() => { - setEditedStreams(serverStreams); + onPendingDataChange?.(SECTION_KEY, undefined, null); setCredentialVisibility({}); - }, [serverStreams]); + }, [onPendingDataChange]); // Stream CRUD operations - const addStream = useCallback((name: string) => { - setEditedStreams((prev) => ({ ...prev, [name]: [""] })); - setNewlyAdded((prev) => new Set(prev).add(name)); - setAddStreamDialogOpen(false); - }, []); + const addStream = useCallback( + (name: string) => { + commitStreams({ ...liveStreams, [name]: [""] }); + setNewlyAdded((prev) => new Set(prev).add(name)); + setAddStreamDialogOpen(false); + }, + [liveStreams, commitStreams], + ); - const deleteStream = useCallback((streamName: string) => { - setEditedStreams((prev) => { - const { [streamName]: _, ...rest } = prev; - return rest; - }); - setDeleteDialog(null); - }, []); + const deleteStream = useCallback( + (streamName: string) => { + const { [streamName]: _removed, ...rest } = liveStreams; + commitStreams(rest); + setDeleteDialog(null); + }, + [liveStreams, commitStreams], + ); - const renameStream = useCallback((oldName: string, newName: string) => { - if (oldName === newName || !newName.trim()) return; + const renameStream = useCallback( + (oldName: string, newName: string) => { + if (oldName === newName || !newName.trim()) return; + if (!(oldName in liveStreams)) return; - setEditedStreams((prev) => { - const urls = prev[oldName]; - if (!urls) return prev; - - const entries = Object.entries(prev); const result: Record = {}; - for (const [key, value] of entries) { - if (key === oldName) { - result[newName] = value; - } else { - result[key] = value; - } + for (const [key, value] of Object.entries(liveStreams)) { + result[key === oldName ? newName : key] = value; } - return result; - }); - }, []); + commitStreams(result); + }, + [liveStreams, commitStreams], + ); const updateUrl = useCallback( (streamName: string, urlIndex: number, newUrl: string) => { - setEditedStreams((prev) => { - const urls = [...(prev[streamName] || [])]; - urls[urlIndex] = newUrl; - return { ...prev, [streamName]: urls }; - }); + const urls = [...(liveStreams[streamName] || [])]; + urls[urlIndex] = newUrl; + commitStreams({ ...liveStreams, [streamName]: urls }); }, - [], + [liveStreams, commitStreams], ); - const addUrl = useCallback((streamName: string) => { - setEditedStreams((prev) => { - const urls = [...(prev[streamName] || []), ""]; - return { ...prev, [streamName]: urls }; - }); - }, []); + const addUrl = useCallback( + (streamName: string) => { + const urls = [...(liveStreams[streamName] || []), ""]; + commitStreams({ ...liveStreams, [streamName]: urls }); + }, + [liveStreams, commitStreams], + ); - const removeUrl = useCallback((streamName: string, urlIndex: number) => { - setEditedStreams((prev) => { - const urls = (prev[streamName] || []).filter((_, i) => i !== urlIndex); - return { ...prev, [streamName]: urls.length > 0 ? urls : [""] }; - }); - }, []); + const removeUrl = useCallback( + (streamName: string, urlIndex: number) => { + const urls = (liveStreams[streamName] || []).filter( + (_, i) => i !== urlIndex, + ); + commitStreams({ + ...liveStreams, + [streamName]: urls.length > 0 ? urls : [""], + }); + }, + [liveStreams, commitStreams], + ); const toggleCredentialVisibility = useCallback((key: string) => { setCredentialVisibility((prev) => ({ ...prev, [key]: !prev[key] })); @@ -320,7 +372,7 @@ export default function Go2RtcStreamsSettingsView({ if (!config) return null; - const streamEntries = Object.entries(editedStreams); + const streamEntries = Object.entries(liveStreams); return (
@@ -391,6 +443,12 @@ export default function Go2RtcStreamsSettingsView({ {t("unsavedChanges")} +
)}
@@ -398,7 +456,7 @@ export default function Go2RtcStreamsSettingsView({
diff --git a/web/src/components/config-form/theme/widgets/ZoneSwitchesWidget.tsx b/web/src/components/config-form/theme/widgets/ZoneSwitchesWidget.tsx index 0acb83b785..94ca405126 100644 --- a/web/src/components/config-form/theme/widgets/ZoneSwitchesWidget.tsx +++ b/web/src/components/config-form/theme/widgets/ZoneSwitchesWidget.tsx @@ -29,8 +29,8 @@ function getZoneDisplayName(zoneName: string, context?: FormContext): string { } } } - // Fallback to cleaning up the zone name - return String(zoneName).replace(/_/g, " "); + // Fallback to the raw zone id verbatim (no friendly_name available) + return String(zoneName); } export function ZoneSwitchesWidget(props: WidgetProps) { diff --git a/web/src/components/overlay/detail/TrackingDetails.tsx b/web/src/components/overlay/detail/TrackingDetails.tsx index 66843d3e4f..c0f109e165 100644 --- a/web/src/components/overlay/detail/TrackingDetails.tsx +++ b/web/src/components/overlay/detail/TrackingDetails.tsx @@ -1197,14 +1197,7 @@ function LifecycleIconRow({ backgroundColor: `rgb(${color})`, }} /> - - {item.data?.zones_friendly_names?.[zidx]} - + {item.data?.zones_friendly_names?.[zidx]} ); })} diff --git a/web/src/hooks/use-zone-friendly-name.ts b/web/src/hooks/use-zone-friendly-name.ts index 18ce2a45c5..e827a215a8 100644 --- a/web/src/hooks/use-zone-friendly-name.ts +++ b/web/src/hooks/use-zone-friendly-name.ts @@ -7,12 +7,12 @@ export function resolveZoneName( zoneId: string, cameraId?: string, ) { - if (!config) return String(zoneId).replace(/_/g, " "); + if (!config) return String(zoneId); if (cameraId) { const camera = config.cameras?.[String(cameraId)]; const zone = camera?.zones?.[zoneId]; - return zone?.friendly_name || String(zoneId).replace(/_/g, " "); + return zone?.friendly_name || String(zoneId); } for (const camKey in config.cameras) { @@ -21,12 +21,12 @@ export function resolveZoneName( if (!cam?.zones) continue; if (Object.prototype.hasOwnProperty.call(cam.zones, zoneId)) { const zone = cam.zones[zoneId]; - return zone?.friendly_name || String(zoneId).replace(/_/g, " "); + return zone?.friendly_name || String(zoneId); } } - // Fallback: return a cleaned-up zoneId string - return String(zoneId).replace(/_/g, " "); + // Fallback: display the raw zone id verbatim (no friendly_name available) + return String(zoneId); } export function useZoneFriendlyName(zoneId: string, cameraId?: string): string { From 5003ab895c9802bc5c292da665eab7132b7f5eba Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Fri, 19 Jun 2026 16:50:19 -0500 Subject: [PATCH 26/92] add camera search, select-all/clear, and group selection to the multi-camera export dialog (#23516) --- web/public/locales/en/components/dialog.json | 7 + web/src/components/overlay/ExportDialog.tsx | 200 ++++++++++++++++++- 2 files changed, 198 insertions(+), 9 deletions(-) diff --git a/web/public/locales/en/components/dialog.json b/web/public/locales/en/components/dialog.json index 3630d68e09..b73e0ca3f9 100644 --- a/web/public/locales/en/components/dialog.json +++ b/web/public/locales/en/components/dialog.json @@ -70,6 +70,13 @@ "selectFromTimeline": "Select from Timeline", "cameraSelection": "Cameras", "cameraSelectionHelp": "Cameras with tracked objects in this time range are pre-selected", + "searchOrSelectGroup": "Search, or select a camera group...", + "selectAll": "Select all cameras", + "clearSelection": "Clear selection", + "selectWithActivity": "Cameras with tracked objects", + "selectGroup": "Select group", + "noMatchingCameras": "No cameras match your search", + "selectedCount": "{{selected}} / {{total}} selected", "checkingActivity": "Checking camera activity...", "noCameras": "No cameras available", "detectionCount_one": "1 tracked object", diff --git a/web/src/components/overlay/ExportDialog.tsx b/web/src/components/overlay/ExportDialog.tsx index 3419f199f9..f4a367dfda 100644 --- a/web/src/components/overlay/ExportDialog.tsx +++ b/web/src/components/overlay/ExportDialog.tsx @@ -39,6 +39,16 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; +import { + Command, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from "../ui/command"; +import { IconRenderer } from "../icons/IconPicker"; +import * as LuIcons from "react-icons/lu"; import { isDesktop, isMobile } from "react-device-detect"; import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer"; import SaveExportOverlay from "./SaveExportOverlay"; @@ -376,6 +386,9 @@ export function ExportContent({ const [newCaseName, setNewCaseName] = useState(""); const [newCaseDescription, setNewCaseDescription] = useState(""); const [isStartingBatchExport, setIsStartingBatchExport] = useState(false); + const [cameraSearch, setCameraSearch] = useState(""); + const [cameraMenuOpen, setCameraMenuOpen] = useState(false); + const cameraMenuRef = useRef(null); const multiRangeKey = useMemo(() => { if (activeTab !== "multi" || !range) { return undefined; @@ -577,6 +590,75 @@ export function ExportContent({ ); }, []); + const availableCameraIds = useMemo( + () => cameraActivities.map((activity) => activity.camera), + [cameraActivities], + ); + + const activeCameraIds = useMemo( + () => + cameraActivities + .filter((activity) => activity.hasDetections) + .map((activity) => activity.camera), + [cameraActivities], + ); + + const cameraGroups = useMemo( + () => + Object.entries(config?.camera_groups ?? {}) + .map(([name, group]) => ({ + name, + icon: group.icon, + order: group.order, + cameras: group.cameras.filter((cameraId) => + availableCameraIds.includes(cameraId), + ), + })) + .filter((group) => group.cameras.length > 0) + .sort((a, b) => a.order - b.order), + [config?.camera_groups, availableCameraIds], + ); + + // Filter the rendered camera cards by the search query + const filteredCameraActivities = useMemo(() => { + const query = cameraSearch.trim().toLowerCase(); + if (!query) { + return cameraActivities; + } + return cameraActivities.filter((activity) => { + const friendlyName = resolveCameraName(config, activity.camera); + return ( + activity.camera.toLowerCase().includes(query) || + friendlyName.toLowerCase().includes(query) + ); + }); + }, [cameraActivities, cameraSearch, config]); + + // Group/all/activity selection replaces the current selection + const applyCameraSelection = useCallback((cameraIds: string[]) => { + setHasManualCameraSelection(true); + setSelectedCameraIds(cameraIds); + setCameraMenuOpen(false); + }, []); + + // Close the dropdown when focus leaves the camera selection control entirely + const handleCameraInputBlur = useCallback((event: React.FocusEvent) => { + if ( + cameraMenuRef.current && + !cameraMenuRef.current.contains(event.relatedTarget as Node) + ) { + setCameraMenuOpen(false); + } + }, []); + + // Reset the search and dropdown when leaving the multi-camera tab + useEffect(() => { + if (activeTab !== "multi") { + setCameraSearch(""); + setCameraMenuOpen(false); + } + }, [activeTab]); + const startBatchExport = useCallback(async () => { if (isStartingBatchExport) { return; @@ -802,7 +884,7 @@ export function ExportContent({ {isAdmin && (
-
)} From 5f6043aa92ae9033203c415a762aed02de3b26c3 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sun, 12 Jul 2026 06:48:14 -0500 Subject: [PATCH 83/92] Fix enabled flag for custom classification models (#23681) * honor enabled flag for custom classification models for both startup and dynamically, even though the UI doesn't currently have a way to toggle dynamically * add test --- frigate/embeddings/maintainer.py | 53 ++++++---- frigate/test/test_classification_enabled.py | 106 ++++++++++++++++++++ 2 files changed, 141 insertions(+), 18 deletions(-) create mode 100644 frigate/test/test_classification_enabled.py diff --git a/frigate/embeddings/maintainer.py b/frigate/embeddings/maintainer.py index c4f79d5736..042c2722c2 100644 --- a/frigate/embeddings/maintainer.py +++ b/frigate/embeddings/maintainer.py @@ -200,6 +200,9 @@ class EmbeddingMaintainer(threading.Thread): ) for model_config in self.config.classification.custom.values(): + if not model_config.enabled: + continue + self.realtime_processors.append( CustomStateClassificationProcessor( self.config, model_config, self.requestor, self.metrics @@ -332,6 +335,25 @@ class EmbeddingMaintainer(threading.Thread): for processor in self.post_processors: processor.update_config(topic, payload) + def _remove_custom_classification_processor(self, model_name: str) -> None: + """Shut down and drop any running processor for a custom model.""" + remaining = [] + for processor in self.realtime_processors: + if ( + isinstance( + processor, + ( + CustomStateClassificationProcessor, + CustomObjectClassificationProcessor, + ), + ) + and processor.model_config.name == model_name + ): + processor.shutdown() + else: + remaining.append(processor) + self.realtime_processors = remaining + def _handle_custom_classification_update( self, topic: str, model_config: Any ) -> None: @@ -339,23 +361,7 @@ class EmbeddingMaintainer(threading.Thread): model_name = topic.split("/")[-1] if model_config is None: - remaining = [] - for processor in self.realtime_processors: - if ( - isinstance( - processor, - ( - CustomStateClassificationProcessor, - CustomObjectClassificationProcessor, - ), - ) - and processor.model_config.name == model_name - ): - processor.shutdown() - else: - remaining.append(processor) - self.realtime_processors = remaining - + self._remove_custom_classification_processor(model_name) logger.info( f"Successfully removed classification processor for model: {model_name}" ) @@ -363,6 +369,13 @@ class EmbeddingMaintainer(threading.Thread): self.config.classification.custom[model_name] = model_config + # A disabled model must not run; tear down any existing processor and + # do not register a new one. + if not model_config.enabled: + self._remove_custom_classification_processor(model_name) + logger.info(f"Disabled classification processor for model: {model_name}") + return + # Check if processor already exists for processor in self.realtime_processors: if isinstance( @@ -702,7 +715,11 @@ class EmbeddingMaintainer(threading.Thread): and "license_plate" not in camera_config.objects.track ) - if not dedicated_lpr_enabled and len(self.config.classification.custom) == 0: + has_enabled_custom = any( + c.enabled for c in self.config.classification.custom.values() + ) + + if not dedicated_lpr_enabled and not has_enabled_custom: # no active features that use this data return diff --git a/frigate/test/test_classification_enabled.py b/frigate/test/test_classification_enabled.py new file mode 100644 index 0000000000..abb139cd08 --- /dev/null +++ b/frigate/test/test_classification_enabled.py @@ -0,0 +1,106 @@ +"""Tests that disabled custom classification models are not registered or run.""" + +import sys +import unittest +from unittest.mock import MagicMock + +# Mock TFLite before importing the maintainer / classification modules +_MOCK_MODULES = [ + "tflite_runtime", + "tflite_runtime.interpreter", + "ai_edge_litert", + "ai_edge_litert.interpreter", +] +for mod in _MOCK_MODULES: + if mod not in sys.modules: + sys.modules[mod] = MagicMock() + +from frigate.data_processing.real_time.custom_classification import ( # noqa: E402 + CustomObjectClassificationProcessor, +) +from frigate.embeddings.maintainer import EmbeddingMaintainer # noqa: E402 + + +class TestCustomClassificationEnabledGating(unittest.TestCase): + """A model with enabled: false must not keep a processor registered.""" + + def _make_maintainer(self) -> EmbeddingMaintainer: + # Bypass the heavy __init__; only the attributes touched by the + # config update path are needed for these tests. + maintainer = EmbeddingMaintainer.__new__(EmbeddingMaintainer) + maintainer.realtime_processors = [] + maintainer.config = MagicMock() + maintainer.config.classification.custom = {} + maintainer.requestor = MagicMock() + maintainer.metrics = MagicMock() + maintainer.event_metadata_publisher = MagicMock() + return maintainer + + def _make_model_config(self, name: str, enabled: bool) -> MagicMock: + model_config = MagicMock() + model_config.name = name + model_config.enabled = enabled + model_config.state_config = None + return model_config + + def _make_processor(self, name: str) -> MagicMock: + processor = MagicMock(spec=CustomObjectClassificationProcessor) + processor.model_config = MagicMock() + processor.model_config.name = name + return processor + + def test_disabled_update_tears_down_existing_processor(self): + """Toggling a running model to disabled shuts down and drops its processor.""" + maintainer = self._make_maintainer() + processor = self._make_processor("atli") + maintainer.realtime_processors = [processor] + + maintainer._handle_custom_classification_update( + "config/classification/custom/atli", + self._make_model_config("atli", enabled=False), + ) + + processor.shutdown.assert_called_once() + self.assertEqual(maintainer.realtime_processors, []) + + def test_disabled_update_does_not_register_processor(self): + """A disabled model that has no processor is never registered.""" + maintainer = self._make_maintainer() + + maintainer._handle_custom_classification_update( + "config/classification/custom/atli", + self._make_model_config("atli", enabled=False), + ) + + self.assertEqual(maintainer.realtime_processors, []) + + def test_disabled_update_leaves_other_processors_untouched(self): + """Disabling one model must not affect other running processors.""" + maintainer = self._make_maintainer() + other = self._make_processor("simbi") + maintainer.realtime_processors = [other] + + maintainer._handle_custom_classification_update( + "config/classification/custom/atli", + self._make_model_config("atli", enabled=False), + ) + + other.shutdown.assert_not_called() + self.assertEqual(maintainer.realtime_processors, [other]) + + def test_removed_model_tears_down_processor(self): + """A None payload (model deleted) still shuts down its processor.""" + maintainer = self._make_maintainer() + processor = self._make_processor("atli") + maintainer.realtime_processors = [processor] + + maintainer._handle_custom_classification_update( + "config/classification/custom/atli", None + ) + + processor.shutdown.assert_called_once() + self.assertEqual(maintainer.realtime_processors, []) + + +if __name__ == "__main__": + unittest.main() From fcd05ec7bc5c9425e7d69c314d8d50feb6b494e1 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 13 Jul 2026 07:30:15 -0500 Subject: [PATCH 84/92] UI improvements and fixes (#23690) * add ability to edit enabled and save_attempts for classification models in the UI * add state motion and interval configs to edit dialog * fix preview playback rate for motion previews * add docs note about environment vars and go2rtc * update live view faq --- docs/docs/configuration/advanced/system.md | 6 + docs/docs/configuration/live.md | 186 +++++++++++------ frigate/embeddings/maintainer.py | 26 +-- .../locales/en/views/classificationModel.json | 16 +- .../ClassificationModelEditDialog.tsx | 188 +++++++++++++++++- web/src/types/frigateConfig.ts | 1 + .../classification/ModelSelectionView.tsx | 14 +- web/src/views/events/MotionPreviewsPane.tsx | 13 ++ 8 files changed, 367 insertions(+), 83 deletions(-) diff --git a/docs/docs/configuration/advanced/system.md b/docs/docs/configuration/advanced/system.md index 14979698c7..28291efff7 100644 --- a/docs/docs/configuration/advanced/system.md +++ b/docs/docs/configuration/advanced/system.md @@ -67,6 +67,12 @@ This section can be used to set environment variables for those unable to modify Variables prefixed with `FRIGATE_` can be referenced in config fields that support environment variable substitution (such as MQTT host and credentials, camera stream URLs, and ONVIF host and credentials) using the `{FRIGATE_VARIABLE_NAME}` syntax. +:::note + +The `go2rtc` section is an exception. go2rtc runs as a separate process, so its stream definitions can only be substituted with variables that exist in the container's environment (set via Docker `-e`, the `environment:` section of `docker-compose.yml`, or Docker secrets). Variables defined in the `environment_vars` block above are not available to go2rtc streams. Home Assistant app users, who cannot set container environment variables, must instead put credentials directly in their go2rtc stream URLs. + +::: + diff --git a/docs/docs/configuration/live.md b/docs/docs/configuration/live.md index a47c9285e5..5df70844c9 100644 --- a/docs/docs/configuration/live.md +++ b/docs/docs/configuration/live.md @@ -6,6 +6,7 @@ title: Live View import ConfigTabs from "@site/src/components/ConfigTabs"; import TabItem from "@theme/TabItem"; import NavPath from "@site/src/components/NavPath"; +import FaqItem from "@site/src/components/FaqItem"; Frigate intelligently displays your camera streams on the Live view dashboard. By default, Frigate employs "smart streaming" where camera images update once per minute when no detectable activity is occurring to conserve bandwidth and resources. As soon as any motion or active objects are detected, cameras seamlessly switch to a live stream. @@ -341,100 +342,155 @@ When your browser runs into problems playing back your camera streams, it will l ## Live view FAQ -1. **Why don't I have audio in my Live view?** +### Getting Live View Working - You must use go2rtc to hear audio in your live streams. If you have go2rtc already configured, you need to ensure your camera is sending PCMA/PCMU or AAC audio. If you can't change your camera's audio codec, you need to [transcode the audio](https://github.com/AlexxIT/go2rtc?tab=readme-ov-file#source-ffmpeg) using go2rtc. + - Note that the low bandwidth mode player is a video-only stream. You should not expect to hear audio when in low bandwidth mode, even if you've set up go2rtc. +You must use go2rtc to hear audio in your live streams. If you have go2rtc already configured, you need to ensure your camera is sending PCMA/PCMU or AAC audio. If you can't change your camera's audio codec, you need to [transcode the audio](https://github.com/AlexxIT/go2rtc?tab=readme-ov-file#source-ffmpeg) using go2rtc. -2. **Frigate shows that my live stream is in "low bandwidth mode". What does this mean?** +If the audio controls don't appear in the UI at all, verify that the Live view is actually using your go2rtc stream. If your go2rtc stream names don't match your Frigate camera name, you must map them with the `live -> streams` config (see [Setting Streams For Live UI](#setting-streams-for-live-ui) above); otherwise the UI falls back to the video-only jsmpeg player. - Frigate intelligently selects the live streaming technology based on a number of factors (user-selected modes like two-way talk, camera settings, browser capabilities, available bandwidth) and prioritizes showing an actual up-to-date live view of your camera's stream as quickly as possible. +Note that the low bandwidth mode player is a video-only stream. You should not expect to hear audio when in low bandwidth mode, even if you've set up go2rtc. - When you have go2rtc configured, Live view initially attempts to load and play back your stream with a clearer, fluent stream technology (MSE). An initial timeout, a low bandwidth condition that would cause buffering of the stream, or decoding errors in the stream will cause Frigate to switch to the stream defined by the `detect` role, using the jsmpeg format. This is what the UI labels as "low bandwidth mode". On Live dashboards, the mode will automatically reset when smart streaming is configured and activity stops. Continuous streaming mode does not have an automatic reset mechanism, but you can use the _Reset_ option to force a reload of your stream. + - If you are using continuous streaming or you are loading more than a few high resolution streams at once on the dashboard, your browser may struggle to begin playback of your streams before the timeout. Frigate always prioritizes showing a live stream as quickly as possible, even if it is a lower quality jsmpeg stream. You can use the "Reset" link/button to try loading your high resolution stream again. + - Errors in stream playback (e.g., connection failures, codec issues, or buffering timeouts) that cause the fallback to low bandwidth mode (jsmpeg) are logged to the browser console for easier debugging. These errors may include: - - Network issues (e.g., MSE or WebRTC network connection problems). - - Unsupported codecs or stream formats (e.g., H.265 in WebRTC, which is not supported in some browsers). - - Buffering timeouts or low bandwidth conditions causing fallback to jsmpeg. - - Browser compatibility problems (e.g., iOS Safari limitations with MSE). +If your camera is streaming (as indicated by a red dot in the upper right, or if it has been set to continuous streaming mode), your browser may be blocking audio until you interact with the page. This is an intentional browser limitation. See [this article](https://developer.mozilla.org/en-US/docs/Web/Media/Autoplay_guide#autoplay_availability). Many browsers have a whitelist feature to change this behavior. - To view browser console logs: - 1. Open the Frigate Live View in your browser. - 2. Open the browser's Developer Tools (F12 or right-click > Inspect > Console tab). - 3. Reproduce the error (e.g., load a problematic stream or simulate network issues). - 4. Look for messages prefixed with the camera name. + - These logs help identify if the issue is player-specific (MSE vs. WebRTC) or related to camera configuration (e.g., go2rtc streams, codecs). If you see frequent errors: - - Verify your camera's H.264/AAC settings (see [Frigate's camera settings recommendations](#camera-settings-recommendations)). - - Check go2rtc configuration for transcoding (e.g., audio to AAC/OPUS). - - Test with a different stream via the UI dropdown (if `live -> streams` is configured). - - For WebRTC-specific issues, ensure port 8555 is forwarded and candidates are set (see [WebRTC Extra Configuration](#webrtc-extra-configuration)). - - If your cameras are streaming at a high resolution, your browser may be struggling to load all of the streams before the buffering timeout occurs. Frigate prioritizes showing a true live view as quickly as possible. If the fallback occurs often, change your live view settings to use a lower bandwidth substream. + -3. **It doesn't seem like my cameras are streaming on the Live dashboard. Why?** +The debug view plays the `detect` stream processed by Frigate itself, while the Live view plays your go2rtc stream directly in the browser. If the debug view works but the Live view doesn't, your browser usually can't decode what the camera is sending, most often H.265 video or an incompatible audio track. - On the default Live dashboard ("All Cameras"), your camera images will update once per minute when no detectable activity is occurring to conserve bandwidth and resources. As soon as any activity is detected, cameras seamlessly switch to a full-resolution live stream. If you want to customize this behavior, use a camera group. +Work through the [go2rtc troubleshooting guide](/troubleshooting/go2rtc#live-view-is-black-buffering-or-stuck-in-low-bandwidth-mode) to isolate the problem. Two fixes resolve the majority of cases: -4. **I see a strange diagonal line on my live view, but my recordings look fine. How can I fix it?** +1. Restream through go2rtc's FFmpeg module by prefixing your source with `ffmpeg:`, for example `- ffmpeg:rtsp://user:password@192.168.1.5:554/stream`. +2. If that doesn't help, transcode to compatible codecs: `- ffmpeg:rtsp://user:password@192.168.1.5:554/stream#video=h264#audio=aac#hardware`. - This is caused by incorrect dimensions set in your detect width or height (or incorrectly auto-detected), causing the jsmpeg player's rendering engine to display a slightly distorted image. You should enlarge the width and height of your `detect` resolution up to a standard aspect ratio (example: 640x352 becomes 640x360, and 800x443 becomes 800x450, 2688x1520 becomes 2688x1512, etc). If changing the resolution to match a standard (4:3, 16:9, or 32:9, etc) aspect ratio does not solve the issue, you can enable "compatibility mode" in your camera group dashboard's stream settings. Depending on your browser and device, more than a few cameras in compatibility mode may not be supported, so only use this option if changing your `detect` width and height fails to resolve the color artifacts and diagonal line. + -5. **How does "smart streaming" work?** + - Because a static image of a scene looks exactly the same as a live stream with no motion or activity, smart streaming updates your camera images once per minute when no detectable activity is occurring to conserve bandwidth and resources. As soon as any activity (motion or object/audio detection) occurs, cameras seamlessly switch to a live stream. +For a full-resolution, low-latency live view in Home Assistant dashboards, use the [Advanced Camera Card](https://card.camera) with the [go2rtc live provider](https://card.camera/#/configuration/cameras/live-provider?id=go2rtc), which streams directly from Frigate's bundled go2rtc. This also supports audio and [two-way talk](#two-way-talk) on capable cameras. See the [Home Assistant integration docs](/integrations/home-assistant) for setup. - This static image is pulled from the stream defined in your config with the `detect` role. When activity is detected, images from the `detect` stream immediately begin updating at ~5 frames per second so you can see the activity until the live player is loaded and begins playing. This usually only takes a second or two. If the live player times out, buffers, or has streaming errors, the jsmpeg player is loaded and plays a video-only stream from the `detect` role. When activity ends, the players are destroyed and a static image is displayed until activity is detected again, and the process repeats. + - Smart streaming depends on having your camera's motion `threshold` and `contour_area` config values dialed in. Use the Motion Tuner in Settings in the UI to tune these values in real-time. +### Streaming Behavior - This is Frigate's default and recommended setting because it results in a significant bandwidth savings, especially for high resolution cameras. + -6. **I have unmuted some cameras on my dashboard, but I do not hear sound. Why?** +Because a static image of a scene looks exactly the same as a live stream with no motion or activity, smart streaming updates your camera images once per minute when no detectable activity is occurring to conserve bandwidth and resources. As soon as any activity (motion or object/audio detection) occurs, cameras seamlessly switch to a live stream. - If your camera is streaming (as indicated by a red dot in the upper right, or if it has been set to continuous streaming mode), your browser may be blocking audio until you interact with the page. This is an intentional browser limitation. See [this article](https://developer.mozilla.org/en-US/docs/Web/Media/Autoplay_guide#autoplay_availability). Many browsers have a whitelist feature to change this behavior. +This static image is pulled from the stream defined in your config with the `detect` role. When activity is detected, images from the `detect` stream immediately begin updating at ~5 frames per second so you can see the activity until the live player is loaded and begins playing. This usually only takes a second or two. If the live player times out, buffers, or has streaming errors, the jsmpeg player is loaded and plays a video-only stream from the `detect` role. When activity ends, the players are destroyed and a static image is displayed until activity is detected again, and the process repeats. -7. **My camera streams have lots of visual artifacts / distortion.** +Smart streaming depends on having your camera's motion `threshold` and `contour_area` config values dialed in. Use the Motion Tuner in Settings in the UI to tune these values in real-time. - Some cameras don't include the hardware to support multiple connections to the high resolution stream, and this can cause unexpected behavior. In this case it is recommended to [restream](./restream.md) the high resolution stream so that it can be used for live view and recordings. +This is Frigate's default and recommended setting because it results in a significant bandwidth savings, especially for high resolution cameras. -8. **Why does my camera stream switch aspect ratios on the Live dashboard?** + - Your camera may change aspect ratios on the dashboard because Frigate uses different streams for different purposes. With go2rtc and Smart Streaming, Frigate shows a static image from the `detect` stream when no activity is present, and switches to the live stream when motion is detected. The camera image will change size if your streams use different aspect ratios. + - To prevent this, make the `detect` stream match the go2rtc live stream's aspect ratio (resolution does not need to match, just the aspect ratio). You can either adjust the camera's output resolution or set the `width` and `height` values in your config's `detect` section to a resolution with an aspect ratio that matches. +On the default Live dashboard ("All Cameras"), your camera images will update once per minute when no detectable activity is occurring to conserve bandwidth and resources. As soon as any activity is detected, cameras seamlessly switch to a full-resolution live stream. If you want to customize this behavior, use a camera group. - Example: Resolutions from two streams - - Mismatched (may cause aspect ratio switching on the dashboard): - - Live/go2rtc stream: 1920x1080 (16:9) - - Detect stream: 640x352 (~1.82:1, not 16:9) + - - Matched (prevents switching): - - Live/go2rtc stream: 1920x1080 (16:9) - - Detect stream: 640x360 (16:9) + - You can update the detect settings in your camera config to match the aspect ratio of your go2rtc live stream. For example: +Frigate intelligently selects the live streaming technology based on a number of factors (user-selected modes like two-way talk, camera settings, browser capabilities, available bandwidth) and prioritizes showing an actual up-to-date live view of your camera's stream as quickly as possible. - ```yaml - cameras: - front_door: - detect: - width: 640 - height: 360 # set this to 360 instead of 352 - ffmpeg: - inputs: - - path: rtsp://127.0.0.1:8554/front_door # main stream 1920x1080 - roles: - - record - - path: rtsp://127.0.0.1:8554/front_door_sub # sub stream 640x352 - roles: - - detect - ``` +When you have go2rtc configured, Live view initially attempts to load and play back your stream with a clearer, fluent stream technology (MSE). An initial timeout, a low bandwidth condition that would cause buffering of the stream, or decoding errors in the stream will cause Frigate to switch to the stream defined by the `detect` role, using the jsmpeg format. This is what the UI labels as "low bandwidth mode". On Live dashboards, the mode will automatically reset when smart streaming is configured and activity stops. Continuous streaming mode does not have an automatic reset mechanism, but you can use the _Reset_ option to force a reload of your stream. - The same applies to your `record` stream: if its aspect ratio differs from your `detect` stream, your recordings will appear in a different shape than the live view. For consistent framing across live view and recordings, use the same aspect ratio for all of a camera's streams (the resolution can still differ). +If you are using continuous streaming or you are loading more than a few high resolution streams at once on the dashboard, your browser may struggle to begin playback of your streams before the timeout. Frigate always prioritizes showing a live stream as quickly as possible, even if it is a lower quality jsmpeg stream. You can use the "Reset" link/button to try loading your high resolution stream again. -9. **Why does Frigate prefer MSE over WebRTC for live view?** +Errors in stream playback (e.g., connection failures, codec issues, or buffering timeouts) that cause the fallback to low bandwidth mode (jsmpeg) are logged to the browser console for easier debugging. These errors may include: - Frigate prefers MSE because it delivers a better out-of-the-box experience than WebRTC on nearly every axis that matters for a security camera system. MSE is an open standard optimized and supported by all modern browsers, works without any extra configuration (WebRTC requires port forwarding and candidate setup, and lacks H.265 support in some browsers), and requires no internet access for NAT traversal. More importantly, MSE runs over TCP, so every frame arrives and is decoded in order, so nothing is ever silently skipped. WebRTC optimizes for latency over UDP by discarding late or incomplete frames, which works against you on cellular or spotty Wi-Fi: you can end up with frozen video, visual corruption, or gaps in the feed without ever knowing you missed something. Frigate's enhanced MSE player has adaptive speed playback and has been tuned for latency and connection robustness that meets or exceeds WebRTC, so you get near-real-time playback with a guarantee that when the video plays, every frame is actually there - which, for an NVR whose whole purpose is letting you see what happened, matters more than shaving fractions of a second off a latency number. That's why Frigate defaults to MSE and reserves WebRTC for cases that require it, like two-way talk. +- Network issues (e.g., MSE or WebRTC network connection problems). +- Unsupported codecs or stream formats (e.g., H.265 in WebRTC, which is not supported in some browsers). +- Buffering timeouts or low bandwidth conditions causing fallback to jsmpeg. +- Browser compatibility problems (e.g., iOS Safari limitations with MSE). + +To view browser console logs: + +1. Open the Frigate Live View in your browser. +2. Open the browser's Developer Tools (F12 or right-click > Inspect > Console tab). +3. Reproduce the error (e.g., load a problematic stream or simulate network issues). +4. Look for messages prefixed with the camera name. + +These logs help identify if the issue is player-specific (MSE vs. WebRTC) or related to camera configuration (e.g., go2rtc streams, codecs). If you see frequent errors: + +- Verify your camera's H.264/AAC settings (see [Frigate's camera settings recommendations](#camera-settings-recommendations)). +- Check go2rtc configuration for transcoding (e.g., audio to AAC/OPUS). +- Test with a different stream via the UI dropdown (if `live -> streams` is configured). +- For WebRTC-specific issues, ensure port 8555 is forwarded and candidates are set (see [WebRTC Extra Configuration](#webrtc-extra-configuration)). +- If your cameras are streaming at a high resolution, your browser may be struggling to load all of the streams before the buffering timeout occurs. Frigate prioritizes showing a true live view as quickly as possible. If the fallback occurs often, change your live view settings to use a lower bandwidth substream. + + + + + +A delay when a stream first starts is usually caused by your camera's I-frame (keyframe) interval. Playback cannot begin until a keyframe arrives, so an interval set higher than your camera's frame rate makes the stream take longer to start. Set the I-frame interval to match the frame rate (or "1x" on Reolink) per the [camera settings recommendations](#camera-settings-recommendations). + +A stream that starts on time but falls further behind live is buffering, which is usually the browser struggling to decode too many high-resolution streams at once. Select a lower-bandwidth substream for your dashboards (see [Setting Streams For Live UI](#setting-streams-for-live-ui)), reduce the number of streams open at once, or improve the network connection between your browser and Frigate. Frigate's player automatically speeds up playback to catch up to live after buffering, and falls back to low bandwidth mode if it stalls for too long. The _Reset_ option forces a fresh connection at the live edge. + + + + + +Frigate prefers MSE because it delivers a better out-of-the-box experience than WebRTC on nearly every axis that matters for a security camera system. MSE is an open standard optimized and supported by all modern browsers, works without any extra configuration (WebRTC requires port forwarding and candidate setup, and lacks H.265 support in some browsers), and requires no internet access for NAT traversal. More importantly, MSE runs over TCP, so every frame arrives and is decoded in order, so nothing is ever silently skipped. WebRTC optimizes for latency over UDP by discarding late or incomplete frames, which works against you on cellular or spotty Wi-Fi: you can end up with frozen video, visual corruption, or gaps in the feed without ever knowing you missed something. Frigate's enhanced MSE player has adaptive speed playback and has been tuned for latency and connection robustness that meets or exceeds WebRTC, so you get near-real-time playback with a guarantee that when the video plays, every frame is actually there - which, for an NVR whose whole purpose is letting you see what happened, matters more than shaving fractions of a second off a latency number. That's why Frigate defaults to MSE and reserves WebRTC for cases that require it, like two-way talk. + + + +### Video Quality Issues + + + +This is caused by incorrect dimensions set in your detect width or height (or incorrectly auto-detected), causing the jsmpeg player's rendering engine to display a slightly distorted image. You should enlarge the width and height of your `detect` resolution up to a standard aspect ratio (example: 640x352 becomes 640x360, and 800x443 becomes 800x450, 2688x1520 becomes 2688x1512, etc). If changing the resolution to match a standard (4:3, 16:9, or 32:9, etc) aspect ratio does not solve the issue, you can enable "compatibility mode" in your camera group dashboard's stream settings. Depending on your browser and device, more than a few cameras in compatibility mode may not be supported, so only use this option if changing your `detect` width and height fails to resolve the color artifacts and diagonal line. + + + + + +Some cameras don't include the hardware to support multiple connections to the high resolution stream, and this can cause unexpected behavior. In this case it is recommended to [restream](./restream.md) the high resolution stream so that it can be used for live view and recordings. + + + + + +Your camera may change aspect ratios on the dashboard because Frigate uses different streams for different purposes. With go2rtc and Smart Streaming, Frigate shows a static image from the `detect` stream when no activity is present, and switches to the live stream when motion is detected. The camera image will change size if your streams use different aspect ratios. + +To prevent this, make the `detect` stream match the go2rtc live stream's aspect ratio (resolution does not need to match, just the aspect ratio). You can either adjust the camera's output resolution or set the `width` and `height` values in your config's `detect` section to a resolution with an aspect ratio that matches. + +Example: Resolutions from two streams + +- Mismatched (may cause aspect ratio switching on the dashboard): + - Live/go2rtc stream: 1920x1080 (16:9) + - Detect stream: 640x352 (~1.82:1, not 16:9) + +- Matched (prevents switching): + - Live/go2rtc stream: 1920x1080 (16:9) + - Detect stream: 640x360 (16:9) + +You can update the detect settings in your camera config to match the aspect ratio of your go2rtc live stream. For example: + +```yaml +cameras: + front_door: + detect: + width: 640 + height: 360 # set this to 360 instead of 352 + ffmpeg: + inputs: + - path: rtsp://127.0.0.1:8554/front_door # main stream 1920x1080 + roles: + - record + - path: rtsp://127.0.0.1:8554/front_door_sub # sub stream 640x352 + roles: + - detect +``` + +The same applies to your `record` stream: if its aspect ratio differs from your `detect` stream, your recordings will appear in a different shape than the live view. For consistent framing across live view and recordings, use the same aspect ratio for all of a camera's streams (the resolution can still differ). + + diff --git a/frigate/embeddings/maintainer.py b/frigate/embeddings/maintainer.py index 042c2722c2..a9b9b837b7 100644 --- a/frigate/embeddings/maintainer.py +++ b/frigate/embeddings/maintainer.py @@ -376,20 +376,22 @@ class EmbeddingMaintainer(threading.Thread): logger.info(f"Disabled classification processor for model: {model_name}") return - # Check if processor already exists for processor in self.realtime_processors: - if isinstance( - processor, - ( - CustomStateClassificationProcessor, - CustomObjectClassificationProcessor, - ), + if ( + isinstance( + processor, + ( + CustomStateClassificationProcessor, + CustomObjectClassificationProcessor, + ), + ) + and processor.model_config.name == model_name ): - if processor.model_config.name == model_name: - logger.debug( - f"Classification processor for model {model_name} already exists, skipping" - ) - return + processor.model_config = model_config + logger.debug( + f"Updated config for classification processor: {model_name}" + ) + return if model_config.state_config is not None: processor = CustomStateClassificationProcessor( diff --git a/web/public/locales/en/views/classificationModel.json b/web/public/locales/en/views/classificationModel.json index 3206ad0339..3e881ae345 100644 --- a/web/public/locales/en/views/classificationModel.json +++ b/web/public/locales/en/views/classificationModel.json @@ -1,5 +1,6 @@ { "documentTitle": "Classification Models - Frigate", + "disabled": "Disabled", "details": { "scoreInfo": "Score represents the average classification confidence across all detections of this object.", "none": "None", @@ -64,7 +65,20 @@ "title": "Edit Classification Model", "descriptionState": "Edit the classes for this state classification model. Changes will require retraining the model.", "descriptionObject": "Edit the object type and classification type for this object classification model.", - "stateClassesInfo": "Note: Changing state classes requires retraining the model with the updated classes." + "enabled": "Enabled", + "enabledDesc": "Run this model. When disabled, it stops running and no longer classifies.", + "saveAttempts": "Save Attempts", + "saveAttemptsDesc": "Number of classification attempt images to keep for the recent classifications UI.", + "motion": "Run on Motion", + "motionDesc": "Run classification when motion is detected within the configured crop.", + "interval": "Interval", + "intervalDesc": "Seconds between periodic classification runs. Leave empty to run only on motion.", + "intervalPlaceholder": "No interval", + "stateClassesInfo": "Model updated. Retrain the model for the class changes to take effect.", + "errors": { + "saveAttemptsInvalid": "Save attempts must be a whole number of 0 or greater", + "intervalInvalid": "Interval must be a whole number greater than 0" + } }, "deleteDatasetImages": { "title": "Delete Dataset Images", diff --git a/web/src/components/classification/ClassificationModelEditDialog.tsx b/web/src/components/classification/ClassificationModelEditDialog.tsx index ac49ee4db7..4be2e04f95 100644 --- a/web/src/components/classification/ClassificationModelEditDialog.tsx +++ b/web/src/components/classification/ClassificationModelEditDialog.tsx @@ -9,6 +9,7 @@ import { import { Form, FormControl, + FormDescription, FormField, FormItem, FormLabel, @@ -17,6 +18,7 @@ import { import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { Switch } from "@/components/ui/switch"; import { Select, SelectContent, @@ -50,14 +52,25 @@ type ClassificationModelEditDialogProps = { type ObjectClassificationType = "sub_label" | "attribute"; type ObjectFormData = { + enabled: boolean; + saveAttempts: number; objectLabel: string; objectType: ObjectClassificationType; }; type StateFormData = { + enabled: boolean; + saveAttempts: number; + motion: boolean; + interval?: number; classes: string[]; }; +const DEFAULT_SAVE_ATTEMPTS = { + object: 200, + state: 100, +} as const; + export default function ClassificationModelEditDialog({ open, model, @@ -71,6 +84,10 @@ export default function ClassificationModelEditDialog({ const isStateModel = model.state_config !== undefined; const isObjectModel = model.object_config !== undefined; + const defaultSaveAttempts = isObjectModel + ? DEFAULT_SAVE_ATTEMPTS.object + : DEFAULT_SAVE_ATTEMPTS.state; + const objectLabels = useMemo(() => { if (!config) return []; @@ -93,8 +110,17 @@ export default function ClassificationModelEditDialog({ // Define form schema based on model type const formSchema = useMemo(() => { + const sharedFields = { + enabled: z.boolean(), + saveAttempts: z.coerce + .number({ message: t("edit.errors.saveAttemptsInvalid") }) + .int(t("edit.errors.saveAttemptsInvalid")) + .min(0, t("edit.errors.saveAttemptsInvalid")), + }; + if (isObjectModel) { return z.object({ + ...sharedFields, objectLabel: z .string() .min(1, t("wizard.step1.errors.objectLabelRequired")), @@ -103,6 +129,17 @@ export default function ClassificationModelEditDialog({ } else { // State model return z.object({ + ...sharedFields, + motion: z.boolean(), + interval: z.preprocess( + (val) => + val === "" || val === null || val === undefined ? undefined : val, + z.coerce + .number({ message: t("edit.errors.intervalInvalid") }) + .int(t("edit.errors.intervalInvalid")) + .positive(t("edit.errors.intervalInvalid")) + .optional(), + ), classes: z .array(z.string()) .min(1, t("wizard.step1.errors.classRequired")) @@ -129,12 +166,18 @@ export default function ClassificationModelEditDialog({ resolver: zodResolver(formSchema), defaultValues: isObjectModel ? ({ + enabled: model.enabled, + saveAttempts: model.save_attempts ?? defaultSaveAttempts, objectLabel: model.object_config?.objects?.[0] || "", objectType: (model.object_config ?.classification_type as ObjectClassificationType) || "sub_label", } as ObjectFormData) : ({ + enabled: model.enabled, + saveAttempts: model.save_attempts ?? defaultSaveAttempts, + motion: model.state_config?.motion ?? false, + interval: model.state_config?.interval, classes: [""], // Will be populated from dataset } as StateFormData), mode: "onChange", @@ -151,6 +194,8 @@ export default function ClassificationModelEditDialog({ if (open) { if (isObjectModel) { form.reset({ + enabled: model.enabled, + saveAttempts: model.save_attempts ?? defaultSaveAttempts, objectLabel: model.object_config?.objects?.[0] || "", objectType: (model.object_config @@ -158,6 +203,10 @@ export default function ClassificationModelEditDialog({ } as ObjectFormData); } else { form.reset({ + enabled: model.enabled, + saveAttempts: model.save_attempts ?? defaultSaveAttempts, + motion: model.state_config?.motion ?? false, + interval: model.state_config?.interval, classes: [""], } as StateFormData); } @@ -166,7 +215,15 @@ export default function ClassificationModelEditDialog({ mutateDataset(); } } - }, [open, isObjectModel, isStateModel, model, form, mutateDataset]); + }, [ + open, + isObjectModel, + isStateModel, + model, + form, + mutateDataset, + defaultSaveAttempts, + ]); // Update form with classes from dataset when loaded useEffect(() => { @@ -233,6 +290,7 @@ export default function ClassificationModelEditDialog({ setIsSaving(true); try { if (isObjectModel) { + // object model save const objectData = data as ObjectFormData; // Update the config @@ -243,9 +301,10 @@ export default function ClassificationModelEditDialog({ classification: { custom: { [model.name]: { - enabled: model.enabled, + enabled: objectData.enabled, name: model.name, threshold: model.threshold, + save_attempts: objectData.saveAttempts, object_config: { objects: [objectData.objectLabel], classification_type: objectData.objectType, @@ -260,7 +319,34 @@ export default function ClassificationModelEditDialog({ position: "top-center", }); } else { + // state model save const stateData = data as StateFormData; + + const stateConfig: { motion: boolean; interval?: number | null } = { + motion: stateData.motion, + }; + if (stateData.interval != null) { + stateConfig.interval = stateData.interval; + } else if (model.state_config?.interval != null) { + stateConfig.interval = null; + } + + await axios.put("/config/set", { + requires_restart: 0, + update_topic: `config/classification/custom/${model.name}`, + config_data: { + classification: { + custom: { + [model.name]: { + enabled: stateData.enabled, + save_attempts: stateData.saveAttempts, + state_config: stateConfig, + }, + }, + }, + }, + }); + const newClasses = stateData.classes.filter( (c) => c.trim().length > 0, ); @@ -307,11 +393,11 @@ export default function ClassificationModelEditDialog({ if (renamePromises.length > 0) { await Promise.all(renamePromises); await mutate(`classification/${model.name}/dataset`); - toast.success(t("toast.success.updatedModel"), { + toast.success(t("edit.stateClassesInfo"), { position: "top-center", }); } else { - toast.info(t("edit.stateClassesInfo"), { + toast.success(t("toast.success.updatedModel"), { position: "top-center", }); } @@ -359,6 +445,29 @@ export default function ClassificationModelEditDialog({
+ ( + +
+ + {t("edit.enabled")} + + + {t("edit.enabledDesc")} + +
+ + + +
+ )} + /> + {isObjectModel && ( <> )} + {isStateModel && ( + <> + ( + +
+ + {t("edit.motion")} + + + {t("edit.motionDesc")} + +
+ + + +
+ )} + /> + + ( + + + {t("edit.interval")} + + + + + + {t("edit.intervalDesc")} + + + + )} + /> + + )} + + ( + + + {t("edit.saveAttempts")} + + + + + + {t("edit.saveAttemptsDesc")} + + + + )} + /> +
diff --git a/docs/src/components/FaqItem/styles.module.css b/docs/src/components/FaqItem/styles.module.css index 7c367212b7..bf348dc88a 100644 --- a/docs/src/components/FaqItem/styles.module.css +++ b/docs/src/components/FaqItem/styles.module.css @@ -44,6 +44,11 @@ transform: rotate(45deg); } +.question { + flex: 1; + min-width: 0; +} + .content { display: none; padding: 0 0 0.85rem; @@ -61,7 +66,7 @@ /* Desktop: render as a normal expanded heading + answer. */ @media (min-width: 997px) { .heading { - margin: 1.75rem 0 0.5rem; + margin: 1.75rem 0 0.85rem; } .toggle { @@ -78,7 +83,8 @@ .content { display: block; - padding: 0; + padding: 0 0 0.5rem 1rem; + border-left: 2px solid var(--ifm-color-emphasis-200); } .heading :global(.hash-link) { diff --git a/frigate/config/config.py b/frigate/config/config.py index 8e4c7f2aef..b450d26e91 100644 --- a/frigate/config/config.py +++ b/frigate/config/config.py @@ -400,6 +400,9 @@ def verify_objects_track( ) camera_config.objects.track = valid_objects + for label in invalid_objects: + camera_config.objects.filters.pop(label, None) + def verify_lpr_and_face( frigate_config: FrigateConfig, camera_config: CameraConfig diff --git a/frigate/test/test_config.py b/frigate/test/test_config.py index 6490a65099..95884342be 100644 --- a/frigate/test/test_config.py +++ b/frigate/test/test_config.py @@ -397,6 +397,43 @@ class TestConfig(unittest.TestCase): assert "dog" in frigate_config.cameras["back"].objects.filters assert frigate_config.cameras["back"].objects.filters["dog"].threshold == 0.7 + def test_unsupported_tracked_object_pruned_from_track_and_filters(self): + # "unicorn" is not in the model labelmap, so it must be removed from the + # tracked objects AND from the object filters, otherwise a stale filter + # entry lingers in the parsed config. + config = { + "mqtt": {"host": "mqtt"}, + "cameras": { + "back": { + "ffmpeg": { + "inputs": [ + {"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]} + ] + }, + "detect": { + "height": 1080, + "width": 1920, + "fps": 5, + }, + "objects": { + "track": ["person", "unicorn"], + "filters": { + "person": {"threshold": 0.7}, + "unicorn": {"threshold": 0.7}, + }, + }, + } + }, + } + + frigate_config = FrigateConfig(**config) + objects = frigate_config.cameras["back"].objects + assert "unicorn" not in objects.track + assert "unicorn" not in objects.filters + # supported entries are left untouched + assert "person" in objects.track + assert "person" in objects.filters + def test_global_object_mask(self): config = { "mqtt": {"host": "mqtt"}, diff --git a/frigate/test/test_gpu_stats.py b/frigate/test/test_gpu_stats.py index f6986912f7..e94625f645 100644 --- a/frigate/test/test_gpu_stats.py +++ b/frigate/test/test_gpu_stats.py @@ -40,18 +40,18 @@ class TestGpuStats(unittest.TestCase): "driver": "i915", "pid": "100", "engines": { - "render": (1_000_000_000, 0), - "video": (5_000_000_000, 0), - "video-enhance": (200_000_000, 0), - "compute": (0, 0), + "render": (1_000_000_000, 0, 1), + "video": (5_000_000_000, 0, 1), + "video-enhance": (200_000_000, 0, 1), + "compute": (0, 0, 1), }, }, ("0000:00:02.0", "2", "200"): { "driver": "i915", "pid": "200", "engines": { - "render": (0, 0), - "compute": (2_000_000_000, 0), + "render": (0, 0, 1), + "compute": (2_000_000_000, 0, 1), }, }, } @@ -60,18 +60,18 @@ class TestGpuStats(unittest.TestCase): "driver": "i915", "pid": "100", "engines": { - "render": (1_200_000_000, 0), - "video": (5_500_000_000, 0), - "video-enhance": (300_000_000, 0), - "compute": (0, 0), + "render": (1_200_000_000, 0, 1), + "video": (5_500_000_000, 0, 1), + "video-enhance": (300_000_000, 0, 1), + "compute": (0, 0, 1), }, }, ("0000:00:02.0", "2", "200"): { "driver": "i915", "pid": "200", "engines": { - "render": (0, 0), - "compute": (2_100_000_000, 0), + "render": (0, 0, 1), + "compute": (2_100_000_000, 0, 1), }, }, } @@ -92,6 +92,64 @@ class TestGpuStats(unittest.TestCase): }, } + @patch("frigate.stats.intel_gpu_info.intel_gpu_name_resolver.get_names") + @patch("frigate.util.services.time.sleep") + @patch("frigate.util.services.time.monotonic") + @patch("frigate.util.services._read_intel_drm_fdinfo") + def test_intel_gpu_stats_xe_capacity( + self, read_fdinfo, monotonic, sleep, get_names + ): + # Xe engines report cumulative cycles paired with total cycles, plus a + # per-class capacity. drm-cycles-* is summed across every instance of a + # class, so on Battlemage (capacity 2 for vcs/vecs) busy/total must be + # divided by capacity to land in 0-100%. + monotonic.side_effect = [0.0, 1.0] + get_names.return_value = {"0000:03:00.0": "Intel Arc"} + + # Deltas over the window (busy, total): render 200/1000 cap 1 = 20%, + # video 800/1000 cap 2 = 40%, video-enhance 400/1000 cap 2 = 20%, + # compute 100/1000 cap 1 = 10%. Without the capacity divisor video/ + # video-enhance would read 80%/40% and dec would clamp at 100%. + snapshot_a = { + ("0000:03:00.0", "1", "300"): { + "driver": "xe", + "pid": "300", + "engines": { + "render": (0, 0, 1), + "video": (0, 0, 2), + "video-enhance": (0, 0, 2), + "compute": (0, 0, 1), + }, + }, + } + snapshot_b = { + ("0000:03:00.0", "1", "300"): { + "driver": "xe", + "pid": "300", + "engines": { + "render": (200, 1000, 1), + "video": (800, 1000, 2), + "video-enhance": (400, 1000, 2), + "compute": (100, 1000, 1), + }, + }, + } + read_fdinfo.side_effect = [snapshot_a, snapshot_b] + + intel_stats = get_intel_gpu_stats(None) + + assert intel_stats == { + "0000:03:00.0": { + "name": "Intel Arc", + "vendor": "intel", + "gpu": "90.0%", + "mem": "-%", + "compute": "30.0%", + "dec": "60.0%", + "clients": {"300": "90.0%"}, + }, + } + @patch("frigate.util.services._read_intel_drm_fdinfo") def test_intel_gpu_stats_no_clients(self, read_fdinfo): read_fdinfo.return_value = {} diff --git a/frigate/util/services.py b/frigate/util/services.py index 956e7893c5..62929d32df 100644 --- a/frigate/util/services.py +++ b/frigate/util/services.py @@ -360,7 +360,7 @@ def _read_intel_drm_fdinfo(target_pdev: str | None) -> dict: if key in snapshot: continue - engines: dict[str, tuple[int, int]] = {} + engines: dict[str, tuple[int, int, int]] = {} if driver == "i915": for fkey, engine in _I915_ENGINE_KEYS.items(): @@ -368,19 +368,34 @@ def _read_intel_drm_fdinfo(target_pdev: str | None) -> dict: if not raw: continue try: - engines[engine] = (int(raw.split()[0]), 0) + engines[engine] = (int(raw.split()[0]), 0, 1) except (ValueError, IndexError): continue else: for suffix, engine in _XE_ENGINE_KEYS.items(): busy_raw = fields.get(f"drm-cycles-{suffix}") total_raw = fields.get(f"drm-total-cycles-{suffix}") + if not (busy_raw and total_raw): continue + + # drm-cycles-* is summed across every instance of the engine + # class while drm-total-cycles-* tracks a single instance, so + # busy/total scales up to the capacity (e.g. Battlemage + # reports 2 for vcs/vecs). Capture it to divide back out; + # absent means a single engine, so default to 1. + capacity_raw = fields.get(f"drm-engine-capacity-{suffix}") + + try: + capacity = int(capacity_raw.split()[0]) if capacity_raw else 1 + except (ValueError, IndexError): + capacity = 1 + try: engines[engine] = ( int(busy_raw.split()[0]), int(total_raw.split()[0]), + max(1, capacity), ) except (ValueError, IndexError): continue @@ -450,11 +465,13 @@ def get_intel_gpu_stats( pid_pct = per_pdev_pid_pct.setdefault(pdev, {}) client_total = 0.0 - for engine, (busy_b, total_b) in data_b["engines"].items(): + for engine, (busy_b, total_b, capacity) in data_b["engines"].items(): if engine not in engine_pct: continue - busy_a, total_a = data_a["engines"].get(engine, (busy_b, total_b)) + busy_a, total_a, _ = data_a["engines"].get( + engine, (busy_b, total_b, capacity) + ) if data_b["driver"] == "i915": delta = max(0, busy_b - busy_a) @@ -464,7 +481,9 @@ def get_intel_gpu_stats( delta_total = total_b - total_a if delta_total <= 0: continue - pct = min(100.0, delta_busy / delta_total * 100.0) + # Normalize by capacity so a class with N engine instances + # (busy summed across all N) reports 0-100%, not 0-N*100%. + pct = min(100.0, delta_busy / (delta_total * capacity) * 100.0) engine_pct[engine] += pct client_total += pct diff --git a/web/src/components/settings/PolygonItem.tsx b/web/src/components/settings/PolygonItem.tsx index ffd85eaec6..2ba196f0be 100644 --- a/web/src/components/settings/PolygonItem.tsx +++ b/web/src/components/settings/PolygonItem.tsx @@ -27,7 +27,7 @@ import axios from "axios"; import { toast } from "sonner"; import useSWR from "swr"; import { FrigateConfig } from "@/types/frigateConfig"; -import { reviewQueries } from "@/utils/zoneEdutUtil"; +import { removeRequiredZoneQuery, reviewQueries } from "@/utils/zoneEdutUtil"; import IconWrapper from "../ui/icon-wrapper"; import { buttonVariants } from "@/components/ui/button"; import { Trans, useTranslation } from "react-i18next"; @@ -153,6 +153,30 @@ export default function PolygonItem({ cameraConfig?.review.alerts.required_zones || [], cameraConfig?.review.detections.required_zones || [], ); + const genaiQueries = removeRequiredZoneQuery( + polygon.name, + polygon.camera, + "objects.genai", + cameraConfig?.objects.genai.required_zones || [], + ); + const snapshotQueries = removeRequiredZoneQuery( + polygon.name, + polygon.camera, + "snapshots", + cameraConfig?.snapshots.required_zones || [], + ); + const mqttQueries = removeRequiredZoneQuery( + polygon.name, + polygon.camera, + "mqtt", + cameraConfig?.mqtt.required_zones || [], + ); + const autotrackQueries = removeRequiredZoneQuery( + polygon.name, + polygon.camera, + "onvif.autotracking", + cameraConfig?.onvif.autotracking.required_zones || [], + ); // Also delete from profiles that have overrides for this zone let profileQueries = ""; if (allProfileNames && cameraConfig) { @@ -165,7 +189,7 @@ export default function PolygonItem({ } } } - url = `cameras.${polygon.camera}.zones.${polygon.name}${alertQueries}${detectionQueries}${profileQueries}`; + url = `cameras.${polygon.camera}.zones.${polygon.name}${alertQueries}${detectionQueries}${genaiQueries}${snapshotQueries}${mqttQueries}${autotrackQueries}${profileQueries}`; } await axios diff --git a/web/src/components/settings/ZoneEditPane.tsx b/web/src/components/settings/ZoneEditPane.tsx index 88213445c6..6132fb9072 100644 --- a/web/src/components/settings/ZoneEditPane.tsx +++ b/web/src/components/settings/ZoneEditPane.tsx @@ -108,7 +108,11 @@ export default function ZoneEditPane({ } const inRequiredZones = cam.review.alerts.required_zones.includes(polygon.name) || - cam.review.detections.required_zones.includes(polygon.name); + cam.review.detections.required_zones.includes(polygon.name) || + cam.objects.genai.required_zones.includes(polygon.name) || + cam.snapshots.required_zones.includes(polygon.name) || + cam.mqtt.required_zones.includes(polygon.name) || + cam.onvif.autotracking.required_zones.includes(polygon.name); const hasProfileOverride = Object.values(cam.profiles ?? {}).some( (profile) => profile?.zones && polygon.name in profile.zones, ); diff --git a/web/src/utils/zoneEdutUtil.ts b/web/src/utils/zoneEdutUtil.ts index ce5ed3d962..7107003e83 100644 --- a/web/src/utils/zoneEdutUtil.ts +++ b/web/src/utils/zoneEdutUtil.ts @@ -1,3 +1,33 @@ +// Build a config/set query fragment that removes `name` from a +// required_zones list on the given camera section (e.g. "snapshots", +// "mqtt", "objects.genai", "onvif.autotracking"), rebuilding the +// remaining entries. When removing the name empties the list, the +// required_zones key itself is deleted so the field reverts to its +// default instead of retaining the now-stale zone name. Returns an empty +// string when `name` is not present so unrelated sections are untouched. +export const removeRequiredZoneQuery = ( + name: string, + camera: string, + section: string, + zones: string[], +) => { + const remaining = new Set(zones || []); + + if (!remaining.has(name)) { + return ""; + } + + remaining.delete(name); + + const key = `cameras.${camera}.${section}.required_zones`; + + if (remaining.size === 0) { + return `&${key}`; + } + + return [...remaining].map((zone) => `&${key}=${zone}`).join(""); +}; + export const reviewQueries = ( name: string, review_alerts: boolean, From 62d4e87e5d9b580c88790f18c1babf2017d31fb7 Mon Sep 17 00:00:00 2001 From: nulledy <254504350+nulledy@users.noreply.github.com> Date: Tue, 14 Jul 2026 06:35:51 -0400 Subject: [PATCH 88/92] Add logout endpoint to Nginx configuration to prevent a new token on logout (#23678) * Add logout endpoint to Nginx configuration to prevent logout from silently generating a new frigate_token cookie * Change JWT cookie expiration to use max_age and have the appropriate expiration time based on JWT_SESSION_LENGTH * ruff formatting --- .../rootfs/usr/local/nginx/conf/nginx.conf | 7 +++++++ frigate/api/auth.py | 18 +++++++++++++----- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/docker/main/rootfs/usr/local/nginx/conf/nginx.conf b/docker/main/rootfs/usr/local/nginx/conf/nginx.conf index d0b18ff805..399dba5736 100644 --- a/docker/main/rootfs/usr/local/nginx/conf/nginx.conf +++ b/docker/main/rootfs/usr/local/nginx/conf/nginx.conf @@ -274,6 +274,13 @@ http { include proxy.conf; } + location /api/logout { + auth_request off; + rewrite ^/api(/.*)$ $1 break; + proxy_pass http://frigate_api; + include proxy.conf; + } + # Allow unauthenticated access to the first_time_login endpoint # so the login page can load help text before authentication. location /api/auth/first_time_login { diff --git a/frigate/api/auth.py b/frigate/api/auth.py index a6a4c65b68..204702e3ac 100644 --- a/frigate/api/auth.py +++ b/frigate/api/auth.py @@ -415,7 +415,7 @@ def create_encoded_jwt(user, role, expiration, secret): ) -def set_jwt_cookie(response: Response, cookie_name, encoded_jwt, expiration, secure): +def set_jwt_cookie(response: Response, cookie_name, encoded_jwt, max_age, secure): # TODO: ideally this would set secure as well, but that requires TLS # SameSite is intentionally left unset (browsers default to Lax). Setting # SameSite=Lax/Strict would stop the cookie from being sent in cross-origin @@ -427,7 +427,7 @@ def set_jwt_cookie(response: Response, cookie_name, encoded_jwt, expiration, sec key=cookie_name, value=encoded_jwt, httponly=True, - expires=expiration, + max_age=max_age, secure=secure, ) @@ -762,7 +762,7 @@ def auth(request: Request): success_response, JWT_COOKIE_NAME, new_encoded_jwt, - new_expiration, + JWT_SESSION_LENGTH, JWT_COOKIE_SECURE, ) @@ -875,7 +875,11 @@ def login(request: Request, body: AppPostLoginBody): encoded_jwt = create_encoded_jwt(user, role, expiration, request.app.jwt_token) response = Response("", 200) set_jwt_cookie( - response, JWT_COOKIE_NAME, encoded_jwt, expiration, JWT_COOKIE_SECURE + response, + JWT_COOKIE_NAME, + encoded_jwt, + JWT_SESSION_LENGTH, + JWT_COOKIE_SECURE, ) # Clear admin_first_time_login flag after successful admin login so the # UI stops showing the first-time login documentation link. @@ -1037,7 +1041,11 @@ async def update_password( ) # Set new JWT cookie on response set_jwt_cookie( - response, JWT_COOKIE_NAME, encoded_jwt, expiration, JWT_COOKIE_SECURE + response, + JWT_COOKIE_NAME, + encoded_jwt, + JWT_SESSION_LENGTH, + JWT_COOKIE_SECURE, ) return response From c2e739b4bc75d848efaa7cb34c604f54282d3a8a Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Tue, 14 Jul 2026 06:27:00 -0500 Subject: [PATCH 89/92] bound REGEXP evaluation with a timeout to prevent ReDoS on the database thread (#23714) The recognized_license_plate event filter passed attacker-controlled patterns to re.search on the single serialized SQLite queue thread, letting any authenticated user freeze the whole application with a catastrophic regex. This swaps stdlib re for the regex module with a per-evaluation timeout so a pathological pattern is aborted instead of stalling every database operation. --- frigate/db/sqlitevecq.py | 10 +++-- frigate/test/test_sqlitevecq_regexp.py | 54 ++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 3 deletions(-) create mode 100644 frigate/test/test_sqlitevecq_regexp.py diff --git a/frigate/db/sqlitevecq.py b/frigate/db/sqlitevecq.py index a72e99b6a2..137fb51451 100644 --- a/frigate/db/sqlitevecq.py +++ b/frigate/db/sqlitevecq.py @@ -1,9 +1,11 @@ -import re import sqlite3 from typing import Any +import regex from playhouse.sqliteq import SqliteQueueDatabase +REGEXP_TIMEOUT_SECONDS = 1.0 + class SqliteVecQueueDatabase(SqliteQueueDatabase): def __init__( @@ -34,8 +36,10 @@ class SqliteVecQueueDatabase(SqliteQueueDatabase): if item is None: return False try: - return re.search(expr, item) is not None - except re.error: + return ( + regex.search(expr, item, timeout=REGEXP_TIMEOUT_SECONDS) is not None + ) + except (regex.error, TimeoutError): return False conn.create_function("REGEXP", 2, regexp) diff --git a/frigate/test/test_sqlitevecq_regexp.py b/frigate/test/test_sqlitevecq_regexp.py new file mode 100644 index 0000000000..71f4fcb6b4 --- /dev/null +++ b/frigate/test/test_sqlitevecq_regexp.py @@ -0,0 +1,54 @@ +"""Tests for the REGEXP function registered on the main Frigate database. + +Regression coverage for GHSA-q8jx-q884-jcq9: an attacker-controlled +catastrophic (ReDoS) pattern reaching the REGEXP sink must not be able to +stall the serialized database worker thread. +""" + +import sqlite3 +import time +import unittest + +from frigate.db.sqlitevecq import REGEXP_TIMEOUT_SECONDS, SqliteVecQueueDatabase + + +class TestRegexpFunction(unittest.TestCase): + def setUp(self) -> None: + # autostart=False keeps the queue worker thread from spinning up; we + # only need the REGEXP registration, exercised on our own connection. + self.db = SqliteVecQueueDatabase(":memory:", autostart=False) + self.conn = sqlite3.connect(":memory:") + self.db._register_regexp(self.conn) + + def tearDown(self) -> None: + self.conn.close() + + def _regexp(self, value: str | None, pattern: str) -> int | None: + # SQLite maps "value REGEXP pattern" to regexp(pattern, value). + return self.conn.execute("SELECT ? REGEXP ?", (value, pattern)).fetchone()[0] + + def test_normal_patterns_still_match(self) -> None: + self.assertTrue(self._regexp("ABC123", "^ABC")) + self.assertTrue(self._regexp("ABC123", "ABC.*")) + self.assertTrue(self._regexp("ABC123", "[0-9]+$")) + self.assertFalse(self._regexp("ABC123", "^XYZ")) + + def test_null_value_does_not_match(self) -> None: + self.assertFalse(self._regexp(None, ".*")) + + def test_invalid_pattern_does_not_raise(self) -> None: + self.assertFalse(self._regexp("ABC123", "(unclosed")) + + def test_catastrophic_pattern_is_time_bounded(self) -> None: + # Without the timeout this evaluation backtracks for minutes to hours + # and wedges the whole database thread (GHSA-q8jx-q884-jcq9). + catastrophic = "(a{2,})+c" + subject = "a" * 4000 + + start = time.monotonic() + result = self._regexp(subject, catastrophic) + elapsed = time.monotonic() - start + + # The pattern does not match; the guarantee is that it returns quickly. + self.assertFalse(result) + self.assertLess(elapsed, REGEXP_TIMEOUT_SECONDS + 2.0) From 81b53b78356577ef6f5b075123aebfdf2e1f8ffb Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Tue, 14 Jul 2026 09:42:26 -0500 Subject: [PATCH 90/92] Miscellaneous fixes (0.18 beta) (#23716) * resolve zone friendly names against the correct camera * Improve handling of zone names in chat prompt * show a numeric keyboard for numeric config form fields on mobile * Specify english only for semantic search tool when model is JinaV1 * resolve export hwaccel args global value against the correct config path --------- Co-authored-by: Nicolas Mowen --- frigate/api/chat.py | 35 ++++++++++--- frigate/genai/prompts.py | 26 +++++++--- .../config-form/section-configs/detect.ts | 7 +++ .../config-form/section-configs/record.ts | 1 + .../config-form/theme/utils/index.ts | 1 + .../config-form/theme/utils/inputMode.ts | 51 +++++++++++++++++++ .../theme/widgets/FfmpegArgsWidget.tsx | 19 +++++-- .../config-form/theme/widgets/TextWidget.tsx | 3 +- .../components/overlay/ObjectTrackOverlay.tsx | 10 ++-- .../components/overlay/detail/ObjectPath.tsx | 6 ++- .../overlay/detail/TrackingDetails.tsx | 24 ++++++--- web/src/components/timeline/DetailStream.tsx | 2 +- 12 files changed, 155 insertions(+), 30 deletions(-) create mode 100644 web/src/components/config-form/theme/utils/inputMode.ts diff --git a/frigate/api/chat.py b/frigate/api/chat.py index ae6ff7a948..fa4510cf14 100644 --- a/frigate/api/chat.py +++ b/frigate/api/chat.py @@ -7,7 +7,7 @@ import operator import time from datetime import datetime from functools import reduce -from typing import Any +from typing import Any, Literal import cv2 from fastapi import APIRouter, Body, Depends, HTTPException, Request @@ -37,6 +37,7 @@ from frigate.api.defs.response.chat_response import ( from frigate.api.defs.tags import Tags from frigate.api.event import _build_attribute_filter_clause, events from frigate.config import FrigateConfig +from frigate.config.classification import SemanticSearchModelEnum from frigate.genai.prompts import ( build_chat_system_prompt, get_attribute_classifications, @@ -86,10 +87,23 @@ def get_tools(request: Request) -> JSONResponse: tools = get_tool_definitions( semantic_search_enabled=semantic_search_enabled, attribute_classifications=attribute_classifications, + embeddings_language=_embeddings_language(config), ) return JSONResponse(content={"tools": tools}) +def _embeddings_language(config: FrigateConfig) -> Literal["english", "multi"]: + """Return the language capability of the configured embeddings model. + + JinaV1 is English-only; every other option (JinaV2 or a GenAI embeddings + provider) handles multiple languages. + """ + if config.semantic_search.model == SemanticSearchModelEnum.jinav1: + return "english" + + return "multi" + + def _resolve_zones( zones: list[str], config: FrigateConfig, @@ -98,11 +112,14 @@ def _resolve_zones( """Map zone names to their canonical config keys, case-insensitively. LLMs frequently echo a user's casing ("Front Yard") instead of the - configured key ("front_yard"). The downstream zone filter is a SQLite GLOB - over the JSON-encoded zones column, which is case-sensitive — so an - unnormalized name silently returns zero matches. Build a lookup over the - relevant cameras' configured zones and substitute when we find a match; - unknown names pass through so behavior matches what the model asked for. + configured key ("front_yard"), or fall back to a zone's friendly name + ("Front Walkway") instead of its ID ("front_walk"). The downstream zone + filter is a SQLite GLOB over the JSON-encoded zones column, which stores + config keys and is case-sensitive — so an unnormalized name silently + returns zero matches. Build a lookup over the relevant cameras' configured + zones, keyed by both the config key and the friendly name, and substitute + when we find a match; unknown names pass through so behavior matches what + the model asked for. """ if not zones: return zones @@ -112,8 +129,11 @@ def _resolve_zones( camera_config = config.cameras.get(camera_id) if camera_config is None: continue - for zone_name in camera_config.zones.keys(): + for zone_name, zone_config in camera_config.zones.items(): lookup.setdefault(zone_name.lower(), zone_name) + lookup.setdefault( + zone_config.get_formatted_name(zone_name).lower(), zone_name + ) return [lookup.get(z.lower(), z) for z in zones] @@ -1134,6 +1154,7 @@ async def chat_completion( tools = get_tool_definitions( semantic_search_enabled=semantic_search_enabled, attribute_classifications=attribute_classifications, + embeddings_language=_embeddings_language(config), ) conversation = [] diff --git a/frigate/genai/prompts.py b/frigate/genai/prompts.py index 5f1d328d7a..33045606eb 100644 --- a/frigate/genai/prompts.py +++ b/frigate/genai/prompts.py @@ -6,7 +6,7 @@ transport. """ import datetime -from typing import Any +from typing import Any, Literal from playhouse.shortcuts import model_to_dict @@ -249,6 +249,7 @@ def get_attribute_classifications(config: FrigateConfig) -> list[dict[str, Any]] def get_tool_definitions( semantic_search_enabled: bool = False, attribute_classifications: list[dict[str, Any]] | None = None, + embeddings_language: Literal["english", "multi"] = "multi", ) -> list[dict[str, Any]]: """ Get OpenAI-compatible tool definitions for Frigate. @@ -258,7 +259,9 @@ def get_tool_definitions( tool exposes an additional `semantic_query` parameter for descriptive queries (e.g. "person riding a lawn mower") and find_similar_objects is included. When attribute classification models are configured, an - `attribute` parameter is exposed for filtering by their labels. + `attribute` parameter is exposed for filtering by their labels. When the + embeddings model only understands English (JinaV1), the `semantic_query` + description instructs the model to write the query in English. """ search_objects_properties: dict[str, Any] = { "camera": { @@ -349,6 +352,14 @@ def get_tool_definitions( "When set, combine with label/time/camera/zone filters as " "usual (e.g. label='person', semantic_query='riding a lawn " "mower', after='2024-05-01T00:00:00Z')." + + ( + " The configured embeddings model only understands " + "English, so always write semantic_query in English, " + "translating the user's description if they phrased it " + "in another language." + if embeddings_language == "english" + else "" + ) ), } @@ -682,14 +693,17 @@ def build_chat_system_prompt( if camera_config.friendly_name else camera_id.replace("_", " ").title() ) - zone_names = list(camera_config.zones.keys()) + zone_descriptors = [ + f"{zone_config.get_formatted_name(zone_name)} (ID: {zone_name})" + for zone_name, zone_config in camera_config.zones.items() + ] if not has_speed_zone: has_speed_zone = any( zone.distances for zone in camera_config.zones.values() ) - if zone_names: + if zone_descriptors: cameras_info.append( - f" - {friendly_name} (ID: {camera_id}, zones: {', '.join(zone_names)})" + f" - {friendly_name} (ID: {camera_id}, zones: {', '.join(zone_descriptors)})" ) else: cameras_info.append(f" - {friendly_name} (ID: {camera_id})") @@ -699,7 +713,7 @@ def build_chat_system_prompt( cameras_section = ( "\n\nAvailable cameras:\n" + "\n".join(cameras_info) - + "\n\nWhen users refer to cameras by their friendly name (e.g., 'Back Deck Camera'), use the corresponding camera ID (e.g., 'back_deck_cam') in tool calls." + + "\n\nWhen users refer to cameras or zones by their friendly name (e.g., 'Back Deck Camera', 'Front Walkway'), use the corresponding ID (e.g., 'back_deck_cam', 'front_walk') in tool calls. Tool results also identify zones by their ID, so when presenting cameras or zones back to the user, translate the ID to its friendly name." ) speed_units_section = "" diff --git a/web/src/components/config-form/section-configs/detect.ts b/web/src/components/config-form/section-configs/detect.ts index 4213a68448..776fbb6567 100644 --- a/web/src/components/config-form/section-configs/detect.ts +++ b/web/src/components/config-form/section-configs/detect.ts @@ -169,6 +169,13 @@ const detect: SectionConfigOverrides = { resolution: ["width", "height", "fps"], tracking: ["min_initialized", "max_disappeared"], }, + uiSchema: { + annotation_offset: { + "ui:options": { + signed: true, + }, + }, + }, hiddenFields: ["enabled_in_config"], advancedFields: [ "min_initialized", diff --git a/web/src/components/config-form/section-configs/record.ts b/web/src/components/config-form/section-configs/record.ts index d4dd481b86..9271e025fb 100644 --- a/web/src/components/config-form/section-configs/record.ts +++ b/web/src/components/config-form/section-configs/record.ts @@ -57,6 +57,7 @@ const record: SectionConfigOverrides = { "ui:options": { suppressMultiSchema: true, ffmpegPresetField: "hwaccel_args", + ffmpegGlobalFieldPath: "export.hwaccel_args", }, }, }, diff --git a/web/src/components/config-form/theme/utils/index.ts b/web/src/components/config-form/theme/utils/index.ts index bb27f297b7..2c483ea231 100644 --- a/web/src/components/config-form/theme/utils/index.ts +++ b/web/src/components/config-form/theme/utils/index.ts @@ -17,3 +17,4 @@ export { isSubtreeModified, } from "./overrides"; export { getSizedFieldClassName } from "./fieldSizing"; +export { getNumericInputMode } from "./inputMode"; diff --git a/web/src/components/config-form/theme/utils/inputMode.ts b/web/src/components/config-form/theme/utils/inputMode.ts new file mode 100644 index 0000000000..02189e42d1 --- /dev/null +++ b/web/src/components/config-form/theme/utils/inputMode.ts @@ -0,0 +1,51 @@ +import type { RJSFSchema } from "@rjsf/utils"; + +type NumericInputOptions = { + signed?: boolean; +}; + +/** + * Derive the on-screen keyboard hint for a schema field. + * + * Numeric config fields render as text inputs because RJSF's NumberField + * relies on the widget echoing raw strings back, so that trailing "." and "0" + * characters survive while a value is being typed. That means the numeric + * keypad has to be requested explicitly. Desktop browsers ignore inputMode, so + * this only affects virtual keyboards. + * + * Fields accepting negative values opt out, since the iOS numeric and decimal + * keypads have no minus key. Most numeric fields declare no minimum even + * though they are non-negative, so signed fields are marked explicitly with + * ui:options.signed. + * + * Args: + * schema: The JSON schema for the field being rendered + * options: The resolved ui:options for the field + * + * Returns: + * The inputMode to apply, or undefined to leave the keyboard alone + */ +export function getNumericInputMode( + schema: RJSFSchema, + options: unknown, +): "numeric" | "decimal" | undefined { + const types = Array.isArray(schema.type) ? schema.type : [schema.type]; + const isInteger = types.includes("integer"); + + if (!isInteger && !types.includes("number")) { + return undefined; + } + + const numericOptions = + typeof options === "object" && options !== null + ? (options as NumericInputOptions) + : undefined; + + const minimum = schema.minimum ?? schema.exclusiveMinimum; + + if (numericOptions?.signed || (minimum ?? 0) < 0) { + return undefined; + } + + return isInteger ? "numeric" : "decimal"; +} diff --git a/web/src/components/config-form/theme/widgets/FfmpegArgsWidget.tsx b/web/src/components/config-form/theme/widgets/FfmpegArgsWidget.tsx index e523bfd466..527789c814 100644 --- a/web/src/components/config-form/theme/widgets/FfmpegArgsWidget.tsx +++ b/web/src/components/config-form/theme/widgets/FfmpegArgsWidget.tsx @@ -120,6 +120,12 @@ export function FfmpegArgsWidget(props: WidgetProps) { id, } = props; const presetField = options?.ffmpegPresetField as PresetField | undefined; + // Path to this field within its config section. This is usually the same as + // the preset field, but the two diverge when the field sits below the + // section root: record.export.hwaccel_args uses the hwaccel_args preset list + // while living at export.hwaccel_args inside the record section. + const globalFieldPath = + (options?.ffmpegGlobalFieldPath as string | undefined) ?? presetField; const allowInherit = options?.allowInherit === true; const hideDescription = options?.hideDescription === true; const useSplitLayout = options?.splitLayout !== false; @@ -131,11 +137,18 @@ export function FfmpegArgsWidget(props: WidgetProps) { // Extract the global value for this specific field to detect inheritance const globalFieldValue = useMemo(() => { - if (!showUseGlobalSetting || !formContext?.globalValue || !presetField) { + if ( + !showUseGlobalSetting || + !formContext?.globalValue || + !globalFieldPath + ) { return undefined; } - return get(formContext.globalValue as Record, presetField); - }, [showUseGlobalSetting, formContext?.globalValue, presetField]); + return get( + formContext.globalValue as Record, + globalFieldPath, + ); + }, [showUseGlobalSetting, formContext?.globalValue, globalFieldPath]); const { data } = useSWR("ffmpeg/presets"); diff --git a/web/src/components/config-form/theme/widgets/TextWidget.tsx b/web/src/components/config-form/theme/widgets/TextWidget.tsx index 9c408797d1..b833a70618 100644 --- a/web/src/components/config-form/theme/widgets/TextWidget.tsx +++ b/web/src/components/config-form/theme/widgets/TextWidget.tsx @@ -2,7 +2,7 @@ import type { WidgetProps } from "@rjsf/utils"; import { Input } from "@/components/ui/input"; import { cn } from "@/lib/utils"; -import { getSizedFieldClassName } from "../utils"; +import { getNumericInputMode, getSizedFieldClassName } from "../utils"; export function TextWidget(props: WidgetProps) { const { @@ -28,6 +28,7 @@ export function TextWidget(props: WidgetProps) { id={id} className={cn(fieldClassName)} type="text" + inputMode={getNumericInputMode(schema, options)} value={value ?? ""} disabled={disabled || readonly} placeholder={placeholder || (options.placeholder as string) || ""} diff --git a/web/src/components/overlay/ObjectTrackOverlay.tsx b/web/src/components/overlay/ObjectTrackOverlay.tsx index 8f78adcd74..4ed243a188 100644 --- a/web/src/components/overlay/ObjectTrackOverlay.tsx +++ b/web/src/components/overlay/ObjectTrackOverlay.tsx @@ -127,8 +127,12 @@ export default function ObjectTrackOverlay({ }, ); - const getZonesFriendlyNames = (zones: string[], config: FrigateConfig) => { - return zones?.map((zone) => resolveZoneName(config, zone)) ?? []; + const getZonesFriendlyNames = ( + zones: string[], + config: FrigateConfig, + cameraId?: string, + ) => { + return zones?.map((zone) => resolveZoneName(config, zone, cameraId)) ?? []; }; const timelineResults = useMemo(() => { @@ -151,7 +155,7 @@ export default function ObjectTrackOverlay({ data: { ...event.data, zones_friendly_names: config - ? getZonesFriendlyNames(event.data?.zones, config) + ? getZonesFriendlyNames(event.data?.zones, config, event.camera) : [], }, })); diff --git a/web/src/components/overlay/detail/ObjectPath.tsx b/web/src/components/overlay/detail/ObjectPath.tsx index 4af68a0d4f..8be0c18d9c 100644 --- a/web/src/components/overlay/detail/ObjectPath.tsx +++ b/web/src/components/overlay/detail/ObjectPath.tsx @@ -61,7 +61,11 @@ export function ObjectPath({ ...pos.lifecycle_item?.data, zones_friendly_names: pos.lifecycle_item?.data.zones.map( (zone) => { - return resolveZoneName(config, zone); + return resolveZoneName( + config, + zone, + pos.lifecycle_item?.camera, + ); }, ), }, diff --git a/web/src/components/overlay/detail/TrackingDetails.tsx b/web/src/components/overlay/detail/TrackingDetails.tsx index 88e48f8012..1e6d3e6306 100644 --- a/web/src/components/overlay/detail/TrackingDetails.tsx +++ b/web/src/components/overlay/detail/TrackingDetails.tsx @@ -301,11 +301,19 @@ export function TrackingDetails({ [recordings, actualVideoStart], ); - eventSequence?.map((event) => { - event.data.zones_friendly_names = event.data?.zones?.map((zone) => { - return resolveZoneName(config, zone); - }); - }); + const sequence = useMemo( + () => + eventSequence?.map((item) => ({ + ...item, + data: { + ...item.data, + zones_friendly_names: item.data?.zones?.map((zone) => + resolveZoneName(config, zone, item.camera), + ), + }, + })), + [eventSequence, config], + ); // Use manualOverride (set when seeking in image mode) if present so // lifecycle rows and overlays follow image-mode seeks. Otherwise fall @@ -849,9 +857,9 @@ export function TrackingDetails({
- {!eventSequence ? ( + {!sequence ? ( - ) : eventSequence.length === 0 ? ( + ) : sequence.length === 0 ? (
{t("detail.noObjectDetailData", { ns: "views/events" })}
@@ -871,7 +879,7 @@ export function TrackingDetails({ /> )}
- {eventSequence.map((item, idx) => { + {sequence.map((item, idx) => { return (
- resolveZoneName(config, zone), + resolveZoneName(config, zone, event.camera), ), }, })); From a8eca68438579aef3326029080d911c89dd3e907 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Tue, 14 Jul 2026 19:49:03 -0500 Subject: [PATCH 91/92] Miscellaneous fixes (0.18 beta) (#23718) * Cleanup llama.cpp and use api key when configured * don't report auto-populated object and audio filters as camera overrides * derive stale replay cameras from bounded directory listings to avoid scanning all clips at startup * fix tests * add -vaapi_device to the birdseye vaapi encode preset so hwupload can initialize on ffmpeg 8 --------- Co-authored-by: Nicolas Mowen --- frigate/debug_replay.py | 16 ++-- frigate/ffmpeg_presets.py | 6 +- frigate/genai/plugins/llama_cpp.py | 77 ++++++++++------- frigate/test/test_genai_providers.py | 2 +- web/src/hooks/use-config-override.ts | 125 +++++++++++++++++++++++++-- 5 files changed, 177 insertions(+), 49 deletions(-) diff --git a/frigate/debug_replay.py b/frigate/debug_replay.py index 17a727c873..f8e801c3f3 100644 --- a/frigate/debug_replay.py +++ b/frigate/debug_replay.py @@ -21,8 +21,6 @@ from frigate.config.camera.updater import ( CameraConfigUpdateTopic, ) from frigate.const import ( - CLIPS_DIR, - RECORD_DIR, REPLAY_CAMERA_PREFIX, REPLAY_DIR, THUMB_DIR, @@ -331,12 +329,14 @@ def cleanup_replay_cameras() -> None: """ stale_cameras: set[str] = set() - # Scan filesystem for leftover replay artifacts to derive camera names - for dir_path in [RECORD_DIR, CLIPS_DIR, THUMB_DIR]: - if os.path.isdir(dir_path): - for entry in os.listdir(dir_path): - if entry.startswith(REPLAY_CAMERA_PREFIX): - stale_cameras.add(entry) + # Derive stale camera names from THUMB_DIR (per-camera dirs) and + # REPLAY_DIR (the session's source clip); both listings are bounded by + # camera count. cleanup_camera_files below removes any remaining + # per-camera artifacts (snapshots, thumbnails, LPR images, etc.) by name. + if os.path.isdir(THUMB_DIR): + for entry in os.listdir(THUMB_DIR): + if entry.startswith(REPLAY_CAMERA_PREFIX): + stale_cameras.add(entry) if os.path.isdir(REPLAY_DIR): for entry in os.listdir(REPLAY_DIR): diff --git a/frigate/ffmpeg_presets.py b/frigate/ffmpeg_presets.py index d005c80980..445cfd4df3 100644 --- a/frigate/ffmpeg_presets.py +++ b/frigate/ffmpeg_presets.py @@ -150,7 +150,11 @@ PRESETS_HW_ACCEL_SCALE["preset-rk-h265"] = PRESETS_HW_ACCEL_SCALE[FFMPEG_HWACCEL PRESETS_HW_ACCEL_ENCODE_BIRDSEYE = { "preset-rpi-64-h264": "{0} -hide_banner {1} -c:v h264_v4l2m2m {2}", "preset-rpi-64-h265": "{0} -hide_banner {1} -c:v hevc_v4l2m2m {2}", - FFMPEG_HWACCEL_VAAPI: "{0} -hide_banner -hwaccel vaapi -hwaccel_output_format vaapi -hwaccel_device {3} {1} -c:v h264_vaapi -g 50 -bf 0 -profile:v high -level:v 4.1 -sei:v 0 -an -vf format=vaapi|nv12,hwupload {2}", + # -vaapi_device is required in addition to -hwaccel_device: this is the only + # birdseye preset that uses hwupload, and ffmpeg 8 initializes filters before + # the decoder creates a device, so hwupload cannot see an -hwaccel_device one. + # See https://github.com/AlexxIT/go2rtc/issues/1984 + FFMPEG_HWACCEL_VAAPI: "{0} -hide_banner -vaapi_device {3} -hwaccel vaapi -hwaccel_output_format vaapi -hwaccel_device {3} {1} -c:v h264_vaapi -g 50 -bf 0 -profile:v high -level:v 4.1 -sei:v 0 -an -vf format=vaapi|nv12,hwupload {2}", "preset-intel-qsv-h264": "{0} -hide_banner {1} -c:v h264_qsv -g 50 -bf 0 -profile:v high -level:v 4.1 -async_depth:v 1 {2}", "preset-intel-qsv-h265": "{0} -hide_banner {1} -c:v h264_qsv -g 50 -bf 0 -profile:v main -level:v 4.1 -async_depth:v 1 {2}", FFMPEG_HWACCEL_NVIDIA: "{0} -hide_banner {1} -c:v h264_nvenc -g 50 -profile:v high -level:v auto -preset:v p2 -tune:v ll {2}", diff --git a/frigate/genai/plugins/llama_cpp.py b/frigate/genai/plugins/llama_cpp.py index 5b338fcfd3..af3ecc9b18 100644 --- a/frigate/genai/plugins/llama_cpp.py +++ b/frigate/genai/plugins/llama_cpp.py @@ -76,29 +76,6 @@ def _parse_launch_arg(args: list[str], flag: str) -> str | None: return args[idx + 1] -def _fetch_llama_props(base_url: str, model: str) -> dict[str, Any]: - """Fetch /props from a llama.cpp server, with llama-swap fallback. - - Raises the underlying RequestException if both endpoints fail; callers - decide how to surface the failure. - """ - try: - response = requests.get( - f"{base_url}/props", - params={"model": model}, - timeout=10, - ) - response.raise_for_status() - return cast(dict[str, Any], response.json()) - except Exception: - response = requests.get( - f"{base_url}/upstream/{model}/props", - timeout=10, - ) - response.raise_for_status() - return cast(dict[str, Any], response.json()) - - def _to_jpeg(img_bytes: bytes) -> bytes | None: """Convert image bytes to JPEG. llama.cpp/STB does not support WebP.""" try: @@ -133,6 +110,43 @@ class LlamaCppClient(GenAIClient): """llama.cpp exposes an /embeddings endpoint for any loaded model.""" return True + def _auth_headers(self) -> dict | None: + """Bearer auth header when an API key is configured, else None.""" + if self.genai_config.api_key: + return {"Authorization": "Bearer " + self.genai_config.api_key} + + return None + + def _get(self, url: str, **kwargs: Any) -> requests.Response: + """GET with the configured auth headers injected.""" + return requests.get(url, headers=self._auth_headers(), **kwargs) + + def _post(self, url: str, **kwargs: Any) -> requests.Response: + """POST with the configured auth headers injected.""" + return requests.post(url, headers=self._auth_headers(), **kwargs) + + def _fetch_llama_props(self, base_url: str, model: str) -> dict[str, Any]: + """Fetch /props from a llama.cpp server, with llama-swap fallback. + + Raises the underlying RequestException if both endpoints fail; callers + decide how to surface the failure. + """ + try: + response = self._get( + f"{base_url}/props", + params={"model": model}, + timeout=10, + ) + response.raise_for_status() + return cast(dict[str, Any], response.json()) + except Exception: + response = self._get( + f"{base_url}/upstream/{model}/props", + timeout=10, + ) + response.raise_for_status() + return cast(dict[str, Any], response.json()) + def _init_provider(self) -> str | None: """Initialize the client and query model metadata from the server.""" self.provider_options = { @@ -216,7 +230,7 @@ class LlamaCppClient(GenAIClient): model_entry: dict[str, Any] | None = None try: - response = requests.get(f"{base_url}/v1/models", timeout=10) + response = self._get(f"{base_url}/v1/models", timeout=10) response.raise_for_status() models_data = response.json() @@ -277,7 +291,7 @@ class LlamaCppClient(GenAIClient): info["supports_tools"] = True try: - props = _fetch_llama_props(base_url, configured_model) + props = self._fetch_llama_props(base_url, configured_model) if info["context_size"] is None: default_settings = props.get("default_generation_settings", {}) @@ -363,7 +377,7 @@ class LlamaCppClient(GenAIClient): if self.supports_toggleable_thinking: payload["chat_template_kwargs"] = {"enable_thinking": enable_thinking} - response = requests.post( + response = self._post( f"{self.provider}/v1/chat/completions", json=payload, timeout=self.timeout, @@ -413,7 +427,7 @@ class LlamaCppClient(GenAIClient): if base_url is None: return [] try: - response = requests.get(f"{base_url}/v1/models", timeout=10) + response = self._get(f"{base_url}/v1/models", timeout=10) response.raise_for_status() models = [] for m in response.json().get("data", []): @@ -516,7 +530,7 @@ class LlamaCppClient(GenAIClient): "messages": [{"role": "user", "content": content}], "max_tokens": 1, } - response = requests.post( + response = self._post( f"{self.provider}/v1/chat/completions", json=payload, timeout=60, @@ -626,7 +640,7 @@ class LlamaCppClient(GenAIClient): if self.provider is None: return False try: - props = _fetch_llama_props(self.provider, self.genai_config.model) + props = self._fetch_llama_props(self.provider, self.genai_config.model) except Exception as e: logger.warning("Failed to refresh llama.cpp media marker: %s", e) return False @@ -687,7 +701,7 @@ class LlamaCppClient(GenAIClient): return content def post_embeddings() -> requests.Response: - return requests.post( + return self._post( f"{self.provider}/embeddings", json={"model": self.genai_config.model, "content": build_content()}, timeout=self.timeout, @@ -791,7 +805,7 @@ class LlamaCppClient(GenAIClient): stream=False, enable_thinking=enable_thinking, ) - response = requests.post( + response = self._post( f"{self.provider}/v1/chat/completions", json=payload, timeout=self.timeout, @@ -872,6 +886,7 @@ class LlamaCppClient(GenAIClient): "POST", f"{self.provider}/v1/chat/completions", json=payload, + headers=self._auth_headers(), ) as response: response.raise_for_status() async for line in response.aiter_lines(): diff --git a/frigate/test/test_genai_providers.py b/frigate/test/test_genai_providers.py index 77a948a74c..d73d632f0a 100644 --- a/frigate/test/test_genai_providers.py +++ b/frigate/test/test_genai_providers.py @@ -401,7 +401,7 @@ class _FakeAsyncClient: async def __aexit__(self, *exc): return False - def stream(self, method, url, json=None): + def stream(self, method, url, json=None, headers=None): return _FakeStreamCtx(self._lines) diff --git a/web/src/hooks/use-config-override.ts b/web/src/hooks/use-config-override.ts index 9b28dc98c7..8b4e4d2da9 100644 --- a/web/src/hooks/use-config-override.ts +++ b/web/src/hooks/use-config-override.ts @@ -102,6 +102,91 @@ function stripAutoDerivedMissingFromGlobal( return cloned; } +/** + * Sections carrying a `filters` map that the backend materializes per-camera: + * every label in the camera's label list (`objects.track`, `audio.listen`) + * without an explicit filter gets a default entry. The global map gets no + * equivalent treatment for those labels, so a label the camera picks up exists + * on the camera side alone. + */ +const AUTO_POPULATED_FILTER_SECTIONS = ["objects", "audio"]; + +/** + * Resolve the schema defaults for a single `
.filters` entry, in the + * normalized/collapsed shape config values are compared in. Returns undefined + * when the schema hasn't loaded or doesn't describe the filters map. + */ +function getDefaultFilter( + sectionPath: string, + schema?: RJSFSchema, +): JsonValue | undefined { + if (!schema) return undefined; + const sectionSchema = extractSectionSchema(schema, sectionPath, "camera"); + const filtersSchema = sectionSchema?.properties?.filters as + | RJSFSchema + | undefined; + if (!filtersSchema) return undefined; + + // An optional map (`dict[str, X] | None`, as `audio.filters` is declared) + // becomes an anyOf, so the entry schema can sit in a branch rather than on + // the map schema itself. + const candidates = [ + filtersSchema, + ...((filtersSchema.anyOf ?? filtersSchema.oneOf ?? []) as RJSFSchema[]), + ]; + const filterSchema = candidates.find((candidate) => + isJsonObject(candidate?.additionalProperties as JsonValue), + )?.additionalProperties; + if (!filterSchema || typeof filterSchema !== "object") return undefined; + return collapseEmpty( + normalizeConfigValue(applySchemaDefaults(filterSchema as RJSFSchema, {})), + ); +} + +/** + * Complete a global baseline with the filter entries the backend only + * materializes per-camera. + * + * The effective global value for a label with no explicit global filter is the + * filter's schema defaults, not "absent": that is exactly what a camera + * inherits for it. Filling those in keeps an untouched label from reading as a + * camera override, and lets a label the camera does filter report the specific + * changed field rather than the whole filter. + */ +function withDefaultFilters( + sectionPath: string, + globalValue: JsonValue, + cameraValue: JsonValue, + schema?: RJSFSchema, +): JsonValue { + if ( + !AUTO_POPULATED_FILTER_SECTIONS.includes(sectionPath) || + !isJsonObject(globalValue) || + !isJsonObject(cameraValue) + ) { + return globalValue; + } + const cameraFilters = cameraValue.filters; + if (!isJsonObject(cameraFilters)) return globalValue; + + const globalFilters = isJsonObject(globalValue.filters) + ? globalValue.filters + : undefined; + const missing = Object.keys(cameraFilters).filter( + (label) => globalFilters?.[label] === undefined, + ); + if (missing.length === 0) return globalValue; + + const defaultFilter = getDefaultFilter(sectionPath, schema); + if (defaultFilter === undefined) return globalValue; + + const filters: JsonObject = { ...globalFilters }; + for (const label of missing) { + filters[label] = defaultFilter; + } + return { ...globalValue, filters }; +} + /** * Whether the given field is auto-derived for `sectionPath` and the global * value at that path is missing — in which case a per-camera value should @@ -290,7 +375,7 @@ export function useConfigOverride({ "camera", buildHiddenFieldContext(config, "camera", cameraName), ); - const collapsedGlobal = stripHiddenPaths( + const collapsedGlobalRaw = stripHiddenPaths( collapseEmpty(normalizedGlobalValue), hiddenFields, ); @@ -300,9 +385,15 @@ export function useConfigOverride({ ); const collapsedCamera = stripAutoDerivedMissingFromGlobal( sectionPath, - collapsedGlobal, + collapsedGlobalRaw, collapsedCameraRaw, ); + const collapsedGlobal = withDefaultFilters( + sectionPath, + collapsedGlobalRaw, + collapsedCamera, + schema, + ); const comparisonGlobal = compareFields ? pickFields(collapsedGlobal, compareFields) @@ -446,7 +537,7 @@ export function useAllCameraOverrides( "camera", buildHiddenFieldContext(config, "camera", cameraName), ); - const collapsedGlobal = stripHiddenPaths( + const collapsedGlobalRaw = stripHiddenPaths( collapseEmpty(globalValue), hiddenFields, ); @@ -456,9 +547,15 @@ export function useAllCameraOverrides( ); const collapsedCamera = stripAutoDerivedMissingFromGlobal( key, - collapsedGlobal, + collapsedGlobalRaw, collapsedCameraRaw, ); + const collapsedGlobal = withDefaultFilters( + key, + collapsedGlobalRaw, + collapsedCamera, + schema, + ); const comparisonGlobal = compareFields ? pickFields(collapsedGlobal, compareFields) : collapsedGlobal; @@ -714,9 +811,15 @@ export function useCamerasOverridingSection( globalValue, collapseEmpty(cameraSectionValues[idx]), ); - for (const delta of collectFieldDeltas( + const effectiveGlobalValue = withDefaultFilters( + sectionPath, globalValue, cameraValue, + schema, + ); + for (const delta of collectFieldDeltas( + effectiveGlobalValue, + cameraValue, compareFields, )) { if (isCrossCameraIgnoredPath(delta.fieldPath)) continue; @@ -738,7 +841,7 @@ export function useCamerasOverridingSection( if (deltasByPath.has(path)) continue; if (isCrossCameraIgnoredPath(path)) continue; if (!isPathAllowed(path, compareFields)) continue; - const g = get(globalValue, path); + const g = get(effectiveGlobalValue, path); const p = get(normalizedProfile, path); if (!isEqual(g, p)) { deltasByPath.set(path, { @@ -791,18 +894,24 @@ export function useCameraSectionDeltas( const sectionMeta = OVERRIDABLE_SECTIONS.find((s) => s.key === sectionPath); const compareFields = sectionMeta?.compareFields; - const globalValue = collapseEmpty( + const rawGlobalValue = collapseEmpty( getEffectiveGlobalBaseline(config, sectionPath, compareFields, schema), ); const cameraValue = stripAutoDerivedMissingFromGlobal( sectionPath, - globalValue, + rawGlobalValue, collapseEmpty( normalizeConfigValue( getBaseCameraSectionValue(config, cameraName, sectionPath), ), ), ); + const globalValue = withDefaultFilters( + sectionPath, + rawGlobalValue, + cameraValue, + schema, + ); const hiddenFields = getEffectiveHiddenFields( sectionPath, From c406a93d3d123fd75173f770ffc9b0603195f25c Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Wed, 15 Jul 2026 19:49:05 -0500 Subject: [PATCH 92/92] Miscellaneous fixes (0.18 beta) (#23725) --- .gitignore | 1 + docs/docs/frigate/installation.md | 27 ++- frigate/api/app.py | 6 +- frigate/camera/maintainer.py | 4 +- frigate/camera/state.py | 4 +- frigate/comms/dispatcher.py | 4 + frigate/config/camera/updater.py | 3 + frigate/output/birdseye.py | 26 ++- frigate/ptz/autotrack.py | 50 ++++- frigate/ptz/onvif.py | 19 +- frigate/test/test_birdseye.py | 71 ++++++- frigate/test/test_gpu_stats.py | 150 +++++++++++++- frigate/test/test_ptz_autotrack.py | 130 ++++++++++++ frigate/test/test_ptz_onvif.py | 147 ++++++++++++++ frigate/util/services.py | 188 +++++++++++++++--- web/public/locales/en/views/system.json | 7 +- .../sectionExtras/BirdseyeCameraReorder.tsx | 1 + web/src/components/icons/IconPicker.tsx | 6 +- web/src/components/overlay/ExportDialog.tsx | 7 +- web/src/views/system/GeneralMetrics.tsx | 84 +------- 20 files changed, 789 insertions(+), 146 deletions(-) create mode 100644 frigate/test/test_ptz_autotrack.py create mode 100644 frigate/test/test_ptz_onvif.py diff --git a/.gitignore b/.gitignore index 7c97a23a0e..70ec5ae76f 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ config/* models *.mp4 *.db +*.db-* *.csv frigate/version.py web/build diff --git a/docs/docs/frigate/installation.md b/docs/docs/frigate/installation.md index 5cdbc13627..14c85ec4f3 100644 --- a/docs/docs/frigate/installation.md +++ b/docs/docs/frigate/installation.md @@ -78,7 +78,7 @@ Users of the Snapcraft build of Docker cannot use storage locations outside your Frigate utilizes shared memory to store frames during processing. The default `shm-size` provided by Docker is **64MB**. -The default shm size of **128MB** is fine for setups with **2 cameras** detecting at **720p**. If Frigate is exiting with "Bus error" messages, it is likely because you have too many high resolution cameras and you need to specify a higher shm size, using [`--shm-size`](https://docs.docker.com/engine/reference/run/#runtime-constraints-on-resources) (or [`service.shm_size`](https://docs.docker.com/compose/compose-file/compose-file-v2/#shm_size) in Docker Compose). +The default shm size of **128MB** is fine for setups with **2 cameras** detecting at **720p**. If Frigate is exiting with "Bus error" messages, it is likely because you have too many high resolution cameras and you need to specify a higher shm size, using [`--shm-size`](https://docs.docker.com/engine/reference/run/#runtime-constraints-on-resources) (or [`service.shm_size`](https://docs.docker.com/compose/compose-file/compose-file-v2/#shm_size) in Docker Compose). If raising the shm size does not help, check your [process and file limits](#process-and-file-limits) as well. The Frigate container also stores logs in shm, which can take up to **40MB**, so make sure to take this into account in your math as well. @@ -86,6 +86,30 @@ The Frigate container also stores logs in shm, which can take up to **40MB**, so The shm size cannot be set per container for Home Assistant Apps. However, this is probably not required since by default Home Assistant Supervisor allocates `/dev/shm` with half the size of your total memory. If your machine has 8GB of memory, chances are that Frigate will have access to up to 4GB without any additional configuration. +### Process and file limits + +Frigate runs many processes and opens a number of shared memory files. Installs with a large number of cameras can exceed the default limits your container runtime applies. + +Hitting the PID limit logs `RuntimeError: can't start new thread`, often followed by a "Bus error" that makes it look like an shm sizing problem. Compare the current count against the max from inside the container: + +```bash +cat /sys/fs/cgroup/pids.current +cat /sys/fs/cgroup/pids.max +``` + +If these are close, raise the limit with [`--pids-limit`](https://docs.docker.com/engine/containers/resource_constraints/) (or `service.pids_limit` in Docker Compose). + +Running out of file descriptors logs `OSError: [Errno 24] Too many open files`. Raise the limit in Docker Compose: + +```yaml +services: + frigate: + ulimits: + nofile: + soft: 65535 + hard: 65535 +``` + ## Extra Steps for Specific Hardware The following sections contain additional setup steps that are only required if you are using specific hardware. If you are not using any of these hardware types, you can skip to the [Docker](#docker) installation section. @@ -484,7 +508,6 @@ Generate a Frigate Docker Compose configuration based on your hardware and requi - ```yaml diff --git a/frigate/api/app.py b/frigate/api/app.py index ac839158e2..49b555606d 100644 --- a/frigate/api/app.py +++ b/frigate/api/app.py @@ -971,7 +971,11 @@ def config_set(request: Request, body: AppConfigSetBody): content=( { "success": True, - "message": "Config successfully updated, restart to apply", + "message": ( + "Config successfully updated" + if body.requires_restart == 0 + else "Config successfully updated, restart to apply" + ), } ), status_code=200, diff --git a/frigate/camera/maintainer.py b/frigate/camera/maintainer.py index ea8df7bff0..9f63ead2da 100644 --- a/frigate/camera/maintainer.py +++ b/frigate/camera/maintainer.py @@ -117,7 +117,9 @@ class CameraMaintainer(threading.Thread): if runtime: self.camera_metrics[name] = CameraMetrics(self.metrics_manager) - self.ptz_metrics[name] = PTZMetrics(autotracker_enabled=False) + self.ptz_metrics[name] = PTZMetrics( + autotracker_enabled=config.onvif.autotracking.enabled + ) self.region_grids[name] = get_camera_regions_grid( name, config.detect, diff --git a/frigate/camera/state.py b/frigate/camera/state.py index c0d0f8b7ff..fed819154e 100644 --- a/frigate/camera/state.py +++ b/frigate/camera/state.py @@ -111,9 +111,9 @@ class CameraState: # draw thicker box around ptz autotracked object if ( self.camera_config.onvif.autotracking.enabled - and self.ptz_autotracker_thread.ptz_autotracker.autotracker_init[ + and self.ptz_autotracker_thread.ptz_autotracker.autotracker_init.get( self.name - ] + ) and self.ptz_autotracker_thread.ptz_autotracker.tracked_object[ self.name ] diff --git a/frigate/comms/dispatcher.py b/frigate/comms/dispatcher.py index f83a117510..6cb4f21b07 100644 --- a/frigate/comms/dispatcher.py +++ b/frigate/comms/dispatcher.py @@ -588,6 +588,10 @@ class Dispatcher: self.ptz_metrics[camera_name].start_time.value = 0 ptz_autotracker_settings.enabled = False + self.config_updater.publish_update( + CameraConfigUpdateTopic(CameraConfigUpdateEnum.autotracking, camera_name), + ptz_autotracker_settings, + ) self.publish(f"{camera_name}/ptz_autotracker/state", payload, retain=True) def _on_motion_contour_area_command(self, camera_name: str, payload: int) -> None: diff --git a/frigate/config/camera/updater.py b/frigate/config/camera/updater.py index afa501904a..c0b9260873 100644 --- a/frigate/config/camera/updater.py +++ b/frigate/config/camera/updater.py @@ -14,6 +14,7 @@ class CameraConfigUpdateEnum(str, Enum): add = "add" # for adding a camera audio = "audio" audio_transcription = "audio_transcription" + autotracking = "autotracking" # ptz autotracking only, without an onvif reinit birdseye = "birdseye" detect = "detect" enabled = "enabled" @@ -145,6 +146,8 @@ class CameraConfigUpdateSubscriber: config.snapshots = updated_config elif update_type == CameraConfigUpdateEnum.onvif: config.onvif = updated_config + elif update_type == CameraConfigUpdateEnum.autotracking: + config.onvif.autotracking = updated_config elif update_type == CameraConfigUpdateEnum.timestamp_style: config.timestamp_style = updated_config elif update_type == CameraConfigUpdateEnum.zones: diff --git a/frigate/output/birdseye.py b/frigate/output/birdseye.py index cd29dfe949..a38669cbf9 100644 --- a/frigate/output/birdseye.py +++ b/frigate/output/birdseye.py @@ -335,6 +335,7 @@ class BirdsEyeFrameManager: self.camera_layout: list[Any] = [] self.active_cameras: set[str] = set() + self.layout_camera_order: list[str] = [] self.last_output_time = 0.0 def add_camera(self, cam: str) -> None: @@ -372,6 +373,13 @@ class BirdsEyeFrameManager: if cam in self.cameras: del self.cameras[cam] + def sort_cameras(self, cameras: set[str]) -> list[str]: + """Sort cameras by birdseye order, falling back to name when tied.""" + return sorted( + cameras, + key=lambda camera: (self.config.cameras[camera].birdseye.order, camera), + ) + def clear_frame(self) -> None: logger.debug("Clearing the birdseye frame") self.frame[:] = self.blank_frame @@ -482,6 +490,7 @@ class BirdsEyeFrameManager: # if the layout needs to be cleared self.camera_layout = [] self.active_cameras = set() + self.layout_camera_order = [] self.clear_frame() frame_changed = True layout_changed = True @@ -500,21 +509,21 @@ class BirdsEyeFrameManager: else: reset_layout = True + sorted_active_cameras = self.sort_cameras(active_cameras) + + if not reset_layout and sorted_active_cameras != self.layout_camera_order: + logger.debug("Birdseye camera order changed") + reset_layout = True + if reset_layout: logger.debug("Resetting Birdseye layout...") self.clear_frame() self.active_cameras = active_cameras + self.layout_camera_order = sorted_active_cameras layout_changed = True # Layout is changing due to reset # this also converts added_cameras from a set to a list since we need # to pop elements in order - active_cameras_to_add = sorted( - active_cameras, - # sort cameras by order and by name if the order is the same - key=lambda active_camera: ( - self.config.cameras[active_camera].birdseye.order, - active_camera, - ), - ) + active_cameras_to_add = sorted_active_cameras if len(active_cameras) == 1: # show single camera as fullscreen camera = active_cameras_to_add[0] @@ -780,6 +789,7 @@ class BirdsEyeFrameManager: frame_changed, layout_changed = False, False self.active_cameras = set() self.camera_layout = [] + self.layout_camera_order = [] print(traceback.format_exc()) # if the frame was updated or the fps is too low, send frame diff --git a/frigate/ptz/autotrack.py b/frigate/ptz/autotrack.py index 9a114c9524..e9bbda9889 100644 --- a/frigate/ptz/autotrack.py +++ b/frigate/ptz/autotrack.py @@ -20,6 +20,10 @@ from norfair.camera_motion import ( from frigate.camera import PTZMetrics from frigate.comms.dispatcher import Dispatcher from frigate.config import CameraConfig, FrigateConfig, ZoomingModeEnum +from frigate.config.camera.updater import ( + CameraConfigUpdateEnum, + CameraConfigUpdateSubscriber, +) from frigate.const import ( AUTOTRACKING_MAX_AREA_RATIO, AUTOTRACKING_MAX_MOVE_METRICS, @@ -194,7 +198,9 @@ class PtzAutoTrackerThread(threading.Thread): def run(self): while not self.stop_event.wait(1): - for camera, camera_config in self.config.cameras.items(): + self.ptz_autotracker.check_for_updates() + + for camera, camera_config in list(self.config.cameras.items()): if not camera_config.enabled: continue @@ -211,6 +217,7 @@ class PtzAutoTrackerThread(threading.Thread): self.ptz_autotracker.tracked_object[camera] = None self.ptz_autotracker.tracked_object_history[camera].clear() + self.ptz_autotracker.config_subscriber.stop() logger.info("Exiting autotracker...") @@ -244,6 +251,16 @@ class PtzAutoTracker: self.zoom_time: dict[str, float] = {} self.zoom_factor: dict[str, object] = {} + self.config_subscriber = CameraConfigUpdateSubscriber( + self.config, + self.config.cameras, + [ + CameraConfigUpdateEnum.add, + CameraConfigUpdateEnum.autotracking, + CameraConfigUpdateEnum.onvif, + ], + ) + # if cam is set to autotrack, onvif should be set up for camera, camera_config in self.config.cameras.items(): if not camera_config.enabled: @@ -260,6 +277,29 @@ class PtzAutoTracker: # Wait for the coroutine to complete future.result() + def check_for_updates(self) -> None: + """Apply camera config updates and mirror autotracking state to ptz metrics. + + The camera processes read autotracker_enabled rather than the config, so it + has to follow every path that can change autotracking, not just the mqtt + toggle that writes it directly. + """ + updates = self.config_subscriber.check_for_updates() + + for cameras in updates.values(): + for camera in cameras: + camera_config = self.config.cameras.get(camera) + metrics = self.ptz_metrics.get(camera) + + # a camera added at runtime gets its metrics from the maintainer on + # another thread, which seeds them from this same config value + if camera_config is None or metrics is None: + continue + + metrics.autotracker_enabled.value = ( + camera_config.onvif.autotracking.enabled + ) + async def _autotracker_setup(self, camera_config: CameraConfig, camera: str): logger.debug(f"{camera}: Autotracker init") @@ -1365,7 +1405,7 @@ class PtzAutoTracker: camera_config = self.config.cameras[camera] if camera_config.onvif.autotracking.enabled: - if not self.autotracker_init[camera]: + if not self.autotracker_init.get(camera): future = asyncio.run_coroutine_threadsafe( self._autotracker_setup(camera_config, camera), self.onvif.loop ) @@ -1483,9 +1523,11 @@ class PtzAutoTracker: } async def camera_maintenance(self, camera): - # bail and don't check anything if we're calibrating or tracking an object + # bail and don't check anything if we're not set up yet, calibrating, or + # tracking an object. a camera enabled at runtime has no autotracker_init + # entry until autotrack_object sets it up if ( - not self.autotracker_init[camera] + not self.autotracker_init.get(camera) or self.calibrating[camera] or self.tracked_object[camera] is not None ): diff --git a/frigate/ptz/onvif.py b/frigate/ptz/onvif.py index d29923e239..3fa786c883 100644 --- a/frigate/ptz/onvif.py +++ b/frigate/ptz/onvif.py @@ -344,16 +344,17 @@ class OnvifController: autotracking_config.enabled_in_config and autotracking_config.enabled ) - # autotracking-only: status request and service capabilities - if autotracking_enabled: - status_request = ptz.create_type("GetStatus") - status_request.ProfileToken = profile.token - self.cams[camera_name]["status_request"] = status_request + # these are local and cost nothing to build, and autotracking can be enabled + # after a camera is initialized, so always create them rather than baking the + # current config value into init state + status_request = ptz.create_type("GetStatus") + status_request.ProfileToken = profile.token + self.cams[camera_name]["status_request"] = status_request - service_capabilities_request = ptz.create_type("GetServiceCapabilities") - self.cams[camera_name]["service_capabilities_request"] = ( - service_capabilities_request - ) + service_capabilities_request = ptz.create_type("GetServiceCapabilities") + self.cams[camera_name]["service_capabilities_request"] = ( + service_capabilities_request + ) # setup relative move request when FOV relative movement is supported if ( diff --git a/frigate/test/test_birdseye.py b/frigate/test/test_birdseye.py index 33683f5c4b..bd70e37efe 100644 --- a/frigate/test/test_birdseye.py +++ b/frigate/test/test_birdseye.py @@ -1,8 +1,10 @@ """Test camera user and password cleanup.""" +import multiprocessing as mp import unittest -from frigate.output.birdseye import get_canvas_shape +from frigate.config import FrigateConfig +from frigate.output.birdseye import BirdsEyeFrameManager, get_canvas_shape class TestBirdseye(unittest.TestCase): @@ -45,3 +47,70 @@ class TestBirdseye(unittest.TestCase): canvas_width, canvas_height = get_canvas_shape(width, height) assert canvas_width == width # width will be the same assert canvas_height != height + + +class TestBirdseyeCameraOrder(unittest.TestCase): + """Test that birdseye reacts to camera order changes without a restart.""" + + def setUp(self): + config = { + "mqtt": {"enabled": False}, + "birdseye": {"enabled": True, "mode": "continuous"}, + "cameras": { + camera: { + "ffmpeg": { + "inputs": [ + {"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]} + ] + }, + "detect": {"height": 1080, "width": 1920, "fps": 5}, + } + for camera in ("back", "front", "side") + }, + } + self.config = FrigateConfig(**config) + self.manager = BirdsEyeFrameManager(self.config, mp.Event()) + + # mark every camera as continuously active with no frame to draw, which + # exercises the layout without needing real yuv frames + for camera_data in self.manager.cameras.values(): + camera_data["current_frame"] = None + camera_data["current_frame_time"] = 1.0 + camera_data["last_active_frame"] = 1.0 + + def layout_order(self) -> list[str]: + """Return the cameras in the order the current layout renders them.""" + return [position[0] for row in self.manager.camera_layout for position in row] + + def test_layout_uses_configured_order(self): + """Test the layout is sorted by order, then by name when tied.""" + self.config.cameras["side"].birdseye.order = 0 + self.config.cameras["back"].birdseye.order = 10 + self.config.cameras["front"].birdseye.order = 20 + + self.manager.update_frame() + + assert self.layout_order() == ["side", "back", "front"] + + def test_order_change_rebuilds_layout(self): + """Test a reorder relayouts even though the active cameras are unchanged.""" + self.manager.update_frame() + assert self.layout_order() == ["back", "front", "side"] + + # a stable active set means only an order change can reset the layout, + # which is what a settings reorder publishes to this process + self.config.cameras["side"].birdseye.order = -10 + + _, layout_changed = self.manager.update_frame() + + assert layout_changed + assert self.layout_order() == ["side", "back", "front"] + + def test_unchanged_order_keeps_layout(self): + """Test a repeat update with no order change doesn't reset the layout.""" + self.manager.update_frame() + + _, layout_changed = self.manager.update_frame() + + assert not layout_changed + assert self.layout_order() == ["back", "front", "side"] diff --git a/frigate/test/test_gpu_stats.py b/frigate/test/test_gpu_stats.py index e94625f645..c99414a419 100644 --- a/frigate/test/test_gpu_stats.py +++ b/frigate/test/test_gpu_stats.py @@ -21,8 +21,12 @@ class TestGpuStats(unittest.TestCase): @patch("frigate.util.services.time.sleep") @patch("frigate.util.services.time.monotonic") @patch("frigate.util.services._read_intel_drm_fdinfo") - def test_intel_gpu_stats_fdinfo(self, read_fdinfo, monotonic, sleep, get_names): + @patch("frigate.util.services._enumerate_drm_devices") + def test_intel_gpu_stats_fdinfo( + self, drm_devices, read_fdinfo, monotonic, sleep, get_names + ): # 1 second of wall clock between snapshots + drm_devices.return_value = {"0000:00:02.0": "i915"} monotonic.side_effect = [0.0, 1.0] get_names.return_value = {"0000:00:02.0": "Intel Graphics"} @@ -96,13 +100,15 @@ class TestGpuStats(unittest.TestCase): @patch("frigate.util.services.time.sleep") @patch("frigate.util.services.time.monotonic") @patch("frigate.util.services._read_intel_drm_fdinfo") + @patch("frigate.util.services._enumerate_drm_devices") def test_intel_gpu_stats_xe_capacity( - self, read_fdinfo, monotonic, sleep, get_names + self, drm_devices, read_fdinfo, monotonic, sleep, get_names ): # Xe engines report cumulative cycles paired with total cycles, plus a # per-class capacity. drm-cycles-* is summed across every instance of a # class, so on Battlemage (capacity 2 for vcs/vecs) busy/total must be # divided by capacity to land in 0-100%. + drm_devices.return_value = {"0000:03:00.0": "xe"} monotonic.side_effect = [0.0, 1.0] get_names.return_value = {"0000:03:00.0": "Intel Arc"} @@ -150,7 +156,145 @@ class TestGpuStats(unittest.TestCase): }, } + @patch("frigate.stats.intel_gpu_info.intel_gpu_name_resolver.get_names") + @patch("frigate.util.services.time.sleep") @patch("frigate.util.services._read_intel_drm_fdinfo") - def test_intel_gpu_stats_no_clients(self, read_fdinfo): + @patch("frigate.util.services._enumerate_drm_devices") + def test_intel_gpu_stats_no_clients_reports_idle( + self, drm_devices, read_fdinfo, sleep, get_names + ): + # The device exists but nothing holds it open, e.g. while camera + # processes are restarting. This is an idle state, not an error: + # returning None here would latch the hwaccel error cooldown and + # blank GPU stats for an hour over a momentary gap. + drm_devices.return_value = {"0000:00:02.0": "i915"} read_fdinfo.return_value = {} + get_names.return_value = {"0000:00:02.0": "Intel Graphics"} + + assert get_intel_gpu_stats(None) == { + "0000:00:02.0": { + "name": "Intel Graphics", + "vendor": "intel", + "gpu": "0.0%", + "mem": "-%", + "compute": "0.0%", + "dec": "0.0%", + }, + } + # Idle short-circuits before spending the sample window + sleep.assert_not_called() + read_fdinfo.assert_called_once() + + @patch("frigate.util.services.time.sleep") + @patch("frigate.util.services._read_intel_drm_fdinfo") + @patch("frigate.util.services._enumerate_drm_devices") + def test_intel_gpu_stats_clients_without_engine_counters( + self, drm_devices, read_fdinfo, sleep + ): + # i915 publishes drm-driver/drm-pdev/drm-client-id but no drm-engine-* + # lines while GuC submission is active on kernels older than 6.5, so + # clients are found with nothing to sample. Reporting idle here would + # be a lie, and sampling a second time cannot help. + drm_devices.return_value = {"0000:00:02.0": "i915"} + read_fdinfo.return_value = { + ("0000:00:02.0", "48", "1109"): { + "driver": "i915", + "pid": "1109", + "engines": {}, + }, + ("0000:00:02.0", "51", "1258"): { + "driver": "i915", + "pid": "1258", + "engines": {}, + }, + } + assert get_intel_gpu_stats(None) is None + sleep.assert_not_called() + read_fdinfo.assert_called_once() + + @patch("frigate.util.services._read_intel_drm_fdinfo") + @patch("frigate.util.services._enumerate_drm_devices") + def test_intel_gpu_stats_no_intel_device(self, drm_devices, read_fdinfo): + # Only a non-Intel GPU is visible in sysfs; /proc is never scanned + drm_devices.return_value = {"0000:01:00.0": "nvidia"} + + assert get_intel_gpu_stats(None) is None + read_fdinfo.assert_not_called() + + @patch("frigate.util.services._read_intel_drm_fdinfo") + @patch("frigate.util.services._enumerate_drm_devices") + @patch("frigate.util.services._resolve_intel_gpu_pdev") + def test_intel_gpu_stats_unresolvable_device_hint( + self, resolve_pdev, drm_devices, read_fdinfo + ): + # A configured intel_gpu_device that cannot be resolved is a config + # error, not a reason to silently fall back to reporting all GPUs + resolve_pdev.return_value = None + + assert get_intel_gpu_stats("/dev/dri/renderD999") is None + drm_devices.assert_not_called() + read_fdinfo.assert_not_called() + + @patch("frigate.util.services._read_intel_drm_fdinfo") + @patch("frigate.util.services._enumerate_drm_devices") + @patch("frigate.util.services._resolve_intel_gpu_pdev") + def test_intel_gpu_stats_hint_resolves_to_non_intel_gpu( + self, resolve_pdev, drm_devices, read_fdinfo + ): + # card numbering can reorder across reboots on multi-GPU hosts, so a + # configured hint may point at another vendor's card; call it out + # instead of reporting nothing + resolve_pdev.return_value = "0000:01:00.0" + drm_devices.return_value = { + "0000:00:02.0": "i915", + "0000:01:00.0": "nvidia", + } + + assert get_intel_gpu_stats("/dev/dri/card0") is None + read_fdinfo.assert_not_called() + + @patch("frigate.util.services._read_intel_drm_fdinfo") + @patch("frigate.util.services._enumerate_drm_devices") + def test_intel_gpu_stats_unreadable_proc(self, drm_devices, read_fdinfo): + # A scan failure (None) is a different condition than a scan that + # finds no clients ({}) and must not report idle + drm_devices.return_value = {"0000:00:02.0": "i915"} + read_fdinfo.return_value = None + + assert get_intel_gpu_stats(None) is None + + @patch("frigate.stats.intel_gpu_info.intel_gpu_name_resolver.get_names") + @patch("frigate.util.services.time.sleep") + @patch("frigate.util.services.time.monotonic") + @patch("frigate.util.services._read_intel_drm_fdinfo") + @patch("frigate.util.services._enumerate_drm_devices") + def test_intel_gpu_stats_clients_lost_between_samples( + self, drm_devices, read_fdinfo, monotonic, sleep, get_names + ): + # Clients disappearing during the sample window is transient process + # churn, so report idle rather than latching an error + drm_devices.return_value = {"0000:00:02.0": "i915"} + monotonic.side_effect = [0.0, 1.0] + get_names.return_value = {"0000:00:02.0": "Intel Graphics"} + read_fdinfo.side_effect = [ + { + ("0000:00:02.0", "1", "100"): { + "driver": "i915", + "pid": "100", + "engines": {"video": (5_000_000_000, 0, 1)}, + }, + }, + {}, + ] + + assert get_intel_gpu_stats(None) == { + "0000:00:02.0": { + "name": "Intel Graphics", + "vendor": "intel", + "gpu": "0.0%", + "mem": "-%", + "compute": "0.0%", + "dec": "0.0%", + }, + } diff --git a/frigate/test/test_ptz_autotrack.py b/frigate/test/test_ptz_autotrack.py new file mode 100644 index 0000000000..1abcc780d0 --- /dev/null +++ b/frigate/test/test_ptz_autotrack.py @@ -0,0 +1,130 @@ +"""Tests for autotracker state that must survive runtime config changes. + +Regression coverage for a family of bugs where per-camera autotracker state was +built once at startup and never revisited. A camera that is added or enabled +after startup, or has autotracking enabled from the UI, would either raise a +KeyError on the autotracker thread or silently keep the wrong state: + +- autotracker_init only got an entry for cameras enabled when PtzAutoTracker was + constructed, so runtime-enabled cameras raised KeyError on lookup. +- ptz_metrics autotracker_enabled is what the camera processes read, but nothing + updated it when autotracking was enabled through a config save, so it stayed + False and the tracker never built a motion estimator. +""" + +import unittest +from unittest.mock import MagicMock + +from frigate.camera import PTZMetrics +from frigate.config import FrigateConfig +from frigate.ptz.autotrack import PtzAutoTracker + +CAMERA = "ptz_cam" + + +def _config(autotracking_enabled: bool) -> FrigateConfig: + return FrigateConfig( + **{ + "mqtt": {"enabled": False}, + "cameras": { + CAMERA: { + "ffmpeg": { + "inputs": [ + {"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]} + ] + }, + "detect": {"width": 1920, "height": 1080}, + "zones": {"zone": {"coordinates": "0,0,1,0,1,1,0,1"}}, + "onvif": { + "host": "10.0.0.1", + "autotracking": { + "enabled": autotracking_enabled, + "required_zones": ["zone"], + }, + }, + } + }, + } + ) + + +def _make_tracker(autotracking_enabled: bool = True) -> PtzAutoTracker: + """Build a PtzAutoTracker without invoking __init__, which would try to set up + onvif over the network. Only the config/metrics state is relevant here.""" + tracker = PtzAutoTracker.__new__(PtzAutoTracker) + tracker.config = _config(autotracking_enabled) + tracker.ptz_metrics = {CAMERA: PTZMetrics(autotracker_enabled=False)} + tracker.onvif = MagicMock() + tracker.config_subscriber = MagicMock() + tracker.autotracker_init = {} + tracker.calibrating = {} + tracker.tracked_object = {} + return tracker + + +class TestAutotrackerInitGuards(unittest.IsolatedAsyncioTestCase): + async def test_camera_maintenance_returns_early_when_not_initialized(self) -> None: + # a camera enabled at runtime has no autotracker_init entry, which used to + # raise KeyError and kill the autotracker thread for every camera + tracker = _make_tracker() + self.assertNotIn(CAMERA, tracker.autotracker_init) + + await tracker.camera_maintenance(CAMERA) + + tracker.onvif.get_camera_status.assert_not_called() + + async def test_camera_maintenance_returns_early_when_init_incomplete(self) -> None: + # autotracker_init is seeded False for enabled cameras before setup runs + tracker = _make_tracker() + tracker.autotracker_init[CAMERA] = False + + await tracker.camera_maintenance(CAMERA) + + tracker.onvif.get_camera_status.assert_not_called() + + +class TestAutotrackerMetricSync(unittest.TestCase): + def test_metric_follows_config_when_enabled_by_update(self) -> None: + # autotracking enabled via a config save: the metric was seeded False when + # the camera was added and nothing else updates it + tracker = _make_tracker(autotracking_enabled=True) + metrics = tracker.ptz_metrics[CAMERA] + self.assertFalse(metrics.autotracker_enabled.value) + + tracker.config_subscriber.check_for_updates.return_value = {"onvif": [CAMERA]} + tracker.check_for_updates() + + self.assertTrue(metrics.autotracker_enabled.value) + + def test_metric_follows_config_when_disabled_by_update(self) -> None: + tracker = _make_tracker(autotracking_enabled=False) + metrics = tracker.ptz_metrics[CAMERA] + metrics.autotracker_enabled.value = True + + tracker.config_subscriber.check_for_updates.return_value = { + "autotracking": [CAMERA] + } + tracker.check_for_updates() + + self.assertFalse(metrics.autotracker_enabled.value) + + def test_metric_sync_skips_camera_without_metrics(self) -> None: + # `add` reaches the maintainer and the autotracker on separate threads with + # no ordering guarantee, so the metrics may not exist yet + tracker = _make_tracker() + tracker.ptz_metrics = {} + tracker.config_subscriber.check_for_updates.return_value = {"add": [CAMERA]} + + tracker.check_for_updates() + + def test_metric_sync_skips_unknown_camera(self) -> None: + tracker = _make_tracker() + tracker.config_subscriber.check_for_updates.return_value = { + "add": ["not_in_config"] + } + + tracker.check_for_updates() + + +if __name__ == "__main__": + unittest.main() diff --git a/frigate/test/test_ptz_onvif.py b/frigate/test/test_ptz_onvif.py new file mode 100644 index 0000000000..d2dd2d046e --- /dev/null +++ b/frigate/test/test_ptz_onvif.py @@ -0,0 +1,147 @@ +"""Tests for ONVIF init state that must not depend on the autotracking config. + +Regression coverage for a camera that is initialized while autotracking is off and +has it enabled later, which is the normal wizard flow: set the camera up first, +configure autotracking afterwards. The autotracking-only request objects used to +be created only when autotracking was enabled at init time, so the camera was left +with init=True but no status_request. get_camera_status skips its re-init branch +when init is True, so it went straight to the missing key and raised KeyError on +the tracking thread. + +The request objects are built from the locally parsed WSDL and cost no network, so +they are always created and init=True now implies they exist. +""" + +import unittest +from unittest.mock import AsyncMock, MagicMock + +from frigate.config import FrigateConfig +from frigate.ptz.onvif import OnvifController + +CAMERA = "ptz_cam" + + +def _config(autotracking_enabled: bool) -> FrigateConfig: + return FrigateConfig( + **{ + "mqtt": {"enabled": False}, + "cameras": { + CAMERA: { + "ffmpeg": { + "inputs": [ + {"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]} + ] + }, + "detect": {"width": 1920, "height": 1080}, + "zones": {"zone": {"coordinates": "0,0,1,0,1,1,0,1"}}, + "onvif": { + "host": "10.0.0.1", + "autotracking": { + "enabled": autotracking_enabled, + "required_zones": ["zone"], + }, + }, + } + }, + } + ) + + +def _make_profile() -> MagicMock: + profile = MagicMock() + profile.token = "profile_1" + profile.Name = "MainStream" + profile.VideoEncoderConfiguration = MagicMock() + ptz_config = MagicMock() + ptz_config.token = "ptz_config_1" + ptz_config.DefaultContinuousPanTiltVelocitySpace = "space" + ptz_config.DefaultContinuousZoomVelocitySpace = "space" + profile.PTZConfiguration = ptz_config + return profile + + +def _make_onvif_camera() -> MagicMock: + """A camera that supports PTZ but nothing optional, so init takes the simplest + path through the feature detection below.""" + onvif = MagicMock() + onvif.update_xaddrs = AsyncMock() + + video_source = MagicMock() + video_source.token = "video_source_1" + + media = MagicMock() + media.GetProfiles = AsyncMock(return_value=[_make_profile()]) + media.GetVideoSources = AsyncMock(return_value=[video_source]) + onvif.create_media_service = AsyncMock(return_value=media) + onvif.get_definition = MagicMock(return_value={"ptz": "definition"}) + + ptz = MagicMock() + # create_type is a local WSDL lookup, so tag the result to assert on it later + ptz.create_type = MagicMock(side_effect=lambda name: MagicMock(request_type=name)) + ptz.GetConfigurationOptions = AsyncMock(side_effect=Exception("not supported")) + onvif.create_ptz_service = AsyncMock(return_value=ptz) + onvif.create_imaging_service = AsyncMock(side_effect=Exception("not supported")) + return onvif + + +def _make_controller(autotracking_enabled: bool) -> OnvifController: + """Build a controller without invoking __init__, which would start an event loop + thread and reach out to the camera.""" + config = _config(autotracking_enabled) + controller = OnvifController.__new__(OnvifController) + controller.config = config + controller.cams = {CAMERA: {"onvif": _make_onvif_camera(), "init": False}} + controller.failed_cams = {} + controller.camera_configs = {CAMERA: config.cameras[CAMERA]} + controller.ptz_metrics = {CAMERA: MagicMock()} + return controller + + +class TestOnvifInitRequests(unittest.IsolatedAsyncioTestCase): + async def test_status_request_created_when_autotracking_disabled(self) -> None: + # the wizard flow: onvif configured first, autotracking enabled later + controller = _make_controller(autotracking_enabled=False) + + self.assertTrue(await controller._init_onvif(CAMERA)) + + cam = controller.cams[CAMERA] + self.assertTrue(cam["init"]) + self.assertIn("status_request", cam) + self.assertIn("service_capabilities_request", cam) + + async def test_status_request_created_when_autotracking_enabled(self) -> None: + controller = _make_controller(autotracking_enabled=True) + + self.assertTrue(await controller._init_onvif(CAMERA)) + + cam = controller.cams[CAMERA] + self.assertIn("status_request", cam) + self.assertIn("service_capabilities_request", cam) + + async def test_init_implies_status_request_exists(self) -> None: + # the invariant get_camera_status relies on: it skips re-init when init is + # True and then reads status_request without guarding + for autotracking_enabled in (True, False): + with self.subTest(autotracking_enabled=autotracking_enabled): + controller = _make_controller(autotracking_enabled) + + await controller._init_onvif(CAMERA) + + cam = controller.cams[CAMERA] + if cam["init"]: + self.assertEqual(cam["status_request"].request_type, "GetStatus") + + async def test_requests_built_without_contacting_camera(self) -> None: + # create_type is a local WSDL lookup; cameras that do not implement + # GetServiceCapabilities must not be asked about it during init + controller = _make_controller(autotracking_enabled=False) + + await controller._init_onvif(CAMERA) + + ptz = controller.cams[CAMERA]["ptz"] + ptz.GetServiceCapabilities.assert_not_called() + ptz.GetStatus.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/frigate/util/services.py b/frigate/util/services.py index 62929d32df..38737907ef 100644 --- a/frigate/util/services.py +++ b/frigate/util/services.py @@ -285,6 +285,10 @@ _XE_ENGINE_KEYS = { "vecs": "video-enhance", "ccs": "compute", } +_INTEL_DRM_DRIVERS = ("i915", "xe") +_PCI_ADDRESS_RE = re.compile( + r"^[0-9a-fA-F]{4}:[0-9a-fA-F]{2}:[0-9a-fA-F]{2}\.[0-9a-fA-F]$" +) def _resolve_intel_gpu_pdev(device: str | None) -> str | None: @@ -294,29 +298,70 @@ def _resolve_intel_gpu_pdev(device: str | None) -> str | None: if not device: return None - if re.match(r"^[0-9a-fA-F]{4}:[0-9a-fA-F]{2}:[0-9a-fA-F]{2}\.[0-9a-fA-F]$", device): + if _PCI_ADDRESS_RE.match(device): return device name = os.path.basename(device.rstrip("/")) try: - return os.path.basename(os.path.realpath(f"/sys/class/drm/{name}/device")) + pdev = os.path.basename(os.path.realpath(f"/sys/class/drm/{name}/device")) except OSError: return None + # realpath does not raise on a nonexistent node; it returns the input + # path unchanged, so validate the result actually looks like a PCI + # address before trusting it. + return pdev if _PCI_ADDRESS_RE.match(pdev) else None -def _read_intel_drm_fdinfo(target_pdev: str | None) -> dict: + +def _enumerate_drm_devices() -> dict[str, str]: + """Map each PCI-attached DRM device to its bound kernel driver. + + Reads /sys/class/drm, which reflects every GPU on the host even when only + some render nodes are mapped into the container, so device presence can be + verified without /dev access. Returns {pdev: driver}, e.g. + {"0000:00:02.0": "i915"}. + """ + devices: dict[str, str] = {} + + try: + entries = os.listdir("/sys/class/drm") + except OSError: + return devices + + for entry in entries: + device_dir = f"/sys/class/drm/{entry}/device" + pdev = os.path.basename(os.path.realpath(device_dir)) + + if not _PCI_ADDRESS_RE.match(pdev): + continue + + try: + driver = os.path.basename(os.readlink(f"{device_dir}/driver")) + except OSError: + continue + + devices[pdev] = driver + + return devices + + +def _read_intel_drm_fdinfo(target_pdev: str | None) -> dict | None: """Snapshot DRM fdinfo for every Intel client visible in /proc. Returns a dict keyed by (pdev, drm-client-id, pid) so the same context seen via multiple file descriptors on a single process collapses to one - entry. + entry. Clients whose fdinfo carries no engine counters are still included + with an empty "engines" dict so the caller can distinguish "clients exist + but the kernel publishes no busyness" from "no clients at all". Returns + None when /proc itself cannot be scanned, which is a different failure + than a scan that finds nothing. """ snapshot: dict = {} try: proc_entries = os.listdir("/proc") except OSError: - return snapshot + return None for entry in proc_entries: if not entry.isdigit(): @@ -400,41 +445,124 @@ def _read_intel_drm_fdinfo(target_pdev: str | None) -> dict: except (ValueError, IndexError): continue - if not engines: - continue - snapshot[key] = {"driver": driver, "pid": entry, "engines": engines} return snapshot +def _idle_intel_gpu_stats( + target_pdev: str | None, intel_pdevs: dict[str, str] +) -> dict[str, dict[str, Any]]: + """Build a 0% reading for the configured (or every) Intel GPU. + + Used when the device is confirmed present but no DRM client is currently + attached, e.g. while camera processes are restarting. That is an idle + state, not a collection failure, so it must produce a valid reading: + returning None would latch the hwaccel error cooldown and blank GPU stats + for an hour over a momentary gap. + """ + from frigate.stats.intel_gpu_info import intel_gpu_name_resolver + + names = intel_gpu_name_resolver.get_names() + pdevs = [target_pdev] if target_pdev else sorted(intel_pdevs) + + return { + pdev: { + "name": names.get(pdev) or "Intel iGPU", + "vendor": "intel", + "gpu": "0.0%", + "mem": "-%", + "compute": "0.0%", + "dec": "0.0%", + } + for pdev in pdevs + } + + def get_intel_gpu_stats( intel_gpu_device: str | None, ) -> dict[str, dict[str, Any]] | None: """Get stats by reading DRM fdinfo files, bucketed per-pdev. Each DRM client FD exposes monotonic per-engine busy counters via - /proc//fdinfo/ (i915 since kernel 5.19, Xe since first release). - We sample twice and divide busy-time deltas by wall-clock to derive - utilization. Render/3D and Compute are pooled into "compute"; Video and - VideoEnhance into "dec". Overall "gpu" is the sum of those pools (clamped - to 100%). + /proc//fdinfo/. For i915 this requires kernel 6.5 or newer: + earlier kernels omit the per-engine counters whenever GuC submission is + active, which is the default on 12th gen and newer. Xe has exposed them + since its first release. We sample twice and divide busy-time deltas by + wall-clock to derive utilization. Render/3D and Compute are pooled into + "compute"; Video and VideoEnhance into "dec". Overall "gpu" is the sum of + those pools (clamped to 100%). The return value is keyed by the GPU's drm-pdev string so multiple Intel GPUs in the same system are reported separately. Each entry carries a "name" populated from OpenVINO (falling back to the pdev) so callers can surface a real device name in the UI. + + A device that exists but has no attached DRM clients reports an idle 0% + reading. None is returned only for durable failures (no Intel GPU, a bad + intel_gpu_device config, unreadable /proc, or a kernel that publishes no + counters), each of which logs a distinct warning, and the caller latches + it against retries for an hour. """ from frigate.stats.intel_gpu_info import intel_gpu_name_resolver target_pdev = _resolve_intel_gpu_pdev(intel_gpu_device) + if intel_gpu_device and not target_pdev: + logger.warning( + "Unable to collect Intel GPU stats: configured intel_gpu_device %s " + "does not exist or could not be resolved to a PCI device", + intel_gpu_device, + ) + return None + + drm_devices = _enumerate_drm_devices() + intel_pdevs = { + pdev: driver + for pdev, driver in drm_devices.items() + if driver in _INTEL_DRM_DRIVERS + } + + if not intel_pdevs: + logger.warning( + "Unable to collect Intel GPU stats: no Intel GPU (i915/xe) found in " + "/sys/class/drm. Check that the driver is loaded on the host" + ) + return None + + if target_pdev and target_pdev not in intel_pdevs: + logger.warning( + "Unable to collect Intel GPU stats: configured intel_gpu_device %s " + "resolved to %s (driver: %s), which is not an Intel GPU", + intel_gpu_device, + target_pdev, + drm_devices.get(target_pdev, "unknown"), + ) + return None snapshot_a = _read_intel_drm_fdinfo(target_pdev) + if snapshot_a is None: + logger.warning("Unable to collect Intel GPU stats: /proc could not be read") + return None + if not snapshot_a: + # No process currently holds the GPU open, e.g. while camera processes + # are restarting. The device is confirmed present, so report idle + # rather than an error; the next stats cycle re-samples normally. + logger.debug("No active DRM clients for Intel GPU, reporting idle") + return _idle_intel_gpu_stats(target_pdev, intel_pdevs) + + if not any(client["engines"] for client in snapshot_a.values()): + # Clients exist but the kernel published no busyness for them, so + # there is nothing to sample and a second snapshot would not help. + # i915 suppresses per-client engine counters while GuC submission is + # active on kernels older than 6.5 (kernel commit 1324680a80eb lifted + # this), which covers stock Debian 12 and Ubuntu 22.04 on 12th gen + # and newer. logger.warning( - "Unable to collect Intel GPU stats: no DRM fdinfo entries found" - "%s. Check that /proc is readable and the i915/xe driver is loaded", - f" for pdev {target_pdev}" if target_pdev else "", + "Unable to collect Intel GPU stats: found %d DRM client(s) for %s but " + "no per-engine counters. Kernel 6.5 or newer is required.", + len(snapshot_a), + "/".join(sorted({client["driver"] for client in snapshot_a.values()})), ) return None @@ -443,12 +571,18 @@ def get_intel_gpu_stats( elapsed_ns = (time.monotonic() - start) * 1e9 snapshot_b = _read_intel_drm_fdinfo(target_pdev) - if not snapshot_b or elapsed_ns <= 0: - logger.warning( - "Unable to collect Intel GPU stats: second DRM fdinfo sample was empty" - ) + if snapshot_b is None: + logger.warning("Unable to collect Intel GPU stats: /proc could not be read") return None + if not snapshot_b or elapsed_ns <= 0: + # Every client disappeared during the sample window; transient by + # definition, so report idle instead of latching an error. + logger.debug( + "No DRM clients persisted across Intel GPU samples, reporting idle" + ) + return _idle_intel_gpu_stats(target_pdev, intel_pdevs) + def _new_engine_pct() -> dict[str, float]: return {"render": 0.0, "video": 0.0, "video-enhance": 0.0, "compute": 0.0} @@ -460,6 +594,11 @@ def get_intel_gpu_stats( if not data_a or data_a["driver"] != data_b["driver"]: continue + # Skip before the setdefault below so a counter-less client cannot + # register its pdev on its own. + if not data_b["engines"]: + continue + pdev = key[0] engine_pct = per_pdev_engine_pct.setdefault(pdev, _new_engine_pct()) pid_pct = per_pdev_pid_pct.setdefault(pdev, {}) @@ -491,11 +630,12 @@ def get_intel_gpu_stats( pid_pct[data_b["pid"]] = pid_pct.get(data_b["pid"], 0.0) + client_total if not per_pdev_engine_pct: - logger.warning( - "Unable to collect Intel GPU stats: no per-engine counters available " - "(i915 requires kernel >= 5.19)" + # Clients were seen in both snapshots but none persisted as the same + # (pdev, client-id, pid); process churn, so report idle. + logger.debug( + "No DRM clients persisted across Intel GPU samples, reporting idle" ) - return None + return _idle_intel_gpu_stats(target_pdev, intel_pdevs) names = intel_gpu_name_resolver.get_names() results: dict[str, dict[str, Any]] = {} diff --git a/web/public/locales/en/views/system.json b/web/public/locales/en/views/system.json index 9f387a7f30..84014aaffe 100644 --- a/web/public/locales/en/views/system.json +++ b/web/public/locales/en/views/system.json @@ -107,12 +107,7 @@ }, "npuUsage": "NPU Usage", "npuMemory": "NPU Memory", - "npuTemperature": "NPU Temperature", - "intelGpuWarning": { - "title": "Intel GPU Stats Warning", - "message": "GPU stats unavailable", - "description": "This is a known bug in Intel's GPU stats reporting tools (intel_gpu_top) where it will break and repeatedly return a GPU usage of 0% even in cases where hardware acceleration and object detection are correctly running on the (i)GPU. This is not a Frigate bug. You can restart the host to temporarily fix the issue and confirm that the GPU is working correctly. This does not affect performance." - } + "npuTemperature": "NPU Temperature" }, "otherProcesses": { "title": "Other Processes", diff --git a/web/src/components/config-form/sectionExtras/BirdseyeCameraReorder.tsx b/web/src/components/config-form/sectionExtras/BirdseyeCameraReorder.tsx index b481e9a86f..f448310230 100644 --- a/web/src/components/config-form/sectionExtras/BirdseyeCameraReorder.tsx +++ b/web/src/components/config-form/sectionExtras/BirdseyeCameraReorder.tsx @@ -91,6 +91,7 @@ export default function BirdseyeCameraReorder({ try { await axios.put("config/set", { requires_restart: 0, + update_topic: "config/cameras/*/birdseye", config_data: { cameras: cameraUpdates }, }); await updateConfig(); diff --git a/web/src/components/icons/IconPicker.tsx b/web/src/components/icons/IconPicker.tsx index a4775ae21b..73f64527c3 100644 --- a/web/src/components/icons/IconPicker.tsx +++ b/web/src/components/icons/IconPicker.tsx @@ -153,11 +153,15 @@ export default function IconPicker({ } type IconRendererProps = { - icon: IconType; + icon: IconType | undefined; size?: number; className?: string; }; export function IconRenderer({ icon, size, className }: IconRendererProps) { + if (!icon) { + return null; + } + return <>{React.createElement(icon, { size, className })}; } diff --git a/web/src/components/overlay/ExportDialog.tsx b/web/src/components/overlay/ExportDialog.tsx index d4f5b14a7b..0b0cb84c13 100644 --- a/web/src/components/overlay/ExportDialog.tsx +++ b/web/src/components/overlay/ExportDialog.tsx @@ -65,6 +65,7 @@ import { Textarea } from "../ui/textarea"; import { useNavigate } from "react-router-dom"; import { useIsAdmin } from "@/hooks/use-is-admin"; import { isReplayCamera } from "@/utils/cameraUtil"; +import { isValidIconName } from "@/utils/iconUtil"; const EXPORT_OPTIONS = [ "1", @@ -1066,7 +1067,11 @@ export function ExportContent({ } > {group.name} diff --git a/web/src/views/system/GeneralMetrics.tsx b/web/src/views/system/GeneralMetrics.tsx index 6d032de840..4cba965298 100644 --- a/web/src/views/system/GeneralMetrics.tsx +++ b/web/src/views/system/GeneralMetrics.tsx @@ -470,50 +470,6 @@ export default function GeneralMetrics({ return Object.keys(series).length > 0 ? Object.values(series) : undefined; }, [statsHistory]); - // Check if Intel GPU has all 0% usage values (known bug) - const showIntelGpuWarning = useMemo(() => { - if (!statsHistory || statsHistory.length < 3) { - return false; - } - - const hasIntelGpu = Object.values(statsHistory[0]?.gpu_usages ?? {}).some( - (stats) => stats.vendor === "intel", - ); - - if (!hasIntelGpu) { - return false; - } - - // Check if all GPU usage values are 0% across all stats - let allZero = true; - let hasDataPoints = false; - - for (const stats of statsHistory) { - if (!stats) { - continue; - } - - Object.values(stats.gpu_usages || {}).forEach((gpuStats) => { - if (gpuStats.vendor !== "intel") { - return; - } - if (gpuStats.gpu) { - hasDataPoints = true; - const gpuValue = parseFloat(gpuStats.gpu.slice(0, -1)); - if (!isNaN(gpuValue) && gpuValue > 0) { - allZero = false; - } - } - }); - - if (!allZero) { - break; - } - } - - return hasDataPoints && allZero; - }, [statsHistory]); - // npu stats const npuSeries = useMemo(() => { @@ -819,46 +775,8 @@ export default function GeneralMetrics({ <> {statsHistory.length != 0 ? (
-
+
{t("general.hardwareInfo.gpuUsage")} - {showIntelGpuWarning && ( - - - - - -
-
- {t( - "general.hardwareInfo.intelGpuWarning.title", - )} -
-
- {t( - "general.hardwareInfo.intelGpuWarning.description", - )} -
-
-
-
- )}
{gpuSeries.map((series) => (