Compare commits

..
Author SHA1 Message Date
dependabot[bot]andGitHub 5c9295ffbc Bump postcss-selector-parser, tailwind-scrollbar and tailwindcss in /web
Removes [postcss-selector-parser](https://github.com/postcss/postcss-selector-parser). It's no longer used after updating ancestor dependencies [postcss-selector-parser](https://github.com/postcss/postcss-selector-parser), [tailwind-scrollbar](https://github.com/adoxography/tailwind-scrollbar) and [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss). These dependencies need to be updated together.


Removes `postcss-selector-parser`

Updates `tailwind-scrollbar` from 3.1.0 to 4.0.2
- [Release notes](https://github.com/adoxography/tailwind-scrollbar/releases)
- [Commits](https://github.com/adoxography/tailwind-scrollbar/compare/v3.1.0...v4.0.2)

Updates `tailwindcss` from 3.4.19 to 4.3.3
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.3/packages/tailwindcss)

---
updated-dependencies:
- dependency-name: postcss-selector-parser
  dependency-version:
  dependency-type: indirect
- dependency-name: tailwind-scrollbar
  dependency-version: 4.0.2
  dependency-type: direct:production
- dependency-name: tailwindcss
  dependency-version: 4.3.3
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-10 20:04:49 +00:00
328 changed files with 2194 additions and 13700 deletions
+4 -4
View File
@@ -34,15 +34,15 @@ runs:
run: sudo systemctl start docker
shell: bash
- id: lowercaseRepo
uses: ASzc/change-string-case-action@v8
uses: ASzc/change-string-case-action@v5
with:
string: ${{ github.repository }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f
uses: docker/login-action@465a07811f14bebb1938fbed4728c6a1ff8901fc
with:
registry: ghcr.io
username: ${{ github.actor }}
+22 -141
View File
@@ -1,159 +1,40 @@
version: 2
updates:
- package-ecosystem: "github-actions"
# Local composite actions are only scanned when their directory is listed
directories:
- "/"
- "/.github/actions/setup"
directory: "/"
schedule:
interval: weekly
cooldown:
default-days: 3
open-pull-requests-limit: 5
groups:
actions:
patterns: ["*"]
actions-security:
applies-to: security-updates
patterns: ["*"]
interval: daily
open-pull-requests-limit: 10
target-branch: dev
- package-ecosystem: "docker"
directory: "/docker"
schedule:
interval: daily
open-pull-requests-limit: 10
target-branch: dev
- package-ecosystem: "pip"
directory: "/docker/main"
schedule:
interval: weekly
cooldown:
default-days: 3
open-pull-requests-limit: 5
groups:
# Sets that fail pip resolution unless they move together
peewee:
patterns: ["peewee", "peewee-migrate"]
crypto:
patterns: ["cryptography", "joserfc", "pywebpush", "py-vapid"]
pydantic-genai:
patterns: ["pydantic", "google-genai"]
pip-main-minor-patch:
patterns: ["*"]
update-types: ["minor", "patch"]
pip-main-security:
applies-to: security-updates
patterns: ["*"]
ignore:
# numpy 2.5 needs Python 3.12; norfair, tflite_runtime and rknn-toolkit2
# cap numpy below 2
- dependency-name: "numpy"
versions: [">=2"]
# Every OpenCV wheel from 4.12 requires numpy 2
- dependency-name: "opencv-python-headless"
versions: [">=4.12"]
- dependency-name: "opencv-contrib-python"
versions: [">=4.12"]
# aarch64 only. Rockchip rknn-toolkit2 caps protobuf at 4.25.4 and
# TensorFlow 2.21 needs protobuf 6.31 or newer.
- dependency-name: "tensorflow"
versions: [">=2.20"]
# Needs Python 3.12 and numpy 2
- dependency-name: "librosa"
versions: [">=1.0"]
# Needs Python 3.12
- dependency-name: "scipy"
versions: [">=1.18"]
# Newer stubs report false errors against peewee 4
- dependency-name: "types-peewee"
versions: [">=4.1"]
# sdist only and imported by HailoRT; needs a Hailo device to test
- dependency-name: "netifaces"
versions: [">=0.11"]
interval: daily
open-pull-requests-limit: 10
target-branch: dev
- package-ecosystem: "pip"
directory: "/docker/tensorrt"
schedule:
interval: weekly
# No pull request check builds these images, so version updates are off.
# Security updates still open.
open-pull-requests-limit: 0
groups:
pip-trt-security:
applies-to: security-updates
patterns: ["*"]
ignore:
# onnx 1.17 and newer write a newer IR and opset than TensorRT 8.6 on
# JetPack 6 is known to parse
- dependency-name: "onnx"
versions: [">=1.17"]
# 1.27 moved to CUDA 13, and the image ships the CUDA 12.8 wheel set
- dependency-name: "onnxruntime-gpu"
versions: [">=1.27"]
# Hand-matched CUDA 12.8 set (2c9a25e67); move as a block
- dependency-name: "nvidia-*"
# Must match the JetPack CUDA runtime
- dependency-name: "cuda-python"
versions: [">=12.9"]
interval: daily
open-pull-requests-limit: 10
target-branch: dev
- package-ecosystem: "npm"
directory: "/web"
schedule:
interval: weekly
cooldown:
default-days: 3
open-pull-requests-limit: 5
groups:
radix:
patterns: ["@radix-ui/*"]
rjsf:
patterns: ["@rjsf/*"]
web-minor-patch:
patterns: ["*"]
update-types: ["minor", "patch"]
web-security:
applies-to: security-updates
patterns: ["*"]
ignore:
# typescript-eslint peers typescript below 6.1
- dependency-name: "typescript"
versions: [">=6.1"]
# Tailwind 4 is a config rewrite; scrollbar 4 and merge 3 depend on it
- dependency-name: "tailwindcss"
update-types: ["version-update:semver-major"]
- dependency-name: "tailwind-scrollbar"
update-types: ["version-update:semver-major"]
- dependency-name: "tailwind-merge"
update-types: ["version-update:semver-major"]
# Intermittent failure in the UI settings import spec, cause not found
- dependency-name: "zod"
update-types: ["version-update:semver-major"]
# Every later version clamps the pan when the player hides or drops
# pinch gestures near the edges, 4.2.0 included
- dependency-name: "react-zoom-pan-pinch"
versions: [">3.4.4"]
# 0.54 pins a vulnerable dompurify; 0.55+ breaks YAML validation until
# monaco-worker-manager releases its PR 4
- dependency-name: "monaco-editor"
versions: [">=0.54.0"]
# Later versions need Node 22 and extract false keys
- dependency-name: "i18next-cli"
versions: [">1.5.11"]
# 6.11.0 refills the saved model in the GenAI settings form
- dependency-name: "@rjsf/*"
versions: ["6.11.0"]
interval: daily
open-pull-requests-limit: 10
target-branch: dev
- package-ecosystem: "npm"
directory: "/docs"
schedule:
interval: weekly
cooldown:
default-days: 3
open-pull-requests-limit: 5
interval: daily
allow:
- dependency-name: "@docusaurus/*"
groups:
docusaurus:
patterns: ["@docusaurus/*"]
docs-security:
applies-to: security-updates
patterns: ["*"]
ignore:
# A Docusaurus major needs the openapi plugin and theme moved in the
# same commit
- dependency-name: "@docusaurus/*"
update-types: ["version-update:semver-major"]
open-pull-requests-limit: 10
target-branch: dev
+1 -1
View File
@@ -586,7 +586,7 @@ jobs:
- arm64_build
steps:
- id: lowercaseRepo
uses: ASzc/change-string-case-action@v8
uses: ASzc/change-string-case-action@v6
with:
string: ${{ github.repository }}
- name: Log in to the Container registry
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
with:
persist-credentials: false
- id: lowercaseRepo
uses: ASzc/change-string-case-action@v8
uses: ASzc/change-string-case-action@v6
with:
string: ${{ github.repository }}
- name: Log in to the Container registry
-2
View File
@@ -217,8 +217,6 @@ COPY docker/main/rootfs/ /
# Frigate deps (ffmpeg, python, nginx, go2rtc, s6-overlay, etc)
FROM slim-base AS deps
LABEL org.opencontainers.image.source="https://github.com/blakeblackshear/frigate"
ARG TARGETARCH
ARG BASE_IMAGE
+1 -1
View File
@@ -1,4 +1,4 @@
ruff == 0.17.0
ruff == 0.16.10
# types
types-peewee == 4.0.*
+1 -1
View File
@@ -1,2 +1,2 @@
numpy
openvino >= 2026.4.1
openvino >= 2026.2.0
+5 -5
View File
@@ -23,9 +23,9 @@ pandas == 3.0.*
peewee == 4.5.*
peewee_migrate == 2.3.*
psutil == 7.2.*
pydantic == 2.14.*
pydantic == 2.13.*
git+https://github.com/fbcotter/py3nvml#egg=py3nvml
tzdata == 2026.5
pytz == 2026.*
pyzmq == 27.2.*
ruamel.yaml == 0.19.*
tzlocal == 5.4.4
@@ -42,16 +42,16 @@ opencv-python-headless == 4.11.0.*
opencv-contrib-python == 4.11.0.*
scipy == 1.17.*
# OpenVino & ONNX
openvino == 2026.4.*
openvino == 2025.4.*
onnxruntime == 1.30.*
# Embeddings
transformers == 5.19.*
# Generative AI
google-genai == 2.31.*
google-genai == 2.29.*
ollama == 0.6.*
openai == 3.26.*
# push notifications
py-vapid == 1.9.6
py-vapid == 1.9.4
pywebpush == 2.5.*
# alpr
pyclipper == 1.4.*
+1 -1
View File
@@ -1 +1 @@
scikit-build == 0.19.*
scikit-build == 0.18.*
@@ -61,7 +61,7 @@ do
;;
esac
liveprint=`openssl s_client -showcerts -connect 127.0.0.1:$listen_external_port </dev/null 2>&1 | openssl x509 -fingerprint 2>&1 | grep -i fingerprint || echo 'failed'`
liveprint=`echo | openssl s_client -showcerts -connect 127.0.0.1:$listen_external_port 2>&1 | openssl x509 -fingerprint 2>&1 | grep -i fingerprint || echo 'failed'`
case "$liveprint" in
*Fingerprint*)
@@ -8,13 +8,13 @@ from typing import Any
from ruamel.yaml import YAML
sys.path.insert(0, "/opt/frigate")
from frigate.config.env import apply_config_env_vars, substitute_frigate_vars
from frigate.const import (
BIRDSEYE_PIPE,
LIBAVFORMAT_VERSION_MAJOR,
)
from frigate.ffmpeg_presets import parse_preset_hardware_acceleration_encode
from frigate.util.config import find_config_file, resolve_ffmpeg_path
from frigate.util.env import apply_config_env_vars, substitute_frigate_vars
from frigate.util.live_streams import raw_transcode_streams
from frigate.util.services import (
is_go2rtc_arbitrary_exec_allowed,
@@ -14,10 +14,7 @@ location /auth {
proxy_pass_request_headers off;
# Pass info about the request
proxy_set_header X-Original-Method $request_method;
proxy_set_header X-Original-URL $request_uri;
# set by the go2rtc stream locations; client headers are not forwarded, so
# this cannot be spoofed
proxy_set_header X-Go2rtc-Stream-Proxy $go2rtc_stream_proxy;
proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
proxy_set_header X-Server-Port $server_port;
proxy_set_header Content-Length "";
# Pass along auth related info
@@ -219,8 +219,6 @@ http {
# frigate lovelace card uses this path
location /live/mse/api/ws {
# tells /auth to apply the go2rtc stream checks
set $go2rtc_stream_proxy 1;
include auth_request.conf;
limit_except GET {
deny all;
@@ -230,8 +228,6 @@ http {
}
location /live/webrtc/api/ws {
# tells /auth to apply the go2rtc stream checks
set $go2rtc_stream_proxy 1;
include auth_request.conf;
limit_except GET {
deny all;
@@ -252,8 +248,6 @@ http {
# integration uses this to add webrtc candidate
location /api/go2rtc/webrtc {
# tells /auth to apply the go2rtc stream checks
set $go2rtc_stream_proxy 1;
include auth_request.conf;
limit_except POST {
deny all;
@@ -2,7 +2,7 @@
proxy_set_header Host $host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header X-Original-URL $request_uri;
proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-URI $request_uri;
+50 -11
View File
@@ -2,9 +2,32 @@
# 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
@@ -19,35 +42,51 @@ 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 --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
RUN pip3 uninstall -y onnxruntime \
&& pip3 install -r /requirements.txt
WORKDIR /opt/frigate
COPY --from=rootfs / /
#######################################################################
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/ /
#######################################################################
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
-63
View File
@@ -1,63 +0,0 @@
#!/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
+1 -19
View File
@@ -1,19 +1 @@
# 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
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
+11
View File
@@ -1,3 +1,6 @@
variable "ROCM" {
default = "7.2.3"
}
variable "HSA_OVERRIDE_GFX_VERSION" {
default = ""
}
@@ -5,6 +8,12 @@ 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"]
@@ -21,10 +30,12 @@ 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
}
@@ -556,7 +556,7 @@ motion:
# Optional: Whether this mask is active (default: true)
enabled: true
# Required: Coordinates polygon for the mask
coordinates: "0.000,0.469,1.0,0.469,1.0,1.0,0.000,1.0"
coordinates: "0.000,0.469,1.000,0.469,1.000,1.000,0.000,1.000"
# Optional: improve contrast (default: shown below)
# Enables dynamic contrast improvement. This should help improve night detections at the cost of making motion detection more sensitive
# for daytime.
+1 -1
View File
@@ -235,7 +235,7 @@ The FeatureList on the [ONVIF Conformant Products Database](https://www.onvif.or
| Amcrest ASH21 | ✅ | ❌ | ONVIF service port: 80 |
| Amcrest IP4M-S2112EW-AI | ✅ | ❌ | FOV relative movement not supported. |
| Amcrest IP5M-1190EW | ✅ | ❌ | ONVIF Port: 80. FOV relative movement not supported. |
| Annke CZ504 | ✅ | ❌ | |
| Annke CZ504 | ✅ | ✅ | Annke support provide specific firmware ([V5.7.1 build 250227](https://github.com/pierrepinon/annke_cz504/raw/refs/heads/main/digicap_V5-7-1_build_250227.dav)) to fix issue with ONVIF "TranslationSpaceFov" |
| Axis Q-6155E | ✅ | ❌ | ONVIF service port: 80; Camera does not support MoveStatus. |
| Ctronics PTZ | ✅ | ❌ | |
| Dahua | ✅ | ✅ | Some low-end Dahuas (lite series, picoo series (commonly), among others) have been reported to not support autotracking. These models usually don't have a four digit model number with chassis prefix and options postfix (e.g. DH-P5AE-PV vs DH-SD49825GB-HNR). |
@@ -136,8 +136,7 @@ ffmpeg:
Frigate reads Intel GPU utilization directly from the kernel's per-client DRM usage counters exposed at `/proc/<pid>/fdinfo/<fd>`. This requires:
- An Intel GPU from Broadwell (5th gen) or newer. Older GPUs do not expose these counters on any kernel.
- Linux kernel **5.19 or newer** for the `i915` driver (**6.5 or newer** on 12th gen and newer), or any release of the `xe` driver.
- Linux kernel **5.19 or newer** for the `i915` driver, or any release of the `xe` driver.
- Frigate running with permission to read other processes' fdinfo. Running as root inside the container (the default) satisfies this; non-root setups may need `CAP_SYS_PTRACE`.
No `intel_gpu_top` binary, `CAP_PERFMON`, privileged mode, or `perf_event_paranoid` tuning is required.
+1 -1
View File
@@ -38,7 +38,7 @@ Frigate [defaults to MSE](#why-does-frigate-prefer-mse-over-webrtc-for-live-view
- The choices are saved **per device, per camera** in your browser's local storage.
- **WebRTC is only selectable when it can actually work for that stream.** When it can't, the option is shown disabled with the reason inline, and a more detailed reason (the failing codecs, or why the connectivity check failed) is logged to your browser's console. Common reasons:
- **Not configured**: no `candidates` or `ice_servers` are set under `go2rtc.webrtc`, and the connectivity check failed with go2rtc's default candidates (see [WebRTC extra configuration](#webrtc-extra-configuration)). This is a setup note rather than an error, since live view works without WebRTC.
- **Not configured**: no `candidates` or `ice_servers` are set under `go2rtc.webrtc` (see [WebRTC extra configuration](#webrtc-extra-configuration)).
- **Could not connect**: e.g. port `8555` isn't reachable, or a STUN/TURN server is misconfigured. Frigate runs a one-time WebRTC connectivity check when the Live view opens; the option may briefly show as "checking" while it runs.
- **Unsupported video codec**: the stream's video codec can't be played over WebRTC in your browser, most commonly H.265/HEVC in Firefox or Edge.
- **Unsupported audio codec**: WebRTC needs opus or G.711 audio, so a stream whose playback audio is only AAC (without an added opus/G.711 track) can't carry audio over WebRTC. See [Audio Support](#audio-support) for how to add one.
+2 -2
View File
@@ -82,13 +82,13 @@ objects:
person_filter1:
friendly_name: "Roof area"
enabled: true
coordinates: "0.000,0.000,1.0,0.000,1.0,0.400,0.000,0.400"
coordinates: "0.000,0.000,1.000,0.000,1.000,0.400,0.000,0.400"
car:
mask:
car_filter1:
friendly_name: "Sidewalk area"
enabled: true
coordinates: "0.000,0.700,1.0,0.700,1.0,1.0,0.000,1.0"
coordinates: "0.000,0.700,1.000,0.700,1.000,1.000,0.000,1.000"
```
</TabItem>
+18 -26
View File
@@ -394,22 +394,6 @@ 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.
@@ -436,10 +420,19 @@ For reference on recommended settings see [running ROCm/pytorch in Docker](https
### Docker settings for overriding the GPU chipset
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`:
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:
```bash
$ docker run -e HSA_OVERRIDE_GFX_VERSION=10.3.0 \
$ docker run -e HSA_OVERRIDE_GFX_VERSION=10.0.0 \
...
```
@@ -450,17 +443,16 @@ services:
frigate:
...
environment:
HSA_OVERRIDE_GFX_VERSION: "10.3.0"
HSA_OVERRIDE_GFX_VERSION: "10.0.0"
```
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.
Figuring out what version you need can be complicated as you can't tell the chipset name and driver from the AMD brand name.
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
- 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
#### Figuring out if AMD/ROCm is working and found your GPU
+2 -2
View File
@@ -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 and recent integrated GPUs to provide efficient object detection
- [ROCm](#rocm---amd-gpu): ROCm can run on AMD Discrete GPUs to provide efficient object detection
- [Supports limited model architectures](../../configuration/object_detectors#amdrocm-gpu-detector)
- Runs best on discrete AMD GPUs
- <CommunityBadge /> [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 AMD discrete and integrated GPUs, see the [supported GPUs](../configuration/object_detectors.md#supported-gpus-rocm).
With the [ROCm](../configuration/object_detectors.md#amdrocm-gpu-detector) detector Frigate can take advantage of many discrete AMD GPUs.
| Name | YOLOv9 Inference Time | YOLO-NAS Inference Time | RF-DETR Inference Time |
| -------------- | --------------------------- | ------------------------- | ---------------------- |
+2 -2
View File
@@ -78,9 +78,9 @@ Users of the Snapcraft build of Docker cannot use storage locations outside your
Frigate utilizes shared memory to store frames during processing. The default `shm-size` provided by Docker is **64MB**.
Frigate needs about **210MB** for **2 cameras** detecting at **720p**. If Frigate is exiting with "Bus error" messages, it is likely because you have too many high resolution cameras and you need to specify a higher shm size, using [`--shm-size`](https://docs.docker.com/engine/reference/run/#runtime-constraints-on-resources) (or [`service.shm_size`](https://docs.docker.com/compose/compose-file/compose-file-v2/#shm_size) in Docker Compose). If raising the shm size does not help, check your [process and file limits](#process-and-file-limits) as well.
The default shm size of **128MB** is fine for setups with **2 cameras** detecting at **720p**. If Frigate is exiting with "Bus error" messages, it is likely because you have too many high resolution cameras and you need to specify a higher shm size, using [`--shm-size`](https://docs.docker.com/engine/reference/run/#runtime-constraints-on-resources) (or [`service.shm_size`](https://docs.docker.com/compose/compose-file/compose-file-v2/#shm_size) in Docker Compose). If raising the shm size does not help, check your [process and file limits](#process-and-file-limits) as well.
The Frigate container also stores logs and an nginx cache in shm, which can take up to **50MB**. The calculator below includes this, along with room for 2 cameras added while Frigate is running. Its result holds about 25 frames per camera, a little above the 20 frame minimum Frigate warns below. Frigate keeps up to 50 when there is room, which gives slow processes more time to read each frame.
The Frigate container also stores logs in shm, which can take up to **40MB**, so make sure to take this into account in your math as well.
<ShmCalculator/>
+7 -7
View File
@@ -5,7 +5,7 @@ title: Updating
# Updating Frigate
The current stable version of Frigate is **0.18.2**. The release notes and any breaking changes for this version can be found on the [Frigate GitHub releases page](https://github.com/blakeblackshear/frigate/releases/tag/v0.18.2).
The current stable version of Frigate is **0.18.0**. The release notes and any breaking changes for this version can be found on the [Frigate GitHub releases page](https://github.com/blakeblackshear/frigate/releases/tag/v0.18.0).
Keeping Frigate up to date ensures you benefit from the latest features, performance improvements, and bug fixes. The update process varies slightly depending on your installation method (Docker, Home Assistant App, etc.). Below are instructions for the most common setups.
@@ -31,21 +31,21 @@ If you’re running Frigate via Docker (recommended method), follow these steps:
2. **Update and Pull the Latest Image**:
- If using Docker Compose:
- Edit your `docker-compose.yml` file to specify the desired version tag (e.g., `0.18.2` instead of `0.17.1`). For example:
- Edit your `docker-compose.yml` file to specify the desired version tag (e.g., `0.18.0` instead of `0.17.1`). For example:
```yaml
services:
frigate:
image: ghcr.io/blakeblackshear/frigate:0.18.2
image: ghcr.io/blakeblackshear/frigate:0.18.0
```
- Then pull the image:
```bash
docker pull ghcr.io/blakeblackshear/frigate:0.18.2
docker pull ghcr.io/blakeblackshear/frigate:0.18.0
```
- **Note for `stable` Tag Users**: If your `docker-compose.yml` uses the `stable` tag (e.g., `ghcr.io/blakeblackshear/frigate:stable`), you don’t need to update the tag manually. The `stable` tag always points to the latest stable release after pulling.
- If using `docker run`:
- Pull the image with the appropriate tag (e.g., `0.18.2`, `0.18.2-tensorrt`, or `stable`):
- Pull the image with the appropriate tag (e.g., `0.18.0`, `0.18.0-tensorrt`, or `stable`):
```bash
docker pull ghcr.io/blakeblackshear/frigate:0.18.2
docker pull ghcr.io/blakeblackshear/frigate:0.18.0
```
3. **Start the Container**:
@@ -100,7 +100,7 @@ If an update causes issues:
1. Stop Frigate.
2. Restore your backed-up config file and database.
3. Revert to the previous image version:
- For Docker: Specify an older tag (e.g., `ghcr.io/blakeblackshear/frigate:0.18.1`) in your `docker run` command.
- For Docker: Specify an older tag (e.g., `ghcr.io/blakeblackshear/frigate:0.17.1`) in your `docker run` command.
- For Docker Compose: Edit your `docker-compose.yml`, specify the older version tag (e.g., `ghcr.io/blakeblackshear/frigate:0.16.4`), and re-run `docker compose up -d`.
- For Home Assistant: Restore from the app/addon backup you took before you updated.
4. Verify the old version is running again.
-67
View File
@@ -45,8 +45,6 @@ There are many solutions available to implement reverse proxies and the communit
- [Nginx](#nginx-reverse-proxy)
- [Traefik](#traefik-reverse-proxy)
- [Caddy](#caddy-reverse-proxy)
- [HAProxy](#haproxy)
- [OPNSense HAProxy Add-on](#opnsense-haproxy-add-on)
## Apache2 Reverse Proxy
@@ -220,68 +218,3 @@ frigate.YOUR_DOMAIN.TLD {
}
```
## HAProxy
This example is for a pure haproxy installation on a dedicated host. It assumes you have already combined the certificate parts in de cert file.
The frontend config:
```# Public Service: SSLSecured (Frigate)
frontend SSLSecured
bind XXX.XXX.XXX.XXX:443 ssl crt /etc/haproxy/certs/frigate.cert
mode http
option http-keep-alive
default_backend NVR
# CONDITION: NVR
acl acl_frigate hdr(host) -i nvr.example.video
# RULE: NVR
use_backend NVR if acl_frigate
```
The backend config:
```# Backend Pool: NVR (Frigate)
backend NVR
mode http
balance source
# stickiness
stick-table type ipv4 size 50k expire 30m
stick on src
http-reuse safe
server Frigate XXX.XXX.XXX.XXX:8971
```
The above config is created for a Layer 7 proxy (application layer, in this case http), if streaming the camera feeds are a little spotty or somewhat unstable, you can try to change the *mode* from http to tcp in both the frontend and backend config, which sets up a Layer 4 proxy (proxy by protocol). Make sure you change hdr(host) to req.ssl_sni in the frontend Condition to make the host based condition work on tcp traffic.
## OPNSense HAProxy add-on
This guide is for the OPNSense firewall add-on *os-haproxy* and is configured and tested on version 5.1 of the add-on on OPNSense 26.7.
The guide starts where you are already done setting up the system, *have a certificate added to the certificate store* and *installed the plugin/add-on*.
Step by step plan:
1. Go to Services -> HAProxy, click on Settings;
2. Add a Real Server under *Real Servers*;
3. Fill in the fields *Name or Prefix* and *Description*;
4. set *Type* to **static**;
5. fill in the frigate IP in the *FQDN or IP* field;
6. under *Common Options* fill *Port* with the port number for your Frigate (e.g. 8971) and
7. Click **Save** to store this configuration.
8. Under *Virtual Services* open *Backend Pools*;
9. Add a *Backend Pool*;
10. Fill in the fields *Name or Prefix* and *Description* but use something else then what you used under *Real Servers* otherwise if and when rules and conditions are needed it gets really confusing really fast, the use of them is out of the scope of this guide for a basic functioning haproxy add-on;
11. set *Mode* to **HTTP (Layer 7)**;
12. set *Balancing Algorithm* to **Source-IP Hash**;
13. set *Servers* by choosing the *Real Servers* instance you added in step 2->6;
14. Click **Save** to store this configuration.
15. Under *Virtual Services* open *Public Services*;
16. Add a *Public Service*;
17. Fill in the fields *Name or Prefix* and *Description* but use something else then what you used under *Real Servers* otherwise if and when rules and conditions are needed it gets really confusing really fast, the use of them is out of the scope of this guide for a basic functioning haproxy add-on;
18. Fill in the external address the haproxy instance will be listening on in *Listen Addresses*;
19. set *Type* to **HTTP/HTTPS (SSL offloading)**
20. set *Default Backend Pool* to the option carrying *the name you used in step 9*;
21. check *Enable SSL Offloading*;
22. Under *SSL Offloading* you set *Default certificate* to the certificate you added before you started following this guide;
23. Click **Save** to store this configuration.
24. Click **Apply** to reload the haproxy config on the service running.
**NOTE: This will forward _ALL_ requests on port 443 to your frigate instance.** If that is not what you want or have more then one website alive behind the haproxy you will need to create a *Condition* to trigger when the right URL is used and a *Rules* rule to use the right backend under *Rules & Checks* when the condition is met. This is a useful hint for those who need it, though it is out of the scope of this guide.
-8
View File
@@ -53,14 +53,6 @@ go2rtc:
Point the camera's inputs at the restream as described in the [restream docs](/configuration/restream.md), and swap `detect -> width` and `detect -> height` to match the rotated resolution.
### Can I add a privacy mask to hide part of my camera's view?
Frigate does not have privacy masks. [Motion masks and object filter masks](../configuration/masks.md) only affect detection, they don't hide anything in live view, recordings, or snapshots.
Privacy masks are best configured in the camera's firmware settings so the area is blacked out before the video ever leaves the camera and no extra processing is needed. Check there first.
If your camera does not support privacy masks, there is no efficient alternative. Frigate copies the camera's video into recordings and live view without decoding it, so part of the image can't be hidden without transcoding (re-encoding) the stream. This can be done with a custom ffmpeg filter in go2rtc, but it is not recommended. Every masked camera needs a continuous re-encode, which significantly increases CPU usage, especially for high resolution streams.
### My mjpeg stream or snapshots look green and crazy
This almost always means that the width/height defined for your camera are not correct. Double check the resolution with VLC or another player. Also make sure you don't have the width and height values backwards.
+30 -49
View File
@@ -2,53 +2,42 @@ import React, { useState, useEffect } from "react";
import Admonition from "@theme/Admonition";
import styles from "./styles.module.css";
// follows calculate_shm_requirements() in frigate/util/services.py, but sizes
// for 25 frames so float rounding can't land under its 20 frame warning
const MB = 1048576;
const RESERVED_MB = 50; // logs + nginx cache
const TARGET_FRAMES = 25;
const DEFAULT_FRAMES = 50;
const frameMb = (width, height) =>
Math.round(((width * height * 1.5 + 270480) / MB) * 10) / 10;
// room for 2 cameras added at runtime, sized as 720p
const addedCamerasMb = 2 * frameMb(1280, 720);
const ShmCalculator = () => {
const [width, setWidth] = useState(1280);
const [height, setHeight] = useState(720);
const [cameraCount, setCameraCount] = useState(1);
const [result, setResult] = useState("-");
const [singleCameraShm, setSingleCameraShm] = useState("-");
const [addedCamerasShm, setAddedCamerasShm] = useState("-");
const [fullRingShm, setFullRingShm] = useState("-");
const [result, setResult] = useState("26.32MB");
const [singleCameraShm, setSingleCameraShm] = useState("26.32MB");
const [totalShm, setTotalShm] = useState("26.32MB");
const calculate = () => {
if (!width || !height || !cameraCount) {
setResult("Please enter valid values");
setSingleCameraShm("-");
setAddedCamerasShm("-");
setFullRingShm("-");
setTotalShm("-");
return;
}
const cameraFramesMb = cameraCount * frameMb(width, height) + addedCamerasMb;
// Single camera base SHM calculation (excluding logs)
// Formula: (width * height * 1.5 * 20 + 270480) / 1048576
const singleCameraBase =
(width * height * 1.5 * 20 + 270480) / 1048576;
setSingleCameraShm(`${singleCameraBase.toFixed(2)}mb`);
setSingleCameraShm(formatMb(frameMb(width, height) * TARGET_FRAMES));
setAddedCamerasShm(formatMb(addedCamerasMb * TARGET_FRAMES));
setResult(formatMb(Math.round(RESERVED_MB + cameraFramesMb * TARGET_FRAMES)));
setFullRingShm(
formatMb(Math.round(RESERVED_MB + cameraFramesMb * DEFAULT_FRAMES)),
);
};
// Total SHM calculation (multiple cameras, including logs)
const totalBase = singleCameraBase * cameraCount;
const finalResult = totalBase + 40; // Default includes logs +40mb
const formatMb = (value) => {
if (value >= 1024) {
return `${(value / 1024).toFixed(2)}gb`;
setTotalShm(`${(totalBase + 40).toFixed(2)}mb`);
// Format result
if (finalResult < 1) {
setResult(`${(finalResult * 1024).toFixed(2)}kb`);
} else if (finalResult >= 1024) {
setResult(`${(finalResult / 1024).toFixed(2)}gb`);
} else {
setResult(`${finalResult.toFixed(2)}mb`);
}
return `${Number(value.toFixed(2))}mb`;
};
const formatWithUnit = (value) => {
@@ -158,27 +147,19 @@ const ShmCalculator = () => {
</div>
<div className={styles.formulaDisplay}>
<p>
<strong>Per Camera:</strong> {formatWithUnit(singleCameraShm)}
<strong>Single Camera:</strong> {formatWithUnit(singleCameraShm)}
</p>
<p>
<strong>Formula:</strong> (width × height × 1.5 + 270480) ÷
1048576 × {TARGET_FRAMES} frames
<strong>Formula:</strong> (width × height × 1.5 × 20 + 270480) ÷
1048576
</p>
{cameraCount > 1 && (
<p>
<strong>Total ({cameraCount} cameras):</strong> {formatWithUnit(totalShm)}
</p>
)}
<p>
<strong>Room for 2 Added Cameras:</strong>{" "}
{formatWithUnit(addedCamerasShm)}
</p>
<p>
<strong>Logs and Cache:</strong> + {RESERVED_MB}
<span className={styles.unit}>mb</span>
</p>
<p>
<strong>Birdseye Restream:</strong> + 8
<span className={styles.unit}>mb</span> if enabled
</p>
<p>
<strong>For {DEFAULT_FRAMES} Frames:</strong>{" "}
{formatWithUnit(fullRingShm)}
<strong>With Logs:</strong> + 40<span className={styles.unit}>mb</span>
</p>
</div>
</div>
+7 -7
View File
@@ -2131,7 +2131,7 @@ paths:
required: false
schema:
type: string
default: UTC
default: utc
title: Timezone
responses:
'200':
@@ -3266,7 +3266,7 @@ paths:
anyOf:
- type: string
- type: 'null'
default: UTC
default: utc
title: Timezone
responses:
'200':
@@ -4666,7 +4666,7 @@ paths:
anyOf:
- type: string
- type: 'null'
default: UTC
default: utc
title: Timezone
responses:
'200':
@@ -4928,7 +4928,7 @@ paths:
anyOf:
- type: string
- type: 'null'
default: UTC
default: utc
title: Timezone
- name: min_score
in: query
@@ -5008,7 +5008,7 @@ paths:
anyOf:
- type: string
- type: 'null'
default: UTC
default: utc
title: Timezone
- name: has_clip
in: query
@@ -7367,7 +7367,7 @@ paths:
required: false
schema:
type: string
default: UTC
default: utc
title: Timezone
- name: cameras
in: query
@@ -7417,7 +7417,7 @@ paths:
required: false
schema:
type: string
default: UTC
default: utc
title: Timezone
responses:
'200':
+1 -3
View File
@@ -15,9 +15,7 @@ from frigate.util.config import find_config_file
def main() -> None:
# Fork this manager so the forkserver does not start until the first
# worker process, after the config has applied environment_vars.
manager = mp.get_context("fork").Manager()
manager = mp.Manager()
faulthandler.enable()
# Setup the logging thread
+2 -80
View File
@@ -78,8 +78,6 @@ from frigate.util.live_streams import (
from frigate.util.object_names import get_categorized_object_names
from frigate.util.schema import get_config_schema
from frigate.util.services import (
Go2rtcStreamIssue,
find_restricted_go2rtc_stream_changes,
get_nvidia_driver_info,
process_logs,
restart_frigate,
@@ -548,63 +546,6 @@ def config_raw():
)
def _go2rtc_streams(raw_config: str) -> Any:
"""Load go2rtc.streams with the same loader Frigate and go2rtc startup use.
Raises ruamel.yaml.YAMLError when the config cannot be parsed.
"""
loaded = ruamel.yaml.YAML().load(raw_config)
go2rtc = loaded.get("go2rtc") if isinstance(loaded, dict) else None
return go2rtc.get("streams") if isinstance(go2rtc, dict) else None
def _restricted_go2rtc_streams_response(
old_raw_config: str, new_raw_config: str
) -> JSONResponse | None:
"""Reject config changes that add restricted go2rtc stream sources.
The same sources are rejected by PUT /go2rtc/streams, and go2rtc would
otherwise load them from the saved config on the next restart.
"""
try:
new_streams = _go2rtc_streams(new_raw_config)
except ruamel.yaml.YAMLError:
logger.warning("Rejected config change that could not be parsed")
return JSONResponse(
content={"success": False, "message": "Unable to parse config"},
status_code=400,
)
try:
old_streams = _go2rtc_streams(old_raw_config)
except ruamel.yaml.YAMLError:
# with no readable saved config, every source is treated as new
old_streams = None
issues = find_restricted_go2rtc_stream_changes(old_streams, new_streams)
if not issues:
return None
messages = []
for issue, label in (
(Go2rtcStreamIssue.restricted, "Restricted go2rtc stream source in"),
(Go2rtcStreamIssue.unknown_variable, "Unknown variable in go2rtc stream"),
(
Go2rtcStreamIssue.invalid_placeholder,
"Invalid variable placeholder in go2rtc stream",
),
):
names = [name for name, found in issues.items() if found == issue]
if names:
messages.append(f"{label}: {', '.join(names)}")
logger.warning("Rejected config change: %s", "; ".join(messages))
return JSONResponse(
content={"success": False, "message": "; ".join(messages)},
status_code=400,
)
@router.post("/config/save", dependencies=[Depends(require_role(["admin"]))])
def config_save(save_option: str, body: Any = Body(media_type="text/plain")):
new_config = body.decode()
@@ -681,22 +622,10 @@ def config_save(save_option: str, body: Any = Body(media_type="text/plain")):
status_code=400,
)
config_file = find_config_file()
try:
with open(config_file) as f:
old_raw_config = f.read()
except OSError:
old_raw_config = ""
restricted_response = _restricted_go2rtc_streams_response(
old_raw_config, new_config
)
if restricted_response is not None:
return restricted_response
# Save the config to file
try:
config_file = find_config_file()
with open(config_file, "w") as f:
f.write(new_config)
f.close()
@@ -1031,13 +960,6 @@ def config_set(request: Request, body: AppConfigSetBody):
),
status_code=400,
)
restricted_response = _restricted_go2rtc_streams_response(
old_raw_config, new_raw_config
)
if restricted_response is not None:
with open(config_file, "w") as f:
f.write(old_raw_config)
return restricted_response
except Exception as e:
logging.error(f"Error updating config: {e}")
return JSONResponse(
+18 -67
View File
@@ -12,9 +12,8 @@ import threading
import time
from datetime import datetime
from pathlib import Path
from urllib.parse import parse_qs
from urllib.parse import parse_qs, urlparse
import requests
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from fastapi.responses import JSONResponse, RedirectResponse
from joserfc import jwt
@@ -32,8 +31,6 @@ from frigate.api.media_auth import (
check_camera_access,
deny_response_for_media_uri,
is_role_restricted,
normalize_request_path,
split_request_uri,
)
from frigate.config import AuthConfig, ProxyConfig
from frigate.const import CONFIG_DIR, JWT_SECRET_ENV_VAR, PASSWORD_HASH_ALGORITHM
@@ -316,10 +313,14 @@ def get_remote_addr(request: Request):
route = list(reversed(forwarded_for.split(",")))
logger.debug(f"IP Route: {[r for r in route]}")
trusted_proxies = [
ipaddress.ip_network(proxy, strict=False)
for proxy in request.app.frigate_config.auth.trusted_proxies
]
trusted_proxies = []
for proxy in request.app.frigate_config.auth.trusted_proxies:
try:
network = ipaddress.ip_network(proxy)
except ValueError:
logger.warning(f"Unable to parse trusted network: {proxy}")
continue
trusted_proxies.append(network)
# return the first remote address that is not trusted
for addr in route:
@@ -1002,7 +1003,7 @@ def create_user(
HASH_ITERATIONS = request.app.frigate_config.auth.hash_iterations
config_roles = list(request.app.frigate_config.auth.roles.keys())
if not re.match(r"^(?!\.+$)[A-Za-z0-9._]+$", body.username):
if not re.match("^[A-Za-z0-9._]+$", body.username):
return JSONResponse(content={"message": "Invalid username"}, status_code=400)
if body.role not in config_roles:
@@ -1228,44 +1229,11 @@ def _get_stream_owner_cameras(request: Request, stream_name: str) -> set[str]:
return owner_cameras
def _is_existing_go2rtc_stream(request: Request, stream_name: str) -> bool:
"""Check that `stream_name` names a stream go2rtc already knows about.
go2rtc turns any `src` that is not an existing stream name into a new ad hoc
stream and starts the producer it describes, so the proxied live-stream
paths must only ever reference existing streams.
"""
frigate_config = request.app.frigate_config
if _get_stream_owner_cameras(request, stream_name):
return True
config_streams = getattr(frigate_config.go2rtc, "streams", None) or {}
if stream_name in config_streams:
return True
# streams added at runtime through PUT /go2rtc/streams only exist in go2rtc
try:
r = requests.get("http://127.0.0.1:1984/api/streams", timeout=2)
except requests.RequestException:
logger.warning("Unable to fetch streams from go2rtc to authorize request")
return False
if not r.ok:
return False
try:
return stream_name in r.json()
except ValueError:
return False
# nginx proxies these paths straight to go2rtc with authentication-only checks
# (see auth_request.conf). Each names the desired stream via the `src` query
# param, so the camera-level check must happen here in the `/auth` subrequest —
# `require_go2rtc_stream_access` only guards the REST `/go2rtc/streams/{name}`
# endpoint, not these proxied live-stream paths. The nginx locations are prefix
# matches, so anything under these paths is proxied too.
# endpoint, not these proxied live-stream paths.
GO2RTC_STREAM_PROXY_PATHS = frozenset(
{
"/live/mse/api/ws",
@@ -1275,48 +1243,31 @@ GO2RTC_STREAM_PROXY_PATHS = frozenset(
)
def _is_go2rtc_stream_proxy_request(path: str, request: Request) -> bool:
# nginx marks requests routed through the go2rtc stream locations, and the
# normalized path catches them if that marker is ever missing
if request.headers.get("x-go2rtc-stream-proxy") == "1":
return True
normalized = normalize_request_path(path)
return any(normalized.startswith(p) for p in GO2RTC_STREAM_PROXY_PATHS)
def deny_response_for_go2rtc_stream(
original_url: str | None, role: str | None, request: Request
) -> int | None:
"""Block go2rtc live-stream requests the current role may not make.
"""Block role-restricted users from go2rtc live streams they cannot access.
Returns 403 when any `src` named in `original_url` is not an existing go2rtc
stream (for every role), or when it resolves to a camera outside a
restricted role's allow-list (or no `src` is provided for such a role),
otherwise None. Mirrors the resolution logic in
Returns 403 when any `src` stream named in `original_url` resolves to a
camera outside the role's allow-list (or when no `src` is provided on a
stream-proxy path), otherwise None. Mirrors the resolution logic in
`require_go2rtc_stream_access` so substream names map to their owning
camera correctly.
"""
if not original_url:
return None
path, query = split_request_uri(original_url)
if not _is_go2rtc_stream_proxy_request(path, request):
parsed = urlparse(original_url)
if parsed.path not in GO2RTC_STREAM_PROXY_PATHS:
return None
frigate_config = request.app.frigate_config
sources = parse_qs(query, keep_blank_values=True).get("src", [])
# an unknown src would make go2rtc create an ad hoc stream from it, which
# bypasses the restricted-source checks on PUT /go2rtc/streams
for src in sources:
if not _is_existing_go2rtc_stream(request, src):
return 403
# admin and full-access roles (no allow-list) bypass the camera check
if not role or not is_role_restricted(role, frigate_config):
return None
sources = parse_qs(parsed.query).get("src", [])
if not sources:
# a stream-proxy request naming no stream has nothing legitimate to
# show a restricted user
+7 -12
View File
@@ -33,15 +33,11 @@ from frigate.config.camera.updater import (
CameraConfigUpdateEnum,
CameraConfigUpdateTopic,
)
from frigate.config.env import UnknownVariableError, substitute_frigate_vars
from frigate.models import User
from frigate.util.builtin import (
clean_camera_user_pass,
clear_orphaned_comments,
get_record_segment_time,
)
from frigate.util.builtin import clean_camera_user_pass, get_record_segment_time
from frigate.util.camera_cleanup import cleanup_camera_db, cleanup_camera_files
from frigate.util.config import find_config_file
from frigate.util.env import UnknownVariableError, substitute_frigate_vars
from frigate.util.image import run_ffmpeg_snapshot
from frigate.util.live_streams import (
generated_transcode_streams,
@@ -51,7 +47,7 @@ from frigate.util.live_streams import (
from frigate.util.services import (
analyze_record_keyframes,
ffprobe_stream,
is_restricted_go2rtc_api_source,
is_restricted_go2rtc_source,
)
logger = logging.getLogger(__name__)
@@ -157,9 +153,9 @@ def go2rtc_camera_stream(request: Request, stream_name: str):
)
def go2rtc_add_stream(request: Request, stream_name: str, src: str = ""):
"""Add or update a go2rtc stream configuration."""
if src and is_restricted_go2rtc_api_source(src):
if src and is_restricted_go2rtc_source(src):
logger.warning(
"Rejected go2rtc stream '%s' with restricted source",
"Rejected go2rtc stream '%s' with restricted source type (echo/expr/exec)",
stream_name,
)
return JSONResponse(
@@ -178,9 +174,9 @@ def go2rtc_add_stream(request: Request, stream_name: str, src: str = ""):
except UnknownVariableError:
resolved_src = src
if is_restricted_go2rtc_api_source(resolved_src):
if is_restricted_go2rtc_source(resolved_src):
logger.warning(
"Rejected go2rtc stream '%s' with restricted source",
"Rejected go2rtc stream '%s' with restricted source type (echo/expr/exec)",
stream_name,
)
return JSONResponse(
@@ -1277,7 +1273,6 @@ async def delete_camera(
# Remove camera from config
if "cameras" in data and camera_name in data["cameras"]:
del data["cameras"][camera_name]
clear_orphaned_comments(data["cameras"], data, "cameras")
# Remove camera from auth roles
auth = data.get("auth", {})
+1 -8
View File
@@ -1,6 +1,5 @@
"""Object classification APIs."""
import asyncio
import datetime
import logging
import os
@@ -49,9 +48,6 @@ logger = logging.getLogger(__name__)
router = APIRouter(tags=[Tags.classification])
# concurrent collections for one model share its temp and train directories
example_collection_lock = asyncio.Lock()
def invalid_name_response(value: str) -> JSONResponse:
"""Response for a name that cannot be used as a path component."""
@@ -1294,10 +1290,7 @@ async def generate_object_examples(request: Request, body: GenerateObjectExample
if model_name is None:
return invalid_name_response(body.model_name)
async with example_collection_lock:
await asyncio.to_thread(
collect_object_classification_examples, model_name, body.label
)
collect_object_classification_examples(model_name, body.label)
return JSONResponse(
content={"success": True, "message": "Example generation completed"},
@@ -7,4 +7,4 @@ class AppTimelineHourlyQueryParameters(BaseModel):
after: float | None = None
before: float | None = None
limit: int | None = 200
timezone: str | None = "UTC"
timezone: str | None = "utc"
@@ -40,7 +40,7 @@ class EventsQueryParams(BaseModel):
max_length: float | None = None
event_id: str | None = None
sort: str | None = None
timezone: str | None = "UTC"
timezone: str | None = "utc"
class EventsSearchQueryParams(BaseModel):
@@ -68,7 +68,7 @@ class EventsSearchQueryParams(BaseModel):
has_clip: bool | None = None
has_snapshot: bool | None = None
is_submitted: bool | None = None
timezone: str | None = "UTC"
timezone: str | None = "utc"
min_score: float | None = None
max_score: float | None = None
min_speed: float | None = None
@@ -78,6 +78,6 @@ class EventsSearchQueryParams(BaseModel):
class EventsSummaryQueryParams(BaseModel):
timezone: str | None = "UTC"
timezone: str | None = "utc"
has_clip: int | None = None
has_snapshot: int | None = None
@@ -3,7 +3,7 @@ from pydantic.json_schema import SkipJsonSchema
class MediaRecordingsSummaryQueryParams(BaseModel):
timezone: str = "UTC"
timezone: str = "utc"
cameras: str | None = "all"
@@ -19,7 +19,7 @@ class ReviewSummaryQueryParams(BaseModel):
cameras: str = "all"
labels: str = "all"
zones: str = "all"
timezone: str = "UTC"
timezone: str = "utc"
class ReviewActivityMotionQueryParams(BaseModel):
+15 -21
View File
@@ -58,7 +58,6 @@ from frigate.api.defs.response.export_response import (
)
from frigate.api.defs.response.generic_response import GenericResponse
from frigate.api.defs.tags import Tags
from frigate.api.media_auth import deny_response_for_media_uri
from frigate.const import CLIPS_DIR, EXPORT_DIR
from frigate.jobs.export import (
ExportJob,
@@ -80,7 +79,6 @@ from frigate.record.export import (
validate_ffmpeg_args,
)
from frigate.util.path import sanitize_contained_path
from frigate.util.recording_coverage import recording_overlap
from frigate.util.time import is_current_hour
logger = logging.getLogger(__name__)
@@ -136,7 +134,6 @@ def _validate_export_case(export_case_id: str | None) -> JSONResponse | None:
def _sanitize_existing_image(
request: Request,
image_path: str | None,
) -> tuple[str | None, JSONResponse | None]:
if not image_path:
@@ -144,15 +141,6 @@ def _sanitize_existing_image(
existing_image = sanitize_contained_path(image_path, CLIPS_DIR)
# CLIPS_DIR is shared by every camera, so the caller must also be allowed
# to read the image.
if existing_image is not None and deny_response_for_media_uri(
f"/clips/{quote(os.path.relpath(existing_image, CLIPS_DIR))}",
request.headers.get("remote-role"),
request.app.frigate_config,
):
existing_image = None
if existing_image is None:
return None, JSONResponse(
content={"success": False, "message": "Invalid image path"},
@@ -178,7 +166,15 @@ def _validate_export_source(
) -> str | None:
if playback_source == PlaybackSourceEnum.recordings:
query = Recordings.select().where(
recording_overlap(start_time, end_time) & (Recordings.camera == camera_name)
(
Recordings.start_time.between(start_time, end_time)
| Recordings.end_time.between(start_time, end_time)
| (
(start_time > Recordings.start_time)
& (end_time < Recordings.end_time)
)
)
& (Recordings.camera == camera_name)
)
# a pinned export reads only that stream, so the other stream's
@@ -243,7 +239,9 @@ def _get_item_recording_export_errors(
query = Recordings.select(Recordings.start_time, Recordings.end_time).where(
Recordings.camera == camera_name,
recording_overlap(min_start, max_end),
Recordings.start_time.between(min_start, max_end)
| Recordings.end_time.between(min_start, max_end)
| ((min_start > Recordings.start_time) & (max_end < Recordings.end_time)),
)
# a pinned batch reads only that stream, so the other stream's
@@ -703,7 +701,7 @@ def export_recordings_batch(
sanitized_images: list[str | None] = []
for item in body.items:
existing_image, image_validation_error = _sanitize_existing_image(
request, item.image_path
item.image_path
)
if image_validation_error is not None:
return image_validation_error
@@ -851,9 +849,7 @@ def export_recording(
playback_source = body.source
friendly_name = body.name
existing_image, image_validation_error = _sanitize_existing_image(
request, body.image_path
)
existing_image, image_validation_error = _sanitize_existing_image(body.image_path)
if image_validation_error is not None:
return image_validation_error
@@ -1048,9 +1044,7 @@ def export_recording_custom(
playback_source = body.source
friendly_name = body.name
existing_image, image_validation_error = _sanitize_existing_image(
request, body.image_path
)
existing_image, image_validation_error = _sanitize_existing_image(body.image_path)
if image_validation_error is not None:
return image_validation_error
ffmpeg_input_args = body.ffmpeg_input_args
+9 -12
View File
@@ -1,7 +1,6 @@
import asyncio
import logging
import re
from contextlib import asynccontextmanager
from fastapi import Depends, FastAPI, Request
from fastapi.responses import JSONResponse
@@ -82,20 +81,9 @@ def create_fastapi_app(
config_holder: ConfigHolder | None = None,
notice_registry: NoticeRegistry | None = None,
):
@asynccontextmanager
async def lifespan(app: FastAPI):
logger.info("FastAPI started")
asyncio.create_task(
debug_replay_auto_stop_watchdog(
replay_manager, frigate_config, config_publisher
)
)
yield
logger.info("Starting FastAPI app")
app = FastAPI(
debug=False,
lifespan=lifespan,
swagger_ui_parameters={"apisSorter": "alpha", "operationsSorter": "alpha"},
dependencies=[Depends(require_admin_by_default())]
if enforce_default_admin
@@ -131,6 +119,15 @@ def create_fastapi_app(
database.close()
return response
@app.on_event("startup")
async def startup():
logger.info("FastAPI started")
asyncio.create_task(
debug_replay_auto_stop_watchdog(
replay_manager, frigate_config, config_publisher
)
)
# Rate limiter (used for login endpoint)
if frigate_config.auth.failed_login_rate_limit is None:
limiter.enabled = False
+26 -32
View File
@@ -14,10 +14,10 @@ from enum import Enum
from pathlib import Path as FilePath
from typing import IO, Any
from urllib.parse import unquote
from zoneinfo import ZoneInfo
import cv2
import numpy as np
import pytz
from fastapi import APIRouter, Depends, Path, Query, Request, Response
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
from pathvalidate import sanitize_filename
@@ -62,10 +62,8 @@ from frigate.util.recording_coverage import (
build_spans,
null_audio_glitches,
plan_clip,
recording_overlap,
resolve_coverage,
)
from frigate.util.time import get_normalized_tz_name
logger = logging.getLogger(__name__)
@@ -297,15 +295,10 @@ async def latest_frame(
and request.app.frigate_config.birdseye.enabled
and request.app.frigate_config.birdseye.restream
):
frame = frame_processor.get_current_frame(camera_name)
if frame is None:
return JSONResponse(
content={"success": False, "message": "Unable to get valid frame"},
status_code=500,
)
frame = cv2.cvtColor(frame, cv2.COLOR_YUV2BGR_I420)
frame = cv2.cvtColor(
frame_processor.get_current_frame(camera_name),
cv2.COLOR_YUV2BGR_I420,
)
height = int(params.height or str(frame.shape[0]))
width = int(height * frame.shape[1] / frame.shape[0])
@@ -353,7 +346,10 @@ async def get_snapshot_from_recording(
Recordings.path,
Recordings.start_time,
)
.where(recording_overlap(frame_time, frame_time))
.where(
(frame_time >= Recordings.start_time)
& (frame_time <= Recordings.end_time)
)
.where(Recordings.camera == camera_name)
.order_by(Recordings.stream_type.asc(), Recordings.start_time.desc())
.limit(1)
@@ -369,7 +365,10 @@ async def get_snapshot_from_recording(
Recordings.path,
Recordings.start_time,
)
.where(recording_overlap(frame_time, frame_time))
.where(
(frame_time >= Recordings.start_time)
& (frame_time <= Recordings.end_time)
)
.where(Recordings.camera == camera_name)
.order_by(Recordings.stream_type.asc(), Recordings.start_time.desc())
.limit(1)
@@ -384,13 +383,8 @@ async def get_snapshot_from_recording(
mime_type = "png" if format == "png" else "jpeg"
config: FrigateConfig = request.app.frigate_config
image_data = await asyncio.to_thread(
get_image_from_recording,
config.ffmpeg,
recording.path,
time_in_segment,
codec,
height,
image_data = get_image_from_recording(
config.ffmpeg, recording.path, time_in_segment, codec, height
)
if not image_data:
@@ -432,7 +426,9 @@ async def submit_recording_snapshot_to_plus(
Recordings.path,
Recordings.start_time,
)
.where(recording_overlap(frame_time, frame_time))
.where(
(frame_time >= Recordings.start_time) & (frame_time <= Recordings.end_time)
)
.where(Recordings.camera == camera_name)
.order_by(Recordings.stream_type.asc(), Recordings.start_time.desc())
.limit(1)
@@ -442,12 +438,8 @@ async def submit_recording_snapshot_to_plus(
config: FrigateConfig = request.app.frigate_config
recording: Recordings = recording_query.get()
time_in_segment = frame_time - recording.start_time
image_data = await asyncio.to_thread(
get_image_from_recording,
config.ffmpeg,
recording.path,
time_in_segment,
"png",
image_data = get_image_from_recording(
config.ffmpeg, recording.path, time_in_segment, "png"
)
if not image_data:
@@ -546,7 +538,11 @@ async def recording_clip(
Recordings.start_time,
Recordings.end_time,
)
.where(recording_overlap(start_ts, end_ts))
.where(
(Recordings.start_time.between(start_ts, end_ts))
| (Recordings.end_time.between(start_ts, end_ts))
| ((start_ts > Recordings.start_time) & (end_ts < Recordings.end_time))
)
.where(Recordings.camera == camera_name)
.where(Recordings.stream_type == stream_type)
.order_by(Recordings.start_time.asc())
@@ -841,9 +837,7 @@ async def vod_hour(
parts = year_month.split("-")
start_date = (
datetime(int(parts[0]), int(parts[1]), day, hour, tzinfo=UTC)
- datetime.now(
ZoneInfo(get_normalized_tz_name(tz_name.replace(",", "/")))
).utcoffset()
- datetime.now(pytz.timezone(tz_name.replace(",", "/"))).utcoffset()
)
end_date = start_date + timedelta(hours=1) - timedelta(milliseconds=1)
start_ts = start_date.timestamp()
+10 -42
View File
@@ -13,9 +13,8 @@ from __future__ import annotations
import logging
import os
import re
from enum import Enum
from urllib.parse import unquote
from urllib.parse import unquote, urlparse
from peewee import DoesNotExist
@@ -25,8 +24,6 @@ from frigate.models import Export, User
logger = logging.getLogger(__name__)
_URI_SCHEME_AND_AUTHORITY = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*://[^/?]*")
class MediaAuthResolution(str, Enum):
"""Classification of an `X-Original-URL` path for media-auth purposes."""
@@ -44,36 +41,6 @@ class MediaAuthResolution(str, Enum):
UNKNOWN = "unknown"
def split_request_uri(request_uri: str) -> tuple[str, str]:
"""Split a raw request URI into its path and query string.
urlparse is not used since it reads a path starting with `//` as a host.
nginx ends the URI at a literal `#`, so anything after it is dropped.
"""
uri = _URI_SCHEME_AND_AUTHORITY.sub("", request_uri, count=1)
path, _, query = uri.partition("#")[0].partition("?")
return path, query
def normalize_request_path(path: str) -> str:
"""Normalize a raw request path the way nginx does before matching locations.
nginx percent-decodes the path, merges repeated slashes and resolves dot
segments, while `X-Original-URL` carries the raw `$request_uri`.
"""
segments: list[str] = []
for segment in unquote(path).split("/"):
if segment in ("", "."):
continue
if segment == "..":
if segments:
segments.pop()
continue
segments.append(segment)
return "/" + "/".join(segments)
def extract_path(original_url: str | None) -> str | None:
"""Return the decoded path component of nginx's `X-Original-URL` header.
@@ -82,14 +49,14 @@ def extract_path(original_url: str | None) -> str | None:
request like `/recordings/.../allowed_cam/../forbidden_cam/file.mp4`
would (1) parse as the allowed camera in our auth check, (2) be served
as the forbidden camera by nginx. To close the bypass we reject any URI
whose path contains `.` or `..` segments outright. Repeated slashes are
merged, as nginx does before matching locations.
whose path contains `.` or `..` segments outright.
"""
if not original_url:
return None
path, _ = split_request_uri(original_url)
decoded = re.sub(r"/{2,}", "/", unquote(path))
parsed = urlparse(original_url)
raw_path = parsed.path or original_url
decoded = unquote(raw_path)
if not decoded:
return None
@@ -285,11 +252,12 @@ def deny_response_for_media_uri(
path = extract_path(original_url)
# `extract_path` returns None for URIs containing `.` or `..` segments.
# Deny them when nginx would resolve them under a media root, and pass
# the rest through to nginx and the backend.
# For media-root URIs that's a traversal attempt — deny outright. For
# non-media URIs, pass through (nginx / the backend handle them).
if path is None:
normalized = normalize_request_path(split_request_uri(original_url)[0])
first = normalized.lstrip("/").split("/", 1)[0]
raw = urlparse(original_url).path or original_url
decoded = unquote(raw)
first = decoded.lstrip("/").split("/", 1)[0] if decoded else ""
if first in ("clips", "recordings", "exports"):
return 403
return None
+9 -14
View File
@@ -9,6 +9,7 @@ from urllib.parse import urlparse
from cryptography.hazmat.primitives import serialization
from fastapi import APIRouter, Depends, Request
from fastapi.responses import JSONResponse
from peewee import DoesNotExist
from py_vapid import Vapid01, utils
from frigate.api.auth import allow_any_authenticated
@@ -172,22 +173,16 @@ def register_notifications(request: Request, body: dict = None):
status_code=400,
)
updated = (
User.update(notification_tokens=User.notification_tokens.append(sub))
.where(User.username == username)
.execute()
)
if not updated:
logger.warning(
"Rejected notification registration for unknown user %s", username
try:
User.update(notification_tokens=User.notification_tokens.append(sub)).where(
User.username == username
).execute()
return JSONResponse(
content=({"success": True, "message": "Successfully saved token."}),
status_code=200,
)
except DoesNotExist:
return JSONResponse(
content=({"success": False, "message": "Could not find user."}),
status_code=404,
)
return JSONResponse(
content=({"success": True, "message": "Successfully saved token."}),
status_code=200,
)
+2 -5
View File
@@ -5,8 +5,8 @@ import logging
import os
import threading
from datetime import UTC, datetime, timedelta
from zoneinfo import ZoneInfo
import pytz
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import JSONResponse
@@ -22,7 +22,6 @@ from frigate.api.defs.response.preview_response import (
from frigate.api.defs.tags import Tags
from frigate.const import BASE_DIR, CACHE_DIR, PREVIEW_FRAME_TYPE
from frigate.models import Previews
from frigate.util.time import get_normalized_tz_name
logger = logging.getLogger(__name__)
@@ -127,9 +126,7 @@ def preview_hour(
parts = year_month.split("-")
start_date = (
datetime(int(parts[0]), int(parts[1]), int(day), int(hour), tzinfo=UTC)
- datetime.now(
ZoneInfo(get_normalized_tz_name(tz_name.replace(",", "/")))
).utcoffset()
- datetime.now(pytz.timezone(tz_name.replace(",", "/"))).utcoffset()
)
end_date = start_date + timedelta(hours=1) - timedelta(milliseconds=1)
start_ts = start_date.timestamp()
+6 -3
View File
@@ -36,7 +36,6 @@ from frigate.util.recording_coverage import (
coverage_spans,
known_video_codecs,
realized_timelines,
recording_overlap,
resolve_coverage,
stream_media_summary,
)
@@ -176,7 +175,7 @@ async def all_recordings_summary(
@router.get(
"/{camera_name}/recordings/summary", dependencies=[Depends(require_camera_access)]
)
async def recordings_summary(camera_name: str, timezone: str = "UTC"):
async def recordings_summary(camera_name: str, timezone: str = "utc"):
"""Returns hourly summary for recordings of given camera"""
time_range_query = (
@@ -538,7 +537,11 @@ async def delete_recordings(
# Build query to find overlapping recordings
clauses = [
recording_overlap(start, end),
(
Recordings.start_time.between(start, end)
| Recordings.end_time.between(start, end)
| ((start > Recordings.start_time) & (end < Recordings.end_time))
),
(Recordings.camera << camera_list),
]
+8 -2
View File
@@ -37,7 +37,6 @@ from frigate.const import STREAM_TYPE_MAIN
from frigate.embeddings import EmbeddingsContext
from frigate.models import Recordings, ReviewSegment, UserReviewStatus
from frigate.review.types import SeverityEnum
from frigate.util.recording_coverage import recording_overlap
from frigate.util.time import get_dst_transitions
logger = logging.getLogger(__name__)
@@ -586,7 +585,14 @@ def delete_reviews(body: ReviewModifyMultipleBody):
camera_name = review["camera"]
recordings = (
Recordings.select(Recordings.id, Recordings.path)
.where(recording_overlap(start_time, end_time))
.where(
Recordings.start_time.between(start_time, end_time)
| Recordings.end_time.between(start_time, end_time)
| (
(start_time > Recordings.start_time)
& (end_time < Recordings.end_time)
)
)
.where(Recordings.camera == camera_name)
.dicts()
.iterator()
+1 -22
View File
@@ -581,12 +581,7 @@ class FrigateApp:
def init_auth(self) -> None:
if self.config.auth.enabled:
if (
User.select().count() == 0
or User.select()
.where((User.username == "admin") & (User.password_hash == ""))
.exists()
):
if User.select().count() == 0:
password = secrets.token_hex(16)
password_hash = hash_password(
password, iterations=self.config.auth.hash_iterations
@@ -598,9 +593,6 @@ class FrigateApp:
User.password_hash: password_hash,
User.notification_tokens: [],
}
).on_conflict(
conflict_target=[User.username],
update={User.password_hash: password_hash},
).execute()
self.config.auth.admin_first_time_login = True
@@ -631,19 +623,6 @@ class FrigateApp:
logger.info(f"*** Password: {password} ***")
logger.info("********************************************************")
logger.info("********************************************************")
else:
# Notification registrations store tokens on the admin row. Its
# empty password hash marks it for a generated password if auth
# is enabled later.
if User.select().count() == 0:
User.insert(
{
User.username: "admin",
User.role: "admin",
User.password_hash: "",
User.notification_tokens: [],
}
).execute()
def start(self) -> None:
logger.info(f"Starting Frigate ({VERSION})")
-4
View File
@@ -169,10 +169,6 @@ class CameraMaintainer(threading.Thread):
frame_size = config.frame_shape_yuv[0] * config.frame_shape_yuv[1]
self.frame_manager.create(f"{config.name}_frame{i}", frame_size)
# a leftover ledger from an unclean exit may be the wrong length
self.frame_manager.delete(f"{config.name}_frame_times")
self.frame_manager.create(f"{config.name}_frame_times", max(count, 1) * 8)
capture_process = CameraCapture(
config,
count,
+9 -5
View File
@@ -349,7 +349,7 @@ class CameraState:
return
current_frame = self.frame_manager.get(
frame_name, self.camera_config.frame_shape_yuv, frame_time
frame_name, self.camera_config.frame_shape_yuv
)
tracked_objects = self.tracked_objects.copy()
@@ -392,6 +392,7 @@ class CameraState:
"attributes": new_obj.obj_data["attributes"],
"current_estimated_speed": 0,
"velocity_angle": 0,
"path_data": [],
"recognized_license_plate": None,
"recognized_license_plate_score": None,
}
@@ -403,7 +404,7 @@ class CameraState:
self.send_mqtt_snapshot(new_obj, object_type)
for c in self.callbacks["start"]:
c(self.name, new_obj, frame_name, frame_time)
c(self.name, new_obj, frame_name)
for id in updated_ids:
updated_obj = tracked_objects[id]
@@ -415,7 +416,7 @@ class CameraState:
if autotracker_update or significant_update:
for c in self.callbacks["autotrack"]:
c(self.name, updated_obj, frame_name, frame_time)
c(self.name, updated_obj, frame_name)
if thumb_update and current_frame is not None:
# ensure this frame is stored in the cache
@@ -465,7 +466,7 @@ class CameraState:
):
# call event handlers
for c in self.callbacks["update"]:
c(self.name, updated_obj, frame_name, frame_time)
c(self.name, updated_obj, frame_name)
updated_obj.last_published = frame_time
# send MQTT snapshot when object first enters a required zone,
@@ -487,7 +488,7 @@ class CameraState:
removed_obj.obj_data["end_time"] = frame_time
logger.debug(f"{self.name}: end callback for object {id}")
for c in self.callbacks["end"]:
c(self.name, removed_obj, frame_name, frame_time)
c(self.name, removed_obj, frame_name)
# TODO: can i switch to looking this up and only changing when an event ends?
# maintain best objects
@@ -602,6 +603,9 @@ class CameraState:
self.current_frame_time = frame_time
self._current_frame = np.copy(current_frame)
if self.previous_frame_id is not None:
self.frame_manager.close(self.previous_frame_id)
self.previous_frame_id = frame_name
def send_mqtt_snapshot(self, new_obj: TrackedObject, object_type: str) -> None:
+2 -5
View File
@@ -1,7 +1,6 @@
"""Facilitates communication between processes."""
import multiprocessing as mp
import threading
from _pickle import UnpicklingError
from multiprocessing.synchronize import Event as MpEvent
from typing import Any
@@ -19,13 +18,11 @@ class ConfigPublisher:
self.socket = self.context.socket(zmq.PUB)
self.socket.bind(SOCKET_PUB_SUB)
self.stop_event: MpEvent = mp.Event()
self.lock = threading.Lock()
def publish(self, topic: str, payload: Any) -> None:
"""There is no communication back to the processes."""
with self.lock:
self.socket.send_string(topic, flags=zmq.SNDMORE)
self.socket.send_pyobj(payload)
self.socket.send_string(topic, flags=zmq.SNDMORE)
self.socket.send_pyobj(payload)
def stop(self) -> None:
self.stop_event.set()
+1 -5
View File
@@ -322,11 +322,7 @@ class Dispatcher:
self.audio_activity.expire_all(payload)
def handle_update_event_description() -> None:
event: Event | None = Event.get_or_none(Event.id == payload["id"])
if event is None:
return
event: Event = Event.get(Event.id == payload["id"])
cast(dict, event.data)["description"] = payload["description"]
event.save()
self.publish(
-8
View File
@@ -1,7 +1,6 @@
"""Facilitates communication between processes."""
import logging
import threading
from collections.abc import Callable
from enum import Enum
from typing import Any
@@ -80,21 +79,14 @@ class EmbeddingsRequestor:
self.context = zmq.Context()
self.socket = self.context.socket(zmq.REQ)
self.socket.connect(SOCKET_REP_REQ)
self.lock = threading.Lock()
def send_data(self, topic: str, data: Any) -> Any:
"""Sends data and then waits for reply."""
# an overlapping call fails fast so a slow reply can't stall the API
if not self.lock.acquire(blocking=False):
return ""
try:
self.socket.send_json((topic, data))
return self.socket.recv_json()
except zmq.ZMQError:
return ""
finally:
self.lock.release()
def stop(self) -> None:
self.socket.close()
+2 -18
View File
@@ -8,16 +8,7 @@ from .zmq_proxy import Publisher, Subscriber
class EventUpdatePublisher(
Publisher[
tuple[
EventTypeEnum,
EventStateEnum,
str | None,
str,
dict[str, Any],
float | None,
]
]
Publisher[tuple[EventTypeEnum, EventStateEnum, str | None, str, dict[str, Any]]]
):
"""Publishes events (objects, audio, manual)."""
@@ -28,14 +19,7 @@ class EventUpdatePublisher(
def publish(
self,
payload: tuple[
EventTypeEnum,
EventStateEnum,
str | None,
str,
dict[str, Any],
float | None,
],
payload: tuple[EventTypeEnum, EventStateEnum, str | None, str, dict[str, Any]],
sub_topic: str = "",
) -> None:
super().publish(payload, sub_topic)
+5 -7
View File
@@ -79,16 +79,14 @@ class InterProcessRequestor:
self.context = zmq.Context()
self.socket = self.context.socket(zmq.REQ)
self.socket.connect(SOCKET_REP_REQ)
self.lock = threading.Lock()
def send_data(self, topic: str, data: Any) -> Any:
"""Sends data and then waits for reply."""
with self.lock:
try:
self.socket.send_json((topic, data))
return self.socket.recv_json()
except zmq.ZMQError:
return ""
try:
self.socket.send_json((topic, data))
return self.socket.recv_json()
except zmq.ZMQError:
return ""
def stop(self) -> None:
self.socket.close(linger=0)
+28 -41
View File
@@ -41,20 +41,6 @@ class PushNotification:
ttl: int = 0
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:
# WebPusher decodes the stored keys and raises on malformed ones
try:
pushers.append(WebPusher(sub))
except Exception:
logger.warning("Skipping invalid notification subscription for %s", user)
return pushers
class WebPushClient(Communicator):
"""Frigate wrapper for webpush client."""
@@ -70,7 +56,6 @@ class WebPushClient(Communicator):
for c in self.config.cameras.values()
}
self.suspension_broadcaster: Callable[[str, Any, bool], None] | None = None
self.config_lock = threading.Lock()
self.last_camera_notification_time: dict[str, float] = {
c.name: 0 # type: ignore[misc]
for c in self.config.cameras.values()
@@ -91,9 +76,9 @@ class WebPushClient(Communicator):
User.select(User.username, User.notification_tokens).dicts().iterator()
)
for user in users:
self.web_pushers[user["username"]] = _build_web_pushers(
user["username"], user["notification_tokens"]
)
self.web_pushers[user["username"]] = []
for sub in user["notification_tokens"]:
self.web_pushers[user["username"]].append(WebPusher(sub))
# notification and auth config updater
self.global_config_subscriber = ConfigSubscriber("config/")
@@ -161,7 +146,10 @@ class WebPushClient(Communicator):
User.username == user
).execute()
self.web_pushers[user] = _build_web_pushers(user, user_subs)
self.web_pushers[user] = []
for sub in user_subs:
self.web_pushers[user].append(WebPusher(sub))
logger.info(
f"Cleaned up {len(expired)} notification subscriptions for {user}"
@@ -210,30 +198,29 @@ class WebPushClient(Communicator):
def publish(self, topic: str, payload: Any, retain: bool = False) -> None:
"""Wrapper for publishing when client is in valid state."""
with self.config_lock:
# check for updated global config (notifications, auth)
while True:
config_topic, config_payload = (
self.global_config_subscriber.check_for_update()
)
if config_topic is None:
break
if config_topic == "config/notifications" and config_payload:
self.config.notifications = config_payload
elif config_topic == "config/auth":
if isinstance(config_payload, AuthConfig):
self.config.auth = config_payload
self._refresh_user_cameras()
updates = self.config_subscriber.check_for_updates()
if "add" in updates:
for camera in updates["add"]:
self.suspended_cameras[camera] = 0
self.last_camera_notification_time[camera] = 0
# check for updated global config (notifications, auth)
while True:
config_topic, config_payload = (
self.global_config_subscriber.check_for_update()
)
if config_topic is None:
break
if config_topic == "config/notifications" and config_payload:
self.config.notifications = config_payload
elif config_topic == "config/auth":
if isinstance(config_payload, AuthConfig):
self.config.auth = config_payload
self._refresh_user_cameras()
updates = self.config_subscriber.check_for_updates()
if "add" in updates:
for camera in updates["add"]:
self.suspended_cameras[camera] = 0
self.last_camera_notification_time[camera] = 0
self._refresh_user_cameras()
if topic == "reviews":
decoded = json.loads(payload)
camera = decoded["before"]["camera"]
+4 -82
View File
@@ -3,9 +3,6 @@
import errno
import json
import logging
import queue
import socket
import struct
import threading
from collections.abc import Callable
from typing import Any
@@ -79,12 +76,6 @@ _WS_VIEWER_TOPICS = frozenset(
# Camera-scoped command topics a camera-authorized (non-admin) user may send.
_WS_CAMERA_COMMAND_TOPICS = frozenset({"ptz"})
# Max outbound messages waiting on a client's writer thread.
WS_MAX_PENDING_MESSAGES = 256
# Seconds a single send() may wait for a client to free socket buffer space.
WS_SEND_TIMEOUT = 10
def _check_ws_authorization(
topic: str,
@@ -473,78 +464,6 @@ def _materialize_for_ws(
class WebSocket(WebSocket_): # type: ignore[misc]
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
self._send_queue: queue.Queue[tuple[Any, bool] | None] = queue.Queue(
maxsize=WS_MAX_PENDING_MESSAGES
)
self._writer: threading.Thread | None = None
self._aborted = False
def opened(self) -> None:
"""Set a send timeout on the socket and start this client's writer thread."""
# ws4py writes close and pong frames on the shared manager thread, so a
# stalled client must not block sendall forever. SO_SNDTIMEO leaves recv
# (and idle clients) untouched, unlike settimeout().
self.sock.setsockopt(
socket.SOL_SOCKET, socket.SO_SNDTIMEO, struct.pack("ll", WS_SEND_TIMEOUT, 0)
)
# every client gets its own writer so a client that stops reading only
# blocks itself, never the thread that called publish()
self._writer = threading.Thread(
target=self._drain_send_queue, name="ws_writer", daemon=True
)
self._writer.start()
def send(self, payload: Any, binary: bool = False) -> None:
"""Queue a message for this client's writer thread.
Returns without waiting for delivery. Disconnects the client if its queue
is full.
"""
try:
self._send_queue.put_nowait((payload, binary))
except queue.Full:
self._abort("Websocket client is not keeping up, disconnecting it")
def _drain_send_queue(self) -> None:
while True:
item = self._send_queue.get()
if item is None or self.terminated or self.sock is None:
return
try:
super().send(*item)
except Exception:
self._abort()
return
def _abort(self, reason: str | None = None) -> None:
# publish() keeps hitting a full queue until the manager thread removes
# the connection, so only act (and log) the first time
if self._aborted:
return
self._aborted = True
if reason:
logger.warning(reason)
# shutdown rather than close so the ws4py manager thread sees EOF and
# runs its normal unregister/terminate; this also unblocks a stuck sendall
sock = self.sock
if sock is not None:
try:
sock.shutdown(socket.SHUT_RDWR)
except OSError:
pass
def closed(self, code: int, reason: str | None = None) -> None:
"""Signal the writer thread to exit."""
try:
self._send_queue.put_nowait(None)
except queue.Full:
pass
def unhandled_error(self, error: Any) -> None:
"""
Handles the unfriendly socket closures on the server side
@@ -678,7 +597,10 @@ class WebSocketClient(Communicator):
)
if message is None:
continue
ws.send(message)
try:
ws.send(message)
except (ConnectionResetError, BrokenPipeError, ValueError):
pass
def stop(self) -> None:
if self.websocket_server is not None:
-10
View File
@@ -1,5 +1,3 @@
import ipaddress
from pydantic import Field, field_validator, model_validator
from .base import FrigateBaseModel
@@ -70,14 +68,6 @@ class AuthConfig(FrigateBaseModel):
),
)
@field_validator("trusted_proxies")
@classmethod
def validate_trusted_proxies(cls, v: list[str]) -> list[str]:
for proxy in v:
ipaddress.ip_network(proxy, strict=False)
return v
@field_validator("roles")
@classmethod
def validate_roles(cls, v: dict[str, list[str]]) -> dict[str, list[str]]:
+1 -1
View File
@@ -3,9 +3,9 @@ from enum import Enum
from pydantic import Field, field_validator
from frigate.util.config import resolve_ffmpeg_path
from frigate.util.env import EnvString
from ..base import FrigateBaseModel
from ..env import EnvString
__all__ = [
"CameraFfmpegConfig",
+1 -2
View File
@@ -3,9 +3,8 @@ from typing import Any, Self
from pydantic import Field, model_validator
from frigate.util.env import EnvString
from ..base import FrigateBaseModel
from ..env import EnvString
__all__ = ["GenAIConfig", "GenAIProviderEnum", "GenAIRoleEnum"]
+1 -2
View File
@@ -1,8 +1,7 @@
from pydantic import Field
from frigate.util.env import EnvString
from ..base import FrigateBaseModel
from ..env import EnvString
__all__ = ["NotificationConfig"]
+1 -2
View File
@@ -2,9 +2,8 @@ from enum import Enum
from pydantic import Field, field_validator
from frigate.util.env import EnvString
from ..base import FrigateBaseModel
from ..env import EnvString
from .objects import DEFAULT_TRACKED_OBJECTS
__all__ = ["OnvifConfig", "PtzAutotrackConfig", "ZoomingModeEnum"]
-1
View File
@@ -147,7 +147,6 @@ class RecordConfig(FrigateBaseModel):
)
expire_interval: int = Field(
default=60,
ge=1,
title="Record cleanup interval",
description="Minutes between cleanup passes that remove expired recording segments.",
)
+2 -2
View File
@@ -128,8 +128,8 @@ class ZoneConfig(BaseModel):
# we know if any points are > 1 then it is using the
# old native resolution coordinates
if isinstance(coordinates, list):
explicit = any(p.split(",")[0] > "1.0" for p in coordinates)
try:
explicit = any(float(p.split(",")[0]) > 1.0 for p in coordinates)
self._contour = np.array(
[
(
@@ -157,8 +157,8 @@ class ZoneConfig(BaseModel):
)
elif isinstance(coordinates, str):
points = coordinates.split(",")
explicit = any(p > "1.0" for p in points)
try:
explicit = any(float(p) > 1.0 for p in points)
self._contour = np.array(
[
(
+1 -1
View File
@@ -34,7 +34,6 @@ from frigate.util.config import (
get_relative_coordinates,
migrate_frigate_config,
)
from frigate.util.env import EnvVars, reload_sources
from frigate.util.image import create_mask
from frigate.util.live_streams import (
default_transcode_source,
@@ -72,6 +71,7 @@ from .classification import (
SemanticSearchModelEnum,
)
from .database import DatabaseConfig
from .env import EnvVars, reload_sources
from .logger import LoggerConfig
from .mqtt import MqttConfig
from .network import NetworkingConfig
+1 -14
View File
@@ -165,25 +165,12 @@ def reload_sources(warn: bool = True) -> None:
_rebuild(warn)
# Security overrides that must come from the container environment, which is
# controlled by the operator, rather than from the config, which admins can edit
_PROTECTED_ENV_VARS = frozenset({"GO2RTC_ALLOW_ARBITRARY_EXEC"})
def apply_config_env_vars(values: Mapping[str, object]) -> None:
"""Install the environment_vars block as the lowest priority source.
Unprefixed keys only set os.environ. Protected security overrides are
skipped.
Unprefixed keys only set os.environ.
"""
for key, value in values.items():
if key.upper() in _PROTECTED_ENV_VARS:
logger.warning(
"Ignoring %s in environment_vars, it must be set in the container environment",
key,
)
continue
resolved = str(value)
if key.startswith("FRIGATE_"):
+1 -1
View File
@@ -3,9 +3,9 @@ from typing import Self
from pydantic import Field, ValidationInfo, model_validator
from frigate.const import FREQUENCY_STATS_POINTS
from frigate.util.env import EnvString
from .base import FrigateBaseModel
from .env import EnvString
__all__ = ["MqttConfig"]
+1 -2
View File
@@ -1,8 +1,7 @@
from pydantic import Field, field_validator
from frigate.util.env import EnvString
from .base import FrigateBaseModel
from .env import EnvString
__all__ = ["ProxyConfig", "HeaderMappingConfig"]
-1
View File
@@ -8,7 +8,6 @@ MODEL_CACHE_DIR = f"{CONFIG_DIR}/model_cache"
BASE_DIR = "/media/frigate"
CLIPS_DIR = f"{BASE_DIR}/clips"
EXPORT_DIR = f"{BASE_DIR}/exports"
EXPORT_STAGING_DIR = f"{EXPORT_DIR}/staging"
FACE_DIR = f"{CLIPS_DIR}/faces"
THUMB_DIR = f"{CLIPS_DIR}/thumbs"
RECORD_DIR = f"{BASE_DIR}/recordings"
@@ -4,7 +4,6 @@ import logging
import os
import threading
import time
from pathlib import Path
from typing import Any
from peewee import DoesNotExist
@@ -194,10 +193,9 @@ class AudioTranscriptionPostProcessor(PostProcessorApi):
logger.debug("Recognizer not initialized")
return None
# Save audio data to a temporary wav (faster-whisper expects a file)
temp_wav = os.path.join(CACHE_DIR, f"temp_audio_{int(time.time())}.wav") # type: ignore[unreachable]
try:
try: # type: ignore[unreachable]
# Save audio data to a temporary wav (faster-whisper expects a file)
temp_wav = os.path.join(CACHE_DIR, f"temp_audio_{int(time.time())}.wav")
with open(temp_wav, "wb") as f:
f.write(audio_data)
@@ -207,6 +205,8 @@ class AudioTranscriptionPostProcessor(PostProcessorApi):
beam_size=5,
)
os.remove(temp_wav)
# Combine all segment texts
text = " ".join(segment.text.strip() for segment in segments)
if not text:
@@ -222,8 +222,6 @@ class AudioTranscriptionPostProcessor(PostProcessorApi):
except Exception as e:
logger.error(f"Error transcribing audio: {e}")
return None
finally:
Path(temp_wav).unlink(missing_ok=True)
def _transcription_wrapper(self, event: dict[str, Any]) -> None:
"""Wrapper to run transcription and reset running flag when done."""
@@ -105,10 +105,6 @@ class ObjectDescriptionProcessor(PostProcessorApi):
logger.debug(f"{camera} sending early request to GenAI")
self.early_request_sent[data["id"]] = True
if camera_config.objects.genai.debug_save_thumbnails:
self._save_debug_thumbnails(data["id"])
# Copy thumbnails to avoid holding references after cleanup
thumbnails_copy = [
data["thumbnail"][:] if data.get("thumbnail") else None
@@ -306,7 +302,26 @@ class ObjectDescriptionProcessor(PostProcessorApi):
)
if camera_config.objects.genai.debug_save_thumbnails and num_thumbnails > 0:
self._save_debug_thumbnails(event_id)
logger.debug(f"Saving {num_thumbnails} thumbnails for event {event_id}")
Path(os.path.join(CLIPS_DIR, f"genai-requests/{event_id}")).mkdir(
parents=True, exist_ok=True
)
for idx, data in enumerate(self.tracked_events[event_id], 1):
jpg_bytes: bytes | None = data["thumbnail"]
if jpg_bytes is None:
logger.warning(f"Unable to save thumbnail {idx} for {event_id}.")
else:
with open(
os.path.join(
CLIPS_DIR,
f"genai-requests/{event_id}/{idx}.jpg",
),
"wb",
) as j:
j.write(jpg_bytes)
# Generate the description. Call happens in a thread since it is network bound.
threading.Thread(
@@ -322,30 +337,6 @@ class ObjectDescriptionProcessor(PostProcessorApi):
# Clean up tracked events and early request state
self.cleanup_event(event_id)
def _save_debug_thumbnails(self, event_id: str) -> None:
"""Write the tracked thumbnails for an event to the genai-requests dir."""
tracked = self.tracked_events.get(event_id, [])
logger.debug(f"Saving {len(tracked)} thumbnails for event {event_id}")
Path(os.path.join(CLIPS_DIR, f"genai-requests/{event_id}")).mkdir(
parents=True, exist_ok=True
)
for idx, data in enumerate(tracked, 1):
jpg_bytes: bytes | None = data["thumbnail"]
if jpg_bytes is None:
logger.warning(f"Unable to save thumbnail {idx} for {event_id}.")
else:
with open(
os.path.join(
CLIPS_DIR,
f"genai-requests/{event_id}/{idx}.jpg",
),
"wb",
) as j:
j.write(jpg_bytes)
def _genai_embed_description(self, event: Event, thumbnails: list[bytes]) -> None:
"""Embed the description for an event."""
start = datetime.datetime.now().timestamp()
@@ -37,7 +37,6 @@ from frigate.genai.manager import GenAIClientManager
from frigate.models import Recordings, ReviewSegment
from frigate.util.builtin import EventsPerSecond, InferenceSpeed
from frigate.util.image import get_image_from_recording
from frigate.util.recording_coverage import recording_overlap
from ..post.api import PostProcessorApi
from ..types import DataProcessorMetrics
@@ -593,7 +592,7 @@ class ReviewDescriptionProcessor(PostProcessorApi):
Recordings.path,
Recordings.start_time,
)
.where(recording_overlap(ts, ts))
.where((ts >= Recordings.start_time) & (ts <= Recordings.end_time))
.where(Recordings.camera == camera)
.where(Recordings.stream_type == STREAM_TYPE_MAIN)
.order_by(Recordings.start_time.desc())
@@ -193,7 +193,12 @@ class CustomStateClassificationProcessor(DeferredRealtimeProcessorApi):
return None
def process_frame(self, frame_data: dict[str, Any], frame: np.ndarray) -> None:
if not self.model_config.name or not self.model_config.state_config:
if (
not self.model_config.name
or not self.model_config.state_config
or not self.tensor_input_details
or not self.tensor_output_details
):
return
if self.metrics and self.model_config.name in self.metrics.classification_cps:
@@ -536,7 +541,12 @@ class CustomObjectClassificationProcessor(DeferredRealtimeProcessorApi):
return best_label, avg_score
def process_frame(self, obj_data: dict[str, Any], frame: np.ndarray) -> None:
if not self.model_config.name or not self.model_config.object_config:
if (
not self.model_config.name
or not self.model_config.object_config
or not self.tensor_input_details
or not self.tensor_output_details
):
return
if self.metrics and self.model_config.name in self.metrics.classification_cps:
+21 -99
View File
@@ -10,7 +10,6 @@ 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
@@ -56,9 +55,6 @@ _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.
@@ -87,88 +83,36 @@ 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()
return machine in ("aarch64", "arm64", "armv8", "armv7l")
def get_ort_session_options(
model_type: str | None = None, cpu: bool = False
) -> ort.SessionOptions | None:
def get_ort_session_options(model_type: str | None = None) -> ort.SessionOptions | None:
"""Get ONNX Runtime session options with appropriate settings.
Args:
model_type: Model being loaded, used to pin its graph optimization level.
cpu: Whether the session runs on the CPU execution provider.
Returns:
SessionOptions for enrichment models, or None for default settings.
SessionOptions with a pinned optimization level, or None for default settings.
"""
# Import here to avoid circular imports
from frigate.embeddings.types import (
ENRICHMENT_FOR_MODEL_TYPE,
EnrichmentModelTypeEnum,
)
if model_type not in ENRICHMENT_FOR_MODEL_TYPE:
return None
sess_options = ort.SessionOptions()
if cpu:
# idle intra-op threads otherwise spin for ~1s after every run, keeping
# most cores busy while an enrichment runs even once a second
sess_options.add_session_config_entry("session.intra_op.allow_spinning", "0")
from frigate.embeddings.types import EnrichmentModelTypeEnum
if model_type == EnrichmentModelTypeEnum.jina_v2.value:
# below EXTENDED the CUDA EP returns an identical vector for every image,
# and ORT_ENABLE_ALL fails to build on CPU with a SimplifiedLayerNormFusion error
sess_options.graph_optimization_level = (
ort.GraphOptimizationLevel.ORT_ENABLE_EXTENDED
)
level = ort.GraphOptimizationLevel.ORT_ENABLE_EXTENDED
elif model_type == EnrichmentModelTypeEnum.jina_v1.value:
# aggressive optimizations create or expect nodes that don't exist
sess_options.graph_optimization_level = (
ort.GraphOptimizationLevel.ORT_ENABLE_BASIC
)
level = ort.GraphOptimizationLevel.ORT_ENABLE_BASIC
else:
return None
sess_options = ort.SessionOptions()
sess_options.graph_optimization_level = level
return sess_options
@@ -796,41 +740,12 @@ 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 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
# 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
if device != "CPU" and is_openvino_gpu_npu_available():
return _record_runner(
model_path,
@@ -859,15 +774,22 @@ 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,
ONNXModelRunner(
ort.InferenceSession(
model_path,
sess_options=get_ort_session_options(
model_type, cpu=providers[0] == "CPUExecutionProvider"
),
sess_options=get_ort_session_options(model_type),
providers=providers,
provider_options=options,
),
-6
View File
@@ -10,7 +10,6 @@ from json.decoder import JSONDecodeError
from multiprocessing.synchronize import Event as MpEvent
from typing import Any
import cv2
import regex
from pathvalidate import ValidationError, sanitize_filename
@@ -52,11 +51,6 @@ class EmbeddingProcess(FrigateProcess):
from .maintainer import EmbeddingMaintainer
self.pre_run_setup(self.config.logger)
# frame conversions and crops here are one-off calls, and OpenCV's
# per-core thread pool burns ~8x the CPU of running them inline
cv2.setNumThreads(1)
maintainer = EmbeddingMaintainer(
self.config,
self.metrics,
+19 -11
View File
@@ -332,9 +332,6 @@ class EmbeddingMaintainer(threading.Thread):
"""Apply camera config updates and register newly enabled processors."""
updated_topics = self.config_updater.check_for_updates()
for camera in updated_topics.get("remove", []):
self.frame_manager.close_camera(camera)
if updated_topics.keys() & GENAI_UPDATE_TOPICS:
self._sync_genai_processors()
@@ -520,7 +517,7 @@ class EmbeddingMaintainer(threading.Thread):
if update is None:
return
source_type, event_type, camera, frame_name, data, frame_time = update
source_type, event_type, camera, frame_name, data = update
logger.debug(
f"Received update - source_type: {source_type}, camera: {camera}, data label: {data.get('label') if data else 'None'}"
@@ -547,9 +544,13 @@ class EmbeddingMaintainer(threading.Thread):
return
# Create our own thumbnail based on the bounding box and the frame time
yuv_frame = self.frame_manager.get(
frame_name, camera_config.frame_shape_yuv, frame_time
)
try:
yuv_frame = self.frame_manager.get(
frame_name, camera_config.frame_shape_yuv
)
except FileNotFoundError:
logger.debug(f"Frame {frame_name} not found for camera {camera}")
pass
if yuv_frame is None:
logger.debug(
@@ -582,6 +583,8 @@ class EmbeddingMaintainer(threading.Thread):
PostProcessDataEnum.tracked_object,
)
self.frame_manager.close(frame_name)
def _process_finalized(self) -> None:
"""Process the end of an event."""
while True:
@@ -790,7 +793,7 @@ class EmbeddingMaintainer(threading.Thread):
if topic is None:
return
camera, frame_name, frame_time, _, motion_boxes, _ = data
camera, frame_name, _, _, motion_boxes, _ = data
if not camera or camera not in self.config.cameras:
return
@@ -812,9 +815,12 @@ class EmbeddingMaintainer(threading.Thread):
# no active features that use this data
return
yuv_frame = self.frame_manager.get(
frame_name, camera_config.frame_shape_yuv, frame_time
)
try:
yuv_frame = self.frame_manager.get(
frame_name, camera_config.frame_shape_yuv
)
except FileNotFoundError:
pass
if yuv_frame is None:
logger.debug(
@@ -835,6 +841,8 @@ class EmbeddingMaintainer(threading.Thread):
{"camera": camera, "motion": motion_boxes}, yuv_frame
)
self.frame_manager.close(frame_name)
def _process_deferred_results(self) -> None:
"""Drain results from deferred processors and perform IPC side-effects."""
for processor in self.realtime_processors:
+2 -2
View File
@@ -5,7 +5,7 @@ import os
import shutil
import threading
from transformers import AutoTokenizer, CLIPImageProcessorPil
from transformers import AutoTokenizer, CLIPImageProcessor
from transformers.utils.logging import disable_progress_bar
from frigate.comms.inter_process import InterProcessRequestor
@@ -209,7 +209,7 @@ class JinaV1ImageEmbedding(BaseEmbedding):
if self.downloader:
self.downloader.wait_for_download()
self.feature_extractor = CLIPImageProcessorPil.from_pretrained(
self.feature_extractor = CLIPImageProcessor.from_pretrained(
f"{MODEL_CACHE_DIR}/{self.model_name}",
)
+1 -1
View File
@@ -94,7 +94,7 @@ class EventProcessor(threading.Thread):
if update == None:
continue
source_type, event_type, camera, _, event_data, _ = update
source_type, event_type, camera, _, event_data = update
logger.debug(
f"Event received: {source_type} {event_type} {camera} {event_data['id']}"
+5 -2
View File
@@ -27,7 +27,6 @@ from frigate.jobs.manager import job_is_running, set_current_job
from frigate.models import Export, Recordings
from frigate.types import JobStatusTypesEnum
from frigate.util.ffmpeg import run_ffmpeg_with_progress
from frigate.util.recording_coverage import recording_overlap
if TYPE_CHECKING:
from frigate.debug_replay import DebugReplayManager
@@ -105,7 +104,11 @@ def query_recordings(source_camera: str, start_ts: float, end_ts: float) -> Mode
Recordings.start_time,
Recordings.end_time,
)
.where(recording_overlap(start_ts, end_ts))
.where(
Recordings.start_time.between(start_ts, end_ts)
| Recordings.end_time.between(start_ts, end_ts)
| ((start_ts > Recordings.start_time) & (end_ts < Recordings.end_time))
)
.where(Recordings.camera == source_camera)
.order_by(Recordings.start_time.asc())
)
+1 -5
View File
@@ -2,7 +2,6 @@
import logging
import os
import shutil
import threading
import time
from collections.abc import Callable
@@ -16,7 +15,7 @@ from peewee import DoesNotExist
from frigate.comms.inter_process import InterProcessRequestor
from frigate.config import FrigateConfig
from frigate.config.camera.record import ChaptersEnum
from frigate.const import EXPORT_STAGING_DIR, UPDATE_JOB_STATE
from frigate.const import UPDATE_JOB_STATE
from frigate.jobs.job import Job
from frigate.models import Export
from frigate.record.export import (
@@ -416,9 +415,6 @@ def reap_stale_exports() -> None:
this in a try/except. A failure on a single row will not stop the rest
of the sweep, and a failure in the top-level query will log and return.
"""
# staged stream runs live on disk, so a killed export leaves them behind
shutil.rmtree(EXPORT_STAGING_DIR, ignore_errors=True)
try:
stale_exports = list(Export.select().where(Export.in_progress == True)) # noqa: E712
except Exception:
+14 -2
View File
@@ -35,7 +35,6 @@ from frigate.jobs.motion_search_decode import (
)
from frigate.models import Recordings
from frigate.types import JobStatusTypesEnum
from frigate.util.recording_coverage import recording_overlap
logger = logging.getLogger(__name__)
@@ -470,7 +469,20 @@ class MotionSearchRunner(threading.Thread):
recordings = list(
Recordings.select()
.where(
recording_overlap(self.job.start_time_range, self.job.end_time_range)
(
Recordings.start_time.between(
self.job.start_time_range, self.job.end_time_range
)
)
| (
Recordings.end_time.between(
self.job.start_time_range, self.job.end_time_range
)
)
| (
(self.job.start_time_range > Recordings.start_time)
& (self.job.end_time_range < Recordings.end_time)
)
)
.where(Recordings.camera == camera_name)
.where(Recordings.stream_type == STREAM_TYPE_MAIN)
+5
View File
@@ -170,6 +170,7 @@ class DetectorRunner(FrigateProcess):
mono_start = time.monotonic()
detections = object_detector.detect_raw(input_frame)
duration = time.monotonic() - mono_start
frame_manager.close(connection_id)
if connection_id not in self.outputs:
self.create_output_shm(connection_id)
@@ -258,6 +259,10 @@ class AsyncDetectorRunner(FrigateProcess):
ts = self.send_times.popleft()
duration = time.perf_counter() - ts
# release input buffer
assert self._frame_manager is not None
self._frame_manager.close(connection_id)
if connection_id not in self.outputs:
self.create_output_shm(connection_id)
+6 -1
View File
@@ -277,7 +277,12 @@ class BroadcastThread(threading.Thread):
and ws.environ["PATH_INFO"] == f"/{self.camera}"
and ws_has_camera_access(ws, self.camera, self.config)
):
ws.send(buf, binary=True)
try:
ws.send(buf, binary=True)
except ValueError:
pass
except (BrokenPipeError, ConnectionResetError, OSError) as e:
logger.debug(f"Websocket unexpectedly closed {e}")
elif self.converter.process.poll() is not None:
break
+6 -1
View File
@@ -127,7 +127,12 @@ class BroadcastThread(threading.Thread):
and ws.environ["PATH_INFO"] == f"/{self.camera}"
and ws_has_camera_access(ws, self.camera, self.config)
):
ws.send(buf, binary=True)
try:
ws.send(buf, binary=True)
except ValueError:
pass
except (BrokenPipeError, ConnectionResetError) as e:
logger.debug(f"Websocket unexpectedly closed {e}")
elif self.converter.process.poll() is not None:
break
+18 -11
View File
@@ -8,7 +8,6 @@ import threading
from multiprocessing.synchronize import Event as MpEvent
from wsgiref.simple_server import make_server
import cv2
from ws4py.server.wsgirefserver import (
WebSocketWSGIHandler,
WebSocketWSGIRequestHandler,
@@ -123,10 +122,6 @@ class OutputProcess(FrigateProcess):
def run(self) -> None:
self.pre_run_setup(self.config.logger)
# birdseye resizes are small, and OpenCV's per-core thread pool burns
# ~3x the CPU of running them inline
cv2.setNumThreads(1)
frame_manager = SharedMemoryFrameManager()
# start a websocket server on 8082
@@ -150,7 +145,6 @@ class OutputProcess(FrigateProcess):
CameraConfigUpdateEnum.birdseye,
CameraConfigUpdateEnum.enabled,
CameraConfigUpdateEnum.record,
CameraConfigUpdateEnum.remove,
],
)
birdseye_config_subscriber = ConfigSubscriber("config/birdseye", exact=True)
@@ -214,10 +208,6 @@ class OutputProcess(FrigateProcess):
birdseye,
)
if CameraConfigUpdateEnum.remove in updates:
for camera in updates["remove"]:
frame_manager.close_camera(camera)
_result = detection_subscriber.check_for_update(timeout=1)
if _result is None:
continue
@@ -251,7 +241,7 @@ class OutputProcess(FrigateProcess):
continue
frame = frame_manager.get(
frame_name, self.config.cameras[camera].frame_shape_yuv, frame_time
frame_name, self.config.cameras[camera].frame_shape_yuv
)
if frame is None:
@@ -306,6 +296,8 @@ class OutputProcess(FrigateProcess):
frame,
)
frame_manager.close(frame_name)
move_preview_frames("clips")
while True:
@@ -317,6 +309,21 @@ class OutputProcess(FrigateProcess):
if not topic or data is None:
break
(
camera,
frame_name,
frame_time,
current_tracked_objects,
motion_boxes,
regions,
) = data
camera_config = self.config.cameras.get(camera)
if camera_config is not None:
frame_manager.get(frame_name, camera_config.frame_shape_yuv)
frame_manager.close(frame_name)
detection_subscriber.stop()
for jsmpeg in jsmpeg_cameras.values():
+3 -1
View File
@@ -124,7 +124,7 @@ class PtzMotionEstimator:
)
yuv_frame = self.frame_manager.get(
frame_name, self.camera_config.frame_shape_yuv, frame_time
frame_name, self.camera_config.frame_shape_yuv
)
if yuv_frame is None:
@@ -181,6 +181,8 @@ class PtzMotionEstimator:
except Exception:
pass
self.frame_manager.close(frame_name)
return self.coord_transformations
+15 -21
View File
@@ -15,8 +15,8 @@ from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from typing import Any, cast
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
import pytz
from pathvalidate import sanitize_filename
from peewee import DoesNotExist
@@ -26,7 +26,6 @@ from frigate.const import (
CACHE_DIR,
CLIPS_DIR,
EXPORT_DIR,
EXPORT_STAGING_DIR,
MAX_PLAYLIST_SECONDS,
PREVIEW_FRAME_TYPE,
STREAM_TYPE_MAIN,
@@ -45,12 +44,11 @@ from frigate.util.recording_coverage import (
known_video_codecs,
null_audio_glitches,
realized_timeline,
recording_overlap,
resolve_coverage,
stream_media_summary,
)
from frigate.util.services import get_video_properties
from frigate.util.time import get_normalized_tz_name, is_current_hour
from frigate.util.time import is_current_hour
logger = logging.getLogger(__name__)
@@ -561,7 +559,7 @@ class RecordingExporter(threading.Thread):
)
def _staged_run_path(self, index: int) -> str:
return os.path.join(EXPORT_STAGING_DIR, f"{self.export_id}_{index}.mp4")
return os.path.join(CACHE_DIR, f"export_stage_{self.export_id}_{index}.mp4")
def _probe_stream_resolution(self, run: StreamRun) -> tuple[int, int] | None:
"""Probe one recording from a run for its resolution.
@@ -716,17 +714,6 @@ class RecordingExporter(threading.Thread):
for index, run in enumerate(runs):
dest = self._staged_run_path(index)
try:
os.makedirs(os.path.dirname(dest), exist_ok=True)
except OSError:
logger.exception(
"Failed to create staging directory for export %s",
self.export_id,
)
self._cleanup_staged_runs()
return False
weight = 100.0 * run.duration / total_duration
base = 100.0 * completed / total_duration
@@ -774,7 +761,14 @@ class RecordingExporter(threading.Thread):
Recordings.start_time,
Recordings.end_time,
)
.where(recording_overlap(self.start_time, self.end_time))
.where(
Recordings.start_time.between(self.start_time, self.end_time)
| Recordings.end_time.between(self.start_time, self.end_time)
| (
(self.start_time > Recordings.start_time)
& (self.end_time < Recordings.end_time)
)
)
.where(
(Recordings.camera == self.camera)
& (Recordings.stream_type == stream_type)
@@ -868,8 +862,8 @@ class RecordingExporter(threading.Thread):
tz_name = self.config.ui.timezone
if tz_name:
try:
tz = ZoneInfo(get_normalized_tz_name(tz_name))
except (ValueError, ZoneInfoNotFoundError):
tz = pytz.timezone(tz_name)
except pytz.UnknownTimeZoneError:
tz = None
if tz is not None:
return datetime.datetime.fromtimestamp(timestamp, tz=tz).strftime(
@@ -1032,8 +1026,8 @@ class RecordingExporter(threading.Thread):
tz: datetime.tzinfo | None = None
if tz_name:
try:
tz = ZoneInfo(get_normalized_tz_name(tz_name))
except (ValueError, ZoneInfoNotFoundError):
tz = pytz.timezone(tz_name)
except pytz.UnknownTimeZoneError:
tz = None
if tz is None:
tz = datetime.UTC
+4 -3
View File
@@ -1000,7 +1000,6 @@ class RecordingMaintainer(threading.Thread):
logger.error(f"Unable to convert {cache_path} to {file_path}")
if p.stderr:
logger.error((await p.stderr.read()).decode("ascii"))
Path(file_path).unlink(missing_ok=True)
return None
else:
logger.debug(
@@ -1018,7 +1017,7 @@ class RecordingMaintainer(threading.Thread):
except OSError:
segment_size = 0
self.drop_segment(cache_path)
os.remove(cache_path)
rand_id = "".join(
random.choices(string.ascii_lowercase + string.digits, k=6)
@@ -1047,8 +1046,10 @@ class RecordingMaintainer(threading.Thread):
}
except Exception:
logger.exception(f"Unable to store recording segment {cache_path}")
Path(cache_path).unlink(missing_ok=True)
self.drop_segment(cache_path)
# clear end_time cache
self.end_time_cache.pop(cache_path, None)
return None
def run(self) -> None:
+13 -7
View File
@@ -504,7 +504,6 @@ class ReviewSegmentMaintainer(threading.Thread):
def _handle_camera_removed(self, camera: str) -> None:
"""Close out a deleted camera's segment so a reused name cannot inherit it."""
self.forcibly_end_segment(camera)
self.frame_manager.close_camera(camera)
self.indefinite_events.pop(camera, None)
self.recent_classification_state_changes.pop(camera, None)
@@ -543,15 +542,19 @@ class ReviewSegmentMaintainer(threading.Thread):
if len(objects) <= pending.frame_active_count:
return
yuv_frame = self.frame_manager.get(
frame_name, camera_config.frame_shape_yuv, frame_time
)
try:
yuv_frame = self.frame_manager.get(
frame_name, camera_config.frame_shape_yuv
)
except FileNotFoundError:
return
if yuv_frame is None:
logger.debug(f"Failed to get frame {frame_name} from SHM")
return
pending.update_frame(camera_config, yuv_frame, objects)
self.frame_manager.close(frame_name)
def update_existing_segment(
self,
@@ -650,7 +653,7 @@ class ReviewSegmentMaintainer(threading.Thread):
try:
if should_update_image:
yuv_frame = self.frame_manager.get(
frame_name, camera_config.frame_shape_yuv, frame_time
frame_name, camera_config.frame_shape_yuv
)
if yuv_frame is None:
@@ -666,6 +669,7 @@ class ReviewSegmentMaintainer(threading.Thread):
activity.get_all_objects(),
prev_data,
)
self.frame_manager.close(frame_name)
except FileNotFoundError:
return
@@ -673,7 +677,7 @@ class ReviewSegmentMaintainer(threading.Thread):
if not segment.has_frame:
try:
yuv_frame = self.frame_manager.get(
frame_name, camera_config.frame_shape_yuv, frame_time
frame_name, camera_config.frame_shape_yuv
)
if yuv_frame is None:
@@ -681,6 +685,7 @@ class ReviewSegmentMaintainer(threading.Thread):
return
segment.save_full_frame(camera_config, yuv_frame)
self.frame_manager.close(frame_name)
self._publish_segment_update(
segment, camera_config, None, [], prev_data
)
@@ -765,7 +770,7 @@ class ReviewSegmentMaintainer(threading.Thread):
try:
yuv_frame = self.frame_manager.get(
frame_name, camera_config.frame_shape_yuv, frame_time
frame_name, camera_config.frame_shape_yuv
)
if yuv_frame is None:
@@ -775,6 +780,7 @@ class ReviewSegmentMaintainer(threading.Thread):
new_segment.update_frame(
camera_config, yuv_frame, activity.get_all_objects()
)
self.frame_manager.close(frame_name)
self._publish_segment_start(new_segment)
except FileNotFoundError:
return
+1 -1
View File
@@ -4,12 +4,12 @@ from unittest.mock import Mock, patch
import frigate.genai
from frigate.config import GenAIProviderEnum
from frigate.config.env import FRIGATE_ENV_VARS
from frigate.const import MODEL_CACHE_DIR, REDACTED_CREDENTIAL_SENTINEL
from frigate.genai import GenAIClient
from frigate.models import Event, Recordings, ReviewSegment
from frigate.stats.emitter import StatsEmitter
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
from frigate.util.env import FRIGATE_ENV_VARS
class TestHttpApp(BaseTestHttp):
@@ -417,77 +417,6 @@ class TestGo2rtcStreamAccess(BaseTestHttp):
assert resp.status_code == 400
assert resp.json().get("success") is False
def test_add_stream_rejects_ffmpeg_argument_injection(self):
"""PUT /go2rtc/streams must reject ffmpeg: sources whose parameters can
inject arbitrary ffmpeg arguments, since go2rtc expands them to exec:"""
app = self._make_app(_MULTI_CAMERA_CONFIG)
with patch("frigate.api.camera.requests.put") as mock_put:
with AuthTestClient(app) as client:
for src in (
# shape of the source in GHSA-qjjw-3v66-279f
"ffmpeg:#input='-f''h264''-i''data:placeholder''-c''copy'",
"ffmpeg:#input=-re",
"ffmpeg:front_door#raw=-vn",
"ffmpeg:front_door#video=-vn",
"ffmpeg:front_door#video=out.mp4",
"ffmpeg:front_door#drawtext=x",
"ffmpeg:front_door#width=1x",
"ffmpeg:front_door#audio=/16000",
"ffmpeg:front_door#audio=opus/",
"ffmpeg:front_door#audio=opus/16000/1",
"ffmpeg:'front_door'",
" ffmpeg:#input=-re",
):
resp = client.put("/go2rtc/streams/injected", params={"src": src})
assert resp.status_code == 400, (
f"Expected 400 for ffmpeg src {src!r}; got {resp.status_code}"
)
assert resp.json().get("success") is False
mock_put.assert_not_called()
def test_add_stream_allows_standard_ffmpeg_sources(self):
"""ffmpeg: sources built by the UI and shown in the docs must still be
forwarded to go2rtc"""
app = self._make_app(_MULTI_CAMERA_CONFIG)
mock_response = type("R", (), {"ok": True, "status_code": 200, "text": "ok"})()
with patch(
"frigate.api.camera.requests.put", return_value=mock_response
) as mock_put:
with AuthTestClient(app) as client:
for src in (
"ffmpeg:rtsp://10.0.0.1:554/video",
"ffmpeg:rtsp://10.0.0.1:554/video#video=copy#audio=copy",
"ffmpeg:rtsp://10.0.0.1:554/video#video=h264#hardware",
"ffmpeg:front_door#audio=opus",
"ffmpeg:front_door#video=h264#hardware=vaapi",
"ffmpeg:front_door#video=h264#width=1280#rotate=-90",
# documented HomeKit source
"ffmpeg:front_door#video=h264#width=1280#height=720#audio=opus/16000",
"ffmpeg:http://10.0.0.1/flv?port=1935&app=bcs#video=copy",
):
resp = client.put("/go2rtc/streams/legit", params={"src": src})
assert resp.status_code == 200, (
f"Expected 200 for ffmpeg src {src!r}; got {resp.status_code}"
)
forwarded_src = mock_put.call_args.kwargs["params"]["src"]
assert forwarded_src == src
def test_add_stream_allows_ffmpeg_injection_when_override_set(self):
"""GO2RTC_ALLOW_ARBITRARY_EXEC also lifts the ffmpeg: parameter check"""
app = self._make_app(_MULTI_CAMERA_CONFIG)
mock_response = type("R", (), {"ok": True, "status_code": 200, "text": "ok"})()
with patch("frigate.util.services._GO2RTC_ARBITRARY_EXEC_ENV", "true"):
with patch(
"frigate.api.camera.requests.put", return_value=mock_response
) as mock_put:
with AuthTestClient(app) as client:
resp = client.put(
"/go2rtc/streams/legit",
params={"src": "ffmpeg:front_door#raw=-vn"},
)
assert resp.status_code == 200
mock_put.assert_called_once()
def test_stream_alias_blocked_when_owning_camera_disallowed(self):
"""limited_user cannot access a stream alias that belongs to a camera they
are not allowed to see."""
@@ -1,226 +0,0 @@
"""Tests for go2rtc stream source restrictions on config/save and config/set."""
import contextlib
import copy
import os
import tempfile
from unittest.mock import patch
import ruamel.yaml
from frigate.models import Event, Recordings, ReviewSegment
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
from frigate.util.env import _CONTAINER_ENV, FRIGATE_ENV_VARS, reload_sources
# shape of the source in GHSA-qjjw-3v66-279f, with the payload replaced
_INJECTED_SRC = "ffmpeg:#input='-f''h264''-i''data:placeholder''-c''copy'"
# an unrestricted ffmpeg: source the operator already has in the file on disk
_EXISTING_SRC = "ffmpeg:rtsp://10.0.0.1:554/video#raw=-vn"
_CONFIG = {
"mqtt": {"host": "mqtt"},
"go2rtc": {"streams": {"front_door": [_EXISTING_SRC]}},
"cameras": {
"front_door": {
"ffmpeg": {
"inputs": [{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}]
},
"detect": {"height": 1080, "width": 1920, "fps": 5},
},
},
}
@contextlib.contextmanager
def _frigate_vars(values: dict[str, str]):
# config parsing rebuilds FRIGATE_ENV_VARS from its sources
with patch.dict(_CONTAINER_ENV, values), patch.dict(FRIGATE_ENV_VARS, values):
yield
reload_sources(warn=False)
def _dump(config: dict) -> str:
yaml = ruamel.yaml.YAML()
stream = tempfile.SpooledTemporaryFile(mode="w+")
yaml.dump(config, stream)
stream.seek(0)
return stream.read()
class TestConfigGo2rtcStreamRestrictions(BaseTestHttp):
def setUp(self):
super().setUp([Event, Recordings, ReviewSegment])
self.minimal_config = _CONFIG
self.app = super().create_app()
fd, self.config_path = tempfile.mkstemp(suffix=".yml")
with os.fdopen(fd, "w") as f:
f.write(_dump(_CONFIG))
self.addCleanup(os.unlink, self.config_path)
find_patch = patch(
"frigate.api.app.find_config_file", return_value=self.config_path
)
find_patch.start()
self.addCleanup(find_patch.stop)
# the override is read once at import; make sure it is off here
exec_patch = patch("frigate.util.services._GO2RTC_ARBITRARY_EXEC_ENV", None)
exec_patch.start()
self.addCleanup(exec_patch.stop)
def _read_config(self) -> str:
with open(self.config_path) as f:
return f.read()
def _set_streams(self, streams: dict):
with AuthTestClient(self.app) as client:
return client.put(
"/config/set",
json={
"config_data": {"go2rtc": {"streams": streams}},
"requires_restart": 1,
},
)
def _save_streams(self, streams: dict, prefix: str = ""):
config = copy.deepcopy(_CONFIG)
config["go2rtc"]["streams"].update(streams)
with AuthTestClient(self.app) as client:
return client.post(
"/config/save?save_option=saveonly",
content=prefix + _dump(config),
headers={"Content-Type": "text/plain"},
)
# --- config/set ---
def test_set_rejects_injected_ffmpeg_source(self):
before = self._read_config()
resp = self._set_streams({"new_stream": [_INJECTED_SRC]})
assert resp.status_code == 400
assert resp.json()["success"] is False
assert self._read_config() == before
def test_set_rejects_injected_string_source(self):
before = self._read_config()
resp = self._set_streams({"new_stream": "ffmpeg:front_door#raw=-vn"})
assert resp.status_code == 400
assert self._read_config() == before
def test_set_rejects_injected_source_added_to_existing_stream(self):
before = self._read_config()
resp = self._set_streams({"front_door": [_EXISTING_SRC, _INJECTED_SRC]})
assert resp.status_code == 400
assert self._read_config() == before
def test_set_allows_standard_sources(self):
resp = self._set_streams(
{
"new_stream": [
"rtsp://10.0.0.2:554/video",
"ffmpeg:new_stream#audio=opus",
]
}
)
assert resp.status_code == 200, resp.text
assert "ffmpeg:new_stream#audio=opus" in self._read_config()
def test_set_keeps_existing_source_on_disk(self):
resp = self._set_streams(
{"front_door": [_EXISTING_SRC, "ffmpeg:front_door#audio=opus"]}
)
assert resp.status_code == 200, resp.text
assert _EXISTING_SRC in self._read_config()
def test_set_allows_injected_source_when_override_set(self):
with patch("frigate.util.services._GO2RTC_ARBITRARY_EXEC_ENV", "true"):
resp = self._set_streams({"new_stream": [_INJECTED_SRC]})
assert resp.status_code == 200, resp.text
# --- config/save ---
def test_save_rejects_injected_ffmpeg_source(self):
before = self._read_config()
resp = self._save_streams({"new_stream": [_INJECTED_SRC]})
assert resp.status_code == 400
assert self._read_config() == before
def test_save_rejects_exec_source(self):
before = self._read_config()
resp = self._save_streams({"new_stream": "exec:/tmp/something"})
assert resp.status_code == 400
assert self._read_config() == before
def test_save_rejects_map_source(self):
before = self._read_config()
resp = self._save_streams({"new_stream": {"url": "rtsp://10.0.0.2/video"}})
assert resp.status_code == 400
assert self._read_config() == before
def test_save_resubmitting_existing_config_allowed(self):
# the UI config editor always sends back the whole file
resp = self._save_streams({"new_stream": "rtsp://10.0.0.2:554/video"})
assert resp.status_code == 200, resp.text
config = self._read_config()
assert _EXISTING_SRC in config
assert "rtsp://10.0.0.2:554/video" in config
def test_save_tagged_config_with_standard_source_allowed(self):
# a tag on the root map still passes validation and startup parsing
resp = self._save_streams(
{"new_stream": "rtsp://10.0.0.2:554/video"}, prefix="!config\n"
)
assert resp.status_code == 200, resp.text
def test_save_tagged_config_with_injected_source_rejected(self):
before = self._read_config()
resp = self._save_streams({"new_stream": [_INJECTED_SRC]}, prefix="!config\n")
assert resp.status_code == 400
assert "Restricted go2rtc stream source" in resp.json()["message"]
assert self._read_config() == before
# --- {FRIGATE_*} placeholders resolve the same way as at go2rtc startup ---
def test_set_rejects_unresolvable_placeholder(self):
before = self._read_config()
with _frigate_vars({"FRIGATE_RTSP_USER": "user"}):
for src, message in (
(
"{FRIGATE_RTSP_USER!r}fmpeg:front_door#raw=-vn",
"Invalid variable placeholder in go2rtc stream: new_stream",
),
(
"{FRIGATE_RTSP_USER:>5}",
"Invalid variable placeholder in go2rtc stream: new_stream",
),
(
"rtsp://{FRIGATE_UNDEFINED}@10.0.0.2:554/video",
"Unknown variable in go2rtc stream: new_stream",
),
):
resp = self._set_streams({"new_stream": [src]})
assert resp.status_code == 400, src
assert resp.json()["message"] == message, src
assert self._read_config() == before
def test_set_reports_each_issue(self):
resp = self._set_streams(
{
"typo_stream": ["rtsp://{FRIGATE_UNDEFINED}@10.0.0.2:554/video"],
"injected_stream": [_INJECTED_SRC],
}
)
assert resp.status_code == 400
assert resp.json()["message"] == (
"Restricted go2rtc stream source in: injected_stream; "
"Unknown variable in go2rtc stream: typo_stream"
)
def test_set_allows_resolvable_placeholder(self):
src = "rtsp://{FRIGATE_RTSP_USER}@10.0.0.2:554/video"
with _frigate_vars({"FRIGATE_RTSP_USER": "user"}):
resp = self._set_streams({"new_stream": [src]})
assert resp.status_code == 200, resp.text
assert src in self._read_config()
-77
View File
@@ -4,7 +4,6 @@ import tempfile
import zipfile
from unittest.mock import patch
from frigate.const import CLIPS_DIR
from frigate.jobs.export import (
ExportJob,
get_export_job_manager,
@@ -454,23 +453,6 @@ class TestHttpExport(BaseTestHttp):
assert unchanged.name == "front door export"
assert unchanged.video_path == video
def test_reap_stale_exports_removes_staged_runs(self):
with tempfile.TemporaryDirectory() as tmpdir:
staging_dir = os.path.join(tmpdir, "staging")
os.makedirs(staging_dir)
staged = os.path.join(staging_dir, "front_door_abc_0.mp4")
finished = os.path.join(tmpdir, "export_stage_saved_abc.mp4")
for path in (staged, finished):
with open(path, "w") as handle:
handle.write("video")
with patch("frigate.jobs.export.EXPORT_STAGING_DIR", staging_dir):
reap_stale_exports()
assert not os.path.exists(staging_dir)
assert os.path.exists(finished)
def test_reap_stale_exports_deletes_rows_with_no_file(self):
with tempfile.TemporaryDirectory() as tmpdir:
stale_video = os.path.join(tmpdir, "stale.mp4")
@@ -1052,65 +1034,6 @@ class TestHttpExport(BaseTestHttp):
assert response.status_code == 400
assert ExportCase.select().count() == 0
def _batch_export_with_image(self, image_path: str, role: str):
with patch(
"frigate.api.export.start_export_job",
side_effect=lambda _config, job: job.id,
) as start_export_job:
with AuthTestClient(self.app) as client:
response = client.post(
"/exports/batch",
headers={"remote-user": role, "remote-role": role},
json={
"items": [
{
"camera": "front_door",
"start_time": 110,
"end_time": 150,
"image_path": image_path,
}
],
},
)
return response, start_export_job
def test_batch_export_restricted_role_rejects_unreadable_image_path(self):
self._insert_recording("rec-front", "front_door", 100, 400)
self.app.frigate_config.auth.roles["limited_user"] = ["front_door"]
for image_path in (
f"{CLIPS_DIR}/review/thumb-backyard-123.456-abc.webp",
f"{CLIPS_DIR}/thumbs/backyard/123.webp",
f"{CLIPS_DIR}/faces/someone/1.webp",
f"{CLIPS_DIR}/custom/thumb.jpg",
):
response, _ = self._batch_export_with_image(image_path, "limited_user")
assert response.status_code == 400, image_path
def test_batch_export_restricted_role_accepts_own_camera_image_path(self):
self._insert_recording("rec-front", "front_door", 100, 400)
self.app.frigate_config.auth.roles["limited_user"] = ["front_door"]
image_path = f"{CLIPS_DIR}/review/thumb-front_door-123.456-abc.webp"
response, start_export_job = self._batch_export_with_image(
image_path, "limited_user"
)
assert response.status_code == 202
assert start_export_job.call_args.args[1].image_path == image_path
def test_batch_export_unrestricted_roles_accept_custom_image_path(self):
self._insert_recording("rec-front", "front_door", 100, 400)
image_path = f"{CLIPS_DIR}/custom/thumb.jpg"
for role in ("admin", "viewer"):
response, start_export_job = self._batch_export_with_image(image_path, role)
assert response.status_code == 202, role
assert start_export_job.call_args.args[1].image_path == image_path
def test_batch_export_non_admin_can_queue(self):
self._insert_recording("rec-front", "front_door", 100, 400)
@@ -1,73 +0,0 @@
"""Tests for the /auth subrequest on nginx-proxied go2rtc live-stream paths."""
import time
from unittest.mock import patch
import requests
from fastapi.testclient import TestClient
from joserfc.jwk import OctKey
from frigate.api.auth import create_encoded_jwt
from frigate.models import Event, Recordings, ReviewSegment, User
from frigate.test.http_api.base_http_test import BaseTestHttp
_CONFIG = {
"mqtt": {"host": "mqtt"},
"cameras": {
"front_door": {
"ffmpeg": {
"inputs": [{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}]
},
"detect": {"height": 1080, "width": 1920, "fps": 5},
},
},
}
# shape of the source in GHSA-93xj-hg88-ffg7, with the payload replaced
_AD_HOC_SRC = "ffmpeg:%23input%3D'-f''h264''-i''data:placeholder''-c''copy'"
class TestGo2rtcProxyAuth(BaseTestHttp):
def setUp(self):
super().setUp([Event, ReviewSegment, Recordings, User])
self.minimal_config = _CONFIG
self.app = super().create_app()
self.app.jwt_token = OctKey.import_key(b"0" * 32)
get_patch = patch(
"frigate.api.auth.requests.get",
side_effect=requests.ConnectionError("go2rtc unavailable"),
)
get_patch.start()
self.addCleanup(get_patch.stop)
def _auth(self, role: str, original_url: str):
token = create_encoded_jwt(
role, role, int(time.time()) + 3600, self.app.jwt_token
)
with TestClient(self.app) as client:
return client.get(
"/auth",
headers={
"authorization": f"Bearer {token}",
"x-original-url": original_url,
},
)
def test_viewer_ad_hoc_mse_source_denied(self):
resp = self._auth("viewer", f"/live/mse/api/ws?src={_AD_HOC_SRC}")
assert resp.status_code == 403
def test_viewer_ad_hoc_webrtc_sources_denied(self):
for path in ("/live/webrtc/api/ws", "/api/go2rtc/webrtc"):
resp = self._auth("viewer", f"{path}?src={_AD_HOC_SRC}")
assert resp.status_code == 403, path
def test_admin_ad_hoc_mse_source_denied(self):
resp = self._auth("admin", f"/live/mse/api/ws?src={_AD_HOC_SRC}")
assert resp.status_code == 403
def test_viewer_camera_stream_allowed(self):
resp = self._auth("viewer", "/live/mse/api/ws?src=front_door")
assert resp.status_code == 202
assert resp.headers["remote-role"] == "viewer"
+21 -96
View File
@@ -1,12 +1,10 @@
"""Unit tests for recordings/media API endpoints."""
import threading
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from unittest.mock import patch
from zoneinfo import ZoneInfo
import pytz
from fastapi import Request
from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user
@@ -130,16 +128,16 @@ class TestHttpMedia(BaseTestHttp):
In 2024, DST in America/New_York transitions on March 10, 2024 at 2:00 AM
Clocks spring forward from 2:00 AM to 3:00 AM (EST to EDT)
"""
tz = ZoneInfo("America/New_York")
tz = pytz.timezone("America/New_York")
# March 9, 2024 at 12:00 PM EST (before DST)
march_9_noon = datetime(2024, 3, 9, 12, 0, 0, tzinfo=tz).timestamp()
march_9_noon = tz.localize(datetime(2024, 3, 9, 12, 0, 0)).timestamp()
# March 10, 2024 at 12:00 PM EDT (after DST transition)
march_10_noon = datetime(2024, 3, 10, 12, 0, 0, tzinfo=tz).timestamp()
march_10_noon = tz.localize(datetime(2024, 3, 10, 12, 0, 0)).timestamp()
# March 11, 2024 at 12:00 PM EDT (after DST)
march_11_noon = datetime(2024, 3, 11, 12, 0, 0, tzinfo=tz).timestamp()
march_11_noon = tz.localize(datetime(2024, 3, 11, 12, 0, 0)).timestamp()
with AuthTestClient(self.app) as client:
# Insert recordings for each day
@@ -203,16 +201,19 @@ class TestHttpMedia(BaseTestHttp):
In 2024, DST in America/New_York transitions on November 3, 2024 at 2:00 AM
Clocks fall back from 2:00 AM to 1:00 AM (EDT to EST)
"""
tz = ZoneInfo("America/New_York")
tz = pytz.timezone("America/New_York")
# November 2, 2024 at 12:00 PM EDT (before DST transition)
nov_2_noon = datetime(2024, 11, 2, 12, 0, 0, tzinfo=tz).timestamp()
nov_2_noon = tz.localize(datetime(2024, 11, 2, 12, 0, 0)).timestamp()
# November 3, 2024 at 12:00 PM EST (after DST transition)
nov_3_noon = datetime(2024, 11, 3, 12, 0, 0, tzinfo=tz).timestamp()
# Need to specify is_dst=False to get the time after fall back
nov_3_noon = tz.localize(
datetime(2024, 11, 3, 12, 0, 0), is_dst=False
).timestamp()
# November 4, 2024 at 12:00 PM EST (after DST)
nov_4_noon = datetime(2024, 11, 4, 12, 0, 0, tzinfo=tz).timestamp()
nov_4_noon = tz.localize(datetime(2024, 11, 4, 12, 0, 0)).timestamp()
with AuthTestClient(self.app) as client:
# Insert recordings for each day
@@ -273,13 +274,13 @@ class TestHttpMedia(BaseTestHttp):
"""
Test recordings summary with multiple cameras across DST boundary.
"""
tz = ZoneInfo("America/New_York")
tz = pytz.timezone("America/New_York")
# March 9, 2024 at 10:00 AM EST (before DST)
march_9_morning = datetime(2024, 3, 9, 10, 0, 0, tzinfo=tz).timestamp()
march_9_morning = tz.localize(datetime(2024, 3, 9, 10, 0, 0)).timestamp()
# March 10, 2024 at 3:00 PM EDT (after DST transition)
march_10_afternoon = datetime(2024, 3, 10, 15, 0, 0, tzinfo=tz).timestamp()
march_10_afternoon = tz.localize(datetime(2024, 3, 10, 15, 0, 0)).timestamp()
with AuthTestClient(self.app) as client:
# Override allowed cameras for this test to include both
@@ -342,15 +343,15 @@ class TestHttpMedia(BaseTestHttp):
"""
Test recordings that span the exact DST transition time.
"""
tz = ZoneInfo("America/New_York")
tz = pytz.timezone("America/New_York")
# March 10, 2024 at 1:00 AM EST (1 hour before DST transition)
# At 2:00 AM, clocks jump to 3:00 AM
before_transition = datetime(2024, 3, 10, 1, 0, 0, tzinfo=tz).timestamp()
before_transition = tz.localize(datetime(2024, 3, 10, 1, 0, 0)).timestamp()
# Recording that spans the transition (1:00 AM to 3:30 AM EDT)
# This is 1.5 hours of actual time but spans the "missing" hour
after_transition = datetime(2024, 3, 10, 3, 30, 0, tzinfo=tz).timestamp()
after_transition = tz.localize(datetime(2024, 3, 10, 3, 30, 0)).timestamp()
with AuthTestClient(self.app) as client:
Recordings.insert(
@@ -410,7 +411,7 @@ class TestHttpMedia(BaseTestHttp):
# Test with UTC timezone
response = client.get(
"/recordings/summary", params={"timezone": "UTC", "cameras": "all"}
"/recordings/summary", params={"timezone": "utc", "cameras": "all"}
)
assert response.status_code == 200
@@ -441,8 +442,8 @@ class TestHttpMedia(BaseTestHttp):
"""
Test recordings summary filtered to a single camera.
"""
tz = ZoneInfo("America/New_York")
march_10_noon = datetime(2024, 3, 10, 12, 0, 0, tzinfo=tz).timestamp()
tz = pytz.timezone("America/New_York")
march_10_noon = tz.localize(datetime(2024, 3, 10, 12, 0, 0)).timestamp()
with AuthTestClient(self.app) as client:
# Insert recordings for both cameras
@@ -2021,79 +2022,3 @@ class TestHttpMedia(BaseTestHttp):
assert response.status_code == 200
assert response.json() == [{"start_time": 1010, "end_time": 1030}]
def test_recording_snapshot_does_not_block_event_loop(self):
self._insert_recording("snapshot", 1000, 1010)
started = threading.Event()
release = threading.Event()
finished = threading.Event()
def slow_image(*args):
started.set()
release.wait(timeout=5)
finished.set()
return b"jpeg"
with (
AuthTestClient(self.app) as client,
patch("frigate.api.media.get_image_from_recording", slow_image),
ThreadPoolExecutor(max_workers=1) as executor,
):
snapshot = executor.submit(
client.get, "/front_door/recordings/1005/snapshot.jpg"
)
self.assertTrue(started.wait(timeout=2))
version = client.get("/version")
blocked = finished.is_set()
release.set()
self.assertEqual(version.status_code, 200)
self.assertFalse(blocked)
self.assertEqual(snapshot.result().status_code, 200)
def test_recording_snapshot_hit_returns_image(self):
self._insert_recording("snapshot", 1000, 1010)
with (
AuthTestClient(self.app) as client,
patch("frigate.api.media.get_image_from_recording", return_value=b"jpeg"),
):
response = client.get("/front_door/recordings/1005/snapshot.jpg")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], "image/jpeg")
self.assertEqual(response.content, b"jpeg")
def test_recording_snapshot_misses_return_404(self):
self._insert_recording("snapshot", 1000, 1010)
with AuthTestClient(self.app) as client:
absent = client.get("/front_door/recordings/2000/snapshot.jpg")
rounded_miss = client.get("/front_door/recordings/1010.5/snapshot.jpg")
self.assertEqual(absent.status_code, 404)
self.assertEqual(rounded_miss.status_code, 404)
def test_plus_snapshot_miss_returns_404_without_image_lookup(self):
with (
AuthTestClient(self.app) as client,
patch("frigate.api.media.get_image_from_recording") as image,
):
response = client.post("/front_door/plus/1005")
self.assertEqual(response.status_code, 404)
image.assert_not_called()
def test_plus_snapshot_hit_uploads_image(self):
self._insert_recording("snapshot", 1000, 1010)
with (
AuthTestClient(self.app) as client,
patch("frigate.api.media.get_image_from_recording", return_value=b"png"),
patch("frigate.api.media.cv2.imdecode", return_value="frame"),
patch.object(self.app.frigate_config.plus_api, "upload_image") as upload,
):
response = client.post("/front_door/plus/1005")
self.assertEqual(response.status_code, 200)
upload.assert_called_once_with("frame", "front_door")
@@ -1,59 +0,0 @@
"""Tests for notification registration and the admin row it relies on."""
from types import SimpleNamespace
from frigate.app import FrigateApp
from frigate.config import FrigateConfig
from frigate.models import User
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
SUBSCRIPTION = {
"endpoint": "https://fcm.googleapis.com/fcm/send/dGhpcy1pcy1hLXRva2Vu",
"keys": {"p256dh": "cHVibGljLWtleQ", "auth": "YXV0aC1zZWNyZXQ"},
}
class TestNotificationRegistration(BaseTestHttp):
def setUp(self):
super().setUp([User])
def _init_auth(self, enabled: bool) -> FrigateConfig:
config = FrigateConfig(**{**self.minimal_config, "auth": {"enabled": enabled}})
FrigateApp.init_auth(SimpleNamespace(config=config))
return config
def _register(self):
self.minimal_config["auth"] = {"enabled": False}
with AuthTestClient(self.create_app()) as client:
return client.post("/notifications/register", json={"sub": SUBSCRIPTION})
def test_auth_disabled_fresh_install_persists_token(self):
self._init_auth(enabled=False)
assert self._register().status_code == 200
admin = User.get(User.username == "admin")
assert admin.password_hash == ""
assert admin.notification_tokens == [SUBSCRIPTION]
def test_auth_disabled_does_not_recreate_deleted_admin(self):
User.insert(
username="viewer1",
role="viewer",
password_hash="x$1$y$z",
notification_tokens=[],
).execute()
self._init_auth(enabled=False)
assert User.get_or_none(User.username == "admin") is None
assert self._register().status_code == 404
def test_enabling_auth_sets_password_and_keeps_tokens(self):
self._init_auth(enabled=False)
assert self._register().status_code == 200
config = self._init_auth(enabled=True)
admin = User.get(User.username == "admin")
assert admin.password_hash.count("$") == 3
assert admin.notification_tokens == [SUBSCRIPTION]
assert config.auth.admin_first_time_login is True
+1 -1
View File
@@ -342,7 +342,7 @@ class TestHttpReview(BaseTestHttp):
"cameras": "front_door",
"labels": "all",
"zones": "all",
"timezone": "UTC",
"timezone": "utc",
}
response = client.get("/review/summary", params=params)
assert response.status_code == 200
-33
View File
@@ -1,33 +0,0 @@
"""Tests for user creation."""
from unittest.mock import MagicMock
from frigate.models import User
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
PASSWORD = "a-valid-password-123"
class TestCreateUser(BaseTestHttp):
def setUp(self):
super().setUp([User])
self.app = super().create_app()
self.app.config_publisher = MagicMock()
def _create(self, username: str):
with AuthTestClient(self.app) as client:
return client.post(
"/users",
json={"username": username, "password": PASSWORD, "role": "viewer"},
)
def test_rejects_dot_only_usernames(self):
# browsers resolve these as URL path segments, so the per-user
# endpoints can never be reached for them
for username in (".", "..", "..."):
assert self._create(username).status_code == 400
assert User.get_or_none(User.username == username) is None
def test_accepts_username_containing_dots(self):
assert self._create("john.doe_1").status_code == 200
assert User.get_or_none(User.username == "john.doe_1") is not None
-2
View File
@@ -183,12 +183,10 @@ class TestReviewMaintainerRemoval(unittest.TestCase):
"deleted_cam": [{"model": "gate", "from": "a", "to": "b", "timestamp": 1.0}]
}
maintainer.forcibly_end_segment = MagicMock()
maintainer.frame_manager = MagicMock()
maintainer._handle_camera_removed("deleted_cam")
maintainer.forcibly_end_segment.assert_called_once_with("deleted_cam")
maintainer.frame_manager.close_camera.assert_called_once_with("deleted_cam")
self.assertNotIn("deleted_cam", maintainer.indefinite_events)
self.assertNotIn("deleted_cam", maintainer.recent_classification_state_changes)

Some files were not shown because too many files have changed in this diff Show More