Compare commits

..
Author SHA1 Message Date
Josh Hawkins c89e77b332 stage main+sub exports on disk instead of /tmp/cache
A main+sub export stages a full copy of itself before the final file is written. That copy went to /tmp/cache, so a long export outgrew the tmpfs and failed with no space left on device. Staged runs are now written to the exports directory, and startup removes any left behind by a killed export.
2026-10-09 06:45:57 -05:00
Josh Hawkins 71445231f6 don't treat unknown audio as an audio-bearing stream
A recording row with NULL has_audio (ffprobe failed and the cv2 fallback can't report audio) marked the whole stream as audio-bearing, so every confirmed video-only row on it was dropped as a glitch. A stream now counts as audio-bearing only when a row is known to carry audio.
2026-10-09 06:37:21 -05:00
Josh Hawkins d4a7504d67 match index-less device strings to hardware units in the detection models picker
A config with `edgetpu:usb` was reported as hardware that wasn't found, because the picker compared device strings exactly and the probe reports the unit as `edgetpu:usb:0`. A device without an index now resolves to the first unit of that kind, so the hardware dropdown, the unit checkboxes, and the model summary all recognize it.
2026-10-07 08:39:28 -05:00
Josh Hawkins 53b0dff949 add docs note about privacy masks 2026-10-06 15:42:49 -05:00
Josh Hawkins 6eebd4234c add notice and system ui message when a camera isn't using go2rtc 2026-10-06 15:29:16 -05:00
Josh Hawkins 945de037a5 fix classification wizard finding no sample images on large databases
The wizard grouped tracked objects by camera and 6 hour block, oldest first, then kept the first 100. With more than 100 groups that was always the oldest objects, which often have no snapshot or thumbnail left on disk, so no examples were generated. Shuffle the selection before truncating, and keep extracting from the remaining tracked objects until 100 usable images are found.
2026-10-06 15:09:51 -05:00
79 changed files with 1555 additions and 2161 deletions
+4 -4
View File
@@ -34,15 +34,15 @@ runs:
run: sudo systemctl start docker run: sudo systemctl start docker
shell: bash shell: bash
- id: lowercaseRepo - id: lowercaseRepo
uses: ASzc/change-string-case-action@v8 uses: ASzc/change-string-case-action@v5
with: with:
string: ${{ github.repository }} string: ${{ github.repository }}
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v4 uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4 uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry - name: Log in to the Container registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f uses: docker/login-action@465a07811f14bebb1938fbed4728c6a1ff8901fc
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
+22 -139
View File
@@ -1,157 +1,40 @@
version: 2 version: 2
updates: updates:
- package-ecosystem: "github-actions" - package-ecosystem: "github-actions"
# Local composite actions are only scanned when their directory is listed directory: "/"
directories:
- "/"
- "/.github/actions/setup"
schedule: schedule:
interval: weekly interval: daily
cooldown: open-pull-requests-limit: 10
default-days: 3 target-branch: dev
open-pull-requests-limit: 5 - package-ecosystem: "docker"
groups: directory: "/docker"
actions: schedule:
patterns: ["*"] interval: daily
actions-security: open-pull-requests-limit: 10
applies-to: security-updates target-branch: dev
patterns: ["*"]
- package-ecosystem: "pip" - package-ecosystem: "pip"
directory: "/docker/main" directory: "/docker/main"
schedule: schedule:
interval: weekly interval: daily
cooldown: open-pull-requests-limit: 10
default-days: 3 target-branch: dev
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" - package-ecosystem: "pip"
directory: "/docker/tensorrt" directory: "/docker/tensorrt"
schedule: schedule:
interval: weekly interval: daily
# No pull request check builds these images, so version updates are off. open-pull-requests-limit: 10
# Security updates still open. target-branch: dev
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" - package-ecosystem: "npm"
directory: "/web" directory: "/web"
schedule: schedule:
interval: weekly interval: daily
cooldown: open-pull-requests-limit: 10
default-days: 3 target-branch: dev
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" - package-ecosystem: "npm"
directory: "/docs" directory: "/docs"
schedule: schedule:
interval: weekly interval: daily
cooldown:
default-days: 3
open-pull-requests-limit: 5
allow: allow:
- dependency-name: "@docusaurus/*" - dependency-name: "@docusaurus/*"
groups: open-pull-requests-limit: 10
docusaurus: target-branch: dev
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"]
+1 -1
View File
@@ -586,7 +586,7 @@ jobs:
- arm64_build - arm64_build
steps: steps:
- id: lowercaseRepo - id: lowercaseRepo
uses: ASzc/change-string-case-action@v8 uses: ASzc/change-string-case-action@v6
with: with:
string: ${{ github.repository }} string: ${{ github.repository }}
- name: Log in to the Container registry - name: Log in to the Container registry
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
- id: lowercaseRepo - id: lowercaseRepo
uses: ASzc/change-string-case-action@v8 uses: ASzc/change-string-case-action@v6
with: with:
string: ${{ github.repository }} string: ${{ github.repository }}
- name: Log in to the Container registry - name: Log in to the Container registry
+1 -1
View File
@@ -1,4 +1,4 @@
ruff == 0.16.10 ruff == 0.15.20
# types # types
types-peewee == 4.0.* types-peewee == 4.0.*
+31 -31
View File
@@ -1,66 +1,66 @@
aiofiles == 25.1.* aiofiles == 25.1.*
click == 8.5.* click == 8.5.*
# FastAPI # FastAPI
aiohttp == 3.14.* aiohttp == 3.12.*
starlette == 1.7.* starlette == 0.47.*
starlette-context == 0.5.* starlette-context == 0.5.*
fastapi[standard-no-fastapi-cloud-cli] == 0.142.* fastapi[standard-no-fastapi-cloud-cli] == 0.116.*
uvicorn == 0.54.* uvicorn == 0.52.*
slowapi == 0.1.* slowapi == 0.1.*
joserfc == 1.7.* joserfc == 1.6.*
cryptography == 50.0.* cryptography == 46.0.*
pathvalidate == 3.3.* pathvalidate == 3.3.*
markupsafe == 3.0.* markupsafe == 3.0.*
python-multipart == 0.0.32 python-multipart == 0.0.31
# Classification Model Training # Classification Model Training
tensorflow == 2.19.* ; platform_machine == 'aarch64' tensorflow == 2.19.* ; platform_machine == 'aarch64'
tensorflow-cpu == 2.21.* ; platform_machine == 'x86_64' tensorflow-cpu == 2.19.* ; platform_machine == 'x86_64'
# General # General
mypy == 2.4.0 mypy == 1.6.1
onvif-zeep-async == 4.3.* onvif-zeep-async == 4.0.*
paho-mqtt == 2.1.* paho-mqtt == 2.1.*
pandas == 3.0.* pandas == 2.2.*
peewee == 4.5.* peewee == 3.17.*
peewee_migrate == 2.3.* peewee_migrate == 1.14.*
psutil == 7.2.* psutil == 7.1.*
pydantic == 2.13.* pydantic == 2.10.*
git+https://github.com/fbcotter/py3nvml#egg=py3nvml git+https://github.com/fbcotter/py3nvml#egg=py3nvml
pytz == 2026.* pytz == 2025.*
pyzmq == 27.2.* pyzmq == 27.1.*
ruamel.yaml == 0.19.* ruamel.yaml == 0.19.*
tzlocal == 5.4.4 tzlocal == 5.2
requests == 2.34.* requests == 2.33.*
types-requests == 2.33.* types-requests == 2.32.*
norfair == 2.3.* norfair == 2.3.*
setproctitle == 1.3.* setproctitle == 1.3.*
ws4py == 0.6.* ws4py == 0.5.*
unidecode == 1.4.* unidecode == 1.4.*
titlecase == 2.4.* titlecase == 2.4.*
# Image Manipulation # Image Manipulation
numpy == 1.26.* numpy == 1.26.*
opencv-python-headless == 4.11.0.* opencv-python-headless == 4.11.0.*
opencv-contrib-python == 4.11.0.* opencv-contrib-python == 4.11.0.*
scipy == 1.17.* scipy == 1.16.*
# OpenVino & ONNX # OpenVino & ONNX
openvino == 2025.4.* openvino == 2025.4.*
onnxruntime == 1.30.* onnxruntime == 1.30.*
# Embeddings # Embeddings
transformers == 5.19.* transformers == 4.45.*
# Generative AI # Generative AI
google-genai == 2.29.* google-genai == 1.58.*
ollama == 0.6.* ollama == 0.6.*
openai == 3.26.* openai == 1.65.*
# push notifications # push notifications
py-vapid == 1.9.4 py-vapid == 1.9.4
pywebpush == 2.5.* pywebpush == 2.0.*
# alpr # alpr
pyclipper == 1.4.* pyclipper == 1.4.*
shapely == 2.2.* shapely == 2.0.*
rapidfuzz==3.14.* rapidfuzz==3.12.*
# HailoRT # HailoRT
argcomplete==3.7.* argcomplete==3.7.*
contextlib2==21.6.* contextlib2==0.6.*
future==1.0.* future==0.18.*
netaddr==1.3.* netaddr==1.3.*
netifaces==0.10.* netifaces==0.10.*
prometheus-client == 0.26.* prometheus-client == 0.26.*
@@ -71,6 +71,6 @@ tflite_runtime @ https://github.com/feranick/TFlite-builds/releases/download/v2.
sherpa-onnx==1.13.* sherpa-onnx==1.13.*
faster-whisper==1.2.* faster-whisper==1.2.*
librosa==0.11.* librosa==0.11.*
soundfile==0.14.* soundfile==0.13.*
# Memory profiling # Memory profiling
memray == 1.20.* memray == 1.20.*
+1 -1
View File
@@ -1 +1 @@
scikit-build == 0.19.* scikit-build == 0.18.*
+1 -1
View File
@@ -14,4 +14,4 @@ nvidia-nccl-cu12==2.26.2.post1; platform_machine == 'x86_64'
nvidia-nvjitlink-cu12==12.8.93; platform_machine == 'x86_64' nvidia-nvjitlink-cu12==12.8.93; platform_machine == 'x86_64'
onnx==1.16.*; platform_machine == 'x86_64' onnx==1.16.*; platform_machine == 'x86_64'
onnxruntime-gpu==1.24.*; platform_machine == 'x86_64' onnxruntime-gpu==1.24.*; platform_machine == 'x86_64'
protobuf==7.36.*; platform_machine == 'x86_64' protobuf==3.20.3; platform_machine == 'x86_64'
+1 -1
View File
@@ -289,7 +289,7 @@ The only field that is valid at the camera level is `enabled`. In particular `mo
#### GenAI Provider #### GenAI Provider
Frigate can send audio to a GenAI provider for transcription when that provider has the `transcribe` role. This is useful if you already run a GenAI provider, or if you do not have the CPU/GPU headroom for a local whisper model. See [Provider support](/configuration/genai/genai_config#provider-support) for which providers can serve this role. The model must accept audio: either a dedicated ASR model such as Qwen3-ASR, or a general multimodal model that accepts audio. Frigate can send audio to a GenAI provider for transcription when that provider has the `transcribe` role. This is useful if you already run a GenAI provider, or if you do not have the CPU/GPU headroom for a local whisper model. Supported providers are **OpenAI**, **Azure OpenAI**, **Gemini**, and **llama.cpp** with an audio-capable model (a dedicated ASR model such as Qwen3-ASR, or a general multimodal model that accepts audio). Ollama is not supported as it has no audio input.
To use a GenAI provider for audio transcription: To use a GenAI provider for audio transcription:
+3 -3
View File
@@ -169,10 +169,10 @@ The [Add Camera Wizard](cameras.md#adding-a-camera-with-the-add-camera-wizard) i
1. Click **Add Camera** in <NavPath path="Settings > Global configuration > Camera management" />. 1. Click **Add Camera** in <NavPath path="Settings > Global configuration > Camera management" />.
2. Choose **Manual selection** as the stream detection method and select **Reolink** as the camera brand. 2. Choose **Manual selection** as the stream detection method and select **Reolink** as the camera brand.
3. The wizard queries the camera and automatically uses an http-flv stream for cameras 5MP and lower. For higher resolution cameras, it tries the http-flv stream first and falls back to RTSP when the camera does not support it. 3. The wizard queries the camera and automatically uses an http-flv stream for cameras 5MP and lower, or an RTSP stream for higher resolution cameras.
4. The wizard turns on **Use stream compatibility mode** for the http-flv stream it selects. Enable it for any other http-flv stream you add, such as the sub stream. 4. In the validation step, enable **Use stream compatibility mode** for http-flv streams when the wizard recommends it.
If you use the **Probe camera** method instead, the discovered stream URLs will be RTSP. For Reolink cameras 5MP and lower, where http-flv is recommended, the wizard will show a warning in the validation step. If you use the **Probe camera** method instead, the discovered stream URLs will be RTSP. For Reolink cameras where http-flv is recommended, the wizard will show a warning in the validation step.
The wizard covers standard single-camera setups. For two way talk, cameras connected through a Reolink NVR, or audio transcoding for WebRTC live view, configure the camera manually as shown below. The wizard covers standard single-camera setups. For two way talk, cameras connected through a Reolink NVR, or audio transcoding for WebRTC live view, configure the camera manually as shown below.
+4 -25
View File
@@ -43,19 +43,7 @@ genai:
The examples on this page all use `my_provider`, but the name is arbitrary and is only used to reference the provider elsewhere in the config (for example, `semantic_search.model`). The examples on this page all use `my_provider`, but the name is arbitrary and is only used to reference the provider elsewhere in the config (for example, `semantic_search.model`).
Each provider handles one or more **roles**: `chat`, `descriptions`, `embeddings`, and `transcribe`. A provider handles the first three by default; `transcribe` must always be listed explicitly. Each role may be assigned to exactly one provider. Define a single provider if you want it to do everything, or split the roles across several providers using the `roles` option. Not every provider supports every role; see [Provider support](#provider-support). Each provider handles one or more **roles**: `chat`, `descriptions`, `embeddings`, and `transcribe`. A provider handles the first three by default; `transcribe` must always be listed explicitly, and is not available on Ollama, which has no audio input. Each role may be assigned to exactly one provider. Define a single provider if you want it to do everything, or split the roles across several providers using the `roles` option.
### Provider support
| Provider | Descriptions | Chat | Embeddings | Transcription |
| ----------------------------- | :----------: | :--: | :--------: | :-----------: |
| llama.cpp (`llamacpp`) | ✅ | ✅ | ✅ | ✅ |
| Ollama (`ollama`) | ✅ | ✅ | ✅ | ❌ |
| OpenAI (`openai`) | ✅ | ✅ | ❌ | ✅ |
| Azure OpenAI (`azure_openai`) | ✅ | ✅ | ❌ | ✅ |
| Google Gemini (`gemini`) | ✅ | ✅ | ❌ | ✅ |
A ✅ means Frigate can use the provider for that feature. The configured model must also support it: a vision model for descriptions and chat, a multimodal embedding model for embeddings (see [Embedding models](#embedding-models)), and an audio-capable model for transcription. Some features also need extra provider setup, covered in each provider's section below. OpenAI-compatible servers use the `openai` provider, so they follow the OpenAI row.
If the provider you choose requires an API key, you may either directly paste it in your configuration, or store it in an environment variable prefixed with `FRIGATE_`. If the provider you choose requires an API key, you may either directly paste it in your configuration, or store it in an environment variable prefixed with `FRIGATE_`.
@@ -85,10 +73,9 @@ You must use a vision-capable model with Frigate. The following models are recom
The `embeddings` role needs a different kind of model. Text queries are matched against the stored image embeddings, so the model must be trained to place images and text into the same vector space. A chat or description model will still return vectors when asked, but those vectors are not trained for retrieval and text searches will return poor matches with no error to indicate why. The `embeddings` role needs a different kind of model. Text queries are matched against the stored image embeddings, so the model must be trained to place images and text into the same vector space. A chat or description model will still return vectors when asked, but those vectors are not trained for retrieval and text searches will return poor matches with no error to indicate why.
| Model | Notes | | Model | Notes |
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `embeddinggemma-2` | Multimodal embeddings for [Semantic Search](/configuration/semantic_search#genai-provider). Strong semantic search accuracy with efficient inference on a small model. | | `qwen3-vl-embedding` | Multimodal embeddings for [Semantic Search](/configuration/semantic_search#genai-provider). Must be served by llama.cpp started with `--embeddings` and `--mmproj`. |
| `qwen3-vl-embedding` | Multimodal embeddings for [Semantic Search](/configuration/semantic_search#genai-provider). Good performance, large model that requires strong hardware for inference. |
#### Transcription models #### Transcription models
@@ -165,10 +152,6 @@ genai:
Frigate queries the llama.cpp server for the model's context size at startup and logs it along with the other detected capabilities. If `context_size` is set in `provider_options`, that value is always used instead, even when the server reports its own. Frigate queries the llama.cpp server for the model's context size at startup and logs it along with the other detected capabilities. If `context_size` is set in `provider_options`, that value is always used instead, even when the server reports its own.
#### Embeddings
To serve the `embeddings` role for [Semantic Search](/configuration/semantic_search#genai-provider), start the llama.cpp server with `--embeddings`, plus `--mmproj` for image support. See the [llama.cpp server documentation](https://github.com/ggml-org/llama.cpp/blob/master/tools/server/README.md) for details.
### Ollama ### Ollama
[Ollama](https://ollama.com/) allows you to self-host large language models and keep everything running locally. It is highly recommended to host this server on a machine with an Nvidia graphics card, or on a Apple silicon Mac for best performance. [Ollama](https://ollama.com/) allows you to self-host large language models and keep everything running locally. It is highly recommended to host this server on a machine with an Nvidia graphics card, or on a Apple silicon Mac for best performance.
@@ -214,10 +197,6 @@ genai:
</TabItem> </TabItem>
</ConfigTabs> </ConfigTabs>
#### Embeddings
Ollama can serve the `embeddings` role for [Semantic Search](/configuration/semantic_search#genai-provider). Embedding images requires Ollama 0.40.1 or newer and an embedding model with a vision encoder, such as `embeddinggemma-2:440m`. For a saved provider, the UI hides the role unless Ollama reports its model as an embedding model. Use a separate provider entry for the embedding model rather than adding the role to a vision chat model.
### OpenAI-Compatible ### OpenAI-Compatible
Frigate supports any provider that implements the OpenAI API standard. This includes self-hosted solutions like [vLLM](https://docs.vllm.ai/), [LocalAI](https://localai.io/), and other OpenAI-compatible servers. Frigate supports any provider that implements the OpenAI API standard. This includes self-hosted solutions like [vLLM](https://docs.vllm.ai/), [LocalAI](https://localai.io/), and other OpenAI-compatible servers.
+6 -3
View File
@@ -133,12 +133,13 @@ Switching between V1 and V2 requires reindexing your embeddings. The embeddings
### GenAI Provider ### GenAI Provider
Frigate can use a GenAI provider for semantic search embeddings when that provider has the `embeddings` role. See [Provider support](/configuration/genai/genai_config#provider-support) for which providers can serve this role. Frigate can use a GenAI provider for semantic search embeddings when that provider has the `embeddings` role. Currently, only **llama.cpp** supports multimodal embeddings (both text and images).
To use a GenAI provider for semantic search: To use llama.cpp for semantic search:
1. Configure a GenAI provider with `embeddings` in its `roles`, using a multimodal embedding model (both text and images). See [Embedding models](/configuration/genai/genai_config#embedding-models) for recommendations, and your provider's section of the [GenAI docs](/configuration/genai/genai_config) for any extra setup it needs. 1. Configure a GenAI provider with `embeddings` in its `roles`.
2. Set the semantic search model to the GenAI config key (e.g. `default`). 2. Set the semantic search model to the GenAI config key (e.g. `default`).
3. Start the llama.cpp server with `--embeddings` and `--mmproj` for image support.
<ConfigTabs> <ConfigTabs>
<TabItem value="ui"> <TabItem value="ui">
@@ -173,6 +174,8 @@ semantic_search:
</TabItem> </TabItem>
</ConfigTabs> </ConfigTabs>
The llama.cpp server must be started with `--embeddings` for the embeddings API, and a multi-modal embeddings model. See the [llama.cpp server documentation](https://github.com/ggml-org/llama.cpp/blob/master/tools/server/README.md) for details.
:::note :::note
Switching between Jina models and a GenAI provider requires reindexing. Embeddings from different backends are incompatible. Switching between Jina models and a GenAI provider requires reindexing. Embeddings from different backends are incompatible.
+1 -1
View File
@@ -189,7 +189,7 @@ Frigate is able to utilize an Nvidia GPU which supports the 12.x series of CUDA
#### Minimum Hardware Support #### Minimum Hardware Support
12.x series of CUDA libraries are used which have minor version compatibility. The minimum driver version on the host system must be `>=545`. The oldest supported GPU generation for object detection is Pascal (Compute Capability 6.0 or greater). Older generations such as Maxwell are not supported but can still be used by following a [community workaround](https://github.com/blakeblackshear/frigate/discussions/20088). For other GPUs, check the NVIDIA GPU Compute Capability table linked below. 12.x series of CUDA libraries are used which have minor version compatibility. The minimum driver version on the host system must be `>=545`. Also the GPU must support a Compute Capability of `5.0` or greater. This generally correlates to a Maxwell-era GPU or newer, check the NVIDIA GPU Compute Capability table linked below.
Make sure your host system has the [nvidia-container-runtime](https://docs.docker.com/config/containers/resource_constraints/#access-an-nvidia-gpu) installed to pass through the GPU to the container and the host system has a compatible driver installed for your GPU. Make sure your host system has the [nvidia-container-runtime](https://docs.docker.com/config/containers/resource_constraints/#access-an-nvidia-gpu) installed to pass through the GPU to the container and the host system has a compatible driver installed for your GPU.
+8
View File
@@ -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. 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 ### 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. 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.
+15 -24
View File
@@ -7030,9 +7030,9 @@
} }
}, },
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "1.1.21", "version": "1.1.18",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
"integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"balanced-match": "^1.0.0", "balanced-match": "^1.0.0",
@@ -7698,15 +7698,14 @@
} }
}, },
"node_modules/compression": { "node_modules/compression": {
"version": "1.8.2", "version": "1.8.1",
"resolved": "https://registry.npmjs.org/compression/-/compression-1.8.2.tgz", "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz",
"integrity": "sha512-o8vI5RE5A6EVVOd9o41jKp41aJom+QTEO/Bx8MYNjexMo/Bv2WOjUfZr+aL0WnYSgymUy6zeguqLTsIhV0gMvQ==", "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"bytes": "3.1.2", "bytes": "3.1.2",
"compressible": "~2.0.18", "compressible": "~2.0.18",
"debug": "2.6.9", "debug": "2.6.9",
"destroy": "1.2.0",
"negotiator": "~0.6.4", "negotiator": "~0.6.4",
"on-headers": "~1.1.0", "on-headers": "~1.1.0",
"safe-buffer": "5.2.1", "safe-buffer": "5.2.1",
@@ -7714,10 +7713,6 @@
}, },
"engines": { "engines": {
"node": ">= 0.8.0" "node": ">= 0.8.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
} }
}, },
"node_modules/compression/node_modules/bytes": { "node_modules/compression/node_modules/bytes": {
@@ -17453,9 +17448,9 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/proxy-addr": { "node_modules/proxy-addr": {
"version": "2.0.8", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"forwarded": "0.2.0", "forwarded": "0.2.0",
@@ -17463,10 +17458,6 @@
}, },
"engines": { "engines": {
"node": ">= 0.10" "node": ">= 0.10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
} }
}, },
"node_modules/proxy-addr/node_modules/ipaddr.js": { "node_modules/proxy-addr/node_modules/ipaddr.js": {
@@ -19238,9 +19229,9 @@
} }
}, },
"node_modules/shell-quote": { "node_modules/shell-quote": {
"version": "1.12.0", "version": "1.10.0",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.12.0.tgz", "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.10.0.tgz",
"integrity": "sha512-PcByqNyT/38F2kDNi006HAMRJaULuBzq/FOsw3qdZvX/GA9W/jamDaRskgHjubHiftXK5sIFxLNkvrXUwcof6Q==", "integrity": "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
@@ -19522,9 +19513,9 @@
} }
}, },
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.2", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
+5 -31
View File
@@ -1088,7 +1088,6 @@ paths:
application/json: application/json:
schema: schema:
type: object type: object
additionalProperties: true
title: Body title: Body
responses: responses:
'200': '200':
@@ -1132,7 +1131,6 @@ paths:
application/json: application/json:
schema: schema:
type: object type: object
additionalProperties: true
title: Body title: Body
responses: responses:
'200': '200':
@@ -1287,7 +1285,6 @@ paths:
application/json: application/json:
schema: schema:
type: object type: object
additionalProperties: true
title: Body title: Body
responses: responses:
'200': '200':
@@ -1647,7 +1644,6 @@ paths:
application/json: application/json:
schema: schema:
type: object type: object
additionalProperties: true
title: Body title: Body
responses: responses:
'200': '200':
@@ -1695,7 +1691,6 @@ paths:
application/json: application/json:
schema: schema:
type: object type: object
additionalProperties: true
title: Body title: Body
responses: responses:
'200': '200':
@@ -1743,7 +1738,6 @@ paths:
application/json: application/json:
schema: schema:
type: object type: object
additionalProperties: true
title: Body title: Body
responses: responses:
'200': '200':
@@ -1785,7 +1779,6 @@ paths:
application/json: application/json:
schema: schema:
type: object type: object
additionalProperties: true
title: Body title: Body
responses: responses:
'200': '200':
@@ -1869,7 +1862,6 @@ paths:
application/json: application/json:
schema: schema:
type: object type: object
additionalProperties: true
title: Body title: Body
responses: responses:
'200': '200':
@@ -3491,7 +3483,6 @@ paths:
application/json: application/json:
schema: schema:
type: object type: object
additionalProperties: true
title: Body title: Body
responses: responses:
'200': '200':
@@ -5661,7 +5652,6 @@ paths:
application/json: application/json:
schema: schema:
type: object type: object
additionalProperties: true
title: Response Create Trigger Embedding Trigger Embedding Post title: Response Create Trigger Embedding Trigger Embedding Post
'422': '422':
description: Validation Error description: Validation Error
@@ -5710,7 +5700,6 @@ paths:
application/json: application/json:
schema: schema:
type: object type: object
additionalProperties: true
title: Response Update Trigger Embedding Trigger Embedding title: Response Update Trigger Embedding Trigger Embedding
Camera Name Name Put Camera Name Name Put
'422': '422':
@@ -5753,7 +5742,6 @@ paths:
application/json: application/json:
schema: schema:
type: object type: object
additionalProperties: true
title: Response Delete Trigger Embedding Trigger Embedding title: Response Delete Trigger Embedding Trigger Embedding
Camera Name Name Delete Camera Name Name Delete
'422': '422':
@@ -5790,7 +5778,6 @@ paths:
application/json: application/json:
schema: schema:
type: object type: object
additionalProperties: true
title: Response Get Triggers Status Triggers Status Camera Name title: Response Get Triggers Status Triggers Status Camera Name
Get Get
'422': '422':
@@ -7587,7 +7574,6 @@ paths:
type: array type: array
items: items:
type: object type: object
additionalProperties: true
title: Response No Recordings Recordings Unavailable Get title: Response No Recordings Recordings Unavailable Get
'422': '422':
description: Validation Error description: Validation Error
@@ -7791,8 +7777,7 @@ components:
title: Update Topic title: Update Topic
config_data: config_data:
anyOf: anyOf:
- additionalProperties: true - type: object
type: object
- type: 'null' - type: 'null'
title: Config Data title: Config Data
skip_save: skip_save:
@@ -8032,7 +8017,7 @@ components:
properties: properties:
file: file:
type: string type: string
contentMediaType: application/octet-stream format: binary
title: File title: File
type: object type: object
required: required:
@@ -8042,7 +8027,7 @@ components:
properties: properties:
file: file:
type: string type: string
contentMediaType: application/octet-stream format: binary
title: File title: File
type: object type: object
required: required:
@@ -8140,7 +8125,6 @@ components:
tool_calls: tool_calls:
anyOf: anyOf:
- items: - items:
additionalProperties: true
type: object type: object
type: array type: array
- type: 'null' - type: 'null'
@@ -8436,7 +8420,6 @@ components:
- type: 'null' - type: 'null'
title: Model Type title: Model Type
data: data:
additionalProperties: true
type: object type: object
title: Data title: Data
type: object type: object
@@ -8508,8 +8491,7 @@ components:
default: true default: true
draw: draw:
anyOf: anyOf:
- additionalProperties: true - type: object
type: object
- type: 'null' - type: 'null'
title: Draw title: Draw
default: {} default: {}
@@ -8752,8 +8734,7 @@ components:
description: Error message for failed jobs description: Error message for failed jobs
results: results:
anyOf: anyOf:
- additionalProperties: true - type: object
type: object
- type: 'null' - type: 'null'
title: Results title: Results
description: Result metadata for completed jobs description: Result metadata for completed jobs
@@ -9003,7 +8984,6 @@ components:
- type: 'null' - type: 'null'
title: Base Url title: Base Url
provider_options: provider_options:
additionalProperties: true
type: object type: object
title: Provider Options title: Provider Options
type: object type: object
@@ -9544,7 +9524,6 @@ components:
type: string type: string
title: Tool Name title: Tool Name
arguments: arguments:
additionalProperties: true
type: object type: object
title: Arguments title: Arguments
type: object type: object
@@ -9622,11 +9601,6 @@ components:
type: type:
type: string type: string
title: Error Type title: Error Type
input:
title: Input
ctx:
type: object
title: Context
type: object type: object
required: required:
- loc - loc
+1 -1
View File
@@ -702,7 +702,7 @@ def motion_activity(
df = df[df["camera"] != ""] df = df[df["camera"] != ""]
# change types for output # change types for output
df.index = df.index.as_unit("s").astype(int) df.index = df.index.astype(int) // (10**9)
normalized = df.reset_index().to_dict("records") normalized = df.reset_index().to_dict("records")
return JSONResponse(content=normalized) return JSONResponse(content=normalized)
+3 -3
View File
@@ -12,8 +12,8 @@ from pathlib import Path
import psutil import psutil
import uvicorn import uvicorn
from peewee import SqliteDatabase
from peewee_migrate import Router from peewee_migrate import Router
from playhouse.sqlite_ext import SqliteExtDatabase
from frigate.api.auth import hash_password from frigate.api.auth import hash_password
from frigate.api.fastapi_app import create_fastapi_app from frigate.api.fastapi_app import create_fastapi_app
@@ -186,7 +186,7 @@ class FrigateApp:
self.timeline_queue: Queue = mp.Queue() self.timeline_queue: Queue = mp.Queue()
def init_database(self) -> None: def init_database(self) -> None:
def vacuum_db(db: SqliteDatabase) -> None: def vacuum_db(db: SqliteExtDatabase) -> None:
logger.info("Running database vacuum") logger.info("Running database vacuum")
db.execute_sql("VACUUM;") db.execute_sql("VACUUM;")
@@ -197,7 +197,7 @@ class FrigateApp:
logger.error("Unable to write to /config to save DB state") logger.error("Unable to write to /config to save DB state")
# Migrate DB schema # Migrate DB schema
migrate_db = SqliteDatabase(self.config.database.path) migrate_db = SqliteExtDatabase(self.config.database.path)
# Run migrations # Run migrations
del logging.getLogger("peewee_migrate").handlers[:] del logging.getLogger("peewee_migrate").handlers[:]
+6 -6
View File
@@ -54,12 +54,12 @@ class PTZMetrics:
reset: Event reset: Event
def __init__(self) -> None: def __init__(self) -> None:
self.start_time = mp.Value("d", 0) self.start_time = mp.Value("d", 0) # type: ignore[assignment]
self.stop_time = mp.Value("d", 0) self.stop_time = mp.Value("d", 0) # type: ignore[assignment]
self.frame_time = mp.Value("d", 0) self.frame_time = mp.Value("d", 0) # type: ignore[assignment]
self.zoom_level = mp.Value("d", 0) self.zoom_level = mp.Value("d", 0) # type: ignore[assignment]
self.max_zoom = mp.Value("d", 0) self.max_zoom = mp.Value("d", 0) # type: ignore[assignment]
self.min_zoom = mp.Value("d", 0) self.min_zoom = mp.Value("d", 0) # type: ignore[assignment]
self.motor_stopped = mp.Event() self.motor_stopped = mp.Event()
self.reset = mp.Event() self.reset = mp.Event()
+6 -29
View File
@@ -3,7 +3,6 @@ from __future__ import annotations
import logging import logging
import queue import queue
import selectors import selectors
import socket
import threading import threading
import time import time
from collections.abc import Callable from collections.abc import Callable
@@ -58,11 +57,6 @@ class MqttClient(Communicator):
self._next_connect_time = 0.0 self._next_connect_time = 0.0
self._last_on_connect_dispatch = 0.0 self._last_on_connect_dispatch = 0.0
# lets other threads interrupt the worker's socket wait
self._wake_recv, self._wake_send = socket.socketpair()
self._wake_recv.setblocking(False)
self._wake_send.setblocking(False)
def subscribe(self, receiver: Callable) -> None: def subscribe(self, receiver: Callable) -> None:
"""Wrapper for allowing dispatcher to subscribe.""" """Wrapper for allowing dispatcher to subscribe."""
self._dispatcher = receiver self._dispatcher = receiver
@@ -92,7 +86,6 @@ class MqttClient(Communicator):
return return
self._publish_queue.put(QueuedPublish(full_topic, payload, retain)) self._publish_queue.put(QueuedPublish(full_topic, payload, retain))
self._wake_worker()
def stop(self) -> None: def stop(self) -> None:
if self._worker is None: if self._worker is None:
@@ -108,11 +101,9 @@ class MqttClient(Communicator):
publish_done, publish_done,
) )
) )
self._wake_worker()
publish_done.wait(MQTT_SHUTDOWN_FLUSH_TIMEOUT) publish_done.wait(MQTT_SHUTDOWN_FLUSH_TIMEOUT)
self._stop_event.set() self._stop_event.set()
self._wake_worker()
if self.client is not None: if self.client is not None:
try: try:
@@ -378,17 +369,11 @@ class MqttClient(Communicator):
exc_info=True, exc_info=True,
) )
def _wake_worker(self) -> None:
try:
self._wake_send.send(b"\0")
except BlockingIOError:
# the buffer is full, so a wake is already pending
pass
def _loop_client(self, timeout: float) -> int: def _loop_client(self, timeout: float) -> int:
"""Drive Paho without select()'s limit on socket file descriptors.""" """Drive Paho without select()'s limit on socket file descriptors."""
assert self.client is not None
client = self.client client = self.client
if client is None:
return mqtt.MQTT_ERR_NO_CONN
sock = client.socket() sock = client.socket()
if sock is None: if sock is None:
return mqtt.MQTT_ERR_NO_CONN return mqtt.MQTT_ERR_NO_CONN
@@ -400,19 +385,11 @@ class MqttClient(Communicator):
pending = hasattr(sock, "pending") and sock.pending() > 0 pending = hasattr(sock, "pending") and sock.pending() > 0
with selectors.DefaultSelector() as selector: with selectors.DefaultSelector() as selector:
selector.register(sock, events) selector.register(sock, events)
selector.register(self._wake_recv, selectors.EVENT_READ) ready = selector.select(0.0 if pending else timeout)
ready = {
key.fileobj: mask
for key, mask in selector.select(0.0 if pending else timeout)
}
if self._wake_recv in ready: ready_events = 0
try: for _, mask in ready:
self._wake_recv.recv(4096) ready_events |= mask
except BlockingIOError:
pass
ready_events = ready.get(sock, 0)
if pending or ready_events & selectors.EVENT_READ: if pending or ready_events & selectors.EVENT_READ:
result = client.loop_read() result = client.loop_read()
if result != mqtt.MQTT_ERR_SUCCESS or client.socket() is None: if result != mqtt.MQTT_ERR_SUCCESS or client.socket() is None:
+2 -2
View File
@@ -6,7 +6,7 @@ import logging
import os import os
import queue import queue
import threading import threading
from collections.abc import Callable, Iterator from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from multiprocessing.synchronize import Event as MpEvent from multiprocessing.synchronize import Event as MpEvent
from typing import Any from typing import Any
@@ -72,7 +72,7 @@ class WebPushClient(Communicator):
# Pull keys from PEM or generate if they do not exist # Pull keys from PEM or generate if they do not exist
self.vapid = Vapid01.from_file(os.path.join(CONFIG_DIR, "notifications.pem")) self.vapid = Vapid01.from_file(os.path.join(CONFIG_DIR, "notifications.pem"))
users: Iterator[dict[str, Any]] = ( users: list[dict[str, Any]] = (
User.select(User.username, User.notification_tokens).dicts().iterator() User.select(User.username, User.notification_tokens).dicts().iterator()
) )
for user in users: for user in users:
@@ -580,8 +580,8 @@ class LicensePlateProcessingMixin:
boxes = [] boxes = []
scores = [] scores = []
for index in range(len(contours)): for index in range(len(contours)): # type: ignore[arg-type]
contour = contours[index] contour = contours[index] # type: ignore[index]
# get minimum bounding box (rotated rectangle) around the contour and the smallest side length. # get minimum bounding box (rotated rectangle) around the contour and the smallest side length.
points, sside = self._get_min_boxes(contour) points, sside = self._get_min_boxes(contour)
@@ -1222,7 +1222,7 @@ class LicensePlateProcessingMixin:
"""Look for license plates in image.""" """Look for license plates in image."""
self.metrics.alpr_pps.value = self.plates_rec_second.eps() self.metrics.alpr_pps.value = self.plates_rec_second.eps()
self.metrics.yolov9_lpr_pps.value = self.plates_det_second.eps() self.metrics.yolov9_lpr_pps.value = self.plates_det_second.eps()
camera: str = obj_data if dedicated_lpr else obj_data["camera"] # type: ignore[assignment] camera = obj_data if dedicated_lpr else obj_data["camera"]
current_time = int(datetime.datetime.now().timestamp()) current_time = int(datetime.datetime.now().timestamp())
debug_frame_id = int(datetime.datetime.now().timestamp() * 1000) debug_frame_id = int(datetime.datetime.now().timestamp() * 1000)
+1 -1
View File
@@ -365,7 +365,7 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
img = cv2.imread(current_file) img = cv2.imread(current_file)
if img is None: if img is None:
return { return { # type: ignore[unreachable]
"message": "Invalid image file.", "message": "Invalid image file.",
"success": False, "success": False,
} }
+13 -13
View File
@@ -2,10 +2,10 @@
import logging import logging
import os import os
import shutil
import threading import threading
import warnings
from transformers import AutoTokenizer, CLIPImageProcessor from transformers import AutoFeatureExtractor, AutoTokenizer
from transformers.utils.logging import disable_progress_bar from transformers.utils.logging import disable_progress_bar
from frigate.comms.inter_process import InterProcessRequestor from frigate.comms.inter_process import InterProcessRequestor
@@ -21,6 +21,12 @@ from frigate.util.downloader import ModelDownloader
from .base_embedding import BaseEmbedding from .base_embedding import BaseEmbedding
warnings.filterwarnings(
"ignore",
category=FutureWarning,
message="The class CLIPFeatureExtractor is deprecated",
)
# disables the progress bar for downloading tokenizers and feature extractors # disables the progress bar for downloading tokenizers and feature extractors
disable_progress_bar() disable_progress_bar()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -52,13 +58,6 @@ class JinaV1TextEmbedding(BaseEmbedding):
self._lock = threading.Lock() self._lock = threading.Lock()
files_names = list(self.download_urls.keys()) + [self.tokenizer_file] files_names = list(self.download_urls.keys()) + [self.tokenizer_file]
# an interrupted download leaves the hub cache without the saved tokenizer
tokenizer_path = os.path.join(self.download_path, self.tokenizer_file)
if os.path.isdir(tokenizer_path) and not os.path.exists(
os.path.join(tokenizer_path, "tokenizer_config.json")
):
shutil.rmtree(tokenizer_path)
if not all( if not all(
os.path.exists(os.path.join(self.download_path, n)) for n in files_names os.path.exists(os.path.join(self.download_path, n)) for n in files_names
): ):
@@ -93,7 +92,7 @@ class JinaV1TextEmbedding(BaseEmbedding):
tokenizer = AutoTokenizer.from_pretrained( tokenizer = AutoTokenizer.from_pretrained(
self.model_name, self.model_name,
trust_remote_code=False, trust_remote_code=True,
cache_dir=f"{MODEL_CACHE_DIR}/{self.model_name}/tokenizer", cache_dir=f"{MODEL_CACHE_DIR}/{self.model_name}/tokenizer",
clean_up_tokenization_spaces=True, clean_up_tokenization_spaces=True,
) )
@@ -124,8 +123,9 @@ class JinaV1TextEmbedding(BaseEmbedding):
f"{MODEL_CACHE_DIR}/{self.model_name}/tokenizer" f"{MODEL_CACHE_DIR}/{self.model_name}/tokenizer"
) )
self.tokenizer = AutoTokenizer.from_pretrained( self.tokenizer = AutoTokenizer.from_pretrained(
tokenizer_path, self.model_name,
trust_remote_code=False, cache_dir=tokenizer_path,
trust_remote_code=True,
clean_up_tokenization_spaces=True, clean_up_tokenization_spaces=True,
) )
@@ -209,7 +209,7 @@ class JinaV1ImageEmbedding(BaseEmbedding):
if self.downloader: if self.downloader:
self.downloader.wait_for_download() self.downloader.wait_for_download()
self.feature_extractor = CLIPImageProcessor.from_pretrained( self.feature_extractor = AutoFeatureExtractor.from_pretrained(
f"{MODEL_CACHE_DIR}/{self.model_name}", f"{MODEL_CACHE_DIR}/{self.model_name}",
) )
+4 -12
View File
@@ -3,7 +3,6 @@
import io import io
import logging import logging
import os import os
import shutil
import threading import threading
import numpy as np import numpy as np
@@ -61,14 +60,6 @@ class JinaV2Embedding(BaseEmbedding):
# download the model and tokenizer # download the model and tokenizer
files_names = list(self.download_urls.keys()) + [self.tokenizer_file] files_names = list(self.download_urls.keys()) + [self.tokenizer_file]
# an interrupted download leaves the hub cache without the saved tokenizer
tokenizer_path = os.path.join(self.download_path, self.tokenizer_file)
if os.path.isdir(tokenizer_path) and not os.path.exists(
os.path.join(tokenizer_path, "tokenizer_config.json")
):
shutil.rmtree(tokenizer_path)
if not all( if not all(
os.path.exists(os.path.join(self.download_path, n)) for n in files_names os.path.exists(os.path.join(self.download_path, n)) for n in files_names
): ):
@@ -106,7 +97,7 @@ class JinaV2Embedding(BaseEmbedding):
tokenizer = AutoTokenizer.from_pretrained( tokenizer = AutoTokenizer.from_pretrained(
self.model_name, self.model_name,
trust_remote_code=False, trust_remote_code=True,
cache_dir=os.path.join( cache_dir=os.path.join(
MODEL_CACHE_DIR, self.model_name, "tokenizer" MODEL_CACHE_DIR, self.model_name, "tokenizer"
), ),
@@ -138,8 +129,9 @@ class JinaV2Embedding(BaseEmbedding):
f"{MODEL_CACHE_DIR}/{self.model_name}/tokenizer" f"{MODEL_CACHE_DIR}/{self.model_name}/tokenizer"
) )
self.tokenizer = AutoTokenizer.from_pretrained( self.tokenizer = AutoTokenizer.from_pretrained(
tokenizer_path, self.model_name,
trust_remote_code=False, cache_dir=tokenizer_path,
trust_remote_code=True,
clean_up_tokenization_spaces=True, clean_up_tokenization_spaces=True,
) )
+9 -7
View File
@@ -82,7 +82,7 @@ class EventCleanup(threading.Thread):
datetime.datetime.now() - datetime.timedelta(days=expire_days) datetime.datetime.now() - datetime.timedelta(days=expire_days)
).timestamp() ).timestamp()
# grab all events after specific time # grab all events after specific time
expired_events: list[Event] = list( expired_events: list[Event] = (
Event.select( Event.select(
Event.id, Event.id,
Event.camera, Event.camera,
@@ -97,6 +97,7 @@ class EventCleanup(threading.Thread):
.namedtuples() .namedtuples()
.iterator() .iterator()
) )
expired_events = list(expired_events)
logger.debug(f"{len(expired_events)} events can be expired") logger.debug(f"{len(expired_events)} events can be expired")
# delete the media from disk # delete the media from disk
@@ -158,7 +159,7 @@ class EventCleanup(threading.Thread):
datetime.datetime.now() - datetime.timedelta(days=expire_days) datetime.datetime.now() - datetime.timedelta(days=expire_days)
).timestamp() ).timestamp()
# grab all events after specific time # grab all events after specific time
camera_events = ( expired_events = (
Event.select( Event.select(
Event.id, Event.id,
Event.camera, Event.camera,
@@ -177,7 +178,7 @@ class EventCleanup(threading.Thread):
# delete the grabbed clips from disk # delete the grabbed clips from disk
# only snapshots are stored in /clips # only snapshots are stored in /clips
# so no need to delete mp4 files # so no need to delete mp4 files
for event in camera_events: for event in expired_events:
events_to_update.append(str(event.id)) events_to_update.append(str(event.id))
deleted = delete_event_snapshot(event) deleted = delete_event_snapshot(event)
@@ -211,7 +212,7 @@ class EventCleanup(threading.Thread):
datetime.datetime.now() - datetime.timedelta(days=expire_days) datetime.datetime.now() - datetime.timedelta(days=expire_days)
).timestamp() ).timestamp()
# grab all events after specific time # grab all events after specific time
expired_events: list[Event] = list( expired_events: list[Event] = (
Event.select( Event.select(
Event.id, Event.id,
Event.camera, Event.camera,
@@ -224,6 +225,7 @@ class EventCleanup(threading.Thread):
.namedtuples() .namedtuples()
.iterator() .iterator()
) )
expired_events = list(expired_events)
logger.debug(f"{len(expired_events)} events can be expired") logger.debug(f"{len(expired_events)} events can be expired")
# delete the media from disk # delete the media from disk
for expired in expired_events: for expired in expired_events:
@@ -233,7 +235,7 @@ class EventCleanup(threading.Thread):
try: try:
media_path.unlink(missing_ok=True) media_path.unlink(missing_ok=True)
if file_extension == "jpg": if file_extension == "jpg":
media_path = Path( # type: ignore[unreachable] media_path = Path(
f"{os.path.join(CLIPS_DIR, media_name)}-clean.webp" f"{os.path.join(CLIPS_DIR, media_name)}-clean.webp"
) )
media_path.unlink(missing_ok=True) media_path.unlink(missing_ok=True)
@@ -287,7 +289,7 @@ class EventCleanup(threading.Thread):
now - datetime.timedelta(days=camera.record.effective_detection_days) now - datetime.timedelta(days=camera.record.effective_detection_days)
).timestamp() ).timestamp()
# grab all events after specific time # grab all events after specific time
camera_events = ( expired_events = (
Event.select( Event.select(
Event.id, Event.id,
Event.camera, Event.camera,
@@ -314,7 +316,7 @@ class EventCleanup(threading.Thread):
# delete the grabbed clips from disk # delete the grabbed clips from disk
# only snapshots are stored in /clips # only snapshots are stored in /clips
# so no need to delete mp4 files # so no need to delete mp4 files
for event in camera_events: for event in expired_events:
events_to_update.append(event.id) events_to_update.append(event.id)
# update the clips attribute for the db entry # update the clips attribute for the db entry
+1 -1
View File
@@ -94,7 +94,7 @@ class EventProcessor(threading.Thread):
if update == None: if update == None:
continue continue
source_type, event_type, camera, _, event_data = update source_type, event_type, camera, _, event_data = update # type: ignore[misc]
logger.debug( logger.debug(
f"Event received: {source_type} {event_type} {camera} {event_data['id']}" f"Event received: {source_type} {event_type} {camera} {event_data['id']}"
+4 -60
View File
@@ -22,7 +22,6 @@ from frigate.genai.prompts import (
build_review_description_response_format, build_review_description_response_format,
build_review_summary_prompt, build_review_summary_prompt,
) )
from frigate.genai.utils import synthetic_jpeg
from frigate.models import Event from frigate.models import Event
from frigate.util.builtin import has_non_finite_number from frigate.util.builtin import has_non_finite_number
@@ -38,7 +37,7 @@ __all__ = [
"register_genai_provider", "register_genai_provider",
] ]
PROVIDERS: dict[GenAIProviderEnum, type["GenAIClient"]] = {} PROVIDERS = {}
def register_genai_provider(key: GenAIProviderEnum) -> Callable: def register_genai_provider(key: GenAIProviderEnum) -> Callable:
@@ -67,8 +66,6 @@ class GenAIClient:
self.genai_config: GenAIConfig = genai_config self.genai_config: GenAIConfig = genai_config
self.timeout = timeout self.timeout = timeout
self.validate_model = validate_model self.validate_model = validate_model
self._image_token_cache: dict[tuple[int, int], int] = {}
self._text_baseline_tokens: int | None = None
self.provider = self._init_provider() self.provider = self._init_provider()
self._last_init_attempt = time.monotonic() self._last_init_attempt = time.monotonic()
@@ -375,63 +372,10 @@ class GenAIClient:
def estimate_image_tokens(self, width: int, height: int) -> float: def estimate_image_tokens(self, width: int, height: int) -> float:
"""Estimate prompt tokens consumed by a single image of the given dimensions. """Estimate prompt tokens consumed by a single image of the given dimensions.
Providers that implement ``_count_prompt_tokens`` are probed for the Default heuristic: ~1 token per 1250 pixels. Providers that can measure or
model's real cost: the same minimal prompt is counted with and without a know their model's exact image-token cost should override.
synthetic image, and the difference is cached per (width, height) since
image tokenization depends only on the dimensions and the loaded model.
Otherwise, or if probing fails, falls back to ~1 token per 1250 pixels.
""" """
heuristic = (width * height) / 1250 return (width * height) / 1250
if self.provider is None:
return heuristic
cached = self._image_token_cache.get((width, height))
if cached is not None:
return cached
try:
if self._text_baseline_tokens is None:
self._text_baseline_tokens = self._count_prompt_tokens(None)
if self._text_baseline_tokens is None:
return heuristic
with_image = self._count_prompt_tokens(synthetic_jpeg(width, height))
except Exception as e:
logger.debug(
"%s image-token probe failed for %dx%d (%s); using heuristic",
self.__class__.__name__,
width,
height,
e,
)
return heuristic
if with_image is None:
return heuristic
tokens = max(1, with_image - self._text_baseline_tokens)
self._image_token_cache[(width, height)] = tokens
logger.debug(
"%s model '%s' uses ~%d tokens for %dx%d images",
self.__class__.__name__,
self.genai_config.model,
tokens,
width,
height,
)
return tokens
def _count_prompt_tokens(self, image: bytes | None) -> int | None:
"""Prompt tokens the provider reports for a minimal "." request, with
``image`` attached when given.
Return None when the provider cannot report prompt tokens; raise on
request failures. Used by estimate_image_tokens.
"""
return None
def embed( def embed(
self, self,
+89 -18
View File
@@ -1,6 +1,7 @@
"""llama.cpp Provider for Frigate AI.""" """llama.cpp Provider for Frigate AI."""
import base64 import base64
import io
import json import json
import logging import logging
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator
@@ -9,14 +10,11 @@ from typing import Any, cast
import httpx import httpx
import numpy as np import numpy as np
import requests import requests
from PIL import Image
from frigate.config import GenAIProviderEnum from frigate.config import GenAIProviderEnum
from frigate.genai import GenAIClient, register_genai_provider from frigate.genai import GenAIClient, register_genai_provider
from frigate.genai.utils import ( from frigate.genai.utils import interleave_images, parse_tool_calls_from_message
interleave_images,
parse_tool_calls_from_message,
to_jpeg,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -78,6 +76,20 @@ def _parse_launch_arg(args: list[str], flag: str) -> str | None:
return args[idx + 1] return args[idx + 1]
def _to_jpeg(img_bytes: bytes) -> bytes | None:
"""Convert image bytes to JPEG. llama.cpp/STB does not support WebP."""
try:
img = Image.open(io.BytesIO(img_bytes))
if img.mode != "RGB":
img = img.convert("RGB") # type: ignore[assignment]
buf = io.BytesIO()
img.save(buf, format="JPEG", quality=85)
return buf.getvalue()
except Exception as e:
logger.warning("Failed to convert image to JPEG: %s", e)
return None
@register_genai_provider(GenAIProviderEnum.llamacpp) @register_genai_provider(GenAIProviderEnum.llamacpp)
class LlamaCppClient(GenAIClient): class LlamaCppClient(GenAIClient):
"""Generative AI client for Frigate using llama.cpp server.""" """Generative AI client for Frigate using llama.cpp server."""
@@ -89,6 +101,8 @@ class LlamaCppClient(GenAIClient):
_supports_audio: bool _supports_audio: bool
_supports_tools: bool _supports_tools: bool
_supports_reasoning: bool _supports_reasoning: bool
_image_token_cache: dict[tuple[int, int], int]
_text_baseline_tokens: int | None
@property @property
def supports_embeddings(self) -> bool: def supports_embeddings(self) -> bool:
@@ -142,6 +156,8 @@ class LlamaCppClient(GenAIClient):
self._supports_audio = False self._supports_audio = False
self._supports_tools = False self._supports_tools = False
self._supports_reasoning = False self._supports_reasoning = False
self._image_token_cache = {}
self._text_baseline_tokens = None
base_url = ( base_url = (
self.genai_config.base_url.rstrip("/") self.genai_config.base_url.rstrip("/")
@@ -585,23 +601,78 @@ class LlamaCppClient(GenAIClient):
return self._context_size return self._context_size
return 4096 return 4096
def _count_prompt_tokens(self, image: bytes | None) -> int | None: def estimate_image_tokens(self, width: int, height: int) -> float:
"""Probe the llama.cpp server to learn the model's image-token cost at the
requested dimensions.
llama.cpp's image tokenization is a deterministic function of dimensions and
the loaded mmproj, so the result is cached per (width, height) for the
lifetime of the process. Falls back to the base pixel heuristic if the
server is unreachable or the response is malformed.
"""
if self.provider is None:
return super().estimate_image_tokens(width, height)
cached = self._image_token_cache.get((width, height))
if cached is not None:
return cached
try:
baseline = self._probe_baseline_tokens()
with_image = self._probe_image_prompt_tokens(width, height)
tokens = max(1, with_image - baseline)
except Exception as e:
logger.debug(
"llama.cpp image-token probe failed for %dx%d (%s); using heuristic",
width,
height,
e,
)
return super().estimate_image_tokens(width, height)
self._image_token_cache[(width, height)] = tokens
logger.debug(
"llama.cpp model '%s' uses ~%d tokens for %dx%d images",
self.genai_config.model,
tokens,
width,
height,
)
return tokens
def _probe_baseline_tokens(self) -> int:
"""Return prompt_tokens for a minimal text-only request. Cached after first call."""
if self._text_baseline_tokens is not None:
return self._text_baseline_tokens
self._text_baseline_tokens = self._probe_prompt_tokens(
[{"type": "text", "text": "."}]
)
return self._text_baseline_tokens
def _probe_image_prompt_tokens(self, width: int, height: int) -> int:
"""Return prompt_tokens for a single synthetic image plus minimal text."""
img = Image.new("RGB", (width, height), (128, 128, 128))
buf = io.BytesIO()
img.save(buf, format="JPEG", quality=60)
encoded = base64.b64encode(buf.getvalue()).decode("utf-8")
return self._probe_prompt_tokens(
[
{"type": "text", "text": "."},
{
"type": "image_url",
"image_url": {"url": f"data:image/jpeg;base64,{encoded}"},
},
]
)
def _probe_prompt_tokens(self, content: list[dict[str, Any]]) -> int:
"""POST a 1-token chat completion and return reported prompt_tokens. """POST a 1-token chat completion and return reported prompt_tokens.
Uses a generous timeout to absorb a cold model load on the first probe Uses a generous timeout to absorb a cold model load on the first probe
when the server lazily loads models on demand (e.g. llama-swap). when the server lazily loads models on demand (e.g. llama-swap).
""" """
content: list[dict[str, Any]] = [{"type": "text", "text": "."}]
if image is not None:
encoded = base64.b64encode(image).decode("utf-8")
content.append(
{
"type": "image_url",
"image_url": {"url": f"data:image/jpeg;base64,{encoded}"},
}
)
payload = { payload = {
"model": self.genai_config.model, "model": self.genai_config.model,
"messages": [{"role": "user", "content": content}], "messages": [{"role": "user", "content": content}],
@@ -736,7 +807,7 @@ class LlamaCppClient(GenAIClient):
for img in images: for img in images:
# llama.cpp uses STB which does not support WebP; convert to JPEG # llama.cpp uses STB which does not support WebP; convert to JPEG
jpeg_bytes = to_jpeg(img) jpeg_bytes = _to_jpeg(img)
to_encode = jpeg_bytes if jpeg_bytes is not None else img to_encode = jpeg_bytes if jpeg_bytes is not None else img
encoded = base64.b64encode(to_encode).decode("utf-8") encoded = base64.b64encode(to_encode).decode("utf-8")
# The trailing newline keeps tokenization identical to the older # The trailing newline keeps tokenization identical to the older
+9 -117
View File
@@ -7,7 +7,6 @@ import logging
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator
from typing import Any from typing import Any
import numpy as np
from httpx import RemoteProtocolError, TimeoutException from httpx import RemoteProtocolError, TimeoutException
from ollama import AsyncClient as OllamaAsyncClient from ollama import AsyncClient as OllamaAsyncClient
from ollama import Client as ApiClient from ollama import Client as ApiClient
@@ -15,11 +14,7 @@ from ollama import ResponseError
from frigate.config import GenAIProviderEnum from frigate.config import GenAIProviderEnum
from frigate.genai import GenAIClient, register_genai_provider from frigate.genai import GenAIClient, register_genai_provider
from frigate.genai.utils import ( from frigate.genai.utils import interleave_images, parse_tool_calls_from_message
interleave_images,
parse_tool_calls_from_message,
to_jpeg,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -129,39 +124,23 @@ class OllamaClient(GenAIClient):
provider: ApiClient | None provider: ApiClient | None
provider_options: dict[str, Any] provider_options: dict[str, Any]
_capabilities_cache: list[str] | None = None
_supports_thinking_cache: bool | None = None _supports_thinking_cache: bool | None = None
def _model_capabilities(self) -> list[str] | None:
"""Capabilities Ollama reports for the configured model, or None when
they could not be fetched. Only successful lookups are cached."""
if self._capabilities_cache is not None:
return self._capabilities_cache
if self.provider is None:
return None
try:
response = self.provider.show(self.genai_config.model)
except Exception as e:
logger.debug("Failed to query Ollama model capabilities: %s", e)
return None
self._capabilities_cache = list(response.get("capabilities") or [])
return self._capabilities_cache
@property @property
def supports_toggleable_thinking(self) -> bool: def supports_toggleable_thinking(self) -> bool:
if self._supports_thinking_cache is not None: if self._supports_thinking_cache is not None:
return self._supports_thinking_cache return self._supports_thinking_cache
capabilities = self._model_capabilities() if self.provider is None:
if capabilities is None:
return False return False
self._supports_thinking_cache = "thinking" in capabilities try:
response = self.provider.show(self.genai_config.model)
capabilities = response.get("capabilities") or []
self._supports_thinking_cache = "thinking" in capabilities
except Exception as e:
logger.debug("Failed to query Ollama model capabilities: %s", e)
self._supports_thinking_cache = False
return self._supports_thinking_cache return self._supports_thinking_cache
@property
def supports_embeddings(self) -> bool:
"""Whether Ollama reports the configured model as an embedding model."""
return "embedding" in (self._model_capabilities() or [])
def _auth_headers(self) -> dict | None: def _auth_headers(self) -> dict | None:
if self.genai_config.api_key: if self.genai_config.api_key:
return {"Authorization": "Bearer " + self.genai_config.api_key} return {"Authorization": "Bearer " + self.genai_config.api_key}
@@ -343,93 +322,6 @@ class OllamaClient(GenAIClient):
self.genai_config.provider_options.get("options", {}).get("num_ctx", 4096) self.genai_config.provider_options.get("options", {}).get("num_ctx", 4096)
) )
def _count_prompt_tokens(self, image: bytes | None) -> int | None:
"""Send a 1-token chat request and return Ollama's prompt_eval_count.
Reuses the description request options so the probe runs with the same
num_ctx; a different value would make Ollama reload the model.
"""
if self.provider is None:
return None
message: dict[str, Any] = {"role": "user", "content": "."}
if image is not None:
message["images"] = [image]
request_params = self._build_request_params(
[message], None, None, enable_thinking=False
)
request_params["options"] = {
**(request_params.get("options") or {}),
"num_predict": 1,
}
response = self.provider.chat(**request_params)
count = response.get("prompt_eval_count")
return int(count) if count is not None else None
def embed(
self,
texts: list[str] | None = None,
images: list[bytes] | None = None,
) -> list[np.ndarray]:
"""Generate embeddings via Ollama's /api/embed endpoint.
Each text is a plain string in `input` and each image is an
``{"image": <base64>}`` item. Image input requires Ollama 0.40.1 or
newer and a model with a vision encoder (e.g. embeddinggemma-2:440m).
"""
if self.provider is None:
logger.warning(
"Ollama provider has not been initialized. Check your Ollama configuration."
)
return []
texts = texts or []
images = images or []
if not texts and not images:
return []
inputs: list[str | dict[str, str]] = list(texts)
for img in images:
jpeg_bytes = to_jpeg(img)
to_encode = jpeg_bytes if jpeg_bytes is not None else img
inputs.append({"image": base64.b64encode(to_encode).decode("utf-8")})
payload: dict[str, Any] = {"model": self.genai_config.model, "input": inputs}
for key in ("options", "keep_alive"):
if key in self.genai_config.provider_options:
payload[key] = self.genai_config.provider_options[key]
try:
# The ollama SDK's embed() validates input as strings only, so
# image items have to bypass it and post the JSON directly.
response = self.provider._request_raw("POST", "/api/embed", json=payload)
body = response.json()
except (
TimeoutException,
ResponseError,
RemoteProtocolError,
ConnectionError,
ValueError,
) as e:
logger.warning("Ollama embeddings error: %s", str(e))
return []
vectors = body.get("embeddings") if isinstance(body, dict) else None
if not isinstance(vectors, list):
logger.warning("Ollama embeddings returned unexpected format")
return []
if len(vectors) != len(inputs):
logger.warning(
"Ollama returned %d embeddings for %d inputs",
len(vectors),
len(inputs),
)
return [np.asarray(v, dtype=np.float32).flatten() for v in vectors]
def _build_request_params( def _build_request_params(
self, self,
messages: list[dict[str, Any]], messages: list[dict[str, Any]],
-30
View File
@@ -1,42 +1,12 @@
"""Shared helpers for GenAI providers and chat (OpenAI-style messages, tool call parsing).""" """Shared helpers for GenAI providers and chat (OpenAI-style messages, tool call parsing)."""
import io
import json import json
import logging import logging
from typing import Any from typing import Any
from PIL import Image
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def to_jpeg(img_bytes: bytes) -> bytes | None:
"""Convert image bytes to JPEG.
Some provider image decoders (e.g. llama.cpp's STB) do not support WebP,
which is the format Frigate stores thumbnails in.
"""
try:
img = Image.open(io.BytesIO(img_bytes))
if img.mode != "RGB":
img = img.convert("RGB") # type: ignore[assignment]
buf = io.BytesIO()
img.save(buf, format="JPEG", quality=85)
return buf.getvalue()
except Exception as e:
logger.warning("Failed to convert image to JPEG: %s", e)
return None
def synthetic_jpeg(width: int, height: int) -> bytes:
"""A flat gray JPEG of the given dimensions, for measuring image token cost."""
buf = io.BytesIO()
Image.new("RGB", (width, height), (128, 128, 128)).save(
buf, format="JPEG", quality=60
)
return buf.getvalue()
def interleave_images( def interleave_images(
prompt: str, images: list[bytes], captions: list[str] | None = None prompt: str, images: list[bytes], captions: list[str] | None = None
) -> list[str | bytes]: ) -> list[str | bytes]:
+5 -1
View File
@@ -15,7 +15,7 @@ from peewee import DoesNotExist
from frigate.comms.inter_process import InterProcessRequestor from frigate.comms.inter_process import InterProcessRequestor
from frigate.config import FrigateConfig from frigate.config import FrigateConfig
from frigate.config.camera.record import ChaptersEnum from frigate.config.camera.record import ChaptersEnum
from frigate.const import UPDATE_JOB_STATE from frigate.const import EXPORT_DIR, UPDATE_JOB_STATE
from frigate.jobs.job import Job from frigate.jobs.job import Job
from frigate.models import Export from frigate.models import Export
from frigate.record.export import ( from frigate.record.export import (
@@ -415,6 +415,10 @@ def reap_stale_exports() -> None:
this in a try/except. A failure on a single row will not stop the rest 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. 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
for staged in Path(EXPORT_DIR).glob("export_stage_*"):
staged.unlink(missing_ok=True)
try: try:
stale_exports = list(Export.select().where(Export.in_progress == True)) # noqa: E712 stale_exports = list(Export.select().where(Export.in_progress == True)) # noqa: E712
except Exception: except Exception:
-1
View File
@@ -85,7 +85,6 @@ def apply_log_levels(default: str, log_levels: dict[str, LogLevel]) -> None:
log_levels = { log_levels = {
"absl": LogLevel.error, "absl": LogLevel.error,
"httpx": LogLevel.error, "httpx": LogLevel.error,
"httpx2": LogLevel.error,
"h5py": LogLevel.error, "h5py": LogLevel.error,
"keras": LogLevel.error, "keras": LogLevel.error,
"matplotlib": LogLevel.error, "matplotlib": LogLevel.error,
-9
View File
@@ -73,15 +73,6 @@ _KINDS = (
"detect_high_cpu", NoticeSeverity.warning, "camera", link="/system#cameras" "detect_high_cpu", NoticeSeverity.warning, "camera", link="/system#cameras"
), ),
NoticeKind("shm_too_low", NoticeSeverity.warning, "system", link="/system#storage"), NoticeKind("shm_too_low", NoticeSeverity.warning, "system", link="/system#storage"),
# every camera process raises it when the shared queue backs up, so repeats
# wait for the next flush instead of writing once per camera
NoticeKind(
"object_processing_behind",
NoticeSeverity.warning,
"system",
link="/system#cameras",
batch_repeats=True,
),
# one row per user per burst; the login log lines carry the address # one row per user per burst; the login log lines carry the address
NoticeKind( NoticeKind(
"failed_login", "failed_login",
+2 -2
View File
@@ -360,7 +360,7 @@ class ObjectDetectProcess:
# detection_start is set only after detection_queue.get() # detection_start is set only after detection_queue.get()
# returns. If it was reset during the grace period, the process # returns. If it was reset during the grace period, the process
# recovered and may be waiting on the shared queue again. # recovered and may be waiting on the shared queue again.
if self.detection_start.value == 0.0: if self.detection_start.value == 0.0: # type: ignore[attr-defined]
logging.info("Detection process recovered before restart") logging.info("Detection process recovered before restart")
return return
@@ -369,7 +369,7 @@ class ObjectDetectProcess:
self.detect_process.join() self.detect_process.join()
logging.info("Detection process has exited...") logging.info("Detection process has exited...")
self.detection_start.value = 0.0 self.detection_start.value = 0.0 # type: ignore[attr-defined]
# Async path for MemryX # Async path for MemryX
if self.detector_config.type == "memryx": if self.detector_config.type == "memryx":
+2 -8
View File
@@ -30,9 +30,6 @@ from frigate.util.image import (
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Minimum seconds to hold the current camera before switching to a different one
CAMERA_HOLD_SECONDS = 5
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class BirdseyeActivity: class BirdseyeActivity:
@@ -499,11 +496,8 @@ class BirdsEyeFrameManager:
if max_cameras: if max_cameras:
now = datetime.datetime.now().timestamp() now = datetime.datetime.now().timestamp()
if ( if len(active_cameras) == max_cameras and now - self.last_refresh_time < 10:
len(self.active_cameras) == max_cameras # don't refresh cameras too often
and len(active_cameras) >= max_cameras
and now - self.last_refresh_time < CAMERA_HOLD_SECONDS
):
active_cameras = self.active_cameras active_cameras = self.active_cameras
else: else:
limited_active_cameras = sorted( limited_active_cameras = sorted(
+2 -2
View File
@@ -10,7 +10,7 @@ from multiprocessing.synchronize import Event as MpEvent
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from peewee import SqliteDatabase from playhouse.sqlite_ext import SqliteExtDatabase
from frigate.config import CameraConfig, FrigateConfig, RetainModeEnum from frigate.config import CameraConfig, FrigateConfig, RetainModeEnum
from frigate.const import ( from frigate.const import (
@@ -88,7 +88,7 @@ class RecordingCleanup(threading.Thread):
if ( if (
os.stat(f"{self.config.database.path}-wal").st_size / (1024 * 1024) os.stat(f"{self.config.database.path}-wal").st_size / (1024 * 1024)
) > MAX_WAL_SIZE: ) > MAX_WAL_SIZE:
db = SqliteDatabase(self.config.database.path) db = SqliteExtDatabase(self.config.database.path)
db.execute_sql("PRAGMA wal_checkpoint(TRUNCATE);") db.execute_sql("PRAGMA wal_checkpoint(TRUNCATE);")
db.close() db.close()
+2 -2
View File
@@ -16,7 +16,7 @@ from enum import Enum
from pathlib import Path from pathlib import Path
from typing import Any, cast from typing import Any, cast
import pytz import pytz # type: ignore[import-untyped]
from pathvalidate import sanitize_filename from pathvalidate import sanitize_filename
from peewee import DoesNotExist from peewee import DoesNotExist
@@ -559,7 +559,7 @@ class RecordingExporter(threading.Thread):
) )
def _staged_run_path(self, index: int) -> str: 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_DIR, f"export_stage_{self.export_id}_{index}.mp4")
def _probe_stream_resolution(self, run: StreamRun) -> tuple[int, int] | None: def _probe_stream_resolution(self, run: StreamRun) -> tuple[int, int] | None:
"""Probe one recording from a run for its resolution. """Probe one recording from a run for its resolution.
+1 -1
View File
@@ -859,7 +859,7 @@ class RecordingMaintainer(threading.Thread):
for box in motion_boxes: for box in motion_boxes:
if len(box) < 4: if len(box) < 4:
continue # type: ignore[unreachable] continue
x1, y1, x2, y2 = box x1, y1, x2, y2 = box
# Convert pixel coordinates to grid cells # Convert pixel coordinates to grid cells
+2 -2
View File
@@ -6,8 +6,8 @@ from unittest.mock import patch
from fastapi import Request from fastapi import Request
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from peewee import SqliteDatabase
from peewee_migrate import Router from peewee_migrate import Router
from playhouse.sqlite_ext import SqliteExtDatabase
from playhouse.sqliteq import SqliteQueueDatabase from playhouse.sqliteq import SqliteQueueDatabase
from pydantic import Json from pydantic import Json
@@ -38,7 +38,7 @@ class AuthTestClient(TestClient):
class BaseTestHttp(unittest.TestCase): class BaseTestHttp(unittest.TestCase):
def setUp(self, models): def setUp(self, models):
# setup clean database for each test run # setup clean database for each test run
migrate_db = SqliteDatabase("test.db") migrate_db = SqliteExtDatabase("test.db")
del logging.getLogger("peewee_migrate").handlers[:] del logging.getLogger("peewee_migrate").handlers[:]
router = Router(migrate_db) router = Router(migrate_db)
router.run() router.run()
+15
View File
@@ -453,6 +453,21 @@ class TestHttpExport(BaseTestHttp):
assert unchanged.name == "front door export" assert unchanged.name == "front door export"
assert unchanged.video_path == video assert unchanged.video_path == video
def test_reap_stale_exports_removes_staged_runs(self):
with tempfile.TemporaryDirectory() as tmpdir:
staged = os.path.join(tmpdir, "export_stage_front_door_abc_0.mp4")
finished = os.path.join(tmpdir, "front_door_export.mp4")
for path in (staged, finished):
with open(path, "w") as handle:
handle.write("video")
with patch("frigate.jobs.export.EXPORT_DIR", tmpdir):
reap_stale_exports()
assert not os.path.exists(staged)
assert os.path.exists(finished)
def test_reap_stale_exports_deletes_rows_with_no_file(self): def test_reap_stale_exports_deletes_rows_with_no_file(self):
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
stale_video = os.path.join(tmpdir, "stale.mp4") stale_video = os.path.join(tmpdir, "stale.mp4")
-74
View File
@@ -500,80 +500,6 @@ class TestBirdseyeLiveActivity(unittest.TestCase):
assert self.manager.active_cameras == {"front"} assert self.manager.active_cameras == {"front"}
class TestBirdseyeCameraHold(unittest.TestCase):
"""Test that CAMERA_HOLD_SECONDS prevents rapid camera switching."""
def setUp(self):
config = {
"mqtt": {"enabled": False},
"birdseye": {
"enabled": True,
"modes": ["motion"],
"inactivity_threshold": 30,
},
"cameras": {
camera: {
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
]
},
"detect": {"height": 1080, "width": 1920, "fps": 5},
}
for camera in ("back", "front")
},
}
self.config = FrigateConfig(**config)
self.manager = BirdsEyeFrameManager(self.config, mp.Event())
for camera_data in self.manager.cameras.values():
camera_data["current_frame"] = None
camera_data["current_frame_time"] = 100.0
camera_data["last_active_frame"] = 0.0
camera_data["live_active"] = False
def test_max_cameras_cooldown_applies_when_more_active_than_max(self):
"""The max_cameras cooldown should apply even when more cameras are active than max."""
self.config.birdseye.layout.max_cameras = 1
self.manager.cameras["front"]["last_active_frame"] = 95.0
self.manager.cameras["front"]["current_frame_time"] = 100.0
self.manager.update_frame()
assert "front" in self.manager.active_cameras
self.manager.cameras["back"]["last_active_frame"] = 99.0
self.manager.cameras["back"]["current_frame_time"] = 100.0
self.manager.update_frame()
assert "front" in self.manager.active_cameras
def test_camera_count_change_ignores_hold(self):
"""Adding a camera (count change) should not be blocked by the hold period."""
self.manager.cameras["front"]["last_active_frame"] = 95.0
self.manager.update_frame()
assert self.manager.active_cameras == {"front"}
self.manager.cameras["back"]["last_active_frame"] = 99.0
self.manager.cameras["back"]["current_frame_time"] = 100.0
self.manager.cameras["front"]["last_active_frame"] = 95.0
self.manager.update_frame()
assert self.manager.active_cameras == {"front", "back"}
def test_max_cameras_count_increase_not_blocked_by_hold(self):
"""With max_cameras=2 showing 1, a second active camera must appear immediately."""
self.config.birdseye.layout.max_cameras = 2
self.manager.cameras["front"]["last_active_frame"] = 95.0
self.manager.cameras["front"]["current_frame_time"] = 100.0
self.manager.update_frame()
assert self.manager.active_cameras == {"front"}
self.manager.cameras["back"]["last_active_frame"] = 99.0
self.manager.cameras["back"]["current_frame_time"] = 100.0
self.manager.update_frame()
assert self.manager.active_cameras == {"front", "back"}
class TestBirdseyeModePayload(unittest.TestCase): class TestBirdseyeModePayload(unittest.TestCase):
"""Test the MQTT payload contract for Birdseye activity modes.""" """Test the MQTT payload contract for Birdseye activity modes."""
@@ -7,7 +7,7 @@ import unittest
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import MagicMock from unittest.mock import MagicMock
from peewee import SqliteDatabase from playhouse.sqlite_ext import SqliteExtDatabase
from frigate.api.chat import ( from frigate.api.chat import (
_execute_find_similar_objects, _execute_find_similar_objects,
@@ -98,7 +98,7 @@ class TestExecuteFindSimilarObjects(unittest.TestCase):
def setUp(self): def setUp(self):
self.tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False) self.tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
self.tmp.close() self.tmp.close()
self.db = SqliteDatabase(self.tmp.name) self.db = SqliteExtDatabase(self.tmp.name)
Event.bind(self.db, bind_refs=False, bind_backrefs=False) Event.bind(self.db, bind_refs=False, bind_backrefs=False)
self.db.connect() self.db.connect()
self.db.create_tables([Event]) self.db.create_tables([Event])
+2 -2
View File
@@ -12,7 +12,7 @@ from unittest.mock import AsyncMock, patch
import cv2 import cv2
import numpy as np import numpy as np
from peewee import SqliteDatabase from playhouse.sqlite_ext import SqliteExtDatabase
from frigate.api.chat import ( from frigate.api.chat import (
TOOL_REJECTED_RESULT, TOOL_REJECTED_RESULT,
@@ -214,7 +214,7 @@ class DatabaseTestCase(unittest.TestCase):
def setUp(self): def setUp(self):
self.tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False) self.tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
self.tmp.close() self.tmp.close()
self.db = SqliteDatabase(self.tmp.name) self.db = SqliteExtDatabase(self.tmp.name)
for model in self.models: for model in self.models:
model.bind(self.db, bind_refs=False, bind_backrefs=False) model.bind(self.db, bind_refs=False, bind_backrefs=False)
self.db.connect() self.db.connect()
@@ -3,7 +3,7 @@
import unittest import unittest
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from peewee import SqliteDatabase from playhouse.sqlite_ext import SqliteExtDatabase
from frigate.comms.dispatcher import Dispatcher from frigate.comms.dispatcher import Dispatcher
from frigate.const import INSERT_MANY_RECORDINGS from frigate.const import INSERT_MANY_RECORDINGS
@@ -30,7 +30,7 @@ class TestInsertManyRecordings(unittest.TestCase):
"""A duplicate path must not cost the rest of the batch.""" """A duplicate path must not cost the rest of the batch."""
def setUp(self): def setUp(self):
self.db = SqliteDatabase(":memory:") self.db = SqliteExtDatabase(":memory:")
self.db.bind([Recordings]) self.db.bind([Recordings])
self.db.create_tables([Recordings]) self.db.create_tables([Recordings])
-103
View File
@@ -1,103 +0,0 @@
"""Tests for the notice raised when object processing drops camera frames."""
import threading
import unittest
from unittest.mock import patch
from frigate.const import REPLAY_CAMERA_PREFIX
from frigate.notices.types import NOTICE_KINDS
from frigate.video.detect import (
DROPPED_FRAMES_NOTICE_COUNT,
DROPPED_FRAMES_NOTICE_INTERVAL_S,
DROPPED_FRAMES_WINDOW_S,
DroppedFrameTracker,
)
class TestDroppedFrameTracker(unittest.TestCase):
def setUp(self):
raise_patch = patch("frigate.video.detect.raise_notice")
self.raise_notice = raise_patch.start()
self.addCleanup(raise_patch.stop)
def _drop(self, tracker: DroppedFrameTracker, count: int, start: float) -> None:
for i in range(count):
tracker.dropped(start + i * 0.2)
# the notice is sent on a background thread
if tracker._sender is not None:
tracker._sender.join(timeout=5)
def test_kind_is_registered(self):
self.assertIn("object_processing_behind", NOTICE_KINDS)
def test_a_few_drops_raise_nothing(self):
tracker = DroppedFrameTracker("front_door")
self._drop(tracker, DROPPED_FRAMES_NOTICE_COUNT - 1, 0.0)
self.raise_notice.assert_not_called()
def test_enough_drops_raise_the_notice(self):
tracker = DroppedFrameTracker("front_door")
self._drop(tracker, DROPPED_FRAMES_NOTICE_COUNT, 0.0)
self.raise_notice.assert_called_once_with("object_processing_behind")
def test_drops_outside_the_window_do_not_add_up(self):
tracker = DroppedFrameTracker("front_door")
for i in range(DROPPED_FRAMES_NOTICE_COUNT):
tracker.dropped(i * (DROPPED_FRAMES_WINDOW_S + 1.0))
self.raise_notice.assert_not_called()
def test_repeats_wait_for_the_interval(self):
tracker = DroppedFrameTracker("front_door")
self._drop(tracker, DROPPED_FRAMES_NOTICE_COUNT, 0.0)
self._drop(tracker, DROPPED_FRAMES_NOTICE_COUNT * 2, 5.0)
self.assertEqual(self.raise_notice.call_count, 1)
self._drop(
tracker, DROPPED_FRAMES_NOTICE_COUNT, DROPPED_FRAMES_NOTICE_INTERVAL_S
)
self.assertEqual(self.raise_notice.call_count, 2)
def test_a_pending_send_does_not_block_or_start_another(self):
sending = threading.Event()
release = threading.Event()
def wait_for_reply(*_) -> None:
sending.set()
release.wait(5)
self.raise_notice.side_effect = wait_for_reply
tracker = DroppedFrameTracker("front_door")
# the first notice gets no reply while the second burst arrives
for i in range(DROPPED_FRAMES_NOTICE_COUNT):
tracker.dropped(i * 0.2)
self.assertTrue(sending.wait(5))
for i in range(DROPPED_FRAMES_NOTICE_COUNT):
tracker.dropped(DROPPED_FRAMES_NOTICE_INTERVAL_S + i * 0.2)
self.assertEqual(self.raise_notice.call_count, 1)
release.set()
assert tracker._sender is not None
tracker._sender.join(timeout=5)
def test_replay_camera_never_raises(self):
tracker = DroppedFrameTracker(f"{REPLAY_CAMERA_PREFIX}front_door")
self._drop(tracker, DROPPED_FRAMES_NOTICE_COUNT * 2, 0.0)
self.raise_notice.assert_not_called()
if __name__ == "__main__":
unittest.main()
+2 -2
View File
@@ -4,7 +4,7 @@ import datetime
import unittest import unittest
from unittest.mock import MagicMock from unittest.mock import MagicMock
from peewee import SqliteDatabase from playhouse.sqlite_ext import SqliteExtDatabase
from frigate.config import FrigateConfig from frigate.config import FrigateConfig
from frigate.events.cleanup import EventCleanup from frigate.events.cleanup import EventCleanup
@@ -15,7 +15,7 @@ class TestEventCleanupSubRetention(unittest.TestCase):
def setUp(self): def setUp(self):
# in-memory database keeps these tests isolated from the shared # in-memory database keeps these tests isolated from the shared
# on-disk test.db used by the http api tests # on-disk test.db used by the http api tests
self.db = SqliteDatabase(":memory:") self.db = SqliteExtDatabase(":memory:")
models = [Event, Timeline] models = [Event, Timeline]
self.db.bind(models) self.db.bind(models)
self.db.create_tables(models) self.db.create_tables(models)
-190
View File
@@ -427,148 +427,6 @@ class TestOllamaProvider(unittest.TestCase):
self.assertEqual(message["content"], "prompt\n[img]") self.assertEqual(message["content"], "prompt\n[img]")
self.assertEqual(message["images"], [b"a"]) self.assertEqual(message["images"], [b"a"])
def test_capabilities_drive_embeddings_and_thinking(self):
client = self._client()
client.provider = MagicMock()
client.provider.show.return_value = {"capabilities": ["embedding", "vision"]}
self.assertTrue(client.supports_embeddings)
self.assertFalse(client.supports_toggleable_thinking)
client.provider.show.assert_called_once()
def test_capability_lookup_failure_is_not_cached(self):
from ollama import ResponseError
client = self._client()
client.provider = MagicMock()
client.provider.show.side_effect = [
ResponseError("unavailable", 503),
{"capabilities": ["embedding"]},
]
self.assertFalse(client.supports_embeddings)
self.assertTrue(client.supports_embeddings)
def test_thinking_rechecked_after_provider_recovers(self):
client = self._client()
client.provider = None
self.assertFalse(client.supports_toggleable_thinking)
client.provider = MagicMock()
client.provider.show.return_value = {"capabilities": ["thinking"]}
self.assertTrue(client.supports_toggleable_thinking)
params = client._build_request_params(
[{"role": "user", "content": "hi"}], None, None, enable_thinking=True
)
self.assertTrue(params["think"])
@staticmethod
def _webp_bytes():
import io
from PIL import Image
buf = io.BytesIO()
Image.new("RGB", (8, 8), (200, 10, 10)).save(buf, format="WEBP")
return buf.getvalue()
def test_embed_posts_text_and_image_items(self):
client = self._client()
client.provider = MagicMock()
client.provider._request_raw.return_value.json.return_value = {
"embeddings": [[0.1] * 768, [0.2] * 768]
}
result = client.embed(texts=["a person"], images=[self._webp_bytes()])
args = client.provider._request_raw.call_args
self.assertEqual(args.args, ("POST", "/api/embed"))
payload = args.kwargs["json"]
self.assertEqual(payload["model"], "llama3")
self.assertEqual(payload["input"][0], "a person")
self.assertEqual(list(payload["input"][1]), ["image"])
# WebP thumbnails are converted to JPEG before being sent
image = base64.b64decode(payload["input"][1]["image"])
self.assertEqual(image[:2], b"\xff\xd8")
self.assertEqual(len(result), 2)
self.assertAlmostEqual(float(result[1][0]), 0.2, places=5)
def test_embed_passes_configured_options(self):
client = _make_client(
"ollama",
model="embeddinggemma-2",
base_url="http://localhost:9999",
provider_options={"options": {"num_ctx": 2048}, "keep_alive": "10m"},
)
client.provider = MagicMock()
client.provider._request_raw.return_value.json.return_value = {
"embeddings": [[0.1] * 768]
}
client.embed(texts=["a"])
payload = client.provider._request_raw.call_args.kwargs["json"]
self.assertEqual(payload["options"], {"num_ctx": 2048})
self.assertEqual(payload["keep_alive"], "10m")
@staticmethod
def _chat_counting_prompt_tokens(**params):
"""Fake chat that reports 10 prompt tokens plus 250 per image."""
images = params["messages"][0].get("images") or []
return {
"message": {"content": "."},
"prompt_eval_count": 10 + 250 * len(images),
}
def test_image_tokens_probed_with_one_token_requests(self):
client = _make_client(
"ollama",
model="qwen3-vl",
base_url="http://localhost:9999",
provider_options={"options": {"num_ctx": 16384}},
)
client.provider = MagicMock()
client.provider.chat.side_effect = self._chat_counting_prompt_tokens
client._supports_thinking_cache = False
self.assertEqual(client.estimate_image_tokens(320, 180), 250)
calls = client.provider.chat.call_args_list
self.assertEqual(len(calls), 2)
for call in calls:
self.assertEqual(
call.kwargs["options"], {"num_ctx": 16384, "num_predict": 1}
)
image = calls[1].kwargs["messages"][0]["images"][0]
self.assertEqual(image[:2], b"\xff\xd8")
def test_image_token_probe_error_uses_heuristic(self):
from ollama import ResponseError
client = self._client()
client.provider = MagicMock()
client.provider.chat.side_effect = [
{"message": {"content": "."}, "prompt_eval_count": 10},
ResponseError("model does not support images", 400),
]
client._supports_thinking_cache = False
self.assertEqual(client.estimate_image_tokens(250, 100), 20)
self.assertEqual(client._image_token_cache, {})
def test_embed_server_error_returns_empty(self):
from ollama import ResponseError
client = self._client()
client.provider = MagicMock()
client.provider._request_raw.side_effect = ResponseError(
"model does not support media embeddings", 400
)
self.assertEqual(client.embed(images=[self._webp_bytes()]), [])
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# llama.cpp # llama.cpp
@@ -780,46 +638,6 @@ class TestLlamaCppProvider(unittest.TestCase):
self.assertEqual([r.shape for r in result], [(768,), (768,)]) self.assertEqual([r.shape for r in result], [(768,), (768,)])
self.assertEqual(float(result[1][-1]), 0.0) self.assertEqual(float(result[1][-1]), 0.0)
@staticmethod
def _post_counting_prompt_tokens(url, json=None, timeout=None):
"""Fake chat completion: 12 prompt tokens plus 300 per image part."""
content = json["messages"][0]["content"]
images = [p for p in content if p["type"] == "image_url"]
response = MagicMock()
response.json.return_value = {
"usage": {"prompt_tokens": 12 + 300 * len(images)}
}
return response
def test_image_tokens_probed_once_per_dimension(self):
client = self._client()
with patch.object(
client, "_post", side_effect=self._post_counting_prompt_tokens
) as post:
self.assertEqual(client.estimate_image_tokens(320, 180), 300)
self.assertEqual(client.estimate_image_tokens(320, 180), 300)
self.assertEqual(client.estimate_image_tokens(640, 360), 300)
# one shared text baseline, then one image request per new dimension
self.assertEqual(post.call_count, 3)
payload = post.call_args_list[0].kwargs["json"]
self.assertEqual(payload["max_tokens"], 1)
self.assertEqual(
post.call_args_list[0].args[0], "http://localhost:9999/v1/chat/completions"
)
def test_image_token_probe_failure_is_not_cached(self):
client = self._client()
with patch.object(
client, "_post", side_effect=requests.exceptions.ConnectionError("down")
):
self.assertEqual(client.estimate_image_tokens(250, 100), 20)
self.assertEqual(client._image_token_cache, {})
self.assertIsNone(client._text_baseline_tokens)
def test_embed_request_error_returns_empty(self): def test_embed_request_error_returns_empty(self):
client = self._client() client = self._client()
response = MagicMock() response = MagicMock()
@@ -1085,14 +903,6 @@ class TestLlamaCppTranscribe(unittest.TestCase):
post.assert_not_called() post.assert_not_called()
class TestImageTokenEstimate(unittest.TestCase):
def test_provider_without_token_counts_uses_heuristic(self):
client = _make_client("gemini", model="m", api_key="k")
self.assertEqual(client.estimate_image_tokens(250, 100), 20)
self.assertEqual(client._image_token_cache, {})
class TestBaseClientTranscribe(unittest.TestCase): class TestBaseClientTranscribe(unittest.TestCase):
"""Providers that don't implement the role must be inert, not broken.""" """Providers that don't implement the role must be inert, not broken."""
+2 -2
View File
@@ -9,8 +9,8 @@ import logging
import os import os
import unittest import unittest
from peewee import SqliteDatabase
from peewee_migrate import Router from peewee_migrate import Router
from playhouse.sqlite_ext import SqliteExtDatabase
from playhouse.sqliteq import SqliteQueueDatabase from playhouse.sqliteq import SqliteQueueDatabase
from frigate.api.media_auth import ( from frigate.api.media_auth import (
@@ -208,7 +208,7 @@ class TestExportResolution(unittest.TestCase):
"""Export resolution requires a DB lookup.""" """Export resolution requires a DB lookup."""
def setUp(self): def setUp(self):
migrate_db = SqliteDatabase("test.db") migrate_db = SqliteExtDatabase("test.db")
del logging.getLogger("peewee_migrate").handlers[:] del logging.getLogger("peewee_migrate").handlers[:]
Router(migrate_db).run() Router(migrate_db).run()
migrate_db.close() migrate_db.close()
+3 -16
View File
@@ -126,18 +126,12 @@ class TestMqttClientLifecycle(unittest.TestCase):
os.makedirs(MODEL_CACHE_DIR) os.makedirs(MODEL_CACHE_DIR)
self.config = build_config() self.config = build_config()
self.client = self._build_client() self.client = MqttClient(self.config)
self.receiver = RuntimeSnapshotReceiver() self.receiver = RuntimeSnapshotReceiver()
self.client.attach_dispatcher(build_dispatcher(self.config, [])) self.client.attach_dispatcher(build_dispatcher(self.config, []))
def _build_client(self) -> MqttClient:
client = MqttClient(self.config)
self.addCleanup(client._wake_recv.close)
self.addCleanup(client._wake_send.close)
return client
def test_subscribe_stores_receiver_without_starting_worker(self) -> None: def test_subscribe_stores_receiver_without_starting_worker(self) -> None:
client = self._build_client() client = MqttClient(self.config)
with patch.object(client, "_start_worker") as mock_start_worker: with patch.object(client, "_start_worker") as mock_start_worker:
client.subscribe(self.receiver._receive) client.subscribe(self.receiver._receive)
@@ -148,7 +142,7 @@ class TestMqttClientLifecycle(unittest.TestCase):
mock_start_worker.assert_not_called() mock_start_worker.assert_not_called()
def test_attach_dispatcher_supplies_command_surface(self) -> None: def test_attach_dispatcher_supplies_command_surface(self) -> None:
client = self._build_client() client = MqttClient(self.config)
self.assertFalse(client._is_supported_command_topic("front/detect/set")) self.assertFalse(client._is_supported_command_topic("front/detect/set"))
@@ -301,13 +295,6 @@ class TestMqttClientLifecycle(unittest.TestCase):
self.assertEqual(self.client._subscription_mid, 42) self.assertEqual(self.client._subscription_mid, 42)
self.client.client.subscribe.assert_called_once_with("frigate/#", qos=0) self.client.client.subscribe.assert_called_once_with("frigate/#", qos=0)
def test_publish_wakes_worker(self) -> None:
self.client.connected = True
self.client.publish("events", "payload")
self.assertEqual(self.client._wake_recv.recv(16), b"\0")
def test_handle_connect_event_reconnects_on_recoverable_subscribe_error( def test_handle_connect_event_reconnects_on_recoverable_subscribe_error(
self, self,
) -> None: ) -> None:
+3 -19
View File
@@ -2,7 +2,6 @@ import fcntl
import resource import resource
import selectors import selectors
import socket import socket
import time
import unittest import unittest
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
@@ -24,11 +23,6 @@ class TestMqttNetworkLoop(unittest.TestCase):
self.sock, self.peer = socket.socketpair() self.sock, self.peer = socket.socketpair()
self.addCleanup(self.sock.close) self.addCleanup(self.sock.close)
self.addCleanup(self.peer.close) self.addCleanup(self.peer.close)
self.transport._wake_recv, self.transport._wake_send = socket.socketpair()
self.transport._wake_recv.setblocking(False)
self.transport._wake_send.setblocking(False)
self.addCleanup(self.transport._wake_recv.close)
self.addCleanup(self.transport._wake_send.close)
self.client.socket.return_value = self.sock self.client.socket.return_value = self.sock
def test_high_fd_handles_connack_suback_publish_and_puback(self) -> None: def test_high_fd_handles_connack_suback_publish_and_puback(self) -> None:
@@ -129,18 +123,8 @@ class TestMqttNetworkLoop(unittest.TestCase):
self.assertEqual(self.transport._loop_client(0), mqtt.MQTT_ERR_CONN_LOST) self.assertEqual(self.transport._loop_client(0), mqtt.MQTT_ERR_CONN_LOST)
self.client.loop_misc.assert_not_called() self.client.loop_misc.assert_not_called()
def test_missing_socket_reports_no_connection(self) -> None: def test_missing_client_or_socket_reports_no_connection(self) -> None:
self.client.socket.return_value = None self.client.socket.return_value = None
self.assertEqual(self.transport._loop_client(0), mqtt.MQTT_ERR_NO_CONN) self.assertEqual(self.transport._loop_client(0), mqtt.MQTT_ERR_NO_CONN)
self.transport.client = None
def test_wake_interrupts_wait_and_is_consumed(self) -> None: self.assertEqual(self.transport._loop_client(0), mqtt.MQTT_ERR_NO_CONN)
self.transport._wake_worker()
start = time.monotonic()
self.assertEqual(self.transport._loop_client(5), mqtt.MQTT_ERR_SUCCESS)
self.assertLess(time.monotonic() - start, 1)
self.client.loop_read.assert_not_called()
start = time.monotonic()
self.transport._loop_client(0.2)
self.assertGreater(time.monotonic() - start, 0.15)
+2 -2
View File
@@ -5,8 +5,8 @@ import os
import unittest import unittest
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from peewee import SqliteDatabase
from peewee_migrate import Router from peewee_migrate import Router
from playhouse.sqlite_ext import SqliteExtDatabase
from playhouse.sqliteq import SqliteQueueDatabase from playhouse.sqliteq import SqliteQueueDatabase
from frigate.models import Notice, NoticeStats from frigate.models import Notice, NoticeStats
@@ -49,7 +49,7 @@ class TestNoticeKinds(unittest.TestCase):
class RegistryTestCase(unittest.TestCase): class RegistryTestCase(unittest.TestCase):
def setUp(self): def setUp(self):
migrate_db = SqliteDatabase("test.db") migrate_db = SqliteExtDatabase("test.db")
del logging.getLogger("peewee_migrate").handlers[:] del logging.getLogger("peewee_migrate").handlers[:]
router = Router(migrate_db) router = Router(migrate_db)
router.run() router.run()
+2 -2
View File
@@ -4,7 +4,7 @@ import datetime
import unittest import unittest
from unittest.mock import MagicMock from unittest.mock import MagicMock
from peewee import SqliteDatabase from playhouse.sqlite_ext import SqliteExtDatabase
from frigate.config import FrigateConfig from frigate.config import FrigateConfig
from frigate.models import Previews, Recordings, ReviewSegment, UserReviewStatus from frigate.models import Previews, Recordings, ReviewSegment, UserReviewStatus
@@ -15,7 +15,7 @@ class TestRecordingCleanupSubRetention(unittest.TestCase):
def setUp(self): def setUp(self):
# in-memory database keeps these tests isolated from the shared # in-memory database keeps these tests isolated from the shared
# on-disk test.db used by the http api tests # on-disk test.db used by the http api tests
self.db = SqliteDatabase(":memory:") self.db = SqliteExtDatabase(":memory:")
models = [Previews, Recordings, ReviewSegment, UserReviewStatus] models = [Previews, Recordings, ReviewSegment, UserReviewStatus]
self.db.bind(models) self.db.bind(models)
self.db.create_tables(models) self.db.create_tables(models)
+2 -2
View File
@@ -8,7 +8,7 @@ import unittest
from collections import defaultdict from collections import defaultdict
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
from peewee import SqliteDatabase from playhouse.sqlite_ext import SqliteExtDatabase
from frigate.config import FrigateConfig from frigate.config import FrigateConfig
from frigate.models import Recordings from frigate.models import Recordings
@@ -719,7 +719,7 @@ class TestSegmentChainSeeding(unittest.IsolatedAsyncioTestCase):
T0 = datetime.datetime(2026, 6, 10, 14, 30, 22, tzinfo=datetime.UTC).timestamp() T0 = datetime.datetime(2026, 6, 10, 14, 30, 22, tzinfo=datetime.UTC).timestamp()
def setUp(self): def setUp(self):
self.db = SqliteDatabase(":memory:") self.db = SqliteExtDatabase(":memory:")
self.db.bind([Recordings]) self.db.bind([Recordings])
self.db.create_tables([Recordings]) self.db.create_tables([Recordings])
+19 -2
View File
@@ -5,7 +5,7 @@ import json
import unittest import unittest
from types import SimpleNamespace from types import SimpleNamespace
from peewee import SqliteDatabase from playhouse.sqlite_ext import SqliteExtDatabase
from frigate.api.media import _vod_response from frigate.api.media import _vod_response
from frigate.const import MAX_SEGMENT_DURATION from frigate.const import MAX_SEGMENT_DURATION
@@ -13,6 +13,7 @@ from frigate.models import Recordings
from frigate.util.recording_coverage import ( from frigate.util.recording_coverage import (
_rows_query, _rows_query,
coverage_spans, coverage_spans,
null_audio_glitches,
plan_clip, plan_clip,
realized_timeline, realized_timeline,
resolve_coverage, resolve_coverage,
@@ -24,7 +25,7 @@ class CoverageDbTestCase(unittest.TestCase):
def setUp(self): def setUp(self):
# in-memory database keeps these tests isolated from the shared # in-memory database keeps these tests isolated from the shared
# on-disk test.db used by the http api tests # on-disk test.db used by the http api tests
self.db = SqliteDatabase(":memory:") self.db = SqliteExtDatabase(":memory:")
models = [Recordings] models = [Recordings]
self.db.bind(models) self.db.bind(models)
self.db.create_tables(models) self.db.create_tables(models)
@@ -166,6 +167,22 @@ class TestRecordingCoverage(CoverageDbTestCase):
expected = int(5 * 1024 * 1024 * 8 / 10) expected = int(5 * 1024 * 1024 * 8 / 10)
assert summary["main"]["bitrate"] == expected assert summary["main"]["bitrate"] == expected
def test_unknown_audio_row_keeps_video_only_stream(self):
self._insert("s1", 1000.0, 1010.0, "sub", has_audio=False)
self._insert("s2", 1010.0, 1020.0, "sub", has_audio=None)
self._insert("s3", 1020.0, 1030.0, "sub", has_audio=False)
kept = null_audio_glitches(resolve_coverage("front_door", 1000.0, 1030.0))
self.assertEqual(
[i.sub.path for i in kept], [f"/tmp/s{n}.mp4" for n in (1, 2, 3)]
)
def test_video_only_glitch_dropped_on_audio_stream(self):
self._insert("s1", 1000.0, 1010.0, "sub", has_audio=True)
self._insert("s2", 1010.0, 1020.0, "sub", has_audio=False)
self._insert("s3", 1020.0, 1030.0, "sub", has_audio=None)
kept = null_audio_glitches(resolve_coverage("front_door", 1000.0, 1030.0))
self.assertEqual([i.sub.path for i in kept], ["/tmp/s1.mp4", "/tmp/s3.mp4"])
def test_other_camera_rows_excluded(self): def test_other_camera_rows_excluded(self):
self._insert("m1", 1000.0, 1010.0, "main") self._insert("m1", 1000.0, 1010.0, "main")
self._insert("o1", 1000.0, 1010.0, "main", camera="back_yard") self._insert("o1", 1000.0, 1010.0, "main", camera="back_yard")
+3 -2
View File
@@ -5,8 +5,9 @@ import tempfile
import unittest import unittest
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from peewee import DoesNotExist, SqliteDatabase from peewee import DoesNotExist
from peewee_migrate import Router from peewee_migrate import Router
from playhouse.sqlite_ext import SqliteExtDatabase
from playhouse.sqliteq import SqliteQueueDatabase from playhouse.sqliteq import SqliteQueueDatabase
from frigate.config import FrigateConfig from frigate.config import FrigateConfig
@@ -19,7 +20,7 @@ from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS
class TestHttp(unittest.TestCase): class TestHttp(unittest.TestCase):
def setUp(self): def setUp(self):
# setup clean database for each test run # setup clean database for each test run
migrate_db = SqliteDatabase("test.db") migrate_db = SqliteExtDatabase("test.db")
del logging.getLogger("peewee_migrate").handlers[:] del logging.getLogger("peewee_migrate").handlers[:]
router = Router(migrate_db) router = Router(migrate_db)
router.run() router.run()
+20 -4
View File
@@ -747,8 +747,14 @@ def collect_object_classification_examples(
selected_events = _select_balanced_events(events, target_count=100) selected_events = _select_balanced_events(events, target_count=100)
logger.debug(f"Selected {len(selected_events)} events") logger.debug(f"Selected {len(selected_events)} events")
# Step 3: Extract thumbnails from events # Step 3: Extract thumbnails from events, falling back to the remaining
thumbnails = _extract_event_thumbnails(selected_events, temp_dir) # events when the selected ones have no image on disk
selected_ids = {e.id for e in selected_events}
remaining_events = [e for e in events if e.id not in selected_ids]
random.shuffle(remaining_events)
thumbnails = _extract_event_thumbnails(
selected_events + remaining_events, temp_dir, target_count=100
)
logger.debug(f"Successfully extracted {len(thumbnails)} thumbnails") logger.debug(f"Successfully extracted {len(thumbnails)} thumbnails")
# Step 4: Select 24 most visually distinct thumbnails # Step 4: Select 24 most visually distinct thumbnails
@@ -833,10 +839,16 @@ def _select_balanced_events(
else: else:
selected.extend(remaining) selected.extend(remaining)
# groups are ordered oldest first, so truncating unshuffled keeps only the
# oldest events, which are the least likely to still have images on disk
random.shuffle(selected)
return selected[:target_count] return selected[:target_count]
def _extract_event_thumbnails(events: list[Event], output_dir: str) -> list[str]: def _extract_event_thumbnails(
events: list[Event], output_dir: str, target_count: int = 100
) -> list[str]:
""" """
Extract a training image for each event. Extract a training image for each event.
@@ -850,8 +862,9 @@ def _extract_event_thumbnails(events: list[Event], output_dir: str) -> list[str]
using a step ladder sized from the box/region area ratio. using a step ladder sized from the box/region area ratio.
Args: Args:
events: List of Event objects events: List of Event objects, in order of preference
output_dir: Directory to save crops output_dir: Directory to save crops
target_count: Number of images to extract before stopping
Returns: Returns:
List of paths to successfully extracted images List of paths to successfully extracted images
@@ -859,6 +872,9 @@ def _extract_event_thumbnails(events: list[Event], output_dir: str) -> list[str]
image_paths = [] image_paths = []
for idx, event in enumerate(events): for idx, event in enumerate(events):
if len(image_paths) >= target_count:
break
try: try:
img = _load_event_classification_crop(event) img = _load_event_classification_crop(event)
if img is None: if img is None:
+4 -3
View File
@@ -204,11 +204,12 @@ def coverage_spans(intervals: list[CoverageInterval]) -> list[dict[str, Any]]:
def stream_has_audio(intervals: list[CoverageInterval], main: bool) -> bool: def stream_has_audio(intervals: list[CoverageInterval], main: bool) -> bool:
"""Whether a stream is audio-bearing over a coverage window. """Whether a stream is audio-bearing over a coverage window.
A stream counts as audio-bearing unless EVERY one of its rows reports A stream counts as audio-bearing only when one of its rows is known
has_audio False; NULL (legacy or undetermined) counts as audio. to carry audio. A NULL row (legacy, or a segment ffprobe could not
read) proves nothing either way.
""" """
return any( return any(
row is not None and row.has_audio is not False row is not None and row.has_audio is True
for row in ((interval.main if main else interval.sub) for interval in intervals) for row in ((interval.main if main else interval.sub) for interval in intervals)
) )
-61
View File
@@ -2,9 +2,7 @@
import logging import logging
import queue import queue
import threading
import time import time
from collections import deque
from datetime import UTC, datetime from datetime import UTC, datetime
from multiprocessing import Queue from multiprocessing import Queue
from multiprocessing.synchronize import Event as MpEvent from multiprocessing.synchronize import Event as MpEvent
@@ -22,12 +20,10 @@ from frigate.config.camera.updater import (
) )
from frigate.const import ( from frigate.const import (
PROCESS_PRIORITY_HIGH, PROCESS_PRIORITY_HIGH,
REPLAY_CAMERA_PREFIX,
REQUEST_REGION_GRID, REQUEST_REGION_GRID,
) )
from frigate.motion import MotionDetector from frigate.motion import MotionDetector
from frigate.motion.improved_motion import ImprovedMotionDetector from frigate.motion.improved_motion import ImprovedMotionDetector
from frigate.notices import raise_notice
from frigate.object_detection.base import RemoteObjectDetector from frigate.object_detection.base import RemoteObjectDetector
from frigate.ptz.autotrack import ptz_moving_at_frame_time from frigate.ptz.autotrack import ptz_moving_at_frame_time
from frigate.track import ObjectTracker from frigate.track import ObjectTracker
@@ -56,61 +52,6 @@ from frigate.util.time import get_tomorrow_at_time
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# this many frames dropped within the window because the shared detected frames
# queue was full means tracked object processing is falling behind
DROPPED_FRAMES_NOTICE_COUNT = 5
DROPPED_FRAMES_WINDOW_S = 30
DROPPED_FRAMES_NOTICE_INTERVAL_S = 60
class DroppedFrameTracker:
"""Raises a notice when a camera drops frames because object processing is behind."""
def __init__(self, camera: str) -> None:
# a debug replay can feed frames faster than real time
self._enabled = not camera.startswith(REPLAY_CAMERA_PREFIX)
self._drops: deque[float] = deque()
self._last_notice: float | None = None
# raising a notice waits on the main process, which answers every
# process's requests one at a time, so it runs off the frame loop
self._sender: threading.Thread | None = None
def dropped(self, now: float) -> None:
"""Record a dropped frame and raise the notice if enough were dropped.
Args:
now: Monotonic time of the drop in seconds
"""
if not self._enabled:
return
self._drops.append(now)
while now - self._drops[0] > DROPPED_FRAMES_WINDOW_S:
self._drops.popleft()
if (
len(self._drops) < DROPPED_FRAMES_NOTICE_COUNT
or (
self._last_notice is not None
and now - self._last_notice < DROPPED_FRAMES_NOTICE_INTERVAL_S
)
or (self._sender is not None and self._sender.is_alive())
):
return
self._drops.clear()
self._last_notice = now
self._sender = threading.Thread(
target=raise_notice,
args=("object_processing_behind",),
name="dropped_frames_notice",
daemon=True,
)
self._sender.start()
class CameraTracker(FrigateProcess): class CameraTracker(FrigateProcess):
def __init__( def __init__(
@@ -266,7 +207,6 @@ def process_frames(
fps_tracker = EventsPerSecond() fps_tracker = EventsPerSecond()
fps_tracker.start() fps_tracker.start()
dropped_frames = DroppedFrameTracker(camera_config.name)
startup_scan = True startup_scan = True
stationary_frame_counter = 0 stationary_frame_counter = 0
@@ -602,7 +542,6 @@ def process_frames(
# add to the queue if not full # add to the queue if not full
if detected_objects_queue.full(): if detected_objects_queue.full():
frame_manager.close(frame_name) frame_manager.close(frame_name)
dropped_frames.dropped(time.monotonic())
continue continue
else: else:
fps_tracker.update() fps_tracker.update()
+1 -1
View File
@@ -116,7 +116,7 @@ class FrigateWatchdog(threading.Thread):
now = datetime.datetime.now().timestamp() now = datetime.datetime.now().timestamp()
for name, detector in self.detectors.items(): for name, detector in self.detectors.items():
detection_start = detector.detection_start.value detection_start = detector.detection_start.value # type: ignore[attr-defined]
# issue https://github.com/python/typeshed/issues/8799 # issue https://github.com/python/typeshed/issues/8799
# from mypy 0.981 onwards # from mypy 0.981 onwards
if detection_start > 0.0 and now - detection_start > 10: if detection_start > 0.0 and now - detection_start > 10:
+25 -32
View File
@@ -136,36 +136,33 @@ ACCESS_NOTES = {
} }
# Mirrors the router set wired up in frigate.api.fastapi_app.
ROUTERS = [
auth.router,
camera.router,
chat.router,
classification.router,
review.router,
main_app.router,
preview.router,
notification.router,
export.router,
hardware.router,
notices.router,
event.router,
media.router,
motion_search.router,
record.router,
debug_replay.router,
]
def build_app() -> FastAPI: def build_app() -> FastAPI:
"""Build a bare app with every router mounted. """Build a bare app with every router mounted.
It omits the global admin dependency and all runtime state; the OpenAPI This mirrors the router set wired up in frigate.api.fastapi_app. It omits
route table and the per-route dependencies are all we need to export and the global admin dependency and all runtime state; the OpenAPI route table
classify. and the per-route dependencies are all we need to export and classify.
""" """
app = FastAPI() app = FastAPI()
for router in ROUTERS: routers = [
auth.router,
camera.router,
chat.router,
classification.router,
review.router,
main_app.router,
preview.router,
notification.router,
export.router,
hardware.router,
notices.router,
event.router,
media.router,
motion_search.router,
record.router,
debug_replay.router,
]
for router in routers:
app.include_router(router) app.include_router(router)
return app return app
@@ -321,17 +318,13 @@ def _classify_base(
def build_access_map( def build_access_map(
app: FastAPI,
exempt_paths: set[str], exempt_paths: set[str],
exempt_prefixes: tuple[str, ...], exempt_prefixes: tuple[str, ...],
) -> dict[tuple[str, str], dict]: ) -> dict[tuple[str, str], dict]:
"""Map (path, lowercase method) -> classification details.""" """Map (path, lowercase method) -> classification details."""
access_map: dict[tuple[str, str], dict] = {} access_map: dict[tuple[str, str], dict] = {}
for route in app.routes:
# app.routes holds opaque wrappers for included routers on newer FastAPI.
# The routers mount without a prefix, so their own routes carry final paths.
routes = [route for router in ROUTERS for route in router.routes]
for route in routes:
if not isinstance(route, APIRoute): if not isinstance(route, APIRoute):
continue continue
level, roles, flag = classify_route(route, exempt_paths, exempt_prefixes) level, roles, flag = classify_route(route, exempt_paths, exempt_prefixes)
@@ -522,7 +515,7 @@ def render(spec: dict) -> str:
def build_spec() -> tuple[dict, dict, list, list, list]: def build_spec() -> tuple[dict, dict, list, list, list]:
app = build_app() app = build_app()
exempt_paths, exempt_prefixes = read_exempt_rules() exempt_paths, exempt_prefixes = read_exempt_rules()
access_map = build_access_map(exempt_paths, exempt_prefixes) access_map = build_access_map(app, exempt_paths, exempt_prefixes)
spec = base_document(app.openapi()) spec = base_document(app.openapi())
normalized = strip_volatile_defaults(spec) normalized = strip_volatile_defaults(spec)
-1
View File
@@ -2,6 +2,5 @@
target-version = "py311" target-version = "py311"
[tool.ruff.lint] [tool.ruff.lint]
select = ["E4", "E7", "E9", "F"]
ignore = ["E501","E711","E712","UP031","UP032","UP042","G004"] ignore = ["E501","E711","E712","UP031","UP032","UP042","G004"]
extend-select = ["I", "UP", "G", "ASYNC210", "B904"] extend-select = ["I", "UP", "G", "ASYNC210", "B904"]
@@ -1,155 +0,0 @@
/**
* Add-camera wizard - Reolink stream selection with the brand template.
*
* The wizard asks the camera for its resolution, then probes http-flv first
* above 5MP and falls back to RTSP. The Step 4 RTSP warning is only for
* cameras that should be on http-flv. An http-flv stream the wizard selects
* is registered with go2rtc through the ffmpeg module.
*/
import { test, expect } from "../../fixtures/frigate-test";
import type { Page } from "@playwright/test";
const FLV_PATH = "channel0_main.bcs";
const RTSP_PATH = "Preview_01_main";
const RTSP_WARNING = "Reolink RTSP is not recommended";
const HTTP_WARNING = "Reolink HTTP streams should use FFmpeg";
const FFPROBE_OK = [
{
return_code: 0,
stderr: [],
stdout: {
streams: [
{
codec_type: "video",
codec_name: "hevc",
width: 3840,
height: 2160,
avg_frame_rate: "15/1",
},
{ codec_type: "audio", codec_name: "aac" },
],
},
},
];
const FFPROBE_FAILED = [
{ return_code: 1, stderr: ["probe failed"], stdout: "" },
];
/**
* Mock the camera's answers and drive the wizard to Step 3. Returns the
* dialog and the stream paths the wizard probed, in order.
*/
async function gotoStep3(
page: Page,
{ protocol, flvProbes }: { protocol: string | null; flvProbes: boolean },
) {
const probed: string[] = [];
await page.route("**/api/reolink/detect**", (route) =>
route.fulfill({ json: { success: protocol !== null, protocol } }),
);
await page.route("**/api/ffprobe**", (route) => {
const paths = new URL(route.request().url()).searchParams.get("paths");
const isFlv = !!paths?.includes(FLV_PATH);
probed.push(isFlv ? FLV_PATH : RTSP_PATH);
return route.fulfill({
json: isFlv && !flvProbes ? FFPROBE_FAILED : FFPROBE_OK,
});
});
await page.route("**/api/ffprobe/snapshot**", (route) =>
route.fulfill({ status: 500 }),
);
await page.getByRole("button", { name: /Add New Camera/i }).click();
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
await dialog.getByPlaceholder(/front_door/i).fill("reolink_test_camera");
await dialog.getByPlaceholder("192.168.1.100").fill("192.168.1.100");
await dialog.getByPlaceholder("Optional").first().fill("admin");
await dialog.getByPlaceholder("Optional").last().fill("pw");
await dialog.getByText("Manual selection").click();
await dialog.getByRole("combobox").click();
await page.getByRole("option", { name: "Reolink" }).click();
await dialog.getByRole("button", { name: /^Continue$/i }).click();
// Step 2 tests the connection on its own, then offers Continue
const next = dialog.getByRole("button", { name: /^Continue$/i });
await expect(next).toBeEnabled({ timeout: 10_000 });
await next.click();
await expect(
dialog.getByRole("button", { name: /Add Another Stream/i }),
).toBeVisible();
return { dialog, probed };
}
test.describe("Camera wizard Reolink stream selection @medium @mobile", () => {
test.beforeEach(async ({ frigateApp }) => {
// not in the default mock; unmocked it 500s and trips the error collector
await frigateApp.page.route("**/api/config/raw_paths", (route) =>
route.fulfill({ json: {} }),
);
await frigateApp.goto("/settings?page=cameraManagement");
await expect(
frigateApp.page.getByRole("heading", { name: /Manage Cameras/i }),
).toBeVisible();
});
test("above 5MP keeps http-flv when it probes", async ({ frigateApp }) => {
const { dialog, probed } = await gotoStep3(frigateApp.page, {
protocol: "rtsp",
flvProbes: true,
});
expect(probed).toEqual([FLV_PATH]);
await expect(dialog.locator(`input[value*="${FLV_PATH}"]`)).toBeVisible();
const registered: string[] = [];
await frigateApp.page.route("**/api/go2rtc/streams/**", (route) => {
const src = new URL(route.request().url()).searchParams.get("src");
if (src) registered.push(src);
return route.fulfill({ json: {} });
});
await dialog.getByRole("button", { name: /^Next$/i }).click();
await expect(
dialog.getByRole("button", { name: /Save New Camera/i }),
).toBeVisible();
await expect.poll(() => registered[0]).toMatch(/^ffmpeg:http:\/\//);
await expect(dialog.getByText(HTTP_WARNING)).toHaveCount(0);
});
test("above 5MP falls back to RTSP without a warning", async ({
frigateApp,
}) => {
const { dialog, probed } = await gotoStep3(frigateApp.page, {
protocol: "rtsp",
flvProbes: false,
});
expect(probed).toEqual([FLV_PATH, RTSP_PATH]);
await expect(dialog.locator(`input[value*="${RTSP_PATH}"]`)).toBeVisible();
await dialog.getByRole("button", { name: /^Next$/i }).click();
await expect(
dialog.getByRole("button", { name: /Save New Camera/i }),
).toBeVisible();
await expect(dialog.getByText(RTSP_WARNING)).toHaveCount(0);
});
test("failed detection uses RTSP and warns", async ({ frigateApp }) => {
const { dialog, probed } = await gotoStep3(frigateApp.page, {
protocol: null,
flvProbes: true,
});
expect(probed).toEqual([RTSP_PATH]);
await dialog.getByRole("button", { name: /^Next$/i }).click();
await expect(dialog.getByText(RTSP_WARNING)).toBeVisible();
});
});
+46 -2
View File
@@ -14,6 +14,17 @@ const NOW = Math.floor(Date.now() / 1000);
// the fixture detector runs at 75.5 ms, above the live warning threshold // the fixture detector runs at 75.5 ms, above the live warning threshold
const QUIET_STATS = { detectors: { cpu: { inference_speed: 10 } } }; const QUIET_STATS = { detectors: { cpu: { inference_speed: 10 } } };
// the fixture has no go2rtc streams, which gives every camera a live view hint
const RESTREAMED = {
go2rtc: {
streams: {
front_door: ["rtsp://x"],
backyard: ["rtsp://x"],
garage: ["rtsp://x"],
},
},
};
const ERROR_NOTICE = { const ERROR_NOTICE = {
id: "model_download_failed:yolo/model.onnx", id: "model_download_failed:yolo/model.onnx",
kind: "model_download_failed", kind: "model_download_failed",
@@ -51,6 +62,7 @@ test.describe("System — Health tab @medium", () => {
frigateApp, frigateApp,
}) => { }) => {
await frigateApp.installDefaults({ await frigateApp.installDefaults({
config: RESTREAMED,
stats: QUIET_STATS, stats: QUIET_STATS,
notices: [ERROR_NOTICE, EVENT_NOTICE], notices: [ERROR_NOTICE, EVENT_NOTICE],
}); });
@@ -84,6 +96,7 @@ test.describe("System — Health tab @medium", () => {
for (const action of ["acknowledge", "mute"] as const) { for (const action of ["acknowledge", "mute"] as const) {
test(`${action} posts and removes the row`, async ({ frigateApp }) => { test(`${action} posts and removes the row`, async ({ frigateApp }) => {
await frigateApp.installDefaults({ await frigateApp.installDefaults({
config: RESTREAMED,
stats: QUIET_STATS, stats: QUIET_STATS,
notices: [EVENT_NOTICE], notices: [EVENT_NOTICE],
}); });
@@ -126,7 +139,10 @@ test.describe("System — Health tab @medium", () => {
} }
test("empty state with no notices", async ({ frigateApp }) => { test("empty state with no notices", async ({ frigateApp }) => {
await frigateApp.installDefaults({ stats: QUIET_STATS }); await frigateApp.installDefaults({
config: RESTREAMED,
stats: QUIET_STATS,
});
await frigateApp.goto("/system#health"); await frigateApp.goto("/system#health");
await expect( await expect(
@@ -329,6 +345,7 @@ test.describe("System — Health tab @medium", () => {
frigateApp, frigateApp,
}) => { }) => {
await frigateApp.installDefaults({ await frigateApp.installDefaults({
config: RESTREAMED,
stats: QUIET_STATS, stats: QUIET_STATS,
notices: [ERROR_NOTICE, EVENT_NOTICE], notices: [ERROR_NOTICE, EVENT_NOTICE],
}); });
@@ -765,6 +782,7 @@ test.describe("System — Health notices sources @medium", () => {
test("status bar problems stay out of the list", async ({ frigateApp }) => { test("status bar problems stay out of the list", async ({ frigateApp }) => {
test.skip(frigateApp.isMobile, "Status bar is desktop-only"); test.skip(frigateApp.isMobile, "Status bar is desktop-only");
await frigateApp.installDefaults({ await frigateApp.installDefaults({
config: RESTREAMED,
stats: { stats: {
service: { retention_unmet: true }, service: { retention_unmet: true },
cameras: { front_door: { camera_fps: 0 } }, cameras: { front_door: { camera_fps: 0 } },
@@ -829,6 +847,28 @@ test.describe("System — Health notices sources @medium", () => {
).toBeVisible({ timeout: 15_000 }); ).toBeVisible({ timeout: 15_000 });
}); });
test("a camera without a go2rtc stream gets a live view hint", async ({
frigateApp,
}) => {
await frigateApp.installDefaults({
config: { go2rtc: { streams: { front_door: ["rtsp://x"] } } },
stats: QUIET_STATS,
});
await frigateApp.goto("/system#health");
const row = frigateApp.page.getByTestId(
"health-problem-config:live:no-go2rtc-stream:camera.backyard",
);
await expect(row).toBeVisible({ timeout: 15_000 });
await expect(row).toHaveAttribute("data-severity", "info");
await expect(row).toContainText("lower frame rate and no audio");
await expect(
frigateApp.page.getByTestId(
"health-problem-config:live:no-go2rtc-stream:camera.front_door",
),
).toHaveCount(0);
});
test("a global config problem is not repeated per camera", async ({ test("a global config problem is not repeated per camera", async ({
frigateApp, frigateApp,
}) => { }) => {
@@ -893,7 +933,10 @@ test.describe("System — Health notices sources @medium", () => {
test("empty state when stats, config, and registry are clean", async ({ test("empty state when stats, config, and registry are clean", async ({
frigateApp, frigateApp,
}) => { }) => {
await frigateApp.installDefaults({ stats: QUIET_STATS }); await frigateApp.installDefaults({
config: RESTREAMED,
stats: QUIET_STATS,
});
await frigateApp.goto("/system#health"); await frigateApp.goto("/system#health");
await expect( await expect(
@@ -1130,6 +1173,7 @@ test.describe("System — Health notices sources @medium", () => {
}) => { }) => {
test.skip(frigateApp.isMobile, "Status bar is desktop-only"); test.skip(frigateApp.isMobile, "Status bar is desktop-only");
await frigateApp.installDefaults({ await frigateApp.installDefaults({
config: RESTREAMED,
stats: QUIET_STATS, stats: QUIET_STATS,
notices: [EVENT_NOTICE], notices: [EVENT_NOTICE],
}); });
+938 -766
View File
File diff suppressed because it is too large Load Diff
+51 -51
View File
@@ -23,33 +23,33 @@
"dependencies": { "dependencies": {
"@cycjimmy/jsmpeg-player": "^6.1.2", "@cycjimmy/jsmpeg-player": "^6.1.2",
"@hookform/resolvers": "^5.9.1", "@hookform/resolvers": "^5.9.1",
"@radix-ui/react-alert-dialog": "^1.1.24", "@radix-ui/react-alert-dialog": "^1.1.23",
"@radix-ui/react-aspect-ratio": "^1.1.16", "@radix-ui/react-aspect-ratio": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.12", "@radix-ui/react-checkbox": "^1.3.11",
"@radix-ui/react-collapsible": "^1.1.21", "@radix-ui/react-collapsible": "^1.1.20",
"@radix-ui/react-context-menu": "^2.3.8", "@radix-ui/react-context-menu": "^2.3.7",
"@radix-ui/react-dialog": "^1.2.0", "@radix-ui/react-dialog": "^1.1.23",
"@radix-ui/react-dropdown-menu": "^2.1.25", "@radix-ui/react-dropdown-menu": "^2.1.24",
"@radix-ui/react-hover-card": "^1.1.24", "@radix-ui/react-hover-card": "^1.1.23",
"@radix-ui/react-label": "^2.1.16", "@radix-ui/react-label": "^2.1.15",
"@radix-ui/react-popover": "^1.2.0", "@radix-ui/react-popover": "^1.1.23",
"@radix-ui/react-progress": "^1.1.17", "@radix-ui/react-progress": "^1.1.16",
"@radix-ui/react-radio-group": "^1.4.8", "@radix-ui/react-radio-group": "^1.4.7",
"@radix-ui/react-scroll-area": "^1.3.0", "@radix-ui/react-scroll-area": "^1.2.18",
"@radix-ui/react-select": "^2.3.8", "@radix-ui/react-select": "^2.3.7",
"@radix-ui/react-separator": "^1.1.16", "@radix-ui/react-separator": "^1.1.15",
"@radix-ui/react-slider": "^1.5.0", "@radix-ui/react-slider": "^1.4.7",
"@radix-ui/react-slot": "1.4.0", "@radix-ui/react-slot": "1.2.4",
"@radix-ui/react-switch": "^1.3.8", "@radix-ui/react-switch": "^1.3.7",
"@radix-ui/react-tabs": "^1.1.22", "@radix-ui/react-tabs": "^1.1.21",
"@radix-ui/react-toggle": "^1.1.19", "@radix-ui/react-toggle": "^1.1.18",
"@radix-ui/react-toggle-group": "^1.1.20", "@radix-ui/react-toggle-group": "^1.1.19",
"@radix-ui/react-tooltip": "^1.3.0", "@radix-ui/react-tooltip": "^1.2.16",
"@rjsf/core": "^6.10.1", "@rjsf/core": "^6.10.0",
"@rjsf/shadcn": "^6.10.1", "@rjsf/shadcn": "^6.10.0",
"@rjsf/utils": "^6.10.1", "@rjsf/utils": "^6.10.0",
"@rjsf/validator-ajv8": "^6.10.1", "@rjsf/validator-ajv8": "^6.10.0",
"apexcharts": "^7.8.0", "apexcharts": "^7.3.0",
"axios": "^1.20.0", "axios": "^1.20.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@@ -57,31 +57,31 @@
"copy-to-clipboard": "^4.0.2", "copy-to-clipboard": "^4.0.2",
"date-fns": "^4.4.0", "date-fns": "^4.4.0",
"date-fns-tz": "^3.2.0", "date-fns-tz": "^3.2.0",
"framer-motion": "^14.0.0", "framer-motion": "^13.3.0",
"hls.js": "^1.7.3", "hls.js": "^1.7.3",
"i18next": "^26.4.2", "i18next": "^26.4.2",
"i18next-http-backend": "^4.0.2", "i18next-http-backend": "^4.0.2",
"idb-keyval": "^6.3.0", "idb-keyval": "^6.3.0",
"js-yaml": "^5.4.3", "js-yaml": "^5.4.2",
"konva": "^10.7.1", "konva": "^10.5.0",
"lodash": "^4.18.1", "lodash": "^4.18.1",
"lucide-react": "^1.52.0", "lucide-react": "^1.46.0",
"monaco-yaml": "^5.5.1", "monaco-yaml": "^5.5.1",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"nosleep.js": "^0.12.0", "nosleep.js": "^0.12.0",
"react": "^19.3.0", "react": "^19.3.0",
"react-apexcharts": "^2.1.1", "react-apexcharts": "^2.1.1",
"react-day-picker": "^10.0.2", "react-day-picker": "^9.14.0",
"react-device-detect": "^2.2.3", "react-device-detect": "^2.2.3",
"react-dom": "^19.3.0", "react-dom": "^19.3.0",
"react-dropzone": "^20.1.2", "react-dropzone": "^20.1.2",
"react-grid-layout": "^2.3.0", "react-grid-layout": "^2.2.4",
"react-hook-form": "^7.89.0", "react-hook-form": "^7.88.0",
"react-i18next": "^17.0.16", "react-i18next": "^17.0.14",
"react-icons": "^5.7.0", "react-icons": "^5.7.0",
"react-konva": "^19.3.0", "react-konva": "^19.2.7",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-router-dom": "^7.18.4", "react-router-dom": "^6.30.6",
"react-swipeable": "^7.0.2", "react-swipeable": "^7.0.2",
"react-zoom-pan-pinch": "3.4.4", "react-zoom-pan-pinch": "3.4.4",
"remark-gfm": "^4.0.0", "remark-gfm": "^4.0.0",
@@ -93,41 +93,41 @@
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"use-long-press": "^3.3.0", "use-long-press": "^3.3.0",
"vaul": "^1.1.2", "vaul": "^1.1.2",
"virtua": "^0.53.3", "virtua": "^0.51.3",
"vite-plugin-monaco-editor": "^1.1.0", "vite-plugin-monaco-editor": "^1.1.0",
"zod": "^3.25.76" "zod": "^3.25.76"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@playwright/test": "^1.64.0", "@playwright/test": "^1.63.0",
"@types/js-yaml": "^4.0.9", "@types/js-yaml": "^4.0.9",
"@types/lodash": "^4.17.25", "@types/lodash": "^4.17.25",
"@types/node": "^26.6.4", "@types/node": "^26.5.1",
"@types/react": "^19.3.0", "@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0", "@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^6.1.2", "@vitejs/plugin-react": "^6.1.1",
"autoprefixer": "^10.6.1", "autoprefixer": "^10.6.0",
"esbuild": "^0.28.2", "esbuild": "^0.28.2",
"eslint": "^10.12.0", "eslint": "^10.10.0",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.6", "eslint-plugin-prettier": "^5.5.6",
"eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.7", "eslint-plugin-react-refresh": "^0.5.7",
"globals": "^17.13.0", "globals": "^17.12.0",
"i18next-cli": "^1.5.11", "i18next-cli": "^1.5.11",
"monaco-editor": "^0.52.2", "monaco-editor": "^0.52.2",
"patch-package": "^8.0.1", "patch-package": "^8.0.1",
"postcss": "^8.5.29", "postcss": "^8.5.12",
"prettier": "^3.9.9", "prettier": "^3.9.6",
"prettier-plugin-tailwindcss": "^0.8.1", "prettier-plugin-tailwindcss": "^0.8.1",
"tailwindcss": "^3.4.19", "tailwindcss": "^3.4.9",
"typescript": "^6.0.3", "typescript": "^5.9.3",
"typescript-eslint": "^8.71.1", "typescript-eslint": "^8.70.0",
"vite": "^8.3.3" "vite": "^8.3.0"
}, },
"overrides": { "overrides": {
"@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-popper": "1.2.8", "@radix-ui/react-popper": "1.2.8",
"@radix-ui/react-slot": "1.4.0" "@radix-ui/react-slot": "1.2.4"
} }
} }
@@ -0,0 +1,46 @@
diff --git a/node_modules/@radix-ui/react-slot/dist/index.js b/node_modules/@radix-ui/react-slot/dist/index.js
index 3691205..3b62ea8 100644
--- a/node_modules/@radix-ui/react-slot/dist/index.js
+++ b/node_modules/@radix-ui/react-slot/dist/index.js
@@ -85,11 +85,12 @@ function createSlotClone(ownerName) {
if (isLazyComponent(children) && typeof use === "function") {
children = use(children._payload);
}
+ const childrenRef = React.isValidElement(children) ? getElementRef(children) : null;
+ const composedRef = (0, import_react_compose_refs.useComposedRefs)(forwardedRef, childrenRef);
if (React.isValidElement(children)) {
- const childrenRef = getElementRef(children);
const props2 = mergeProps(slotProps, children.props);
if (children.type !== React.Fragment) {
- props2.ref = forwardedRef ? (0, import_react_compose_refs.composeRefs)(forwardedRef, childrenRef) : childrenRef;
+ props2.ref = forwardedRef ? composedRef : childrenRef;
}
return React.cloneElement(children, props2);
}
diff --git a/node_modules/@radix-ui/react-slot/dist/index.mjs b/node_modules/@radix-ui/react-slot/dist/index.mjs
index d7ea374..a990150 100644
--- a/node_modules/@radix-ui/react-slot/dist/index.mjs
+++ b/node_modules/@radix-ui/react-slot/dist/index.mjs
@@ -1,6 +1,6 @@
// src/slot.tsx
import * as React from "react";
-import { composeRefs } from "@radix-ui/react-compose-refs";
+import { composeRefs, useComposedRefs } from "@radix-ui/react-compose-refs";
import { Fragment as Fragment2, jsx } from "react/jsx-runtime";
var REACT_LAZY_TYPE = Symbol.for("react.lazy");
var use = React[" use ".trim().toString()];
@@ -45,11 +45,12 @@ function createSlotClone(ownerName) {
if (isLazyComponent(children) && typeof use === "function") {
children = use(children._payload);
}
+ const childrenRef = React.isValidElement(children) ? getElementRef(children) : null;
+ const composedRef = useComposedRefs(forwardedRef, childrenRef);
if (React.isValidElement(children)) {
- const childrenRef = getElementRef(children);
const props2 = mergeProps(slotProps, children.props);
if (children.type !== React.Fragment) {
- props2.ref = forwardedRef ? composeRefs(forwardedRef, childrenRef) : childrenRef;
+ props2.ref = forwardedRef ? composedRef : childrenRef;
}
return React.cloneElement(children, props2);
}
@@ -1997,6 +1997,9 @@
"genaiImageSourceRecordingsRecordDisabled": "Image source is set to 'recordings', but recording is disabled. Frigate will fall back to preview images.", "genaiImageSourceRecordingsRecordDisabled": "Image source is set to 'recordings', but recording is disabled. Frigate will fall back to preview images.",
"genaiImageSourceRecordingsRecordRuntimeDisabled": "Image source is set to 'recordings', but recording is currently turned off for this camera even though your config enables it. Frigate will fall back to preview images." "genaiImageSourceRecordingsRecordRuntimeDisabled": "Image source is set to 'recordings', but recording is currently turned off for this camera even though your config enables it. Frigate will fall back to preview images."
}, },
"live": {
"noGo2rtcStream": "Live view for this camera is using a basic player with a lower frame rate and no audio. Set up a go2rtc stream for this camera to get smoother video and audio."
},
"audio": { "audio": {
"noAudioRole": "No streams have the audio role defined. You must enable the audio role for audio detection to function." "noAudioRole": "No streams have the audio role defined. You must enable the audio role for audio detection to function."
}, },
-1
View File
@@ -53,7 +53,6 @@
"ffmpeg_high_cpu": "FFmpeg CPU usage is high ({{cpu}}% average)", "ffmpeg_high_cpu": "FFmpeg CPU usage is high ({{cpu}}% average)",
"detect_high_cpu": "Detection CPU usage is high ({{cpu}}% average)", "detect_high_cpu": "Detection CPU usage is high ({{cpu}}% average)",
"shm_too_low": "/dev/shm allocation ({{total}} MB) should be increased to at least {{min}} MB", "shm_too_low": "/dev/shm allocation ({{total}} MB) should be increased to at least {{min}} MB",
"object_processing_behind": "Tracked object processing could not keep up, so camera frames were dropped. Check CPU utilization, and whether CPU limits or pinning on the container or VM are leaving Frigate too little CPU",
"failed_login_one": "Failed login attempt for {{user}}", "failed_login_one": "Failed login attempt for {{user}}",
"failed_login_other": "Failed login attempts for {{user}}", "failed_login_other": "Failed login attempts for {{user}}",
"update_available": "Frigate {{version}} is available" "update_available": "Frigate {{version}} is available"
+1 -1
View File
@@ -44,7 +44,7 @@ function App() {
return ( return (
<Providers> <Providers>
<BrowserRouter basename={window.baseUrl} useTransitions={false}> <BrowserRouter basename={window.baseUrl}>
<Wrapper> <Wrapper>
{config?.safe_mode ? <SafeAppView /> : <DefaultAppView />} {config?.safe_mode ? <SafeAppView /> : <DefaultAppView />}
</Wrapper> </Wrapper>
@@ -1,8 +1,24 @@
import { isRestreamedStream } from "@/utils/liveTranscode";
import type { SectionConfigOverrides } from "./types"; import type { SectionConfigOverrides } from "./types";
const live: SectionConfigOverrides = { const live: SectionConfigOverrides = {
base: { base: {
sectionDocs: "/configuration/live", sectionDocs: "/configuration/live",
messages: [
{
key: "no-go2rtc-stream",
health: true,
messageKey: "configMessages.live.noGo2rtcStream",
severity: "info",
docLink: "/configuration/live",
condition: (ctx) => {
if (ctx.level !== "camera" || !ctx.fullCameraConfig) return false;
return !Object.values(ctx.fullCameraConfig.live.streams).some(
(name) => isRestreamedStream(ctx.fullConfig, name),
);
},
},
],
restartRequired: [], restartRequired: [],
fieldOrder: ["streams", "transcode", "height", "quality"], fieldOrder: ["streams", "transcode", "height", "quality"],
fieldGroups: {}, fieldGroups: {},
@@ -15,6 +15,7 @@ import {
hardwareForDevices, hardwareForDevices,
MAX_DETECTORS, MAX_DETECTORS,
recommendedDetectorCount, recommendedDetectorCount,
resolveUnitDevice,
} from "@/utils/detectionHardware"; } from "@/utils/detectionHardware";
type HardwarePickerProps = { type HardwarePickerProps = {
@@ -60,9 +61,13 @@ export function HardwarePicker({
return []; return [];
} }
const assigned = devices.map((device) =>
resolveUnitDevice(selected, device),
);
return selected.units return selected.units
.map((unit) => unit.device) .map((unit) => unit.device)
.filter((device) => devices.includes(device)); .filter((device) => assigned.includes(device));
}, [selected, devices]); }, [selected, devices]);
/** Spread `count` detectors round robin over the selected units. */ /** Spread `count` detectors round robin over the selected units. */
@@ -200,7 +205,7 @@ export function HardwarePicker({
<Checkbox <Checkbox
id={`${idPrefix}-${unit.device}`} id={`${idPrefix}-${unit.device}`}
className="size-5 text-white accent-white data-[state=checked]:bg-selected data-[state=checked]:text-white" className="size-5 text-white accent-white data-[state=checked]:bg-selected data-[state=checked]:text-white"
checked={devices.includes(unit.device)} checked={selectedUnits.includes(unit.device)}
disabled={disabled || Boolean(claimedBy)} disabled={disabled || Boolean(claimedBy)}
onCheckedChange={(checked) => onCheckedChange={(checked) =>
handleUnitToggle(unit.device, checked === true) handleUnitToggle(unit.device, checked === true)
@@ -155,7 +155,7 @@ export function CombinedStorageGraph({
formatter: function (val, opts) { formatter: function (val, opts) {
const entry = opts ? series[opts.seriesIndex] : undefined; const entry = opts ? series[opts.seriesIndex] : undefined;
if (entry) { if (entry) {
return `${getUnitSize(entry.usage)} (${(val ?? 0).toFixed(2)}%)`; return `${getUnitSize(entry.usage)} (${val.toFixed(2)}%)`;
} }
}, },
}, },
@@ -222,7 +222,6 @@ export default function Step2ProbeOrSnapshot({
wizardData.username, wizardData.username,
wizardData.password, wizardData.password,
); );
update.reolinkProtocol = protocol;
if (protocol === "http-flv") { if (protocol === "http-flv") {
update.brandTemplate = "reolink"; update.brandTemplate = "reolink";
} }
@@ -295,72 +294,73 @@ export default function Step2ProbeOrSnapshot({
[probeUri], [probeUri],
); );
const generateDynamicStreamUrls = useCallback( const generateDynamicStreamUrl = useCallback(
async (data: Partial<WizardFormData>): Promise<string[]> => { async (data: Partial<WizardFormData>): Promise<string | null> => {
const brand = CAMERA_BRANDS.find((b) => b.value === data.brandTemplate); const brand = CAMERA_BRANDS.find((b) => b.value === data.brandTemplate);
const host = data.host; if (!brand || !data.host) return null;
if (!brand || !host) return [];
let protocol = undefined; let protocol = undefined;
if (data.brandTemplate === "reolink" && data.username && data.password) { if (data.brandTemplate === "reolink" && data.username && data.password) {
try { try {
protocol = await detectReolinkCamera( protocol = await detectReolinkCamera(
host, data.host,
data.username, data.username,
data.password, data.password,
); );
} catch { } catch {
return []; return null;
} }
onUpdate({ reolinkProtocol: protocol });
} }
// Only some Reolink cameras above 5MP serve http-flv, so RTSP is the const protocolKey = protocol || "rtsp";
// fallback when the http-flv stream does not probe.
const protocolKeys =
protocol === "rtsp" ? ["http-flv", "rtsp"] : [protocol || "rtsp"];
const templates: Record<string, string> = brand.dynamicTemplates || {}; const templates: Record<string, string> = brand.dynamicTemplates || {};
return protocolKeys if (Object.keys(templates).includes(protocolKey)) {
.filter((key) => key in templates) const template =
.map((key) => templates[protocolKey as keyof typeof brand.dynamicTemplates];
templates[key] return template
.replace("{username}", data.username || "") .replace("{username}", data.username || "")
.replace("{password}", data.password || "") .replace("{password}", data.password || "")
.replace("{host}", host), .replace("{host}", data.host);
); }
return null;
}, },
[onUpdate], [],
); );
const generateStreamUrls = useCallback( const generateStreamUrl = useCallback(
async (data: Partial<WizardFormData>): Promise<string[]> => { async (data: Partial<WizardFormData>): Promise<string> => {
if (data.brandTemplate === "other") { if (data.brandTemplate === "other") {
return data.customUrl ? [data.customUrl] : []; return data.customUrl || "";
} }
const brand = CAMERA_BRANDS.find((b) => b.value === data.brandTemplate); const brand = CAMERA_BRANDS.find((b) => b.value === data.brandTemplate);
if (!brand || !data.host) return []; if (!brand || !data.host) return "";
if (brand.template === "dynamic" && "dynamicTemplates" in brand) { if (brand.template === "dynamic" && "dynamicTemplates" in brand) {
return generateDynamicStreamUrls(data); const dynamicUrl = await generateDynamicStreamUrl(data);
if (dynamicUrl) {
return dynamicUrl;
}
return "";
} }
return [ return brand.template
brand.template .replace("{username}", data.username || "")
.replace("{username}", data.username || "") .replace("{password}", data.password || "")
.replace("{password}", data.password || "") .replace("{host}", data.host);
.replace("{host}", data.host),
];
}, },
[generateDynamicStreamUrls], [generateDynamicStreamUrl],
); );
const testConnection = useCallback( const testConnection = useCallback(
async (showToast = true) => { async (showToast = true) => {
const streamUrls = await generateStreamUrls(wizardData); const streamUrl = await generateStreamUrl(wizardData);
if (streamUrls.length === 0) { if (!streamUrl) {
toast.error(t("cameraWizard.commonErrors.noUrl")); toast.error(t("cameraWizard.commonErrors.noUrl"));
return; return;
} }
@@ -370,18 +370,8 @@ export default function Step2ProbeOrSnapshot({
setTestResult(null); setTestResult(null);
try { try {
let streamUrl = streamUrls[0]; setTestStatus(t("cameraWizard.step2.testing.probingMetadata"));
let result: TestResult | undefined; const result = await probeUri(streamUrl, true, setTestStatus);
for (const url of streamUrls) {
streamUrl = url;
setTestStatus(t("cameraWizard.step2.testing.probingMetadata"));
result = await probeUri(url, true, setTestStatus);
if (result.success && result.resolution) {
break;
}
}
if (result && result.success) { if (result && result.success) {
setTestResult(result); setTestResult(result);
@@ -393,9 +383,6 @@ export default function Step2ProbeOrSnapshot({
url: streamUrl, url: streamUrl,
roles: ["detect"] as StreamRole[], roles: ["detect"] as StreamRole[],
testResult: result, testResult: result,
useFfmpeg:
wizardData.brandTemplate === "reolink" &&
streamUrl.startsWith("http://"),
}, },
], ],
}); });
@@ -447,7 +434,7 @@ export default function Step2ProbeOrSnapshot({
setTestStatus(""); setTestStatus("");
} }
}, },
[wizardData, generateStreamUrls, t, onUpdate, probeUri], [wizardData, generateStreamUrl, t, onUpdate, probeUri],
); );
const handleContinue = useCallback(() => { const handleContinue = useCallback(() => {
@@ -499,7 +499,6 @@ function StreamIssues({
url: stream.url, url: stream.url,
roles: stream.roles, roles: stream.roles,
brand: wizardData.brandTemplate, brand: wizardData.brandTemplate,
reolinkProtocol: wizardData.reolinkProtocol,
useFfmpeg: stream.useFfmpeg, useFfmpeg: stream.useFfmpeg,
restream: stream.restream, restream: stream.restream,
testResult: stream.testResult, testResult: stream.testResult,
-1
View File
@@ -110,7 +110,6 @@ export type WizardFormData = {
username?: string; username?: string;
password?: string; password?: string;
brandTemplate?: CameraBrand; brandTemplate?: CameraBrand;
reolinkProtocol?: "http-flv" | "rtsp" | null; // null when detection failed
customUrl?: string; customUrl?: string;
streams?: StreamConfig[]; streams?: StreamConfig[];
probeMode?: boolean; // true for probe, false for manual probeMode?: boolean; // true for probe, false for manual
+25 -4
View File
@@ -21,10 +21,31 @@ export function hardwareForDevices(
return undefined; return undefined;
} }
return hardware.find((entry) => { return hardware.find((entry) =>
const known = new Set(entry.units.map((unit) => unit.device)); devices.every((device) => resolveUnitDevice(entry, device)),
return devices.every((device) => known.has(device)); );
}); }
/**
* The unit device string a configured device refers to.
*
* A config may leave the index off, such as "edgetpu:usb", which the detector
* resolves to the first unit of that kind.
*/
export function resolveUnitDevice(
entry: DetectionHardware,
device: string,
): string | undefined {
const unitDevices = entry.units.map((unit) => unit.device);
return (
unitDevices.find((candidate) => candidate === device) ??
unitDevices.find(
(candidate) =>
candidate.startsWith(`${device}:`) ||
candidate.startsWith(`${device}.`),
)
);
} }
/** /**
+1 -2
View File
@@ -13,7 +13,6 @@ export type StreamIssueInput = {
url: string; url: string;
roles: StreamRole[]; roles: StreamRole[];
brand?: CameraBrand; brand?: CameraBrand;
reolinkProtocol?: "http-flv" | "rtsp" | null;
useFfmpeg?: boolean; useFfmpeg?: boolean;
restream?: boolean; restream?: boolean;
testResult?: TestResult; testResult?: TestResult;
@@ -97,7 +96,7 @@ export function getStreamIssues(
if (input.brand === "reolink") { if (input.brand === "reolink") {
const streamUrl = input.url.toLowerCase(); const streamUrl = input.url.toLowerCase();
if (streamUrl.startsWith("rtsp://") && input.reolinkProtocol !== "rtsp") { if (streamUrl.startsWith("rtsp://")) {
result.push({ result.push({
type: "warning", type: "warning",
rule: "reolink-rtsp", rule: "reolink-rtsp",
+1 -1
View File
@@ -5,7 +5,7 @@
"lib": ["ES2022", "DOM", "DOM.Iterable"], "lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext", "module": "ESNext",
"skipLibCheck": true, "skipLibCheck": true,
"types": ["node"], "baseUrl": ".",
"paths": { "paths": {
"@/*": ["./src/*"] "@/*": ["./src/*"]
}, },