mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-19 05:02:49 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c9295ffbc |
@@ -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
-139
@@ -1,157 +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"]
|
||||
# Needs an on-device pinch test before moving
|
||||
- dependency-name: "react-zoom-pan-pinch"
|
||||
update-types: ["version-update:semver-major"]
|
||||
# Build fails until monaco-worker-manager releases its PR 4
|
||||
- dependency-name: "monaco-editor"
|
||||
versions: [">=0.55.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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
ruff == 0.17.0
|
||||
ruff == 0.16.10
|
||||
|
||||
# types
|
||||
types-peewee == 4.0.*
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
numpy
|
||||
openvino >= 2026.4.1
|
||||
openvino >= 2026.2.0
|
||||
|
||||
@@ -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 @@
|
||||
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
@@ -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
|
||||
|
||||
|
||||
@@ -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 +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
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 |
|
||||
| -------------- | --------------------------- | ------------------------- | ---------------------- |
|
||||
|
||||
@@ -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/>
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
Vendored
+7
-7
@@ -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
@@ -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
|
||||
|
||||
+35
-252
@@ -61,7 +61,6 @@ from frigate.util.builtin import (
|
||||
clean_camera_user_pass,
|
||||
deep_merge,
|
||||
flatten_config_data,
|
||||
is_masked_camera_path,
|
||||
load_labels,
|
||||
process_config_query_string,
|
||||
split_config_key_path,
|
||||
@@ -72,7 +71,6 @@ from frigate.util.config import (
|
||||
find_config_file,
|
||||
redact_credential,
|
||||
)
|
||||
from frigate.util.env import substitute_frigate_vars
|
||||
from frigate.util.live_streams import (
|
||||
generated_transcode_streams,
|
||||
sync_transcode_streams,
|
||||
@@ -80,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,
|
||||
@@ -404,8 +400,6 @@ def config(request: Request):
|
||||
|
||||
config["go2rtc"]["streams"][stream_name] = cleaned
|
||||
|
||||
_redact_go2rtc_credentials(config["go2rtc"])
|
||||
|
||||
config["plus"] = {"enabled": request.app.frigate_config.plus_api.is_active()}
|
||||
|
||||
for index, model in enumerate(config_obj.models):
|
||||
@@ -432,28 +426,6 @@ def config(request: Request):
|
||||
return JSONResponse(content=config)
|
||||
|
||||
|
||||
def _redact_go2rtc_credentials(go2rtc: dict[str, Any]) -> None:
|
||||
"""Redact go2rtc secrets outside of stream URLs, in place."""
|
||||
for section in ("rtsp", "api", "mqtt"):
|
||||
if isinstance(go2rtc.get(section), dict):
|
||||
redact_credential(go2rtc[section], "username")
|
||||
redact_credential(go2rtc[section], "password")
|
||||
|
||||
if isinstance(go2rtc.get("ngrok"), dict):
|
||||
redact_credential(go2rtc["ngrok"], "command")
|
||||
|
||||
if isinstance(go2rtc.get("homekit"), dict):
|
||||
for accessory in go2rtc["homekit"].values():
|
||||
if isinstance(accessory, dict):
|
||||
for key in ("pin", "device_private", "pairings"):
|
||||
redact_credential(accessory, key)
|
||||
|
||||
# publish destinations carry stream keys in the URL path
|
||||
if isinstance(go2rtc.get("publish"), dict):
|
||||
for name in list(go2rtc["publish"]):
|
||||
redact_credential(go2rtc["publish"], name)
|
||||
|
||||
|
||||
@router.get("/profiles", dependencies=[Depends(allow_any_authenticated())])
|
||||
def get_profiles(request: Request):
|
||||
"""List all available profiles and the currently active profile."""
|
||||
@@ -574,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()
|
||||
@@ -707,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()
|
||||
@@ -768,24 +671,17 @@ def config_save(save_option: str, body: Any = Body(media_type="text/plain")):
|
||||
)
|
||||
|
||||
|
||||
def _restore_masked_camera_paths(config_data: dict, saved: dict) -> str | None:
|
||||
"""Substitute incoming masked camera paths with the saved ones.
|
||||
def _restore_masked_camera_paths(config_data: dict, config: FrigateConfig) -> None:
|
||||
"""Substitute incoming `*:*` masked credentials with the in-memory ones.
|
||||
|
||||
The /config response masks ffmpeg input credentials, so the settings UI
|
||||
sends the masked path back when sibling fields (e.g. hwaccel_args) are
|
||||
edited. Each masked path is matched to the saved input that masks to the
|
||||
same value, with roles breaking a tie, so deleting or reordering inputs
|
||||
keeps each path's own credentials. The saved path is restored as written,
|
||||
keeping any {FRIGATE_*} placeholder. Mutates `config_data` in place and
|
||||
returns an error message when a masked path has no single match.
|
||||
edited. Without this we'd write `rtsp://*:*@host` into YAML and lose
|
||||
the real credentials. Mutates `config_data` in place.
|
||||
"""
|
||||
cameras = config_data.get("cameras")
|
||||
if not isinstance(cameras, dict):
|
||||
return None
|
||||
|
||||
saved_cameras = saved.get("cameras")
|
||||
if not isinstance(saved_cameras, dict):
|
||||
saved_cameras = {}
|
||||
return
|
||||
|
||||
for camera_name, camera_data in cameras.items():
|
||||
if not isinstance(camera_data, dict):
|
||||
@@ -793,122 +689,20 @@ def _restore_masked_camera_paths(config_data: dict, saved: dict) -> str | None:
|
||||
inputs = camera_data.get("ffmpeg", {}).get("inputs")
|
||||
if not isinstance(inputs, list):
|
||||
continue
|
||||
|
||||
saved_camera = saved_cameras.get(camera_name)
|
||||
saved_ffmpeg = (
|
||||
saved_camera.get("ffmpeg") if isinstance(saved_camera, dict) else None
|
||||
)
|
||||
saved_inputs = [
|
||||
inp
|
||||
for inp in (
|
||||
saved_ffmpeg.get("inputs") if isinstance(saved_ffmpeg, dict) else None
|
||||
)
|
||||
or []
|
||||
if isinstance(inp, dict) and isinstance(inp.get("path"), str)
|
||||
]
|
||||
|
||||
existing = config.cameras.get(camera_name)
|
||||
if existing is None:
|
||||
continue
|
||||
existing_paths = [inp.path for inp in existing.ffmpeg.inputs]
|
||||
for index, input_obj in enumerate(inputs):
|
||||
if not isinstance(input_obj, dict):
|
||||
continue
|
||||
path = input_obj.get("path")
|
||||
if not isinstance(path, str) or not is_masked_camera_path(path):
|
||||
if not isinstance(path, str):
|
||||
continue
|
||||
candidates = [
|
||||
inp
|
||||
for inp in saved_inputs
|
||||
if clean_camera_user_pass(_resolve_frigate_vars(inp["path"])) == path
|
||||
]
|
||||
if len({inp["path"] for inp in candidates}) > 1:
|
||||
candidates = [
|
||||
inp
|
||||
for inp in candidates
|
||||
if inp.get("roles") == input_obj.get("roles")
|
||||
]
|
||||
real_paths = {inp["path"] for inp in candidates}
|
||||
if len(real_paths) != 1:
|
||||
return (
|
||||
f"Input {index} of camera {camera_name} has masked credentials "
|
||||
"that could not be matched; resend it with real credentials"
|
||||
)
|
||||
input_obj["path"] = real_paths.pop()
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_frigate_vars(value: str) -> str:
|
||||
"""Resolve {FRIGATE_*} placeholders the way /config shows them."""
|
||||
try:
|
||||
return substitute_frigate_vars(value)
|
||||
except ValueError:
|
||||
return value
|
||||
|
||||
|
||||
def _saved_config_from_memory(config: FrigateConfig) -> dict[str, Any]:
|
||||
"""Shape the in-memory camera inputs like the saved YAML."""
|
||||
return {
|
||||
"cameras": {
|
||||
name: {
|
||||
"ffmpeg": {
|
||||
"inputs": [
|
||||
{"path": inp.path, "roles": [role.value for role in inp.roles]}
|
||||
for inp in camera.ffmpeg.inputs
|
||||
]
|
||||
}
|
||||
}
|
||||
for name, camera in config.cameras.items()
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _config_data_to_updates(
|
||||
config_data: dict, saved: dict[str, Any]
|
||||
) -> tuple[dict[str, Any], str | None]:
|
||||
"""Flatten a /config/set payload after restoring masked credentials.
|
||||
|
||||
`saved` is the config the masked values were taken from. Returns the flat
|
||||
updates, or an error message when a masked value can't be restored.
|
||||
"""
|
||||
error = _restore_masked_camera_paths(config_data, saved)
|
||||
if error:
|
||||
return {}, error
|
||||
|
||||
updates = flatten_config_data(config_data)
|
||||
# Convert None values to empty strings for deletion (e.g., when deleting masks)
|
||||
updates = {k: ("" if v is None else v) for k, v in updates.items()}
|
||||
# Drop sentinel-valued fields so untouched credential placeholders don't
|
||||
# clobber the saved YAML value.
|
||||
updates = {k: v for k, v in updates.items() if v != REDACTED_CREDENTIAL_SENTINEL}
|
||||
|
||||
error = _find_masked_update(updates)
|
||||
if error:
|
||||
return {}, error
|
||||
|
||||
return updates, None
|
||||
|
||||
|
||||
def _holds_masked_value(value: Any) -> bool:
|
||||
if isinstance(value, str):
|
||||
return REDACTED_CREDENTIAL_SENTINEL in value or is_masked_camera_path(value)
|
||||
if isinstance(value, dict):
|
||||
return any(_holds_masked_value(v) for v in value.values())
|
||||
if isinstance(value, list):
|
||||
return any(_holds_masked_value(v) for v in value)
|
||||
return False
|
||||
|
||||
|
||||
def _find_masked_update(updates: dict[str, Any]) -> str | None:
|
||||
"""Return an error naming the first update that still holds a masked value.
|
||||
|
||||
Runs after the restores so a masked path or redaction sentinel that could
|
||||
not be restored is never written over the real credential.
|
||||
"""
|
||||
for key, value in updates.items():
|
||||
if _holds_masked_value(value):
|
||||
return (
|
||||
f"{key} contains masked or saved credentials; "
|
||||
"resend it with real values"
|
||||
)
|
||||
return None
|
||||
if ("://*:*@" in path or "user=*&password=*" in path) and index < len(
|
||||
existing_paths
|
||||
):
|
||||
input_obj["path"] = existing_paths[index]
|
||||
|
||||
|
||||
def _config_set_in_memory(request: Request, body: AppConfigSetBody) -> JSONResponse:
|
||||
@@ -921,13 +715,13 @@ def _config_set_in_memory(request: Request, body: AppConfigSetBody) -> JSONRespo
|
||||
try:
|
||||
updates = {}
|
||||
if body.config_data:
|
||||
updates, error = _config_data_to_updates(
|
||||
body.config_data, _saved_config_from_memory(request.app.frigate_config)
|
||||
)
|
||||
if error:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": error}, status_code=400
|
||||
)
|
||||
_restore_masked_camera_paths(body.config_data, request.app.frigate_config)
|
||||
updates = flatten_config_data(body.config_data)
|
||||
updates = {k: ("" if v is None else v) for k, v in updates.items()}
|
||||
# Drop any field whose value is still the redaction sentinel
|
||||
updates = {
|
||||
k: v for k, v in updates.items() if v != REDACTED_CREDENTIAL_SENTINEL
|
||||
}
|
||||
|
||||
if not updates:
|
||||
return JSONResponse(
|
||||
@@ -1074,26 +868,22 @@ def config_set(request: Request, body: AppConfigSetBody):
|
||||
# Filter out empty keys but keep blank values for non-empty keys
|
||||
query_string = {k: v for k, v in query_string.items() if k}
|
||||
|
||||
error = None
|
||||
|
||||
if query_string:
|
||||
updates = process_config_query_string(query_string)
|
||||
error = _find_masked_update(updates)
|
||||
elif body.config_data:
|
||||
try:
|
||||
saved_config = ruamel.yaml.YAML(typ="safe").load(old_raw_config)
|
||||
except ruamel.yaml.YAMLError:
|
||||
saved_config = None
|
||||
updates, error = _config_data_to_updates(
|
||||
body.config_data,
|
||||
saved_config if isinstance(saved_config, dict) else {},
|
||||
)
|
||||
|
||||
if error:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": error},
|
||||
status_code=400,
|
||||
_restore_masked_camera_paths(
|
||||
body.config_data, request.app.frigate_config
|
||||
)
|
||||
updates = flatten_config_data(body.config_data)
|
||||
# Convert None values to empty strings for deletion (e.g., when deleting masks)
|
||||
updates = {k: ("" if v is None else v) for k, v in updates.items()}
|
||||
# Drop sentinel-valued fields so untouched credential
|
||||
# placeholders don't clobber the saved YAML value.
|
||||
updates = {
|
||||
k: v
|
||||
for k, v in updates.items()
|
||||
if v != REDACTED_CREDENTIAL_SENTINEL
|
||||
}
|
||||
|
||||
if not updates:
|
||||
return JSONResponse(
|
||||
@@ -1170,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
@@ -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
@@ -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,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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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,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,
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
|
||||
|
||||
@@ -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
@@ -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})")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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]]:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,8 +1,7 @@
|
||||
from pydantic import Field
|
||||
|
||||
from frigate.util.env import EnvString
|
||||
|
||||
from ..base import FrigateBaseModel
|
||||
from ..env import EnvString
|
||||
|
||||
__all__ = ["NotificationConfig"]
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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.",
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
[
|
||||
(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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_"):
|
||||
@@ -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,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
-14
@@ -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"
|
||||
@@ -117,19 +116,7 @@ SUPPORTED_RK_SOCS = ["rk3562", "rk3566", "rk3568", "rk3576", "rk3588"]
|
||||
|
||||
REGEX_CAMERA_NAME = r"^[a-zA-Z0-9_-]+$"
|
||||
REGEX_RTSP_CAMERA_USER_PASS = r":\/\/[a-zA-Z0-9_-]+:[\S]+@"
|
||||
# A "user:" userinfo may hold "/", "#" or "@" in the password, so it runs to the
|
||||
# last "@" before the next URL. Anything else stops at the authority end. Group 1
|
||||
# is the userinfo and group 2 the rest of the URL.
|
||||
REGEX_URL_USERINFO = (
|
||||
r"://((?:[^\s/?#:\[\]]*:(?:(?!://)\S)*|[^\s/?#]+))@((?:(?!://)\S)*)"
|
||||
)
|
||||
_CREDENTIAL_QUERY_KEYS = (
|
||||
r"(?:user(?:name)?|usr|login|pass(?:word|wd)?|pwd"
|
||||
r"|(?:[a-z_]*_)?token|(?:[a-z_]*_)?secret|client_private|api_?key)"
|
||||
)
|
||||
REGEX_CREDENTIAL_QUERY_KEY = rf"(?i)[?&]{_CREDENTIAL_QUERY_KEYS}="
|
||||
REGEX_CREDENTIAL_QUERY_PARAM = rf"(?i)([?&]{_CREDENTIAL_QUERY_KEYS}=)[^&\s]*"
|
||||
REGEX_MASKED_CREDENTIAL = rf"(?i)://\*:\*@|[?&]{_CREDENTIAL_QUERY_KEYS}=\*(?![^&\s])"
|
||||
REGEX_HTTP_CAMERA_USER_PASS = r"user=[a-zA-Z0-9_-]+&password=[\S]+"
|
||||
REGEX_JSON = re.compile(r"^\s*\{")
|
||||
|
||||
# Known Driver Names
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}",
|
||||
)
|
||||
|
||||
|
||||
@@ -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']}"
|
||||
|
||||
@@ -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())
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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():
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -14,7 +14,6 @@ from frigate.config.camera.updater import (
|
||||
CameraConfigUpdateTopic,
|
||||
)
|
||||
from frigate.config.holder import ConfigHolder
|
||||
from frigate.const import REDACTED_CREDENTIAL_SENTINEL
|
||||
from frigate.models import Event, Recordings, ReviewSegment
|
||||
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
|
||||
|
||||
@@ -504,358 +503,6 @@ class TestConfigSetWildcardPropagation(BaseTestHttp):
|
||||
finally:
|
||||
os.unlink(config_path)
|
||||
|
||||
camera_path = "rtsp://10.0.0.1:554/video?password=s3cret&user=admin"
|
||||
|
||||
def _add_credentials(self):
|
||||
"""Give the config a credentialed camera path and go2rtc secrets."""
|
||||
self.minimal_config["cameras"]["front_door"]["ffmpeg"]["inputs"][0]["path"] = (
|
||||
self.camera_path
|
||||
)
|
||||
self.minimal_config["go2rtc"] = {
|
||||
"rtsp": {"username": "rtspuser", "password": "rtsppass"},
|
||||
"api": {"username": "apiuser", "password": "apipass"},
|
||||
"mqtt": {"username": "mquser", "password": "mqpass"},
|
||||
"ngrok": {"command": "ngrok tcp 8555 --authtoken ngroksecret"},
|
||||
"homekit": {"front_door": {"pin": "19550224"}},
|
||||
"publish": {"front_door": "rtmp://a.rtmp.youtube.com/live2/streamkey"},
|
||||
"webrtc": {
|
||||
"candidates": ["10.0.0.5:8555"],
|
||||
"ice_servers": [
|
||||
{
|
||||
"urls": ["turn:turn.example.com:3478"],
|
||||
"username": "turnuser",
|
||||
"credential": "turnpass",
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
def _read_config_file(self, config_path):
|
||||
with open(config_path) as f:
|
||||
raw = f.read()
|
||||
return raw, ruamel.yaml.YAML().load(raw)
|
||||
|
||||
@patch("frigate.api.app.find_config_file")
|
||||
def test_masked_camera_path_round_trips(self, mock_find_config):
|
||||
self._add_credentials()
|
||||
config_path = self._write_config_file()
|
||||
mock_find_config.return_value = config_path
|
||||
|
||||
try:
|
||||
app, _ = self._create_app_with_publisher()
|
||||
|
||||
with AuthTestClient(app) as client:
|
||||
camera = client.get("/config").json()["cameras"]["front_door"]
|
||||
inputs = camera["ffmpeg"]["inputs"]
|
||||
self.assertNotIn("s3cret", inputs[0]["path"])
|
||||
|
||||
resp = client.put(
|
||||
"/config/set",
|
||||
json={
|
||||
"config_data": {
|
||||
"cameras": {
|
||||
"front_door": {
|
||||
"ffmpeg": {"inputs": inputs},
|
||||
"detect": {"fps": 7},
|
||||
}
|
||||
}
|
||||
},
|
||||
"requires_restart": 0,
|
||||
},
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
_, saved = self._read_config_file(config_path)
|
||||
self.assertEqual(
|
||||
saved["cameras"]["front_door"]["ffmpeg"]["inputs"][0]["path"],
|
||||
self.camera_path,
|
||||
)
|
||||
finally:
|
||||
os.unlink(config_path)
|
||||
|
||||
def _save(self, client, config_data):
|
||||
return client.put(
|
||||
"/config/set",
|
||||
json={"config_data": config_data, "requires_restart": 0},
|
||||
)
|
||||
|
||||
def _set_inputs(self, paths):
|
||||
self.minimal_config["cameras"]["front_door"]["ffmpeg"]["inputs"] = [
|
||||
{"path": path, "roles": [role]} for path, role in paths
|
||||
]
|
||||
|
||||
# config parsing rebuilds FRIGATE_ENV_VARS from its sources
|
||||
@patch.dict("frigate.util.env._CONTAINER_ENV", {"FRIGATE_RTSP_PASSWORD": "s3cret"})
|
||||
@patch.dict(
|
||||
"frigate.util.env.FRIGATE_ENV_VARS", {"FRIGATE_RTSP_PASSWORD": "s3cret"}
|
||||
)
|
||||
@patch("frigate.api.app.find_config_file")
|
||||
def test_masked_path_keeps_env_placeholder(self, mock_find_config):
|
||||
path = "rtsp://admin:{FRIGATE_RTSP_PASSWORD}@10.0.0.1:554/live"
|
||||
self._set_inputs([(path, "detect")])
|
||||
config_path = self._write_config_file()
|
||||
mock_find_config.return_value = config_path
|
||||
|
||||
try:
|
||||
app, _ = self._create_app_with_publisher()
|
||||
|
||||
with AuthTestClient(app) as client:
|
||||
camera = client.get("/config").json()["cameras"]["front_door"]
|
||||
self.assertNotIn("s3cret", str(camera))
|
||||
|
||||
resp = self._save(
|
||||
client,
|
||||
{
|
||||
"cameras": {
|
||||
"front_door": {
|
||||
"ffmpeg": {"inputs": camera["ffmpeg"]["inputs"]},
|
||||
"detect": {"fps": 7},
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
raw, saved = self._read_config_file(config_path)
|
||||
self.assertNotIn("s3cret", raw)
|
||||
self.assertEqual(
|
||||
saved["cameras"]["front_door"]["ffmpeg"]["inputs"][0]["path"], path
|
||||
)
|
||||
finally:
|
||||
os.unlink(config_path)
|
||||
|
||||
@patch("frigate.api.app.find_config_file")
|
||||
def test_over_masked_path_round_trips(self, mock_find_config):
|
||||
path = "rtsp://10.0.0.1:554/live?x=a@b"
|
||||
self._set_inputs([(path, "detect")])
|
||||
config_path = self._write_config_file()
|
||||
mock_find_config.return_value = config_path
|
||||
|
||||
try:
|
||||
app, _ = self._create_app_with_publisher()
|
||||
|
||||
with AuthTestClient(app) as client:
|
||||
inputs = client.get("/config").json()["cameras"]["front_door"][
|
||||
"ffmpeg"
|
||||
]["inputs"]
|
||||
self.assertNotEqual(inputs[0]["path"], path)
|
||||
|
||||
resp = self._save(
|
||||
client,
|
||||
{"cameras": {"front_door": {"ffmpeg": {"inputs": inputs}}}},
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
_, saved = self._read_config_file(config_path)
|
||||
self.assertEqual(
|
||||
saved["cameras"]["front_door"]["ffmpeg"]["inputs"][0]["path"], path
|
||||
)
|
||||
finally:
|
||||
os.unlink(config_path)
|
||||
|
||||
@patch("frigate.api.app.find_config_file")
|
||||
def test_unrestorable_masked_values_are_rejected(self, mock_find_config):
|
||||
self._add_credentials()
|
||||
config_path = self._write_config_file()
|
||||
mock_find_config.return_value = config_path
|
||||
|
||||
try:
|
||||
app, _ = self._create_app_with_publisher()
|
||||
|
||||
with AuthTestClient(app) as client:
|
||||
for name, request in (
|
||||
(
|
||||
"index-keyed inputs",
|
||||
{
|
||||
"json": {
|
||||
"config_data": {
|
||||
"cameras": {
|
||||
"front_door": {
|
||||
"ffmpeg": {
|
||||
"inputs": {
|
||||
"0": {"path": "rtsp://*:*@h/x"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"requires_restart": 0,
|
||||
}
|
||||
},
|
||||
),
|
||||
(
|
||||
"go2rtc streams",
|
||||
{
|
||||
"json": {
|
||||
"config_data": {
|
||||
"go2rtc": {"streams": {"cam": ["rtsp://*:*@h/x"]}}
|
||||
},
|
||||
"requires_restart": 0,
|
||||
}
|
||||
},
|
||||
),
|
||||
(
|
||||
"query string",
|
||||
{
|
||||
"params": {
|
||||
"cameras.front_door.ffmpeg.inputs.0.path": (
|
||||
"rtsp://*:*@h/x"
|
||||
),
|
||||
"go2rtc.rtsp.password": REDACTED_CREDENTIAL_SENTINEL,
|
||||
},
|
||||
"json": {"requires_restart": 0},
|
||||
},
|
||||
),
|
||||
):
|
||||
with self.subTest(name):
|
||||
resp = client.put("/config/set", **request)
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
self.assertIn("credentials", resp.json()["message"])
|
||||
|
||||
raw, _ = self._read_config_file(config_path)
|
||||
self.assertNotIn("*:*", raw)
|
||||
self.assertNotIn(REDACTED_CREDENTIAL_SENTINEL, raw)
|
||||
finally:
|
||||
os.unlink(config_path)
|
||||
|
||||
@patch("frigate.api.app.find_config_file")
|
||||
def test_go2rtc_credentials_redacted_and_round_trip(self, mock_find_config):
|
||||
self._add_credentials()
|
||||
config_path = self._write_config_file()
|
||||
mock_find_config.return_value = config_path
|
||||
|
||||
try:
|
||||
app, _ = self._create_app_with_publisher()
|
||||
|
||||
with AuthTestClient(app) as client:
|
||||
response = client.get("/config").json()
|
||||
body = str(response)
|
||||
for secret in (
|
||||
"rtsppass",
|
||||
"apipass",
|
||||
"mqpass",
|
||||
"ngroksecret",
|
||||
"19550224",
|
||||
"streamkey",
|
||||
):
|
||||
self.assertNotIn(secret, body)
|
||||
|
||||
go2rtc = response["go2rtc"]
|
||||
self.assertEqual(go2rtc["webrtc"]["candidates"], ["10.0.0.5:8555"])
|
||||
# browsers need the TURN account to relay WebRTC
|
||||
self.assertEqual(
|
||||
go2rtc["webrtc"]["ice_servers"][0]["credential"], "turnpass"
|
||||
)
|
||||
|
||||
go2rtc.pop("ngrok")
|
||||
go2rtc["webrtc"]["candidates"] = ["10.0.0.9:8555"]
|
||||
self.assertEqual(
|
||||
self._save(client, {"go2rtc": go2rtc}).status_code, 200
|
||||
)
|
||||
|
||||
raw, saved = self._read_config_file(config_path)
|
||||
self.assertNotIn(REDACTED_CREDENTIAL_SENTINEL, raw)
|
||||
self.assertEqual(saved["go2rtc"]["rtsp"]["password"], "rtsppass")
|
||||
self.assertEqual(saved["go2rtc"]["mqtt"]["password"], "mqpass")
|
||||
self.assertEqual(
|
||||
saved["go2rtc"]["homekit"]["front_door"]["pin"], "19550224"
|
||||
)
|
||||
self.assertIn("streamkey", saved["go2rtc"]["publish"]["front_door"])
|
||||
self.assertEqual(saved["go2rtc"]["webrtc"]["candidates"], ["10.0.0.9:8555"])
|
||||
self.assertEqual(
|
||||
saved["go2rtc"]["webrtc"]["ice_servers"][0]["credential"], "turnpass"
|
||||
)
|
||||
finally:
|
||||
os.unlink(config_path)
|
||||
|
||||
@patch("frigate.api.app.find_config_file")
|
||||
def test_masked_inputs_restore_by_value(self, mock_find_config):
|
||||
main = "rtsp://u:MAIN@10.0.0.1:554/main"
|
||||
sub = "rtsp://u:SUB@10.0.0.1:554/sub"
|
||||
|
||||
for name, edit, expected in (
|
||||
("delete first", lambda inputs: [inputs[1]], [sub]),
|
||||
("reorder", lambda inputs: [inputs[1], inputs[0]], [sub, main]),
|
||||
):
|
||||
with self.subTest(name):
|
||||
self._set_inputs([(main, "record"), (sub, "detect")])
|
||||
config_path = self._write_config_file()
|
||||
mock_find_config.return_value = config_path
|
||||
|
||||
try:
|
||||
app, _ = self._create_app_with_publisher()
|
||||
|
||||
with AuthTestClient(app) as client:
|
||||
inputs = client.get("/config").json()["cameras"]["front_door"][
|
||||
"ffmpeg"
|
||||
]["inputs"]
|
||||
resp = self._save(
|
||||
client,
|
||||
{
|
||||
"cameras": {
|
||||
"front_door": {"ffmpeg": {"inputs": edit(inputs)}}
|
||||
}
|
||||
},
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
_, saved = self._read_config_file(config_path)
|
||||
saved_inputs = saved["cameras"]["front_door"]["ffmpeg"]["inputs"]
|
||||
self.assertEqual([i["path"] for i in saved_inputs], expected)
|
||||
finally:
|
||||
os.unlink(config_path)
|
||||
|
||||
@patch("frigate.api.app.find_config_file")
|
||||
def test_ambiguous_or_unmatched_masked_input(self, mock_find_config):
|
||||
self._set_inputs(
|
||||
[
|
||||
("rtsp://u:one@10.0.0.1:554/live", "record"),
|
||||
("rtsp://u:two@10.0.0.1:554/live", "detect"),
|
||||
]
|
||||
)
|
||||
config_path = self._write_config_file()
|
||||
mock_find_config.return_value = config_path
|
||||
|
||||
try:
|
||||
app, _ = self._create_app_with_publisher()
|
||||
|
||||
with AuthTestClient(app) as client:
|
||||
inputs = client.get("/config").json()["cameras"]["front_door"][
|
||||
"ffmpeg"
|
||||
]["inputs"]
|
||||
|
||||
# both inputs mask to the same value and roles no longer tell
|
||||
# them apart
|
||||
remaining = dict(inputs[1], roles=["detect", "record"])
|
||||
resp = self._save(
|
||||
client,
|
||||
{"cameras": {"front_door": {"ffmpeg": {"inputs": [remaining]}}}},
|
||||
)
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
|
||||
inputs[0]["path"] = "rtsp://*:*@10.0.0.99:554/live"
|
||||
resp = self._save(
|
||||
client,
|
||||
{"cameras": {"front_door": {"ffmpeg": {"inputs": inputs}}}},
|
||||
)
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
|
||||
# roles break the tie when the second input stays as it was
|
||||
resp = self._save(
|
||||
client,
|
||||
{"cameras": {"front_door": {"ffmpeg": {"inputs": [inputs[1]]}}}},
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
raw, saved = self._read_config_file(config_path)
|
||||
self.assertNotIn("*:*", raw)
|
||||
self.assertEqual(
|
||||
saved["cameras"]["front_door"]["ffmpeg"]["inputs"][0]["path"],
|
||||
"rtsp://u:two@10.0.0.1:554/live",
|
||||
)
|
||||
finally:
|
||||
os.unlink(config_path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user