mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-19 13:12:48 +03:00
Compare commits
63
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8f79e7427 | ||
|
|
859a5caffb | ||
|
|
1b1a38f0a1 | ||
|
|
a409309be4 | ||
|
|
a2e7e5c991 | ||
|
|
3dc382938c | ||
|
|
275e18403e | ||
|
|
1a8644fed5 | ||
|
|
add310d63d | ||
|
|
c177849f57 | ||
|
|
c9f7cc8165 | ||
|
|
c42fb58391 | ||
|
|
62ca5b25ea | ||
|
|
698b1ce628 | ||
|
|
16a377233e | ||
|
|
6ff8bbd014 | ||
|
|
b22ef39aad | ||
|
|
7620ea61a8 | ||
|
|
754a4a9888 | ||
|
|
58d6454537 | ||
|
|
9c399fc519 | ||
|
|
48470c4537 | ||
|
|
8fd57d81fc | ||
|
|
99134ed4cd | ||
|
|
822c8e2bda | ||
|
|
df92f07a79 | ||
|
|
7227929a9d | ||
|
|
03ff04311b | ||
|
|
5eb2b6f04d | ||
|
|
ba5330f8cf | ||
|
|
7c6c529314 | ||
|
|
15da89e56c | ||
|
|
64b07889af | ||
|
|
cfa08c56b2 | ||
|
|
2b0a69c028 | ||
|
|
a3ca8742b0 | ||
|
|
d3b2af932c | ||
|
|
b79e043976 | ||
|
|
e61ad52b67 | ||
|
|
41d9577d72 | ||
|
|
cf67f08913 | ||
|
|
ff9eb8f184 | ||
|
|
bc06b8c753 | ||
|
|
e7164882f5 | ||
|
|
135a95d8f2 | ||
|
|
7e402d70df | ||
|
|
186a730790 | ||
|
|
5c32af3c7f | ||
|
|
acc84d7658 | ||
|
|
8e569ff4f9 | ||
|
|
2cb03f442c | ||
|
|
9d9de6263e | ||
|
|
c75f7c3241 | ||
|
|
2e92251aa5 | ||
|
|
cc86519603 | ||
|
|
7786da465f | ||
|
|
e66da62db3 | ||
|
|
c06a97378c | ||
|
|
33cb8d987d | ||
|
|
2273cf2d50 | ||
|
|
f929134a1b | ||
|
|
cb745d0a50 | ||
|
|
e912d0852b |
@@ -34,15 +34,15 @@ runs:
|
||||
run: sudo systemctl start docker
|
||||
shell: bash
|
||||
- id: lowercaseRepo
|
||||
uses: ASzc/change-string-case-action@v5
|
||||
uses: ASzc/change-string-case-action@v8
|
||||
with:
|
||||
string: ${{ github.repository }}
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@465a07811f14bebb1938fbed4728c6a1ff8901fc
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
||||
+139
-22
@@ -1,40 +1,157 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
# Local composite actions are only scanned when their directory is listed
|
||||
directories:
|
||||
- "/"
|
||||
- "/.github/actions/setup"
|
||||
schedule:
|
||||
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
|
||||
interval: weekly
|
||||
cooldown:
|
||||
default-days: 3
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
actions:
|
||||
patterns: ["*"]
|
||||
actions-security:
|
||||
applies-to: security-updates
|
||||
patterns: ["*"]
|
||||
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/docker/main"
|
||||
schedule:
|
||||
interval: daily
|
||||
open-pull-requests-limit: 10
|
||||
target-branch: dev
|
||||
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"]
|
||||
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/docker/tensorrt"
|
||||
schedule:
|
||||
interval: daily
|
||||
open-pull-requests-limit: 10
|
||||
target-branch: dev
|
||||
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"]
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/web"
|
||||
schedule:
|
||||
interval: daily
|
||||
open-pull-requests-limit: 10
|
||||
target-branch: dev
|
||||
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"]
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/docs"
|
||||
schedule:
|
||||
interval: daily
|
||||
interval: weekly
|
||||
cooldown:
|
||||
default-days: 3
|
||||
open-pull-requests-limit: 5
|
||||
allow:
|
||||
- dependency-name: "@docusaurus/*"
|
||||
open-pull-requests-limit: 10
|
||||
target-branch: dev
|
||||
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"]
|
||||
|
||||
@@ -586,7 +586,7 @@ jobs:
|
||||
- arm64_build
|
||||
steps:
|
||||
- id: lowercaseRepo
|
||||
uses: ASzc/change-string-case-action@v6
|
||||
uses: ASzc/change-string-case-action@v8
|
||||
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@v6
|
||||
uses: ASzc/change-string-case-action@v8
|
||||
with:
|
||||
string: ${{ github.repository }}
|
||||
- name: Log in to the Container registry
|
||||
|
||||
@@ -217,6 +217,8 @@ 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.16.10
|
||||
ruff == 0.17.0
|
||||
|
||||
# types
|
||||
types-peewee == 4.0.*
|
||||
|
||||
@@ -25,7 +25,7 @@ peewee_migrate == 2.3.*
|
||||
psutil == 7.2.*
|
||||
pydantic == 2.13.*
|
||||
git+https://github.com/fbcotter/py3nvml#egg=py3nvml
|
||||
pytz == 2026.*
|
||||
tzdata == 2026.5
|
||||
pyzmq == 27.2.*
|
||||
ruamel.yaml == 0.19.*
|
||||
tzlocal == 5.4.4
|
||||
|
||||
@@ -61,7 +61,7 @@ do
|
||||
;;
|
||||
esac
|
||||
|
||||
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'`
|
||||
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'`
|
||||
|
||||
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,8 +14,12 @@ 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 $scheme://$http_host$request_uri;
|
||||
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-Server-Port $server_port;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Content-Length "";
|
||||
# Pass along auth related info
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
|
||||
@@ -219,6 +219,8 @@ 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;
|
||||
@@ -228,6 +230,8 @@ 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;
|
||||
@@ -248,6 +252,8 @@ 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 $scheme://$http_host$request_uri;
|
||||
proxy_set_header X-Original-URL $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;
|
||||
|
||||
@@ -117,7 +117,9 @@ auth:
|
||||
# Optional: Cookie to store the JWT token for native auth (default: shown below)
|
||||
cookie_name: frigate_token
|
||||
# Optional: Set secure flag on cookie. (default: shown below)
|
||||
# NOTE: This should be set to True if you are using TLS
|
||||
# NOTE: Frigate sets the flag automatically when serving TLS. Set this to True
|
||||
# when a reverse proxy terminates TLS in front of Frigate. A proxy that connects
|
||||
# to the TLS port must serve Frigate to browsers over HTTPS
|
||||
cookie_secure: False
|
||||
# Optional: Session length in seconds (default: shown below)
|
||||
session_length: 86400 # 24 hours
|
||||
@@ -556,7 +558,7 @@ motion:
|
||||
# Optional: Whether this mask is active (default: true)
|
||||
enabled: true
|
||||
# Required: Coordinates polygon for the mask
|
||||
coordinates: "0.000,0.469,1.000,0.469,1.000,1.000,0.000,1.000"
|
||||
coordinates: "0.000,0.469,1.0,0.469,1.0,1.0,0.000,1.0"
|
||||
# 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 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" |
|
||||
| Annke CZ504 | ✅ | ❌ | |
|
||||
| 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,7 +136,8 @@ ffmpeg:
|
||||
|
||||
Frigate reads Intel GPU utilization directly from the kernel's per-client DRM usage counters exposed at `/proc/<pid>/fdinfo/<fd>`. This requires:
|
||||
|
||||
- Linux kernel **5.19 or newer** for the `i915` driver, or any release of the `xe` driver.
|
||||
- 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.
|
||||
- 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` (see [WebRTC extra configuration](#webrtc-extra-configuration)).
|
||||
- **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.
|
||||
- **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.000,0.000,1.000,0.400,0.000,0.400"
|
||||
coordinates: "0.000,0.000,1.0,0.000,1.0,0.400,0.000,0.400"
|
||||
car:
|
||||
mask:
|
||||
car_filter1:
|
||||
friendly_name: "Sidewalk area"
|
||||
enabled: true
|
||||
coordinates: "0.000,0.700,1.000,0.700,1.000,1.000,0.000,1.000"
|
||||
coordinates: "0.000,0.700,1.0,0.700,1.0,1.0,0.000,1.0"
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
|
||||
@@ -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**.
|
||||
|
||||
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.
|
||||
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 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.
|
||||
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.
|
||||
|
||||
<ShmCalculator/>
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ title: Updating
|
||||
|
||||
# Updating Frigate
|
||||
|
||||
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).
|
||||
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).
|
||||
|
||||
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.0` instead of `0.17.1`). For example:
|
||||
- Edit your `docker-compose.yml` file to specify the desired version tag (e.g., `0.18.2` instead of `0.17.1`). For example:
|
||||
```yaml
|
||||
services:
|
||||
frigate:
|
||||
image: ghcr.io/blakeblackshear/frigate:0.18.0
|
||||
image: ghcr.io/blakeblackshear/frigate:0.18.2
|
||||
```
|
||||
- Then pull the image:
|
||||
```bash
|
||||
docker pull ghcr.io/blakeblackshear/frigate:0.18.0
|
||||
docker pull ghcr.io/blakeblackshear/frigate:0.18.2
|
||||
```
|
||||
- **Note for `stable` Tag Users**: If your `docker-compose.yml` uses the `stable` tag (e.g., `ghcr.io/blakeblackshear/frigate:stable`), you don’t need to update the tag manually. The `stable` tag always points to the latest stable release after pulling.
|
||||
- If using `docker run`:
|
||||
- Pull the image with the appropriate tag (e.g., `0.18.0`, `0.18.0-tensorrt`, or `stable`):
|
||||
- Pull the image with the appropriate tag (e.g., `0.18.2`, `0.18.2-tensorrt`, or `stable`):
|
||||
```bash
|
||||
docker pull ghcr.io/blakeblackshear/frigate:0.18.0
|
||||
docker pull ghcr.io/blakeblackshear/frigate:0.18.2
|
||||
```
|
||||
|
||||
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.17.1`) in your `docker run` command.
|
||||
- For Docker: Specify an older tag (e.g., `ghcr.io/blakeblackshear/frigate:0.18.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,6 +45,8 @@ 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
|
||||
|
||||
@@ -218,3 +220,68 @@ 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,6 +53,14 @@ 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,42 +2,53 @@ 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("26.32MB");
|
||||
const [singleCameraShm, setSingleCameraShm] = useState("26.32MB");
|
||||
const [totalShm, setTotalShm] = useState("26.32MB");
|
||||
const [result, setResult] = useState("-");
|
||||
const [singleCameraShm, setSingleCameraShm] = useState("-");
|
||||
const [addedCamerasShm, setAddedCamerasShm] = useState("-");
|
||||
const [fullRingShm, setFullRingShm] = useState("-");
|
||||
|
||||
const calculate = () => {
|
||||
if (!width || !height || !cameraCount) {
|
||||
setResult("Please enter valid values");
|
||||
setSingleCameraShm("-");
|
||||
setTotalShm("-");
|
||||
setAddedCamerasShm("-");
|
||||
setFullRingShm("-");
|
||||
return;
|
||||
}
|
||||
|
||||
// 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`);
|
||||
const cameraFramesMb = cameraCount * frameMb(width, height) + addedCamerasMb;
|
||||
|
||||
// Total SHM calculation (multiple cameras, including logs)
|
||||
const totalBase = singleCameraBase * cameraCount;
|
||||
const finalResult = totalBase + 40; // Default includes logs +40mb
|
||||
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)),
|
||||
);
|
||||
};
|
||||
|
||||
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`);
|
||||
const formatMb = (value) => {
|
||||
if (value >= 1024) {
|
||||
return `${(value / 1024).toFixed(2)}gb`;
|
||||
}
|
||||
|
||||
return `${Number(value.toFixed(2))}mb`;
|
||||
};
|
||||
|
||||
const formatWithUnit = (value) => {
|
||||
@@ -147,19 +158,27 @@ const ShmCalculator = () => {
|
||||
</div>
|
||||
<div className={styles.formulaDisplay}>
|
||||
<p>
|
||||
<strong>Single Camera:</strong> {formatWithUnit(singleCameraShm)}
|
||||
<strong>Per Camera:</strong> {formatWithUnit(singleCameraShm)}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Formula:</strong> (width × height × 1.5 × 20 + 270480) ÷
|
||||
1048576
|
||||
<strong>Formula:</strong> (width × height × 1.5 + 270480) ÷
|
||||
1048576 × {TARGET_FRAMES} frames
|
||||
</p>
|
||||
{cameraCount > 1 && (
|
||||
<p>
|
||||
<strong>Total ({cameraCount} cameras):</strong> {formatWithUnit(totalShm)}
|
||||
</p>
|
||||
)}
|
||||
<p>
|
||||
<strong>With Logs:</strong> + 40<span className={styles.unit}>mb</span>
|
||||
<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)}
|
||||
</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':
|
||||
|
||||
+3
-1
@@ -15,7 +15,9 @@ from frigate.util.config import find_config_file
|
||||
|
||||
|
||||
def main() -> None:
|
||||
manager = mp.Manager()
|
||||
# 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()
|
||||
faulthandler.enable()
|
||||
|
||||
# Setup the logging thread
|
||||
|
||||
+80
-2
@@ -78,6 +78,8 @@ 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,
|
||||
@@ -546,6 +548,63 @@ 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()
|
||||
@@ -622,10 +681,22 @@ 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()
|
||||
@@ -960,6 +1031,13 @@ 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(
|
||||
|
||||
+138
-60
@@ -5,6 +5,7 @@ import hashlib
|
||||
import ipaddress
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
@@ -12,8 +13,9 @@ import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
from urllib.parse import parse_qs
|
||||
|
||||
import requests
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from fastapi.responses import JSONResponse, RedirectResponse
|
||||
from joserfc import jwt
|
||||
@@ -31,6 +33,8 @@ 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
|
||||
@@ -313,14 +317,10 @@ def get_remote_addr(request: Request):
|
||||
|
||||
route = list(reversed(forwarded_for.split(",")))
|
||||
logger.debug(f"IP Route: {[r for r in route]}")
|
||||
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)
|
||||
trusted_proxies = [
|
||||
ipaddress.ip_network(proxy, strict=False)
|
||||
for proxy in request.app.frigate_config.auth.trusted_proxies
|
||||
]
|
||||
|
||||
# return the first remote address that is not trusted
|
||||
for addr in route:
|
||||
@@ -464,16 +464,47 @@ def validate_password_strength(password: str) -> tuple[bool, str | None]:
|
||||
return True, None
|
||||
|
||||
|
||||
def create_encoded_jwt(user, role, expiration, secret):
|
||||
def create_encoded_jwt(user, role, expiration, secret, iat: int | None = None):
|
||||
if iat is None:
|
||||
iat = int(time.time())
|
||||
|
||||
return jwt.encode(
|
||||
{"alg": "HS256"},
|
||||
{"sub": user, "role": role, "exp": expiration, "iat": int(time.time())},
|
||||
{"sub": user, "role": role, "exp": expiration, "iat": iat},
|
||||
secret,
|
||||
)
|
||||
|
||||
|
||||
def _user_session(user: User) -> tuple[str, int | None]:
|
||||
changed_at = user.password_changed_at
|
||||
|
||||
# rounded up so a token issued earlier in the same second is revoked
|
||||
return user.role, math.ceil(changed_at.timestamp()) if changed_at else None
|
||||
|
||||
|
||||
def load_user_sessions() -> dict[str, tuple[str, int | None]]:
|
||||
"""Map each username to its role and password change time for /auth."""
|
||||
return {user.username: _user_session(user) for user in User.select()}
|
||||
|
||||
|
||||
def refresh_user_session(request: Request, username: str) -> None:
|
||||
"""Sync the /auth session map after a user is created, changed or deleted."""
|
||||
user = User.get_or_none(User.username == username)
|
||||
if user is None:
|
||||
request.app.user_sessions.pop(username, None)
|
||||
else:
|
||||
request.app.user_sessions[username] = _user_session(user)
|
||||
|
||||
|
||||
def is_secure_request(request: Request) -> bool:
|
||||
# nginx sets X-Forwarded-Proto to its own scheme, replacing any client value
|
||||
return (
|
||||
request.app.frigate_config.auth.cookie_secure
|
||||
or request.headers.get("x-forwarded-proto") == "https"
|
||||
)
|
||||
|
||||
|
||||
def set_jwt_cookie(response: Response, cookie_name, encoded_jwt, max_age, secure):
|
||||
# TODO: ideally this would set secure as well, but that requires TLS
|
||||
# Starlette sets SameSite=Lax by default. The cookie is still sent to
|
||||
# same-site iframes (e.g. Home Assistant on the same host or domain), but
|
||||
# not to cross-site ones. CSRF is also mitigated by requiring a custom
|
||||
@@ -751,7 +782,6 @@ def auth(request: Request):
|
||||
fail_response.headers["location"] = "/login"
|
||||
|
||||
JWT_COOKIE_NAME = request.app.frigate_config.auth.cookie_name
|
||||
JWT_COOKIE_SECURE = request.app.frigate_config.auth.cookie_secure
|
||||
JWT_REFRESH = request.app.frigate_config.auth.refresh_time
|
||||
JWT_SESSION_LENGTH = request.app.frigate_config.auth.session_length
|
||||
|
||||
@@ -777,20 +807,27 @@ def auth(request: Request):
|
||||
if "sub" not in token.claims:
|
||||
logger.debug("user not set in jwt token")
|
||||
return fail_response
|
||||
if "role" not in token.claims:
|
||||
logger.debug("role not set in jwt token")
|
||||
return fail_response
|
||||
if "exp" not in token.claims:
|
||||
logger.debug("exp not set in jwt token")
|
||||
return fail_response
|
||||
|
||||
user = token.claims.get("sub")
|
||||
role = token.claims.get("role")
|
||||
session = request.app.user_sessions.get(user)
|
||||
if session is None:
|
||||
logger.debug("jwt user %s no longer exists", user)
|
||||
return fail_response
|
||||
|
||||
role, password_changed_at = session
|
||||
if password_changed_at is not None and (
|
||||
int(token.claims.get("iat", 0)) < password_changed_at
|
||||
):
|
||||
logger.debug("jwt token issued before password change")
|
||||
return fail_response
|
||||
|
||||
# the token keeps the role it was issued with, so a role removed from
|
||||
# the config since then must send the user back through login
|
||||
if role not in auth_config.roles:
|
||||
logger.debug("jwt role %s is not in the config", role)
|
||||
logger.debug(
|
||||
"jwt user %s has role %s, which is not in the config", user, role
|
||||
)
|
||||
return fail_response
|
||||
|
||||
current_time = int(time.time())
|
||||
@@ -813,25 +850,6 @@ def auth(request: Request):
|
||||
# if the jwt cookie is expiring soon
|
||||
if jwt_source == "cookie" and expiration - JWT_REFRESH <= current_time:
|
||||
logger.debug("jwt token expiring soon, refreshing cookie")
|
||||
|
||||
# Check if password has been changed since token was issued
|
||||
# If so, force re-login by rejecting the refresh
|
||||
try:
|
||||
user_obj = User.get_by_id(user)
|
||||
if user_obj.password_changed_at is not None:
|
||||
token_iat = int(token.claims.get("iat", 0))
|
||||
password_changed_timestamp = int(
|
||||
user_obj.password_changed_at.timestamp()
|
||||
)
|
||||
if token_iat < password_changed_timestamp:
|
||||
logger.debug(
|
||||
"jwt token issued before password change, rejecting refresh"
|
||||
)
|
||||
return fail_response
|
||||
except DoesNotExist:
|
||||
logger.debug("user not found")
|
||||
return fail_response
|
||||
|
||||
new_expiration = current_time + JWT_SESSION_LENGTH
|
||||
new_encoded_jwt = create_encoded_jwt(
|
||||
user, role, new_expiration, request.app.jwt_token
|
||||
@@ -841,7 +859,7 @@ def auth(request: Request):
|
||||
JWT_COOKIE_NAME,
|
||||
new_encoded_jwt,
|
||||
JWT_SESSION_LENGTH,
|
||||
JWT_COOKIE_SECURE,
|
||||
is_secure_request(request),
|
||||
)
|
||||
|
||||
success_response.headers["remote-user"] = user
|
||||
@@ -930,7 +948,6 @@ def login(request: Request, body: AppPostLoginBody):
|
||||
)
|
||||
|
||||
JWT_COOKIE_NAME = request.app.frigate_config.auth.cookie_name
|
||||
JWT_COOKIE_SECURE = request.app.frigate_config.auth.cookie_secure
|
||||
JWT_SESSION_LENGTH = request.app.frigate_config.auth.session_length
|
||||
user = body.user
|
||||
password = body.password
|
||||
@@ -946,13 +963,16 @@ def login(request: Request, body: AppPostLoginBody):
|
||||
|
||||
password_hash = db_user.password_hash
|
||||
if verify_password(password, password_hash):
|
||||
role = getattr(db_user, "role", "viewer")
|
||||
config_roles_set = set(request.app.frigate_config.auth.roles.keys())
|
||||
if role not in config_roles_set:
|
||||
role = db_user.role
|
||||
if role not in request.app.frigate_config.auth.roles:
|
||||
logger.warning(
|
||||
f"User {db_user.username} has an invalid role {role}, falling back to 'viewer'."
|
||||
"Login refused for user '%s' with role '%s', which is not in the config",
|
||||
user,
|
||||
role,
|
||||
)
|
||||
role = "viewer"
|
||||
failed_logins.record(user, time.time(), known=True)
|
||||
return JSONResponse(content={"message": "Login failed"}, status_code=401)
|
||||
|
||||
expiration = int(time.time()) + JWT_SESSION_LENGTH
|
||||
encoded_jwt = create_encoded_jwt(user, role, expiration, request.app.jwt_token)
|
||||
response = Response("", 200)
|
||||
@@ -961,7 +981,7 @@ def login(request: Request, body: AppPostLoginBody):
|
||||
JWT_COOKIE_NAME,
|
||||
encoded_jwt,
|
||||
JWT_SESSION_LENGTH,
|
||||
JWT_COOKIE_SECURE,
|
||||
is_secure_request(request),
|
||||
)
|
||||
# Clear admin_first_time_login flag after successful admin login so the
|
||||
# UI stops showing the first-time login documentation link.
|
||||
@@ -1003,7 +1023,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("^[A-Za-z0-9._]+$", body.username):
|
||||
if not re.match(r"^(?!\.+$)[A-Za-z0-9._]+$", body.username):
|
||||
return JSONResponse(content={"message": "Invalid username"}, status_code=400)
|
||||
|
||||
if body.role not in config_roles:
|
||||
@@ -1030,6 +1050,7 @@ def create_user(
|
||||
User.notification_tokens: [],
|
||||
}
|
||||
).execute()
|
||||
refresh_user_session(request, body.username)
|
||||
request.app.config_publisher.publisher.publish("config/auth", None)
|
||||
return JSONResponse(content={"username": body.username})
|
||||
|
||||
@@ -1048,6 +1069,7 @@ def delete_user(request: Request, username: str):
|
||||
)
|
||||
|
||||
User.delete_by_id(username)
|
||||
refresh_user_session(request, username)
|
||||
request.app.config_publisher.publisher.publish("config/auth", None)
|
||||
return JSONResponse(content={"success": True})
|
||||
|
||||
@@ -1109,24 +1131,29 @@ async def update_password(
|
||||
)
|
||||
|
||||
password_hash = hash_password(body.password, iterations=HASH_ITERATIONS)
|
||||
changed_at = datetime.now()
|
||||
User.update(
|
||||
{
|
||||
User.password_hash: password_hash,
|
||||
User.password_changed_at: datetime.now(),
|
||||
User.password_changed_at: changed_at,
|
||||
}
|
||||
).where(User.username == username).execute()
|
||||
refresh_user_session(request, username)
|
||||
|
||||
response = JSONResponse(content={"success": True})
|
||||
|
||||
# If user changed their own password, issue a new JWT to keep them logged in
|
||||
if current_username == username:
|
||||
JWT_COOKIE_NAME = request.app.frigate_config.auth.cookie_name
|
||||
JWT_COOKIE_SECURE = request.app.frigate_config.auth.cookie_secure
|
||||
JWT_SESSION_LENGTH = request.app.frigate_config.auth.session_length
|
||||
|
||||
expiration = int(time.time()) + JWT_SESSION_LENGTH
|
||||
encoded_jwt = create_encoded_jwt(
|
||||
username, current_role, expiration, request.app.jwt_token
|
||||
username,
|
||||
current_role,
|
||||
expiration,
|
||||
request.app.jwt_token,
|
||||
iat=math.ceil(changed_at.timestamp()),
|
||||
)
|
||||
# Set new JWT cookie on response
|
||||
set_jwt_cookie(
|
||||
@@ -1134,7 +1161,7 @@ async def update_password(
|
||||
JWT_COOKIE_NAME,
|
||||
encoded_jwt,
|
||||
JWT_SESSION_LENGTH,
|
||||
JWT_COOKIE_SECURE,
|
||||
is_secure_request(request),
|
||||
)
|
||||
|
||||
return response
|
||||
@@ -1174,6 +1201,7 @@ async def update_role(
|
||||
)
|
||||
|
||||
User.set_by_id(username, {User.role: body.role})
|
||||
refresh_user_session(request, username)
|
||||
request.app.config_publisher.publisher.publish("config/auth", None)
|
||||
return JSONResponse(content={"success": True})
|
||||
|
||||
@@ -1229,11 +1257,44 @@ 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.
|
||||
# endpoint, not these proxied live-stream paths. The nginx locations are prefix
|
||||
# matches, so anything under these paths is proxied too.
|
||||
GO2RTC_STREAM_PROXY_PATHS = frozenset(
|
||||
{
|
||||
"/live/mse/api/ws",
|
||||
@@ -1243,31 +1304,48 @@ 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 role-restricted users from go2rtc live streams they cannot access.
|
||||
"""Block go2rtc live-stream requests the current role may not make.
|
||||
|
||||
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
|
||||
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
|
||||
`require_go2rtc_stream_access` so substream names map to their owning
|
||||
camera correctly.
|
||||
"""
|
||||
if not original_url:
|
||||
return None
|
||||
|
||||
parsed = urlparse(original_url)
|
||||
if parsed.path not in GO2RTC_STREAM_PROXY_PATHS:
|
||||
path, query = split_request_uri(original_url)
|
||||
if not _is_go2rtc_stream_proxy_request(path, request):
|
||||
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
|
||||
|
||||
+12
-7
@@ -33,11 +33,15 @@ 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, get_record_segment_time
|
||||
from frigate.util.builtin import (
|
||||
clean_camera_user_pass,
|
||||
clear_orphaned_comments,
|
||||
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,
|
||||
@@ -47,7 +51,7 @@ from frigate.util.live_streams import (
|
||||
from frigate.util.services import (
|
||||
analyze_record_keyframes,
|
||||
ffprobe_stream,
|
||||
is_restricted_go2rtc_source,
|
||||
is_restricted_go2rtc_api_source,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -153,9 +157,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_source(src):
|
||||
if src and is_restricted_go2rtc_api_source(src):
|
||||
logger.warning(
|
||||
"Rejected go2rtc stream '%s' with restricted source type (echo/expr/exec)",
|
||||
"Rejected go2rtc stream '%s' with restricted source",
|
||||
stream_name,
|
||||
)
|
||||
return JSONResponse(
|
||||
@@ -174,9 +178,9 @@ def go2rtc_add_stream(request: Request, stream_name: str, src: str = ""):
|
||||
except UnknownVariableError:
|
||||
resolved_src = src
|
||||
|
||||
if is_restricted_go2rtc_source(resolved_src):
|
||||
if is_restricted_go2rtc_api_source(resolved_src):
|
||||
logger.warning(
|
||||
"Rejected go2rtc stream '%s' with restricted source type (echo/expr/exec)",
|
||||
"Rejected go2rtc stream '%s' with restricted source",
|
||||
stream_name,
|
||||
)
|
||||
return JSONResponse(
|
||||
@@ -1273,6 +1277,7 @@ 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,5 +1,6 @@
|
||||
"""Object classification APIs."""
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import logging
|
||||
import os
|
||||
@@ -48,6 +49,9 @@ 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."""
|
||||
@@ -1290,7 +1294,10 @@ async def generate_object_examples(request: Request, body: GenerateObjectExample
|
||||
if model_name is None:
|
||||
return invalid_name_response(body.model_name)
|
||||
|
||||
collect_object_classification_examples(model_name, body.label)
|
||||
async with example_collection_lock:
|
||||
await asyncio.to_thread(
|
||||
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):
|
||||
|
||||
+21
-15
@@ -58,6 +58,7 @@ 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,
|
||||
@@ -79,6 +80,7 @@ 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__)
|
||||
@@ -134,6 +136,7 @@ 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:
|
||||
@@ -141,6 +144,15 @@ 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"},
|
||||
@@ -166,15 +178,7 @@ def _validate_export_source(
|
||||
) -> str | None:
|
||||
if playback_source == PlaybackSourceEnum.recordings:
|
||||
query = Recordings.select().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)
|
||||
)
|
||||
)
|
||||
& (Recordings.camera == camera_name)
|
||||
recording_overlap(start_time, end_time) & (Recordings.camera == camera_name)
|
||||
)
|
||||
|
||||
# a pinned export reads only that stream, so the other stream's
|
||||
@@ -239,9 +243,7 @@ def _get_item_recording_export_errors(
|
||||
|
||||
query = Recordings.select(Recordings.start_time, Recordings.end_time).where(
|
||||
Recordings.camera == camera_name,
|
||||
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)),
|
||||
recording_overlap(min_start, max_end),
|
||||
)
|
||||
|
||||
# a pinned batch reads only that stream, so the other stream's
|
||||
@@ -701,7 +703,7 @@ def export_recordings_batch(
|
||||
sanitized_images: list[str | None] = []
|
||||
for item in body.items:
|
||||
existing_image, image_validation_error = _sanitize_existing_image(
|
||||
item.image_path
|
||||
request, item.image_path
|
||||
)
|
||||
if image_validation_error is not None:
|
||||
return image_validation_error
|
||||
@@ -849,7 +851,9 @@ def export_recording(
|
||||
|
||||
playback_source = body.source
|
||||
friendly_name = body.name
|
||||
existing_image, image_validation_error = _sanitize_existing_image(body.image_path)
|
||||
existing_image, image_validation_error = _sanitize_existing_image(
|
||||
request, body.image_path
|
||||
)
|
||||
if image_validation_error is not None:
|
||||
return image_validation_error
|
||||
|
||||
@@ -1044,7 +1048,9 @@ def export_recording_custom(
|
||||
|
||||
playback_source = body.source
|
||||
friendly_name = body.name
|
||||
existing_image, image_validation_error = _sanitize_existing_image(body.image_path)
|
||||
existing_image, image_validation_error = _sanitize_existing_image(
|
||||
request, body.image_path
|
||||
)
|
||||
if image_validation_error is not None:
|
||||
return image_validation_error
|
||||
ffmpeg_input_args = body.ffmpeg_input_args
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import Depends, FastAPI, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
@@ -81,9 +82,20 @@ 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
|
||||
@@ -119,15 +131,6 @@ 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
|
||||
@@ -193,7 +196,9 @@ def create_fastapi_app(
|
||||
key_bytes = str(secret).encode("utf-8")
|
||||
|
||||
app.jwt_token = OctKey.import_key(key_bytes)
|
||||
app.user_sessions = auth.load_user_sessions()
|
||||
else:
|
||||
app.jwt_token = None
|
||||
app.user_sessions = {}
|
||||
|
||||
return app
|
||||
|
||||
+32
-26
@@ -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,8 +62,10 @@ 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__)
|
||||
|
||||
@@ -295,10 +297,15 @@ async def latest_frame(
|
||||
and request.app.frigate_config.birdseye.enabled
|
||||
and request.app.frigate_config.birdseye.restream
|
||||
):
|
||||
frame = cv2.cvtColor(
|
||||
frame_processor.get_current_frame(camera_name),
|
||||
cv2.COLOR_YUV2BGR_I420,
|
||||
)
|
||||
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)
|
||||
|
||||
height = int(params.height or str(frame.shape[0]))
|
||||
width = int(height * frame.shape[1] / frame.shape[0])
|
||||
@@ -346,10 +353,7 @@ async def get_snapshot_from_recording(
|
||||
Recordings.path,
|
||||
Recordings.start_time,
|
||||
)
|
||||
.where(
|
||||
(frame_time >= Recordings.start_time)
|
||||
& (frame_time <= Recordings.end_time)
|
||||
)
|
||||
.where(recording_overlap(frame_time, frame_time))
|
||||
.where(Recordings.camera == camera_name)
|
||||
.order_by(Recordings.stream_type.asc(), Recordings.start_time.desc())
|
||||
.limit(1)
|
||||
@@ -365,10 +369,7 @@ async def get_snapshot_from_recording(
|
||||
Recordings.path,
|
||||
Recordings.start_time,
|
||||
)
|
||||
.where(
|
||||
(frame_time >= Recordings.start_time)
|
||||
& (frame_time <= Recordings.end_time)
|
||||
)
|
||||
.where(recording_overlap(frame_time, frame_time))
|
||||
.where(Recordings.camera == camera_name)
|
||||
.order_by(Recordings.stream_type.asc(), Recordings.start_time.desc())
|
||||
.limit(1)
|
||||
@@ -383,8 +384,13 @@ async def get_snapshot_from_recording(
|
||||
mime_type = "png" if format == "png" else "jpeg"
|
||||
config: FrigateConfig = request.app.frigate_config
|
||||
|
||||
image_data = get_image_from_recording(
|
||||
config.ffmpeg, recording.path, time_in_segment, codec, height
|
||||
image_data = await asyncio.to_thread(
|
||||
get_image_from_recording,
|
||||
config.ffmpeg,
|
||||
recording.path,
|
||||
time_in_segment,
|
||||
codec,
|
||||
height,
|
||||
)
|
||||
|
||||
if not image_data:
|
||||
@@ -426,9 +432,7 @@ async def submit_recording_snapshot_to_plus(
|
||||
Recordings.path,
|
||||
Recordings.start_time,
|
||||
)
|
||||
.where(
|
||||
(frame_time >= Recordings.start_time) & (frame_time <= Recordings.end_time)
|
||||
)
|
||||
.where(recording_overlap(frame_time, frame_time))
|
||||
.where(Recordings.camera == camera_name)
|
||||
.order_by(Recordings.stream_type.asc(), Recordings.start_time.desc())
|
||||
.limit(1)
|
||||
@@ -438,8 +442,12 @@ 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 = get_image_from_recording(
|
||||
config.ffmpeg, recording.path, time_in_segment, "png"
|
||||
image_data = await asyncio.to_thread(
|
||||
get_image_from_recording,
|
||||
config.ffmpeg,
|
||||
recording.path,
|
||||
time_in_segment,
|
||||
"png",
|
||||
)
|
||||
|
||||
if not image_data:
|
||||
@@ -538,11 +546,7 @@ async def recording_clip(
|
||||
Recordings.start_time,
|
||||
Recordings.end_time,
|
||||
)
|
||||
.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(recording_overlap(start_ts, end_ts))
|
||||
.where(Recordings.camera == camera_name)
|
||||
.where(Recordings.stream_type == stream_type)
|
||||
.order_by(Recordings.start_time.asc())
|
||||
@@ -837,7 +841,9 @@ async def vod_hour(
|
||||
parts = year_month.split("-")
|
||||
start_date = (
|
||||
datetime(int(parts[0]), int(parts[1]), day, hour, tzinfo=UTC)
|
||||
- datetime.now(pytz.timezone(tz_name.replace(",", "/"))).utcoffset()
|
||||
- datetime.now(
|
||||
ZoneInfo(get_normalized_tz_name(tz_name.replace(",", "/")))
|
||||
).utcoffset()
|
||||
)
|
||||
end_date = start_date + timedelta(hours=1) - timedelta(milliseconds=1)
|
||||
start_ts = start_date.timestamp()
|
||||
|
||||
+42
-10
@@ -13,8 +13,9 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from enum import Enum
|
||||
from urllib.parse import unquote, urlparse
|
||||
from urllib.parse import unquote
|
||||
|
||||
from peewee import DoesNotExist
|
||||
|
||||
@@ -24,6 +25,8 @@ 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."""
|
||||
@@ -41,6 +44,36 @@ 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.
|
||||
|
||||
@@ -49,14 +82,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.
|
||||
whose path contains `.` or `..` segments outright. Repeated slashes are
|
||||
merged, as nginx does before matching locations.
|
||||
"""
|
||||
if not original_url:
|
||||
return None
|
||||
|
||||
parsed = urlparse(original_url)
|
||||
raw_path = parsed.path or original_url
|
||||
decoded = unquote(raw_path)
|
||||
path, _ = split_request_uri(original_url)
|
||||
decoded = re.sub(r"/{2,}", "/", unquote(path))
|
||||
if not decoded:
|
||||
return None
|
||||
|
||||
@@ -252,12 +285,11 @@ def deny_response_for_media_uri(
|
||||
path = extract_path(original_url)
|
||||
|
||||
# `extract_path` returns None for URIs containing `.` or `..` segments.
|
||||
# For media-root URIs that's a traversal attempt — deny outright. For
|
||||
# non-media URIs, pass through (nginx / the backend handle them).
|
||||
# Deny them when nginx would resolve them under a media root, and pass
|
||||
# the rest through to nginx and the backend.
|
||||
if path is None:
|
||||
raw = urlparse(original_url).path or original_url
|
||||
decoded = unquote(raw)
|
||||
first = decoded.lstrip("/").split("/", 1)[0] if decoded else ""
|
||||
normalized = normalize_request_path(split_request_uri(original_url)[0])
|
||||
first = normalized.lstrip("/").split("/", 1)[0]
|
||||
if first in ("clips", "recordings", "exports"):
|
||||
return 403
|
||||
return None
|
||||
|
||||
@@ -9,7 +9,6 @@ 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
|
||||
@@ -173,16 +172,22 @@ def register_notifications(request: Request, body: dict = None):
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
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,
|
||||
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
|
||||
)
|
||||
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,6 +22,7 @@ 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__)
|
||||
|
||||
@@ -126,7 +127,9 @@ def preview_hour(
|
||||
parts = year_month.split("-")
|
||||
start_date = (
|
||||
datetime(int(parts[0]), int(parts[1]), int(day), int(hour), tzinfo=UTC)
|
||||
- datetime.now(pytz.timezone(tz_name.replace(",", "/"))).utcoffset()
|
||||
- datetime.now(
|
||||
ZoneInfo(get_normalized_tz_name(tz_name.replace(",", "/")))
|
||||
).utcoffset()
|
||||
)
|
||||
end_date = start_date + timedelta(hours=1) - timedelta(milliseconds=1)
|
||||
start_ts = start_date.timestamp()
|
||||
|
||||
@@ -36,6 +36,7 @@ from frigate.util.recording_coverage import (
|
||||
coverage_spans,
|
||||
known_video_codecs,
|
||||
realized_timelines,
|
||||
recording_overlap,
|
||||
resolve_coverage,
|
||||
stream_media_summary,
|
||||
)
|
||||
@@ -175,7 +176,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 = (
|
||||
@@ -537,11 +538,7 @@ async def delete_recordings(
|
||||
|
||||
# Build query to find overlapping recordings
|
||||
clauses = [
|
||||
(
|
||||
Recordings.start_time.between(start, end)
|
||||
| Recordings.end_time.between(start, end)
|
||||
| ((start > Recordings.start_time) & (end < Recordings.end_time))
|
||||
),
|
||||
recording_overlap(start, end),
|
||||
(Recordings.camera << camera_list),
|
||||
]
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ 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__)
|
||||
@@ -585,14 +586,7 @@ def delete_reviews(body: ReviewModifyMultipleBody):
|
||||
camera_name = review["camera"]
|
||||
recordings = (
|
||||
Recordings.select(Recordings.id, Recordings.path)
|
||||
.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(recording_overlap(start_time, end_time))
|
||||
.where(Recordings.camera == camera_name)
|
||||
.dicts()
|
||||
.iterator()
|
||||
|
||||
+23
-1
@@ -581,7 +581,12 @@ class FrigateApp:
|
||||
|
||||
def init_auth(self) -> None:
|
||||
if self.config.auth.enabled:
|
||||
if User.select().count() == 0:
|
||||
if (
|
||||
User.select().count() == 0
|
||||
or User.select()
|
||||
.where((User.username == "admin") & (User.password_hash == ""))
|
||||
.exists()
|
||||
):
|
||||
password = secrets.token_hex(16)
|
||||
password_hash = hash_password(
|
||||
password, iterations=self.config.auth.hash_iterations
|
||||
@@ -593,6 +598,9 @@ 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
|
||||
@@ -614,6 +622,7 @@ class FrigateApp:
|
||||
username="admin",
|
||||
role="admin",
|
||||
password_hash=password_hash,
|
||||
password_changed_at=datetime.datetime.now(),
|
||||
notification_tokens=[],
|
||||
).execute()
|
||||
|
||||
@@ -623,6 +632,19 @@ 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,6 +169,10 @@ 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_name, self.camera_config.frame_shape_yuv, frame_time
|
||||
)
|
||||
|
||||
tracked_objects = self.tracked_objects.copy()
|
||||
@@ -392,7 +392,6 @@ 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,
|
||||
}
|
||||
@@ -404,7 +403,7 @@ class CameraState:
|
||||
self.send_mqtt_snapshot(new_obj, object_type)
|
||||
|
||||
for c in self.callbacks["start"]:
|
||||
c(self.name, new_obj, frame_name)
|
||||
c(self.name, new_obj, frame_name, frame_time)
|
||||
|
||||
for id in updated_ids:
|
||||
updated_obj = tracked_objects[id]
|
||||
@@ -416,7 +415,7 @@ class CameraState:
|
||||
|
||||
if autotracker_update or significant_update:
|
||||
for c in self.callbacks["autotrack"]:
|
||||
c(self.name, updated_obj, frame_name)
|
||||
c(self.name, updated_obj, frame_name, frame_time)
|
||||
|
||||
if thumb_update and current_frame is not None:
|
||||
# ensure this frame is stored in the cache
|
||||
@@ -466,7 +465,7 @@ class CameraState:
|
||||
):
|
||||
# call event handlers
|
||||
for c in self.callbacks["update"]:
|
||||
c(self.name, updated_obj, frame_name)
|
||||
c(self.name, updated_obj, frame_name, frame_time)
|
||||
updated_obj.last_published = frame_time
|
||||
|
||||
# send MQTT snapshot when object first enters a required zone,
|
||||
@@ -488,7 +487,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)
|
||||
c(self.name, removed_obj, frame_name, frame_time)
|
||||
|
||||
# TODO: can i switch to looking this up and only changing when an event ends?
|
||||
# maintain best objects
|
||||
@@ -603,9 +602,6 @@ 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,6 +1,7 @@
|
||||
"""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
|
||||
@@ -18,11 +19,13 @@ 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."""
|
||||
self.socket.send_string(topic, flags=zmq.SNDMORE)
|
||||
self.socket.send_pyobj(payload)
|
||||
with self.lock:
|
||||
self.socket.send_string(topic, flags=zmq.SNDMORE)
|
||||
self.socket.send_pyobj(payload)
|
||||
|
||||
def stop(self) -> None:
|
||||
self.stop_event.set()
|
||||
|
||||
@@ -322,7 +322,11 @@ class Dispatcher:
|
||||
self.audio_activity.expire_all(payload)
|
||||
|
||||
def handle_update_event_description() -> None:
|
||||
event: Event = Event.get(Event.id == payload["id"])
|
||||
event: Event | None = Event.get_or_none(Event.id == payload["id"])
|
||||
|
||||
if event is None:
|
||||
return
|
||||
|
||||
cast(dict, event.data)["description"] = payload["description"]
|
||||
event.save()
|
||||
self.publish(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Facilitates communication between processes."""
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
@@ -79,14 +80,21 @@ 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,7 +8,16 @@ from .zmq_proxy import Publisher, Subscriber
|
||||
|
||||
|
||||
class EventUpdatePublisher(
|
||||
Publisher[tuple[EventTypeEnum, EventStateEnum, str | None, str, dict[str, Any]]]
|
||||
Publisher[
|
||||
tuple[
|
||||
EventTypeEnum,
|
||||
EventStateEnum,
|
||||
str | None,
|
||||
str,
|
||||
dict[str, Any],
|
||||
float | None,
|
||||
]
|
||||
]
|
||||
):
|
||||
"""Publishes events (objects, audio, manual)."""
|
||||
|
||||
@@ -19,7 +28,14 @@ class EventUpdatePublisher(
|
||||
|
||||
def publish(
|
||||
self,
|
||||
payload: tuple[EventTypeEnum, EventStateEnum, str | None, str, dict[str, Any]],
|
||||
payload: tuple[
|
||||
EventTypeEnum,
|
||||
EventStateEnum,
|
||||
str | None,
|
||||
str,
|
||||
dict[str, Any],
|
||||
float | None,
|
||||
],
|
||||
sub_topic: str = "",
|
||||
) -> None:
|
||||
super().publish(payload, sub_topic)
|
||||
|
||||
@@ -79,14 +79,16 @@ 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."""
|
||||
try:
|
||||
self.socket.send_json((topic, data))
|
||||
return self.socket.recv_json()
|
||||
except zmq.ZMQError:
|
||||
return ""
|
||||
with self.lock:
|
||||
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)
|
||||
|
||||
+41
-28
@@ -41,6 +41,20 @@ 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."""
|
||||
|
||||
@@ -56,6 +70,7 @@ 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()
|
||||
@@ -76,9 +91,9 @@ class WebPushClient(Communicator):
|
||||
User.select(User.username, User.notification_tokens).dicts().iterator()
|
||||
)
|
||||
for user in users:
|
||||
self.web_pushers[user["username"]] = []
|
||||
for sub in user["notification_tokens"]:
|
||||
self.web_pushers[user["username"]].append(WebPusher(sub))
|
||||
self.web_pushers[user["username"]] = _build_web_pushers(
|
||||
user["username"], user["notification_tokens"]
|
||||
)
|
||||
|
||||
# notification and auth config updater
|
||||
self.global_config_subscriber = ConfigSubscriber("config/")
|
||||
@@ -146,10 +161,7 @@ class WebPushClient(Communicator):
|
||||
User.username == user
|
||||
).execute()
|
||||
|
||||
self.web_pushers[user] = []
|
||||
|
||||
for sub in user_subs:
|
||||
self.web_pushers[user].append(WebPusher(sub))
|
||||
self.web_pushers[user] = _build_web_pushers(user, user_subs)
|
||||
|
||||
logger.info(
|
||||
f"Cleaned up {len(expired)} notification subscriptions for {user}"
|
||||
@@ -198,29 +210,30 @@ class WebPushClient(Communicator):
|
||||
|
||||
def publish(self, topic: str, payload: Any, retain: bool = False) -> None:
|
||||
"""Wrapper for publishing when client is in valid state."""
|
||||
# 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
|
||||
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
|
||||
|
||||
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"]
|
||||
|
||||
+82
-4
@@ -3,6 +3,9 @@
|
||||
import errno
|
||||
import json
|
||||
import logging
|
||||
import queue
|
||||
import socket
|
||||
import struct
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
@@ -76,6 +79,12 @@ _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,
|
||||
@@ -464,6 +473,78 @@ 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
|
||||
@@ -597,10 +678,7 @@ class WebSocketClient(Communicator):
|
||||
)
|
||||
if message is None:
|
||||
continue
|
||||
try:
|
||||
ws.send(message)
|
||||
except (ConnectionResetError, BrokenPipeError, ValueError):
|
||||
pass
|
||||
ws.send(message)
|
||||
|
||||
def stop(self) -> None:
|
||||
if self.websocket_server is not None:
|
||||
|
||||
+11
-1
@@ -1,3 +1,5 @@
|
||||
import ipaddress
|
||||
|
||||
from pydantic import Field, field_validator, model_validator
|
||||
|
||||
from .base import FrigateBaseModel
|
||||
@@ -25,7 +27,7 @@ class AuthConfig(FrigateBaseModel):
|
||||
cookie_secure: bool = Field(
|
||||
default=False,
|
||||
title="Secure cookie flag",
|
||||
description="Set the secure flag on the auth cookie; should be true when using TLS.",
|
||||
description="Always set the secure flag on the auth cookie. Frigate sets it automatically when serving TLS; enable this when a reverse proxy terminates TLS in front of Frigate. A proxy that connects to the TLS port must serve Frigate to browsers over HTTPS.",
|
||||
)
|
||||
session_length: int = Field(
|
||||
default=86400,
|
||||
@@ -68,6 +70,14 @@ 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,8 +3,9 @@ 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,7 +1,8 @@
|
||||
from pydantic import Field
|
||||
|
||||
from frigate.util.env import EnvString
|
||||
|
||||
from ..base import FrigateBaseModel
|
||||
from ..env import EnvString
|
||||
|
||||
__all__ = ["NotificationConfig"]
|
||||
|
||||
|
||||
@@ -2,8 +2,9 @@ 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,6 +147,7 @@ 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,6 +34,7 @@ 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,
|
||||
@@ -71,7 +72,6 @@ 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
|
||||
|
||||
@@ -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,7 +1,8 @@
|
||||
from pydantic import Field, field_validator
|
||||
|
||||
from frigate.util.env import EnvString
|
||||
|
||||
from .base import FrigateBaseModel
|
||||
from .env import EnvString
|
||||
|
||||
__all__ = ["ProxyConfig", "HeaderMappingConfig"]
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ MODEL_CACHE_DIR = f"{CONFIG_DIR}/model_cache"
|
||||
BASE_DIR = "/media/frigate"
|
||||
CLIPS_DIR = f"{BASE_DIR}/clips"
|
||||
EXPORT_DIR = f"{BASE_DIR}/exports"
|
||||
EXPORT_STAGING_DIR = f"{EXPORT_DIR}/staging"
|
||||
FACE_DIR = f"{CLIPS_DIR}/faces"
|
||||
THUMB_DIR = f"{CLIPS_DIR}/thumbs"
|
||||
RECORD_DIR = f"{BASE_DIR}/recordings"
|
||||
|
||||
@@ -4,6 +4,7 @@ import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from peewee import DoesNotExist
|
||||
@@ -193,9 +194,10 @@ class AudioTranscriptionPostProcessor(PostProcessorApi):
|
||||
logger.debug("Recognizer not initialized")
|
||||
return None
|
||||
|
||||
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")
|
||||
# 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:
|
||||
with open(temp_wav, "wb") as f:
|
||||
f.write(audio_data)
|
||||
|
||||
@@ -205,8 +207,6 @@ 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,6 +222,8 @@ 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,6 +105,10 @@ 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
|
||||
@@ -302,26 +306,7 @@ class ObjectDescriptionProcessor(PostProcessorApi):
|
||||
)
|
||||
|
||||
if camera_config.objects.genai.debug_save_thumbnails and num_thumbnails > 0:
|
||||
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)
|
||||
self._save_debug_thumbnails(event_id)
|
||||
|
||||
# Generate the description. Call happens in a thread since it is network bound.
|
||||
threading.Thread(
|
||||
@@ -337,6 +322,30 @@ 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,6 +37,7 @@ 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
|
||||
@@ -592,7 +593,7 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
||||
Recordings.path,
|
||||
Recordings.start_time,
|
||||
)
|
||||
.where((ts >= Recordings.start_time) & (ts <= Recordings.end_time))
|
||||
.where(recording_overlap(ts, ts))
|
||||
.where(Recordings.camera == camera)
|
||||
.where(Recordings.stream_type == STREAM_TYPE_MAIN)
|
||||
.order_by(Recordings.start_time.desc())
|
||||
|
||||
@@ -193,12 +193,7 @@ 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
|
||||
or not self.tensor_input_details
|
||||
or not self.tensor_output_details
|
||||
):
|
||||
if not self.model_config.name or not self.model_config.state_config:
|
||||
return
|
||||
|
||||
if self.metrics and self.model_config.name in self.metrics.classification_cps:
|
||||
@@ -541,12 +536,7 @@ 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
|
||||
or not self.tensor_input_details
|
||||
or not self.tensor_output_details
|
||||
):
|
||||
if not self.model_config.name or not self.model_config.object_config:
|
||||
return
|
||||
|
||||
if self.metrics and self.model_config.name in self.metrics.classification_cps:
|
||||
|
||||
@@ -89,30 +89,46 @@ def is_arm64_platform() -> bool:
|
||||
return machine in ("aarch64", "arm64", "armv8", "armv7l")
|
||||
|
||||
|
||||
def get_ort_session_options(model_type: str | None = None) -> ort.SessionOptions | None:
|
||||
def get_ort_session_options(
|
||||
model_type: str | None = None, cpu: bool = False
|
||||
) -> 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 with a pinned optimization level, or None for default settings.
|
||||
SessionOptions for enrichment models, or None for default settings.
|
||||
"""
|
||||
# Import here to avoid circular imports
|
||||
from frigate.embeddings.types import EnrichmentModelTypeEnum
|
||||
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")
|
||||
|
||||
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
|
||||
level = ort.GraphOptimizationLevel.ORT_ENABLE_EXTENDED
|
||||
sess_options.graph_optimization_level = (
|
||||
ort.GraphOptimizationLevel.ORT_ENABLE_EXTENDED
|
||||
)
|
||||
elif model_type == EnrichmentModelTypeEnum.jina_v1.value:
|
||||
# aggressive optimizations create or expect nodes that don't exist
|
||||
level = ort.GraphOptimizationLevel.ORT_ENABLE_BASIC
|
||||
else:
|
||||
return None
|
||||
sess_options.graph_optimization_level = (
|
||||
ort.GraphOptimizationLevel.ORT_ENABLE_BASIC
|
||||
)
|
||||
|
||||
sess_options = ort.SessionOptions()
|
||||
sess_options.graph_optimization_level = level
|
||||
return sess_options
|
||||
|
||||
|
||||
@@ -789,7 +805,9 @@ def get_optimized_runner(
|
||||
ONNXModelRunner(
|
||||
ort.InferenceSession(
|
||||
model_path,
|
||||
sess_options=get_ort_session_options(model_type),
|
||||
sess_options=get_ort_session_options(
|
||||
model_type, cpu=providers[0] == "CPUExecutionProvider"
|
||||
),
|
||||
providers=providers,
|
||||
provider_options=options,
|
||||
),
|
||||
|
||||
@@ -10,6 +10,7 @@ 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
|
||||
|
||||
@@ -51,6 +52,11 @@ 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,6 +332,9 @@ 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()
|
||||
|
||||
@@ -517,7 +520,7 @@ class EmbeddingMaintainer(threading.Thread):
|
||||
if update is None:
|
||||
return
|
||||
|
||||
source_type, event_type, camera, frame_name, data = update
|
||||
source_type, event_type, camera, frame_name, data, frame_time = update
|
||||
|
||||
logger.debug(
|
||||
f"Received update - source_type: {source_type}, camera: {camera}, data label: {data.get('label') if data else 'None'}"
|
||||
@@ -544,13 +547,9 @@ class EmbeddingMaintainer(threading.Thread):
|
||||
return
|
||||
|
||||
# Create our own thumbnail based on the bounding box and the 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
|
||||
yuv_frame = self.frame_manager.get(
|
||||
frame_name, camera_config.frame_shape_yuv, frame_time
|
||||
)
|
||||
|
||||
if yuv_frame is None:
|
||||
logger.debug(
|
||||
@@ -583,8 +582,6 @@ 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:
|
||||
@@ -793,7 +790,7 @@ class EmbeddingMaintainer(threading.Thread):
|
||||
if topic is None:
|
||||
return
|
||||
|
||||
camera, frame_name, _, _, motion_boxes, _ = data
|
||||
camera, frame_name, frame_time, _, motion_boxes, _ = data
|
||||
|
||||
if not camera or camera not in self.config.cameras:
|
||||
return
|
||||
@@ -815,12 +812,9 @@ class EmbeddingMaintainer(threading.Thread):
|
||||
# no active features that use this data
|
||||
return
|
||||
|
||||
try:
|
||||
yuv_frame = self.frame_manager.get(
|
||||
frame_name, camera_config.frame_shape_yuv
|
||||
)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
yuv_frame = self.frame_manager.get(
|
||||
frame_name, camera_config.frame_shape_yuv, frame_time
|
||||
)
|
||||
|
||||
if yuv_frame is None:
|
||||
logger.debug(
|
||||
@@ -841,8 +835,6 @@ 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, CLIPImageProcessor
|
||||
from transformers import AutoTokenizer, CLIPImageProcessorPil
|
||||
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 = CLIPImageProcessor.from_pretrained(
|
||||
self.feature_extractor = CLIPImageProcessorPil.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,6 +27,7 @@ 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
|
||||
@@ -104,11 +105,7 @@ def query_recordings(source_camera: str, start_ts: float, end_ts: float) -> Mode
|
||||
Recordings.start_time,
|
||||
Recordings.end_time,
|
||||
)
|
||||
.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(recording_overlap(start_ts, end_ts))
|
||||
.where(Recordings.camera == source_camera)
|
||||
.order_by(Recordings.start_time.asc())
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
@@ -15,7 +16,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 UPDATE_JOB_STATE
|
||||
from frigate.const import EXPORT_STAGING_DIR, UPDATE_JOB_STATE
|
||||
from frigate.jobs.job import Job
|
||||
from frigate.models import Export
|
||||
from frigate.record.export import (
|
||||
@@ -415,6 +416,9 @@ 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,6 +35,7 @@ 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__)
|
||||
|
||||
@@ -469,20 +470,7 @@ class MotionSearchRunner(threading.Thread):
|
||||
recordings = list(
|
||||
Recordings.select()
|
||||
.where(
|
||||
(
|
||||
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)
|
||||
)
|
||||
recording_overlap(self.job.start_time_range, self.job.end_time_range)
|
||||
)
|
||||
.where(Recordings.camera == camera_name)
|
||||
.where(Recordings.stream_type == STREAM_TYPE_MAIN)
|
||||
|
||||
@@ -170,7 +170,6 @@ 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)
|
||||
@@ -259,10 +258,6 @@ 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,12 +277,7 @@ class BroadcastThread(threading.Thread):
|
||||
and ws.environ["PATH_INFO"] == f"/{self.camera}"
|
||||
and ws_has_camera_access(ws, self.camera, self.config)
|
||||
):
|
||||
try:
|
||||
ws.send(buf, binary=True)
|
||||
except ValueError:
|
||||
pass
|
||||
except (BrokenPipeError, ConnectionResetError, OSError) as e:
|
||||
logger.debug(f"Websocket unexpectedly closed {e}")
|
||||
ws.send(buf, binary=True)
|
||||
elif self.converter.process.poll() is not None:
|
||||
break
|
||||
|
||||
|
||||
@@ -127,12 +127,7 @@ class BroadcastThread(threading.Thread):
|
||||
and ws.environ["PATH_INFO"] == f"/{self.camera}"
|
||||
and ws_has_camera_access(ws, self.camera, self.config)
|
||||
):
|
||||
try:
|
||||
ws.send(buf, binary=True)
|
||||
except ValueError:
|
||||
pass
|
||||
except (BrokenPipeError, ConnectionResetError) as e:
|
||||
logger.debug(f"Websocket unexpectedly closed {e}")
|
||||
ws.send(buf, binary=True)
|
||||
elif self.converter.process.poll() is not None:
|
||||
break
|
||||
|
||||
|
||||
+11
-18
@@ -8,6 +8,7 @@ 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,
|
||||
@@ -122,6 +123,10 @@ 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
|
||||
@@ -145,6 +150,7 @@ class OutputProcess(FrigateProcess):
|
||||
CameraConfigUpdateEnum.birdseye,
|
||||
CameraConfigUpdateEnum.enabled,
|
||||
CameraConfigUpdateEnum.record,
|
||||
CameraConfigUpdateEnum.remove,
|
||||
],
|
||||
)
|
||||
birdseye_config_subscriber = ConfigSubscriber("config/birdseye", exact=True)
|
||||
@@ -208,6 +214,10 @@ 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
|
||||
@@ -241,7 +251,7 @@ class OutputProcess(FrigateProcess):
|
||||
continue
|
||||
|
||||
frame = frame_manager.get(
|
||||
frame_name, self.config.cameras[camera].frame_shape_yuv
|
||||
frame_name, self.config.cameras[camera].frame_shape_yuv, frame_time
|
||||
)
|
||||
|
||||
if frame is None:
|
||||
@@ -296,8 +306,6 @@ class OutputProcess(FrigateProcess):
|
||||
frame,
|
||||
)
|
||||
|
||||
frame_manager.close(frame_name)
|
||||
|
||||
move_preview_frames("clips")
|
||||
|
||||
while True:
|
||||
@@ -309,21 +317,6 @@ 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_name, self.camera_config.frame_shape_yuv, frame_time
|
||||
)
|
||||
|
||||
if yuv_frame is None:
|
||||
@@ -181,8 +181,6 @@ class PtzMotionEstimator:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self.frame_manager.close(frame_name)
|
||||
|
||||
return self.coord_transformations
|
||||
|
||||
|
||||
|
||||
+21
-15
@@ -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,6 +26,7 @@ from frigate.const import (
|
||||
CACHE_DIR,
|
||||
CLIPS_DIR,
|
||||
EXPORT_DIR,
|
||||
EXPORT_STAGING_DIR,
|
||||
MAX_PLAYLIST_SECONDS,
|
||||
PREVIEW_FRAME_TYPE,
|
||||
STREAM_TYPE_MAIN,
|
||||
@@ -44,11 +45,12 @@ 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 is_current_hour
|
||||
from frigate.util.time import get_normalized_tz_name, is_current_hour
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -559,7 +561,7 @@ class RecordingExporter(threading.Thread):
|
||||
)
|
||||
|
||||
def _staged_run_path(self, index: int) -> str:
|
||||
return os.path.join(CACHE_DIR, f"export_stage_{self.export_id}_{index}.mp4")
|
||||
return os.path.join(EXPORT_STAGING_DIR, f"{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.
|
||||
@@ -714,6 +716,17 @@ 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
|
||||
|
||||
@@ -761,14 +774,7 @@ class RecordingExporter(threading.Thread):
|
||||
Recordings.start_time,
|
||||
Recordings.end_time,
|
||||
)
|
||||
.where(
|
||||
Recordings.start_time.between(self.start_time, self.end_time)
|
||||
| Recordings.end_time.between(self.start_time, self.end_time)
|
||||
| (
|
||||
(self.start_time > Recordings.start_time)
|
||||
& (self.end_time < Recordings.end_time)
|
||||
)
|
||||
)
|
||||
.where(recording_overlap(self.start_time, self.end_time))
|
||||
.where(
|
||||
(Recordings.camera == self.camera)
|
||||
& (Recordings.stream_type == stream_type)
|
||||
@@ -862,8 +868,8 @@ class RecordingExporter(threading.Thread):
|
||||
tz_name = self.config.ui.timezone
|
||||
if tz_name:
|
||||
try:
|
||||
tz = pytz.timezone(tz_name)
|
||||
except pytz.UnknownTimeZoneError:
|
||||
tz = ZoneInfo(get_normalized_tz_name(tz_name))
|
||||
except (ValueError, ZoneInfoNotFoundError):
|
||||
tz = None
|
||||
if tz is not None:
|
||||
return datetime.datetime.fromtimestamp(timestamp, tz=tz).strftime(
|
||||
@@ -1026,8 +1032,8 @@ class RecordingExporter(threading.Thread):
|
||||
tz: datetime.tzinfo | None = None
|
||||
if tz_name:
|
||||
try:
|
||||
tz = pytz.timezone(tz_name)
|
||||
except pytz.UnknownTimeZoneError:
|
||||
tz = ZoneInfo(get_normalized_tz_name(tz_name))
|
||||
except (ValueError, ZoneInfoNotFoundError):
|
||||
tz = None
|
||||
if tz is None:
|
||||
tz = datetime.UTC
|
||||
|
||||
@@ -1000,6 +1000,7 @@ 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(
|
||||
@@ -1017,7 +1018,7 @@ class RecordingMaintainer(threading.Thread):
|
||||
except OSError:
|
||||
segment_size = 0
|
||||
|
||||
os.remove(cache_path)
|
||||
self.drop_segment(cache_path)
|
||||
|
||||
rand_id = "".join(
|
||||
random.choices(string.ascii_lowercase + string.digits, k=6)
|
||||
@@ -1046,10 +1047,8 @@ class RecordingMaintainer(threading.Thread):
|
||||
}
|
||||
except Exception:
|
||||
logger.exception(f"Unable to store recording segment {cache_path}")
|
||||
Path(cache_path).unlink(missing_ok=True)
|
||||
|
||||
# clear end_time cache
|
||||
self.end_time_cache.pop(cache_path, None)
|
||||
self.drop_segment(cache_path)
|
||||
return None
|
||||
|
||||
def run(self) -> None:
|
||||
|
||||
@@ -504,6 +504,7 @@ 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)
|
||||
|
||||
@@ -542,19 +543,15 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
if len(objects) <= pending.frame_active_count:
|
||||
return
|
||||
|
||||
try:
|
||||
yuv_frame = self.frame_manager.get(
|
||||
frame_name, camera_config.frame_shape_yuv
|
||||
)
|
||||
except FileNotFoundError:
|
||||
return
|
||||
yuv_frame = self.frame_manager.get(
|
||||
frame_name, camera_config.frame_shape_yuv, frame_time
|
||||
)
|
||||
|
||||
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,
|
||||
@@ -653,7 +650,7 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
try:
|
||||
if should_update_image:
|
||||
yuv_frame = self.frame_manager.get(
|
||||
frame_name, camera_config.frame_shape_yuv
|
||||
frame_name, camera_config.frame_shape_yuv, frame_time
|
||||
)
|
||||
|
||||
if yuv_frame is None:
|
||||
@@ -669,7 +666,6 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
activity.get_all_objects(),
|
||||
prev_data,
|
||||
)
|
||||
self.frame_manager.close(frame_name)
|
||||
except FileNotFoundError:
|
||||
return
|
||||
|
||||
@@ -677,7 +673,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_name, camera_config.frame_shape_yuv, frame_time
|
||||
)
|
||||
|
||||
if yuv_frame is None:
|
||||
@@ -685,7 +681,6 @@ 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
|
||||
)
|
||||
@@ -770,7 +765,7 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
|
||||
try:
|
||||
yuv_frame = self.frame_manager.get(
|
||||
frame_name, camera_config.frame_shape_yuv
|
||||
frame_name, camera_config.frame_shape_yuv, frame_time
|
||||
)
|
||||
|
||||
if yuv_frame is None:
|
||||
@@ -780,7 +775,6 @@ 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):
|
||||
|
||||
@@ -1,22 +1,29 @@
|
||||
"""Tests that /auth only accepts a JWT whose role is still in the config."""
|
||||
"""Tests that /auth takes the role and revocation state from the user, not the JWT."""
|
||||
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
from frigate.api.auth import create_encoded_jwt
|
||||
from joserfc import jwt
|
||||
|
||||
from frigate.api.auth import create_encoded_jwt, hash_password
|
||||
from frigate.api.fastapi_app import create_fastapi_app
|
||||
from frigate.app import FrigateApp
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.config.camera.updater import CameraConfigUpdatePublisher
|
||||
from frigate.const import JWT_SECRET_ENV_VAR
|
||||
from frigate.models import Event, Recordings, ReviewSegment
|
||||
from frigate.models import Event, Recordings, ReviewSegment, User
|
||||
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
|
||||
|
||||
PASSWORD = "correct-horse-battery"
|
||||
|
||||
|
||||
@patch.dict(os.environ, {JWT_SECRET_ENV_VAR: "test-secret"})
|
||||
class TestAuthJwtRole(BaseTestHttp):
|
||||
def setUp(self):
|
||||
super().setUp(models=[Event, Recordings, ReviewSegment])
|
||||
super().setUp(models=[Event, Recordings, ReviewSegment, User])
|
||||
self.minimal_config = {
|
||||
"mqtt": {"host": "mqtt"},
|
||||
"auth": {"enabled": True, "roles": {"garage": ["front_door"]}},
|
||||
@@ -36,6 +43,19 @@ class TestAuthJwtRole(BaseTestHttp):
|
||||
}
|
||||
},
|
||||
}
|
||||
User.delete().execute()
|
||||
self._insert_user("admin", "admin")
|
||||
self._insert_user("bob", "garage")
|
||||
|
||||
def _insert_user(self, username: str, role: str):
|
||||
User.insert(
|
||||
{
|
||||
User.username: username,
|
||||
User.role: role,
|
||||
User.password_hash: hash_password(PASSWORD, iterations=1),
|
||||
User.notification_tokens: [],
|
||||
}
|
||||
).execute()
|
||||
|
||||
def _create_app(self):
|
||||
mock_publisher = Mock(spec=CameraConfigUpdatePublisher)
|
||||
@@ -55,27 +75,228 @@ class TestAuthJwtRole(BaseTestHttp):
|
||||
enforce_default_admin=False,
|
||||
)
|
||||
|
||||
def _auth(self, app, role: str):
|
||||
token = create_encoded_jwt("bob", role, int(time.time()) + 3600, app.jwt_token)
|
||||
|
||||
with AuthTestClient(app) as client:
|
||||
return client.get(
|
||||
"/auth",
|
||||
headers={
|
||||
"x-server-port": "8971",
|
||||
"authorization": f"Bearer {token}",
|
||||
},
|
||||
def _token(self, app, user: str, role: str, iat: int | None = None) -> str:
|
||||
if iat is None:
|
||||
return create_encoded_jwt(
|
||||
user, role, int(time.time()) + 3600, app.jwt_token
|
||||
)
|
||||
|
||||
return jwt.encode(
|
||||
{"alg": "HS256"},
|
||||
{"sub": user, "role": role, "exp": int(time.time()) + 3600, "iat": iat},
|
||||
app.jwt_token,
|
||||
)
|
||||
|
||||
def _auth(self, client, token: str, source: str = "bearer"):
|
||||
headers = {"x-server-port": "8971"}
|
||||
if source == "bearer":
|
||||
headers["authorization"] = f"Bearer {token}"
|
||||
else:
|
||||
client.cookies.set("frigate_token", token)
|
||||
|
||||
resp = client.get("/auth", headers=headers)
|
||||
client.cookies.clear()
|
||||
return resp
|
||||
|
||||
def test_configured_role_is_accepted(self):
|
||||
resp = self._auth(self._create_app(), "garage")
|
||||
app = self._create_app()
|
||||
with AuthTestClient(app) as client:
|
||||
resp = self._auth(client, self._token(app, "bob", "garage"))
|
||||
|
||||
self.assertEqual(resp.status_code, 202)
|
||||
self.assertEqual(resp.headers["remote-user"], "bob")
|
||||
self.assertEqual(resp.headers["remote-role"], "garage")
|
||||
|
||||
def test_role_claim_is_ignored(self):
|
||||
app = self._create_app()
|
||||
with AuthTestClient(app) as client:
|
||||
resp = self._auth(client, self._token(app, "bob", "admin"))
|
||||
|
||||
self.assertEqual(resp.status_code, 202)
|
||||
self.assertEqual(resp.headers["remote-role"], "garage")
|
||||
|
||||
def test_role_removed_from_config_is_rejected(self):
|
||||
resp = self._auth(self._create_app(), "removed_role")
|
||||
User.update(role="removed_role").where(User.username == "bob").execute()
|
||||
app = self._create_app()
|
||||
with AuthTestClient(app) as client:
|
||||
for source in ("bearer", "cookie"):
|
||||
resp = self._auth(client, self._token(app, "bob", "garage"), source)
|
||||
self.assertEqual(resp.status_code, 401)
|
||||
self.assertNotIn("remote-role", resp.headers)
|
||||
|
||||
def test_login_refused_for_role_removed_from_config(self):
|
||||
User.update(role="removed_role").where(User.username == "bob").execute()
|
||||
with AuthTestClient(self._create_app()) as client:
|
||||
resp = client.post("/login", json={"user": "bob", "password": PASSWORD})
|
||||
|
||||
self.assertEqual(resp.status_code, 401)
|
||||
self.assertNotIn("remote-role", resp.headers)
|
||||
self.assertNotIn("set-cookie", resp.headers)
|
||||
|
||||
def test_demoted_user_gets_new_role_on_next_request(self):
|
||||
app = self._create_app()
|
||||
token = self._token(app, "bob", "admin")
|
||||
User.update(role="admin").where(User.username == "bob").execute()
|
||||
app.user_sessions["bob"] = ("admin", None)
|
||||
|
||||
with AuthTestClient(app) as client:
|
||||
self.assertEqual(self._auth(client, token).headers["remote-role"], "admin")
|
||||
resp = client.put("/users/bob/role", json={"role": "viewer"})
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
for source in ("bearer", "cookie"):
|
||||
resp = self._auth(client, token, source)
|
||||
self.assertEqual(resp.status_code, 202)
|
||||
self.assertEqual(resp.headers["remote-role"], "viewer")
|
||||
|
||||
def test_deleted_user_is_rejected(self):
|
||||
app = self._create_app()
|
||||
token = self._token(app, "bob", "garage")
|
||||
|
||||
with AuthTestClient(app) as client:
|
||||
self.assertEqual(self._auth(client, token).status_code, 202)
|
||||
self.assertEqual(client.delete("/users/bob").status_code, 200)
|
||||
|
||||
for source in ("bearer", "cookie"):
|
||||
self.assertEqual(self._auth(client, token, source).status_code, 401)
|
||||
|
||||
def test_created_user_is_accepted(self):
|
||||
app = self._create_app()
|
||||
with AuthTestClient(app) as client:
|
||||
resp = client.post(
|
||||
"/users",
|
||||
json={"username": "carol", "password": PASSWORD, "role": "viewer"},
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
resp = self._auth(client, self._token(app, "carol", "viewer"))
|
||||
|
||||
self.assertEqual(resp.status_code, 202)
|
||||
self.assertEqual(resp.headers["remote-role"], "viewer")
|
||||
|
||||
def test_password_change_revokes_older_tokens(self):
|
||||
app = self._create_app()
|
||||
old_token = self._token(app, "bob", "garage", iat=int(time.time()) - 10)
|
||||
|
||||
with AuthTestClient(app) as client:
|
||||
self.assertEqual(self._auth(client, old_token).status_code, 202)
|
||||
resp = client.put(
|
||||
"/users/bob/password",
|
||||
json={"password": "a-brand-new-password"},
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
for source in ("bearer", "cookie"):
|
||||
self.assertEqual(self._auth(client, old_token, source).status_code, 401)
|
||||
|
||||
changed_at = app.user_sessions["bob"][1]
|
||||
resp = self._auth(client, self._token(app, "bob", "garage", changed_at))
|
||||
self.assertEqual(resp.status_code, 202)
|
||||
|
||||
def test_password_change_revokes_token_from_the_same_second(self):
|
||||
app = self._create_app()
|
||||
changed_at = datetime.now().replace(microsecond=500000)
|
||||
old_token = self._token(app, "bob", "garage", iat=int(changed_at.timestamp()))
|
||||
|
||||
with (
|
||||
patch("frigate.api.auth.datetime") as mock_datetime,
|
||||
AuthTestClient(app) as client,
|
||||
):
|
||||
mock_datetime.now.return_value = changed_at
|
||||
resp = client.put(
|
||||
"/users/bob/password",
|
||||
json={"password": "a-brand-new-password"},
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
for source in ("bearer", "cookie"):
|
||||
self.assertEqual(self._auth(client, old_token, source).status_code, 401)
|
||||
|
||||
def test_own_password_change_cookie_stays_valid(self):
|
||||
app = self._create_app()
|
||||
with AuthTestClient(app) as client:
|
||||
resp = client.put(
|
||||
"/users/bob/password",
|
||||
json={"password": "a-brand-new-password", "old_password": PASSWORD},
|
||||
headers={"remote-user": "bob", "remote-role": "garage"},
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
new_token = resp.cookies["frigate_token"]
|
||||
|
||||
resp = self._auth(client, new_token, "cookie")
|
||||
|
||||
self.assertEqual(resp.status_code, 202)
|
||||
self.assertEqual(resp.headers["remote-role"], "garage")
|
||||
|
||||
def test_admin_password_reset_revokes_older_tokens(self):
|
||||
config = FrigateConfig(
|
||||
**{**self.minimal_config, "auth": {"reset_admin_password": True}}
|
||||
)
|
||||
FrigateApp.init_auth(SimpleNamespace(config=config))
|
||||
|
||||
app = self._create_app()
|
||||
old_token = self._token(app, "admin", "admin", iat=int(time.time()) - 10)
|
||||
|
||||
with AuthTestClient(app) as client:
|
||||
for source in ("bearer", "cookie"):
|
||||
self.assertEqual(self._auth(client, old_token, source).status_code, 401)
|
||||
|
||||
def test_auth_does_not_query_users(self):
|
||||
app = self._create_app()
|
||||
|
||||
# expires inside the refresh window so the cookie refresh runs too
|
||||
token = jwt.encode(
|
||||
{"alg": "HS256"},
|
||||
{"sub": "bob", "role": "garage", "exp": int(time.time()) + 60, "iat": 0},
|
||||
app.jwt_token,
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(User, "select", side_effect=AssertionError("db query")),
|
||||
AuthTestClient(app) as client,
|
||||
):
|
||||
client.cookies.set("frigate_token", token)
|
||||
resp = client.get("/auth", headers={"x-server-port": "8971"})
|
||||
|
||||
self.assertEqual(resp.status_code, 202)
|
||||
self.assertIn("frigate_token=", resp.headers["set-cookie"])
|
||||
|
||||
def _login_cookie(self, headers: dict | None = None) -> str:
|
||||
with AuthTestClient(self._create_app()) as client:
|
||||
resp = client.post(
|
||||
"/login",
|
||||
json={"user": "bob", "password": PASSWORD},
|
||||
headers=headers or {},
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
return resp.headers["set-cookie"].lower()
|
||||
|
||||
def test_login_cookie_is_secure_over_tls(self):
|
||||
cookie = self._login_cookie({"x-forwarded-proto": "https"})
|
||||
self.assertIn("; secure", cookie)
|
||||
|
||||
def test_login_cookie_is_not_secure_over_http(self):
|
||||
cookie = self._login_cookie({"x-forwarded-proto": "http"})
|
||||
self.assertNotIn("; secure", cookie)
|
||||
|
||||
def test_cookie_secure_config_forces_secure(self):
|
||||
self.minimal_config["auth"]["cookie_secure"] = True
|
||||
cookie = self._login_cookie()
|
||||
self.assertIn("; secure", cookie)
|
||||
|
||||
def test_refreshed_cookie_is_secure_over_tls(self):
|
||||
app = self._create_app()
|
||||
token = jwt.encode(
|
||||
{"alg": "HS256"},
|
||||
{"sub": "bob", "exp": int(time.time()) + 60, "iat": int(time.time())},
|
||||
app.jwt_token,
|
||||
)
|
||||
|
||||
with AuthTestClient(app) as client:
|
||||
client.cookies.set("frigate_token", token)
|
||||
resp = client.get(
|
||||
"/auth",
|
||||
headers={"x-server-port": "8971", "x-forwarded-proto": "https"},
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 202)
|
||||
self.assertIn("; secure", resp.headers["set-cookie"].lower())
|
||||
|
||||
@@ -417,6 +417,77 @@ 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."""
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
"""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()
|
||||
@@ -4,6 +4,7 @@ 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,
|
||||
@@ -453,6 +454,23 @@ 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")
|
||||
@@ -1034,6 +1052,65 @@ 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)
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"""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)
|
||||
self.app.user_sessions = {"admin": ("admin", None), "viewer": ("viewer", None)}
|
||||
|
||||
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,10 +1,12 @@
|
||||
"""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
|
||||
@@ -128,16 +130,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 = pytz.timezone("America/New_York")
|
||||
tz = ZoneInfo("America/New_York")
|
||||
|
||||
# March 9, 2024 at 12:00 PM EST (before DST)
|
||||
march_9_noon = tz.localize(datetime(2024, 3, 9, 12, 0, 0)).timestamp()
|
||||
march_9_noon = datetime(2024, 3, 9, 12, 0, 0, tzinfo=tz).timestamp()
|
||||
|
||||
# March 10, 2024 at 12:00 PM EDT (after DST transition)
|
||||
march_10_noon = tz.localize(datetime(2024, 3, 10, 12, 0, 0)).timestamp()
|
||||
march_10_noon = datetime(2024, 3, 10, 12, 0, 0, tzinfo=tz).timestamp()
|
||||
|
||||
# March 11, 2024 at 12:00 PM EDT (after DST)
|
||||
march_11_noon = tz.localize(datetime(2024, 3, 11, 12, 0, 0)).timestamp()
|
||||
march_11_noon = datetime(2024, 3, 11, 12, 0, 0, tzinfo=tz).timestamp()
|
||||
|
||||
with AuthTestClient(self.app) as client:
|
||||
# Insert recordings for each day
|
||||
@@ -201,19 +203,16 @@ 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 = pytz.timezone("America/New_York")
|
||||
tz = ZoneInfo("America/New_York")
|
||||
|
||||
# November 2, 2024 at 12:00 PM EDT (before DST transition)
|
||||
nov_2_noon = tz.localize(datetime(2024, 11, 2, 12, 0, 0)).timestamp()
|
||||
nov_2_noon = datetime(2024, 11, 2, 12, 0, 0, tzinfo=tz).timestamp()
|
||||
|
||||
# November 3, 2024 at 12:00 PM EST (after DST transition)
|
||||
# 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()
|
||||
nov_3_noon = datetime(2024, 11, 3, 12, 0, 0, tzinfo=tz).timestamp()
|
||||
|
||||
# November 4, 2024 at 12:00 PM EST (after DST)
|
||||
nov_4_noon = tz.localize(datetime(2024, 11, 4, 12, 0, 0)).timestamp()
|
||||
nov_4_noon = datetime(2024, 11, 4, 12, 0, 0, tzinfo=tz).timestamp()
|
||||
|
||||
with AuthTestClient(self.app) as client:
|
||||
# Insert recordings for each day
|
||||
@@ -274,13 +273,13 @@ class TestHttpMedia(BaseTestHttp):
|
||||
"""
|
||||
Test recordings summary with multiple cameras across DST boundary.
|
||||
"""
|
||||
tz = pytz.timezone("America/New_York")
|
||||
tz = ZoneInfo("America/New_York")
|
||||
|
||||
# March 9, 2024 at 10:00 AM EST (before DST)
|
||||
march_9_morning = tz.localize(datetime(2024, 3, 9, 10, 0, 0)).timestamp()
|
||||
march_9_morning = datetime(2024, 3, 9, 10, 0, 0, tzinfo=tz).timestamp()
|
||||
|
||||
# March 10, 2024 at 3:00 PM EDT (after DST transition)
|
||||
march_10_afternoon = tz.localize(datetime(2024, 3, 10, 15, 0, 0)).timestamp()
|
||||
march_10_afternoon = datetime(2024, 3, 10, 15, 0, 0, tzinfo=tz).timestamp()
|
||||
|
||||
with AuthTestClient(self.app) as client:
|
||||
# Override allowed cameras for this test to include both
|
||||
@@ -343,15 +342,15 @@ class TestHttpMedia(BaseTestHttp):
|
||||
"""
|
||||
Test recordings that span the exact DST transition time.
|
||||
"""
|
||||
tz = pytz.timezone("America/New_York")
|
||||
tz = ZoneInfo("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 = tz.localize(datetime(2024, 3, 10, 1, 0, 0)).timestamp()
|
||||
before_transition = datetime(2024, 3, 10, 1, 0, 0, tzinfo=tz).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 = tz.localize(datetime(2024, 3, 10, 3, 30, 0)).timestamp()
|
||||
after_transition = datetime(2024, 3, 10, 3, 30, 0, tzinfo=tz).timestamp()
|
||||
|
||||
with AuthTestClient(self.app) as client:
|
||||
Recordings.insert(
|
||||
@@ -411,7 +410,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
|
||||
@@ -442,8 +441,8 @@ class TestHttpMedia(BaseTestHttp):
|
||||
"""
|
||||
Test recordings summary filtered to a single camera.
|
||||
"""
|
||||
tz = pytz.timezone("America/New_York")
|
||||
march_10_noon = tz.localize(datetime(2024, 3, 10, 12, 0, 0)).timestamp()
|
||||
tz = ZoneInfo("America/New_York")
|
||||
march_10_noon = datetime(2024, 3, 10, 12, 0, 0, tzinfo=tz).timestamp()
|
||||
|
||||
with AuthTestClient(self.app) as client:
|
||||
# Insert recordings for both cameras
|
||||
@@ -2022,3 +2021,79 @@ 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")
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""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
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"""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
|
||||
@@ -183,10 +183,12 @@ class TestReviewMaintainerRemoval(unittest.TestCase):
|
||||
"deleted_cam": [{"model": "gate", "from": "a", "to": "b", "timestamp": 1.0}]
|
||||
}
|
||||
maintainer.forcibly_end_segment = MagicMock()
|
||||
maintainer.frame_manager = MagicMock()
|
||||
|
||||
maintainer._handle_camera_removed("deleted_cam")
|
||||
|
||||
maintainer.forcibly_end_segment.assert_called_once_with("deleted_cam")
|
||||
maintainer.frame_manager.close_camera.assert_called_once_with("deleted_cam")
|
||||
self.assertNotIn("deleted_cam", maintainer.indefinite_events)
|
||||
self.assertNotIn("deleted_cam", maintainer.recent_classification_state_changes)
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"""Tests for per stream recording health tracking in the camera watchdog."""
|
||||
|
||||
import subprocess as sp
|
||||
import unittest
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import MagicMock, call, patch
|
||||
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.const import STREAM_TYPE_MAIN, STREAM_TYPE_SUB
|
||||
@@ -62,6 +63,39 @@ def build_watchdog(
|
||||
return watchdog
|
||||
|
||||
|
||||
class TestCameraWatchdogReset(unittest.TestCase):
|
||||
def _build_resettable_watchdog(self) -> CameraWatchdog:
|
||||
watchdog = build_watchdog()
|
||||
watchdog.ffmpeg_detect_process = MagicMock()
|
||||
watchdog.capture_thread = MagicMock()
|
||||
watchdog.capture_thread.is_alive.return_value = False
|
||||
watchdog.start_ffmpeg_detect = MagicMock()
|
||||
return watchdog
|
||||
|
||||
def test_reset_does_not_buffer_raw_frames(self):
|
||||
watchdog = self._build_resettable_watchdog()
|
||||
process = watchdog.ffmpeg_detect_process
|
||||
|
||||
watchdog.reset_capture_thread()
|
||||
|
||||
process.terminate.assert_called_once()
|
||||
process.wait.assert_called_once_with(timeout=30)
|
||||
process.communicate.assert_not_called()
|
||||
watchdog.start_ffmpeg_detect.assert_called_once()
|
||||
|
||||
def test_timed_out_reset_kills_and_reaps_without_reading_stdout(self):
|
||||
watchdog = self._build_resettable_watchdog()
|
||||
process = watchdog.ffmpeg_detect_process
|
||||
process.wait.side_effect = [sp.TimeoutExpired("ffmpeg", 30), 0]
|
||||
|
||||
watchdog.reset_capture_thread()
|
||||
|
||||
process.kill.assert_called_once()
|
||||
self.assertEqual(process.wait.call_args_list, [call(timeout=30), call()])
|
||||
process.communicate.assert_not_called()
|
||||
watchdog.start_ffmpeg_detect.assert_called_once()
|
||||
|
||||
|
||||
class TestCameraWatchdogStreamHealth(unittest.TestCase):
|
||||
def _build_watchdog(
|
||||
self, sub_enabled: bool = True, output_args: dict | None = None
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Tests for object classification example extraction."""
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import numpy as np
|
||||
|
||||
from frigate.util.classification import _extract_event_thumbnails
|
||||
|
||||
|
||||
class TestExtractEventThumbnails(unittest.TestCase):
|
||||
def test_falls_back_past_missing_images_and_stops_at_target(self):
|
||||
events = [SimpleNamespace(id=f"event_{i}") for i in range(10)]
|
||||
missing = {"event_0", "event_1", "event_2"}
|
||||
image = np.zeros((32, 32, 3), dtype=np.uint8)
|
||||
|
||||
with (
|
||||
tempfile.TemporaryDirectory() as tmpdir,
|
||||
patch(
|
||||
"frigate.util.classification._load_event_classification_crop",
|
||||
side_effect=lambda event: None if event.id in missing else image,
|
||||
) as load,
|
||||
):
|
||||
paths = _extract_event_thumbnails(events, tmpdir, target_count=4)
|
||||
|
||||
self.assertEqual(len(paths), 4)
|
||||
self.assertEqual(load.call_count, 7)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,193 @@
|
||||
"""Tests for sharing one zmq socket wrapper across threads."""
|
||||
|
||||
import os
|
||||
import random
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
import zmq
|
||||
|
||||
from frigate.comms import config_updater, embeddings_updater, inter_process
|
||||
from frigate.comms.config_updater import ConfigPublisher, ConfigSubscriber
|
||||
from frigate.comms.embeddings_updater import EmbeddingsRequestor
|
||||
from frigate.comms.inter_process import InterProcessRequestor
|
||||
from frigate.comms.webpush import WebPushClient
|
||||
|
||||
THREADS = 8
|
||||
CALLS = 100
|
||||
|
||||
# nothing reads until the end, so the total stays under the default zmq HWM
|
||||
PUBLISH_CALLS = 100
|
||||
|
||||
|
||||
class TestSharedRequestor(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.address = f"ipc://{os.path.join(self.tmp.name, 'comms')}"
|
||||
self.stop = threading.Event()
|
||||
self.context = zmq.Context()
|
||||
self.responder = self.context.socket(zmq.REP)
|
||||
self.responder.bind(self.address)
|
||||
self.responder_thread = threading.Thread(target=self._respond)
|
||||
self.responder_thread.start()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.stop.set()
|
||||
self.responder_thread.join()
|
||||
self.responder.close(linger=0)
|
||||
self.context.destroy(linger=0)
|
||||
self.tmp.cleanup()
|
||||
|
||||
def _respond(self) -> None:
|
||||
while not self.stop.is_set():
|
||||
ready, _, _ = zmq.select([self.responder], [], [], 0.1)
|
||||
|
||||
if ready:
|
||||
self.responder.recv_json()
|
||||
time.sleep(random.uniform(0, 0.002))
|
||||
self.responder.send_json(["ok"])
|
||||
|
||||
def _call_from_threads(self, requestor) -> list:
|
||||
results: list = []
|
||||
|
||||
def call() -> None:
|
||||
for _ in range(CALLS):
|
||||
# jitter lands some calls on the instant another reply arrives
|
||||
time.sleep(random.uniform(0, 0.002))
|
||||
results.append(requestor.send_data("topic", {"key": "value"}))
|
||||
|
||||
threads = [threading.Thread(target=call, daemon=True) for _ in range(THREADS)]
|
||||
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
|
||||
for thread in threads:
|
||||
thread.join(30)
|
||||
|
||||
self.assertFalse(any(thread.is_alive() for thread in threads))
|
||||
return results
|
||||
|
||||
def test_inter_process_requestor_waits_for_overlapping_calls(self) -> None:
|
||||
with patch.object(inter_process, "SOCKET_REP_REQ", self.address):
|
||||
requestor = InterProcessRequestor()
|
||||
|
||||
results = self._call_from_threads(requestor)
|
||||
self.assertEqual(results, [["ok"]] * THREADS * CALLS)
|
||||
requestor.stop()
|
||||
|
||||
def test_embeddings_requestor_survives_overlapping_calls(self) -> None:
|
||||
with patch.object(embeddings_updater, "SOCKET_REP_REQ", self.address):
|
||||
requestor = EmbeddingsRequestor()
|
||||
|
||||
# an overlapping call may fail fast, but the socket has to stay usable
|
||||
results = self._call_from_threads(requestor)
|
||||
self.assertEqual(len(results), THREADS * CALLS)
|
||||
self.assertEqual(requestor.send_data("topic", {}), ["ok"])
|
||||
requestor.stop()
|
||||
|
||||
|
||||
class TestSharedConfigPublisher(unittest.TestCase):
|
||||
def test_frames_stay_paired_across_threads(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
address = f"ipc://{os.path.join(tmp, 'config')}"
|
||||
|
||||
with patch.object(config_updater, "SOCKET_PUB_SUB", address):
|
||||
publisher = ConfigPublisher()
|
||||
subscriber = ConfigSubscriber("config/")
|
||||
|
||||
# a subscription that is still connecting drops messages
|
||||
subscriber.socket.setsockopt(zmq.RCVTIMEO, 5000)
|
||||
|
||||
while True:
|
||||
publisher.publish("config/ready", None)
|
||||
|
||||
if zmq.select([subscriber.socket], [], [], 0.05)[0]:
|
||||
break
|
||||
|
||||
def publish(index: int) -> None:
|
||||
topic = f"config/cameras/camera_{index}"
|
||||
|
||||
for _ in range(PUBLISH_CALLS):
|
||||
publisher.publish(topic, topic)
|
||||
|
||||
threads = [
|
||||
threading.Thread(target=publish, args=(i,)) for i in range(THREADS)
|
||||
]
|
||||
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
|
||||
for thread in threads:
|
||||
thread.join()
|
||||
|
||||
publisher.publish("config/done", None)
|
||||
received = 0
|
||||
|
||||
while True:
|
||||
topic = subscriber.socket.recv_string()
|
||||
payload = subscriber.socket.recv_pyobj()
|
||||
|
||||
if topic == "config/done":
|
||||
break
|
||||
|
||||
if topic != "config/ready":
|
||||
self.assertEqual(topic, payload)
|
||||
received += 1
|
||||
|
||||
self.assertEqual(received, THREADS * PUBLISH_CALLS)
|
||||
subscriber.stop()
|
||||
publisher.stop()
|
||||
|
||||
|
||||
class FakeConfigSubscriber:
|
||||
"""Records whether two threads read at the same time."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.reading = threading.Lock()
|
||||
self.overlapped = False
|
||||
|
||||
def _read(self) -> None:
|
||||
if not self.reading.acquire(blocking=False):
|
||||
self.overlapped = True
|
||||
return
|
||||
|
||||
time.sleep(0.001)
|
||||
self.reading.release()
|
||||
|
||||
def check_for_update(self) -> tuple[None, None]:
|
||||
self._read()
|
||||
return (None, None)
|
||||
|
||||
def check_for_updates(self) -> dict:
|
||||
self._read()
|
||||
return {}
|
||||
|
||||
|
||||
class TestSharedWebPushClient(unittest.TestCase):
|
||||
def test_config_subscribers_read_by_one_thread_at_a_time(self) -> None:
|
||||
client = WebPushClient.__new__(WebPushClient)
|
||||
client.config_lock = threading.Lock()
|
||||
client.global_config_subscriber = FakeConfigSubscriber()
|
||||
client.config_subscriber = FakeConfigSubscriber()
|
||||
|
||||
def publish() -> None:
|
||||
for _ in range(20):
|
||||
client.publish("topic", "payload")
|
||||
|
||||
threads = [threading.Thread(target=publish) for _ in range(THREADS)]
|
||||
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
|
||||
for thread in threads:
|
||||
thread.join()
|
||||
|
||||
self.assertFalse(client.global_config_subscriber.overlapped)
|
||||
self.assertFalse(client.config_subscriber.overlapped)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -75,6 +75,16 @@ class TestConfig(unittest.TestCase):
|
||||
DetectorTypeEnum.cpu
|
||||
)
|
||||
|
||||
def test_trusted_proxies_reject_invalid_networks(self):
|
||||
config = {
|
||||
"auth": {"trusted_proxies": ["172.18.0.0/16", "192.168.1.1/24", "fd00::/8"]}
|
||||
}
|
||||
FrigateConfig(**deep_merge(config, self.minimal))
|
||||
|
||||
config = {"auth": {"trusted_proxies": ["172.18.0.0/33"]}}
|
||||
with self.assertRaises(ValidationError):
|
||||
FrigateConfig(**deep_merge(config, self.minimal))
|
||||
|
||||
@patch("frigate.detectors.detector_config.load_labels")
|
||||
def test_model_custom_path(self, mock_labels):
|
||||
mock_labels.return_value = {}
|
||||
|
||||
@@ -35,15 +35,34 @@ class TestGetOrtSessionOptions(unittest.TestCase):
|
||||
ort.GraphOptimizationLevel.ORT_ENABLE_BASIC,
|
||||
)
|
||||
|
||||
def test_other_models_use_defaults(self):
|
||||
for model_type in [
|
||||
None,
|
||||
EnrichmentModelTypeEnum.paddleocr.value,
|
||||
EnrichmentModelTypeEnum.arcface.value,
|
||||
ModelTypeEnum.rfdetr.value,
|
||||
]:
|
||||
def test_cpu_enrichment_models_disable_spinning(self):
|
||||
for model_type in EnrichmentModelTypeEnum:
|
||||
with self.subTest(model_type=model_type):
|
||||
self.assertIsNone(get_ort_session_options(model_type))
|
||||
options = get_ort_session_options(model_type.value, cpu=True)
|
||||
|
||||
self.assertEqual(
|
||||
options.get_session_config_entry("session.intra_op.allow_spinning"),
|
||||
"0",
|
||||
)
|
||||
|
||||
def test_gpu_enrichment_models_keep_spinning(self):
|
||||
options = get_ort_session_options(EnrichmentModelTypeEnum.arcface.value)
|
||||
|
||||
with self.assertRaises(RuntimeError):
|
||||
options.get_session_config_entry("session.intra_op.allow_spinning")
|
||||
|
||||
def test_arcface_keeps_default_optimization(self):
|
||||
options = get_ort_session_options(EnrichmentModelTypeEnum.arcface.value)
|
||||
|
||||
self.assertEqual(
|
||||
options.graph_optimization_level,
|
||||
ort.GraphOptimizationLevel.ORT_ENABLE_ALL,
|
||||
)
|
||||
|
||||
def test_detector_models_use_defaults(self):
|
||||
for model_type in [None, ModelTypeEnum.rfdetr.value]:
|
||||
with self.subTest(model_type=model_type):
|
||||
self.assertIsNone(get_ort_session_options(model_type, cpu=True))
|
||||
|
||||
|
||||
class TestCudaGraphRunner(unittest.TestCase):
|
||||
|
||||
+36
-16
@@ -7,8 +7,9 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
from pydantic import ValidationError
|
||||
|
||||
from frigate.config import FrigateConfig, env
|
||||
from frigate.config.env import (
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.util import env
|
||||
from frigate.util.env import (
|
||||
FRIGATE_ENV_VARS,
|
||||
validate_env_string,
|
||||
validate_env_vars,
|
||||
@@ -53,14 +54,10 @@ class TestGo2RtcAddStreamSubstitution(unittest.TestCase):
|
||||
def test_mixed_localtime_and_frigate_var(self):
|
||||
"""%{localtime\\:...} alongside {FRIGATE_USER} substitutes only the var."""
|
||||
FRIGATE_ENV_VARS["FRIGATE_USER"] = "admin"
|
||||
src = (
|
||||
"ffmpeg:rtsp://host/s#raw=-vf "
|
||||
"drawtext=text=%{localtime\\:%Y-%m-%d}:user={FRIGATE_USER}"
|
||||
)
|
||||
src = "rtsp://host/s?text=%{localtime\\:%Y-%m-%d}&user={FRIGATE_USER}"
|
||||
self.assertEqual(
|
||||
self._call_route(src),
|
||||
"ffmpeg:rtsp://host/s#raw=-vf "
|
||||
"drawtext=text=%{localtime\\:%Y-%m-%d}:user=admin",
|
||||
"rtsp://host/s?text=%{localtime\\:%Y-%m-%d}&user=admin",
|
||||
)
|
||||
|
||||
def test_unknown_var_falls_back_to_raw_src(self):
|
||||
@@ -250,6 +247,29 @@ class TestEnvVars(unittest.TestCase):
|
||||
result = validate_env_string("{FRIGATE_BROKER}")
|
||||
self.assertEqual(result, "mqtt.local")
|
||||
|
||||
def test_install_ignores_go2rtc_exec_override(self):
|
||||
"""The go2rtc exec override cannot be enabled from the config."""
|
||||
from frigate.util.services import is_go2rtc_arbitrary_exec_allowed
|
||||
|
||||
env_patch = patch.dict(os.environ)
|
||||
env_patch.start()
|
||||
self.addCleanup(env_patch.stop)
|
||||
os.environ.pop("GO2RTC_ALLOW_ARBITRARY_EXEC", None)
|
||||
ctx = self._make_context(install=True)
|
||||
with patch("frigate.util.services.os.path.isfile", return_value=False):
|
||||
for key in ("GO2RTC_ALLOW_ARBITRARY_EXEC", "go2rtc_allow_arbitrary_exec"):
|
||||
validate_env_vars({key: "true", "OTHER_VAR": "value"}, ctx)
|
||||
self.assertNotIn(key, os.environ)
|
||||
self.assertFalse(is_go2rtc_arbitrary_exec_allowed())
|
||||
self.assertEqual(os.environ.get("OTHER_VAR"), "value")
|
||||
|
||||
def test_install_keeps_container_go2rtc_exec_override(self):
|
||||
"""An override set in the container environment is left untouched."""
|
||||
ctx = self._make_context(install=True)
|
||||
with patch.dict(os.environ, {"GO2RTC_ALLOW_ARBITRARY_EXEC": "true"}):
|
||||
validate_env_vars({"GO2RTC_ALLOW_ARBITRARY_EXEC": "false"}, ctx)
|
||||
self.assertEqual(os.environ.get("GO2RTC_ALLOW_ARBITRARY_EXEC"), "true")
|
||||
|
||||
|
||||
class TestVariableSources(unittest.TestCase):
|
||||
"""Precedence between the sources that feed FRIGATE_ENV_VARS."""
|
||||
@@ -301,14 +321,14 @@ class TestVariableSources(unittest.TestCase):
|
||||
def test_collision_warns_once_naming_the_winner(self):
|
||||
"""A key from two sources logs one warning naming the source that won."""
|
||||
with patch.dict(env._CONTAINER_ENV, {"FRIGATE_MQTT_HOST": "from_env"}):
|
||||
with self.assertLogs("frigate.config.env", level="WARNING") as logs:
|
||||
with self.assertLogs("frigate.util.env", level="WARNING") as logs:
|
||||
env.apply_config_env_vars({"FRIGATE_MQTT_HOST": "from_config"})
|
||||
self.assertEqual(len(logs.output), 1)
|
||||
self.assertIn("FRIGATE_MQTT_HOST", logs.output[0])
|
||||
self.assertIn("using the value from container environment", logs.output[0])
|
||||
self.assertNotIn("environment_vars", logs.output[0])
|
||||
|
||||
with self.assertNoLogs("frigate.config.env", level="WARNING"):
|
||||
with self.assertNoLogs("frigate.util.env", level="WARNING"):
|
||||
env._rebuild()
|
||||
|
||||
|
||||
@@ -360,7 +380,7 @@ class TestSecretsFile(unittest.TestCase):
|
||||
def test_unprefixed_key_is_ignored_with_a_warning(self):
|
||||
"""Names must start with FRIGATE_, matching the credentials directory."""
|
||||
self._write("cam_pass: hunter2\nFRIGATE_CAM_PASS: hunter2\n")
|
||||
with self.assertLogs("frigate.config.env", level="WARNING") as logs:
|
||||
with self.assertLogs("frigate.util.env", level="WARNING") as logs:
|
||||
values = env._load_secrets_file()
|
||||
self.assertEqual(values, {"FRIGATE_CAM_PASS": "hunter2"})
|
||||
self.assertIn("cam_pass", logs.output[0])
|
||||
@@ -483,10 +503,10 @@ class TestSourceRobustness(unittest.TestCase):
|
||||
env.reload_sources()
|
||||
env._CONFIG_ENV_VARS["FRIGATE_DUPE"] = "from_config"
|
||||
|
||||
with self.assertNoLogs("frigate.config.env", level="WARNING"):
|
||||
with self.assertNoLogs("frigate.util.env", level="WARNING"):
|
||||
env._rebuild(warn=False)
|
||||
|
||||
with self.assertLogs("frigate.config.env", level="WARNING") as logs:
|
||||
with self.assertLogs("frigate.util.env", level="WARNING") as logs:
|
||||
env._rebuild()
|
||||
self.assertIn("FRIGATE_DUPE", logs.output[0])
|
||||
|
||||
@@ -496,7 +516,7 @@ class TestSourceRobustness(unittest.TestCase):
|
||||
env.reload_sources()
|
||||
|
||||
self._write_secrets("FRIGATE_CAMS:\n alley: hunter2\n")
|
||||
with self.assertLogs("frigate.config.env", level="ERROR") as logs:
|
||||
with self.assertLogs("frigate.util.env", level="ERROR") as logs:
|
||||
env.reload_sources()
|
||||
|
||||
self.assertIn("FRIGATE_CAMS", logs.output[0])
|
||||
@@ -505,7 +525,7 @@ class TestSourceRobustness(unittest.TestCase):
|
||||
def test_duplicate_key_error_does_not_log_the_values(self):
|
||||
"""ruamel's duplicate key message quotes both values; the log must not."""
|
||||
self._write_secrets("FRIGATE_CAM_PASS: hunter2\nFRIGATE_CAM_PASS: hunter3\n")
|
||||
with self.assertLogs("frigate.config.env", level="ERROR") as logs:
|
||||
with self.assertLogs("frigate.util.env", level="ERROR") as logs:
|
||||
env.reload_sources()
|
||||
|
||||
self.assertNotIn("hunter2", logs.output[0])
|
||||
@@ -535,7 +555,7 @@ class TestSourceRobustness(unittest.TestCase):
|
||||
with open(os.path.join(self._creds.name, "FRIGATE_CRED"), "w") as f:
|
||||
f.write("from_creds\n")
|
||||
|
||||
with self.assertLogs("frigate.config.env", level="WARNING") as logs:
|
||||
with self.assertLogs("frigate.util.env", level="WARNING") as logs:
|
||||
env.reload_sources()
|
||||
|
||||
self.assertIn("FRIGATE_NOT_A_FILE", logs.output[0])
|
||||
|
||||
@@ -653,6 +653,17 @@ class TestStagedFileCleanup(unittest.TestCase):
|
||||
|
||||
self.assertEqual(os.listdir(tmpdir), [])
|
||||
|
||||
def test_unwritable_staging_directory_fails_the_run(self) -> None:
|
||||
"""A raise here would skip the failed-export cleanup."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
exporter = self._exporter(tmpdir)
|
||||
runs = [StreamRun("main", 1_000, 1_020, "/m1.mp4")]
|
||||
|
||||
with patch(
|
||||
"frigate.record.export.os.makedirs", side_effect=OSError("no space")
|
||||
):
|
||||
self.assertFalse(exporter._stage_stream_runs(runs, {"h264"}, False))
|
||||
|
||||
|
||||
class TestStagingFailure(unittest.TestCase):
|
||||
def test_failed_staging_aborts_rather_than_falling_back(self) -> None:
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import unittest
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from frigate.util.image import get_histogram
|
||||
|
||||
|
||||
def reference_histogram(image, x_min, y_min, x_max, y_max):
|
||||
bgr = cv2.cvtColor(image, cv2.COLOR_YUV2BGR_I420)[y_min:y_max, x_min:x_max]
|
||||
hist = cv2.calcHist([bgr], [0, 1, 2], None, [8, 8, 8], [0, 256, 0, 256, 0, 256])
|
||||
return cv2.normalize(hist, hist).flatten()
|
||||
|
||||
|
||||
class TestGetHistogram(unittest.TestCase):
|
||||
def setUp(self):
|
||||
rng = np.random.default_rng(0)
|
||||
self.frame = rng.integers(0, 255, (720 * 3 // 2, 1280), np.uint8)
|
||||
|
||||
def test_matches_full_frame_conversion_on_even_box(self):
|
||||
box = (100, 200, 400, 600)
|
||||
np.testing.assert_array_equal(
|
||||
get_histogram(self.frame, *box), reference_histogram(self.frame, *box)
|
||||
)
|
||||
|
||||
def test_odd_box_widens_to_even_edges(self):
|
||||
np.testing.assert_array_equal(
|
||||
get_histogram(self.frame, 101, 201, 399, 599),
|
||||
reference_histogram(self.frame, 100, 200, 400, 600),
|
||||
)
|
||||
|
||||
def test_box_is_clamped_to_frame(self):
|
||||
np.testing.assert_array_equal(
|
||||
get_histogram(self.frame, -10, -10, 5000, 5000),
|
||||
reference_histogram(self.frame, 0, 0, 1280, 720),
|
||||
)
|
||||
|
||||
def test_empty_box_returns_zeros(self):
|
||||
hist = get_histogram(self.frame, 50, 50, 50, 50)
|
||||
self.assertEqual(hist.shape, (512,))
|
||||
self.assertEqual(hist.sum(), 0)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user