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 23645e382b..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.*
@@ -42,16 +42,16 @@ 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.*
# Generative AI
-google-genai == 2.29.*
+google-genai == 2.31.*
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.*
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.*
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/conf/auth_location.conf b/docker/main/rootfs/usr/local/nginx/conf/auth_location.conf
index 3d2ed0ccb9..2ff7bf9d9f 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;
# inputs for the cross-origin check on websocket and go2rtc stream requests
proxy_set_header Origin $http_origin;
proxy_set_header X-Original-Host $http_host;
diff --git a/docker/main/rootfs/usr/local/nginx/conf/nginx.conf b/docker/main/rootfs/usr/local/nginx/conf/nginx.conf
index 9d0c1e57d6..5884a11ed5 100644
--- a/docker/main/rootfs/usr/local/nginx/conf/nginx.conf
+++ b/docker/main/rootfs/usr/local/nginx/conf/nginx.conf
@@ -183,6 +183,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/ {
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/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/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/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/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/api/auth.py b/frigate/api/auth.py
index d080258d41..c3f8f2d623 100644
--- a/frigate/api/auth.py
+++ b/frigate/api/auth.py
@@ -472,8 +472,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
@@ -761,7 +790,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
@@ -787,20 +815,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())
@@ -823,25 +858,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
@@ -851,7 +867,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
@@ -940,7 +956,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
@@ -956,13 +971,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)
@@ -971,7 +989,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.
@@ -1037,9 +1055,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})
@@ -1058,6 +1078,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})
@@ -1125,13 +1146,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
@@ -1144,7 +1165,7 @@ async def update_password(
JWT_COOKIE_NAME,
encoded_jwt,
JWT_SESSION_LENGTH,
- JWT_COOKIE_SECURE,
+ is_secure_request(request),
)
return response
@@ -1184,6 +1205,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/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/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/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/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/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/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/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/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/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_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_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/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/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_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/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_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/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/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/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/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/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.
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(
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:
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/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 =
+ " " +
+ " " +
+ "[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/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/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/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",
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/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}>
+ ),
+ a: ({ node: _n, href, ...props }) =>
+ isSameOrigin(href) ? (
+
+ ) : (
+
+ ),
}}
>
{content}
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/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;
}
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);
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)) {