Compare commits

..
21 Commits
Author SHA1 Message Date
Blake BlackshearandGitHub 50a2b6729e update labels/faq (#23759) 2026-07-18 11:19:12 -06:00
Josh HawkinsandGitHub 3d4dd3ac4b allow non-admin users to send PTZ commands for cameras they have access to (#23578)
CI / AMD64 Build (push) Has been cancelled
CI / ARM Build (push) Has been cancelled
CI / Jetson Jetpack 6 (push) Has been cancelled
CI / Assemble and push default build (push) Has been cancelled
CI / AMD64 Extra Build (push) Has been cancelled
CI / ARM Extra Build (push) Has been cancelled
CI / Synaptics Build (push) Has been cancelled
2026-06-27 15:55:39 -06:00
Josh HawkinsandGitHub 933a7f1a3f resolve the leaked Query default so media Cache-Control max-age is always a valid int (#23553)
CI / ARM Build (push) Has been cancelled
CI / Jetson Jetpack 6 (push) Has been cancelled
CI / AMD64 Extra Build (push) Has been cancelled
CI / ARM Extra Build (push) Has been cancelled
CI / Synaptics Build (push) Has been cancelled
CI / Assemble and push default build (push) Has been cancelled
CI / AMD64 Build (push) Has been cancelled
2026-06-24 07:57:46 -05:00
Josh HawkinsandGitHub 4e5e8e3c59 Offload preview encoding and Plus upload off the API event loop (#23552)
* offload preview ffmpeg encoding to a thread to avoid blocking the api event loop

* offload Frigate+ recording snapshot upload to a thread to avoid blocking the api event loop
2026-06-24 07:17:23 -05:00
Nicolas MowenandGitHub b3ce4486b9 Catch edge cases in security protections (#23493)
CI / AMD64 Build (push) Has been cancelled
CI / ARM Build (push) Has been cancelled
CI / Jetson Jetpack 6 (push) Has been cancelled
CI / AMD64 Extra Build (push) Has been cancelled
CI / ARM Extra Build (push) Has been cancelled
CI / Synaptics Build (push) Has been cancelled
CI / Assemble and push default build (push) Has been cancelled
* Fix go2rtc nested key dict

* Don't allow path traversal
2026-06-16 08:07:12 -06:00
Nicolas MowenandGitHub 06e3d0ac5d Chapter tweaks (#23440)
CI / AMD64 Build (push) Has been cancelled
CI / ARM Build (push) Has been cancelled
CI / Jetson Jetpack 6 (push) Has been cancelled
CI / AMD64 Extra Build (push) Has been cancelled
CI / ARM Extra Build (push) Has been cancelled
CI / Synaptics Build (push) Has been cancelled
CI / Assemble and push default build (push) Has been cancelled
* Add camera metadata and fix preview chapters

* Add config option for chapters
2026-06-09 09:07:42 -06:00
Nicolas MowenandGitHub 28e3e1ec74 Add ability to control chapters set on MP4 Export (#23310)
CI / AMD64 Build (push) Has been cancelled
CI / ARM Build (push) Has been cancelled
CI / Jetson Jetpack 6 (push) Has been cancelled
CI / AMD64 Extra Build (push) Has been cancelled
CI / ARM Extra Build (push) Has been cancelled
CI / Assemble and push default build (push) Has been cancelled
CI / Synaptics Build (push) Has been cancelled
2026-05-25 13:06:16 -05:00
Josh HawkinsandGitHub fa07109a85 filter motion review by allowed cameras (#23294)
CI / AMD64 Build (push) Has been cancelled
CI / ARM Build (push) Has been cancelled
CI / Jetson Jetpack 6 (push) Has been cancelled
CI / AMD64 Extra Build (push) Has been cancelled
CI / ARM Extra Build (push) Has been cancelled
CI / Synaptics Build (push) Has been cancelled
CI / Assemble and push default build (push) Has been cancelled
2026-05-23 06:47:32 -06:00
Josh HawkinsandGitHub 910059281f update mask docs for more clarity (#23282) 2026-05-21 14:00:46 -06:00
Josh HawkinsandGitHub ef44c18c07 Docs update (#23280)
* stationary car detection troubleshooting tips

* tweak
2026-05-21 09:04:41 -05:00
Josh HawkinsandGitHub 06b059c36a fix admin response cache leak to non-admin users via nginx proxy_cache (#23261)
CI / AMD64 Build (push) Has been cancelled
CI / ARM Build (push) Has been cancelled
CI / Jetson Jetpack 6 (push) Has been cancelled
CI / AMD64 Extra Build (push) Has been cancelled
CI / ARM Extra Build (push) Has been cancelled
CI / Synaptics Build (push) Has been cancelled
CI / Assemble and push default build (push) Has been cancelled
2026-05-20 07:29:37 -05:00
Nicolas MowenandGitHub 26d31300e6 Add metadata for creation time to recording segments and exports (#23239)
CI / AMD64 Build (push) Has been cancelled
CI / ARM Build (push) Has been cancelled
CI / Jetson Jetpack 6 (push) Has been cancelled
CI / AMD64 Extra Build (push) Has been cancelled
CI / ARM Extra Build (push) Has been cancelled
CI / Assemble and push default build (push) Has been cancelled
CI / Synaptics Build (push) Has been cancelled
2026-05-18 10:58:10 -05:00
0013555528 Fixes (#23235)
* use stable empty object reference for swr metadata default

* version bump

* Refactor get_min_region_size for dimension normalization

Refactor get_min_region_size to normalize dimensions for smaller models and ensure minimum region size is 320 for larger models.

* reject restricted go2rtc stream sources when added via api

* add env var check function

* fix typing

---------

Co-authored-by: Nicolas Mowen <nickmowen213@gmail.com>
2026-05-18 10:32:39 -05:00
Josh HawkinsandGitHub 2cfb530dbf fix yolonas colab notebook (#22936)
CI / ARM Build (push) Has been cancelled
CI / Jetson Jetpack 6 (push) Has been cancelled
CI / AMD64 Extra Build (push) Has been cancelled
CI / ARM Extra Build (push) Has been cancelled
CI / Synaptics Build (push) Has been cancelled
CI / Assemble and push default build (push) Has been cancelled
CI / AMD64 Build (push) Has been cancelled
2026-04-21 11:08:10 -06:00
Josh HawkinsandGitHub 81b0d94793 fix broken docs links with hash fragments that resolve wrong on reload (#22925) 2026-04-18 16:50:28 -06:00
67837f61d0 Update restream.md docs and clarify output config (#22860)
* Update restream.md

Clarified that exec output must be put in curly braces ONLY in case of RTSP, not pipe, as per go2rtc docs. Added additional example use case for exec function (rpi5b cam set-up).

* Cleanup

Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>

---------

Co-authored-by: Nicolas Mowen <nickmowen213@gmail.com>
Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
2026-04-14 14:00:18 -05:00
Josh HawkinsandGitHub 58c93c2e9e clarify emergency cleanup (#22864) 2026-04-13 07:07:09 -06:00
Abinila SivaandGitHub 6b71feffab Memryx docs update (#22746)
* docs: update MemryX documentation section

* docs: update MemryX documentation section
2026-04-03 11:32:32 -06:00
Abinila SivaandGitHub 1c26bc289e docs: update MemryX docs (#22712)
CI / AMD64 Build (push) Has been cancelled
CI / ARM Build (push) Has been cancelled
CI / Jetson Jetpack 6 (push) Has been cancelled
CI / AMD64 Extra Build (push) Has been cancelled
CI / ARM Extra Build (push) Has been cancelled
CI / Assemble and push default build (push) Has been cancelled
CI / Synaptics Build (push) Has been cancelled
2026-03-31 12:22:23 -05:00
Josh HawkinsandGitHub 0371b60c71 limit access to admin-only websocket topics for viewer users (#22710) 2026-03-31 08:51:55 -05:00
Nicolas MowenandGitHub 01392e03ac Update docs for DEIMv2 support (#22598) 2026-03-23 16:16:54 -06:00
933 changed files with 13852 additions and 87906 deletions
-1
View File
@@ -229,7 +229,6 @@ Reolink
restream restream
restreamed restreamed
restreaming restreaming
RJSF
rkmpp rkmpp
rknn rknn
rkrga rkrga
-16
View File
@@ -324,12 +324,6 @@ try:
value = await sensor.read() value = await sensor.read()
except Exception: # ❌ Too broad except Exception: # ❌ Too broad
logger.error("Failed") logger.error("Failed")
# Returning exceptions in JSON responses
except ValueError as e:
return JSONResponse(
content={"success": False, "message": str(e)},
)
``` ```
### ✅ Use These Instead ### ✅ Use These Instead
@@ -359,16 +353,6 @@ try:
value = await sensor.read() value = await sensor.read()
except SensorException as err: # ✅ Specific except SensorException as err: # ✅ Specific
logger.exception("Failed to read sensor") logger.exception("Failed to read sensor")
# Safe error responses
except ValueError:
logger.exception("Invalid parameters for API request")
return JSONResponse(
content={
"success": False,
"message": "Invalid request parameters",
},
)
``` ```
## Project-Specific Conventions ## Project-Specific Conventions
+5 -44
View File
@@ -1,17 +1,17 @@
_Please read the [contributing guidelines](https://github.com/blakeblackshear/frigate/blob/dev/CONTRIBUTING.md) before submitting a PR._
## Proposed change ## Proposed change
<!-- <!--
Thank you! Thank you!
Describe what this pull request does and how it will benefit users of Frigate.
Please describe in detail any considerations, breaking changes, etc.
If you're introducing a new feature or significantly refactoring existing functionality, If you're introducing a new feature or significantly refactoring existing functionality,
we encourage you to start a discussion first. This helps ensure your idea aligns with we encourage you to start a discussion first. This helps ensure your idea aligns with
Frigate's development goals. Frigate's development goals.
Describe what this pull request does and how it will benefit users of Frigate.
Please describe in detail any considerations, breaking changes, etc. that are
made in this pull request.
--> -->
## Type of change ## Type of change
- [ ] Dependency upgrade - [ ] Dependency upgrade
@@ -26,44 +26,6 @@ _Please read the [contributing guidelines](https://github.com/blakeblackshear/fr
- This PR fixes or closes issue: fixes # - This PR fixes or closes issue: fixes #
- This PR is related to issue: - This PR is related to issue:
## For new features
<!--
Every new feature adds scope that maintainers must test, maintain, and support long-term.
We try to be thoughtful about what we take on, and sometimes that means saying no to
good code if the feature isn't the right fit — or saying yes to something we weren't sure
about. These calls are sometimes subjective, and we won't always get them right. We're
happy to discuss and reconsider.
Linking to an existing feature request or discussion with community interest helps us
understand demand, but a great idea is a great idea even without a crowd behind it.
You can delete this section for bugfixes and non-feature changes.
-->
- [ ] There is an existing feature request or discussion with community interest for this change.
- Link:
## AI disclosure
<!--
We welcome contributions that use AI tools, but we need to understand your relationship
with the code you're submitting. See our AI usage policy in CONTRIBUTING.md for details.
Be honest — this won't disqualify your PR. Trust matters more than method.
-->
- [ ] No AI tools were used in this PR.
- [ ] AI tools were used in this PR. Details below:
**AI tool(s) used** (e.g., Claude, Copilot, ChatGPT, Cursor):
**How AI was used** (e.g., code generation, code review, debugging, documentation):
**Extent of AI involvement** (e.g., generated entire implementation, assisted with specific functions, suggested fixes):
**Human oversight**: Describe what manual review, testing, and validation you performed on the AI-generated portions.
## Checklist ## Checklist
<!-- <!--
@@ -73,6 +35,5 @@ _Please read the [contributing guidelines](https://github.com/blakeblackshear/fr
- [ ] The code change is tested and works locally. - [ ] The code change is tested and works locally.
- [ ] Local tests pass. **Your PR cannot be merged unless tests pass** - [ ] Local tests pass. **Your PR cannot be merged unless tests pass**
- [ ] There is no commented out code in this PR. - [ ] There is no commented out code in this PR.
- [ ] I can explain every line of code in this PR if asked.
- [ ] UI changes including text have used i18n keys and have been added to the `en` locale. - [ ] UI changes including text have used i18n keys and have been added to the `en` locale.
- [ ] The code has been formatted using Ruff (`ruff format frigate`) - [ ] The code has been formatted using Ruff (`ruff format frigate`)
+8 -8
View File
@@ -32,7 +32,7 @@ jobs:
with: with:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push amd64 standard build - name: Build and push amd64 standard build
uses: docker/build-push-action@v7 uses: docker/build-push-action@v5
with: with:
context: . context: .
file: docker/main/Dockerfile file: docker/main/Dockerfile
@@ -56,7 +56,7 @@ jobs:
with: with:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push arm64 standard build - name: Build and push arm64 standard build
uses: docker/build-push-action@v7 uses: docker/build-push-action@v5
with: with:
context: . context: .
file: docker/main/Dockerfile file: docker/main/Dockerfile
@@ -67,7 +67,7 @@ jobs:
${{ steps.setup.outputs.image-name }}-standard-arm64 ${{ steps.setup.outputs.image-name }}-standard-arm64
cache-from: type=registry,ref=${{ steps.setup.outputs.cache-name }}-arm64 cache-from: type=registry,ref=${{ steps.setup.outputs.cache-name }}-arm64
- name: Build and push RPi build - name: Build and push RPi build
uses: docker/bake-action@v7 uses: docker/bake-action@v6
with: with:
source: . source: .
push: true push: true
@@ -96,7 +96,7 @@ jobs:
BASE_IMAGE: nvcr.io/nvidia/tensorrt:23.12-py3-igpu BASE_IMAGE: nvcr.io/nvidia/tensorrt:23.12-py3-igpu
SLIM_BASE: nvcr.io/nvidia/tensorrt:23.12-py3-igpu SLIM_BASE: nvcr.io/nvidia/tensorrt:23.12-py3-igpu
TRT_BASE: nvcr.io/nvidia/tensorrt:23.12-py3-igpu TRT_BASE: nvcr.io/nvidia/tensorrt:23.12-py3-igpu
uses: docker/bake-action@v7 uses: docker/bake-action@v6
with: with:
source: . source: .
push: true push: true
@@ -124,7 +124,7 @@ jobs:
- name: Build and push TensorRT (x86 GPU) - name: Build and push TensorRT (x86 GPU)
env: env:
COMPUTE_LEVEL: "50 60 70 80 90" COMPUTE_LEVEL: "50 60 70 80 90"
uses: docker/bake-action@v7 uses: docker/bake-action@v6
with: with:
source: . source: .
push: true push: true
@@ -137,7 +137,7 @@ jobs:
- name: AMD/ROCm general build - name: AMD/ROCm general build
env: env:
HSA_OVERRIDE: 0 HSA_OVERRIDE: 0
uses: docker/bake-action@v7 uses: docker/bake-action@v6
with: with:
source: . source: .
push: true push: true
@@ -163,7 +163,7 @@ jobs:
with: with:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Rockchip build - name: Build and push Rockchip build
uses: docker/bake-action@v7 uses: docker/bake-action@v6
with: with:
source: . source: .
push: true push: true
@@ -188,7 +188,7 @@ jobs:
with: with:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Synaptics build - name: Build and push Synaptics build
uses: docker/bake-action@v7 uses: docker/bake-action@v6
with: with:
source: . source: .
push: true push: true
-3
View File
@@ -27,9 +27,6 @@ jobs:
- name: Lint - name: Lint
run: npm run lint run: npm run lint
working-directory: ./web working-directory: ./web
- name: Check i18n keys
run: npm run i18n:extract:ci
working-directory: ./web
web_test: web_test:
name: Web - Test name: Web - Test
+1 -3
View File
@@ -3,8 +3,6 @@ __pycache__
.mypy_cache .mypy_cache
*.swp *.swp
debug debug
.claude/*
.mcp.json
.vscode/* .vscode/*
!.vscode/launch.json !.vscode/launch.json
config/* config/*
@@ -21,4 +19,4 @@ web/.env
core core
!/web/**/*.ts !/web/**/*.ts
.idea/* .idea/*
.ipynb_checkpoints .ipynb_checkpoints
-17
View File
@@ -6,23 +6,6 @@
"type": "debugpy", "type": "debugpy",
"request": "launch", "request": "launch",
"module": "frigate" "module": "frigate"
},
{
"type": "editor-browser",
"request": "launch",
"name": "Vite: Launch in integrated browser",
"url": "http://localhost:5173"
},
{
"type": "editor-browser",
"request": "launch",
"name": "Nginx: Launch in integrated browser",
"url": "http://localhost:5000"
},
{
"type": "editor-browser",
"request": "attach",
"name": "Attach to integrated browser"
} }
] ]
} }
-140
View File
@@ -1,140 +0,0 @@
# Contributing to Frigate
Thank you for your interest in contributing to Frigate. This document covers the expectations and guidelines for contributions. Please read it before submitting a pull request.
## Before you start
### Bugfixes
If you've found a bug and want to fix it, go for it. Link to the relevant issue in your PR if one exists, or describe the bug in the PR description.
### New features
Every new feature adds scope that the maintainers must test, maintain, and support long-term. Before writing code for a new feature:
1. **Check for existing discussion.** Search [feature requests](https://github.com/blakeblackshear/frigate/issues) and [discussions](https://github.com/blakeblackshear/frigate/discussions) to see if it's been proposed or discussed. Pinned feature requests are on our radar — we plan to get to them, but we don't maintain a public roadmap or timeline. Check in with us first if you have interest in contributing to one.
2. **Start a discussion or feature request first.** This helps ensure your idea aligns with Frigate's direction before you invest time building it. Community interest in a feature request helps us gauge demand, though a great idea is a great idea even without a crowd behind it.
3. **Be open to "no".** We try to be thoughtful about what we take on, and sometimes that means saying no to good code if the feature isn't the right fit for the project. These calls are sometimes subjective, and we won't always get them right. We're happy to discuss and reconsider.
## AI usage policy
AI tools are a reality of modern development and we're not opposed to their use. But we need to understand your relationship with the code you're submitting. The more AI was involved, the more important it is that you've genuinely reviewed, tested, and understood what it produced.
### Requirements when AI is used
If AI is used to generate any portion of the code, contributors must adhere to the following requirements:
1. **Explicitly disclose the manner in which AI was employed.** The PR template asks for this. Be honest — this won't automatically disqualify your PR. We'd rather have an honest disclosure than find out later. Trust matters more than method.
2. **Perform a comprehensive manual review prior to submitting the pull request.** Don't submit code you haven't read carefully and tested locally.
3. **Be prepared to explain every line of code they submitted when asked about it by a maintainer.** If you can't explain why something works the way it does, you're not ready to submit it.
4. **It is strictly prohibited to use AI to write your posts for you** (bug reports, feature requests, pull request descriptions, GitHub discussions, responding to humans, etc.). We need to hear from _you_, not your AI assistant. These are the spaces where we build trust and understanding with contributors, and that only works if we're talking to each other.
### Established contributors
Contributors with a long history of thoughtful, quality contributions to Frigate have earned trust through that track record. The level of scrutiny we apply to AI usage naturally reflects that trust. This isn't a formal exemption — it's just how trust works. If you've been around, we know how you think and how you work. If you're new, we're still getting to know you, and clear disclosure helps build that relationship.
### What this means in practice
We're not trying to gatekeep how you write code. Use whatever tools make you productive. But there's a difference between using AI as a tool to implement something you understand and handing a feature request to an AI and submitting whatever comes back. The former is fine. The latter creates maintenance risk for the project.
Some honest context: when we review a PR, we're not just evaluating whether the code works today. We're evaluating whether we can maintain it, debug it, and extend it long-term — often without the original author's involvement. Code that the author doesn't deeply understand is code that nobody understands, and that's a liability.
## Pull request guidelines
### Before submitting
- **Search for existing PRs** to avoid duplicating effort.
- **Test your changes locally.** Your PR cannot be merged unless tests pass.
- **Format your code.** Run `ruff format frigate` for Python and `npm run prettier:write` from the `web/` directory for frontend changes.
- **Run the linter.** Run `ruff check frigate` for Python and `npm run lint` from `web/` for frontend.
- **One concern per PR.** Don't combine unrelated changes. A bugfix and a new feature should be separate PRs.
### What we look for in review
- **Does it work?** Tested locally, tests pass, no regressions.
- **Is it maintainable?** Clear code, appropriate complexity, good separation of concerns.
- **Does it fit?** Consistent with Frigate's architecture and design philosophy.
- **Is it scoped well?** Solves the stated problem without unnecessary additions.
### After submitting
- Be responsive to review feedback. We may ask for changes.
- Expect honest, direct feedback. We try to be respectful but we also try to be efficient.
- If your PR goes stale, rebase it on the latest `dev` branch.
## Coding standards
### Python (backend)
- **Python** — use modern language features (type hints, pattern matching, f-strings, dataclasses)
- **Formatting**: Ruff (configured in `pyproject.toml`)
- **Linting**: Ruff
- **Testing**: `python3 -u -m unittest`
- **Logging**: Use module-level `logger = logging.getLogger(__name__)` with lazy formatting
- **Async**: All external I/O must be async. No blocking calls in async functions.
- **Error handling**: Use specific exception types. Keep try blocks minimal.
- **Language**: American English for all code, comments, and documentation
### TypeScript/React (frontend)
- **Linting**: ESLint (`npm run lint` from `web/`)
- **Formatting**: Prettier (`npm run prettier:write` from `web/`)
- **Type safety**: TypeScript strict mode. Avoid `any`.
- **i18n**: All user-facing strings must use `react-i18next`. Never hardcode display text in components. Add English strings to the appropriate files in `web/public/locales/en/`.
- **Components**: Use Radix UI/shadcn primitives and TailwindCSS with the `cn()` utility.
### Development commands
```bash
# Python
python3 -u -m unittest # Run all tests
python3 -u -m unittest frigate.test.test_ffmpeg_presets # Run specific test
ruff format frigate # Format
ruff check frigate # Lint
# Frontend (from web/ directory)
npm run build # Build
npm run lint # Lint
npm run lint:fix # Lint + fix
npm run prettier:write # Format
```
## Project structure
```
frigate/ # Python backend
api/ # FastAPI route handlers
config/ # Configuration parsing and validation
detectors/ # Object detection backends
events/ # Event management and storage
test/ # Backend tests
util/ # Shared utilities
web/ # React/TypeScript frontend
src/
api/ # API client functions
components/ # Reusable components
hooks/ # Custom React hooks
pages/ # Route components
types/ # TypeScript type definitions
views/ # Complex view components
docker/ # Docker build files
docs/ # Documentation site
migrations/ # Database migrations
```
## Translations
Frigate uses [Weblate](https://hosted.weblate.org/projects/frigate-nvr/) for managing language translations. If you'd like to help translate Frigate into your language:
1. Visit the [Frigate project on Weblate](https://hosted.weblate.org/projects/frigate-nvr/).
2. Create an account or log in.
3. Browse the available languages and select the one you'd like to contribute to, or request a new language.
4. Translate strings directly in the Weblate interface — no code changes or pull requests needed.
Translation contributions through Weblate are automatically synced to the repository. Please do not submit pull requests for translation changes — use Weblate instead so that translations are properly tracked and coordinated.
## Resources
- [Documentation](https://docs.frigate.video)
- [Discussions, Support, and Bug Reports](https://github.com/blakeblackshear/frigate/discussions)
- [Feature Requests](https://github.com/blakeblackshear/frigate/issues)
+2 -3
View File
@@ -1,7 +1,7 @@
default_target: local default_target: local
COMMIT_HASH := $(shell git log -1 --pretty=format:"%h"|tail -1) COMMIT_HASH := $(shell git log -1 --pretty=format:"%h"|tail -1)
VERSION = 0.18.0 VERSION = 0.17.2
IMAGE_REPO ?= ghcr.io/blakeblackshear/frigate IMAGE_REPO ?= ghcr.io/blakeblackshear/frigate
GITHUB_REF_NAME ?= $(shell git rev-parse --abbrev-ref HEAD) GITHUB_REF_NAME ?= $(shell git rev-parse --abbrev-ref HEAD)
BOARDS= #Initialized empty BOARDS= #Initialized empty
@@ -49,8 +49,7 @@ push: push-boards
--push --push
run: local run: local
docker run --rm --publish=5000:5000 --publish=8971:8971 \ docker run --rm --publish=5000:5000 --volume=${PWD}/config:/config frigate:latest
--volume=${PWD}/config:/config frigate:latest
run_tests: local run_tests: local
docker run --rm --workdir=/opt/frigate --entrypoint= frigate:latest \ docker run --rm --workdir=/opt/frigate --entrypoint= frigate:latest \
+1 -7
View File
@@ -55,7 +55,7 @@ RUN --mount=type=tmpfs,target=/tmp --mount=type=tmpfs,target=/var/cache/apt \
FROM scratch AS go2rtc FROM scratch AS go2rtc
ARG TARGETARCH ARG TARGETARCH
WORKDIR /rootfs/usr/local/go2rtc/bin WORKDIR /rootfs/usr/local/go2rtc/bin
ADD --link --chmod=755 "https://github.com/AlexxIT/go2rtc/releases/download/v1.9.13/go2rtc_linux_${TARGETARCH}" go2rtc ADD --link --chmod=755 "https://github.com/AlexxIT/go2rtc/releases/download/v1.9.10/go2rtc_linux_${TARGETARCH}" go2rtc
FROM wget AS tempio FROM wget AS tempio
ARG TARGETARCH ARG TARGETARCH
@@ -266,12 +266,6 @@ RUN wget -q https://bootstrap.pypa.io/get-pip.py -O get-pip.py \
RUN --mount=type=bind,from=wheels,source=/wheels,target=/deps/wheels \ RUN --mount=type=bind,from=wheels,source=/wheels,target=/deps/wheels \
pip3 install -U /deps/wheels/*.whl pip3 install -U /deps/wheels/*.whl
# Install Axera Engine
RUN pip3 install https://github.com/AXERA-TECH/pyaxengine/releases/download/0.1.3-frigate/axengine-0.1.3-py3-none-any.whl
ENV PATH="${PATH}:/usr/bin/axcl"
ENV LD_LIBRARY_PATH="${LD_LIBRARY_PATH}:/usr/lib/axcl"
# Install MemryX runtime (requires libgomp (OpenMP) in the final docker image) # Install MemryX runtime (requires libgomp (OpenMP) in the final docker image)
RUN --mount=type=bind,source=docker/main/install_memryx.sh,target=/deps/install_memryx.sh \ RUN --mount=type=bind,source=docker/main/install_memryx.sh,target=/deps/install_memryx.sh \
bash -c "bash /deps/install_memryx.sh" bash -c "bash /deps/install_memryx.sh"
-1
View File
@@ -73,7 +73,6 @@ cd /tmp/nginx
--with-file-aio \ --with-file-aio \
--with-http_sub_module \ --with-http_sub_module \
--with-http_ssl_module \ --with-http_ssl_module \
--with-http_v2_module \
--with-http_auth_request_module \ --with-http_auth_request_module \
--with-http_realip_module \ --with-http_realip_module \
--with-threads \ --with-threads \
+13 -14
View File
@@ -52,7 +52,7 @@ if [[ "${TARGETARCH}" == "amd64" ]]; then
tar -xf ffmpeg.tar.xz -C /usr/lib/ffmpeg/5.0 --strip-components 1 amd64/bin/ffmpeg amd64/bin/ffprobe tar -xf ffmpeg.tar.xz -C /usr/lib/ffmpeg/5.0 --strip-components 1 amd64/bin/ffmpeg amd64/bin/ffprobe
rm -rf ffmpeg.tar.xz rm -rf ffmpeg.tar.xz
mkdir -p /usr/lib/ffmpeg/7.0 mkdir -p /usr/lib/ffmpeg/7.0
wget -qO ffmpeg.tar.xz "https://github.com/NickM-27/FFmpeg-Builds/releases/download/autobuild-2026-03-19-13-03/ffmpeg-n7.1.3-43-g5a1f107b4c-linux64-gpl-7.1.tar.xz" wget -qO ffmpeg.tar.xz "https://github.com/NickM-27/FFmpeg-Builds/releases/download/autobuild-2024-09-19-12-51/ffmpeg-n7.0.2-18-g3e6cec1286-linux64-gpl-7.0.tar.xz"
tar -xf ffmpeg.tar.xz -C /usr/lib/ffmpeg/7.0 --strip-components 1 amd64/bin/ffmpeg amd64/bin/ffprobe tar -xf ffmpeg.tar.xz -C /usr/lib/ffmpeg/7.0 --strip-components 1 amd64/bin/ffmpeg amd64/bin/ffprobe
rm -rf ffmpeg.tar.xz rm -rf ffmpeg.tar.xz
fi fi
@@ -64,7 +64,7 @@ if [[ "${TARGETARCH}" == "arm64" ]]; then
tar -xf ffmpeg.tar.xz -C /usr/lib/ffmpeg/5.0 --strip-components 1 arm64/bin/ffmpeg arm64/bin/ffprobe tar -xf ffmpeg.tar.xz -C /usr/lib/ffmpeg/5.0 --strip-components 1 arm64/bin/ffmpeg arm64/bin/ffprobe
rm -f ffmpeg.tar.xz rm -f ffmpeg.tar.xz
mkdir -p /usr/lib/ffmpeg/7.0 mkdir -p /usr/lib/ffmpeg/7.0
wget -qO ffmpeg.tar.xz "https://github.com/NickM-27/FFmpeg-Builds/releases/download/autobuild-2026-03-19-13-03/ffmpeg-n7.1.3-43-g5a1f107b4c-linuxarm64-gpl-7.1.tar.xz" wget -qO ffmpeg.tar.xz "https://github.com/NickM-27/FFmpeg-Builds/releases/download/autobuild-2024-09-19-12-51/ffmpeg-n7.0.2-18-g3e6cec1286-linuxarm64-gpl-7.0.tar.xz"
tar -xf ffmpeg.tar.xz -C /usr/lib/ffmpeg/7.0 --strip-components 1 arm64/bin/ffmpeg arm64/bin/ffprobe tar -xf ffmpeg.tar.xz -C /usr/lib/ffmpeg/7.0 --strip-components 1 arm64/bin/ffmpeg arm64/bin/ffprobe
rm -f ffmpeg.tar.xz rm -f ffmpeg.tar.xz
fi fi
@@ -105,9 +105,9 @@ if [[ "${TARGETARCH}" == "amd64" ]]; then
# install legacy and standard intel icd and level-zero-gpu # install legacy and standard intel icd and level-zero-gpu
# see https://github.com/intel/compute-runtime/blob/master/LEGACY_PLATFORMS.md for more info # see https://github.com/intel/compute-runtime/blob/master/LEGACY_PLATFORMS.md for more info
# needed core package # needed core package
wget https://github.com/intel/compute-runtime/releases/download/25.13.33276.19/libigdgmm12_22.7.0_amd64.deb wget https://github.com/intel/compute-runtime/releases/download/24.52.32224.5/libigdgmm12_22.5.5_amd64.deb
dpkg -i libigdgmm12_22.7.0_amd64.deb dpkg -i libigdgmm12_22.5.5_amd64.deb
rm libigdgmm12_22.7.0_amd64.deb rm libigdgmm12_22.5.5_amd64.deb
# legacy packages # legacy packages
wget https://github.com/intel/compute-runtime/releases/download/24.35.30872.36/intel-opencl-icd-legacy1_24.35.30872.36_amd64.deb wget https://github.com/intel/compute-runtime/releases/download/24.35.30872.36/intel-opencl-icd-legacy1_24.35.30872.36_amd64.deb
@@ -115,19 +115,18 @@ if [[ "${TARGETARCH}" == "amd64" ]]; then
wget https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-opencl_1.0.17537.24_amd64.deb wget https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-opencl_1.0.17537.24_amd64.deb
wget https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-core_1.0.17537.24_amd64.deb wget https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-core_1.0.17537.24_amd64.deb
# standard packages # standard packages
wget https://github.com/intel/compute-runtime/releases/download/25.13.33276.19/intel-opencl-icd_25.13.33276.19_amd64.deb wget https://github.com/intel/compute-runtime/releases/download/24.52.32224.5/intel-opencl-icd_24.52.32224.5_amd64.deb
wget https://github.com/intel/compute-runtime/releases/download/25.13.33276.19/intel-level-zero-gpu_1.6.33276.19_amd64.deb wget https://github.com/intel/compute-runtime/releases/download/24.52.32224.5/intel-level-zero-gpu_1.6.32224.5_amd64.deb
wget https://github.com/intel/intel-graphics-compiler/releases/download/v2.10.10/intel-igc-opencl-2_2.10.10+18926_amd64.deb wget https://github.com/intel/intel-graphics-compiler/releases/download/v2.5.6/intel-igc-opencl-2_2.5.6+18417_amd64.deb
wget https://github.com/intel/intel-graphics-compiler/releases/download/v2.10.10/intel-igc-core-2_2.10.10+18926_amd64.deb wget https://github.com/intel/intel-graphics-compiler/releases/download/v2.5.6/intel-igc-core-2_2.5.6+18417_amd64.deb
# npu packages # npu packages
wget https://github.com/oneapi-src/level-zero/releases/download/v1.28.2/level-zero_1.28.2+u22.04_amd64.deb wget https://github.com/oneapi-src/level-zero/releases/download/v1.21.9/level-zero_1.21.9+u22.04_amd64.deb
wget https://github.com/intel/linux-npu-driver/releases/download/v1.19.0/intel-driver-compiler-npu_1.19.0.20250707-16111289554_ubuntu22.04_amd64.deb wget https://github.com/intel/linux-npu-driver/releases/download/v1.17.0/intel-driver-compiler-npu_1.17.0.20250508-14912879441_ubuntu22.04_amd64.deb
wget https://github.com/intel/linux-npu-driver/releases/download/v1.19.0/intel-fw-npu_1.19.0.20250707-16111289554_ubuntu22.04_amd64.deb wget https://github.com/intel/linux-npu-driver/releases/download/v1.17.0/intel-fw-npu_1.17.0.20250508-14912879441_ubuntu22.04_amd64.deb
wget https://github.com/intel/linux-npu-driver/releases/download/v1.19.0/intel-level-zero-npu_1.19.0.20250707-16111289554_ubuntu22.04_amd64.deb wget https://github.com/intel/linux-npu-driver/releases/download/v1.17.0/intel-level-zero-npu_1.17.0.20250508-14912879441_ubuntu22.04_amd64.deb
dpkg -i *.deb dpkg -i *.deb
rm *.deb rm *.deb
apt-get -qq install -f -y
fi fi
if [[ "${TARGETARCH}" == "arm64" ]]; then if [[ "${TARGETARCH}" == "arm64" ]]; then
@@ -10,8 +10,7 @@ echo "[INFO] Starting certsync..."
lefile="/etc/letsencrypt/live/frigate/fullchain.pem" lefile="/etc/letsencrypt/live/frigate/fullchain.pem"
tls_enabled=`python3 /usr/local/nginx/get_nginx_settings.py | jq -r .tls.enabled` tls_enabled=`python3 /usr/local/nginx/get_listen_settings.py | jq -r .tls.enabled`
listen_external_port=`python3 /usr/local/nginx/get_nginx_settings.py | jq -r .listen.external_port`
while true while true
do do
@@ -35,7 +34,7 @@ do
;; ;;
esac esac
liveprint=`echo | openssl s_client -showcerts -connect 127.0.0.1:$listen_external_port 2>&1 | openssl x509 -fingerprint 2>&1 | grep -i fingerprint || echo 'failed'` liveprint=`echo | openssl s_client -showcerts -connect 127.0.0.1:8971 2>&1 | openssl x509 -fingerprint 2>&1 | grep -i fingerprint || echo 'failed'`
case "$liveprint" in case "$liveprint" in
*Fingerprint*) *Fingerprint*)
@@ -56,4 +55,4 @@ do
done done
exit 0 exit 0
@@ -80,14 +80,14 @@ if [ ! \( -f "$letsencrypt_path/privkey.pem" -a -f "$letsencrypt_path/fullchain.
fi fi
# build templates for optional FRIGATE_BASE_PATH environment variable # build templates for optional FRIGATE_BASE_PATH environment variable
python3 /usr/local/nginx/get_nginx_settings.py | \ python3 /usr/local/nginx/get_base_path.py | \
tempio -template /usr/local/nginx/templates/base_path.gotmpl \ tempio -template /usr/local/nginx/templates/base_path.gotmpl \
-out /usr/local/nginx/conf/base_path.conf -out /usr/local/nginx/conf/base_path.conf
# build templates for additional network settings # build templates for optional TLS support
python3 /usr/local/nginx/get_nginx_settings.py | \ python3 /usr/local/nginx/get_listen_settings.py | \
tempio -template /usr/local/nginx/templates/listen.gotmpl \ tempio -template /usr/local/nginx/templates/listen.gotmpl \
-out /usr/local/nginx/conf/listen.conf -out /usr/local/nginx/conf/listen.conf
# Replace the bash process with the NGINX process, redirecting stderr to stdout # Replace the bash process with the NGINX process, redirecting stderr to stdout
exec 2>&1 exec 2>&1
@@ -17,36 +17,15 @@ from frigate.const import (
) )
from frigate.ffmpeg_presets import parse_preset_hardware_acceleration_encode from frigate.ffmpeg_presets import parse_preset_hardware_acceleration_encode
from frigate.util.config import find_config_file from frigate.util.config import find_config_file
from frigate.util.services import (
is_go2rtc_arbitrary_exec_allowed,
is_restricted_go2rtc_source,
)
sys.path.remove("/opt/frigate") sys.path.remove("/opt/frigate")
yaml = YAML() yaml = YAML()
# Check if arbitrary exec sources are allowed (defaults to False for security)
allow_arbitrary_exec = None
if "GO2RTC_ALLOW_ARBITRARY_EXEC" in os.environ:
allow_arbitrary_exec = os.environ.get("GO2RTC_ALLOW_ARBITRARY_EXEC")
elif (
os.path.isdir("/run/secrets")
and os.access("/run/secrets", os.R_OK)
and "GO2RTC_ALLOW_ARBITRARY_EXEC" in os.listdir("/run/secrets")
):
allow_arbitrary_exec = (
Path(os.path.join("/run/secrets", "GO2RTC_ALLOW_ARBITRARY_EXEC"))
.read_text()
.strip()
)
# check for the add-on options file
elif os.path.isfile("/data/options.json"):
with open("/data/options.json") as f:
raw_options = f.read()
options = json.loads(raw_options)
allow_arbitrary_exec = options.get("go2rtc_allow_arbitrary_exec")
ALLOW_ARBITRARY_EXEC = allow_arbitrary_exec is not None and str(
allow_arbitrary_exec
).lower() in ("true", "1", "yes")
FRIGATE_ENV_VARS = {k: v for k, v in os.environ.items() if k.startswith("FRIGATE_")} FRIGATE_ENV_VARS = {k: v for k, v in os.environ.items() if k.startswith("FRIGATE_")}
# read docker secret files as env vars too # read docker secret files as env vars too
if os.path.isdir("/run/secrets"): if os.path.isdir("/run/secrets"):
@@ -135,18 +114,13 @@ if LIBAVFORMAT_VERSION_MAJOR < 59:
go2rtc_config["ffmpeg"]["rtsp"] = rtsp_args go2rtc_config["ffmpeg"]["rtsp"] = rtsp_args
def is_restricted_source(stream_source: str) -> bool:
"""Check if a stream source is restricted (echo, expr, or exec)."""
return stream_source.strip().startswith(("echo:", "expr:", "exec:"))
for name in list(go2rtc_config.get("streams", {})): for name in list(go2rtc_config.get("streams", {})):
stream = go2rtc_config["streams"][name] stream = go2rtc_config["streams"][name]
if isinstance(stream, str): if isinstance(stream, str):
try: try:
formatted_stream = stream.format(**FRIGATE_ENV_VARS) formatted_stream = stream.format(**FRIGATE_ENV_VARS)
if not ALLOW_ARBITRARY_EXEC and is_restricted_source(formatted_stream): if is_restricted_go2rtc_source(formatted_stream):
print( print(
f"[ERROR] Stream '{name}' uses a restricted source (echo/expr/exec) which is disabled by default for security. " f"[ERROR] Stream '{name}' uses a restricted source (echo/expr/exec) which is disabled by default for security. "
f"Set GO2RTC_ALLOW_ARBITRARY_EXEC=true to enable arbitrary exec sources." f"Set GO2RTC_ALLOW_ARBITRARY_EXEC=true to enable arbitrary exec sources."
@@ -165,7 +139,7 @@ for name in list(go2rtc_config.get("streams", {})):
for i, stream_item in enumerate(stream): for i, stream_item in enumerate(stream):
try: try:
formatted_stream = stream_item.format(**FRIGATE_ENV_VARS) formatted_stream = stream_item.format(**FRIGATE_ENV_VARS)
if not ALLOW_ARBITRARY_EXEC and is_restricted_source(formatted_stream): if is_restricted_go2rtc_source(formatted_stream):
print( print(
f"[ERROR] Stream '{name}' item {i + 1} uses a restricted source (echo/expr/exec) which is disabled by default for security. " f"[ERROR] Stream '{name}' item {i + 1} uses a restricted source (echo/expr/exec) which is disabled by default for security. "
f"Set GO2RTC_ALLOW_ARBITRARY_EXEC=true to enable arbitrary exec sources." f"Set GO2RTC_ALLOW_ARBITRARY_EXEC=true to enable arbitrary exec sources."
@@ -188,6 +162,20 @@ for name in list(go2rtc_config.get("streams", {})):
) )
del go2rtc_config["streams"][name] del go2rtc_config["streams"][name]
elif isinstance(stream, dict):
# The map form ({"url": ...}) lets go2rtc resolve the source
# recursively, so it is effectively a dynamic way to generate the URL
# for a stream. That can only be backed by an exec source, so it cannot
# be allowed unless arbitrary exec is explicitly enabled. When it is
# enabled, leave the map untouched for go2rtc to resolve.
if not is_go2rtc_arbitrary_exec_allowed():
print(
f"[ERROR] Stream '{name}' uses a dynamic source format which is disabled by default for security. "
f"Set GO2RTC_ALLOW_ARBITRARY_EXEC=true to enable arbitrary exec sources."
)
del go2rtc_config["streams"][name]
continue
# add birdseye restream stream if enabled # add birdseye restream stream if enabled
if config.get("birdseye", {}).get("restream", False): if config.get("birdseye", {}).get("restream", False):
birdseye: dict[str, Any] = config.get("birdseye") birdseye: dict[str, Any] = config.get("birdseye")
@@ -63,9 +63,6 @@ http {
server { server {
include listen.conf; include listen.conf;
# enable HTTP/2 for TLS connections to eliminate browser 6-connection limit
http2 on;
# vod settings # vod settings
vod_base_url ''; vod_base_url '';
vod_segments_base_url ''; vod_segments_base_url '';
@@ -262,6 +259,7 @@ http {
include proxy.conf; include proxy.conf;
proxy_cache api_cache; proxy_cache api_cache;
proxy_cache_key "$scheme$proxy_host$request_uri|$role|$groups|$user";
proxy_cache_lock on; proxy_cache_lock on;
proxy_cache_use_stale updating; proxy_cache_use_stale updating;
proxy_cache_valid 200 5s; proxy_cache_valid 200 5s;
@@ -0,0 +1,11 @@
"""Prints the base path as json to stdout."""
import json
import os
from typing import Any
base_path = os.environ.get("FRIGATE_BASE_PATH", "")
result: dict[str, Any] = {"base_path": base_path}
print(json.dumps(result))
@@ -0,0 +1,35 @@
"""Prints the tls config as json to stdout."""
import json
import sys
from typing import Any
from ruamel.yaml import YAML
sys.path.insert(0, "/opt/frigate")
from frigate.util.config import find_config_file
sys.path.remove("/opt/frigate")
yaml = YAML()
config_file = find_config_file()
try:
with open(config_file) as f:
raw_config = f.read()
if config_file.endswith((".yaml", ".yml")):
config: dict[str, Any] = yaml.load(raw_config)
elif config_file.endswith(".json"):
config: dict[str, Any] = json.loads(raw_config)
except FileNotFoundError:
config: dict[str, Any] = {}
tls_config: dict[str, any] = config.get("tls", {"enabled": True})
networking_config = config.get("networking", {})
ipv6_config = networking_config.get("ipv6", {"enabled": False})
output = {"tls": tls_config, "ipv6": ipv6_config}
print(json.dumps(output))
@@ -1,62 +0,0 @@
"""Prints the nginx settings as json to stdout."""
import json
import os
import sys
from typing import Any
from ruamel.yaml import YAML
sys.path.insert(0, "/opt/frigate")
from frigate.util.config import find_config_file
sys.path.remove("/opt/frigate")
yaml = YAML()
config_file = find_config_file()
try:
with open(config_file) as f:
raw_config = f.read()
if config_file.endswith((".yaml", ".yml")):
config: dict[str, Any] = yaml.load(raw_config)
elif config_file.endswith(".json"):
config: dict[str, Any] = json.loads(raw_config)
except FileNotFoundError:
config: dict[str, Any] = {}
tls_config: dict[str, Any] = config.get("tls", {})
tls_config.setdefault("enabled", True)
networking_config: dict[str, Any] = config.get("networking", {})
ipv6_config: dict[str, Any] = networking_config.get("ipv6", {})
ipv6_config.setdefault("enabled", False)
listen_config: dict[str, Any] = networking_config.get("listen", {})
listen_config.setdefault("internal", 5000)
listen_config.setdefault("external", 8971)
# handle case where internal port is a string with ip:port
internal_port = listen_config["internal"]
if type(internal_port) is str:
internal_port = int(internal_port.split(":")[-1])
listen_config["internal_port"] = internal_port
# handle case where external port is a string with ip:port
external_port = listen_config["external"]
if type(external_port) is str:
external_port = int(external_port.split(":")[-1])
listen_config["external_port"] = external_port
base_path = os.environ.get("FRIGATE_BASE_PATH", "")
result: dict[str, Any] = {
"tls": tls_config,
"ipv6": ipv6_config,
"listen": listen_config,
"base_path": base_path,
}
print(json.dumps(result))
@@ -7,7 +7,7 @@ location ^~ {{ .base_path }}/ {
# remove base_url from the path before passing upstream # remove base_url from the path before passing upstream
rewrite ^{{ .base_path }}/(.*) /$1 break; rewrite ^{{ .base_path }}/(.*) /$1 break;
proxy_pass $scheme://127.0.0.1:{{ .listen.external_port }}; proxy_pass $scheme://127.0.0.1:8971;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade"; proxy_set_header Connection "upgrade";
@@ -1,36 +1,45 @@
# Internal (IPv4 always; IPv6 optional) # Internal (IPv4 always; IPv6 optional)
listen {{ .listen.internal }}; listen 5000;
{{ if .ipv6.enabled }}listen [::]:{{ .listen.internal_port }};{{ end }} {{ if .ipv6 }}{{ if .ipv6.enabled }}listen [::]:5000;{{ end }}{{ end }}
# intended for external traffic, protected by auth # intended for external traffic, protected by auth
{{ if .tls.enabled }} {{ if .tls }}
# external HTTPS (IPv4 always; IPv6 optional) {{ if .tls.enabled }}
listen {{ .listen.external }} ssl; # external HTTPS (IPv4 always; IPv6 optional)
{{ if .ipv6.enabled }}listen [::]:{{ .listen.external_port }} ssl;{{ end }} listen 8971 ssl;
{{ if .ipv6 }}{{ if .ipv6.enabled }}listen [::]:8971 ssl;{{ end }}{{ end }}
ssl_certificate /etc/letsencrypt/live/frigate/fullchain.pem; ssl_certificate /etc/letsencrypt/live/frigate/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/frigate/privkey.pem; ssl_certificate_key /etc/letsencrypt/live/frigate/privkey.pem;
# generated 2024-06-01, Mozilla Guideline v5.7, nginx 1.25.3, OpenSSL 1.1.1w, modern configuration, no OCSP # generated 2024-06-01, Mozilla Guideline v5.7, nginx 1.25.3, OpenSSL 1.1.1w, modern configuration, no OCSP
# https://ssl-config.mozilla.org/#server=nginx&version=1.25.3&config=modern&openssl=1.1.1w&ocsp=false&guideline=5.7 # https://ssl-config.mozilla.org/#server=nginx&version=1.25.3&config=modern&openssl=1.1.1w&ocsp=false&guideline=5.7
ssl_session_timeout 1d; ssl_session_timeout 1d;
ssl_session_cache shared:MozSSL:10m; # about 40000 sessions ssl_session_cache shared:MozSSL:10m; # about 40000 sessions
ssl_session_tickets off; ssl_session_tickets off;
# modern configuration # modern configuration
ssl_protocols TLSv1.3; ssl_protocols TLSv1.3;
ssl_prefer_server_ciphers off; ssl_prefer_server_ciphers off;
# HSTS (ngx_http_headers_module is required) (63072000 seconds) # HSTS (ngx_http_headers_module is required) (63072000 seconds)
add_header Strict-Transport-Security "max-age=63072000" always; add_header Strict-Transport-Security "max-age=63072000" always;
# ACME challenge location # ACME challenge location
location /.well-known/acme-challenge/ { location /.well-known/acme-challenge/ {
default_type "text/plain"; default_type "text/plain";
root /etc/letsencrypt/www; root /etc/letsencrypt/www;
} }
{{ else }}
# external HTTP (IPv4 always; IPv6 optional)
listen 8971;
{{ if .ipv6 }}{{ if .ipv6.enabled }}listen [::]:8971;{{ end }}{{ end }}
{{ end }}
{{ else }} {{ else }}
# (No tls) default to HTTP (IPv4 always; IPv6 optional) # (No tls section) default to HTTP (IPv4 always; IPv6 optional)
listen {{ .listen.external }}; listen 8971;
{{ if .ipv6.enabled }}listen [::]:{{ .listen.external_port }};{{ end }} {{ if .ipv6 }}{{ if .ipv6.enabled }}listen [::]:8971;{{ end }}{{ end }}
{{ end }} {{ end }}
+3 -7
View File
@@ -13,7 +13,7 @@ ARG ROCM
RUN apt update -qq && \ RUN apt update -qq && \
apt install -y wget gpg && \ apt install -y wget gpg && \
wget -O rocm.deb https://repo.radeon.com/amdgpu-install/7.2/ubuntu/jammy/amdgpu-install_7.2.70200-1_all.deb && \ wget -O rocm.deb https://repo.radeon.com/amdgpu-install/7.1.1/ubuntu/jammy/amdgpu-install_7.1.1.70101-1_all.deb && \
apt install -y ./rocm.deb && \ apt install -y ./rocm.deb && \
apt update && \ apt update && \
apt install -qq -y rocm apt install -qq -y rocm
@@ -56,17 +56,13 @@ FROM scratch AS rocm-dist
ARG ROCM ARG ROCM
# Copy HIP headers required for MIOpen JIT (BuildHip) / HIPRTC at runtime
COPY --from=rocm /opt/rocm-${ROCM}/include/ /opt/rocm-${ROCM}/include/
COPY --from=rocm /opt/rocm-$ROCM/bin/rocminfo /opt/rocm-$ROCM/bin/migraphx-driver /opt/rocm-$ROCM/bin/ COPY --from=rocm /opt/rocm-$ROCM/bin/rocminfo /opt/rocm-$ROCM/bin/migraphx-driver /opt/rocm-$ROCM/bin/
# Copy MIOpen database files for gfx10xx, gfx11xx, and gfx12xx only (RDNA2/RDNA3/RDNA4) # Copy MIOpen database files for gfx10xx and gfx11xx only (RDNA2/RDNA3)
COPY --from=rocm /opt/rocm-$ROCM/share/miopen/db/*gfx10* /opt/rocm-$ROCM/share/miopen/db/ COPY --from=rocm /opt/rocm-$ROCM/share/miopen/db/*gfx10* /opt/rocm-$ROCM/share/miopen/db/
COPY --from=rocm /opt/rocm-$ROCM/share/miopen/db/*gfx11* /opt/rocm-$ROCM/share/miopen/db/ COPY --from=rocm /opt/rocm-$ROCM/share/miopen/db/*gfx11* /opt/rocm-$ROCM/share/miopen/db/
COPY --from=rocm /opt/rocm-$ROCM/share/miopen/db/*gfx12* /opt/rocm-$ROCM/share/miopen/db/ # Copy rocBLAS library files for gfx10xx and gfx11xx only
# Copy rocBLAS library files for gfx10xx, gfx11xx, and gfx12xx only
COPY --from=rocm /opt/rocm-$ROCM/lib/rocblas/library/*gfx10* /opt/rocm-$ROCM/lib/rocblas/library/ COPY --from=rocm /opt/rocm-$ROCM/lib/rocblas/library/*gfx10* /opt/rocm-$ROCM/lib/rocblas/library/
COPY --from=rocm /opt/rocm-$ROCM/lib/rocblas/library/*gfx11* /opt/rocm-$ROCM/lib/rocblas/library/ COPY --from=rocm /opt/rocm-$ROCM/lib/rocblas/library/*gfx11* /opt/rocm-$ROCM/lib/rocblas/library/
COPY --from=rocm /opt/rocm-$ROCM/lib/rocblas/library/*gfx12* /opt/rocm-$ROCM/lib/rocblas/library/
COPY --from=rocm /opt/rocm-dist/ / COPY --from=rocm /opt/rocm-dist/ /
####################################################################### #######################################################################
+1 -1
View File
@@ -1 +1 @@
onnxruntime-migraphx @ https://github.com/NickM-27/frigate-onnxruntime-rocm/releases/download/v7.2.0/onnxruntime_migraphx-1.23.1-cp311-cp311-linux_x86_64.whl onnxruntime-migraphx @ https://github.com/NickM-27/frigate-onnxruntime-rocm/releases/download/v7.1.0/onnxruntime_migraphx-1.23.1-cp311-cp311-linux_x86_64.whl
+1 -1
View File
@@ -1,5 +1,5 @@
variable "ROCM" { variable "ROCM" {
default = "7.2.0" default = "7.1.1"
} }
variable "HSA_OVERRIDE_GFX_VERSION" { variable "HSA_OVERRIDE_GFX_VERSION" {
default = "" default = ""
+14 -14
View File
@@ -1,18 +1,18 @@
# Nvidia ONNX Runtime GPU Support # NVidia TensorRT Support (amd64 only)
--extra-index-url 'https://pypi.nvidia.com' --extra-index-url 'https://pypi.nvidia.com'
cython==3.0.*; platform_machine == 'x86_64' cython==3.0.*; platform_machine == 'x86_64'
nvidia-cuda-cupti-cu12==12.8.90; platform_machine == 'x86_64' nvidia_cuda_cupti_cu12==12.5.82; platform_machine == 'x86_64'
nvidia-cublas-cu12==12.8.4.1; platform_machine == 'x86_64' nvidia-cublas-cu12==12.5.3.*; platform_machine == 'x86_64'
nvidia-cudnn-cu12==9.8.0.87; platform_machine == 'x86_64' nvidia-cudnn-cu12==9.3.0.*; platform_machine == 'x86_64'
nvidia-cufft-cu12==11.3.3.83; platform_machine == 'x86_64' nvidia-cufft-cu12==11.2.3.*; platform_machine == 'x86_64'
nvidia-curand-cu12==10.3.9.90; platform_machine == 'x86_64' nvidia-curand-cu12==10.3.6.*; platform_machine == 'x86_64'
nvidia-cuda-nvcc-cu12==12.8.93; platform_machine == 'x86_64' nvidia_cuda_nvcc_cu12==12.5.82; platform_machine == 'x86_64'
nvidia-cuda-nvrtc-cu12==12.8.93; platform_machine == 'x86_64' nvidia-cuda-nvrtc-cu12==12.5.82; platform_machine == 'x86_64'
nvidia-cuda-runtime-cu12==12.8.90; platform_machine == 'x86_64' nvidia_cuda_runtime_cu12==12.5.82; platform_machine == 'x86_64'
nvidia-cusolver-cu12==11.7.3.90; platform_machine == 'x86_64' nvidia_cusolver_cu12==11.6.3.*; platform_machine == 'x86_64'
nvidia-cusparse-cu12==12.5.8.93; platform_machine == 'x86_64' nvidia_cusparse_cu12==12.5.1.*; platform_machine == 'x86_64'
nvidia-nccl-cu12==2.26.2.post1; platform_machine == 'x86_64' nvidia_nccl_cu12==2.23.4; platform_machine == 'x86_64'
nvidia-nvjitlink-cu12==12.8.93; platform_machine == 'x86_64' nvidia_nvjitlink_cu12==12.5.82; 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.22.*; platform_machine == 'x86_64'
protobuf==3.20.3; platform_machine == 'x86_64' protobuf==3.20.3; platform_machine == 'x86_64'
+22 -21
View File
@@ -163,32 +163,33 @@ services:
### Enabling IPv6 ### Enabling IPv6
IPv6 is disabled by default, to enable IPv6 modify your Frigate configuration as follows: IPv6 is disabled by default, to enable IPv6 listen.gotmpl needs to be bind mounted with IPv6 enabled. For example:
```yaml ```
networking: {{ if not .enabled }}
ipv6: # intended for external traffic, protected by auth
enabled: True listen 8971;
{{ else }}
# intended for external traffic, protected by auth
listen 8971 ssl;
# intended for internal traffic, not protected by auth
listen 5000;
``` ```
### Listen on different ports becomes
You can change the ports Nginx uses for listening using Frigate's configuration file. The internal port (unauthenticated) and external port (authenticated) can be changed independently. You can also specify an IP address using the format `ip:port` if you wish to bind the port to a specific interface. This may be useful for example to prevent exposing the internal port outside the container.
For example:
```yaml
networking:
listen:
internal: 127.0.0.1:5000
external: 8971
``` ```
{{ if not .enabled }}
# intended for external traffic, protected by auth
listen [::]:8971 ipv6only=off;
{{ else }}
# intended for external traffic, protected by auth
listen [::]:8971 ipv6only=off ssl;
:::warning # intended for internal traffic, not protected by auth
listen [::]:5000 ipv6only=off;
This setting is for advanced users. For the majority of use cases it's recommended to change the `ports` section of your Docker compose file or use the Docker `run` `--publish` option instead, e.g. `-p 443:8971`. Changing Frigate's ports may break some integrations. ```
:::
## Base path ## Base path
@@ -241,7 +242,7 @@ To do this:
### Custom go2rtc version ### Custom go2rtc version
Frigate currently includes go2rtc v1.9.13, there may be certain cases where you want to run a different version of go2rtc. Frigate currently includes go2rtc v1.9.10, there may be certain cases where you want to run a different version of go2rtc.
To do this: To do this:
+1 -1
View File
@@ -246,7 +246,7 @@ go2rtc:
- rtspx://192.168.1.1:7441/abcdefghijk - rtspx://192.168.1.1:7441/abcdefghijk
``` ```
[See the go2rtc docs for more information](https://github.com/AlexxIT/go2rtc/tree/v1.9.13#source-rtsp) [See the go2rtc docs for more information](https://github.com/AlexxIT/go2rtc/tree/v1.9.10#source-rtsp)
In the Unifi 2.0 update Unifi Protect Cameras had a change in audio sample rate which causes issues for ffmpeg. The input rate needs to be set for record if used directly with unifi protect. In the Unifi 2.0 update Unifi Protect Cameras had a change in audio sample rate which causes issues for ffmpeg. The input rate needs to be set for record if used directly with unifi protect.
+1 -1
View File
@@ -9,7 +9,7 @@ Face recognition identifies known individuals by matching detected faces with pr
### Face Detection ### Face Detection
When running a Frigate+ model (or any custom model that natively detects faces) should ensure that `face` is added to the [list of objects to track](../plus/#available-label-types) either globally or for a specific camera. This will allow face detection to run at the same time as object detection and be more efficient. When running a Frigate+ model (or any custom model that natively detects faces) should ensure that `face` is added to the [list of objects to track](../plus/index.md#available-label-types) either globally or for a specific camera. This will allow face detection to run at the same time as object detection and be more efficient.
When running a default COCO model or another model that does not include `face` as a detectable label, face detection will run via CV2 using a lightweight DNN model that runs on the CPU. In this case, you should _not_ define `face` in your list of objects to track. When running a default COCO model or another model that does not include `face` as a detectable label, face detection will run via CV2 using a lightweight DNN model that runs on the CPU. In this case, you should _not_ define `face` in your list of objects to track.
+56 -134
View File
@@ -5,167 +5,89 @@ title: Configuring Generative AI
## Configuration ## Configuration
A Generative AI provider can be configured in the global config, which will make the Generative AI features available for use. There are currently 4 native providers available to integrate with Frigate. Other providers that support the OpenAI standard API can also be used. See the OpenAI-Compatible section below. A Generative AI provider can be configured in the global config, which will make the Generative AI features available for use. There are currently 3 native providers available to integrate with Frigate. Other providers that support the OpenAI standard API can also be used. See the OpenAI section below.
To use Generative AI, you must define a single provider at the global level of your Frigate configuration. 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_`. To use Generative AI, you must define a single provider at the global level of your Frigate configuration. 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_`.
## Local Providers ## Ollama
Local providers run on your own hardware and keep all data processing private. These require a GPU or dedicated hardware for best performance.
:::warning :::warning
Running Generative AI models on CPU is not recommended, as high inference times make using Generative AI impractical. Using Ollama on CPU is not recommended, high inference times make using Generative AI impractical.
::: :::
### Recommended Local Models
You must use a vision-capable model with Frigate. The following models are recommended for local deployment:
| Model | Notes |
| ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `qwen3-vl` | Strong visual and situational understanding, strong ability to identify smaller objects and interactions with object. |
| `qwen3.5` | Strong situational understanding, but missing DeepStack from qwen3-vl leading to worse performance for identifying objects in people's hand and other small details. |
| `Intern3.5VL` | Relatively fast with good vision comprehension |
| `gemma3` | Slower model with good vision and temporal understanding |
| `qwen2.5-vl` | Fast but capable model with good vision comprehension |
:::info
Each model is available in multiple parameter sizes (3b, 4b, 8b, etc.). Larger sizes are more capable of complex tasks and understanding of situations, but requires more memory and computational resources. It is recommended to try multiple models and experiment to see which performs best.
:::
:::note
You should have at least 8 GB of RAM available (or VRAM if running on GPU) to run the 7B models, 16 GB to run the 13B models, and 24 GB to run the 33B models.
:::
### Model Types: Instruct vs Thinking
Most vision-language models are available as **instruct** models, which are fine-tuned to follow instructions and respond concisely to prompts. However, some models (such as certain Qwen-VL or minigpt variants) offer both **instruct** and **thinking** versions.
- **Instruct models** are always recommended for use with Frigate. These models generate direct, relevant, actionable descriptions that best fit Frigate's object and event summary use case.
- **Reasoning / Thinking models** are fine-tuned for more free-form, open-ended, and speculative outputs, which are typically not concise and may not provide the practical summaries Frigate expects. For this reason, Frigate does **not** recommend or support using thinking models.
Some models are labeled as **hybrid** (capable of both thinking and instruct tasks). In these cases, it is recommended to disable reasoning / thinking, which is generally model specific (see your models documentation).
**Recommendation:**
Always select the `-instruct` or documented instruct/tagged variant of any model you use in your Frigate configuration. If in doubt, refer to your model provider's documentation or model library for guidance on the correct model variant to use.
### llama.cpp
[llama.cpp](https://github.com/ggml-org/llama.cpp) is a C++ implementation of LLaMA that provides a high-performance inference server.
It is highly recommended to host the llama.cpp server on a machine with a discrete graphics card, or on an Apple silicon Mac for best performance.
#### Supported Models
You must use a vision capable model with Frigate. The llama.cpp server supports various vision models in GGUF format.
#### Configuration
All llama.cpp native options can be passed through `provider_options`, including `temperature`, `top_k`, `top_p`, `min_p`, `repeat_penalty`, `repeat_last_n`, `seed`, `grammar`, and more. See the [llama.cpp server documentation](https://github.com/ggml-org/llama.cpp/blob/master/tools/server/README.md) for a complete list of available parameters.
```yaml
genai:
provider: llamacpp
base_url: http://localhost:8080
model: your-model-name
provider_options:
context_size: 16000 # Tell Frigate your context size so it can send the appropriate amount of information.
```
### 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.
Most of the 7b parameter 4-bit vision models will fit inside 8GB of VRAM. There is also a [Docker container](https://hub.docker.com/r/ollama/ollama) available. Most of the 7b parameter 4-bit vision models will fit inside 8GB of VRAM. There is also a [Docker container](https://hub.docker.com/r/ollama/ollama) available.
Parallel requests also come with some caveats. You will need to set `OLLAMA_NUM_PARALLEL=1` and choose a `OLLAMA_MAX_QUEUE` and `OLLAMA_MAX_LOADED_MODELS` values that are appropriate for your hardware and preferences. See the [Ollama documentation](https://docs.ollama.com/faq#how-does-ollama-handle-concurrent-requests). Parallel requests also come with some caveats. You will need to set `OLLAMA_NUM_PARALLEL=1` and choose a `OLLAMA_MAX_QUEUE` and `OLLAMA_MAX_LOADED_MODELS` values that are appropriate for your hardware and preferences. See the [Ollama documentation](https://docs.ollama.com/faq#how-does-ollama-handle-concurrent-requests).
### Model Types: Instruct vs Thinking
Most vision-language models are available as **instruct** models, which are fine-tuned to follow instructions and respond concisely to prompts. However, some models (such as certain Qwen-VL or minigpt variants) offer both **instruct** and **thinking** versions.
- **Instruct models** are always recommended for use with Frigate. These models generate direct, relevant, actionable descriptions that best fit Frigate's object and event summary use case.
- **Thinking models** are fine-tuned for more free-form, open-ended, and speculative outputs, which are typically not concise and may not provide the practical summaries Frigate expects. For this reason, Frigate does **not** recommend or support using thinking models.
Some models are labeled as **hybrid** (capable of both thinking and instruct tasks). In these cases, Frigate will always use instruct-style prompts and specifically disables thinking-mode behaviors to ensure concise, useful responses.
**Recommendation:**
Always select the `-instruct` or documented instruct/tagged variant of any model you use in your Frigate configuration. If in doubt, refer to your model providers documentation or model library for guidance on the correct model variant to use.
### Supported Models
You must use a vision capable model with Frigate. Current model variants can be found [in their model library](https://ollama.com/library). Note that Frigate will not automatically download the model you specify in your config, Ollama will try to download the model but it may take longer than the timeout, it is recommended to pull the model beforehand by running `ollama pull your_model` on your Ollama server/Docker container. Note that the model specified in Frigate's config must match the downloaded model tag.
:::info
Each model is available in multiple parameter sizes (3b, 4b, 8b, etc.). Larger sizes are more capable of complex tasks and understanding of situations, but requires more memory and computational resources. It is recommended to try multiple models and experiment to see which performs best.
:::
:::tip :::tip
If you are trying to use a single model for Frigate and HomeAssistant, it will need to support vision and tools calling. qwen3-VL supports vision and tools simultaneously in Ollama. If you are trying to use a single model for Frigate and HomeAssistant, it will need to support vision and tools calling. qwen3-VL supports vision and tools simultaneously in Ollama.
::: :::
Note that Frigate will not automatically download the model you specify in your config. Ollama will try to download the model but it may take longer than the timeout, so it is recommended to pull the model beforehand by running `ollama pull your_model` on your Ollama server/Docker container. The model specified in Frigate's config must match the downloaded model tag. The following models are recommended:
#### Configuration | Model | Notes |
| ------------- | -------------------------------------------------------------------- |
| `qwen3-vl` | Strong visual and situational understanding, higher vram requirement |
| `Intern3.5VL` | Relatively fast with good vision comprehension |
| `gemma3` | Strong frame-to-frame understanding, slower inference times |
| `qwen2.5-vl` | Fast but capable model with good vision comprehension |
:::note
You should have at least 8 GB of RAM available (or VRAM if running on GPU) to run the 7B models, 16 GB to run the 13B models, and 32 GB to run the 33B models.
:::
#### Ollama Cloud models
Ollama also supports [cloud models](https://ollama.com/cloud), where your local Ollama instance handles requests from Frigate, but model inference is performed in the cloud. Set up Ollama locally, sign in with your Ollama account, and specify the cloud model name in your Frigate config. For more details, see the Ollama cloud model [docs](https://docs.ollama.com/cloud).
### Configuration
```yaml ```yaml
genai: genai:
provider: ollama provider: ollama
base_url: http://localhost:11434 base_url: http://localhost:11434
model: qwen3-vl:4b model: qwen3-vl:4b
provider_options: # other Ollama client options can be defined
keep_alive: -1
options:
num_ctx: 8192 # make sure the context matches other services that are using ollama
``` ```
### OpenAI-Compatible ## Google Gemini
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.
:::tip
For OpenAI-compatible servers (such as llama.cpp) that don't expose the configured context size in the API response, you can manually specify the context size in `provider_options`:
```yaml
genai:
provider: openai
base_url: http://your-llama-server
model: your-model-name
provider_options:
context_size: 8192 # Specify the configured context size
```
This ensures Frigate uses the correct context window size when generating prompts.
:::
#### Configuration
```yaml
genai:
provider: openai
base_url: http://your-server:port
api_key: your-api-key # May not be required for local servers
model: your-model-name
```
To use a different OpenAI-compatible API endpoint, set the `OPENAI_BASE_URL` environment variable to your provider's API URL.
## Cloud Providers
Cloud providers run on remote infrastructure and require an API key for authentication. These services handle all model inference on their servers.
### Ollama Cloud
Ollama also supports [cloud models](https://ollama.com/cloud), where your local Ollama instance handles requests from Frigate, but model inference is performed in the cloud. Set up Ollama locally, sign in with your Ollama account, and specify the cloud model name in your Frigate config. For more details, see the Ollama cloud model [docs](https://docs.ollama.com/cloud).
#### Configuration
```yaml
genai:
provider: ollama
base_url: http://localhost:11434
model: cloud-model-name
```
### Google Gemini
Google Gemini has a [free tier](https://ai.google.dev/pricing) for the API, however the limits may not be sufficient for standard Frigate usage. Choose a plan appropriate for your installation. Google Gemini has a [free tier](https://ai.google.dev/pricing) for the API, however the limits may not be sufficient for standard Frigate usage. Choose a plan appropriate for your installation.
#### Supported Models ### Supported Models
You must use a vision capable model with Frigate. Current model variants can be found [in their documentation](https://ai.google.dev/gemini-api/docs/models/gemini). You must use a vision capable model with Frigate. Current model variants can be found [in their documentation](https://ai.google.dev/gemini-api/docs/models/gemini).
#### Get API Key ### Get API Key
To start using Gemini, you must first get an API key from [Google AI Studio](https://aistudio.google.com). To start using Gemini, you must first get an API key from [Google AI Studio](https://aistudio.google.com).
@@ -174,7 +96,7 @@ To start using Gemini, you must first get an API key from [Google AI Studio](htt
3. Click "Create API key in new project" 3. Click "Create API key in new project"
4. Copy the API key for use in your config 4. Copy the API key for use in your config
#### Configuration ### Configuration
```yaml ```yaml
genai: genai:
@@ -199,19 +121,19 @@ Other HTTP options are available, see the [python-genai documentation](https://g
::: :::
### OpenAI ## OpenAI
OpenAI does not have a free tier for their API. With the release of gpt-4o, pricing has been reduced and each generation should cost fractions of a cent if you choose to go this route. OpenAI does not have a free tier for their API. With the release of gpt-4o, pricing has been reduced and each generation should cost fractions of a cent if you choose to go this route.
#### Supported Models ### Supported Models
You must use a vision capable model with Frigate. Current model variants can be found [in their documentation](https://platform.openai.com/docs/models). You must use a vision capable model with Frigate. Current model variants can be found [in their documentation](https://platform.openai.com/docs/models).
#### Get API Key ### Get API Key
To start using OpenAI, you must first [create an API key](https://platform.openai.com/api-keys) and [configure billing](https://platform.openai.com/settings/organization/billing/overview). To start using OpenAI, you must first [create an API key](https://platform.openai.com/api-keys) and [configure billing](https://platform.openai.com/settings/organization/billing/overview).
#### Configuration ### Configuration
```yaml ```yaml
genai: genai:
@@ -243,19 +165,19 @@ This ensures Frigate uses the correct context window size when generating prompt
::: :::
### Azure OpenAI ## Azure OpenAI
Microsoft offers several vision models through Azure OpenAI. A subscription is required. Microsoft offers several vision models through Azure OpenAI. A subscription is required.
#### Supported Models ### Supported Models
You must use a vision capable model with Frigate. Current model variants can be found [in their documentation](https://learn.microsoft.com/en-us/azure/ai-services/openai/concepts/models). You must use a vision capable model with Frigate. Current model variants can be found [in their documentation](https://learn.microsoft.com/en-us/azure/ai-services/openai/concepts/models).
#### Create Resource and Get API Key ### Create Resource and Get API Key
To start using Azure OpenAI, you must first [create a resource](https://learn.microsoft.com/azure/cognitive-services/openai/how-to/create-resource?pivots=web-portal#create-a-resource). You'll need your API key, model name, and resource URL, which must include the `api-version` parameter (see the example below). To start using Azure OpenAI, you must first [create a resource](https://learn.microsoft.com/azure/cognitive-services/openai/how-to/create-resource?pivots=web-portal#create-a-resource). You'll need your API key, model name, and resource URL, which must include the `api-version` parameter (see the example below).
#### Configuration ### Configuration
```yaml ```yaml
genai: genai:
+1 -1
View File
@@ -11,7 +11,7 @@ By default, descriptions will be generated for all tracked objects and all zones
Optionally, you can generate the description using a snapshot (if enabled) by setting `use_snapshot` to `True`. By default, this is set to `False`, which sends the uncompressed images from the `detect` stream collected over the object's lifetime to the model. Once the object lifecycle ends, only a single compressed and cropped thumbnail is saved with the tracked object. Using a snapshot might be useful when you want to _regenerate_ a tracked object's description as it will provide the AI with a higher-quality image (typically downscaled by the AI itself) than the cropped/compressed thumbnail. Using a snapshot otherwise has a trade-off in that only a single image is sent to your provider, which will limit the model's ability to determine object movement or direction. Optionally, you can generate the description using a snapshot (if enabled) by setting `use_snapshot` to `True`. By default, this is set to `False`, which sends the uncompressed images from the `detect` stream collected over the object's lifetime to the model. Once the object lifecycle ends, only a single compressed and cropped thumbnail is saved with the tracked object. Using a snapshot might be useful when you want to _regenerate_ a tracked object's description as it will provide the AI with a higher-quality image (typically downscaled by the AI itself) than the cropped/compressed thumbnail. Using a snapshot otherwise has a trade-off in that only a single image is sent to your provider, which will limit the model's ability to determine object movement or direction.
Generative AI object descriptions can also be toggled dynamically for a camera via MQTT with the topic `frigate/<camera_name>/object_descriptions/set`. See the [MQTT documentation](/integrations/mqtt#frigatecamera_nameobject_descriptionsset). Generative AI object descriptions can also be toggled dynamically for a camera via MQTT with the topic `frigate/<camera_name>/object_descriptions/set`. See the [MQTT documentation](/integrations/mqtt/#frigatecamera_nameobjectdescriptionsset).
## Usage and Best Practices ## Usage and Best Practices
@@ -7,7 +7,7 @@ Generative AI can be used to automatically generate structured summaries of revi
Requests for a summary are requested automatically to your AI provider for alert review items when the activity has ended, they can also be optionally enabled for detections as well. Requests for a summary are requested automatically to your AI provider for alert review items when the activity has ended, they can also be optionally enabled for detections as well.
Generative AI review summaries can also be toggled dynamically for a [camera via MQTT](/integrations/mqtt#frigatecamera_namereview_descriptionsset). Generative AI review summaries can also be toggled dynamically for a [camera via MQTT](/integrations/mqtt/#frigatecamera_namereviewdescriptionsset).
## Review Summary Usage and Best Practices ## Review Summary Usage and Best Practices
+3 -12
View File
@@ -139,10 +139,7 @@ cameras:
- detect - detect
motion: motion:
mask: mask:
timestamp: - 0.000,0.427,0.002,0.000,0.999,0.000,0.999,0.781,0.885,0.456,0.700,0.424,0.701,0.311,0.507,0.294,0.453,0.347,0.451,0.400
friendly_name: "Camera timestamp"
enabled: true
coordinates: "0.000,0.427,0.002,0.000,0.999,0.000,0.999,0.781,0.885,0.456,0.700,0.424,0.701,0.311,0.507,0.294,0.453,0.347,0.451,0.400"
``` ```
### Standalone Intel Mini PC with USB Coral ### Standalone Intel Mini PC with USB Coral
@@ -199,10 +196,7 @@ cameras:
- detect - detect
motion: motion:
mask: mask:
timestamp: - 0.000,0.427,0.002,0.000,0.999,0.000,0.999,0.781,0.885,0.456,0.700,0.424,0.701,0.311,0.507,0.294,0.453,0.347,0.451,0.400
friendly_name: "Camera timestamp"
enabled: true
coordinates: "0.000,0.427,0.002,0.000,0.999,0.000,0.999,0.781,0.885,0.456,0.700,0.424,0.701,0.311,0.507,0.294,0.453,0.347,0.451,0.400"
``` ```
### Home Assistant integrated Intel Mini PC with OpenVino ### Home Assistant integrated Intel Mini PC with OpenVino
@@ -269,8 +263,5 @@ cameras:
- detect - detect
motion: motion:
mask: mask:
timestamp: - 0.000,0.427,0.002,0.000,0.999,0.000,0.999,0.781,0.885,0.456,0.700,0.424,0.701,0.311,0.507,0.294,0.453,0.347,0.451,0.400
friendly_name: "Camera timestamp"
enabled: true
coordinates: "0.000,0.427,0.002,0.000,0.999,0.000,0.999,0.781,0.885,0.456,0.700,0.424,0.701,0.311,0.507,0.294,0.453,0.347,0.451,0.400"
``` ```
+26 -41
View File
@@ -3,6 +3,8 @@ id: masks
title: Masks title: Masks
--- ---
Frigate has two kinds of masks: motion masks and object filter masks. Both are narrow tools for fine-tuning, **not for hiding an area from Frigate**. Masks should be used sparingly; in most cases where users reach for one, a [zone](zones.md) with `required_zones` is the right tool instead. See [Which tool do I need?](#which-tool-do-i-need) and [Common mistakes](#common-mistakes) below if you're new to Frigate's mask behavior.
## Motion masks ## Motion masks
Motion masks are used to prevent unwanted types of motion from triggering detection. Try watching the Debug feed (Settings --> Debug) with `Motion Boxes` enabled to see what may be regularly detected as motion. For example, you want to mask out your timestamp, the sky, rooftops, etc. Keep in mind that this mask only prevents motion from being detected and does not prevent objects from being detected if object detection was started due to motion in unmasked areas. Motion is also used during object tracking to refine the object detection area in the next frame. _Over-masking will make it more difficult for objects to be tracked._ Motion masks are used to prevent unwanted types of motion from triggering detection. Try watching the Debug feed (Settings --> Debug) with `Motion Boxes` enabled to see what may be regularly detected as motion. For example, you want to mask out your timestamp, the sky, rooftops, etc. Keep in mind that this mask only prevents motion from being detected and does not prevent objects from being detected if object detection was started due to motion in unmasked areas. Motion is also used during object tracking to refine the object detection area in the next frame. _Over-masking will make it more difficult for objects to be tracked._
@@ -17,6 +19,15 @@ Object filter masks can be used to filter out stubborn false positives in fixed
![object mask](/img/bottom-center-mask.jpg) ![object mask](/img/bottom-center-mask.jpg)
## Which tool do I need?
| What you're trying to do | Recommended tool | How it works |
| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Don't get alerts or recordings for activity in an area (e.g., the sidewalk in front of your house) | A [zone](zones.md) combined with `review.alerts.required_zones` (and/or `review.detections.required_zones`) | Frigate keeps detecting and tracking activity in the area, but a review item is only created once the bottom-center of an object's bounding box enters a required zone. |
| Stop a stubborn false positive at a specific fixed spot (e.g., a tree base that keeps being detected as a person) | An **object filter mask** for that object type | Any detection of that object type whose bounding-box bottom-center lands inside the mask is treated as a false positive and discarded. |
| Ignore motion in an area that obviously isn't an object of interest (e.g., the camera timestamp, sky, flags, treetops swaying) | A **motion mask** | Motion inside the mask is ignored when deciding whether to run object detection. Objects can still be detected in a motion masked area if motion elsewhere in the frame triggers detection. |
| Stop tracking an object type altogether on this camera (e.g., you never care about cats) | Remove the object from the camera's [`objects.track`](objects.md) list | Frigate skips this object type entirely on this camera, regardless of where it appears. |
## Using the mask creator ## Using the mask creator
To create a poly mask: To create a poly mask:
@@ -33,55 +44,18 @@ Your config file will be updated with the relative coordinates of the mask/zone:
```yaml ```yaml
motion: motion:
mask: mask: "0.000,0.427,0.002,0.000,0.999,0.000,0.999,0.781,0.885,0.456,0.700,0.424,0.701,0.311,0.507,0.294,0.453,0.347,0.451,0.400"
# Motion mask name (required)
mask1:
# Optional: A friendly name for the mask
friendly_name: "Timestamp area"
# Optional: Whether this mask is active (default: true)
enabled: true
# Required: Coordinates polygon for the mask
coordinates: "0.000,0.427,0.002,0.000,0.999,0.000,0.999,0.781,0.885,0.456,0.700,0.424,0.701,0.311,0.507,0.294,0.453,0.347,0.451,0.400"
``` ```
Multiple motion masks can be listed in your config: Multiple masks can be listed in your config.
```yaml ```yaml
motion: motion:
mask: mask:
mask1: - 0.239,1.246,0.175,0.901,0.165,0.805,0.195,0.802
friendly_name: "Timestamp area" - 0.000,0.427,0.002,0.000,0.999,0.000,0.999,0.781,0.885,0.456
enabled: true
coordinates: "0.239,1.246,0.175,0.901,0.165,0.805,0.195,0.802"
mask2:
friendly_name: "Tree area"
enabled: true
coordinates: "0.000,0.427,0.002,0.000,0.999,0.000,0.999,0.781,0.885,0.456"
``` ```
Object filter masks can also be created through the UI or manually in the config. They are configured under the object filters section for each object type:
```yaml
objects:
filters:
person:
mask:
person_filter1:
friendly_name: "Roof area"
enabled: true
coordinates: "0.000,0.000,1.000,0.000,1.000,0.400,0.000,0.400"
car:
mask:
car_filter1:
friendly_name: "Sidewalk area"
enabled: true
coordinates: "0.000,0.700,1.000,0.700,1.000,1.000,0.000,1.000"
```
## Enabling/Disabling Masks
Both motion masks and object filter masks can be toggled on or off without removing them from the configuration. Disabled masks are completely ignored at runtime - they will not affect motion detection or object filtering. This is useful for temporarily disabling a mask during certain seasons or times of day without modifying the configuration.
### Further Clarification ### Further Clarification
This is a response to a [question posed on reddit](https://www.reddit.com/r/homeautomation/comments/ppxdve/replacing_my_doorbell_with_a_security_camera_a_6/hd876w4?utm_source=share&utm_medium=web2x&context=3): This is a response to a [question posed on reddit](https://www.reddit.com/r/homeautomation/comments/ppxdve/replacing_my_doorbell_with_a_security_camera_a_6/hd876w4?utm_source=share&utm_medium=web2x&context=3):
@@ -119,3 +93,14 @@ This is what `required_zones` are for. You should define a zone (remember this i
> Maybe my specific situation just warrants this. I've just been having a hard time understanding the relevance of this information - it seems to be that it's exactly what would be expected when "masking out" an area of ANY image. > Maybe my specific situation just warrants this. I've just been having a hard time understanding the relevance of this information - it seems to be that it's exactly what would be expected when "masking out" an area of ANY image.
That may be the case for you. Frigate will definitely work harder tracking people on the sidewalk to make sure it doesn't miss anyone who steps foot on your stoop. The trade off with the way you have it now is slower recognition of objects and potential misses. That may be acceptable based on your needs. Also, if your resolution is low enough on the detect stream, your regions may already be so big that they grab the entire object anyway. That may be the case for you. Frigate will definitely work harder tracking people on the sidewalk to make sure it doesn't miss anyone who steps foot on your stoop. The trade off with the way you have it now is slower recognition of objects and potential misses. That may be acceptable based on your needs. Also, if your resolution is low enough on the detect stream, your regions may already be so big that they grab the entire object anyway.
## Common mistakes
**"I added a motion mask to ignore my driveway/sidewalk."**
A motion mask doesn't hide an area from Frigate. Objects can still be detected and tracked inside a masked area. The mask only stops motion _in that area_ from triggering object detection. If you want activity on the sidewalk to never produce a review item, define a [zone](zones.md) over the area you DO care about (your stoop, your driveway) and add it to `review.alerts.required_zones`. Frigate will still see people on the sidewalk, but it won't create an alert until they cross into the zone.
**"I added an object filter mask because I don't care about cars in my yard."**
Object filter masks are for stubborn false positives at fixed locations, not for filtering whole areas or whole object types. If you only want alerts when a car enters the driveway, use a [zone](zones.md) with `required_zones`. If you don't care about a whole object type on this camera, remove it from [`objects.track`](objects.md).
**"I masked everything except a thin strip on my stoop."**
Heavy masking hurts tracking. Frigate uses motion near a tracked object's previous bounding box to decide where to look in the next frame; with most of the frame masked, an object walking from an unmasked area into a masked one effectively disappears and gets picked up as a "new" object when it reappears. For example: someone walks down your sidewalk, stops under a tree (masked area) to tie their shoe, then continues. Frigate sees that as two separate people and can create two separate review items. Because Frigate needs several consecutive frames above the confidence threshold to commit to a detection, each re-appearance can also delay or miss alerts. Use `required_zones` for "only alert me about this spot" and leave the surrounding area unmasked so tracking stays intact.
+13 -33
View File
@@ -38,6 +38,7 @@ Remember that motion detection is just used to determine when object detection s
The threshold value dictates how much of a change in a pixels luminance is required to be considered motion. The threshold value dictates how much of a change in a pixels luminance is required to be considered motion.
```yaml ```yaml
# default threshold value
motion: motion:
# Optional: The threshold passed to cv2.threshold to determine if a pixel is different enough to be counted as motion. (default: shown below) # Optional: The threshold passed to cv2.threshold to determine if a pixel is different enough to be counted as motion. (default: shown below)
# Increasing this value will make motion detection less sensitive and decreasing it will make motion detection more sensitive. # Increasing this value will make motion detection less sensitive and decreasing it will make motion detection more sensitive.
@@ -52,6 +53,7 @@ Watching the motion boxes in the debug view, increase the threshold until you on
### Contour Area ### Contour Area
```yaml ```yaml
# default contour_area value
motion: motion:
# Optional: Minimum size in pixels in the resized motion image that counts as motion (default: shown below) # Optional: Minimum size in pixels in the resized motion image that counts as motion (default: shown below)
# Increasing this value will prevent smaller areas of motion from being detected. Decreasing will # Increasing this value will prevent smaller areas of motion from being detected. Decreasing will
@@ -79,49 +81,27 @@ However, if the preferred day settings do not work well at night it is recommend
## Tuning For Large Changes In Motion ## Tuning For Large Changes In Motion
### Lightning Threshold
```yaml ```yaml
# default lightning_threshold:
motion: motion:
# Optional: The percentage of the image used to detect lightning or # Optional: The percentage of the image used to detect lightning or other substantial changes where motion detection
# other substantial changes where motion detection needs to # needs to recalibrate. (default: shown below)
# recalibrate. (default: shown below) # Increasing this value will make motion detection more likely to consider lightning or ir mode changes as valid motion.
# Increasing this value will make motion detection more likely # Decreasing this value will make motion detection more likely to ignore large amounts of motion such as a person approaching
# to consider lightning or IR mode changes as valid motion. # a doorbell camera.
# Decreasing this value will make motion detection more likely
# to ignore large amounts of motion such as a person
# approaching a doorbell camera.
lightning_threshold: 0.8 lightning_threshold: 0.8
``` ```
Large changes in motion like PTZ moves and camera switches between Color and IR mode should result in a pause in object detection. `lightning_threshold` defines the percentage of the image used to detect these substantial changes. Increasing this value makes motion detection more likely to treat large changes (like IR mode switches) as valid motion. Decreasing it makes motion detection more likely to ignore large amounts of motion, such as a person approaching a doorbell camera.
Note that `lightning_threshold` does **not** stop motion-based recordings from being saved — it only prevents additional motion analysis after the threshold is exceeded, reducing false positive object detections during high-motion periods (e.g. storms or PTZ sweeps) without interfering with recordings.
:::warning :::warning
Some cameras, like doorbell cameras, may have missed detections when someone walks directly in front of the camera and the `lightning_threshold` causes motion detection to recalibrate. In this case, it may be desirable to increase the `lightning_threshold` to ensure these objects are not missed. Some cameras like doorbell cameras may have missed detections when someone walks directly in front of the camera and the lightning_threshold causes motion detection to be re-calibrated. In this case, it may be desirable to increase the `lightning_threshold` to ensure these objects are not missed.
::: :::
### Skip Motion On Large Scene Changes :::note
```yaml Lightning threshold does not stop motion based recordings from being saved.
motion:
# Optional: Fraction of the frame that must change in a single update
# before Frigate will completely ignore any motion in that frame.
# Values range between 0.0 and 1.0, leave unset (null) to disable.
# Setting this to 0.7 would cause Frigate to **skip** reporting
# motion boxes when more than 70% of the image appears to change
# (e.g. during lightning storms, IR/color mode switches, or other
# sudden lighting events).
skip_motion_threshold: 0.7
```
This option is handy when you want to prevent large transient changes from triggering recordings or object detection. It differs from `lightning_threshold` because it completely suppresses motion instead of just forcing a recalibration.
:::warning
When the skip threshold is exceeded, **no motion is reported** for that frame, meaning **nothing is recorded** for that frame. That means you can miss something important, like a PTZ camera auto-tracking an object or activity while the camera is moving. If you prefer to guarantee that every frame is saved, leave this unset and accept occasional recordings containing scene noise — they typically only take up a few megabytes and are quick to scan in the timeline UI.
::: :::
Large changes in motion like PTZ moves and camera switches between Color and IR mode should result in a pause in object detection. This is done via the `lightning_threshold` configuration. It is defined as the percentage of the image used to detect lightning or other substantial changes where motion detection needs to recalibrate. Increasing this value will make motion detection more likely to consider lightning or IR mode changes as valid motion. Decreasing this value will make motion detection more likely to ignore large amounts of motion such as a person approaching a doorbell camera.
+127 -58
View File
@@ -49,11 +49,6 @@ Frigate supports multiple different detectors that work on different types of ha
- [Synaptics](#synaptics): synap models can run on Synaptics devices(e.g astra machina) with included NPUs. - [Synaptics](#synaptics): synap models can run on Synaptics devices(e.g astra machina) with included NPUs.
**AXERA** <CommunityBadge />
- [AXEngine](#axera): axmodels can run on AXERA AI acceleration.
**For Testing** **For Testing**
- [CPU Detector (not recommended for actual use](#cpu-detector-not-recommended): Use a CPU to run tflite model, this is not recommended and in most cases OpenVINO can be used in CPU mode with better results. - [CPU Detector (not recommended for actual use](#cpu-detector-not-recommended): Use a CPU to run tflite model, this is not recommended and in most cases OpenVINO can be used in CPU mode with better results.
@@ -335,7 +330,7 @@ detectors:
| [YOLO-NAS](#yolo-nas) | ✅ | ✅ | | | [YOLO-NAS](#yolo-nas) | ✅ | ✅ | |
| [MobileNet v2](#ssdlite-mobilenet-v2) | ✅ | ✅ | Fast and lightweight model, less accurate than larger models | | [MobileNet v2](#ssdlite-mobilenet-v2) | ✅ | ✅ | Fast and lightweight model, less accurate than larger models |
| [YOLOX](#yolox) | ✅ | ? | | | [YOLOX](#yolox) | ✅ | ? | |
| [D-FINE](#d-fine) | ❌ | ❌ | | | [D-FINE / DEIMv2](#d-fine--deimv2) | ❌ | ❌ | |
#### SSDLite MobileNet v2 #### SSDLite MobileNet v2
@@ -469,13 +464,13 @@ model:
</details> </details>
#### D-FINE #### D-FINE / DEIMv2
[D-FINE](https://github.com/Peterande/D-FINE) is a DETR based model. The ONNX exported models are supported, but not included by default. See [the models section](#downloading-d-fine-model) for more information on downloading the D-FINE model for use in Frigate. [D-FINE](https://github.com/Peterande/D-FINE) and [DEIMv2](https://github.com/Intellindust-AI-Lab/DEIMv2) are DETR based models that share the same ONNX input/output format. The ONNX exported models are supported, but not included by default. See the models section for downloading [D-FINE](#downloading-d-fine-model) or [DEIMv2](#downloading-deimv2-model) for use in Frigate.
:::warning :::warning
Currently D-FINE models only run on OpenVINO in CPU mode, GPUs currently fail to compile the model Currently D-FINE / DEIMv2 models only run on OpenVINO in CPU mode, GPUs currently fail to compile the model
::: :::
@@ -504,6 +499,31 @@ Note that the labelmap uses a subset of the complete COCO label set that has onl
</details> </details>
<details>
<summary>DEIMv2 Setup & Config</summary>
After placing the downloaded onnx model in your `config/model_cache` folder, you can use the following configuration:
```yaml
detectors:
ov:
type: openvino
device: CPU
model:
model_type: dfine
width: 640
height: 640
input_tensor: nchw
input_dtype: float
path: /config/model_cache/deimv2_hgnetv2_n.onnx
labelmap_path: /labelmap/coco-80.txt
```
Note that the labelmap uses a subset of the complete COCO label set that has only 80 objects.
</details>
## Apple Silicon detector ## Apple Silicon detector
The NPU in Apple Silicon can't be accessed from within a container, so the [Apple Silicon detector client](https://github.com/frigate-nvr/apple-silicon-detector) must first be setup. It is recommended to use the Frigate docker image with `-standard-arm64` suffix, for example `ghcr.io/blakeblackshear/frigate:stable-standard-arm64`. The NPU in Apple Silicon can't be accessed from within a container, so the [Apple Silicon detector client](https://github.com/frigate-nvr/apple-silicon-detector) must first be setup. It is recommended to use the Frigate docker image with `-standard-arm64` suffix, for example `ghcr.io/blakeblackshear/frigate:stable-standard-arm64`.
@@ -653,7 +673,7 @@ The AMD GPU kernel is known problematic especially when converting models to mxr
See [ONNX supported models](#supported-models) for supported models, there are some caveats: See [ONNX supported models](#supported-models) for supported models, there are some caveats:
- D-FINE models are not supported - D-FINE / DEIMv2 models are not supported
- YOLO-NAS models are known to not run well on integrated GPUs - YOLO-NAS models are known to not run well on integrated GPUs
## ONNX ## ONNX
@@ -698,7 +718,7 @@ detectors:
| [RF-DETR](#rf-detr) | ✅ | ❌ | Supports CUDA Graphs for optimal Nvidia performance | | [RF-DETR](#rf-detr) | ✅ | ❌ | Supports CUDA Graphs for optimal Nvidia performance |
| [YOLO-NAS](#yolo-nas-1) | ⚠️ | ⚠️ | Not supported by CUDA Graphs | | [YOLO-NAS](#yolo-nas-1) | ⚠️ | ⚠️ | Not supported by CUDA Graphs |
| [YOLOX](#yolox-1) | ✅ | ✅ | Supports CUDA Graphs for optimal Nvidia performance | | [YOLOX](#yolox-1) | ✅ | ✅ | Supports CUDA Graphs for optimal Nvidia performance |
| [D-FINE](#d-fine) | ⚠️ | ❌ | Not supported by CUDA Graphs | | [D-FINE / DEIMv2](#d-fine--deimv2-1) | ⚠️ | ❌ | Not supported by CUDA Graphs |
There is no default model provided, the following formats are supported: There is no default model provided, the following formats are supported:
@@ -827,9 +847,9 @@ model:
</details> </details>
#### D-FINE #### D-FINE / DEIMv2
[D-FINE](https://github.com/Peterande/D-FINE) is a DETR based model. The ONNX exported models are supported, but not included by default. See [the models section](#downloading-d-fine-model) for more information on downloading the D-FINE model for use in Frigate. [D-FINE](https://github.com/Peterande/D-FINE) and [DEIMv2](https://github.com/Intellindust-AI-Lab/DEIMv2) are DETR based models that share the same ONNX input/output format. The ONNX exported models are supported, but not included by default. See the models section for downloading [D-FINE](#downloading-d-fine-model) or [DEIMv2](#downloading-deimv2-model) for use in Frigate.
<details> <details>
<summary>D-FINE Setup & Config</summary> <summary>D-FINE Setup & Config</summary>
@@ -853,6 +873,28 @@ model:
</details> </details>
<details>
<summary>DEIMv2 Setup & Config</summary>
After placing the downloaded onnx model in your `config/model_cache` folder, you can use the following configuration:
```yaml
detectors:
onnx:
type: onnx
model:
model_type: dfine
width: 640
height: 640
input_tensor: nchw
input_dtype: float
path: /config/model_cache/deimv2_hgnetv2_n.onnx
labelmap_path: /labelmap/coco-80.txt
```
</details>
Note that the labelmap uses a subset of the complete COCO label set that has only 80 objects. Note that the labelmap uses a subset of the complete COCO label set that has only 80 objects.
## CPU Detector (not recommended) ## CPU Detector (not recommended)
@@ -952,7 +994,7 @@ MemryX `.dfp` models are automatically downloaded at runtime, if enabled, to the
#### YOLO-NAS #### YOLO-NAS
The [YOLO-NAS](https://github.com/Deci-AI/super-gradients/blob/master/YOLONAS.md) model included in this detector is downloaded from the [Models Section](#downloading-yolo-nas-model) and compiled to DFP with [mx_nc](https://developer.memryx.com/tools/neural_compiler.html#usage). The [YOLO-NAS](https://github.com/Deci-AI/super-gradients/blob/master/YOLONAS.md) model included in this detector is downloaded from the [Models Section](#downloading-yolo-nas-model) and compiled to DFP with [mx_nc](https://developer.memryx.com/2p1/tools/neural_compiler.html#usage).
**Note:** The default model for the MemryX detector is YOLO-NAS 320x320. **Note:** The default model for the MemryX detector is YOLO-NAS 320x320.
@@ -986,7 +1028,7 @@ model:
#### YOLOv9 #### YOLOv9
The YOLOv9s model included in this detector is downloaded from [the original GitHub](https://github.com/WongKinYiu/yolov9) like in the [Models Section](#yolov9-1) and compiled to DFP with [mx_nc](https://developer.memryx.com/tools/neural_compiler.html#usage). The YOLOv9s model included in this detector is downloaded from [the original GitHub](https://github.com/WongKinYiu/yolov9) like in the [Models Section](#yolov9-1) and compiled to DFP with [mx_nc](https://developer.memryx.com/2p1/tools/neural_compiler.html#usage).
##### Configuration ##### Configuration
@@ -1068,19 +1110,39 @@ model:
#### Using a Custom Model #### Using a Custom Model
To use your own model: To use your own custom model, first compile it into a [.dfp](https://developer.memryx.com/2p1/specs/files.html#dataflow-program) file, which is the format used by MemryX.
1. Package your compiled model into a `.zip` file. #### Compile the Model
2. The `.zip` must contain the compiled `.dfp` file. Custom models must be compiled using **MemryX SDK 2.1**.
3. Depending on the model, the compiler may also generate a cropped post-processing network. If present, it will be named with the suffix `_post.onnx`. Before compiling your model, install the MemryX Neural Compiler tools from the
[Install Tools](https://developer.memryx.com/2p1/get_started/install_tools.html) page on the **host**.
4. Bind-mount the `.zip` file into the container and specify its path using `model.path` in your config. > **Note:** It is recommended to compile the model on the host machine, or on another separate machine, rather than inside the Frigate Docker container. Installing the compiler inside Docker may conflict with container packages. It is recommended to create a Python virtual environment and install the compiler there.
5. Update the `labelmap_path` to match your custom model's labels. Once the SDK 2.1 environment is set up, follow the
[MemryX Compiler](https://developer.memryx.com/2p1/tools/neural_compiler.html#usage) documentation to compile your model.
For detailed instructions on compiling models, refer to the [MemryX Compiler](https://developer.memryx.com/tools/neural_compiler.html#usage) docs and [Tutorials](https://developer.memryx.com/tutorials/tutorials.html). Example:
```bash
mx_nc -m yolonas.onnx -c 4 --autocrop -v --dfp_fname yolonas.dfp
```
For detailed instructions on compiling models, refer to the [MemryX Compiler](https://developer.memryx.com/2p1/tools/neural_compiler.html#usage) docs and [Tutorials](https://developer.memryx.com/2p1/tutorials/tutorials.html).
#### Package the Compiled Model
1. Package your compiled model into a `.zip` file.
2. The `.zip` file must contain the compiled `.dfp` file.
3. Depending on the model, the compiler may also generate a cropped post-processing network. If present, it will be named with the suffix `_post.onnx`.
4. Bind-mount the `.zip` file into the container and specify its path using `model.path` in your config.
5. Update `labelmap_path` to match your custom model's labels.
```yaml ```yaml
# The detector automatically selects the default model if nothing is provided in the config. # The detector automatically selects the default model if nothing is provided in the config.
@@ -1483,42 +1545,6 @@ model:
input_pixel_format: rgb/bgr # look at the model.json to figure out which to put here input_pixel_format: rgb/bgr # look at the model.json to figure out which to put here
``` ```
## AXERA
Hardware accelerated object detection is supported on the following SoCs:
- AX650N
- AX8850N
This implementation uses the [AXera Pulsar2 Toolchain](https://huggingface.co/AXERA-TECH/Pulsar2).
See the [installation docs](../frigate/installation.md#axera) for information on configuring the AXEngine hardware.
### Configuration
When configuring the AXEngine detector, you have to specify the model name.
#### yolov9
A yolov9 model is provided in the container at `/axmodels` and is used by this detector type by default.
Use the model configuration shown below when using the axengine detector with the default axmodel:
```yaml
detectors:
axengine:
type: axengine
model:
path: frigate-yolov9-tiny
model_type: yolo-generic
width: 320
height: 320
input_dtype: int
input_pixel_format: bgr
labelmap_path: /labelmap/coco-80.txt
```
# Models # Models
Some model types are not included in Frigate by default. Some model types are not included in Frigate by default.
@@ -1553,6 +1579,49 @@ COPY --from=build /dfine/output/dfine_${MODEL_SIZE}_obj2coco.onnx /dfine-${MODEL
EOF EOF
``` ```
### Downloading DEIMv2 Model
[DEIMv2](https://github.com/Intellindust-AI-Lab/DEIMv2) can be exported as ONNX by running the command below. Pretrained weights are available on Hugging Face for two backbone families:
- **HGNetv2** (smaller/faster): `atto`, `femto`, `pico`, `n`
- **DINOv3** (larger/more accurate): `s`, `m`, `l`, `x`
Set `BACKBONE` and `MODEL_SIZE` in the first line to match your desired variant. Hugging Face model names use uppercase (e.g. `HGNetv2_N`, `DINOv3_S`), while config files use lowercase (e.g. `hgnetv2_n`, `dinov3_s`).
```sh
docker build . --rm --build-arg BACKBONE=hgnetv2 --build-arg MODEL_SIZE=n --output . -f- <<'EOF'
FROM python:3.11-slim AS build
RUN apt-get update && apt-get install --no-install-recommends -y git libgl1 libglib2.0-0 && rm -rf /var/lib/apt/lists/*
COPY --from=ghcr.io/astral-sh/uv:0.8.0 /uv /bin/
WORKDIR /deimv2
RUN git clone https://github.com/Intellindust-AI-Lab/DEIMv2.git .
# Install CPU-only PyTorch first to avoid pulling CUDA variant
RUN uv pip install --no-cache --system torch torchvision --index-url https://download.pytorch.org/whl/cpu
RUN uv pip install --no-cache --system -r requirements.txt
RUN uv pip install --no-cache --system onnx safetensors huggingface_hub
RUN mkdir -p output
ARG BACKBONE
ARG MODEL_SIZE
# Download from Hugging Face and convert safetensors to pth
RUN python3 -c "\
from huggingface_hub import hf_hub_download; \
from safetensors.torch import load_file; \
import torch; \
backbone = '${BACKBONE}'.replace('hgnetv2','HGNetv2').replace('dinov3','DINOv3'); \
size = '${MODEL_SIZE}'.upper(); \
st = load_file(hf_hub_download('Intellindust/DEIMv2_' + backbone + '_' + size + '_COCO', 'model.safetensors')); \
torch.save({'model': st}, 'output/deimv2.pth')"
RUN sed -i "s/data = torch.rand(2/data = torch.rand(1/" tools/deployment/export_onnx.py
# HuggingFace safetensors omits frozen constants that the model constructor initializes
RUN sed -i "s/cfg.model.load_state_dict(state)/cfg.model.load_state_dict(state, strict=False)/" tools/deployment/export_onnx.py
RUN python3 tools/deployment/export_onnx.py -c configs/deimv2/deimv2_${BACKBONE}_${MODEL_SIZE}_coco.yml -r output/deimv2.pth
FROM scratch
ARG BACKBONE
ARG MODEL_SIZE
COPY --from=build /deimv2/output/deimv2.onnx /deimv2_${BACKBONE}_${MODEL_SIZE}.onnx
EOF
```
### Downloading RF-DETR Model ### Downloading RF-DETR Model
RF-DETR can be exported as ONNX by running the command below. You can copy and paste the whole thing to your terminal and execute, altering `MODEL_SIZE=Nano` in the first line to `Nano`, `Small`, or `Medium` size. RF-DETR can be exported as ONNX by running the command below. You can copy and paste the whole thing to your terminal and execute, altering `MODEL_SIZE=Nano` in the first line to `Nano`, `Small`, or `Medium` size.
-188
View File
@@ -1,188 +0,0 @@
---
id: profiles
title: Profiles
---
Profiles allow you to define named sets of camera configuration overrides that can be activated and deactivated at runtime without restarting Frigate. This is useful for scenarios like switching between "Home" and "Away" modes, daytime and nighttime configurations, or any situation where you want to quickly change how multiple cameras behave.
## How Profiles Work
Profiles operate as a two-level system:
1. **Profile definitions** are declared at the top level of your config under `profiles`. Each definition has a machine name (the key) and a `friendly_name` for display in the UI.
2. **Camera profile overrides** are declared under each camera's `profiles` section, keyed by the profile name. Only the settings you want to change need to be specified — everything else is inherited from the camera's base configuration.
When a profile is activated, Frigate merges each camera's profile overrides on top of its base config. When the profile is deactivated, all cameras revert to their original settings. Only one profile can be active at a time.
:::info
Profile changes are applied in-memory and take effect immediately — no restart is required. The active profile is persisted across Frigate restarts (stored in the `/config/.active_profile` file).
:::
## Configuration
The easiest way to define profiles is to use the Frigate UI. Profiles can also be configured manually in your configuration file.
### Using the UI
To create and manage profiles from the UI, open **Settings**. From there you can:
1. **Create a profile** — Navigate to **Profiles**. Click the **Add Profile** button, enter a name (and optionally a profile ID).
2. **Configure overrides** — Navigate to a camera configuration section (e.g. Motion detection, Record, Notifications). In the top right, two buttons will appear - choose a camera and a profile from the profile selector to edit overrides for that camera and section. Only the fields you change will be stored as overrides — fields that require a restart are hidden since profiles are applied at runtime. You can click the **Remove Profile Override** button
3. **Activate a profile** — Use the **Profiles** option in Frigate's main menu to choose a profile. Alternatively, in Settings, navigate to **Profiles**, then choose a profile in the Active Profile dropdown to activate it. The active profile is also shown in the status bar at the bottom of the screen on desktop browsers.
4. **Delete a profile** — Navigate to **Profiles**, then click the trash icon for a profile. This removes the profile definition and all camera overrides associated with it.
### Defining Profiles in YAML
First, define your profiles at the top level of your Frigate config. Every profile name referenced by a camera must be defined here.
```yaml
profiles:
home:
friendly_name: Home
away:
friendly_name: Away
night:
friendly_name: Night Mode
```
### Camera Profile Overrides
Under each camera, add a `profiles` section with overrides for each profile. You only need to include the settings you want to change.
```yaml
cameras:
front_door:
ffmpeg:
inputs:
- path: rtsp://camera:554/stream
roles:
- detect
- record
detect:
enabled: true
record:
enabled: true
profiles:
away:
detect:
enabled: true
notifications:
enabled: true
objects:
track:
- person
- car
- package
review:
alerts:
labels:
- person
- car
- package
home:
detect:
enabled: true
notifications:
enabled: false
objects:
track:
- person
```
### Supported Override Sections
The following camera configuration sections can be overridden in a profile:
| Section | Description |
| ------------------ | ----------------------------------------- |
| `enabled` | Enable or disable the camera entirely |
| `audio` | Audio detection settings |
| `birdseye` | Birdseye view settings |
| `detect` | Object detection settings |
| `face_recognition` | Face recognition settings |
| `lpr` | License plate recognition settings |
| `motion` | Motion detection settings |
| `notifications` | Notification settings |
| `objects` | Object tracking and filter settings |
| `record` | Recording settings |
| `review` | Review alert and detection settings |
| `snapshots` | Snapshot settings |
| `zones` | Zone definitions (merged with base zones) |
:::note
Only the fields you explicitly set in a profile override are applied. All other fields retain their base configuration values. For zones, profile zones are merged with the camera's base zones — any zone defined in the profile will override or add to the base zones.
:::
## Activating Profiles
Profiles can be activated and deactivated from the Frigate UI. Open the Settings cog and select **Profiles** from the submenu to see all defined profiles. From there you can activate any profile or deactivate the current one. The active profile is indicated in the UI so you always know which profile is in effect.
## Example: Home / Away Setup
A common use case is having different detection and notification settings based on whether you are home or away.
```yaml
profiles:
home:
friendly_name: Home
away:
friendly_name: Away
cameras:
front_door:
ffmpeg:
inputs:
- path: rtsp://camera:554/stream
roles:
- detect
- record
detect:
enabled: true
record:
enabled: true
notifications:
enabled: false
profiles:
away:
notifications:
enabled: true
review:
alerts:
labels:
- person
- car
home:
notifications:
enabled: false
indoor_cam:
ffmpeg:
inputs:
- path: rtsp://camera:554/indoor
roles:
- detect
- record
detect:
enabled: false
record:
enabled: false
profiles:
away:
enabled: true
detect:
enabled: true
record:
enabled: true
home:
enabled: false
```
In this example:
- **Away profile**: The front door camera enables notifications and tracks specific alert labels. The indoor camera is fully enabled with detection and recording.
- **Home profile**: The front door camera disables notifications. The indoor camera is completely disabled for privacy.
- **No profile active**: All cameras use their base configuration values.
+10 -15
View File
@@ -68,7 +68,7 @@ record:
## Will Frigate delete old recordings if my storage runs out? ## Will Frigate delete old recordings if my storage runs out?
As of Frigate 0.12 if there is less than an hour left of storage, the oldest 2 hours of recordings will be deleted. If there is less than an hour left of storage, the oldest hour of recordings will be deleted and a message will be printed in the Frigate logs. This emergency cleanup deletes the oldest recordings first regardless of retention settings to reclaim space as quickly as possible.
## Configuring Recording Retention ## Configuring Recording Retention
@@ -139,13 +139,7 @@ record:
:::tip :::tip
When using `hwaccel_args`, hardware encoding is used for timelapse generation. This setting can be overridden for a specific camera (e.g., when camera resolution exceeds hardware encoder limits); set `cameras.<camera>.record.export.hwaccel_args` with the appropriate settings. Using an unrecognized value or empty string will fall back to software encoding (libx264). When using `hwaccel_args` globally hardware encoding is used for time lapse generation. The encoder determines its own behavior so the resulting file size may be undesirably large.
:::
:::tip
The encoder determines its own behavior so the resulting file size may be undesirably large.
To reduce the output file size the ffmpeg parameter `-qp n` can be utilized (where `n` stands for the value of the quantisation parameter). The value can be adjusted to get an acceptable tradeoff between quality and file size for the given scenario. To reduce the output file size the ffmpeg parameter `-qp n` can be utilized (where `n` stands for the value of the quantisation parameter). The value can be adjusted to get an acceptable tradeoff between quality and file size for the given scenario.
::: :::
@@ -154,18 +148,19 @@ To reduce the output file size the ffmpeg parameter `-qp n` can be utilized (whe
Apple devices running the Safari browser may fail to playback h.265 recordings. The [apple compatibility option](../configuration/camera_specific.md#h265-cameras-via-safari) should be used to ensure seamless playback on Apple devices. Apple devices running the Safari browser may fail to playback h.265 recordings. The [apple compatibility option](../configuration/camera_specific.md#h265-cameras-via-safari) should be used to ensure seamless playback on Apple devices.
## Syncing Media Files With Disk ## Syncing Recordings With Disk
Media files (event snapshots, event thumbnails, review thumbnails, previews, exports, and recordings) can become orphaned when database entries are deleted but the corresponding files remain on disk. In some cases the recordings files may be deleted but Frigate will not know this has happened. Recordings sync can be enabled which will tell Frigate to check the file system and delete any db entries for files which don't exist.
Normal operation may leave small numbers of orphaned files until Frigate's scheduled cleanup, but crashes, configuration changes, or upgrades may cause more orphaned files that Frigate does not clean up. This feature checks the file system for media files and removes any that are not referenced in the database. ```yaml
record:
sync_recordings: True
```
The Maintenance pane in the Frigate UI or an API endpoint `POST /api/media/sync` can be used to trigger a media sync. When using the API, a job ID is returned and the operation continues on the server. Status can be checked with the `/api/media/sync/status/{job_id}` endpoint. This feature is meant to fix variations in files, not completely delete entries in the database. If you delete all of your media, don't use `sync_recordings`, just stop Frigate, delete the `frigate.db` database, and restart.
Setting `verbose: true` writes a detailed report of every orphaned file and database entry to `/config/media_sync/<job_id>.txt`. For recordings, the report separates orphaned database entries (DB records whose files are missing from disk) from orphaned files (files on disk with no corresponding database record).
:::warning :::warning
This operation uses considerable CPU resources and includes a safety threshold that aborts if more than 50% of files would be deleted. Only run when necessary. If you set `force: true` the safety threshold will be bypassed; do not use `force` unless you are certain the deletions are intended. The sync operation uses considerable CPU resources and in most cases is not needed, only enable when necessary.
::: :::
+17 -113
View File
@@ -75,19 +75,11 @@ tls:
# Optional: Enable TLS for port 8971 (default: shown below) # Optional: Enable TLS for port 8971 (default: shown below)
enabled: True enabled: True
# Optional: Networking configuration # Optional: IPv6 configuration
networking: networking:
# Optional: Enable IPv6 on 5000, and 8971 if tls is configured (default: shown below) # Optional: Enable IPv6 on 5000, and 8971 if tls is configured (default: shown below)
ipv6: ipv6:
enabled: False enabled: False
# Optional: Override ports Frigate uses for listening (defaults: shown below)
# An IP address may also be provided to bind to a specific interface, e.g. ip:port
# NOTE: This setting is for advanced users and may break some integrations. The majority
# of users should change ports in the docker compose file
# or use the docker run `--publish` option to select a different port.
listen:
internal: 5000
external: 8971
# Optional: Proxy configuration # Optional: Proxy configuration
proxy: proxy:
@@ -347,15 +339,7 @@ objects:
# Optional: mask to prevent all object types from being detected in certain areas (default: no mask) # Optional: mask to prevent all object types from being detected in certain areas (default: no mask)
# Checks based on the bottom center of the bounding box of the object. # Checks based on the bottom center of the bounding box of the object.
# NOTE: This mask is COMBINED with the object type specific mask below # NOTE: This mask is COMBINED with the object type specific mask below
mask: mask: 0.000,0.000,0.781,0.000,0.781,0.278,0.000,0.278
# Object filter mask name (required)
mask1:
# Optional: A friendly name for the mask
friendly_name: "Object filter mask area"
# Optional: Whether this mask is active (default: true)
enabled: true
# Required: Coordinates polygon for the mask
coordinates: "0.000,0.000,0.781,0.000,0.781,0.278,0.000,0.278"
# Optional: filters to reduce false positives for specific object types # Optional: filters to reduce false positives for specific object types
filters: filters:
person: person:
@@ -375,15 +359,7 @@ objects:
threshold: 0.7 threshold: 0.7
# Optional: mask to prevent this object type from being detected in certain areas (default: no mask) # Optional: mask to prevent this object type from being detected in certain areas (default: no mask)
# Checks based on the bottom center of the bounding box of the object # Checks based on the bottom center of the bounding box of the object
mask: mask: 0.000,0.000,0.781,0.000,0.781,0.278,0.000,0.278
# Object filter mask name (required)
mask1:
# Optional: A friendly name for the mask
friendly_name: "Object filter mask area"
# Optional: Whether this mask is active (default: true)
enabled: true
# Required: Coordinates polygon for the mask
coordinates: "0.000,0.000,0.781,0.000,0.781,0.278,0.000,0.278"
# Optional: Configuration for AI generated tracked object descriptions # Optional: Configuration for AI generated tracked object descriptions
genai: genai:
# Optional: Enable AI object description generation (default: shown below) # Optional: Enable AI object description generation (default: shown below)
@@ -482,16 +458,12 @@ motion:
# Increasing this value will make motion detection less sensitive and decreasing it will make motion detection more sensitive. # Increasing this value will make motion detection less sensitive and decreasing it will make motion detection more sensitive.
# The value should be between 1 and 255. # The value should be between 1 and 255.
threshold: 30 threshold: 30
# Optional: The percentage of the image used to detect lightning or other substantial changes where motion detection needs # Optional: The percentage of the image used to detect lightning or other substantial changes where motion detection
# to recalibrate and motion checks stop for that frame. Recordings are unaffected. (default: shown below) # needs to recalibrate. (default: shown below)
# Increasing this value will make motion detection more likely to consider lightning or ir mode changes as valid motion. # Increasing this value will make motion detection more likely to consider lightning or ir mode changes as valid motion.
# Decreasing this value will make motion detection more likely to ignore large amounts of motion such as a person approaching a doorbell camera. # Decreasing this value will make motion detection more likely to ignore large amounts of motion such as a person approaching
# a doorbell camera.
lightning_threshold: 0.8 lightning_threshold: 0.8
# Optional: Fraction of the frame that must change in a single update before motion boxes are completely
# ignored. Values range between 0.0 and 1.0. When exceeded, no motion boxes are reported and **no motion
# recording** is created for that frame. Leave unset (null) to disable this feature. Use with care on PTZ
# cameras or other situations where you require guaranteed frame capture.
skip_motion_threshold: None
# Optional: Minimum size in pixels in the resized motion image that counts as motion (default: shown below) # Optional: Minimum size in pixels in the resized motion image that counts as motion (default: shown below)
# Increasing this value will prevent smaller areas of motion from being detected. Decreasing will # Increasing this value will prevent smaller areas of motion from being detected. Decreasing will
# make motion detection more sensitive to smaller moving objects. # make motion detection more sensitive to smaller moving objects.
@@ -511,15 +483,7 @@ motion:
frame_height: 100 frame_height: 100
# Optional: motion mask # Optional: motion mask
# NOTE: see docs for more detailed info on creating masks # NOTE: see docs for more detailed info on creating masks
mask: mask: 0.000,0.469,1.000,0.469,1.000,1.000,0.000,1.000
# Motion mask name (required)
mask1:
# Optional: A friendly name for the mask
friendly_name: "Motion mask area"
# Optional: Whether this mask is active (default: true)
enabled: true
# Required: Coordinates polygon for the mask
coordinates: "0.000,0.469,1.000,0.469,1.000,1.000,0.000,1.000"
# Optional: improve contrast (default: shown below) # Optional: improve contrast (default: shown below)
# Enables dynamic contrast improvement. This should help improve night detections at the cost of making motion detection more sensitive # Enables dynamic contrast improvement. This should help improve night detections at the cost of making motion detection more sensitive
# for daytime. # for daytime.
@@ -548,6 +512,8 @@ record:
# Optional: Number of minutes to wait between cleanup runs (default: shown below) # Optional: Number of minutes to wait between cleanup runs (default: shown below)
# This can be used to reduce the frequency of deleting recording segments from disk if you want to minimize i/o # This can be used to reduce the frequency of deleting recording segments from disk if you want to minimize i/o
expire_interval: 60 expire_interval: 60
# Optional: Two-way sync recordings database with disk on startup and once a day (default: shown below).
sync_recordings: False
# Optional: Continuous retention settings # Optional: Continuous retention settings
continuous: continuous:
# Optional: Number of days to retain recordings regardless of tracked objects or motion (default: shown below) # Optional: Number of days to retain recordings regardless of tracked objects or motion (default: shown below)
@@ -570,8 +536,6 @@ record:
# The -r (framerate) dictates how smooth the output video is. # The -r (framerate) dictates how smooth the output video is.
# So the args would be -vf setpts=0.02*PTS -r 30 in that case. # So the args would be -vf setpts=0.02*PTS -r 30 in that case.
timelapse_args: "-vf setpts=0.04*PTS -r 30" timelapse_args: "-vf setpts=0.04*PTS -r 30"
# Optional: Global hardware acceleration settings for timelapse exports. (default: inherit)
hwaccel_args: auto
# Optional: Recording Preview Settings # Optional: Recording Preview Settings
preview: preview:
# Optional: Quality of recording preview (default: shown below). # Optional: Quality of recording preview (default: shown below).
@@ -618,12 +582,13 @@ record:
# never stored, so setting the mode to "all" here won't bring them back. # never stored, so setting the mode to "all" here won't bring them back.
mode: motion mode: motion
# Optional: Configuration for the snapshots written to the clips directory for each tracked object # Optional: Configuration for the jpg snapshots written to the clips directory for each tracked object
# Timestamp, bounding_box, crop and height settings are applied by default to API requests for snapshots.
# NOTE: Can be overridden at the camera level # NOTE: Can be overridden at the camera level
snapshots: snapshots:
# Optional: Enable writing snapshot images to /media/frigate/clips (default: shown below) # Optional: Enable writing jpg snapshot to /media/frigate/clips (default: shown below)
enabled: False enabled: False
# Optional: save a clean copy of the snapshot image (default: shown below)
clean_copy: True
# Optional: print a timestamp on the snapshots (default: shown below) # Optional: print a timestamp on the snapshots (default: shown below)
timestamp: False timestamp: False
# Optional: draw bounding box on the snapshots (default: shown below) # Optional: draw bounding box on the snapshots (default: shown below)
@@ -641,8 +606,8 @@ snapshots:
# Optional: Per object retention days # Optional: Per object retention days
objects: objects:
person: 15 person: 15
# Optional: quality of the encoded snapshot image, 0-100 (default: shown below) # Optional: quality of the encoded jpeg, 0-100 (default: shown below)
quality: 60 quality: 70
# Optional: Configuration for semantic search capability # Optional: Configuration for semantic search capability
semantic_search: semantic_search:
@@ -789,7 +754,7 @@ classification:
interval: None interval: None
# Optional: Restream configuration # Optional: Restream configuration
# Uses https://github.com/AlexxIT/go2rtc (v1.9.13) # Uses https://github.com/AlexxIT/go2rtc (v1.9.10)
# NOTE: The default go2rtc API port (1984) must be used, # NOTE: The default go2rtc API port (1984) must be used,
# changing this port for the integrated go2rtc instance is not supported. # changing this port for the integrated go2rtc instance is not supported.
go2rtc: go2rtc:
@@ -875,11 +840,6 @@ cameras:
# Optional: camera specific output args (default: inherit) # Optional: camera specific output args (default: inherit)
# output_args: # output_args:
# Optional: camera specific hwaccel args for timelapse export (default: inherit)
# record:
# export:
# hwaccel_args:
# Optional: timeout for highest scoring image before allowing it # Optional: timeout for highest scoring image before allowing it
# to be replaced by a newer image. (default: shown below) # to be replaced by a newer image. (default: shown below)
best_image_timeout: 60 best_image_timeout: 60
@@ -895,9 +855,6 @@ cameras:
front_steps: front_steps:
# Optional: A friendly name or descriptive text for the zones # Optional: A friendly name or descriptive text for the zones
friendly_name: "" friendly_name: ""
# Optional: Whether this zone is active (default: shown below)
# Disabled zones are completely ignored at runtime - no object tracking or debug drawing
enabled: True
# Required: List of x,y coordinates to define the polygon of the zone. # Required: List of x,y coordinates to define the polygon of the zone.
# NOTE: Presence in a zone is evaluated only based on the bottom center of the objects bounding box. # NOTE: Presence in a zone is evaluated only based on the bottom center of the objects bounding box.
coordinates: 0.033,0.306,0.324,0.138,0.439,0.185,0.042,0.428 coordinates: 0.033,0.306,0.324,0.138,0.439,0.185,0.042,0.428
@@ -1029,49 +986,6 @@ cameras:
actions: actions:
- notification - notification
# Optional: Named config profiles with partial overrides that can be activated at runtime.
# NOTE: Profile names must be defined in the top-level 'profiles' section.
profiles:
# Required: name of the profile (must match a top-level profile definition)
away:
# Optional: Enable or disable the camera when this profile is active (default: not set, inherits base)
enabled: true
# Optional: Override audio settings
audio:
enabled: true
# Optional: Override birdseye settings
# birdseye:
# Optional: Override detect settings
detect:
enabled: true
# Optional: Override face_recognition settings
# face_recognition:
# Optional: Override lpr settings
# lpr:
# Optional: Override motion settings
# motion:
# Optional: Override notification settings
notifications:
enabled: true
# Optional: Override objects settings
objects:
track:
- person
- car
# Optional: Override record settings
record:
enabled: true
# Optional: Override review settings
review:
alerts:
labels:
- person
- car
# Optional: Override snapshot settings
# snapshots:
# Optional: Override or add zones (merged with base zones)
# zones:
# Optional # Optional
ui: ui:
# Optional: Set a timezone to use in the UI (default: use browser local time) # Optional: Set a timezone to use in the UI (default: use browser local time)
@@ -1138,14 +1052,4 @@ camera_groups:
icon: LuCar icon: LuCar
# Required: index of this group # Required: index of this group
order: 0 order: 0
# Optional: Profile definitions for named config overrides
# NOTE: Profile names defined here can be referenced in camera profiles sections
profiles:
# Required: name of the profile (machine name used internally)
home:
# Required: display name shown in the UI
friendly_name: Home
away:
friendly_name: Away
``` ```
+4 -9
View File
@@ -7,7 +7,7 @@ title: Restream
Frigate can restream your video feed as an RTSP feed for other applications such as Home Assistant to utilize it at `rtsp://<frigate_host>:8554/<camera_name>`. Port 8554 must be open. [This allows you to use a video feed for detection in Frigate and Home Assistant live view at the same time without having to make two separate connections to the camera](#reduce-connections-to-camera). The video feed is copied from the original video feed directly to avoid re-encoding. This feed does not include any annotation by Frigate. Frigate can restream your video feed as an RTSP feed for other applications such as Home Assistant to utilize it at `rtsp://<frigate_host>:8554/<camera_name>`. Port 8554 must be open. [This allows you to use a video feed for detection in Frigate and Home Assistant live view at the same time without having to make two separate connections to the camera](#reduce-connections-to-camera). The video feed is copied from the original video feed directly to avoid re-encoding. This feed does not include any annotation by Frigate.
Frigate uses [go2rtc](https://github.com/AlexxIT/go2rtc/tree/v1.9.13) to provide its restream and MSE/WebRTC capabilities. The go2rtc config is hosted at the `go2rtc` in the config, see [go2rtc docs](https://github.com/AlexxIT/go2rtc/tree/v1.9.13#configuration) for more advanced configurations and features. Frigate uses [go2rtc](https://github.com/AlexxIT/go2rtc/tree/v1.9.10) to provide its restream and MSE/WebRTC capabilities. The go2rtc config is hosted at the `go2rtc` in the config, see [go2rtc docs](https://github.com/AlexxIT/go2rtc/tree/v1.9.10#configuration) for more advanced configurations and features.
:::note :::note
@@ -208,7 +208,7 @@ Enabling arbitrary exec sources allows execution of arbitrary commands through g
## Advanced Restream Configurations ## Advanced Restream Configurations
The [exec](https://github.com/AlexxIT/go2rtc/tree/v1.9.13#source-exec) source in go2rtc can be used for custom ffmpeg commands. An example is below: The [exec](https://github.com/AlexxIT/go2rtc/tree/v1.9.10#source-exec) source in go2rtc can be used for custom ffmpeg commands and other applications. An example is below:
:::warning :::warning
@@ -216,16 +216,11 @@ The `exec:`, `echo:`, and `expr:` sources are disabled by default for security.
::: :::
:::warning NOTE: RTSP output will need to be passed with two curly braces `{{output}}`, whereas pipe output must be passed without curly braces.
The `exec:`, `echo:`, and `expr:` sources are disabled by default for security. You must set `GO2RTC_ALLOW_ARBITRARY_EXEC=true` to use them. See [Security: Restricted Stream Sources](#security-restricted-stream-sources) for more information.
:::
NOTE: The output will need to be passed with two curly braces `{{output}}`
```yaml ```yaml
go2rtc: go2rtc:
streams: streams:
stream1: exec:ffmpeg -hide_banner -re -stream_loop -1 -i /media/BigBuckBunny.mp4 -c copy -rtsp_transport tcp -f rtsp {{output}} stream1: exec:ffmpeg -hide_banner -re -stream_loop -1 -i /media/BigBuckBunny.mp4 -c copy -rtsp_transport tcp -f rtsp {{output}}
stream2: exec:rpicam-vid -t 0 --libav-format h264 -o -
``` ```
@@ -76,40 +76,6 @@ Switching between V1 and V2 requires reindexing your embeddings. The embeddings
::: :::
### GenAI Provider
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 llama.cpp for semantic search:
1. Configure a GenAI provider in your config with `embeddings` in its `roles`.
2. Set `semantic_search.model` to the GenAI config key (e.g. `default`).
3. Start the llama.cpp server with `--embeddings` and `--mmproj` for image support:
```yaml
genai:
default:
provider: llamacpp
base_url: http://localhost:8080
model: your-model-name
roles:
- embeddings
- vision
- tools
semantic_search:
enabled: True
model: default
```
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
Switching between Jina models and a GenAI provider requires reindexing. Embeddings from different backends are incompatible.
:::
### GPU Acceleration ### GPU Acceleration
The CLIP models are downloaded in ONNX format, and the `large` model can be accelerated using GPU hardware, when available. This depends on the Docker build that is used. You can also target a specific device in a multi-GPU installation. The CLIP models are downloaded in ONNX format, and the `large` model can be accelerated using GPU hardware, when available. This depends on the Docker build that is used. You can also target a specific device in a multi-GPU installation.
+13 -11
View File
@@ -3,7 +3,7 @@ id: snapshots
title: Snapshots title: Snapshots
--- ---
Frigate can save a snapshot image to `/media/frigate/clips` for each object that is detected named as `<camera>-<id>-clean.webp`. They are also accessible [via the api](../integrations/api/event-snapshot-events-event-id-snapshot-jpg-get.api.mdx) Frigate can save a snapshot image to `/media/frigate/clips` for each object that is detected named as `<camera>-<id>.jpg`. They are also accessible [via the api](../integrations/api/event-snapshot-events-event-id-snapshot-jpg-get.api.mdx)
Snapshots are accessible in the UI in the Explore pane. This allows for quick submission to the Frigate+ service. Snapshots are accessible in the UI in the Explore pane. This allows for quick submission to the Frigate+ service.
@@ -13,19 +13,21 @@ Snapshots sent via MQTT are configured in the [config file](/configuration) unde
## Frame Selection ## Frame Selection
Frigate does not save every frame. It picks a single "best" frame for each tracked object based on detection confidence, object size, and the presence of key attributes like faces or license plates. Frames where the object touches the edge of the frame are deprioritized. That best frame is written to disk once tracking ends. Frigate does not save every frame — it picks a single "best" frame for each tracked object and uses it for both the snapshot and clean copy. As the object is tracked across frames, Frigate continuously evaluates whether the current frame is better than the previous best based on detection confidence, object size, and the presence of key attributes like faces or license plates. Frames where the object touches the edge of the frame are deprioritized. The snapshot is written to disk once tracking ends using whichever frame was determined to be the best.
MQTT snapshots are published more frequently — each time a better thumbnail frame is found during tracking, or when the current best image is older than `best_image_timeout` (default: 60s). These use their own annotation settings configured under `cameras -> your_camera -> mqtt`. MQTT snapshots are published more frequently — each time a better thumbnail frame is found during tracking, or when the current best image is older than `best_image_timeout` (default: 60s). These use their own annotation settings configured under `cameras -> your_camera -> mqtt`.
## Rendering ## Clean Copy
Frigate stores a single clean snapshot on disk: Frigate can produce up to two snapshot files per event, each used in different places:
| API / Use | Result | | Version | File | Annotations | Used by |
| ---------------------------------------- | ----------------------------------------------------------------------------------------------------- | | --- | --- | --- | --- |
| Stored file | `<camera>-<id>-clean.webp`, always unannotated | | **Regular snapshot** | `<camera>-<id>.jpg` | Respects your `timestamp`, `bounding_box`, `crop`, and `height` settings | API (`/api/events/<id>/snapshot.jpg`), MQTT (`<camera>/<label>/snapshot`), Explore pane in the UI |
| `/api/events/<id>/snapshot.jpg` | Starts from the camera's `snapshots` defaults, then applies any query param overrides at request time | | **Clean copy** | `<camera>-<id>-clean.webp` | Always unannotated — no bounding box, no timestamp, no crop, full resolution | API (`/api/events/<id>/snapshot-clean.webp`), [Frigate+](/plus/first_model) submissions, "Download Clean Snapshot" in the UI |
| `/api/events/<id>/snapshot-clean.webp` | Returns the same stored snapshot without annotations |
| [Frigate+](/plus/first_model) submission | Uses the same stored clean snapshot |
MQTT snapshots are configured separately under `cameras -> your_camera -> mqtt` and are unrelated to the stored event snapshot. MQTT snapshots are configured separately under `cameras -> your_camera -> mqtt` and are unrelated to the clean copy.
The clean copy is required for submitting events to [Frigate+](/plus/first_model) — if you plan to use Frigate+, keep `clean_copy` enabled regardless of your other snapshot settings.
If you are not using Frigate+ and `timestamp`, `bounding_box`, and `crop` are all disabled, the regular snapshot is already effectively clean, so `clean_copy` provides no benefit and only uses additional disk space. You can safely set `clean_copy: False` in this case.
+1 -5
View File
@@ -10,10 +10,6 @@ For example, the cat in this image is currently in Zone 1, but **not** Zone 2.
Zones cannot have the same name as a camera. If desired, a single zone can include multiple cameras if you have multiple cameras covering the same area by configuring zones with the same name for each camera. Zones cannot have the same name as a camera. If desired, a single zone can include multiple cameras if you have multiple cameras covering the same area by configuring zones with the same name for each camera.
## Enabling/Disabling Zones
Zones can be toggled on or off without removing them from the configuration. Disabled zones are completely ignored at runtime - objects will not be tracked for zone presence, and zones will not appear in the debug view. This is useful for temporarily disabling a zone during certain seasons or times of day without modifying the configuration.
During testing, enable the Zones option for the Debug view of your camera (Settings --> Debug) so you can adjust as needed. The zone line will increase in thickness when any object enters the zone. During testing, enable the Zones option for the Debug view of your camera (Settings --> Debug) so you can adjust as needed. The zone line will increase in thickness when any object enters the zone.
To create a zone, follow [the steps for a "Motion mask"](masks.md), but use the section of the web UI for creating a zone instead. To create a zone, follow [the steps for a "Motion mask"](masks.md), but use the section of the web UI for creating a zone instead.
@@ -90,6 +86,7 @@ cameras:
Only car objects can trigger the `front_yard_street` zone and only person can trigger the `entire_yard`. Objects will be tracked for any `person` that enter anywhere in the yard, and for cars only if they enter the street. Only car objects can trigger the `front_yard_street` zone and only person can trigger the `entire_yard`. Objects will be tracked for any `person` that enter anywhere in the yard, and for cars only if they enter the street.
### Zone Loitering ### Zone Loitering
Sometimes objects are expected to be passing through a zone, but an object loitering in an area is unexpected. Zones can be configured to have a minimum loitering time after which the object will be considered in the zone. Sometimes objects are expected to be passing through a zone, but an object loitering in an area is unexpected. Zones can be configured to have a minimum loitering time after which the object will be considered in the zone.
@@ -97,7 +94,6 @@ Sometimes objects are expected to be passing through a zone, but an object loite
:::note :::note
When using loitering zones, a review item will behave in the following way: When using loitering zones, a review item will behave in the following way:
- When a person is in a loitering zone, the review item will remain active until the person leaves the loitering zone, regardless of if they are stationary. - When a person is in a loitering zone, the review item will remain active until the person leaves the loitering zone, regardless of if they are stationary.
- When any other object is in a loitering zone, the review item will remain active until the loitering time is met. Then if the object is stationary the review item will end. - When any other object is in a loitering zone, the review item will remain active until the loitering time is met. Then if the object is stationary the review item will end.
+7 -20
View File
@@ -103,10 +103,6 @@ Frigate supports multiple different detectors that work on different types of ha
- [Synaptics](#synaptics): synap models can run on Synaptics devices(e.g astra machina) with included NPUs to provide efficient object detection. - [Synaptics](#synaptics): synap models can run on Synaptics devices(e.g astra machina) with included NPUs to provide efficient object detection.
**AXERA** <CommunityBadge />
- [AXEngine](#axera): axera models can run on AXERA NPUs via AXEngine, delivering highly efficient object detection.
::: :::
### Hailo-8 ### Hailo-8
@@ -200,14 +196,13 @@ Inference is done with the `onnx` detector type. Speeds will vary greatly depend
✅ - Accelerated with CUDA Graphs ✅ - Accelerated with CUDA Graphs
❌ - Not accelerated with CUDA Graphs ❌ - Not accelerated with CUDA Graphs
| Name | ✅ YOLOv9 Inference Time | ✅ RF-DETR Inference Time | ❌ YOLO-NAS Inference Time | | Name | ✅ YOLOv9 Inference Time | ✅ RF-DETR Inference Time | ❌ YOLO-NAS Inference Time |
| ----------- | ------------------------------------- | ------------------------- | -------------------------- | | --------- | ------------------------------------- | ------------------------- | -------------------------- |
| GTX 1070 | s-320: 16 ms | | 320: 14 ms | | GTX 1070 | s-320: 16 ms | | 320: 14 ms |
| RTX 3050 | t-320: 8 ms s-320: 10 ms s-640: 28 ms | Nano-320: ~ 12 ms | 320: ~ 10 ms 640: ~ 16 ms | | RTX 3050 | t-320: 8 ms s-320: 10 ms s-640: 28 ms | Nano-320: ~ 12 ms | 320: ~ 10 ms 640: ~ 16 ms |
| RTX 3070 | t-320: 6 ms s-320: 8 ms s-640: 25 ms | Nano-320: ~ 9 ms | 320: ~ 8 ms 640: ~ 14 ms | | RTX 3070 | t-320: 6 ms s-320: 8 ms s-640: 25 ms | Nano-320: ~ 9 ms | 320: ~ 8 ms 640: ~ 14 ms |
| RTX 5060 Ti | t-320: 5 ms s-320: 7 ms s-640: 22 ms | Nano-320: ~ 4 ms | | | RTX A4000 | | | 320: ~ 15 ms |
| RTX A4000 | | | 320: ~ 15 ms | | Tesla P40 | | | 320: ~ 105 ms |
| Tesla P40 | | | 320: ~ 105 ms |
### Apple Silicon ### Apple Silicon
@@ -293,14 +288,6 @@ The inference time of a rk3588 with all 3 cores enabled is typically 25-30 ms fo
| ssd mobilenet | ~ 25 ms | | ssd mobilenet | ~ 25 ms |
| yolov5m | ~ 118 ms | | yolov5m | ~ 118 ms |
### AXERA
- **AXEngine** Default model is **yolov9**
| Name | AXERA AX650N/AX8850N Inference Time |
| ---------------- | ----------------------------------- |
| yolov9-tiny | ~ 4 ms |
## What does Frigate use the CPU for and what does it use a detector for? (ELI5 Version) ## What does Frigate use the CPU for and what does it use a detector for? (ELI5 Version)
This is taken from a [user question on reddit](https://www.reddit.com/r/homeassistant/comments/q8mgau/comment/hgqbxh5/?utm_source=share&utm_medium=web2x&context=3). Modified slightly for clarity. This is taken from a [user question on reddit](https://www.reddit.com/r/homeassistant/comments/q8mgau/comment/hgqbxh5/?utm_source=share&utm_medium=web2x&context=3). Modified slightly for clarity.
+22 -41
View File
@@ -3,13 +3,11 @@ id: installation
title: Installation title: Installation
--- ---
import ShmCalculator from '@site/src/components/ShmCalculator'
Frigate is a Docker container that can be run on any Docker host including as a [Home Assistant App](https://www.home-assistant.io/apps/). Note that the Home Assistant App is **not** the same thing as the integration. The [integration](/integrations/home-assistant) is required to integrate Frigate into Home Assistant, whether you are running Frigate as a standalone Docker container or as a Home Assistant App. Frigate is a Docker container that can be run on any Docker host including as a [Home Assistant App](https://www.home-assistant.io/apps/). Note that the Home Assistant App is **not** the same thing as the integration. The [integration](/integrations/home-assistant) is required to integrate Frigate into Home Assistant, whether you are running Frigate as a standalone Docker container or as a Home Assistant App.
:::tip :::tip
If you already have Frigate installed as a Home Assistant App, check out the [getting started guide](../guides/getting_started#configuring-frigate) to configure Frigate. If you already have Frigate installed as a Home Assistant App, check out the [getting started guide](../guides/getting_started.md#configuring-frigate) to configure Frigate.
::: :::
@@ -79,7 +77,20 @@ The default shm size of **128MB** is fine for setups with **2 cameras** detectin
The Frigate container also stores logs in shm, which can take up to **40MB**, so make sure to take this into account in your math as well. The Frigate container also stores logs in shm, which can take up to **40MB**, so make sure to take this into account in your math as well.
<ShmCalculator/> You can calculate the **minimum** shm size for each camera with the following formula using the resolution specified for detect:
```console
# Template for one camera without logs, replace <width> and <height>
$ python -c 'print("{:.2f}MB".format((<width> * <height> * 1.5 * 20 + 270480) / 1048576))'
# Example for 1280x720, including logs
$ python -c 'print("{:.2f}MB".format((1280 * 720 * 1.5 * 20 + 270480) / 1048576 + 40))'
66.63MB
# Example for eight cameras detecting at 1280x720, including logs
$ python -c 'print("{:.2f}MB".format(((1280 * 720 * 1.5 * 20 + 270480) / 1048576) * 8 + 40))'
253MB
```
The shm size cannot be set per container for Home Assistant Apps. However, this is probably not required since by default Home Assistant Supervisor allocates `/dev/shm` with half the size of your total memory. If your machine has 8GB of memory, chances are that Frigate will have access to up to 4GB without any additional configuration. The shm size cannot be set per container for Home Assistant Apps. However, this is probably not required since by default Home Assistant Supervisor allocates `/dev/shm` with half the size of your total memory. If your machine has 8GB of memory, chances are that Frigate will have access to up to 4GB without any additional configuration.
@@ -286,7 +297,7 @@ The MemryX MX3 Accelerator is available in the M.2 2280 form factor (like an NVM
#### Installation #### Installation
To get started with MX3 hardware setup for your system, refer to the [Hardware Setup Guide](https://developer.memryx.com/get_started/hardware_setup.html). To get started with MX3 hardware setup for your system, refer to the [Hardware Setup Guide](https://developer.memryx.com/2p1/get_started/install_hardware.html).
Then follow these steps for installing the correct driver/runtime configuration: Then follow these steps for installing the correct driver/runtime configuration:
@@ -295,6 +306,12 @@ Then follow these steps for installing the correct driver/runtime configuration:
3. Run the script with `./user_installation.sh` 3. Run the script with `./user_installation.sh`
4. **Restart your computer** to complete driver installation. 4. **Restart your computer** to complete driver installation.
:::warning
For manual setup, use **MemryX SDK 2.1** only. Other SDK versions are not supported for this setup. See the [SDK 2.1 documentation](https://developer.memryx.com/2p1/index.html)
:::
#### Setup #### Setup
To set up Frigate, follow the default installation instructions, for example: `ghcr.io/blakeblackshear/frigate:stable` To set up Frigate, follow the default installation instructions, for example: `ghcr.io/blakeblackshear/frigate:stable`
@@ -428,42 +445,6 @@ or add these options to your `docker run` command:
Next, you should configure [hardware object detection](/configuration/object_detectors#synaptics) and [hardware video processing](/configuration/hardware_acceleration_video#synaptics). Next, you should configure [hardware object detection](/configuration/object_detectors#synaptics) and [hardware video processing](/configuration/hardware_acceleration_video#synaptics).
### AXERA
AXERA accelerators are available in an M.2 form factor, compatible with both Raspberry Pi and Orange Pi. This form factor has also been successfully tested on x86 platforms, making it a versatile choice for various computing environments.
#### Installation
Using AXERA accelerators requires the installation of the AXCL driver. We provide a convenient Linux script to complete this installation.
Follow these steps for installation:
1. Copy or download [this script](https://github.com/ivanshi1108/assets/releases/download/v0.16.2/user_installation.sh).
2. Ensure it has execution permissions with `sudo chmod +x user_installation.sh`
3. Run the script with `./user_installation.sh`
#### Setup
To set up Frigate, follow the default installation instructions, for example: `ghcr.io/blakeblackshear/frigate:stable`
Next, grant Docker permissions to access your hardware by adding the following lines to your `docker-compose.yml` file:
```yaml
devices:
- /dev/axcl_host
- /dev/ax_mmb_dev
- /dev/msg_userdev
volumes:
- /usr/bin/axcl:/usr/bin/axcl
- /usr/lib/axcl:/usr/lib/axcl
```
If you are using `docker run`, add this option to your command `--device /dev/axcl_host --device /dev/ax_mmb_dev --device /dev/msg_userdev`
#### Configuration
Finally, configure [hardware object detection](/configuration/object_detectors#axera) to complete the setup.
## Docker ## Docker
Running through Docker with Docker Compose is the recommended install method. Running through Docker with Docker Compose is the recommended install method.
+6 -6
View File
@@ -5,7 +5,7 @@ title: Updating
# Updating Frigate # Updating Frigate
The current stable version of Frigate is **0.17.0**. The release notes and any breaking changes for this version can be found on the [Frigate GitHub releases page](https://github.com/blakeblackshear/frigate/releases/tag/v0.17.0). The current stable version of Frigate is **0.17.2**. The release notes and any breaking changes for this version can be found on the [Frigate GitHub releases page](https://github.com/blakeblackshear/frigate/releases/tag/v0.17.2).
Keeping Frigate up to date ensures you benefit from the latest features, performance improvements, and bug fixes. The update process varies slightly depending on your installation method (Docker, Home Assistant App, etc.). Below are instructions for the most common setups. Keeping Frigate up to date ensures you benefit from the latest features, performance improvements, and bug fixes. The update process varies slightly depending on your installation method (Docker, Home Assistant App, etc.). Below are instructions for the most common setups.
@@ -31,21 +31,21 @@ If youre running Frigate via Docker (recommended method), follow these steps:
2. **Update and Pull the Latest Image**: 2. **Update and Pull the Latest Image**:
- If using Docker Compose: - If using Docker Compose:
- Edit your `docker-compose.yml` file to specify the desired version tag (e.g., `0.17.0` instead of `0.16.4`). For example: - Edit your `docker-compose.yml` file to specify the desired version tag (e.g., `0.17.2` instead of `0.16.4`). For example:
```yaml ```yaml
services: services:
frigate: frigate:
image: ghcr.io/blakeblackshear/frigate:0.17.0 image: ghcr.io/blakeblackshear/frigate:0.17.2
``` ```
- Then pull the image: - Then pull the image:
```bash ```bash
docker pull ghcr.io/blakeblackshear/frigate:0.17.0 docker pull ghcr.io/blakeblackshear/frigate:0.17.2
``` ```
- **Note for `stable` Tag Users**: If your `docker-compose.yml` uses the `stable` tag (e.g., `ghcr.io/blakeblackshear/frigate:stable`), you dont need to update the tag manually. The `stable` tag always points to the latest stable release after pulling. - **Note for `stable` Tag Users**: If your `docker-compose.yml` uses the `stable` tag (e.g., `ghcr.io/blakeblackshear/frigate:stable`), you dont need to update the tag manually. The `stable` tag always points to the latest stable release after pulling.
- If using `docker run`: - If using `docker run`:
- Pull the image with the appropriate tag (e.g., `0.17.0`, `0.17.0-tensorrt`, or `stable`): - Pull the image with the appropriate tag (e.g., `0.17.2`, `0.17.2-tensorrt`, or `stable`):
```bash ```bash
docker pull ghcr.io/blakeblackshear/frigate:0.17.0 docker pull ghcr.io/blakeblackshear/frigate:0.17.2
``` ```
3. **Start the Container**: 3. **Start the Container**:
+3 -3
View File
@@ -11,7 +11,7 @@ Use of the bundled go2rtc is optional. You can still configure FFmpeg to connect
## Setup a go2rtc stream ## Setup a go2rtc stream
First, you will want to configure go2rtc to connect to your camera stream by adding the stream you want to use for live view in your Frigate config file. Avoid changing any other parts of your config at this step. Note that go2rtc supports [many different stream types](https://github.com/AlexxIT/go2rtc/tree/v1.9.13#module-streams), not just rtsp. First, you will want to configure go2rtc to connect to your camera stream by adding the stream you want to use for live view in your Frigate config file. Avoid changing any other parts of your config at this step. Note that go2rtc supports [many different stream types](https://github.com/AlexxIT/go2rtc/tree/v1.9.10#module-streams), not just rtsp.
:::tip :::tip
@@ -44,8 +44,8 @@ After adding this to the config, restart Frigate and try to watch the live strea
- Check Video Codec: - Check Video Codec:
- If the camera stream works in go2rtc but not in your browser, the video codec might be unsupported. - If the camera stream works in go2rtc but not in your browser, the video codec might be unsupported.
- If using H265, switch to H264. Refer to [video codec compatibility](https://github.com/AlexxIT/go2rtc/tree/v1.9.13#codecs-madness) in go2rtc documentation. - If using H265, switch to H264. Refer to [video codec compatibility](https://github.com/AlexxIT/go2rtc/tree/v1.9.10#codecs-madness) in go2rtc documentation.
- If unable to switch from H265 to H264, or if the stream format is different (e.g., MJPEG), re-encode the video using [FFmpeg parameters](https://github.com/AlexxIT/go2rtc/tree/v1.9.13#source-ffmpeg). It supports rotating and resizing video feeds and hardware acceleration. Keep in mind that transcoding video from one format to another is a resource intensive task and you may be better off using the built-in jsmpeg view. - If unable to switch from H265 to H264, or if the stream format is different (e.g., MJPEG), re-encode the video using [FFmpeg parameters](https://github.com/AlexxIT/go2rtc/tree/v1.9.10#source-ffmpeg). It supports rotating and resizing video feeds and hardware acceleration. Keep in mind that transcoding video from one format to another is a resource intensive task and you may be better off using the built-in jsmpeg view.
```yaml ```yaml
go2rtc: go2rtc:
streams: streams:
+1 -4
View File
@@ -282,10 +282,7 @@ cameras:
- detect - detect
motion: motion:
mask: mask:
motion_area: - 0,461,3,0,1919,0,1919,843,1699,492,1344,458,1346,336,973,317,869,375,866,432
friendly_name: "Motion mask"
enabled: true
coordinates: "0,461,3,0,1919,0,1919,843,1699,492,1344,458,1346,336,973,317,869,375,866,432"
``` ```
### Step 6: Enable recordings ### Step 6: Enable recordings
+2 -36
View File
@@ -11,8 +11,7 @@ These are the MQTT messages generated by Frigate. The default topic_prefix is `f
Designed to be used as an availability topic with Home Assistant. Possible message are: Designed to be used as an availability topic with Home Assistant. Possible message are:
"online": published when Frigate is running (on startup) "online": published when Frigate is running (on startup)
"stopped": published when Frigate is stopped normally "offline": published after Frigate has stopped
"offline": published automatically by the MQTT broker if Frigate disconnects unexpectedly (via MQTT Will Message)
### `frigate/restart` ### `frigate/restart`
@@ -160,8 +159,7 @@ Published when a license plate is recognized on a car object. See the [License P
"plate": "123ABC", "plate": "123ABC",
"score": 0.95, "score": 0.95,
"camera": "driveway_cam", "camera": "driveway_cam",
"timestamp": 1607123958.748393, "timestamp": 1607123958.748393
"plate_box": [917, 487, 1029, 529] // box coordinates of the detected license plate in the frame
} }
``` ```
@@ -276,14 +274,6 @@ Same data available at `/api/stats` published at a configurable interval.
Returns data about each camera, its current features, and if it is detecting motion, objects, etc. Can be triggered by publising to `frigate/onConnect` Returns data about each camera, its current features, and if it is detecting motion, objects, etc. Can be triggered by publising to `frigate/onConnect`
### `frigate/profile/set`
Topic to activate or deactivate a [profile](/configuration/profiles). Publish a profile name to activate it, or `none` to deactivate the current profile.
### `frigate/profile/state`
Topic with the currently active profile name. Published value is the profile name or `none` if no profile is active. This topic is retained.
### `frigate/notifications/set` ### `frigate/notifications/set`
Topic to turn notifications on and off. Expected values are `ON` and `OFF`. Topic to turn notifications on and off. Expected values are `ON` and `OFF`.
@@ -439,30 +429,6 @@ Topic to adjust motion contour area for a camera. Expected value is an integer.
Topic with current motion contour area for a camera. Published value is an integer. Topic with current motion contour area for a camera. Published value is an integer.
### `frigate/<camera_name>/motion_mask/<mask_name>/set`
Topic to turn a specific motion mask for a camera on and off. Expected values are `ON` and `OFF`.
### `frigate/<camera_name>/motion_mask/<mask_name>/state`
Topic with current state of a specific motion mask for a camera. Published values are `ON` and `OFF`.
### `frigate/<camera_name>/object_mask/<mask_name>/set`
Topic to turn a specific object mask for a camera on and off. Expected values are `ON` and `OFF`.
### `frigate/<camera_name>/object_mask/<mask_name>/state`
Topic with current state of a specific object mask for a camera. Published values are `ON` and `OFF`.
### `frigate/<camera_name>/zone/<zone_name>/set`
Topic to turn a specific zone for a camera on and off. Expected values are `ON` and `OFF`.
### `frigate/<camera_name>/zone/<zone_name>/state`
Topic with current state of a specific zone for a camera. Published values are `ON` and `OFF`.
### `frigate/<camera_name>/review_status` ### `frigate/<camera_name>/review_status`
Topic with current activity status of the camera. Possible values are `NONE`, `DETECTION`, or `ALERT`. Topic with current activity status of the camera. Possible values are `NONE`, `DETECTION`, or `ALERT`.
+9 -2
View File
@@ -21,13 +21,20 @@ Yes. Models and metadata are stored in the `model_cache` directory within the co
### Can I keep using my Frigate+ models even if I do not renew my subscription? ### Can I keep using my Frigate+ models even if I do not renew my subscription?
Yes. Subscriptions to Frigate+ provide access to the infrastructure used to train the models. Models trained with your subscription are yours to keep and use forever. However, do note that the terms and conditions prohibit you from sharing, reselling, or creating derivative products from the models. Yes. Subscriptions to Frigate+ provide access to the infrastructure used to train the models. Models you train during an active subscription remain licensed for your continued use even after your subscription ends — models already in your model cache will keep working indefinitely. An active subscription is required to train new models and download new versions.
### Can I use Frigate+ models commercially?
A standard subscription covers use on camera systems you own or operate, including for your business. A shop, restaurant, warehouse, or office running Frigate+ at its own locations (including multiple locations) is exactly the kind of use the subscription is for.
What the standard subscription does not cover is using Frigate+ models to provide a product or service to others. If you're deploying models at your customers' sites, bundling them with hardware you sell, or running them as part of a hosted or managed service, even if your customers never receive the model files themselves, you'll need a commercial license.
Note that professional installers are fine under standard subscriptions when each customer holds their own Frigate+ subscription. The commercial license is for cases where your license powers your customers' sites.
### Why can't I submit images to Frigate+? ### Why can't I submit images to Frigate+?
If you've configured your API key and the Frigate+ Settings page in the UI shows that the key is active, you need to ensure that snapshots are enabled for the cameras you'd like to submit images for. If you've configured your API key and the Frigate+ Settings page in the UI shows that the key is active, you need to ensure that you've enabled both snapshots and `clean_copy` snapshots for the cameras you'd like to submit images for. Note that `clean_copy` is enabled by default when snapshots are enabled.
```yaml ```yaml
snapshots: snapshots:
enabled: true enabled: true
clean_copy: true
``` ```
+9 -6
View File
@@ -65,11 +65,11 @@ Some users may find that Frigate+ models result in more false positives initiall
Frigate+ models support a more relevant set of objects for security cameras. The labels for annotation in Frigate+ are configurable by editing the camera in the Cameras section of Frigate+. Currently, the following objects are supported: Frigate+ models support a more relevant set of objects for security cameras. The labels for annotation in Frigate+ are configurable by editing the camera in the Cameras section of Frigate+. Currently, the following objects are supported:
- **People**: `person`, `face` - **People**: `person`, `face`, `baby`
- **Vehicles**: `car`, `motorcycle`, `bicycle`, `boat`, `school_bus`, `license_plate` - **Vehicles**: `car`, `motorcycle`, `bicycle`, `boat`, `school_bus`, `garbage truck`, `license_plate`
- **Delivery Logos**: `amazon`, `usps`, `ups`, `fedex`, `dhl`, `an_post`, `purolator`, `postnl`, `nzpost`, `postnord`, `gls`, `dpd`, `canada_post`, `royal_mail` - **Delivery Logos**: `amazon`, `usps`, `ups`, `fedex`, `dhl`, `an_post`, `purolator`, `postnl`, `nzpost`, `postnord`, `gls`, `dpd`, `canada_post`, `royal_mail`
- **Animals**: `dog`, `cat`, `deer`, `horse`, `bird`, `raccoon`, `fox`, `bear`, `cow`, `squirrel`, `goat`, `rabbit`, `skunk`, `kangaroo` - **Animals**: `dog`, `cat`, `deer`, `horse`, `bird`, `raccoon`, `fox`, `bear`, `cow`, `squirrel`, `goat`, `rabbit`, `skunk`, `kangaroo`, `possum`, `rodent`
- **Other**: `package`, `waste_bin`, `bbq_grill`, `robot_lawnmower`, `umbrella` - **Other**: `package`, `waste_bin`, `bbq_grill`, `robot_lawnmower`, `umbrella`, `baby_stroller`
Other object types available in the default Frigate model are not available. Additional object types will be added in future releases. Other object types available in the default Frigate model are not available. Additional object types will be added in future releases.
@@ -77,9 +77,12 @@ Other object types available in the default Frigate model are not available. Add
Candidate labels are also available for annotation. These labels don't have enough data to be included in the model yet, but using them will help add support sooner. You can enable these labels by editing the camera settings. Candidate labels are also available for annotation. These labels don't have enough data to be included in the model yet, but using them will help add support sooner. You can enable these labels by editing the camera settings.
Where possible, these labels are mapped to existing labels during training. For example, any `baby` labels are mapped to `person` until support for new labels is added. Where possible, these labels are mapped to existing labels during training. For example, any `duck` labels are mapped to `bird` until support for new labels is added.
The candidate labels are: `baby`, `bpost`, `badger`, `possum`, `rodent`, `chicken`, `groundhog`, `boar`, `hedgehog`, `tractor`, `golf cart`, `garbage truck`, `bus`, `sports ball`, `la_poste`, `lawnmower`, `heron`, `rickshaw`, `wombat`, `auspost`, `aramex`, `bobcat`, `mustelid`, `transoflex`, `airplane`, `drone`, `mountain_lion`, `crocodile`, `turkey`, `baby_stroller`, `monkey`, `coyote`, `porcupine`, `parcelforce`, `sheep`, `snake`, `helicopter`, `lizard`, `duck`, `hermes`, `cargus`, `fan_courier`, `sameday` - **Vehicles**: `tractor`, `golf_cart`, `bus`, `airplane`, `helicopter`, `rickshaw`, `scooter`
- **Delivery Logos**: `bpost`, `auspost`, `aramex`, `transoflex`, `parcelforce`, `hermes`, `cargus`, `fan_courier`, `sameday`, `la_poste`
- **Animals**: `badger`, `chicken`, `duck`, `turkey`, `groundhog`, `boar`, `hedgehog`, `wombat`, `bobcat`, `mustelid`, `mountain_lion`, `crocodile`, `monkey`, `coyote`, `porcupine`, `sheep`, `snake`, `lizard`, `heron`, `elk`, `moose`, `pig`, `donkey`, `civet`
- **Other**: `sports_ball`, `drone`, `lawnmower`
Candidate labels are not available for automatic suggestions. Candidate labels are not available for automatic suggestions.
+22 -64
View File
@@ -3,67 +3,17 @@ id: dummy-camera
title: Analyzing Object Detection title: Analyzing Object Detection
--- ---
Frigate provides several tools for investigating object detection and tracking behavior: reviewing recorded detections through the UI, using the built-in Debug Replay feature, and manually setting up a dummy camera for advanced scenarios. When investigating object detection or tracking problems, it can be helpful to replay an exported video as a temporary "dummy" camera. This lets you reproduce issues locally, iterate on configuration (detections, zones, enrichment settings), and capture logs and clips for analysis.
## Reviewing Detections in the UI ## When to use
Before setting up a replay, you can often diagnose detection issues by reviewing existing recordings directly in the Frigate UI. - Replaying an exported clip to reproduce incorrect detections
- Testing configuration changes (model settings, trackers, filters) against a known clip
- Gathering deterministic logs and recordings for debugging or issue reports
### Detail View (History) ## Example Config
The **Detail Stream** view in History shows recorded video with detection overlays (bounding boxes, path points, and zone highlights) drawn on top. Select a review item to see its tracked objects and lifecycle events. Clicking a lifecycle event seeks the video to that point so you can see exactly what the detector saw. Place the clip you want to replay in a location accessible to Frigate (for example `/media/frigate/` or the repository `debug/` folder when developing). Then add a temporary camera to your `config/config.yml` like this:
### Tracking Details (Explore)
In **Explore**, clicking a thumbnail opens the **Tracking Details** pane, which shows the full lifecycle of a single tracked object: every detection, zone entry/exit, and attribute change. The video plays back with the bounding box overlaid, letting you step through the object's entire lifecycle.
### Annotation Offset
Both views support an **Annotation Offset** setting (`detect.annotation_offset` in your camera config) that shifts the detection overlay in time relative to the recorded video. This compensates for the timing drift between the `detect` and `record` pipelines.
These streams use fundamentally different clocks with different buffering and latency characteristics, so the detection data and the recorded video are never perfectly synchronized. The annotation offset shifts the overlay to visually align the bounding boxes with the objects in the recorded video.
#### Why the offset varies between clips
The base timing drift between detect and record is roughly constant for a given camera, so a single offset value works well on average. However, you may notice the alignment is not pixel-perfect in every clip. This is normal and caused by several factors:
- **Keyframe-constrained seeking**: When the browser seeks to a timestamp, it can only land on the nearest keyframe. Each recording segment has keyframes at different positions relative to the detection timestamps, so the same offset may land slightly early in one clip and slightly late in another.
- **Segment boundary trimming**: When a recording range starts mid-segment, the video is trimmed to the requested start point. This trim may not align with a keyframe, shifting the effective reference point.
- **Capture-time jitter**: Network buffering, camera buffer flushes, and ffmpeg's own buffering mean the system-clock timestamp and the corresponding recorded frame are not always offset by exactly the same amount.
The per-clip variation is typically quite low and is mostly an artifact of keyframe granularity rather than a change in the true drift. A "perfect" alignment would require per-frame, keyframe-aware offset compensation, which is not practical. Treat the annotation offset as a best-effort average for your camera.
## Debug Replay
Debug Replay lets you re-run Frigate's detection pipeline against a section of recorded video without manually configuring a dummy camera. It automatically extracts the recording, creates a temporary camera with the same detection settings as the original, and loops the clip through the pipeline so you can observe detections in real time.
### When to use
- Reproducing a detection or tracking issue from a specific time range
- Testing configuration changes (model settings, zones, filters, motion) against a known clip
- Gathering logs and debug overlays for a bug report
:::note
Only one replay session can be active at a time. If a session is already running, you will be prompted to navigate to it or stop it first.
:::
### Variables to consider
- The replay will not always produce identical results to the original run. Different frames may be selected on replay, which can change detections and tracking.
- Motion detection depends on the exact frames used; small frame shifts can change motion regions and therefore what gets passed to the detector.
- Object detection is not fully deterministic: models and post-processing can yield slightly different results across runs.
Treat the replay as a close approximation rather than an exact reproduction. Run multiple loops and examine the debug overlays and logs to understand the behavior.
## Manual Dummy Camera
For advanced scenarios — such as testing with a clip from a different source, debugging ffmpeg behavior, or running a clip through a completely custom configuration — you can set up a dummy camera manually.
### Example config
Place the clip you want to replay in a location accessible to Frigate (for example `/media/frigate/` or the repository `debug/` folder when developing). Then add a temporary camera to your `config/config.yml`:
```yaml ```yaml
cameras: cameras:
@@ -82,10 +32,10 @@ cameras:
enabled: false enabled: false
``` ```
- `-re -stream_loop -1` tells ffmpeg to play the file in real time and loop indefinitely. - `-re -stream_loop -1` tells `ffmpeg` to play the file in realtime and loop indefinitely, which is useful for long debugging sessions.
- `-fflags +genpts` generates presentation timestamps when they are missing in the file. - `-fflags +genpts` helps generate presentation timestamps when they are missing in the file.
### Steps ## Steps
1. Export or copy the clip you want to replay to the Frigate host (e.g., `/media/frigate/` or `debug/clips/`). Depending on what you are looking to debug, it is often helpful to add some "pre-capture" time (where the tracked object is not yet visible) to the clip when exporting. 1. Export or copy the clip you want to replay to the Frigate host (e.g., `/media/frigate/` or `debug/clips/`). Depending on what you are looking to debug, it is often helpful to add some "pre-capture" time (where the tracked object is not yet visible) to the clip when exporting.
2. Add the temporary camera to `config/config.yml` (example above). Use a unique name such as `test` or `replay_camera` so it's easy to remove later. 2. Add the temporary camera to `config/config.yml` (example above). Use a unique name such as `test` or `replay_camera` so it's easy to remove later.
@@ -95,8 +45,16 @@ cameras:
5. Iterate on camera or enrichment settings (model, fps, zones, filters) and re-check the replay until the behavior is resolved. 5. Iterate on camera or enrichment settings (model, fps, zones, filters) and re-check the replay until the behavior is resolved.
6. Remove the temporary camera from your config after debugging to avoid spurious telemetry or recordings. 6. Remove the temporary camera from your config after debugging to avoid spurious telemetry or recordings.
### Troubleshooting ## Variables to consider in object tracking
- **No video**: verify the file path is correct and accessible from the Frigate process/container. - The exported video will not always line up exactly with how it originally ran through Frigate (or even with the last loop). Different frames may be used on replay, which can change detections and tracking.
- **FFmpeg errors**: check the log output and adjust `input_args` for your file format. You may also need to disable hardware acceleration (`hwaccel_args: ""`) for the dummy camera. - Motion detection depends on the frames used; small frame shifts can change motion regions and therefore what gets passed to the detector.
- **No detections**: confirm the camera `roles` include `detect` and that the model/detector configuration is enabled. - Object detection is not deterministic: models and post-processing can yield different results across runs, so you may not get identical detections or track IDs every time.
When debugging, treat the replay as a close approximation rather than a byte-for-byte replay. Capture multiple runs, enable recording if helpful, and examine logs and saved event clips to understand variability.
## Troubleshooting
- No video: verify the path is correct and accessible from the Frigate process/container.
- FFmpeg errors: check the log output for ffmpeg-specific flags and adjust `input_args` accordingly for your file/container. You may also need to disable hardware acceleration (`hwaccel_args: ""`) for the dummy camera.
- No detections: confirm the camera `roles` include `detect`, and model/detector configuration is enabled.
+16
View File
@@ -110,3 +110,19 @@ No. Frigate uses the TCP protocol to connect to your camera's RTSP URL. VLC auto
TCP ensures that all data packets arrive in the correct order. This is crucial for video recording, decoding, and stream processing, which is why Frigate enforces a TCP connection. UDP is faster but less reliable, as it does not guarantee packet delivery or order, and VLC does not have the same requirements as Frigate. TCP ensures that all data packets arrive in the correct order. This is crucial for video recording, decoding, and stream processing, which is why Frigate enforces a TCP connection. UDP is faster but less reliable, as it does not guarantee packet delivery or order, and VLC does not have the same requirements as Frigate.
You can still configure Frigate to use UDP by using ffmpeg input args or the preset `preset-rtsp-udp`. See the [ffmpeg presets](/configuration/ffmpeg_presets) documentation. You can still configure Frigate to use UDP by using ffmpeg input args or the preset `preset-rtsp-udp`. See the [ffmpeg presets](/configuration/ffmpeg_presets) documentation.
### Why does Frigate keep creating new events for my parked car?
Stationary tracking is designed to _prevent_ this — a parked car should stay one tracked object and not generate new events. If you're getting repeated events for the same car, it's likely that Frigate is losing the tracked object and re-detecting it as a new one.
Open one of the events in Explore → **Tracking Details**. If the detection scores are low (< 70% or so), the model isn't confident the parked car is a car. This is common with the free [COCO-trained](https://cocodataset.org/#explore) object detection models on steep/top-down angles, partially occluded cars, foliage, or low-light footage. When detections fall below `min_score` for too many frames the tracker loses the object, and the next confident frame creates a brand new one.
What helps:
- **Improve the view** — even a small angle change that gets more of the car visible could lift scores enough to stabilize tracking.
- **Use a more accurate model** — switching from `mobiledet` to `yolov9`, or stepping up to a larger variant like `yolov9-s` over `yolov9-t`, can help (at the cost of inference time, and still on the COCO dataset). The biggest gains usually come from fine-tuning a model on images from your own cameras so it learns your specific scene. [Frigate+](https://frigate.video/plus) is a paid option that does this - models are trained on security-camera footage and can be fine-tuned on images you submit from your own setup.
- **Don't set `detect -> stationary -> max_frames` for `car`** — it artificially ends tracking and forces re-detection as a new object. See [Stationary Objects](../configuration/stationary_objects.md).
- **Restrict alerts to the areas you care about** with `required_zones` — see [Zones](../configuration/zones.md#restricting-alerts-and-detections-to-specific-zones). Make sure those zones use the default `loitering_time: 0` unless you specifically want the review item to stay open until the car leaves.
- **Filter impossible locations** with [object filter masks](../configuration/masks.md#object-filter-masks) if cars are being detected on rooftops, treetops, etc.
See [Object Filters](../configuration/object_filters.md) for more on tuning `min_score` and `threshold` — note that raising them too high will make this exact problem worse.
+3 -3
View File
@@ -12313,9 +12313,9 @@
} }
}, },
"node_modules/immutable": { "node_modules/immutable": {
"version": "5.1.5", "version": "5.1.4",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz", "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.4.tgz",
"integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==", "integrity": "sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/import-fresh": { "node_modules/import-fresh": {
+1 -2
View File
@@ -28,7 +28,7 @@ const sidebars: SidebarsConfig = {
{ {
type: "link", type: "link",
label: "Go2RTC Configuration Reference", label: "Go2RTC Configuration Reference",
href: "https://github.com/AlexxIT/go2rtc/tree/v1.9.13#configuration", href: "https://github.com/AlexxIT/go2rtc/tree/v1.9.10#configuration",
} as PropSidebarItemLink, } as PropSidebarItemLink,
], ],
Detectors: [ Detectors: [
@@ -94,7 +94,6 @@ const sidebars: SidebarsConfig = {
"Extra Configuration": [ "Extra Configuration": [
"configuration/authentication", "configuration/authentication",
"configuration/notifications", "configuration/notifications",
"configuration/profiles",
"configuration/ffmpeg_presets", "configuration/ffmpeg_presets",
"configuration/pwa", "configuration/pwa",
"configuration/tls", "configuration/tls",
-201
View File
@@ -1,201 +0,0 @@
import React, { useState, useEffect } from "react";
import Admonition from "@theme/Admonition";
import styles from "./styles.module.css";
const ShmCalculator = () => {
const [width, setWidth] = useState(1280);
const [height, setHeight] = useState(720);
const [cameraCount, setCameraCount] = useState(1);
const [result, setResult] = useState("26.32MB");
const [singleCameraShm, setSingleCameraShm] = useState("26.32MB");
const [totalShm, setTotalShm] = useState("26.32MB");
const calculate = () => {
if (!width || !height || !cameraCount) {
setResult("Please enter valid values");
setSingleCameraShm("-");
setTotalShm("-");
return;
}
// Single camera base SHM calculation (excluding logs)
// Formula: (width * height * 1.5 * 20 + 270480) / 1048576
const singleCameraBase =
(width * height * 1.5 * 20 + 270480) / 1048576;
setSingleCameraShm(`${singleCameraBase.toFixed(2)}mb`);
// Total SHM calculation (multiple cameras, including logs)
const totalBase = singleCameraBase * cameraCount;
const finalResult = totalBase + 40; // Default includes logs +40mb
setTotalShm(`${(totalBase + 40).toFixed(2)}mb`);
// Format result
if (finalResult < 1) {
setResult(`${(finalResult * 1024).toFixed(2)}kb`);
} else if (finalResult >= 1024) {
setResult(`${(finalResult / 1024).toFixed(2)}gb`);
} else {
setResult(`${finalResult.toFixed(2)}mb`);
}
};
const formatWithUnit = (value) => {
const match = value.match(/^([\d.]+)(mb|kb|gb)$/i);
if (match) {
return (
<>
{match[1]}<span className={styles.unit}>{match[2]}</span>
</>
);
}
return value;
};
const applyPreset = (w, h, count) => {
setWidth(w);
setHeight(h);
setCameraCount(count);
calculate();
};
useEffect(() => {
calculate();
}, [width, height, cameraCount]);
return (
<div className={styles.shmCalculator}>
<div className={styles.card}>
<h3 className={styles.title}>SHM Calculator</h3>
<p className={styles.description}>
Calculate required shared memory (SHM) based on camera resolution and
count
</p>
<Admonition type="note">
The resolution below is the <strong>detect</strong> stream resolution,
not the <strong>record</strong> stream resolution. SHM size is
determined by the detect resolution used for object detection.{" "}
<a href="/frigate/camera_setup#choosing-a-detect-resolution">
Learn more about choosing a detect resolution.
</a>
</Admonition>
{width * height > 1280 * 720 && (
<Admonition type="warning">
Using a detect resolution higher than 720p is not recommended.
Higher resolutions do not improve object detection accuracy and will
consume significantly more resources.
</Admonition>
)}
<div className="row">
<div className="col col--6">
<div className={styles.formGroup}>
<label htmlFor="width" className={styles.label}>
Width:
</label>
<input
id="width"
type="number"
min="1"
placeholder="e.g.: 1280"
className={styles.input}
value={width}
onChange={(e) => setWidth(Number(e.target.value))}
/>
</div>
</div>
<div className="col col--6">
<div className={styles.formGroup}>
<label htmlFor="height" className={styles.label}>
Height:
</label>
<input
id="height"
type="number"
min="1"
placeholder="e.g.: 720"
className={styles.input}
value={height}
onChange={(e) => setHeight(Number(e.target.value))}
/>
</div>
</div>
</div>
<div className={styles.formGroup}>
<label htmlFor="cameraCount" className={styles.label}>
Camera Count:
</label>
<input
id="cameraCount"
type="number"
min="1"
placeholder="e.g.: 8"
className={styles.input}
value={cameraCount}
onChange={(e) => setCameraCount(Number(e.target.value))}
/>
</div>
<div className={styles.resultSection}>
<h4>Calculation Result</h4>
<div className={styles.resultValue}>
<span className={styles.resultNumber}>{formatWithUnit(result)}</span>
</div>
<div className={styles.formulaDisplay}>
<p>
<strong>Single Camera:</strong> {formatWithUnit(singleCameraShm)}
</p>
<p>
<strong>Formula:</strong> (width × height × 1.5 × 20 + 270480) ÷
1048576
</p>
{cameraCount > 1 && (
<p>
<strong>Total ({cameraCount} cameras):</strong> {formatWithUnit(totalShm)}
</p>
)}
<p>
<strong>With Logs:</strong> + 40<span className={styles.unit}>mb</span>
</p>
</div>
</div>
<div className={styles.presets}>
<h4>Common Presets</h4>
<div className={styles.presetButtons}>
<button
className="button button--outline button--primary button--sm"
onClick={() => applyPreset(640, 360, 1)}
>
640x360 × 1
</button>
<button
className="button button--outline button--primary button--sm"
onClick={() => applyPreset(1280, 720, 1)}
>
1280x720 × 1
</button>
<button
className="button button--outline button--primary button--sm"
onClick={() => applyPreset(1280, 720, 4)}
>
1280x720 × 4
</button>
<button
className="button button--outline button--primary button--sm"
onClick={() => applyPreset(1280, 720, 8)}
>
1280x720 × 8
</button>
</div>
</div>
</div>
</div>
);
};
export default ShmCalculator;
@@ -1,131 +0,0 @@
.shmCalculator {
margin: 2rem 0;
max-width: 600px;
}
.card {
background: var(--ifm-background-surface-color);
border: 1px solid var(--ifm-border-color);
border-radius: 12px;
padding: 2rem;
box-shadow: var(--ifm-global-shadow-lw);
}
[data-theme='light'] .card {
background: var(--ifm-color-emphasis-100);
border: 1px solid var(--ifm-color-emphasis-300);
}
.title {
margin: 0 0 0.5rem 0;
font-size: 1.5rem;
color: var(--ifm-font-color-base);
font-weight: var(--ifm-font-weight-semibold);
}
.description {
margin: 0 0 1.5rem 0;
color: var(--ifm-font-color-secondary);
font-size: 0.9rem;
}
.formGroup {
margin-bottom: 1rem;
}
.label {
display: block;
margin-bottom: 0.25rem;
color: var(--ifm-font-color-base);
font-weight: var(--ifm-font-weight-semibold);
font-size: 0.9rem;
}
.input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--ifm-border-color);
border-radius: 6px;
background: var(--ifm-background-color);
color: var(--ifm-font-color-base);
font-size: 0.95rem;
transition: border-color 0.2s, box-shadow 0.2s;
}
[data-theme='light'] .input {
background: #fff;
border: 1px solid #d0d7de;
}
.input:focus {
outline: none;
border-color: var(--ifm-color-primary);
box-shadow: 0 0 0 3px var(--ifm-color-primary-lightest);
}
.resultSection {
margin-top: 1rem;
padding: 1.5rem;
background: var(--ifm-background-color);
border-radius: 8px;
border: 1px solid var(--ifm-border-color);
}
[data-theme='light'] .resultSection {
background: #f6f8fa;
border: 1px solid #d0d7de;
}
.resultSection h4 {
margin: 0 0 1rem 0;
color: var(--ifm-font-color-base);
font-weight: var(--ifm-font-weight-semibold);
}
.resultValue {
text-align: center;
padding: 1rem;
background: var(--ifm-color-primary);
border-radius: 6px;
margin-bottom: 1rem;
}
.resultNumber {
font-size: 2rem;
font-weight: var(--ifm-font-weight-bold);
color: #fff;
}
.formulaDisplay {
font-size: 0.85rem;
color: var(--ifm-font-color-secondary);
line-height: 1.6;
}
.formulaDisplay p {
margin: 0.25rem 0;
}
.formulaDisplay strong {
color: var(--ifm-font-color-base);
}
.unit {
text-transform: uppercase;
}
.presets {
margin-top: 1.5rem;
}
.presets h4 {
margin: 0 0 0.75rem 0;
color: var(--ifm-font-color-base);
font-weight: var(--ifm-font-weight-semibold);
}
.presetButtons {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
+442 -2320
View File
File diff suppressed because it is too large Load Diff
+82 -434
View File
@@ -5,7 +5,6 @@ import copy
import json import json
import logging import logging
import os import os
import platform
import traceback import traceback
import urllib import urllib
from datetime import datetime, timedelta from datetime import datetime, timedelta
@@ -20,7 +19,6 @@ from fastapi import APIRouter, Body, Path, Request, Response
from fastapi.encoders import jsonable_encoder from fastapi.encoders import jsonable_encoder
from fastapi.params import Depends from fastapi.params import Depends
from fastapi.responses import JSONResponse, PlainTextResponse, StreamingResponse from fastapi.responses import JSONResponse, PlainTextResponse, StreamingResponse
from filelock import FileLock, Timeout
from markupsafe import escape from markupsafe import escape
from peewee import SQL, fn, operator from peewee import SQL, fn, operator
from pydantic import ValidationError from pydantic import ValidationError
@@ -32,35 +30,22 @@ from frigate.api.auth import (
require_role, require_role,
) )
from frigate.api.defs.query.app_query_parameters import AppTimelineHourlyQueryParameters from frigate.api.defs.query.app_query_parameters import AppTimelineHourlyQueryParameters
from frigate.api.defs.request.app_body import ( from frigate.api.defs.request.app_body import AppConfigSetBody
AppConfigSetBody,
MediaSyncBody,
)
from frigate.api.defs.tags import Tags from frigate.api.defs.tags import Tags
from frigate.config import FrigateConfig from frigate.config import FrigateConfig
from frigate.config.camera.updater import ( from frigate.config.camera.updater import (
CameraConfigUpdateEnum, CameraConfigUpdateEnum,
CameraConfigUpdateTopic, CameraConfigUpdateTopic,
) )
from frigate.ffmpeg_presets import FFMPEG_HWACCEL_VAAPI, _gpu_selector
from frigate.jobs.media_sync import (
get_current_media_sync_job,
get_media_sync_job_by_id,
start_media_sync_job,
)
from frigate.models import Event, Timeline from frigate.models import Event, Timeline
from frigate.stats.prometheus import get_metrics, update_metrics from frigate.stats.prometheus import get_metrics, update_metrics
from frigate.types import JobStatusTypesEnum
from frigate.util.builtin import ( from frigate.util.builtin import (
clean_camera_user_pass, clean_camera_user_pass,
deep_merge,
flatten_config_data, flatten_config_data,
load_labels,
process_config_query_string, process_config_query_string,
update_yaml_file_bulk, update_yaml_file_bulk,
) )
from frigate.util.config import apply_section_update, find_config_file from frigate.util.config import find_config_file
from frigate.util.schema import get_config_schema
from frigate.util.services import ( from frigate.util.services import (
get_nvidia_driver_info, get_nvidia_driver_info,
process_logs, process_logs,
@@ -85,7 +70,9 @@ def is_healthy():
@router.get("/config/schema.json", dependencies=[Depends(allow_public())]) @router.get("/config/schema.json", dependencies=[Depends(allow_public())])
def config_schema(request: Request): def config_schema(request: Request):
return JSONResponse(content=get_config_schema(FrigateConfig)) return Response(
content=request.app.frigate_config.schema_json(), media_type="application/json"
)
@router.get( @router.get(
@@ -131,10 +118,6 @@ def config(request: Request):
config: dict[str, dict[str, Any]] = config_obj.model_dump( config: dict[str, dict[str, Any]] = config_obj.model_dump(
mode="json", warnings="none", exclude_none=True mode="json", warnings="none", exclude_none=True
) )
config["detectors"] = {
name: detector.model_dump(mode="json", warnings="none", exclude_none=True)
for name, detector in config_obj.detectors.items()
}
# remove the mqtt password # remove the mqtt password
config["mqtt"].pop("password", None) config["mqtt"].pop("password", None)
@@ -158,31 +141,6 @@ def config(request: Request):
for zone_name, zone in config_obj.cameras[camera_name].zones.items(): for zone_name, zone in config_obj.cameras[camera_name].zones.items():
camera_dict["zones"][zone_name]["color"] = zone.color camera_dict["zones"][zone_name]["color"] = zone.color
# Re-dump profile overrides with exclude_unset so that only
# explicitly-set fields are returned (not Pydantic defaults).
# Without this, the frontend merges defaults (e.g. threshold=30)
# over the camera's actual base values (e.g. threshold=20).
if camera.profiles:
for profile_name, profile_config in camera.profiles.items():
camera_dict.setdefault("profiles", {})[profile_name] = (
profile_config.model_dump(
mode="json", warnings="none", exclude_unset=True
)
)
# When a profile is active, the top-level camera sections contain
# profile-merged (effective) values. Include the original base
# configs so the frontend settings can display them separately.
if (
config_obj.active_profile is not None
and request.app.profile_manager is not None
):
base_sections = request.app.profile_manager.get_base_configs_for_api(
camera_name
)
if base_sections:
camera_dict["base_config"] = base_sections
# remove go2rtc stream passwords # remove go2rtc stream passwords
go2rtc: dict[str, Any] = config_obj.go2rtc.model_dump( go2rtc: dict[str, Any] = config_obj.go2rtc.model_dump(
mode="json", warnings="none", exclude_none=True mode="json", warnings="none", exclude_none=True
@@ -230,75 +188,6 @@ def config(request: Request):
return JSONResponse(content=config) return JSONResponse(content=config)
@router.get("/profiles", dependencies=[Depends(allow_any_authenticated())])
def get_profiles(request: Request):
"""List all available profiles and the currently active profile."""
profile_manager = request.app.profile_manager
return JSONResponse(content=profile_manager.get_profile_info())
@router.get("/profile/active", dependencies=[Depends(allow_any_authenticated())])
def get_active_profile(request: Request):
"""Get the currently active profile."""
config_obj: FrigateConfig = request.app.frigate_config
return JSONResponse(content={"active_profile": config_obj.active_profile})
@router.get("/ffmpeg/presets", dependencies=[Depends(allow_any_authenticated())])
def ffmpeg_presets():
"""Return available ffmpeg preset keys for config UI usage."""
machine = platform.machine().lower()
is_arm64 = machine in ("aarch64", "arm64", "armv8", "armv7l")
if is_arm64:
hwaccel_presets = [
"preset-rpi-64-h264",
"preset-rpi-64-h265",
"preset-jetson-h264",
"preset-jetson-h265",
"preset-rkmpp",
"preset-vaapi",
]
else:
hwaccel_presets = [
"preset-vaapi",
"preset-intel-qsv-h264",
"preset-intel-qsv-h265",
"preset-nvidia",
]
input_presets = [
"preset-http-jpeg-generic",
"preset-http-mjpeg-generic",
"preset-http-reolink",
"preset-rtmp-generic",
"preset-rtsp-generic",
"preset-rtsp-restream",
"preset-rtsp-restream-low-latency",
"preset-rtsp-udp",
"preset-rtsp-blue-iris",
]
record_output_presets = [
"preset-record-generic",
"preset-record-generic-audio-copy",
"preset-record-generic-audio-aac",
"preset-record-mjpeg",
"preset-record-jpeg",
"preset-record-ubiquiti",
]
return JSONResponse(
content={
"hwaccel_args": hwaccel_presets,
"input_args": input_presets,
"output_args": {
"record": record_output_presets,
"detect": [],
},
}
)
@router.get("/config/raw_paths", dependencies=[Depends(require_role(["admin"]))]) @router.get("/config/raw_paths", dependencies=[Depends(require_role(["admin"]))])
def config_raw_paths(request: Request): def config_raw_paths(request: Request):
"""Admin-only endpoint that returns camera paths and go2rtc streams without credential masking.""" """Admin-only endpoint that returns camera paths and go2rtc streams without credential masking."""
@@ -473,248 +362,108 @@ def config_save(save_option: str, body: Any = Body(media_type="text/plain")):
) )
def _config_set_in_memory(request: Request, body: AppConfigSetBody) -> JSONResponse:
"""Apply config changes in-memory only, without writing to YAML.
Used for temporary config changes like debug replay camera tuning.
Updates the in-memory Pydantic config and publishes ZMQ updates,
bypassing YAML parsing entirely.
"""
try:
updates = {}
if body.config_data:
updates = flatten_config_data(body.config_data)
updates = {k: ("" if v is None else v) for k, v in updates.items()}
if not updates:
return JSONResponse(
content={"success": False, "message": "No configuration data provided"},
status_code=400,
)
config: FrigateConfig = request.app.frigate_config
# Group flat key paths into nested per-camera, per-section dicts
grouped: dict[str, dict[str, dict]] = {}
for key_path, value in updates.items():
parts = key_path.split(".")
if len(parts) < 3 or parts[0] != "cameras":
continue
cam, section = parts[1], parts[2]
grouped.setdefault(cam, {}).setdefault(section, {})
# Build nested dict from remaining path (e.g. "filters.person.threshold")
target = grouped[cam][section]
for part in parts[3:-1]:
target = target.setdefault(part, {})
if len(parts) > 3:
target[parts[-1]] = value
elif isinstance(value, dict):
grouped[cam][section] = deep_merge(
grouped[cam][section], value, override=True
)
else:
grouped[cam][section] = value
# Apply each section update
for cam_name, sections in grouped.items():
camera_config = config.cameras.get(cam_name)
if not camera_config:
return JSONResponse(
content={
"success": False,
"message": f"Camera '{cam_name}' not found",
},
status_code=400,
)
for section_name, update in sections.items():
err = apply_section_update(camera_config, section_name, update)
if err is not None:
return JSONResponse(
content={"success": False, "message": err},
status_code=400,
)
# Publish ZMQ updates so processing threads pick up changes
if body.update_topic and body.update_topic.startswith("config/cameras/"):
_, _, camera, field = body.update_topic.split("/")
settings = getattr(config.cameras.get(camera, None), field, None)
if settings is not None:
request.app.config_publisher.publish_update(
CameraConfigUpdateTopic(CameraConfigUpdateEnum[field], camera),
settings,
)
return JSONResponse(
content={"success": True, "message": "Config applied in-memory"},
status_code=200,
)
except Exception as e:
logger.error(f"Error applying config in-memory: {e}")
return JSONResponse(
content={"success": False, "message": "Error applying config"},
status_code=500,
)
@router.put("/config/set", dependencies=[Depends(require_role(["admin"]))]) @router.put("/config/set", dependencies=[Depends(require_role(["admin"]))])
def config_set(request: Request, body: AppConfigSetBody): def config_set(request: Request, body: AppConfigSetBody):
config_file = find_config_file() config_file = find_config_file()
if body.skip_save: with open(config_file, "r") as f:
return _config_set_in_memory(request, body) old_raw_config = f.read()
lock = FileLock(f"{config_file}.lock", timeout=5)
try: try:
with lock: updates = {}
with open(config_file, "r") as f:
old_raw_config = f.read()
try: # process query string parameters (takes precedence over body.config_data)
updates = {} parsed_url = urllib.parse.urlparse(str(request.url))
query_string = urllib.parse.parse_qs(parsed_url.query, keep_blank_values=True)
# process query string parameters (takes precedence over body.config_data) # Filter out empty keys but keep blank values for non-empty keys
parsed_url = urllib.parse.urlparse(str(request.url)) query_string = {k: v for k, v in query_string.items() if k}
query_string = urllib.parse.parse_qs(
parsed_url.query, keep_blank_values=True
)
# Filter out empty keys but keep blank values for non-empty keys if query_string:
query_string = {k: v for k, v in query_string.items() if k} updates = process_config_query_string(query_string)
elif body.config_data:
updates = flatten_config_data(body.config_data)
if query_string: if not updates:
updates = process_config_query_string(query_string) return JSONResponse(
elif body.config_data: content=(
updates = flatten_config_data(body.config_data) {"success": False, "message": "No configuration data provided"}
# Convert None values to empty strings for deletion (e.g., when deleting masks) ),
updates = {k: ("" if v is None else v) for k, v in updates.items()} status_code=400,
)
if not updates: # apply all updates in a single operation
return JSONResponse( update_yaml_file_bulk(config_file, updates)
content=(
{
"success": False,
"message": "No configuration data provided",
}
),
status_code=400,
)
# apply all updates in a single operation # validate the updated config
update_yaml_file_bulk(config_file, updates) with open(config_file, "r") as f:
new_raw_config = f.read()
# validate the updated config
with open(config_file, "r") as f:
new_raw_config = f.read()
try:
config = FrigateConfig.parse(new_raw_config)
except Exception:
with open(config_file, "w") as f:
f.write(old_raw_config)
f.close()
logger.error(f"\nConfig Error:\n\n{str(traceback.format_exc())}")
return JSONResponse(
content=(
{
"success": False,
"message": "Error parsing config. Check logs for error message.",
}
),
status_code=400,
)
except Exception as e:
logging.error(f"Error updating config: {e}")
return JSONResponse(
content=({"success": False, "message": "Error updating config"}),
status_code=500,
)
if body.requires_restart == 0 or body.update_topic:
old_config: FrigateConfig = request.app.frigate_config
request.app.frigate_config = config
request.app.genai_manager.update_config(config)
if request.app.profile_manager is not None:
request.app.profile_manager.update_config(config)
if request.app.stats_emitter is not None:
request.app.stats_emitter.config = config
if body.update_topic:
if body.update_topic.startswith("config/cameras/"):
_, _, camera, field = body.update_topic.split("/")
if camera == "*":
# Wildcard: fan out update to all cameras
enum_value = CameraConfigUpdateEnum[field]
for camera_name in config.cameras:
settings = config.get_nested_object(
f"config/cameras/{camera_name}/{field}"
)
request.app.config_publisher.publish_update(
CameraConfigUpdateTopic(enum_value, camera_name),
settings,
)
else:
if field == "add":
settings = config.cameras[camera]
elif field == "remove":
settings = old_config.cameras[camera]
else:
settings = config.get_nested_object(body.update_topic)
request.app.config_publisher.publish_update(
CameraConfigUpdateTopic(
CameraConfigUpdateEnum[field], camera
),
settings,
)
else:
# Generic handling for global config updates
settings = config.get_nested_object(body.update_topic)
# Publish None for removal, actual config for add/update
request.app.config_publisher.publisher.publish(
body.update_topic, settings
)
try:
config = FrigateConfig.parse(new_raw_config)
except Exception:
with open(config_file, "w") as f:
f.write(old_raw_config)
f.close()
logger.error(f"\nConfig Error:\n\n{str(traceback.format_exc())}")
return JSONResponse( return JSONResponse(
content=( content=(
{ {
"success": True, "success": False,
"message": "Config successfully updated, restart to apply", "message": "Error parsing config. Check logs for error message.",
} }
), ),
status_code=200, status_code=400,
) )
except Timeout: except Exception as e:
logging.error(f"Error updating config: {e}")
return JSONResponse( return JSONResponse(
content=( content=({"success": False, "message": "Error updating config"}),
{ status_code=500,
"success": False,
"message": "Another process is currently updating the config. Please try again in a few seconds.",
}
),
status_code=503,
) )
if body.requires_restart == 0 or body.update_topic:
old_config: FrigateConfig = request.app.frigate_config
request.app.frigate_config = config
if body.update_topic:
if body.update_topic.startswith("config/cameras/"):
_, _, camera, field = body.update_topic.split("/")
if field == "add":
settings = config.cameras[camera]
elif field == "remove":
settings = old_config.cameras[camera]
else:
settings = config.get_nested_object(body.update_topic)
request.app.config_publisher.publish_update(
CameraConfigUpdateTopic(CameraConfigUpdateEnum[field], camera),
settings,
)
else:
# Generic handling for global config updates
settings = config.get_nested_object(body.update_topic)
# Publish None for removal, actual config for add/update
request.app.config_publisher.publisher.publish(
body.update_topic, settings
)
return JSONResponse(
content=(
{
"success": True,
"message": "Config successfully updated, restart to apply",
}
),
status_code=200,
)
@router.get("/vainfo", dependencies=[Depends(allow_any_authenticated())]) @router.get("/vainfo", dependencies=[Depends(allow_any_authenticated())])
def vainfo(): def vainfo():
# Use LibvaGpuSelector to pick an appropriate libva device (if available) vainfo = vainfo_hwaccel()
selected_gpu = ""
try:
selected_gpu = _gpu_selector.get_gpu_arg(FFMPEG_HWACCEL_VAAPI, 0) or ""
except Exception:
selected_gpu = ""
# If selected_gpu is empty, pass None to vainfo_hwaccel to run plain `vainfo`.
vainfo = vainfo_hwaccel(device_name=selected_gpu or None)
return JSONResponse( return JSONResponse(
content={ content={
"return_code": vainfo.returncode, "return_code": vainfo.returncode,
@@ -849,101 +598,6 @@ def restart():
) )
@router.post(
"/media/sync",
dependencies=[Depends(require_role(["admin"]))],
summary="Start media sync job",
description="""Start an asynchronous media sync job to find and (optionally) remove orphaned media files.
Returns 202 with job details when queued, or 409 if a job is already running.""",
)
def sync_media(body: MediaSyncBody = Body(...)):
"""Start async media sync job - remove orphaned files.
Syncs specified media types: event snapshots, event thumbnails, review thumbnails,
previews, exports, and/or recordings. Job runs in background; use /media/sync/current
or /media/sync/status/{job_id} to check status.
Args:
body: MediaSyncBody with dry_run flag and media_types list.
media_types can include: 'all', 'event_snapshots', 'event_thumbnails',
'review_thumbnails', 'previews', 'exports', 'recordings'
Returns:
202 Accepted with job_id, or 409 Conflict if job already running.
"""
job_id = start_media_sync_job(
dry_run=body.dry_run,
media_types=body.media_types,
force=body.force,
verbose=body.verbose,
)
if job_id is None:
# A job is already running
current = get_current_media_sync_job()
return JSONResponse(
content={
"error": "A media sync job is already running",
"current_job_id": current.id if current else None,
},
status_code=409,
)
return JSONResponse(
content={
"job": {
"job_type": "media_sync",
"status": JobStatusTypesEnum.queued,
"id": job_id,
}
},
status_code=202,
)
@router.get(
"/media/sync/current",
dependencies=[Depends(require_role(["admin"]))],
summary="Get current media sync job",
description="""Retrieve the current running media sync job, if any. Returns the job details
or null when no job is active.""",
)
def get_media_sync_current():
"""Get the current running media sync job, if any."""
job = get_current_media_sync_job()
if job is None:
return JSONResponse(content={"job": None}, status_code=200)
return JSONResponse(
content={"job": job.to_dict()},
status_code=200,
)
@router.get(
"/media/sync/status/{job_id}",
dependencies=[Depends(require_role(["admin"]))],
summary="Get media sync job status",
description="""Get status and results for the specified media sync job id. Returns 200 with
job details including results, or 404 if the job is not found.""",
)
def get_media_sync_status(job_id: str):
"""Get the status of a specific media sync job."""
job = get_media_sync_job_by_id(job_id)
if job is None:
return JSONResponse(
content={"error": "Job not found"},
status_code=404,
)
return JSONResponse(
content={"job": job.to_dict()},
status_code=200,
)
@router.get("/labels", dependencies=[Depends(allow_any_authenticated())]) @router.get("/labels", dependencies=[Depends(allow_any_authenticated())])
def get_labels(camera: str = ""): def get_labels(camera: str = ""):
try: try:
@@ -993,12 +647,6 @@ def get_sub_labels(split_joined: Optional[int] = None):
return JSONResponse(content=sub_labels) return JSONResponse(content=sub_labels)
@router.get("/audio_labels", dependencies=[Depends(allow_any_authenticated())])
def get_audio_labels():
labels = load_labels("/audio-labelmap.txt", prefill=521)
return JSONResponse(content=labels)
@router.get("/plus/models", dependencies=[Depends(allow_any_authenticated())]) @router.get("/plus/models", dependencies=[Depends(allow_any_authenticated())])
def plusModels(request: Request, filterByCurrentModelDetector: bool = False): def plusModels(request: Request, filterByCurrentModelDetector: bool = False):
if not request.app.frigate_config.plus_api.is_active(): if not request.app.frigate_config.plus_api.is_active():
+7 -66
View File
@@ -26,18 +26,12 @@ from frigate.api.defs.request.app_body import (
AppPutRoleBody, AppPutRoleBody,
) )
from frigate.api.defs.tags import Tags from frigate.api.defs.tags import Tags
from frigate.config import AuthConfig, NetworkingConfig, ProxyConfig from frigate.config import AuthConfig, ProxyConfig
from frigate.const import CONFIG_DIR, JWT_SECRET_ENV_VAR, PASSWORD_HASH_ALGORITHM from frigate.const import CONFIG_DIR, JWT_SECRET_ENV_VAR, PASSWORD_HASH_ALGORITHM
from frigate.models import User from frigate.models import User
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# In-memory cache to track which clients we've logged for an anonymous access event.
# Keyed by a hashed value combining remote address + user-agent. The value is
# an expiration timestamp (float).
FIRST_LOAD_TTL_SECONDS = 60 * 60 * 24 * 7 # 7 days
_first_load_seen: dict[str, float] = {}
def require_admin_by_default(): def require_admin_by_default():
""" """
@@ -47,7 +41,7 @@ def require_admin_by_default():
endpoints require admin access unless explicitly overridden with endpoints require admin access unless explicitly overridden with
allow_public(), allow_any_authenticated(), or require_role(). allow_public(), allow_any_authenticated(), or require_role().
Internal port always has admin role set by the /auth endpoint, Port 5000 (internal) always has admin role set by the /auth endpoint,
so this check passes automatically for internal requests. so this check passes automatically for internal requests.
Certain paths are exempted from the global admin check because they must Certain paths are exempted from the global admin check because they must
@@ -135,7 +129,7 @@ def require_admin_by_default():
pass pass
# For all other paths, require admin role # For all other paths, require admin role
# Internal port requests have admin role set automatically # Port 5000 (internal) requests have admin role set automatically
role = request.headers.get("remote-role") role = request.headers.get("remote-role")
if role == "admin": if role == "admin":
return return
@@ -148,17 +142,6 @@ def require_admin_by_default():
return admin_checker return admin_checker
def _is_authenticated(request: Request) -> bool:
"""
Helper to determine if a request is from an authenticated user.
Returns True if the request has a valid authenticated user (not anonymous).
Internal port requests are considered anonymous despite having admin role.
"""
username = request.headers.get("remote-user")
return username is not None and username != "anonymous"
def allow_public(): def allow_public():
""" """
Override dependency to allow unauthenticated access to an endpoint. Override dependency to allow unauthenticated access to an endpoint.
@@ -187,7 +170,6 @@ def allow_any_authenticated():
Rejects: Rejects:
- Requests with no remote-user header (did not pass through /auth endpoint) - Requests with no remote-user header (did not pass through /auth endpoint)
- External port requests with anonymous user (auth disabled, no proxy auth)
Example: Example:
@router.get("/authenticated-endpoint", dependencies=[Depends(allow_any_authenticated())]) @router.get("/authenticated-endpoint", dependencies=[Depends(allow_any_authenticated())])
@@ -196,14 +178,8 @@ def allow_any_authenticated():
async def auth_checker(request: Request): async def auth_checker(request: Request):
# Ensure a remote-user has been set by the /auth endpoint # Ensure a remote-user has been set by the /auth endpoint
username = request.headers.get("remote-user") username = request.headers.get("remote-user")
if username is None:
# Internal port requests have admin role and should be allowed raise HTTPException(status_code=401, detail="Authentication required")
role = request.headers.get("remote-role")
if role != "admin":
if username is None or not _is_authenticated(request):
raise HTTPException(status_code=401, detail="Authentication required")
return return
return auth_checker return auth_checker
@@ -289,15 +265,6 @@ def get_remote_addr(request: Request):
return remote_addr or "127.0.0.1" return remote_addr or "127.0.0.1"
def _cleanup_first_load_seen() -> None:
"""Cleanup expired entries in the in-memory first-load cache."""
now = time.time()
# Build list for removal to avoid mutating dict during iteration
expired = [k for k, exp in _first_load_seen.items() if exp <= now]
for k in expired:
del _first_load_seen[k]
def get_jwt_secret() -> str: def get_jwt_secret() -> str:
jwt_secret = None jwt_secret = None
# check env var # check env var
@@ -602,18 +569,12 @@ def resolve_role(
def auth(request: Request): def auth(request: Request):
auth_config: AuthConfig = request.app.frigate_config.auth auth_config: AuthConfig = request.app.frigate_config.auth
proxy_config: ProxyConfig = request.app.frigate_config.proxy proxy_config: ProxyConfig = request.app.frigate_config.proxy
networking_config: NetworkingConfig = request.app.frigate_config.networking
success_response = Response("", status_code=202) success_response = Response("", status_code=202)
# handle case where internal port is a string with ip:port
internal_port = networking_config.listen.internal
if type(internal_port) is str:
internal_port = int(internal_port.split(":")[-1])
# dont require auth if the request is on the internal port # dont require auth if the request is on the internal port
# this header is set by Frigate's nginx proxy, so it cant be spoofed # this header is set by Frigate's nginx proxy, so it cant be spoofed
if int(request.headers.get("x-server-port", default=0)) == internal_port: if int(request.headers.get("x-server-port", default=0)) == 5000:
success_response.headers["remote-user"] = "anonymous" success_response.headers["remote-user"] = "anonymous"
success_response.headers["remote-role"] = "admin" success_response.headers["remote-role"] = "admin"
return success_response return success_response
@@ -758,30 +719,10 @@ def profile(request: Request):
roles_dict = request.app.frigate_config.auth.roles roles_dict = request.app.frigate_config.auth.roles
allowed_cameras = User.get_allowed_cameras(role, roles_dict, all_camera_names) allowed_cameras = User.get_allowed_cameras(role, roles_dict, all_camera_names)
response = JSONResponse( return JSONResponse(
content={"username": username, "role": role, "allowed_cameras": allowed_cameras} content={"username": username, "role": role, "allowed_cameras": allowed_cameras}
) )
if username == "anonymous":
try:
remote_addr = get_remote_addr(request)
except Exception:
remote_addr = (
request.client.host if hasattr(request, "client") else "unknown"
)
ua = request.headers.get("user-agent", "")
key_material = f"{remote_addr}|{ua}"
cache_key = hashlib.sha256(key_material.encode()).hexdigest()
_cleanup_first_load_seen()
now = time.time()
if cache_key not in _first_load_seen:
_first_load_seen[cache_key] = now + FIRST_LOAD_TTL_SECONDS
logger.info(f"Anonymous user access from {remote_addr} ua={ua[:200]}")
return response
@router.get( @router.get(
"/logout", "/logout",
+15 -236
View File
@@ -1,6 +1,5 @@
"""Camera apis.""" """Camera apis."""
import asyncio
import json import json
import logging import logging
import re import re
@@ -12,9 +11,7 @@ import httpx
import requests import requests
from fastapi import APIRouter, Depends, Query, Request, Response from fastapi import APIRouter, Depends, Query, Request, Response
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from filelock import FileLock, Timeout
from onvif import ONVIFCamera, ONVIFError from onvif import ONVIFCamera, ONVIFError
from ruamel.yaml import YAML
from zeep.exceptions import Fault, TransportError from zeep.exceptions import Fault, TransportError
from zeep.transports import AsyncTransport from zeep.transports import AsyncTransport
@@ -23,18 +20,11 @@ from frigate.api.auth import (
require_go2rtc_stream_access, require_go2rtc_stream_access,
require_role, require_role,
) )
from frigate.api.defs.request.app_body import CameraSetBody
from frigate.api.defs.tags import Tags from frigate.api.defs.tags import Tags
from frigate.config import FrigateConfig from frigate.config.config import FrigateConfig
from frigate.config.camera.updater import (
CameraConfigUpdateEnum,
CameraConfigUpdateTopic,
)
from frigate.util.builtin import clean_camera_user_pass from frigate.util.builtin import clean_camera_user_pass
from frigate.util.camera_cleanup import cleanup_camera_db, cleanup_camera_files
from frigate.util.config import find_config_file
from frigate.util.image import run_ffmpeg_snapshot from frigate.util.image import run_ffmpeg_snapshot
from frigate.util.services import ffprobe_stream from frigate.util.services import ffprobe_stream, is_restricted_go2rtc_source
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -121,6 +111,19 @@ def go2rtc_camera_stream(request: Request, stream_name: str):
) )
def go2rtc_add_stream(request: Request, stream_name: str, src: str = ""): def go2rtc_add_stream(request: Request, stream_name: str, src: str = ""):
"""Add or update a go2rtc stream configuration.""" """Add or update a go2rtc stream configuration."""
if src and is_restricted_go2rtc_source(src):
logger.warning(
"Rejected go2rtc stream '%s' with restricted source type (echo/expr/exec)",
stream_name,
)
return JSONResponse(
content={
"success": False,
"message": "Restricted stream source type",
},
status_code=400,
)
try: try:
params = {"name": stream_name} params = {"name": stream_name}
if src: if src:
@@ -1018,227 +1021,3 @@ async def onvif_probe(
await onvif_camera.close() await onvif_camera.close()
except Exception as e: except Exception as e:
logger.debug(f"Error closing ONVIF camera session: {e}") logger.debug(f"Error closing ONVIF camera session: {e}")
@router.delete(
"/cameras/{camera_name}",
dependencies=[Depends(require_role(["admin"]))],
)
async def delete_camera(
request: Request,
camera_name: str,
delete_exports: bool = Query(default=False),
):
"""Delete a camera and all its associated data.
Removes the camera from config, stops processes, and cleans up
all database entries and media files.
Args:
camera_name: Name of the camera to delete
delete_exports: Whether to also delete exports for this camera
"""
frigate_config: FrigateConfig = request.app.frigate_config
if camera_name not in frigate_config.cameras:
return JSONResponse(
content={
"success": False,
"message": f"Camera {camera_name} not found",
},
status_code=404,
)
old_camera_config = frigate_config.cameras[camera_name]
config_file = find_config_file()
lock = FileLock(f"{config_file}.lock", timeout=5)
try:
with lock:
with open(config_file, "r") as f:
old_raw_config = f.read()
try:
yaml = YAML()
yaml.indent(mapping=2, sequence=4, offset=2)
with open(config_file, "r") as f:
data = yaml.load(f)
# Remove camera from config
if "cameras" in data and camera_name in data["cameras"]:
del data["cameras"][camera_name]
# Remove camera from auth roles
auth = data.get("auth", {})
if auth and "roles" in auth:
empty_roles = []
for role_name, cameras_list in auth["roles"].items():
if (
isinstance(cameras_list, list)
and camera_name in cameras_list
):
cameras_list.remove(camera_name)
# Custom roles can't be empty; mark for removal
if not cameras_list and role_name not in (
"admin",
"viewer",
):
empty_roles.append(role_name)
for role_name in empty_roles:
del auth["roles"][role_name]
with open(config_file, "w") as f:
yaml.dump(data, f)
with open(config_file, "r") as f:
new_raw_config = f.read()
try:
config = FrigateConfig.parse(new_raw_config)
except Exception:
with open(config_file, "w") as f:
f.write(old_raw_config)
logger.exception(
"Config error after removing camera %s",
camera_name,
)
return JSONResponse(
content={
"success": False,
"message": "Error parsing config after camera removal",
},
status_code=400,
)
except Exception as e:
logger.error(
"Error updating config to remove camera %s: %s", camera_name, e
)
return JSONResponse(
content={
"success": False,
"message": "Error updating config",
},
status_code=500,
)
# Update runtime config
request.app.frigate_config = config
request.app.genai_manager.update_config(config)
# Publish removal to stop ffmpeg processes and clean up runtime state
request.app.config_publisher.publish_update(
CameraConfigUpdateTopic(CameraConfigUpdateEnum.remove, camera_name),
old_camera_config,
)
except Timeout:
return JSONResponse(
content={
"success": False,
"message": "Another process is currently updating the config",
},
status_code=409,
)
# Clean up database entries
counts, export_paths = await asyncio.to_thread(
cleanup_camera_db, camera_name, delete_exports
)
# Clean up media files in background thread
await asyncio.to_thread(
cleanup_camera_files, camera_name, export_paths if delete_exports else None
)
# Best-effort go2rtc stream removal
try:
requests.delete(
"http://127.0.0.1:1984/api/streams",
params={"src": camera_name},
timeout=5,
)
except Exception:
logger.debug("Failed to remove go2rtc stream for %s", camera_name)
return JSONResponse(
content={
"success": True,
"message": f"Camera {camera_name} has been deleted",
"cleanup": counts,
},
status_code=200,
)
_SUB_COMMAND_FEATURES = {"motion_mask", "object_mask", "zone"}
@router.put(
"/camera/{camera_name}/set/{feature}",
dependencies=[Depends(require_role(["admin"]))],
)
@router.put(
"/camera/{camera_name}/set/{feature}/{sub_command}",
dependencies=[Depends(require_role(["admin"]))],
)
def camera_set(
request: Request,
camera_name: str,
feature: str,
body: CameraSetBody,
sub_command: str | None = None,
):
"""Set a camera feature state. Use camera_name='*' to target all cameras."""
dispatcher = request.app.dispatcher
frigate_config: FrigateConfig = request.app.frigate_config
if feature == "profile":
if camera_name != "*":
return JSONResponse(
content={
"success": False,
"message": "Profile feature requires camera_name='*'",
},
status_code=400,
)
dispatcher._receive("profile/set", body.value)
return JSONResponse(content={"success": True})
if feature not in dispatcher._camera_settings_handlers:
return JSONResponse(
content={"success": False, "message": f"Unknown feature: {feature}"},
status_code=400,
)
if sub_command and feature not in _SUB_COMMAND_FEATURES:
return JSONResponse(
content={
"success": False,
"message": f"Feature '{feature}' does not support sub-commands",
},
status_code=400,
)
if camera_name == "*":
cameras = list(frigate_config.cameras.keys())
elif camera_name not in frigate_config.cameras:
return JSONResponse(
content={
"success": False,
"message": f"Camera '{camera_name}' not found",
},
status_code=404,
)
else:
cameras = [camera_name]
for cam in cameras:
topic = (
f"{cam}/{feature}/{sub_command}/set"
if sub_command
else f"{cam}/{feature}/set"
)
dispatcher._receive(topic, body.value)
return JSONResponse(content={"success": True})
-1220
View File
File diff suppressed because it is too large Load Diff
-171
View File
@@ -338,82 +338,6 @@ async def recognize_face(request: Request, file: UploadFile):
) )
@router.post(
"/faces/{name}/reclassify",
response_model=GenericResponse,
dependencies=[Depends(require_role(["admin"]))],
summary="Reclassify a face image to a different name",
description="""Moves a single face image from one person's folder to another.
The image is moved and renamed, and the face classifier is cleared to
incorporate the change. Returns a success message or an error if the
image or target name is invalid.""",
)
def reclassify_face_image(request: Request, name: str, body: dict = None):
if not request.app.frigate_config.face_recognition.enabled:
return JSONResponse(
status_code=400,
content={"message": "Face recognition is not enabled.", "success": False},
)
json: dict[str, Any] = body or {}
image_id = sanitize_filename(json.get("id", ""))
new_name = sanitize_filename(json.get("new_name", ""))
if not image_id or not new_name:
return JSONResponse(
content=(
{
"success": False,
"message": "Both 'id' and 'new_name' are required.",
}
),
status_code=400,
)
if new_name == name:
return JSONResponse(
content=(
{
"success": False,
"message": "New name must differ from the current name.",
}
),
status_code=400,
)
source_folder = os.path.join(FACE_DIR, sanitize_filename(name))
source_file = os.path.join(source_folder, image_id)
if not os.path.isfile(source_file):
return JSONResponse(
content=(
{
"success": False,
"message": f"Image not found: {image_id}",
}
),
status_code=404,
)
target_filename = f"{new_name}-{datetime.datetime.now().timestamp()}.webp"
target_folder = os.path.join(FACE_DIR, new_name)
os.makedirs(target_folder, exist_ok=True)
shutil.move(source_file, os.path.join(target_folder, target_filename))
# Clean up empty source folder
if os.path.exists(source_folder) and not os.listdir(source_folder):
os.rmdir(source_folder)
context: EmbeddingsContext = request.app.embeddings
context.clear_face_classifier()
return JSONResponse(
content=({"success": True, "message": "Successfully reclassified face."}),
status_code=200,
)
@router.post( @router.post(
"/faces/{name}/delete", "/faces/{name}/delete",
response_model=GenericResponse, response_model=GenericResponse,
@@ -863,101 +787,6 @@ def delete_classification_dataset_images(
) )
@router.post(
"/classification/{name}/dataset/{category}/reclassify",
response_model=GenericResponse,
dependencies=[Depends(require_role(["admin"]))],
summary="Reclassify a dataset image to a different category",
description="""Moves a single dataset image from one category to another.
The image is re-saved as PNG in the target category and removed from the source.""",
)
def reclassify_classification_image(
request: Request, name: str, category: str, body: dict = None
):
config: FrigateConfig = request.app.frigate_config
if name not in config.classification.custom:
return JSONResponse(
content=(
{
"success": False,
"message": f"{name} is not a known classification model.",
}
),
status_code=404,
)
json: dict[str, Any] = body or {}
image_id = sanitize_filename(json.get("id", ""))
new_category = sanitize_filename(json.get("new_category", ""))
if not image_id or not new_category:
return JSONResponse(
content=(
{
"success": False,
"message": "Both 'id' and 'new_category' are required.",
}
),
status_code=400,
)
if new_category == category:
return JSONResponse(
content=(
{
"success": False,
"message": "New category must differ from the current category.",
}
),
status_code=400,
)
sanitized_name = sanitize_filename(name)
source_folder = os.path.join(
CLIPS_DIR, sanitized_name, "dataset", sanitize_filename(category)
)
source_file = os.path.join(source_folder, image_id)
if not os.path.isfile(source_file):
return JSONResponse(
content=(
{
"success": False,
"message": f"Image not found: {image_id}",
}
),
status_code=404,
)
random_id = "".join(random.choices(string.ascii_lowercase + string.digits, k=6))
timestamp = datetime.datetime.now().timestamp()
new_name = f"{new_category}-{timestamp}-{random_id}.png"
target_folder = os.path.join(CLIPS_DIR, sanitized_name, "dataset", new_category)
os.makedirs(target_folder, exist_ok=True)
img = cv2.imread(source_file)
cv2.imwrite(os.path.join(target_folder, new_name), img)
os.unlink(source_file)
# Clean up empty source folder (unless it is "none")
if (
os.path.exists(source_folder)
and not os.listdir(source_folder)
and category.lower() != "none"
):
os.rmdir(source_folder)
# Mark dataset as changed so UI knows retraining is needed
write_training_metadata(sanitized_name, 0)
return JSONResponse(
content=({"success": True, "message": "Successfully reclassified image."}),
status_code=200,
)
@router.put( @router.put(
"/classification/{name}/dataset/{old_category}/rename", "/classification/{name}/dataset/{old_category}/rename",
response_model=GenericResponse, response_model=GenericResponse,
-176
View File
@@ -1,176 +0,0 @@
"""Debug replay API endpoints."""
import asyncio
import logging
from datetime import datetime
from fastapi import APIRouter, Depends, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from frigate.api.auth import require_role
from frigate.api.defs.tags import Tags
logger = logging.getLogger(__name__)
router = APIRouter(tags=[Tags.app])
class DebugReplayStartBody(BaseModel):
"""Request body for starting a debug replay session."""
camera: str = Field(title="Source camera name")
start_time: float = Field(title="Start timestamp")
end_time: float = Field(title="End timestamp")
class DebugReplayStartResponse(BaseModel):
"""Response for starting a debug replay session."""
success: bool
replay_camera: str
class DebugReplayStatusResponse(BaseModel):
"""Response for debug replay status."""
active: bool
replay_camera: str | None = None
source_camera: str | None = None
start_time: float | None = None
end_time: float | None = None
live_ready: bool = False
class DebugReplayStopResponse(BaseModel):
"""Response for stopping a debug replay session."""
success: bool
@router.post(
"/debug_replay/start",
response_model=DebugReplayStartResponse,
dependencies=[Depends(require_role(["admin"]))],
summary="Start debug replay",
description="Start a debug replay session from camera recordings.",
)
async def start_debug_replay(request: Request, body: DebugReplayStartBody):
"""Start a debug replay session."""
replay_manager = request.app.replay_manager
if replay_manager.active:
return JSONResponse(
content={
"success": False,
"message": "A replay session is already active",
},
status_code=409,
)
try:
replay_camera = await asyncio.to_thread(
replay_manager.start,
source_camera=body.camera,
start_ts=body.start_time,
end_ts=body.end_time,
frigate_config=request.app.frigate_config,
config_publisher=request.app.config_publisher,
)
except ValueError:
logger.exception("Invalid parameters for debug replay start request")
return JSONResponse(
content={
"success": False,
"message": "Invalid debug replay request parameters",
},
status_code=400,
)
except RuntimeError:
logger.exception("Error while starting debug replay session")
return JSONResponse(
content={
"success": False,
"message": "An internal error occurred while starting debug replay",
},
status_code=500,
)
return DebugReplayStartResponse(
success=True,
replay_camera=replay_camera,
)
@router.get(
"/debug_replay/status",
response_model=DebugReplayStatusResponse,
dependencies=[Depends(require_role(["admin"]))],
summary="Get debug replay status",
description="Get the status of the current debug replay session.",
)
def get_debug_replay_status(request: Request):
"""Get the current replay session status."""
replay_manager = request.app.replay_manager
live_ready = False
replay_camera = replay_manager.replay_camera_name
if replay_manager.active and replay_camera:
frame_processor = request.app.detected_frames_processor
frame = frame_processor.get_current_frame(replay_camera)
if frame is not None:
frame_time = frame_processor.get_current_frame_time(replay_camera)
camera_config = request.app.frigate_config.cameras.get(replay_camera)
retry_interval = 10
if camera_config is not None:
retry_interval = float(camera_config.ffmpeg.retry_interval or 10)
live_ready = datetime.now().timestamp() <= frame_time + retry_interval
return DebugReplayStatusResponse(
active=replay_manager.active,
replay_camera=replay_camera,
source_camera=replay_manager.source_camera,
start_time=replay_manager.start_ts,
end_time=replay_manager.end_ts,
live_ready=live_ready,
)
@router.post(
"/debug_replay/stop",
response_model=DebugReplayStopResponse,
dependencies=[Depends(require_role(["admin"]))],
summary="Stop debug replay",
description="Stop the active debug replay session and clean up all artifacts.",
)
async def stop_debug_replay(request: Request):
"""Stop the active replay session."""
replay_manager = request.app.replay_manager
if not replay_manager.active:
return JSONResponse(
content={"success": False, "message": "No active replay session"},
status_code=400,
)
try:
await asyncio.to_thread(
replay_manager.stop,
frigate_config=request.app.frigate_config,
config_publisher=request.app.config_publisher,
)
except (ValueError, RuntimeError, OSError) as e:
logger.error("Error stopping replay: %s", e)
return JSONResponse(
content={
"success": False,
"message": "Failed to stop replay session due to an internal error.",
},
status_code=500,
)
return DebugReplayStopResponse(success=True)
@@ -1,7 +1,8 @@
from enum import Enum from enum import Enum
from typing import Optional from typing import Optional, Union
from pydantic import BaseModel from pydantic import BaseModel
from pydantic.json_schema import SkipJsonSchema
class Extension(str, Enum): class Extension(str, Enum):
@@ -35,7 +36,7 @@ class MediaEventsSnapshotQueryParams(BaseModel):
bbox: Optional[int] = None bbox: Optional[int] = None
crop: Optional[int] = None crop: Optional[int] = None
height: Optional[int] = None height: Optional[int] = None
quality: Optional[int] = None quality: Optional[int] = 70
class MediaMjpegFeedQueryParams(BaseModel): class MediaMjpegFeedQueryParams(BaseModel):
@@ -47,3 +48,15 @@ class MediaMjpegFeedQueryParams(BaseModel):
mask: Optional[int] = None mask: Optional[int] = None
motion: Optional[int] = None motion: Optional[int] = None
regions: Optional[int] = None regions: Optional[int] = None
class MediaRecordingsSummaryQueryParams(BaseModel):
timezone: str = "utc"
cameras: Optional[str] = "all"
class MediaRecordingsAvailabilityQueryParams(BaseModel):
cameras: str = "all"
before: Union[float, SkipJsonSchema[None]] = None
after: Union[float, SkipJsonSchema[None]] = None
scale: int = 30
@@ -1,21 +0,0 @@
from typing import Optional, Union
from pydantic import BaseModel
from pydantic.json_schema import SkipJsonSchema
class MediaRecordingsSummaryQueryParams(BaseModel):
timezone: str = "utc"
cameras: Optional[str] = "all"
class MediaRecordingsAvailabilityQueryParams(BaseModel):
cameras: str = "all"
before: Union[float, SkipJsonSchema[None]] = None
after: Union[float, SkipJsonSchema[None]] = None
scale: int = 30
class RecordingsDeleteQueryParams(BaseModel):
keep: Optional[str] = None
cameras: Optional[str] = "all"
+2 -24
View File
@@ -1,13 +1,12 @@
from typing import Any, Dict, List, Optional from typing import Any, Dict, Optional
from pydantic import BaseModel, Field from pydantic import BaseModel
class AppConfigSetBody(BaseModel): class AppConfigSetBody(BaseModel):
requires_restart: int = 1 requires_restart: int = 1
update_topic: str | None = None update_topic: str | None = None
config_data: Optional[Dict[str, Any]] = None config_data: Optional[Dict[str, Any]] = None
skip_save: bool = False
class AppPutPasswordBody(BaseModel): class AppPutPasswordBody(BaseModel):
@@ -28,24 +27,3 @@ class AppPostLoginBody(BaseModel):
class AppPutRoleBody(BaseModel): class AppPutRoleBody(BaseModel):
role: str role: str
class CameraSetBody(BaseModel):
value: str = Field(..., description="The value to set for the feature")
class MediaSyncBody(BaseModel):
dry_run: bool = Field(
default=True, description="If True, only report orphans without deleting them"
)
media_types: List[str] = Field(
default=["all"],
description="Types of media to sync: 'all', 'event_snapshots', 'event_thumbnails', 'review_thumbnails', 'previews', 'exports', 'recordings'",
)
force: bool = Field(
default=False, description="If True, bypass safety threshold checks"
)
verbose: bool = Field(
default=False,
description="If True, write full orphan file list to disk",
)
-38
View File
@@ -1,38 +0,0 @@
"""Chat API request models."""
from typing import Optional
from pydantic import BaseModel, Field
class ChatMessage(BaseModel):
"""A single message in a chat conversation."""
role: str = Field(
description="Message role: 'user', 'assistant', 'system', or 'tool'"
)
content: str = Field(description="Message content")
tool_call_id: Optional[str] = Field(
default=None, description="For tool messages, the ID of the tool call"
)
name: Optional[str] = Field(
default=None, description="For tool messages, the tool name"
)
class ChatCompletionRequest(BaseModel):
"""Request for chat completion with tool calling."""
messages: list[ChatMessage] = Field(
description="List of messages in the conversation"
)
max_tool_iterations: int = Field(
default=5,
ge=1,
le=10,
description="Maximum number of tool call iterations (default: 5)",
)
stream: bool = Field(
default=False,
description="If true, stream the final assistant response in the body as newline-delimited JSON.",
)
-1
View File
@@ -41,7 +41,6 @@ class EventsCreateBody(BaseModel):
duration: Optional[int] = 30 duration: Optional[int] = 30
include_recording: Optional[bool] = True include_recording: Optional[bool] = True
draw: Optional[dict] = {} draw: Optional[dict] = {}
pre_capture: Optional[int] = None
class EventsEndBody(BaseModel): class EventsEndBody(BaseModel):
@@ -1,35 +0,0 @@
from typing import Optional
from pydantic import BaseModel, Field
class ExportCaseCreateBody(BaseModel):
"""Request body for creating a new export case."""
name: str = Field(max_length=100, description="Friendly name of the export case")
description: Optional[str] = Field(
default=None, description="Optional description of the export case"
)
class ExportCaseUpdateBody(BaseModel):
"""Request body for updating an existing export case."""
name: Optional[str] = Field(
default=None,
max_length=100,
description="Updated friendly name of the export case",
)
description: Optional[str] = Field(
default=None, description="Updated description of the export case"
)
class ExportCaseAssignBody(BaseModel):
"""Request body for assigning or unassigning an export to a case."""
export_case_id: Optional[str] = Field(
default=None,
max_length=30,
description="Case ID to assign to the export, or null to unassign",
)
@@ -3,47 +3,27 @@ from typing import Optional, Union
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from pydantic.json_schema import SkipJsonSchema from pydantic.json_schema import SkipJsonSchema
from frigate.record.export import PlaybackSourceEnum from frigate.record.export import (
ChaptersEnum,
PlaybackFactorEnum,
PlaybackSourceEnum,
)
class ExportRecordingsBody(BaseModel): class ExportRecordingsBody(BaseModel):
playback: PlaybackFactorEnum = Field(
default=PlaybackFactorEnum.realtime, title="Playback factor"
)
source: PlaybackSourceEnum = Field( source: PlaybackSourceEnum = Field(
default=PlaybackSourceEnum.recordings, title="Playback source" default=PlaybackSourceEnum.recordings, title="Playback source"
) )
name: Optional[str] = Field(title="Friendly name", default=None, max_length=256) name: Optional[str] = Field(title="Friendly name", default=None, max_length=256)
image_path: Union[str, SkipJsonSchema[None]] = None image_path: Union[str, SkipJsonSchema[None]] = None
export_case_id: Optional[str] = Field( chapters: Optional[ChaptersEnum] = Field(
default=None, default=None,
title="Export case ID", title="Chapter mode",
max_length=30, description=(
description="ID of the export case to assign this export to", "Optional chapter metadata to embed in the export. When omitted, "
) "no chapter track is added."
),
class ExportRecordingsCustomBody(BaseModel):
source: PlaybackSourceEnum = Field(
default=PlaybackSourceEnum.recordings, title="Playback source"
)
name: str = Field(title="Friendly name", default=None, max_length=256)
image_path: Union[str, SkipJsonSchema[None]] = None
export_case_id: Optional[str] = Field(
default=None,
title="Export case ID",
max_length=30,
description="ID of the export case to assign this export to",
)
ffmpeg_input_args: Optional[str] = Field(
default=None,
title="FFmpeg input arguments",
description="Custom FFmpeg input arguments. If not provided, defaults to timelapse input args.",
)
ffmpeg_output_args: Optional[str] = Field(
default=None,
title="FFmpeg output arguments",
description="Custom FFmpeg output arguments. If not provided, defaults to timelapse output args.",
)
cpu_fallback: bool = Field(
default=False,
title="CPU Fallback",
description="If true, retry export without hardware acceleration if the initial export fails.",
) )
@@ -1,54 +0,0 @@
"""Chat API response models."""
from typing import Any, Optional
from pydantic import BaseModel, Field
class ToolCallInvocation(BaseModel):
"""A tool call requested by the LLM (before execution)."""
id: str = Field(description="Unique identifier for this tool call")
name: str = Field(description="Tool name to call")
arguments: dict[str, Any] = Field(description="Arguments for the tool call")
class ChatMessageResponse(BaseModel):
"""A message in the chat response."""
role: str = Field(description="Message role")
content: Optional[str] = Field(
default=None, description="Message content (None if tool calls present)"
)
tool_calls: Optional[list[ToolCallInvocation]] = Field(
default=None, description="Tool calls if LLM wants to call tools"
)
class ToolCall(BaseModel):
"""A tool that was executed during the completion, with its response."""
name: str = Field(description="Tool name that was called")
arguments: dict[str, Any] = Field(
default_factory=dict, description="Arguments passed to the tool"
)
response: str = Field(
default="",
description="The response or result returned from the tool execution",
)
class ChatCompletionResponse(BaseModel):
"""Response from chat completion."""
message: ChatMessageResponse = Field(description="The assistant's message")
finish_reason: str = Field(
description="Reason generation stopped: 'stop', 'tool_calls', 'length', 'error'"
)
tool_iterations: int = Field(
default=0, description="Number of tool call iterations performed"
)
tool_calls: list[ToolCall] = Field(
default_factory=list,
description="List of tool calls that were executed during this completion",
)
@@ -1,22 +0,0 @@
from typing import List, Optional
from pydantic import BaseModel, Field
class ExportCaseModel(BaseModel):
"""Model representing a single export case."""
id: str = Field(description="Unique identifier for the export case")
name: str = Field(description="Friendly name of the export case")
description: Optional[str] = Field(
default=None, description="Optional description of the export case"
)
created_at: float = Field(
description="Unix timestamp when the export case was created"
)
updated_at: float = Field(
description="Unix timestamp when the export case was last updated"
)
ExportCasesResponse = List[ExportCaseModel]
@@ -15,9 +15,6 @@ class ExportModel(BaseModel):
in_progress: bool = Field( in_progress: bool = Field(
description="Whether the export is currently being processed" description="Whether the export is currently being processed"
) )
export_case_id: Optional[str] = Field(
default=None, description="ID of the export case this export belongs to"
)
class StartExportResponse(BaseModel): class StartExportResponse(BaseModel):
+5 -8
View File
@@ -3,16 +3,13 @@ from enum import Enum
class Tags(Enum): class Tags(Enum):
app = "App" app = "App"
auth = "Auth"
camera = "Camera" camera = "Camera"
chat = "Chat" preview = "Preview"
events = "Events"
export = "Export"
classification = "Classification"
logs = "Logs" logs = "Logs"
media = "Media" media = "Media"
motion_search = "Motion Search"
notifications = "Notifications" notifications = "Notifications"
preview = "Preview"
recordings = "Recordings"
review = "Review" review = "Review"
export = "Export"
events = "Events"
classification = "Classification"
auth = "Auth"
+31 -17
View File
@@ -1,6 +1,5 @@
"""Event apis.""" """Event apis."""
import asyncio
import base64 import base64
import datetime import datetime
import json import json
@@ -13,6 +12,7 @@ from pathlib import Path
from typing import List from typing import List
from urllib.parse import unquote from urllib.parse import unquote
import cv2
import numpy as np import numpy as np
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
from fastapi.params import Depends from fastapi.params import Depends
@@ -61,7 +61,7 @@ from frigate.const import CLIPS_DIR, TRIGGER_DIR
from frigate.embeddings import EmbeddingsContext from frigate.embeddings import EmbeddingsContext
from frigate.models import Event, ReviewSegment, Timeline, Trigger from frigate.models import Event, ReviewSegment, Timeline, Trigger
from frigate.track.object_processing import TrackedObject from frigate.track.object_processing import TrackedObject
from frigate.util.file import get_event_thumbnail_bytes, load_event_snapshot_image from frigate.util.file import get_event_thumbnail_bytes
from frigate.util.time import get_dst_transitions, get_tz_modifiers from frigate.util.time import get_dst_transitions, get_tz_modifiers
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -1081,8 +1081,30 @@ async def send_to_plus(request: Request, event_id: str, body: SubmitPlusBody = N
content=({"success": False, "message": message}), status_code=400 content=({"success": False, "message": message}), status_code=400
) )
# load clean.webp or clean.png (legacy)
try: try:
image, is_clean_snapshot = load_event_snapshot_image(event, clean_only=True) filename_webp = f"{event.camera}-{event.id}-clean.webp"
filename_png = f"{event.camera}-{event.id}-clean.png"
image_path = None
if os.path.exists(os.path.join(CLIPS_DIR, filename_webp)):
image_path = os.path.join(CLIPS_DIR, filename_webp)
elif os.path.exists(os.path.join(CLIPS_DIR, filename_png)):
image_path = os.path.join(CLIPS_DIR, filename_png)
if image_path is None:
logger.error(f"Unable to find clean snapshot for event: {event.id}")
return JSONResponse(
content=(
{
"success": False,
"message": "Unable to find clean snapshot for event",
}
),
status_code=400,
)
image = cv2.imread(image_path)
except Exception: except Exception:
logger.error(f"Unable to load clean snapshot for event: {event.id}") logger.error(f"Unable to load clean snapshot for event: {event.id}")
return JSONResponse( return JSONResponse(
@@ -1092,22 +1114,17 @@ async def send_to_plus(request: Request, event_id: str, body: SubmitPlusBody = N
status_code=400, status_code=400,
) )
if not is_clean_snapshot or image is None or image.size == 0: if image is None or image.size == 0:
logger.error(f"Unable to find clean snapshot for event: {event.id}") logger.error(f"Unable to load clean snapshot for event: {event.id}")
return JSONResponse( return JSONResponse(
content=( content=(
{ {"success": False, "message": "Unable to load clean snapshot for event"}
"success": False,
"message": "Unable to find clean snapshot for event",
}
), ),
status_code=400, status_code=400,
) )
try: try:
plus_id = await asyncio.to_thread( plus_id = request.app.frigate_config.plus_api.upload_image(image, event.camera)
request.app.frigate_config.plus_api.upload_image, image, event.camera
)
except Exception as ex: except Exception as ex:
logger.exception(ex) logger.exception(ex)
return JSONResponse( return JSONResponse(
@@ -1123,8 +1140,7 @@ async def send_to_plus(request: Request, event_id: str, body: SubmitPlusBody = N
box = event.data["box"] box = event.data["box"]
try: try:
await asyncio.to_thread( request.app.frigate_config.plus_api.add_annotation(
request.app.frigate_config.plus_api.add_annotation,
event.plus_id, event.plus_id,
box, box,
event.label, event.label,
@@ -1214,8 +1230,7 @@ async def false_positive(request: Request, event_id: str):
) )
try: try:
await asyncio.to_thread( request.app.frigate_config.plus_api.add_false_positive(
request.app.frigate_config.plus_api.add_false_positive,
event.plus_id, event.plus_id,
region, region,
box, box,
@@ -1767,7 +1782,6 @@ def create_event(
body.duration, body.duration,
"api", "api",
body.draw, body.draw,
body.pre_capture,
), ),
EventMetadataTypeEnum.manual_event_create.value, EventMetadataTypeEnum.manual_event_create.value,
) )
+36 -350
View File
@@ -4,10 +4,10 @@ import logging
import random import random
import string import string
from pathlib import Path from pathlib import Path
from typing import List, Optional from typing import List
import psutil import psutil
from fastapi import APIRouter, Depends, Query, Request from fastapi import APIRouter, Depends, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from pathvalidate import sanitize_filepath from pathvalidate import sanitize_filepath
from peewee import DoesNotExist from peewee import DoesNotExist
@@ -19,20 +19,8 @@ from frigate.api.auth import (
require_camera_access, require_camera_access,
require_role, require_role,
) )
from frigate.api.defs.request.export_case_body import ( from frigate.api.defs.request.export_recordings_body import ExportRecordingsBody
ExportCaseAssignBody,
ExportCaseCreateBody,
ExportCaseUpdateBody,
)
from frigate.api.defs.request.export_recordings_body import (
ExportRecordingsBody,
ExportRecordingsCustomBody,
)
from frigate.api.defs.request.export_rename_body import ExportRenameBody from frigate.api.defs.request.export_rename_body import ExportRenameBody
from frigate.api.defs.response.export_case_response import (
ExportCaseModel,
ExportCasesResponse,
)
from frigate.api.defs.response.export_response import ( from frigate.api.defs.response.export_response import (
ExportModel, ExportModel,
ExportsResponse, ExportsResponse,
@@ -41,12 +29,11 @@ from frigate.api.defs.response.export_response import (
from frigate.api.defs.response.generic_response import GenericResponse from frigate.api.defs.response.generic_response import GenericResponse
from frigate.api.defs.tags import Tags from frigate.api.defs.tags import Tags
from frigate.const import CLIPS_DIR, EXPORT_DIR from frigate.const import CLIPS_DIR, EXPORT_DIR
from frigate.models import Export, ExportCase, Previews, Recordings from frigate.models import Export, Previews, Recordings
from frigate.record.export import ( from frigate.record.export import (
DEFAULT_TIME_LAPSE_FFMPEG_ARGS, PlaybackFactorEnum,
PlaybackSourceEnum, PlaybackSourceEnum,
RecordingExporter, RecordingExporter,
validate_ffmpeg_args,
) )
from frigate.util.time import is_current_hour from frigate.util.time import is_current_hour
@@ -65,182 +52,17 @@ router = APIRouter(tags=[Tags.export])
) )
def get_exports( def get_exports(
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter), allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
export_case_id: Optional[str] = None,
cameras: Optional[str] = Query(default="all"),
start_date: Optional[float] = None,
end_date: Optional[float] = None,
): ):
query = Export.select().where(Export.camera << allowed_cameras) exports = (
Export.select()
if export_case_id is not None: .where(Export.camera << allowed_cameras)
if export_case_id == "unassigned": .order_by(Export.date.desc())
query = query.where(Export.export_case.is_null(True)) .dicts()
else: .iterator()
query = query.where(Export.export_case == export_case_id) )
if cameras and cameras != "all":
requested = set(cameras.split(","))
filtered_cameras = list(requested.intersection(allowed_cameras))
if not filtered_cameras:
return JSONResponse(content=[])
query = query.where(Export.camera << filtered_cameras)
if start_date is not None:
query = query.where(Export.date >= start_date)
if end_date is not None:
query = query.where(Export.date <= end_date)
exports = query.order_by(Export.date.desc()).dicts().iterator()
return JSONResponse(content=[e for e in exports]) return JSONResponse(content=[e for e in exports])
@router.get(
"/cases",
response_model=ExportCasesResponse,
dependencies=[Depends(allow_any_authenticated())],
summary="Get export cases",
description="Gets all export cases from the database.",
)
def get_export_cases():
cases = (
ExportCase.select().order_by(ExportCase.created_at.desc()).dicts().iterator()
)
return JSONResponse(content=[c for c in cases])
@router.post(
"/cases",
response_model=ExportCaseModel,
dependencies=[Depends(require_role(["admin"]))],
summary="Create export case",
description="Creates a new export case.",
)
def create_export_case(body: ExportCaseCreateBody):
case = ExportCase.create(
id="".join(random.choices(string.ascii_lowercase + string.digits, k=12)),
name=body.name,
description=body.description,
created_at=Path().stat().st_mtime,
updated_at=Path().stat().st_mtime,
)
return JSONResponse(content=model_to_dict(case))
@router.get(
"/cases/{case_id}",
response_model=ExportCaseModel,
dependencies=[Depends(allow_any_authenticated())],
summary="Get a single export case",
description="Gets a specific export case by ID.",
)
def get_export_case(case_id: str):
try:
case = ExportCase.get(ExportCase.id == case_id)
return JSONResponse(content=model_to_dict(case))
except DoesNotExist:
return JSONResponse(
content={"success": False, "message": "Export case not found"},
status_code=404,
)
@router.patch(
"/cases/{case_id}",
response_model=GenericResponse,
dependencies=[Depends(require_role(["admin"]))],
summary="Update export case",
description="Updates an existing export case.",
)
def update_export_case(case_id: str, body: ExportCaseUpdateBody):
try:
case = ExportCase.get(ExportCase.id == case_id)
except DoesNotExist:
return JSONResponse(
content={"success": False, "message": "Export case not found"},
status_code=404,
)
if body.name is not None:
case.name = body.name
if body.description is not None:
case.description = body.description
case.save()
return JSONResponse(
content={"success": True, "message": "Successfully updated export case."}
)
@router.delete(
"/cases/{case_id}",
response_model=GenericResponse,
dependencies=[Depends(require_role(["admin"]))],
summary="Delete export case",
description="""Deletes an export case.\n Exports that reference this case will have their export_case set to null.\n """,
)
def delete_export_case(case_id: str):
try:
case = ExportCase.get(ExportCase.id == case_id)
except DoesNotExist:
return JSONResponse(
content={"success": False, "message": "Export case not found"},
status_code=404,
)
# Unassign exports from this case but keep the exports themselves
Export.update(export_case=None).where(Export.export_case == case).execute()
case.delete_instance()
return JSONResponse(
content={"success": True, "message": "Successfully deleted export case."}
)
@router.patch(
"/export/{export_id}/case",
response_model=GenericResponse,
dependencies=[Depends(require_role(["admin"]))],
summary="Assign export to case",
description=(
"Assigns an export to a case, or unassigns it if export_case_id is null."
),
)
async def assign_export_case(
export_id: str,
body: ExportCaseAssignBody,
request: Request,
):
try:
export: Export = Export.get(Export.id == export_id)
await require_camera_access(export.camera, request=request)
except DoesNotExist:
return JSONResponse(
content={"success": False, "message": "Export not found."},
status_code=404,
)
if body.export_case_id is not None:
try:
ExportCase.get(ExportCase.id == body.export_case_id)
except DoesNotExist:
return JSONResponse(
content={"success": False, "message": "Export case not found."},
status_code=404,
)
export.export_case = body.export_case_id
else:
export.export_case = None
export.save()
return JSONResponse(
content={"success": True, "message": "Successfully updated export case."}
)
@router.post( @router.post(
"/export/{camera_name}/start/{start_time}/end/{end_time}", "/export/{camera_name}/start/{start_time}/end/{end_time}",
response_model=StartExportResponse, response_model=StartExportResponse,
@@ -266,19 +88,28 @@ def export_recording(
status_code=404, status_code=404,
) )
playback_factor = body.playback
playback_source = body.source playback_source = body.source
friendly_name = body.name friendly_name = body.name
# sanitize_filepath normalizes "\" to "/" but leaves ".." intact, so a path
# like "clips\..\..\etc/passwd" passes the CLIPS_DIR prefix check yet still
# escapes the directory once resolved. A valid snapshot path never uses "..".
if body.image_path and ".." in body.image_path:
return JSONResponse(
content=({"success": False, "message": "Invalid image path"}),
status_code=400,
)
existing_image = sanitize_filepath(body.image_path) if body.image_path else None existing_image = sanitize_filepath(body.image_path) if body.image_path else None
export_case_id = body.export_case_id # a chapters value in the request body overrides the camera's export config
if export_case_id is not None: camera_config = request.app.frigate_config.cameras[camera_name]
try: chapters = (
ExportCase.get(ExportCase.id == export_case_id) body.chapters
except DoesNotExist: if body.chapters is not None
return JSONResponse( else camera_config.record.export.chapters
content={"success": False, "message": "Export case not found"}, )
status_code=404,
)
# Ensure that existing_image is a valid path # Ensure that existing_image is a valid path
if existing_image and not existing_image.startswith(CLIPS_DIR): if existing_image and not existing_image.startswith(CLIPS_DIR):
@@ -338,12 +169,17 @@ def export_recording(
existing_image, existing_image,
int(start_time), int(start_time),
int(end_time), int(end_time),
(
PlaybackFactorEnum[playback_factor]
if playback_factor in PlaybackFactorEnum.__members__.values()
else PlaybackFactorEnum.realtime
),
( (
PlaybackSourceEnum[playback_source] PlaybackSourceEnum[playback_source]
if playback_source in PlaybackSourceEnum.__members__.values() if playback_source in PlaybackSourceEnum.__members__.values()
else PlaybackSourceEnum.recordings else PlaybackSourceEnum.recordings
), ),
export_case_id, chapters=chapters,
) )
exporter.start() exporter.start()
return JSONResponse( return JSONResponse(
@@ -454,156 +290,6 @@ async def export_delete(event_id: str, request: Request):
) )
@router.post(
"/export/custom/{camera_name}/start/{start_time}/end/{end_time}",
response_model=StartExportResponse,
dependencies=[Depends(require_camera_access)],
summary="Start custom recording export",
description="""Starts an export of a recording for the specified time range using custom FFmpeg arguments.
The export can be from recordings or preview footage. Returns the export ID if
successful, or an error message if the camera is invalid or no recordings/previews
are found for the time range. If ffmpeg_input_args and ffmpeg_output_args are not provided,
defaults to timelapse export settings.""",
)
def export_recording_custom(
request: Request,
camera_name: str,
start_time: float,
end_time: float,
body: ExportRecordingsCustomBody,
):
if not camera_name or not request.app.frigate_config.cameras.get(camera_name):
return JSONResponse(
content=(
{"success": False, "message": f"{camera_name} is not a valid camera."}
),
status_code=404,
)
playback_source = body.source
friendly_name = body.name
existing_image = sanitize_filepath(body.image_path) if body.image_path else None
ffmpeg_input_args = body.ffmpeg_input_args
ffmpeg_output_args = body.ffmpeg_output_args
cpu_fallback = body.cpu_fallback
export_case_id = body.export_case_id
if export_case_id is not None:
try:
ExportCase.get(ExportCase.id == export_case_id)
except DoesNotExist:
return JSONResponse(
content={"success": False, "message": "Export case not found"},
status_code=404,
)
# Ensure that existing_image is a valid path
if existing_image and not existing_image.startswith(CLIPS_DIR):
return JSONResponse(
content=({"success": False, "message": "Invalid image path"}),
status_code=400,
)
if playback_source == "recordings":
recordings_count = (
Recordings.select()
.where(
Recordings.start_time.between(start_time, end_time)
| Recordings.end_time.between(start_time, end_time)
| (
(start_time > Recordings.start_time)
& (end_time < Recordings.end_time)
)
)
.where(Recordings.camera == camera_name)
.count()
)
if recordings_count <= 0:
return JSONResponse(
content=(
{"success": False, "message": "No recordings found for time range"}
),
status_code=400,
)
else:
previews_count = (
Previews.select()
.where(
Previews.start_time.between(start_time, end_time)
| Previews.end_time.between(start_time, end_time)
| ((start_time > Previews.start_time) & (end_time < Previews.end_time))
)
.where(Previews.camera == camera_name)
.count()
)
if not is_current_hour(start_time) and previews_count <= 0:
return JSONResponse(
content=(
{"success": False, "message": "No previews found for time range"}
),
status_code=400,
)
export_id = f"{camera_name}_{''.join(random.choices(string.ascii_lowercase + string.digits, k=6))}"
# Validate user-provided ffmpeg args to prevent injection
for args_label, args_value in [
("input", ffmpeg_input_args),
("output", ffmpeg_output_args),
]:
if args_value is not None:
valid, message = validate_ffmpeg_args(args_value)
if not valid:
return JSONResponse(
content=(
{
"success": False,
"message": f"Invalid ffmpeg {args_label} arguments: {message}",
}
),
status_code=400,
)
# Set default values if not provided (timelapse defaults)
if ffmpeg_input_args is None:
ffmpeg_input_args = ""
if ffmpeg_output_args is None:
ffmpeg_output_args = DEFAULT_TIME_LAPSE_FFMPEG_ARGS
exporter = RecordingExporter(
request.app.frigate_config,
export_id,
camera_name,
friendly_name,
existing_image,
int(start_time),
int(end_time),
(
PlaybackSourceEnum[playback_source]
if playback_source in PlaybackSourceEnum.__members__.values()
else PlaybackSourceEnum.recordings
),
export_case_id,
ffmpeg_input_args,
ffmpeg_output_args,
cpu_fallback,
)
exporter.start()
return JSONResponse(
content=(
{
"success": True,
"message": "Starting export of recording.",
"export_id": export_id,
}
),
status_code=200,
)
@router.get( @router.get(
"/exports/{export_id}", "/exports/{export_id}",
response_model=ExportModel, response_model=ExportModel,
-19
View File
@@ -16,29 +16,21 @@ from frigate.api import app as main_app
from frigate.api import ( from frigate.api import (
auth, auth,
camera, camera,
chat,
classification, classification,
debug_replay,
event, event,
export, export,
media, media,
motion_search,
notification, notification,
preview, preview,
record,
review, review,
) )
from frigate.api.auth import get_jwt_secret, limiter, require_admin_by_default from frigate.api.auth import get_jwt_secret, limiter, require_admin_by_default
from frigate.comms.dispatcher import Dispatcher
from frigate.comms.event_metadata_updater import ( from frigate.comms.event_metadata_updater import (
EventMetadataPublisher, EventMetadataPublisher,
) )
from frigate.config import FrigateConfig from frigate.config import FrigateConfig
from frigate.config.camera.updater import CameraConfigUpdatePublisher from frigate.config.camera.updater import CameraConfigUpdatePublisher
from frigate.config.profile_manager import ProfileManager
from frigate.debug_replay import DebugReplayManager
from frigate.embeddings import EmbeddingsContext from frigate.embeddings import EmbeddingsContext
from frigate.genai import GenAIClientManager
from frigate.ptz.onvif import OnvifController from frigate.ptz.onvif import OnvifController
from frigate.stats.emitter import StatsEmitter from frigate.stats.emitter import StatsEmitter
from frigate.storage import StorageMaintainer from frigate.storage import StorageMaintainer
@@ -70,9 +62,6 @@ def create_fastapi_app(
stats_emitter: StatsEmitter, stats_emitter: StatsEmitter,
event_metadata_updater: EventMetadataPublisher, event_metadata_updater: EventMetadataPublisher,
config_publisher: CameraConfigUpdatePublisher, config_publisher: CameraConfigUpdatePublisher,
replay_manager: DebugReplayManager,
dispatcher: Optional[Dispatcher] = None,
profile_manager: Optional[ProfileManager] = None,
enforce_default_admin: bool = True, enforce_default_admin: bool = True,
): ):
logger.info("Starting FastAPI app") logger.info("Starting FastAPI app")
@@ -131,7 +120,6 @@ def create_fastapi_app(
# Order of include_router matters: https://fastapi.tiangolo.com/tutorial/path-params/#order-matters # Order of include_router matters: https://fastapi.tiangolo.com/tutorial/path-params/#order-matters
app.include_router(auth.router) app.include_router(auth.router)
app.include_router(camera.router) app.include_router(camera.router)
app.include_router(chat.router)
app.include_router(classification.router) app.include_router(classification.router)
app.include_router(review.router) app.include_router(review.router)
app.include_router(main_app.router) app.include_router(main_app.router)
@@ -140,12 +128,8 @@ def create_fastapi_app(
app.include_router(export.router) app.include_router(export.router)
app.include_router(event.router) app.include_router(event.router)
app.include_router(media.router) app.include_router(media.router)
app.include_router(motion_search.router)
app.include_router(record.router)
app.include_router(debug_replay.router)
# App Properties # App Properties
app.frigate_config = frigate_config app.frigate_config = frigate_config
app.genai_manager = GenAIClientManager(frigate_config)
app.embeddings = embeddings app.embeddings = embeddings
app.detected_frames_processor = detected_frames_processor app.detected_frames_processor = detected_frames_processor
app.storage_maintainer = storage_maintainer app.storage_maintainer = storage_maintainer
@@ -154,9 +138,6 @@ def create_fastapi_app(
app.stats_emitter = stats_emitter app.stats_emitter = stats_emitter
app.event_metadata_updater = event_metadata_updater app.event_metadata_updater = event_metadata_updater
app.config_publisher = config_publisher app.config_publisher = config_publisher
app.replay_manager = replay_manager
app.dispatcher = dispatcher
app.profile_manager = profile_manager
if frigate_config.auth.enabled: if frigate_config.auth.enabled:
secret = get_jwt_secret() secret = get_jwt_secret()
+459 -208
View File
@@ -8,8 +8,9 @@ import os
import subprocess as sp import subprocess as sp
import time import time
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from functools import reduce
from pathlib import Path as FilePath from pathlib import Path as FilePath
from typing import Any from typing import Any, List
from urllib.parse import unquote from urllib.parse import unquote
import cv2 import cv2
@@ -18,41 +19,39 @@ import pytz
from fastapi import APIRouter, Depends, Path, Query, Request, Response from fastapi import APIRouter, Depends, Path, Query, Request, Response
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
from pathvalidate import sanitize_filename from pathvalidate import sanitize_filename
from peewee import DoesNotExist, fn from peewee import DoesNotExist, fn, operator
from tzlocal import get_localzone_name from tzlocal import get_localzone_name
from frigate.api.auth import ( from frigate.api.auth import (
allow_any_authenticated, allow_any_authenticated,
get_allowed_cameras_for_filter,
require_camera_access, require_camera_access,
require_role,
) )
from frigate.api.defs.query.media_query_parameters import ( from frigate.api.defs.query.media_query_parameters import (
Extension, Extension,
MediaEventsSnapshotQueryParams, MediaEventsSnapshotQueryParams,
MediaLatestFrameQueryParams, MediaLatestFrameQueryParams,
MediaMjpegFeedQueryParams, MediaMjpegFeedQueryParams,
MediaRecordingsAvailabilityQueryParams,
MediaRecordingsSummaryQueryParams,
) )
from frigate.api.defs.tags import Tags from frigate.api.defs.tags import Tags
from frigate.camera.state import CameraState from frigate.camera.state import CameraState
from frigate.config import FrigateConfig from frigate.config import FrigateConfig
from frigate.config.camera.snapshots import SnapshotsConfig
from frigate.const import ( from frigate.const import (
CACHE_DIR, CACHE_DIR,
CLIPS_DIR,
INSTALL_DIR, INSTALL_DIR,
MAX_SEGMENT_DURATION, MAX_SEGMENT_DURATION,
PREVIEW_FRAME_TYPE, PREVIEW_FRAME_TYPE,
RECORD_DIR,
) )
from frigate.models import Event, Previews, Recordings, Regions, ReviewSegment from frigate.models import Event, Previews, Recordings, Regions, ReviewSegment
from frigate.output.preview import get_most_recent_preview_frame
from frigate.track.object_processing import TrackedObjectProcessor from frigate.track.object_processing import TrackedObjectProcessor
from frigate.util.file import ( from frigate.util.file import get_event_thumbnail_bytes
get_event_snapshot_bytes, from frigate.util.image import get_image_from_recording
get_event_snapshot_path,
get_event_thumbnail_bytes,
load_event_snapshot_image,
)
from frigate.util.image import get_image_from_recording, get_image_quality_params
from frigate.util.media import get_keyframe_before from frigate.util.media import get_keyframe_before
from frigate.util.time import get_dst_transitions
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -60,6 +59,19 @@ logger = logging.getLogger(__name__)
router = APIRouter(tags=[Tags.media]) router = APIRouter(tags=[Tags.media])
def _resolve_cache_age(max_cache_age: int) -> int:
"""Return max_cache_age as an int.
When a media handler is invoked directly by another handler instead of
through its route, FastAPI doesn't resolve the Query() default and
max_cache_age arrives as the Query object; fall back to its int default.
"""
if isinstance(max_cache_age, int):
return max_cache_age
return max_cache_age.default
@router.get("/{camera_name}", dependencies=[Depends(require_camera_access)]) @router.get("/{camera_name}", dependencies=[Depends(require_camera_access)])
async def mjpeg_feed( async def mjpeg_feed(
request: Request, request: Request,
@@ -117,24 +129,6 @@ def imagestream(
) )
def _resolve_snapshot_settings(
snapshot_config: SnapshotsConfig, params: MediaEventsSnapshotQueryParams
) -> dict[str, Any]:
return {
"timestamp": snapshot_config.timestamp
if params.timestamp is None
else bool(params.timestamp),
"bounding_box": snapshot_config.bounding_box
if params.bbox is None
else bool(params.bbox),
"crop": snapshot_config.crop if params.crop is None else bool(params.crop),
"height": snapshot_config.height if params.height is None else params.height,
"quality": snapshot_config.quality
if params.quality is None
else params.quality,
}
@router.get("/{camera_name}/ptz/info", dependencies=[Depends(require_camera_access)]) @router.get("/{camera_name}/ptz/info", dependencies=[Depends(require_camera_access)])
async def camera_ptz_info(request: Request, camera_name: str): async def camera_ptz_info(request: Request, camera_name: str):
if camera_name in request.app.frigate_config.cameras: if camera_name in request.app.frigate_config.cameras:
@@ -152,9 +146,7 @@ async def camera_ptz_info(request: Request, camera_name: str):
@router.get( @router.get(
"/{camera_name}/latest.{extension}", "/{camera_name}/latest.{extension}", dependencies=[Depends(require_camera_access)]
dependencies=[Depends(require_camera_access)],
description="Returns the latest frame from the specified camera in the requested format (jpg, png, webp). Falls back to preview frames if the camera is offline.",
) )
async def latest_frame( async def latest_frame(
request: Request, request: Request,
@@ -172,7 +164,14 @@ async def latest_frame(
"paths": params.paths, "paths": params.paths,
"regions": params.regions, "regions": params.regions,
} }
quality_params = get_image_quality_params(extension.value, params.quality) quality = params.quality
if extension == Extension.png:
quality_params = None
elif extension == Extension.webp:
quality_params = [int(cv2.IMWRITE_WEBP_QUALITY), quality]
else: # jpg or jpeg
quality_params = [int(cv2.IMWRITE_JPEG_QUALITY), quality]
if camera_name in request.app.frigate_config.cameras: if camera_name in request.app.frigate_config.cameras:
frame = frame_processor.get_current_frame(camera_name, draw_options) frame = frame_processor.get_current_frame(camera_name, draw_options)
@@ -181,37 +180,20 @@ async def latest_frame(
or 10 or 10
) )
is_offline = False
if frame is None or datetime.now().timestamp() > ( if frame is None or datetime.now().timestamp() > (
frame_processor.get_current_frame_time(camera_name) + retry_interval frame_processor.get_current_frame_time(camera_name) + retry_interval
): ):
last_frame_time = frame_processor.get_current_frame_time(camera_name) if request.app.camera_error_image is None:
preview_path = get_most_recent_preview_frame( error_image = glob.glob(
camera_name, before=last_frame_time os.path.join(INSTALL_DIR, "frigate/images/camera-error.jpg")
)
if preview_path:
logger.debug(f"Using most recent preview frame for {camera_name}")
frame = cv2.imread(preview_path, cv2.IMREAD_UNCHANGED)
if frame is not None:
is_offline = True
if frame is None or not is_offline:
logger.debug(
f"No live or preview frame available for {camera_name}. Using error image."
) )
if request.app.camera_error_image is None:
error_image = glob.glob( if len(error_image) > 0:
os.path.join(INSTALL_DIR, "frigate/images/camera-error.jpg") request.app.camera_error_image = cv2.imread(
error_image[0], cv2.IMREAD_UNCHANGED
) )
if len(error_image) > 0: frame = request.app.camera_error_image
request.app.camera_error_image = cv2.imread(
error_image[0], cv2.IMREAD_UNCHANGED
)
frame = request.app.camera_error_image
height = int(params.height or str(frame.shape[0])) height = int(params.height or str(frame.shape[0]))
width = int(height * frame.shape[1] / frame.shape[0]) width = int(height * frame.shape[1] / frame.shape[0])
@@ -233,18 +215,14 @@ async def latest_frame(
frame = cv2.resize(frame, dsize=(width, height), interpolation=cv2.INTER_AREA) frame = cv2.resize(frame, dsize=(width, height), interpolation=cv2.INTER_AREA)
_, img = cv2.imencode(f".{extension.value}", frame, quality_params) _, img = cv2.imencode(f".{extension.value}", frame, quality_params)
headers = {
"Cache-Control": "no-store" if not params.store else "private, max-age=60",
}
if is_offline:
headers["X-Frigate-Offline"] = "true"
return Response( return Response(
content=img.tobytes(), content=img.tobytes(),
media_type=extension.get_mime_type(), media_type=extension.get_mime_type(),
headers=headers, headers={
"Cache-Control": "no-store"
if not params.store
else "private, max-age=60",
},
) )
elif ( elif (
camera_name == "birdseye" camera_name == "birdseye"
@@ -415,7 +393,9 @@ async def submit_recording_snapshot_to_plus(
) )
nd = cv2.imdecode(np.frombuffer(image_data, dtype=np.int8), cv2.IMREAD_COLOR) nd = cv2.imdecode(np.frombuffer(image_data, dtype=np.int8), cv2.IMREAD_COLOR)
request.app.frigate_config.plus_api.upload_image(nd, camera_name) await asyncio.to_thread(
request.app.frigate_config.plus_api.upload_image, nd, camera_name
)
return JSONResponse( return JSONResponse(
content={ content={
@@ -434,6 +414,333 @@ async def submit_recording_snapshot_to_plus(
) )
@router.get("/recordings/storage", dependencies=[Depends(allow_any_authenticated())])
def get_recordings_storage_usage(request: Request):
recording_stats = request.app.stats_emitter.get_latest_stats()["service"][
"storage"
][RECORD_DIR]
if not recording_stats:
return JSONResponse({})
total_mb = recording_stats["total"]
camera_usages: dict[str, dict] = (
request.app.storage_maintainer.calculate_camera_usages()
)
for camera_name in camera_usages.keys():
if camera_usages.get(camera_name, {}).get("usage"):
camera_usages[camera_name]["usage_percent"] = (
camera_usages.get(camera_name, {}).get("usage", 0) / total_mb
) * 100
return JSONResponse(content=camera_usages)
@router.get("/recordings/summary", dependencies=[Depends(allow_any_authenticated())])
def all_recordings_summary(
request: Request,
params: MediaRecordingsSummaryQueryParams = Depends(),
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
):
"""Returns true/false by day indicating if recordings exist"""
cameras = params.cameras
if cameras != "all":
requested = set(unquote(cameras).split(","))
filtered = requested.intersection(allowed_cameras)
if not filtered:
return JSONResponse(content={})
camera_list = list(filtered)
else:
camera_list = allowed_cameras
time_range_query = (
Recordings.select(
fn.MIN(Recordings.start_time).alias("min_time"),
fn.MAX(Recordings.start_time).alias("max_time"),
)
.where(Recordings.camera << camera_list)
.dicts()
.get()
)
min_time = time_range_query.get("min_time")
max_time = time_range_query.get("max_time")
if min_time is None or max_time is None:
return JSONResponse(content={})
dst_periods = get_dst_transitions(params.timezone, min_time, max_time)
days: dict[str, bool] = {}
for period_start, period_end, period_offset in dst_periods:
hours_offset = int(period_offset / 60 / 60)
minutes_offset = int(period_offset / 60 - hours_offset * 60)
period_hour_modifier = f"{hours_offset} hour"
period_minute_modifier = f"{minutes_offset} minute"
period_query = (
Recordings.select(
fn.strftime(
"%Y-%m-%d",
fn.datetime(
Recordings.start_time,
"unixepoch",
period_hour_modifier,
period_minute_modifier,
),
).alias("day")
)
.where(
(Recordings.camera << camera_list)
& (Recordings.end_time >= period_start)
& (Recordings.start_time <= period_end)
)
.group_by(
fn.strftime(
"%Y-%m-%d",
fn.datetime(
Recordings.start_time,
"unixepoch",
period_hour_modifier,
period_minute_modifier,
),
)
)
.order_by(Recordings.start_time.desc())
.namedtuples()
)
for g in period_query:
days[g.day] = True
return JSONResponse(content=dict(sorted(days.items())))
@router.get(
"/{camera_name}/recordings/summary", dependencies=[Depends(require_camera_access)]
)
async def recordings_summary(camera_name: str, timezone: str = "utc"):
"""Returns hourly summary for recordings of given camera"""
time_range_query = (
Recordings.select(
fn.MIN(Recordings.start_time).alias("min_time"),
fn.MAX(Recordings.start_time).alias("max_time"),
)
.where(Recordings.camera == camera_name)
.dicts()
.get()
)
min_time = time_range_query.get("min_time")
max_time = time_range_query.get("max_time")
days: dict[str, dict] = {}
if min_time is None or max_time is None:
return JSONResponse(content=list(days.values()))
dst_periods = get_dst_transitions(timezone, min_time, max_time)
for period_start, period_end, period_offset in dst_periods:
hours_offset = int(period_offset / 60 / 60)
minutes_offset = int(period_offset / 60 - hours_offset * 60)
period_hour_modifier = f"{hours_offset} hour"
period_minute_modifier = f"{minutes_offset} minute"
recording_groups = (
Recordings.select(
fn.strftime(
"%Y-%m-%d %H",
fn.datetime(
Recordings.start_time,
"unixepoch",
period_hour_modifier,
period_minute_modifier,
),
).alias("hour"),
fn.SUM(Recordings.duration).alias("duration"),
fn.SUM(Recordings.motion).alias("motion"),
fn.SUM(Recordings.objects).alias("objects"),
)
.where(
(Recordings.camera == camera_name)
& (Recordings.end_time >= period_start)
& (Recordings.start_time <= period_end)
)
.group_by((Recordings.start_time + period_offset).cast("int") / 3600)
.order_by(Recordings.start_time.desc())
.namedtuples()
)
event_groups = (
Event.select(
fn.strftime(
"%Y-%m-%d %H",
fn.datetime(
Event.start_time,
"unixepoch",
period_hour_modifier,
period_minute_modifier,
),
).alias("hour"),
fn.COUNT(Event.id).alias("count"),
)
.where(Event.camera == camera_name, Event.has_clip)
.where(
(Event.start_time >= period_start) & (Event.start_time <= period_end)
)
.group_by((Event.start_time + period_offset).cast("int") / 3600)
.namedtuples()
)
event_map = {g.hour: g.count for g in event_groups}
for recording_group in recording_groups:
parts = recording_group.hour.split()
hour = parts[1]
day = parts[0]
events_count = event_map.get(recording_group.hour, 0)
hour_data = {
"hour": hour,
"events": events_count,
"motion": recording_group.motion,
"objects": recording_group.objects,
"duration": round(recording_group.duration),
}
if day in days:
# merge counts if already present (edge-case at DST boundary)
days[day]["events"] += events_count or 0
days[day]["hours"].append(hour_data)
else:
days[day] = {
"events": events_count or 0,
"hours": [hour_data],
"day": day,
}
return JSONResponse(content=list(days.values()))
@router.get("/{camera_name}/recordings", dependencies=[Depends(require_camera_access)])
async def recordings(
camera_name: str,
after: float = (datetime.now() - timedelta(hours=1)).timestamp(),
before: float = datetime.now().timestamp(),
):
"""Return specific camera recordings between the given 'after'/'end' times. If not provided the last hour will be used"""
recordings = (
Recordings.select(
Recordings.id,
Recordings.start_time,
Recordings.end_time,
Recordings.segment_size,
Recordings.motion,
Recordings.objects,
Recordings.duration,
)
.where(
Recordings.camera == camera_name,
Recordings.end_time >= after,
Recordings.start_time <= before,
)
.order_by(Recordings.start_time)
.dicts()
.iterator()
)
return JSONResponse(content=list(recordings))
@router.get(
"/recordings/unavailable",
response_model=list[dict],
dependencies=[Depends(allow_any_authenticated())],
)
async def no_recordings(
request: Request,
params: MediaRecordingsAvailabilityQueryParams = Depends(),
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
):
"""Get time ranges with no recordings."""
cameras = params.cameras
if cameras != "all":
requested = set(unquote(cameras).split(","))
filtered = requested.intersection(allowed_cameras)
if not filtered:
return JSONResponse(content=[])
cameras = ",".join(filtered)
else:
cameras = allowed_cameras
before = params.before or datetime.datetime.now().timestamp()
after = (
params.after
or (datetime.datetime.now() - datetime.timedelta(hours=1)).timestamp()
)
scale = params.scale
clauses = [(Recordings.end_time >= after) & (Recordings.start_time <= before)]
if cameras != "all":
camera_list = cameras.split(",")
clauses.append((Recordings.camera << camera_list))
else:
camera_list = allowed_cameras
# Get recording start times
data: list[Recordings] = (
Recordings.select(Recordings.start_time, Recordings.end_time)
.where(reduce(operator.and_, clauses))
.order_by(Recordings.start_time.asc())
.dicts()
.iterator()
)
# Convert recordings to list of (start, end) tuples
recordings = [(r["start_time"], r["end_time"]) for r in data]
# Iterate through time segments and check if each has any recording
no_recording_segments = []
current = after
current_gap_start = None
while current < before:
segment_end = min(current + scale, before)
# Check if this segment overlaps with any recording
has_recording = any(
rec_start < segment_end and rec_end > current
for rec_start, rec_end in recordings
)
if not has_recording:
# This segment has no recordings
if current_gap_start is None:
current_gap_start = current # Start a new gap
else:
# This segment has recordings
if current_gap_start is not None:
# End the current gap and append it
no_recording_segments.append(
{"start_time": int(current_gap_start), "end_time": int(current)}
)
current_gap_start = None
current = segment_end
# Append the last gap if it exists
if current_gap_start is not None:
no_recording_segments.append(
{"start_time": int(current_gap_start), "end_time": int(before)}
)
return JSONResponse(content=no_recording_segments)
@router.get( @router.get(
"/{camera_name}/start/{start_ts}/end/{end_ts}/clip.mp4", "/{camera_name}/start/{start_ts}/end/{end_ts}/clip.mp4",
dependencies=[Depends(require_camera_access)], dependencies=[Depends(require_camera_access)],
@@ -774,7 +1081,7 @@ async def vod_clip(
@router.get( @router.get(
"/events/{event_id}/snapshot.jpg", "/events/{event_id}/snapshot.jpg",
description="Returns a snapshot image for the specified object id.", description="Returns a snapshot image for the specified object id. NOTE: The query params only take affect while the event is in-progress. Once the event has ended the snapshot configuration is used.",
) )
async def event_snapshot( async def event_snapshot(
request: Request, request: Request,
@@ -783,7 +1090,6 @@ async def event_snapshot(
): ):
event_complete = False event_complete = False
jpg_bytes = None jpg_bytes = None
frame_time = 0
try: try:
event = Event.get(Event.id == event_id, Event.end_time != None) event = Event.get(Event.id == event_id, Event.end_time != None)
event_complete = True event_complete = True
@@ -793,22 +1099,11 @@ async def event_snapshot(
content={"success": False, "message": "Snapshot not available"}, content={"success": False, "message": "Snapshot not available"},
status_code=404, status_code=404,
) )
snapshot_settings = _resolve_snapshot_settings( # read snapshot from disk
request.app.frigate_config.cameras[event.camera].snapshots, params with open(
) os.path.join(CLIPS_DIR, f"{event.camera}-{event.id}.jpg"), "rb"
jpg_bytes, frame_time = get_event_snapshot_bytes( ) as image_file:
event, jpg_bytes = image_file.read()
ext="jpg",
timestamp=snapshot_settings["timestamp"],
bounding_box=snapshot_settings["bounding_box"],
crop=snapshot_settings["crop"],
height=snapshot_settings["height"],
quality=snapshot_settings["quality"],
timestamp_style=request.app.frigate_config.cameras[
event.camera
].timestamp_style,
colormap=request.app.frigate_config.model.colormap,
)
except DoesNotExist: except DoesNotExist:
# see if the object is currently being tracked # see if the object is currently being tracked
try: try:
@@ -819,16 +1114,13 @@ async def event_snapshot(
if event_id in camera_state.tracked_objects: if event_id in camera_state.tracked_objects:
tracked_obj = camera_state.tracked_objects.get(event_id) tracked_obj = camera_state.tracked_objects.get(event_id)
if tracked_obj is not None: if tracked_obj is not None:
snapshot_settings = _resolve_snapshot_settings( jpg_bytes = tracked_obj.get_img_bytes(
camera_state.camera_config.snapshots, params
)
jpg_bytes, frame_time = tracked_obj.get_img_bytes(
ext="jpg", ext="jpg",
timestamp=snapshot_settings["timestamp"], timestamp=params.timestamp,
bounding_box=snapshot_settings["bounding_box"], bounding_box=params.bbox,
crop=snapshot_settings["crop"], crop=params.crop,
height=snapshot_settings["height"], height=params.height,
quality=snapshot_settings["quality"], quality=params.quality,
) )
await require_camera_access(camera_state.name, request=request) await require_camera_access(camera_state.name, request=request)
except Exception: except Exception:
@@ -851,7 +1143,6 @@ async def event_snapshot(
headers = { headers = {
"Content-Type": "image/jpeg", "Content-Type": "image/jpeg",
"Cache-Control": "private, max-age=31536000" if event_complete else "no-store", "Cache-Control": "private, max-age=31536000" if event_complete else "no-store",
"X-Frame-Time": str(frame_time),
} }
if params.download: if params.download:
@@ -937,7 +1228,7 @@ async def event_thumbnail(
thumbnail_bytes, thumbnail_bytes,
media_type=extension.get_mime_type(), media_type=extension.get_mime_type(),
headers={ headers={
"Cache-Control": f"private, max-age={max_cache_age}" "Cache-Control": f"private, max-age={_resolve_cache_age(max_cache_age)}"
if event_complete if event_complete
else "no-store", else "no-store",
}, },
@@ -1065,39 +1356,20 @@ def grid_snapshot(
) )
@router.delete(
"/{camera_name}/region_grid", dependencies=[Depends(require_role("admin"))]
)
def clear_region_grid(request: Request, camera_name: str):
"""Clear the region grid for a camera."""
if camera_name not in request.app.frigate_config.cameras:
return JSONResponse(
content={"success": False, "message": "Camera not found"},
status_code=404,
)
Regions.delete().where(Regions.camera == camera_name).execute()
return JSONResponse(
content={"success": True, "message": "Region grid cleared"},
)
@router.get( @router.get(
"/events/{event_id}/snapshot-clean.webp", "/events/{event_id}/snapshot-clean.webp",
) )
async def event_snapshot_clean(request: Request, event_id: str, download: bool = False): async def event_snapshot_clean(request: Request, event_id: str, download: bool = False):
webp_bytes = None webp_bytes = None
event_complete = False
try: try:
event = Event.get(Event.id == event_id) event = Event.get(Event.id == event_id)
event_complete = event.end_time is not None
await require_camera_access(event.camera, request=request) await require_camera_access(event.camera, request=request)
snapshot_config = request.app.frigate_config.cameras[event.camera].snapshots snapshot_config = request.app.frigate_config.cameras[event.camera].snapshots
if not (snapshot_config.enabled and event.has_snapshot): if not (snapshot_config.enabled and event.has_snapshot):
return JSONResponse( return JSONResponse(
content={ content={
"success": False, "success": False,
"message": "Snapshots must be enabled in the config", "message": "Snapshots and clean_copy must be enabled in the config",
}, },
status_code=404, status_code=404,
) )
@@ -1129,10 +1401,54 @@ async def event_snapshot_clean(request: Request, event_id: str, download: bool =
) )
if webp_bytes is None: if webp_bytes is None:
try: try:
image_path, is_clean_snapshot = get_event_snapshot_path( # webp
event, clean_only=True clean_snapshot_path_webp = os.path.join(
CLIPS_DIR, f"{event.camera}-{event.id}-clean.webp"
) )
if not is_clean_snapshot or image_path is None: # png (legacy)
clean_snapshot_path_png = os.path.join(
CLIPS_DIR, f"{event.camera}-{event.id}-clean.png"
)
if os.path.exists(clean_snapshot_path_webp):
with open(clean_snapshot_path_webp, "rb") as image_file:
webp_bytes = image_file.read()
elif os.path.exists(clean_snapshot_path_png):
# convert png to webp and save for future use
png_image = cv2.imread(clean_snapshot_path_png, cv2.IMREAD_UNCHANGED)
if png_image is None:
return JSONResponse(
content={
"success": False,
"message": "Invalid png snapshot",
},
status_code=400,
)
ret, webp_data = cv2.imencode(
".webp", png_image, [int(cv2.IMWRITE_WEBP_QUALITY), 60]
)
if not ret:
return JSONResponse(
content={
"success": False,
"message": "Unable to convert png to webp",
},
status_code=400,
)
webp_bytes = webp_data.tobytes()
# save the converted webp for future requests
try:
with open(clean_snapshot_path_webp, "wb") as f:
f.write(webp_bytes)
except Exception as e:
logger.warning(
f"Failed to save converted webp for event {event.id}: {e}"
)
# continue since we now have the data to return
else:
return JSONResponse( return JSONResponse(
content={ content={
"success": False, "success": False,
@@ -1140,34 +1456,6 @@ async def event_snapshot_clean(request: Request, event_id: str, download: bool =
}, },
status_code=404, status_code=404,
) )
if image_path.endswith(".webp"):
with open(image_path, "rb") as image_file:
webp_bytes = image_file.read()
else:
image = load_event_snapshot_image(event, clean_only=True)[0]
if image is None:
return JSONResponse(
content={
"success": False,
"message": "Unable to load clean snapshot for event",
},
status_code=400,
)
ret, webp_data = cv2.imencode(
".webp", image, get_image_quality_params("webp", None)
)
if not ret:
return JSONResponse(
content={
"success": False,
"message": "Unable to convert snapshot to webp",
},
status_code=400,
)
webp_bytes = webp_data.tobytes()
except Exception: except Exception:
logger.error(f"Unable to load clean snapshot for event: {event.id}") logger.error(f"Unable to load clean snapshot for event: {event.id}")
return JSONResponse( return JSONResponse(
@@ -1180,7 +1468,7 @@ async def event_snapshot_clean(request: Request, event_id: str, download: bool =
headers = { headers = {
"Content-Type": "image/webp", "Content-Type": "image/webp",
"Cache-Control": "private, max-age=31536000" if event_complete else "no-cache", "Cache-Control": "private, max-age=31536000",
} }
if download: if download:
@@ -1227,33 +1515,6 @@ async def event_clip(
) )
@router.get(
"/review/{review_id}/clip.mp4",
)
async def review_clip(
request: Request,
review_id: str,
padding: int = Query(0, description="Padding to apply to clip."),
):
try:
review: ReviewSegment = ReviewSegment.get(ReviewSegment.id == review_id)
except DoesNotExist:
return JSONResponse(
content={"success": False, "message": "Review not found"}, status_code=404
)
await require_camera_access(review.camera, request=request)
end_ts = (
datetime.now().timestamp()
if review.end_time is None
else review.end_time + padding
)
return await recording_clip(
request, review.camera, review.start_time - padding, end_ts
)
@router.get( @router.get(
"/events/{event_id}/preview.gif", "/events/{event_id}/preview.gif",
) )
@@ -1271,14 +1532,14 @@ async def event_preview(request: Request, event_id: str):
end_ts = start_ts + ( end_ts = start_ts + (
min(event.end_time - event.start_time, 20) if event.end_time else 20 min(event.end_time - event.start_time, 20) if event.end_time else 20
) )
return preview_gif(request, event.camera, start_ts, end_ts) return await preview_gif(request, event.camera, start_ts, end_ts)
@router.get( @router.get(
"/{camera_name}/start/{start_ts}/end/{end_ts}/preview.gif", "/{camera_name}/start/{start_ts}/end/{end_ts}/preview.gif",
dependencies=[Depends(require_camera_access)], dependencies=[Depends(require_camera_access)],
) )
def preview_gif( async def preview_gif(
request: Request, request: Request,
camera_name: str, camera_name: str,
start_ts: float, start_ts: float,
@@ -1341,7 +1602,8 @@ def preview_gif(
"-", "-",
] ]
process = sp.run( process = await asyncio.to_thread(
sp.run,
ffmpeg_cmd, ffmpeg_cmd,
capture_output=True, capture_output=True,
) )
@@ -1357,16 +1619,9 @@ def preview_gif(
else: else:
# need to generate from existing images # need to generate from existing images
preview_dir = os.path.join(CACHE_DIR, "preview_frames") preview_dir = os.path.join(CACHE_DIR, "preview_frames")
file_start = f"preview_{camera_name}"
if not os.path.isdir(preview_dir): start_file = f"{file_start}-{start_ts}.{PREVIEW_FRAME_TYPE}"
return JSONResponse( end_file = f"{file_start}-{end_ts}.{PREVIEW_FRAME_TYPE}"
content={"success": False, "message": "Preview not found"},
status_code=404,
)
file_start = f"preview_{camera_name}-"
start_file = f"{file_start}{start_ts}.{PREVIEW_FRAME_TYPE}"
end_file = f"{file_start}{end_ts}.{PREVIEW_FRAME_TYPE}"
selected_previews = [] selected_previews = []
for file in sorted(os.listdir(preview_dir)): for file in sorted(os.listdir(preview_dir)):
@@ -1415,7 +1670,8 @@ def preview_gif(
"-", "-",
] ]
process = sp.run( process = await asyncio.to_thread(
sp.run,
ffmpeg_cmd, ffmpeg_cmd,
input=str.encode("\n".join(selected_previews)), input=str.encode("\n".join(selected_previews)),
capture_output=True, capture_output=True,
@@ -1434,7 +1690,7 @@ def preview_gif(
gif_bytes, gif_bytes,
media_type="image/gif", media_type="image/gif",
headers={ headers={
"Cache-Control": f"private, max-age={max_cache_age}", "Cache-Control": f"private, max-age={_resolve_cache_age(max_cache_age)}",
"Content-Type": "image/gif", "Content-Type": "image/gif",
}, },
) )
@@ -1444,7 +1700,7 @@ def preview_gif(
"/{camera_name}/start/{start_ts}/end/{end_ts}/preview.mp4", "/{camera_name}/start/{start_ts}/end/{end_ts}/preview.mp4",
dependencies=[Depends(require_camera_access)], dependencies=[Depends(require_camera_access)],
) )
def preview_mp4( async def preview_mp4(
request: Request, request: Request,
camera_name: str, camera_name: str,
start_ts: float, start_ts: float,
@@ -1524,7 +1780,8 @@ def preview_mp4(
path, path,
] ]
process = sp.run( process = await asyncio.to_thread(
sp.run,
ffmpeg_cmd, ffmpeg_cmd,
capture_output=True, capture_output=True,
) )
@@ -1539,16 +1796,9 @@ def preview_mp4(
else: else:
# need to generate from existing images # need to generate from existing images
preview_dir = os.path.join(CACHE_DIR, "preview_frames") preview_dir = os.path.join(CACHE_DIR, "preview_frames")
file_start = f"preview_{camera_name}"
if not os.path.isdir(preview_dir): start_file = f"{file_start}-{start_ts}.{PREVIEW_FRAME_TYPE}"
return JSONResponse( end_file = f"{file_start}-{end_ts}.{PREVIEW_FRAME_TYPE}"
content={"success": False, "message": "Preview not found"},
status_code=404,
)
file_start = f"preview_{camera_name}-"
start_file = f"{file_start}{start_ts}.{PREVIEW_FRAME_TYPE}"
end_file = f"{file_start}{end_ts}.{PREVIEW_FRAME_TYPE}"
selected_previews = [] selected_previews = []
for file in sorted(os.listdir(preview_dir)): for file in sorted(os.listdir(preview_dir)):
@@ -1595,7 +1845,8 @@ def preview_mp4(
path, path,
] ]
process = sp.run( process = await asyncio.to_thread(
sp.run,
ffmpeg_cmd, ffmpeg_cmd,
input=str.encode("\n".join(selected_previews)), input=str.encode("\n".join(selected_previews)),
capture_output=True, capture_output=True,
@@ -1610,7 +1861,7 @@ def preview_mp4(
headers = { headers = {
"Content-Description": "File Transfer", "Content-Description": "File Transfer",
"Cache-Control": f"private, max-age={max_cache_age}", "Cache-Control": f"private, max-age={_resolve_cache_age(max_cache_age)}",
"Content-Type": "video/mp4", "Content-Type": "video/mp4",
"Content-Length": str(os.path.getsize(path)), "Content-Length": str(os.path.getsize(path)),
# nginx: https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_ignore_headers # nginx: https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_ignore_headers
@@ -1648,9 +1899,9 @@ async def review_preview(
) )
if format == "gif": if format == "gif":
return preview_gif(request, review.camera, start_ts, end_ts) return await preview_gif(request, review.camera, start_ts, end_ts)
else: else:
return preview_mp4(request, review.camera, start_ts, end_ts) return await preview_mp4(request, review.camera, start_ts, end_ts)
@router.get( @router.get(
-292
View File
@@ -1,292 +0,0 @@
"""Motion search API for detecting changes within a region of interest."""
import logging
from typing import Any, List, Optional
from fastapi import APIRouter, Depends, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from frigate.api.auth import require_camera_access
from frigate.api.defs.tags import Tags
from frigate.jobs.motion_search import (
cancel_motion_search_job,
get_motion_search_job,
start_motion_search_job,
)
from frigate.types import JobStatusTypesEnum
logger = logging.getLogger(__name__)
router = APIRouter(tags=[Tags.motion_search])
class MotionSearchRequest(BaseModel):
"""Request body for motion search."""
start_time: float = Field(description="Start timestamp for the search range")
end_time: float = Field(description="End timestamp for the search range")
polygon_points: List[List[float]] = Field(
description="List of [x, y] normalized coordinates (0-1) defining the ROI polygon"
)
threshold: int = Field(
default=30,
ge=1,
le=255,
description="Pixel difference threshold (1-255)",
)
min_area: float = Field(
default=5.0,
ge=0.1,
le=100.0,
description="Minimum change area as a percentage of the ROI",
)
frame_skip: int = Field(
default=5,
ge=1,
le=30,
description="Process every Nth frame (1=all frames, 5=every 5th frame)",
)
parallel: bool = Field(
default=False,
description="Enable parallel scanning across segments",
)
max_results: int = Field(
default=25,
ge=1,
le=200,
description="Maximum number of search results to return",
)
class MotionSearchResult(BaseModel):
"""A single search result with timestamp and change info."""
timestamp: float = Field(description="Timestamp where change was detected")
change_percentage: float = Field(description="Percentage of ROI area that changed")
class MotionSearchMetricsResponse(BaseModel):
"""Metrics collected during motion search execution."""
segments_scanned: int = 0
segments_processed: int = 0
metadata_inactive_segments: int = 0
heatmap_roi_skip_segments: int = 0
fallback_full_range_segments: int = 0
frames_decoded: int = 0
wall_time_seconds: float = 0.0
segments_with_errors: int = 0
class MotionSearchStartResponse(BaseModel):
"""Response when motion search job starts."""
success: bool
message: str
job_id: str
class MotionSearchStatusResponse(BaseModel):
"""Response containing job status and results."""
success: bool
message: str
status: str # "queued", "running", "success", "failed", or "cancelled"
results: Optional[List[MotionSearchResult]] = None
total_frames_processed: Optional[int] = None
error_message: Optional[str] = None
metrics: Optional[MotionSearchMetricsResponse] = None
@router.post(
"/{camera_name}/search/motion",
response_model=MotionSearchStartResponse,
dependencies=[Depends(require_camera_access)],
summary="Start motion search job",
description="""Starts an asynchronous search for significant motion changes within
a user-defined Region of Interest (ROI) over a specified time range. Returns a job_id
that can be used to poll for results.""",
)
async def start_motion_search(
request: Request,
camera_name: str,
body: MotionSearchRequest,
):
"""Start an async motion search job."""
config = request.app.frigate_config
if camera_name not in config.cameras:
return JSONResponse(
content={"success": False, "message": f"Camera {camera_name} not found"},
status_code=404,
)
# Validate polygon has at least 3 points
if len(body.polygon_points) < 3:
return JSONResponse(
content={
"success": False,
"message": "Polygon must have at least 3 points",
},
status_code=400,
)
# Validate time range
if body.start_time >= body.end_time:
return JSONResponse(
content={
"success": False,
"message": "Start time must be before end time",
},
status_code=400,
)
# Start the job using the jobs module
job_id = start_motion_search_job(
config=config,
camera_name=camera_name,
start_time=body.start_time,
end_time=body.end_time,
polygon_points=body.polygon_points,
threshold=body.threshold,
min_area=body.min_area,
frame_skip=body.frame_skip,
parallel=body.parallel,
max_results=body.max_results,
)
return JSONResponse(
content={
"success": True,
"message": "Search job started",
"job_id": job_id,
}
)
@router.get(
"/{camera_name}/search/motion/{job_id}",
response_model=MotionSearchStatusResponse,
dependencies=[Depends(require_camera_access)],
summary="Get motion search job status",
description="Returns the status and results (if complete) of a motion search job.",
)
async def get_motion_search_status_endpoint(
request: Request,
camera_name: str,
job_id: str,
):
"""Get the status of a motion search job."""
config = request.app.frigate_config
if camera_name not in config.cameras:
return JSONResponse(
content={"success": False, "message": f"Camera {camera_name} not found"},
status_code=404,
)
job = get_motion_search_job(job_id)
if not job:
return JSONResponse(
content={"success": False, "message": "Job not found"},
status_code=404,
)
api_status = job.status
# Build response content
response_content: dict[str, Any] = {
"success": api_status != JobStatusTypesEnum.failed,
"status": api_status,
}
if api_status == JobStatusTypesEnum.failed:
response_content["message"] = job.error_message or "Search failed"
response_content["error_message"] = job.error_message
elif api_status == JobStatusTypesEnum.cancelled:
response_content["message"] = "Search cancelled"
response_content["total_frames_processed"] = job.total_frames_processed
elif api_status == JobStatusTypesEnum.success:
response_content["message"] = "Search complete"
if job.results:
response_content["results"] = job.results.get("results", [])
response_content["total_frames_processed"] = job.results.get(
"total_frames_processed", job.total_frames_processed
)
else:
response_content["results"] = []
response_content["total_frames_processed"] = job.total_frames_processed
else:
response_content["message"] = "Job processing"
response_content["total_frames_processed"] = job.total_frames_processed
# Include partial results if available (streaming)
if job.results:
response_content["results"] = job.results.get("results", [])
response_content["total_frames_processed"] = job.results.get(
"total_frames_processed", job.total_frames_processed
)
# Include metrics if available
if job.metrics:
response_content["metrics"] = job.metrics.to_dict()
return JSONResponse(content=response_content)
@router.post(
"/{camera_name}/search/motion/{job_id}/cancel",
dependencies=[Depends(require_camera_access)],
summary="Cancel motion search job",
description="Cancels an active motion search job if it is still processing.",
)
async def cancel_motion_search_endpoint(
request: Request,
camera_name: str,
job_id: str,
):
"""Cancel an active motion search job."""
config = request.app.frigate_config
if camera_name not in config.cameras:
return JSONResponse(
content={"success": False, "message": f"Camera {camera_name} not found"},
status_code=404,
)
job = get_motion_search_job(job_id)
if not job:
return JSONResponse(
content={"success": False, "message": "Job not found"},
status_code=404,
)
# Check if already finished
api_status = job.status
if api_status not in (JobStatusTypesEnum.queued, JobStatusTypesEnum.running):
return JSONResponse(
content={
"success": True,
"message": "Job already finished",
"status": api_status,
}
)
# Request cancellation
cancelled = cancel_motion_search_job(job_id)
if cancelled:
return JSONResponse(
content={
"success": True,
"message": "Search cancelled",
"status": "cancelled",
}
)
return JSONResponse(
content={
"success": False,
"message": "Failed to cancel job",
},
status_code=500,
)
+3 -3
View File
@@ -145,9 +145,9 @@ def preview_hour(
def get_preview_frames_from_cache(camera_name: str, start_ts: float, end_ts: float): def get_preview_frames_from_cache(camera_name: str, start_ts: float, end_ts: float):
"""Get list of cached preview frames""" """Get list of cached preview frames"""
preview_dir = os.path.join(CACHE_DIR, "preview_frames") preview_dir = os.path.join(CACHE_DIR, "preview_frames")
file_start = f"preview_{camera_name}-" file_start = f"preview_{camera_name}"
start_file = f"{file_start}{start_ts}.{PREVIEW_FRAME_TYPE}" start_file = f"{file_start}-{start_ts}.{PREVIEW_FRAME_TYPE}"
end_file = f"{file_start}{end_ts}.{PREVIEW_FRAME_TYPE}" end_file = f"{file_start}-{end_ts}.{PREVIEW_FRAME_TYPE}"
selected_previews = [] selected_previews = []
for file in sorted(os.listdir(preview_dir)): for file in sorted(os.listdir(preview_dir)):
-458
View File
@@ -1,458 +0,0 @@
"""Recording APIs."""
import datetime as dt
import logging
from datetime import datetime, timedelta
from functools import reduce
from pathlib import Path
from typing import List
from urllib.parse import unquote
from fastapi import APIRouter, Depends, Request
from fastapi import Path as PathParam
from fastapi.responses import JSONResponse
from peewee import fn, operator
from frigate.api.auth import (
allow_any_authenticated,
get_allowed_cameras_for_filter,
require_camera_access,
require_role,
)
from frigate.api.defs.query.recordings_query_parameters import (
MediaRecordingsAvailabilityQueryParams,
MediaRecordingsSummaryQueryParams,
RecordingsDeleteQueryParams,
)
from frigate.api.defs.response.generic_response import GenericResponse
from frigate.api.defs.tags import Tags
from frigate.const import RECORD_DIR
from frigate.models import Event, Recordings
from frigate.util.time import get_dst_transitions
logger = logging.getLogger(__name__)
router = APIRouter(tags=[Tags.recordings])
@router.get("/recordings/storage", dependencies=[Depends(allow_any_authenticated())])
def get_recordings_storage_usage(request: Request):
recording_stats = request.app.stats_emitter.get_latest_stats()["service"][
"storage"
][RECORD_DIR]
if not recording_stats:
return JSONResponse({})
total_mb = recording_stats["total"]
camera_usages: dict[str, dict] = (
request.app.storage_maintainer.calculate_camera_usages()
)
for camera_name in camera_usages.keys():
if camera_usages.get(camera_name, {}).get("usage"):
camera_usages[camera_name]["usage_percent"] = (
camera_usages.get(camera_name, {}).get("usage", 0) / total_mb
) * 100
return JSONResponse(content=camera_usages)
@router.get("/recordings/summary", dependencies=[Depends(allow_any_authenticated())])
def all_recordings_summary(
request: Request,
params: MediaRecordingsSummaryQueryParams = Depends(),
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
):
"""Returns true/false by day indicating if recordings exist"""
cameras = params.cameras
if cameras != "all":
requested = set(unquote(cameras).split(","))
filtered = requested.intersection(allowed_cameras)
if not filtered:
return JSONResponse(content={})
camera_list = list(filtered)
else:
camera_list = allowed_cameras
time_range_query = (
Recordings.select(
fn.MIN(Recordings.start_time).alias("min_time"),
fn.MAX(Recordings.start_time).alias("max_time"),
)
.where(Recordings.camera << camera_list)
.dicts()
.get()
)
min_time = time_range_query.get("min_time")
max_time = time_range_query.get("max_time")
if min_time is None or max_time is None:
return JSONResponse(content={})
dst_periods = get_dst_transitions(params.timezone, min_time, max_time)
days: dict[str, bool] = {}
for period_start, period_end, period_offset in dst_periods:
day_expr = ((Recordings.start_time + period_offset) / 86400).cast("int")
period_query = (
Recordings.select(day_expr.alias("day_idx"))
.where(
(Recordings.camera << camera_list)
& (Recordings.end_time >= period_start)
& (Recordings.start_time <= period_end)
)
.distinct()
.namedtuples()
)
for g in period_query:
day_str = (dt.date(1970, 1, 1) + dt.timedelta(days=g.day_idx)).isoformat()
days[day_str] = True
return JSONResponse(content=dict(sorted(days.items())))
@router.get(
"/{camera_name}/recordings/summary", dependencies=[Depends(require_camera_access)]
)
async def recordings_summary(camera_name: str, timezone: str = "utc"):
"""Returns hourly summary for recordings of given camera"""
time_range_query = (
Recordings.select(
fn.MIN(Recordings.start_time).alias("min_time"),
fn.MAX(Recordings.start_time).alias("max_time"),
)
.where(Recordings.camera == camera_name)
.dicts()
.get()
)
min_time = time_range_query.get("min_time")
max_time = time_range_query.get("max_time")
days: dict[str, dict] = {}
if min_time is None or max_time is None:
return JSONResponse(content=list(days.values()))
dst_periods = get_dst_transitions(timezone, min_time, max_time)
for period_start, period_end, period_offset in dst_periods:
hours_offset = int(period_offset / 60 / 60)
minutes_offset = int(period_offset / 60 - hours_offset * 60)
period_hour_modifier = f"{hours_offset} hour"
period_minute_modifier = f"{minutes_offset} minute"
recording_groups = (
Recordings.select(
fn.strftime(
"%Y-%m-%d %H",
fn.datetime(
Recordings.start_time,
"unixepoch",
period_hour_modifier,
period_minute_modifier,
),
).alias("hour"),
fn.SUM(Recordings.duration).alias("duration"),
fn.SUM(Recordings.motion).alias("motion"),
fn.SUM(Recordings.objects).alias("objects"),
)
.where(
(Recordings.camera == camera_name)
& (Recordings.end_time >= period_start)
& (Recordings.start_time <= period_end)
)
.group_by((Recordings.start_time + period_offset).cast("int") / 3600)
.order_by(Recordings.start_time.desc())
.namedtuples()
)
event_groups = (
Event.select(
fn.strftime(
"%Y-%m-%d %H",
fn.datetime(
Event.start_time,
"unixepoch",
period_hour_modifier,
period_minute_modifier,
),
).alias("hour"),
fn.COUNT(Event.id).alias("count"),
)
.where(Event.camera == camera_name, Event.has_clip)
.where(
(Event.start_time >= period_start) & (Event.start_time <= period_end)
)
.group_by((Event.start_time + period_offset).cast("int") / 3600)
.namedtuples()
)
event_map = {g.hour: g.count for g in event_groups}
for recording_group in recording_groups:
parts = recording_group.hour.split()
hour = parts[1]
day = parts[0]
events_count = event_map.get(recording_group.hour, 0)
hour_data = {
"hour": hour,
"events": events_count,
"motion": recording_group.motion,
"objects": recording_group.objects,
"duration": round(recording_group.duration),
}
if day in days:
# merge counts if already present (edge-case at DST boundary)
days[day]["events"] += events_count or 0
days[day]["hours"].append(hour_data)
else:
days[day] = {
"events": events_count or 0,
"hours": [hour_data],
"day": day,
}
return JSONResponse(content=list(days.values()))
@router.get("/{camera_name}/recordings", dependencies=[Depends(require_camera_access)])
async def recordings(
camera_name: str,
after: float = (datetime.now() - timedelta(hours=1)).timestamp(),
before: float = datetime.now().timestamp(),
):
"""Return specific camera recordings between the given 'after'/'end' times. If not provided the last hour will be used"""
recordings = (
Recordings.select(
Recordings.id,
Recordings.start_time,
Recordings.end_time,
Recordings.segment_size,
Recordings.motion,
Recordings.objects,
Recordings.motion_heatmap,
Recordings.duration,
)
.where(
Recordings.camera == camera_name,
Recordings.end_time >= after,
Recordings.start_time <= before,
)
.order_by(Recordings.start_time)
.dicts()
.iterator()
)
return JSONResponse(content=list(recordings))
@router.get(
"/recordings/unavailable",
response_model=list[dict],
dependencies=[Depends(allow_any_authenticated())],
)
async def no_recordings(
request: Request,
params: MediaRecordingsAvailabilityQueryParams = Depends(),
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
):
"""Get time ranges with no recordings."""
cameras = params.cameras
if cameras != "all":
requested = set(unquote(cameras).split(","))
filtered = requested.intersection(allowed_cameras)
if not filtered:
return JSONResponse(content=[])
cameras = ",".join(filtered)
else:
cameras = allowed_cameras
before = params.before or datetime.datetime.now().timestamp()
after = (
params.after
or (datetime.datetime.now() - datetime.timedelta(hours=1)).timestamp()
)
scale = params.scale
clauses = [(Recordings.end_time >= after) & (Recordings.start_time <= before)]
if cameras != "all":
camera_list = cameras.split(",")
clauses.append((Recordings.camera << camera_list))
else:
camera_list = allowed_cameras
# Get recording start times
data: list[Recordings] = (
Recordings.select(Recordings.start_time, Recordings.end_time)
.where(reduce(operator.and_, clauses))
.order_by(Recordings.start_time.asc())
.dicts()
.iterator()
)
# Convert recordings to list of (start, end) tuples
recordings = [(r["start_time"], r["end_time"]) for r in data]
# Iterate through time segments and check if each has any recording
no_recording_segments = []
current = after
current_gap_start = None
while current < before:
segment_end = min(current + scale, before)
# Check if this segment overlaps with any recording
has_recording = any(
rec_start < segment_end and rec_end > current
for rec_start, rec_end in recordings
)
if not has_recording:
# This segment has no recordings
if current_gap_start is None:
current_gap_start = current # Start a new gap
else:
# This segment has recordings
if current_gap_start is not None:
# End the current gap and append it
no_recording_segments.append(
{"start_time": int(current_gap_start), "end_time": int(current)}
)
current_gap_start = None
current = segment_end
# Append the last gap if it exists
if current_gap_start is not None:
no_recording_segments.append(
{"start_time": int(current_gap_start), "end_time": int(before)}
)
return JSONResponse(content=no_recording_segments)
@router.delete(
"/recordings/start/{start}/end/{end}",
response_model=GenericResponse,
dependencies=[Depends(require_role(["admin"]))],
summary="Delete recordings",
description="""Deletes recordings within the specified time range.
Recordings can be filtered by cameras and kept based on motion, objects, or audio attributes.
""",
)
async def delete_recordings(
start: float = PathParam(..., description="Start timestamp (unix)"),
end: float = PathParam(..., description="End timestamp (unix)"),
params: RecordingsDeleteQueryParams = Depends(),
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
):
"""Delete recordings in the specified time range."""
if start >= end:
return JSONResponse(
content={
"success": False,
"message": "Start time must be less than end time.",
},
status_code=400,
)
cameras = params.cameras
if cameras != "all":
requested = set(cameras.split(","))
filtered = requested.intersection(allowed_cameras)
if not filtered:
return JSONResponse(
content={
"success": False,
"message": "No valid cameras found in the request.",
},
status_code=400,
)
camera_list = list(filtered)
else:
camera_list = allowed_cameras
# Parse keep parameter
keep_set = set()
if params.keep:
keep_set = set(params.keep.split(","))
# Build query to find overlapping recordings
clauses = [
(
Recordings.start_time.between(start, end)
| Recordings.end_time.between(start, end)
| ((start > Recordings.start_time) & (end < Recordings.end_time))
),
(Recordings.camera << camera_list),
]
keep_clauses = []
if "motion" in keep_set:
keep_clauses.append(Recordings.motion.is_null(False) & (Recordings.motion > 0))
if "object" in keep_set:
keep_clauses.append(
Recordings.objects.is_null(False) & (Recordings.objects > 0)
)
if "audio" in keep_set:
keep_clauses.append(Recordings.dBFS.is_null(False))
if keep_clauses:
keep_condition = reduce(operator.or_, keep_clauses)
clauses.append(~keep_condition)
recordings_to_delete = (
Recordings.select(Recordings.id, Recordings.path)
.where(reduce(operator.and_, clauses))
.dicts()
.iterator()
)
recording_ids = []
deleted_count = 0
error_count = 0
for recording in recordings_to_delete:
recording_ids.append(recording["id"])
try:
Path(recording["path"]).unlink(missing_ok=True)
deleted_count += 1
except Exception as e:
logger.error(f"Failed to delete recording file {recording['path']}: {e}")
error_count += 1
if recording_ids:
max_deletes = 100000
recording_ids_list = list(recording_ids)
for i in range(0, len(recording_ids_list), max_deletes):
Recordings.delete().where(
Recordings.id << recording_ids_list[i : i + max_deletes]
).execute()
message = f"Successfully deleted {deleted_count} recording(s)."
if error_count > 0:
message += f" {error_count} file deletion error(s) occurred."
return JSONResponse(
content={"success": True, "message": message},
status_code=200,
)
+4 -1
View File
@@ -33,6 +33,7 @@ from frigate.api.defs.response.review_response import (
ReviewSummaryResponse, ReviewSummaryResponse,
) )
from frigate.api.defs.tags import Tags from frigate.api.defs.tags import Tags
from frigate.config import FrigateConfig
from frigate.embeddings import EmbeddingsContext from frigate.embeddings import EmbeddingsContext
from frigate.models import Recordings, ReviewSegment, UserReviewStatus from frigate.models import Recordings, ReviewSegment, UserReviewStatus
from frigate.review.types import SeverityEnum from frigate.review.types import SeverityEnum
@@ -746,7 +747,9 @@ async def set_not_reviewed(
description="Use GenAI to summarize review items over a period of time.", description="Use GenAI to summarize review items over a period of time.",
) )
def generate_review_summary(request: Request, start_ts: float, end_ts: float): def generate_review_summary(request: Request, start_ts: float, end_ts: float):
if not request.app.genai_manager.vision_client: config: FrigateConfig = request.app.frigate_config
if not config.genai.provider:
return JSONResponse( return JSONResponse(
content=( content=(
{ {
+1 -79
View File
@@ -8,7 +8,7 @@ from multiprocessing import Queue
from multiprocessing.managers import DictProxy, SyncManager from multiprocessing.managers import DictProxy, SyncManager
from multiprocessing.synchronize import Event as MpEvent from multiprocessing.synchronize import Event as MpEvent
from pathlib import Path from pathlib import Path
from typing import Callable, Optional from typing import Optional
import psutil import psutil
import uvicorn import uvicorn
@@ -30,7 +30,6 @@ from frigate.comms.ws import WebSocketClient
from frigate.comms.zmq_proxy import ZmqProxy from frigate.comms.zmq_proxy import ZmqProxy
from frigate.config.camera.updater import CameraConfigUpdatePublisher from frigate.config.camera.updater import CameraConfigUpdatePublisher
from frigate.config.config import FrigateConfig from frigate.config.config import FrigateConfig
from frigate.config.profile_manager import ProfileManager
from frigate.const import ( from frigate.const import (
CACHE_DIR, CACHE_DIR,
CLIPS_DIR, CLIPS_DIR,
@@ -44,15 +43,10 @@ from frigate.const import (
) )
from frigate.data_processing.types import DataProcessorMetrics from frigate.data_processing.types import DataProcessorMetrics
from frigate.db.sqlitevecq import SqliteVecQueueDatabase from frigate.db.sqlitevecq import SqliteVecQueueDatabase
from frigate.debug_replay import (
DebugReplayManager,
cleanup_replay_cameras,
)
from frigate.embeddings import EmbeddingProcess, EmbeddingsContext from frigate.embeddings import EmbeddingProcess, EmbeddingsContext
from frigate.events.audio import AudioProcessor from frigate.events.audio import AudioProcessor
from frigate.events.cleanup import EventCleanup from frigate.events.cleanup import EventCleanup
from frigate.events.maintainer import EventProcessor from frigate.events.maintainer import EventProcessor
from frigate.jobs.motion_search import stop_all_motion_search_jobs
from frigate.log import _stop_logging from frigate.log import _stop_logging
from frigate.models import ( from frigate.models import (
Event, Event,
@@ -81,7 +75,6 @@ from frigate.timeline import TimelineProcessor
from frigate.track.object_processing import TrackedObjectProcessor from frigate.track.object_processing import TrackedObjectProcessor
from frigate.util.builtin import empty_and_close_queue from frigate.util.builtin import empty_and_close_queue
from frigate.util.image import UntrackedSharedMemory from frigate.util.image import UntrackedSharedMemory
from frigate.util.process import FrigateProcess
from frigate.util.services import set_file_limit from frigate.util.services import set_file_limit
from frigate.version import VERSION from frigate.version import VERSION
from frigate.watchdog import FrigateWatchdog from frigate.watchdog import FrigateWatchdog
@@ -120,7 +113,6 @@ class FrigateApp:
self.ptz_metrics: dict[str, PTZMetrics] = {} self.ptz_metrics: dict[str, PTZMetrics] = {}
self.processes: dict[str, int] = {} self.processes: dict[str, int] = {}
self.embeddings: Optional[EmbeddingsContext] = None self.embeddings: Optional[EmbeddingsContext] = None
self.profile_manager: Optional[ProfileManager] = None
self.config = config self.config = config
def ensure_dirs(self) -> None: def ensure_dirs(self) -> None:
@@ -147,9 +139,6 @@ class FrigateApp:
else: else:
logger.debug(f"Skipping directory: {d}") logger.debug(f"Skipping directory: {d}")
def init_debug_replay_manager(self) -> None:
self.replay_manager = DebugReplayManager()
def init_camera_metrics(self) -> None: def init_camera_metrics(self) -> None:
# create camera_metrics # create camera_metrics
for camera_name in self.config.cameras.keys(): for camera_name in self.config.cameras.keys():
@@ -352,19 +341,6 @@ class FrigateApp:
comms, comms,
) )
def init_profile_manager(self) -> None:
self.profile_manager = ProfileManager(
self.config, self.inter_config_updater, self.dispatcher
)
self.dispatcher.profile_manager = self.profile_manager
persisted = ProfileManager.load_persisted_profile()
if persisted and any(
persisted in cam.profiles for cam in self.config.cameras.values()
):
logger.info("Restoring persisted profile '%s'", persisted)
self.profile_manager.activate_profile(persisted)
def start_detectors(self) -> None: def start_detectors(self) -> None:
for name in self.config.cameras.keys(): for name in self.config.cameras.keys():
try: try:
@@ -498,47 +474,6 @@ class FrigateApp:
def start_watchdog(self) -> None: def start_watchdog(self) -> None:
self.frigate_watchdog = FrigateWatchdog(self.detectors, self.stop_event) self.frigate_watchdog = FrigateWatchdog(self.detectors, self.stop_event)
# (attribute on self, key in self.processes, factory)
specs: list[tuple[str, str, Callable[[], FrigateProcess]]] = [
(
"embedding_process",
"embeddings",
lambda: EmbeddingProcess(
self.config, self.embeddings_metrics, self.stop_event
),
),
(
"recording_process",
"recording",
lambda: RecordProcess(self.config, self.stop_event),
),
(
"review_segment_process",
"review_segment",
lambda: ReviewProcess(self.config, self.stop_event),
),
(
"output_processor",
"output",
lambda: OutputProcess(self.config, self.stop_event),
),
]
for attr, key, factory in specs:
if not hasattr(self, attr):
continue
def on_restart(
proc: FrigateProcess, _attr: str = attr, _key: str = key
) -> None:
setattr(self, _attr, proc)
self.processes[_key] = proc.pid or 0
self.frigate_watchdog.register(
key, getattr(self, attr), factory, on_restart
)
self.frigate_watchdog.start() self.frigate_watchdog.start()
def init_auth(self) -> None: def init_auth(self) -> None:
@@ -596,7 +531,6 @@ class FrigateApp:
set_file_limit() set_file_limit()
# Start frigate services. # Start frigate services.
self.init_debug_replay_manager()
self.init_camera_metrics() self.init_camera_metrics()
self.init_queues() self.init_queues()
self.init_database() self.init_database()
@@ -607,14 +541,9 @@ class FrigateApp:
self.init_embeddings_manager() self.init_embeddings_manager()
self.bind_database() self.bind_database()
self.check_db_data_migrations() self.check_db_data_migrations()
# Clean up any stale replay camera artifacts (filesystem + DB)
cleanup_replay_cameras()
self.init_inter_process_communicator() self.init_inter_process_communicator()
self.start_detectors() self.start_detectors()
self.init_dispatcher() self.init_dispatcher()
self.init_profile_manager()
self.init_embeddings_client() self.init_embeddings_client()
self.start_video_output_processor() self.start_video_output_processor()
self.start_ptz_autotracker() self.start_ptz_autotracker()
@@ -643,9 +572,6 @@ class FrigateApp:
self.stats_emitter, self.stats_emitter,
self.event_metadata_updater, self.event_metadata_updater,
self.inter_config_updater, self.inter_config_updater,
self.replay_manager,
self.dispatcher,
self.profile_manager,
), ),
host="127.0.0.1", host="127.0.0.1",
port=5001, port=5001,
@@ -660,9 +586,6 @@ class FrigateApp:
# used by the docker healthcheck # used by the docker healthcheck
Path("/dev/shm/.frigate-is-stopping").touch() Path("/dev/shm/.frigate-is-stopping").touch()
# Cancel any running motion search jobs before setting stop_event
stop_all_motion_search_jobs()
self.stop_event.set() self.stop_event.set()
# set an end_time on entries without an end_time before exiting # set an end_time on entries without an end_time before exiting
@@ -714,7 +637,6 @@ class FrigateApp:
self.record_cleanup.join() self.record_cleanup.join()
self.stats_emitter.join() self.stats_emitter.join()
self.frigate_watchdog.join() self.frigate_watchdog.join()
self.camera_maintainer.join()
self.db.stop() self.db.stop()
# Save embeddings stats to disk # Save embeddings stats to disk
-4
View File
@@ -19,8 +19,6 @@ class CameraMetrics:
process_pid: Synchronized process_pid: Synchronized
capture_process_pid: Synchronized capture_process_pid: Synchronized
ffmpeg_pid: Synchronized ffmpeg_pid: Synchronized
reconnects_last_hour: Synchronized
stalls_last_hour: Synchronized
def __init__(self, manager: SyncManager): def __init__(self, manager: SyncManager):
self.camera_fps = manager.Value("d", 0) self.camera_fps = manager.Value("d", 0)
@@ -37,8 +35,6 @@ class CameraMetrics:
self.process_pid = manager.Value("i", 0) self.process_pid = manager.Value("i", 0)
self.capture_process_pid = manager.Value("i", 0) self.capture_process_pid = manager.Value("i", 0)
self.ffmpeg_pid = manager.Value("i", 0) self.ffmpeg_pid = manager.Value("i", 0)
self.reconnects_last_hour = manager.Value("i", 0)
self.stalls_last_hour = manager.Value("i", 0)
class PTZMetrics: class PTZMetrics:
+2 -17
View File
@@ -57,9 +57,6 @@ class CameraActivityManager:
all_objects: list[dict[str, Any]] = [] all_objects: list[dict[str, Any]] = []
for camera in new_activity.keys(): for camera in new_activity.keys():
if camera not in self.config.cameras:
continue
# handle cameras that were added dynamically # handle cameras that were added dynamically
if camera not in self.camera_all_object_counts: if camera not in self.camera_all_object_counts:
self.__init_camera(self.config.cameras[camera]) self.__init_camera(self.config.cameras[camera])
@@ -127,11 +124,7 @@ class CameraActivityManager:
any_changed = False any_changed = False
# run through each object and check what topics need to be updated # run through each object and check what topics need to be updated
camera_config = self.config.cameras.get(camera) for label in self.config.cameras[camera].objects.track:
if camera_config is None:
return
for label in camera_config.objects.track:
if label in self.config.model.non_logo_attributes: if label in self.config.model.non_logo_attributes:
continue continue
@@ -181,9 +174,6 @@ class AudioActivityManager:
now = datetime.datetime.now().timestamp() now = datetime.datetime.now().timestamp()
for camera in new_activity.keys(): for camera in new_activity.keys():
if camera not in self.config.cameras:
continue
# handle cameras that were added dynamically # handle cameras that were added dynamically
if camera not in self.current_audio_detections: if camera not in self.current_audio_detections:
self.__init_camera(self.config.cameras[camera]) self.__init_camera(self.config.cameras[camera])
@@ -203,11 +193,7 @@ class AudioActivityManager:
def compare_audio_activity( def compare_audio_activity(
self, camera: str, new_detections: list[tuple[str, float]], now: float self, camera: str, new_detections: list[tuple[str, float]], now: float
) -> None: ) -> None:
camera_config = self.config.cameras.get(camera) max_not_heard = self.config.cameras[camera].audio.max_not_heard
if camera_config is None:
return False
max_not_heard = camera_config.audio.max_not_heard
current = self.current_audio_detections[camera] current = self.current_audio_detections[camera]
any_changed = False any_changed = False
@@ -236,7 +222,6 @@ class AudioActivityManager:
None, None,
"audio", "audio",
{}, {},
None,
), ),
EventMetadataTypeEnum.manual_event_create.value, EventMetadataTypeEnum.manual_event_create.value,
) )
+12 -59
View File
@@ -55,20 +55,8 @@ class CameraMaintainer(threading.Thread):
self.shm_count = self.__calculate_shm_frame_count() self.shm_count = self.__calculate_shm_frame_count()
self.camera_processes: dict[str, mp.Process] = {} self.camera_processes: dict[str, mp.Process] = {}
self.capture_processes: dict[str, mp.Process] = {} self.capture_processes: dict[str, mp.Process] = {}
self.camera_stop_events: dict[str, MpEvent] = {}
self.metrics_manager = metrics_manager self.metrics_manager = metrics_manager
def __ensure_camera_stop_event(self, camera: str) -> MpEvent:
camera_stop_event = self.camera_stop_events.get(camera)
if camera_stop_event is None:
camera_stop_event = mp.Event()
self.camera_stop_events[camera] = camera_stop_event
else:
camera_stop_event.clear()
return camera_stop_event
def __init_historical_regions(self) -> None: def __init_historical_regions(self) -> None:
# delete region grids for removed or renamed cameras # delete region grids for removed or renamed cameras
cameras = list(self.config.cameras.keys()) cameras = list(self.config.cameras.keys())
@@ -111,8 +99,6 @@ class CameraMaintainer(threading.Thread):
logger.info(f"Camera processor not started for disabled camera {name}") logger.info(f"Camera processor not started for disabled camera {name}")
return return
camera_stop_event = self.__ensure_camera_stop_event(name)
if runtime: if runtime:
self.camera_metrics[name] = CameraMetrics(self.metrics_manager) self.camera_metrics[name] = CameraMetrics(self.metrics_manager)
self.ptz_metrics[name] = PTZMetrics(autotracker_enabled=False) self.ptz_metrics[name] = PTZMetrics(autotracker_enabled=False)
@@ -149,7 +135,7 @@ class CameraMaintainer(threading.Thread):
self.camera_metrics[name], self.camera_metrics[name],
self.ptz_metrics[name], self.ptz_metrics[name],
self.region_grids[name], self.region_grids[name],
camera_stop_event, self.stop_event,
self.config.logger, self.config.logger,
) )
self.camera_processes[config.name] = camera_process self.camera_processes[config.name] = camera_process
@@ -164,8 +150,6 @@ class CameraMaintainer(threading.Thread):
logger.info(f"Capture process not started for disabled camera {name}") logger.info(f"Capture process not started for disabled camera {name}")
return return
camera_stop_event = self.__ensure_camera_stop_event(name)
# pre-create shms # pre-create shms
count = 10 if runtime else self.shm_count count = 10 if runtime else self.shm_count
for i in range(count): for i in range(count):
@@ -176,7 +160,7 @@ class CameraMaintainer(threading.Thread):
config, config,
count, count,
self.camera_metrics[name], self.camera_metrics[name],
camera_stop_event, self.stop_event,
self.config.logger, self.config.logger,
) )
capture_process.daemon = True capture_process.daemon = True
@@ -186,36 +170,18 @@ class CameraMaintainer(threading.Thread):
logger.info(f"Capture process started for {name}: {capture_process.pid}") logger.info(f"Capture process started for {name}: {capture_process.pid}")
def __stop_camera_capture_process(self, camera: str) -> None: def __stop_camera_capture_process(self, camera: str) -> None:
capture_process = self.capture_processes.get(camera) capture_process = self.capture_processes[camera]
if capture_process is not None: if capture_process is not None:
logger.info(f"Waiting for capture process for {camera} to stop") logger.info(f"Waiting for capture process for {camera} to stop")
camera_stop_event = self.camera_stop_events.get(camera) capture_process.terminate()
capture_process.join()
if camera_stop_event is not None:
camera_stop_event.set()
capture_process.join(timeout=10)
if capture_process.is_alive():
logger.warning(
f"Capture process for {camera} didn't exit, forcing termination"
)
capture_process.terminate()
capture_process.join()
def __stop_camera_process(self, camera: str) -> None: def __stop_camera_process(self, camera: str) -> None:
camera_process = self.camera_processes.get(camera) camera_process = self.camera_processes[camera]
if camera_process is not None: if camera_process is not None:
logger.info(f"Waiting for process for {camera} to stop") logger.info(f"Waiting for process for {camera} to stop")
camera_stop_event = self.camera_stop_events.get(camera) camera_process.terminate()
camera_process.join()
if camera_stop_event is not None:
camera_stop_event.set()
camera_process.join(timeout=10)
if camera_process.is_alive():
logger.warning(f"Process for {camera} didn't exit, forcing termination")
camera_process.terminate()
camera_process.join()
logger.info(f"Closing frame queue for {camera}") logger.info(f"Closing frame queue for {camera}")
empty_and_close_queue(self.camera_metrics[camera].frame_queue) empty_and_close_queue(self.camera_metrics[camera].frame_queue)
@@ -233,12 +199,6 @@ class CameraMaintainer(threading.Thread):
for update_type, updated_cameras in updates.items(): for update_type, updated_cameras in updates.items():
if update_type == CameraConfigUpdateEnum.add.name: if update_type == CameraConfigUpdateEnum.add.name:
for camera in updated_cameras: for camera in updated_cameras:
if (
camera in self.camera_processes
or camera in self.capture_processes
):
continue
self.__start_camera_processor( self.__start_camera_processor(
camera, camera,
self.update_subscriber.camera_configs[camera], self.update_subscriber.camera_configs[camera],
@@ -250,22 +210,15 @@ class CameraMaintainer(threading.Thread):
runtime=True, runtime=True,
) )
elif update_type == CameraConfigUpdateEnum.remove.name: elif update_type == CameraConfigUpdateEnum.remove.name:
for camera in updated_cameras: self.__stop_camera_capture_process(camera)
self.__stop_camera_capture_process(camera) self.__stop_camera_process(camera)
self.__stop_camera_process(camera)
self.capture_processes.pop(camera, None)
self.camera_processes.pop(camera, None)
self.camera_stop_events.pop(camera, None)
self.region_grids.pop(camera, None)
self.camera_metrics.pop(camera, None)
self.ptz_metrics.pop(camera, None)
# ensure the capture processes are done # ensure the capture processes are done
for camera in self.capture_processes.keys(): for camera in self.camera_processes.keys():
self.__stop_camera_capture_process(camera) self.__stop_camera_capture_process(camera)
# ensure the camera processors are done # ensure the camera processors are done
for camera in self.camera_processes.keys(): for camera in self.capture_processes.keys():
self.__stop_camera_process(camera) self.__stop_camera_process(camera)
self.update_subscriber.stop() self.update_subscriber.stop()
+42 -17
View File
@@ -65,7 +65,7 @@ class CameraState:
frame_copy = cv2.cvtColor(frame_copy, cv2.COLOR_YUV2BGR_I420) frame_copy = cv2.cvtColor(frame_copy, cv2.COLOR_YUV2BGR_I420)
# draw on the frame # draw on the frame
if draw_options.get("mask"): if draw_options.get("mask"):
mask_overlay = np.where(self.camera_config.motion.rasterized_mask == [0]) mask_overlay = np.where(self.camera_config.motion.mask == [0])
frame_copy[mask_overlay] = [0, 0, 0] frame_copy[mask_overlay] = [0, 0, 0]
if draw_options.get("bounding_boxes"): if draw_options.get("bounding_boxes"):
@@ -197,10 +197,6 @@ class CameraState:
if draw_options.get("zones"): if draw_options.get("zones"):
for name, zone in self.camera_config.zones.items(): for name, zone in self.camera_config.zones.items():
# skip disabled zones
if not zone.enabled:
continue
thickness = ( thickness = (
8 8
if any( if any(
@@ -532,19 +528,48 @@ class CameraState:
) -> None: ) -> None:
img_frame = frame if frame is not None else self.get_current_frame() img_frame = frame if frame is not None else self.get_current_frame()
ret, webp = cv2.imencode( # write clean snapshot if enabled
".webp", img_frame, [int(cv2.IMWRITE_WEBP_QUALITY), 80] if self.camera_config.snapshots.clean_copy:
) ret, webp = cv2.imencode(
".webp", img_frame, [int(cv2.IMWRITE_WEBP_QUALITY), 80]
)
if ret: if ret:
with open( with open(
os.path.join( os.path.join(
CLIPS_DIR, CLIPS_DIR,
f"{self.camera_config.name}-{event_id}-clean.webp", f"{self.camera_config.name}-{event_id}-clean.webp",
), ),
"wb", "wb",
) as p: ) as p:
p.write(webp.tobytes()) p.write(webp.tobytes())
# write jpg snapshot with optional annotations
if draw.get("boxes") and isinstance(draw.get("boxes"), list):
for box in draw.get("boxes"):
x = int(box["box"][0] * self.camera_config.detect.width)
y = int(box["box"][1] * self.camera_config.detect.height)
width = int(box["box"][2] * self.camera_config.detect.width)
height = int(box["box"][3] * self.camera_config.detect.height)
draw_box_with_label(
img_frame,
x,
y,
x + width,
y + height,
label,
f"{box.get('score', '-')}% {int(width * height)}",
thickness=2,
color=box.get("color", (255, 0, 0)),
)
ret, jpg = cv2.imencode(".jpg", img_frame)
with open(
os.path.join(CLIPS_DIR, f"{self.camera_config.name}-{event_id}.jpg"),
"wb",
) as j:
j.write(jpg.tobytes())
# create thumbnail with max height of 175 and save # create thumbnail with max height of 175 and save
width = int(175 * img_frame.shape[1] / img_frame.shape[0]) width = int(175 * img_frame.shape[1] / img_frame.shape[0])
+4 -4
View File
@@ -26,8 +26,8 @@ class ConfigPublisher:
def stop(self) -> None: def stop(self) -> None:
self.stop_event.set() self.stop_event.set()
self.socket.close(linger=0) self.socket.close()
self.context.destroy(linger=0) self.context.destroy()
class ConfigSubscriber: class ConfigSubscriber:
@@ -55,5 +55,5 @@ class ConfigSubscriber:
return (None, None) return (None, None)
def stop(self) -> None: def stop(self) -> None:
self.socket.close(linger=0) self.socket.close()
self.context.destroy(linger=0) self.context.destroy()
+4 -223
View File
@@ -15,8 +15,6 @@ from frigate.config.camera.updater import (
CameraConfigUpdatePublisher, CameraConfigUpdatePublisher,
CameraConfigUpdateTopic, CameraConfigUpdateTopic,
) )
from frigate.config.config import RuntimeFilterConfig, RuntimeMotionConfig
from frigate.config.profile_manager import ProfileManager
from frigate.const import ( from frigate.const import (
CLEAR_ONGOING_REVIEW_SEGMENTS, CLEAR_ONGOING_REVIEW_SEGMENTS,
EXPIRE_AUDIO_ACTIVITY, EXPIRE_AUDIO_ACTIVITY,
@@ -30,7 +28,6 @@ from frigate.const import (
UPDATE_CAMERA_ACTIVITY, UPDATE_CAMERA_ACTIVITY,
UPDATE_EMBEDDINGS_REINDEX_PROGRESS, UPDATE_EMBEDDINGS_REINDEX_PROGRESS,
UPDATE_EVENT_DESCRIPTION, UPDATE_EVENT_DESCRIPTION,
UPDATE_JOB_STATE,
UPDATE_MODEL_STATE, UPDATE_MODEL_STATE,
UPDATE_REVIEW_DESCRIPTION, UPDATE_REVIEW_DESCRIPTION,
UPSERT_REVIEW_SEGMENT, UPSERT_REVIEW_SEGMENT,
@@ -63,7 +60,6 @@ class Dispatcher:
self.camera_activity = CameraActivityManager(config, self.publish) self.camera_activity = CameraActivityManager(config, self.publish)
self.audio_activity = AudioActivityManager(config, self.publish) self.audio_activity = AudioActivityManager(config, self.publish)
self.model_state: dict[str, ModelStatusTypesEnum] = {} self.model_state: dict[str, ModelStatusTypesEnum] = {}
self.job_state: dict[str, dict[str, Any]] = {} # {job_type: job_data}
self.embeddings_reindex: dict[str, Any] = {} self.embeddings_reindex: dict[str, Any] = {}
self.birdseye_layout: dict[str, Any] = {} self.birdseye_layout: dict[str, Any] = {}
self.audio_transcription_state: str = "idle" self.audio_transcription_state: str = "idle"
@@ -86,15 +82,10 @@ class Dispatcher:
"review_detections": self._on_detections_command, "review_detections": self._on_detections_command,
"object_descriptions": self._on_object_description_command, "object_descriptions": self._on_object_description_command,
"review_descriptions": self._on_review_description_command, "review_descriptions": self._on_review_description_command,
"motion_mask": self._on_motion_mask_command,
"object_mask": self._on_object_mask_command,
"zone": self._on_zone_command,
} }
self._global_settings_handlers: dict[str, Callable] = { self._global_settings_handlers: dict[str, Callable] = {
"notifications": self._on_global_notification_command, "notifications": self._on_global_notification_command,
"profile": self._on_profile_command,
} }
self.profile_manager: Optional[ProfileManager] = None
for comm in self.comms: for comm in self.comms:
comm.subscribe(self._receive) comm.subscribe(self._receive)
@@ -107,23 +98,11 @@ class Dispatcher:
"""Handle receiving of payload from communicators.""" """Handle receiving of payload from communicators."""
def handle_camera_command( def handle_camera_command(
command_type: str, command_type: str, camera_name: str, command: str, payload: str
camera_name: str,
command: str,
payload: str,
sub_command: str | None = None,
) -> None: ) -> None:
if camera_name not in self.config.cameras:
return
try: try:
if command_type == "set": if command_type == "set":
if sub_command: self._camera_settings_handlers[command](camera_name, payload)
self._camera_settings_handlers[command](
camera_name, sub_command, payload
)
else:
self._camera_settings_handlers[command](camera_name, payload)
elif command_type == "ptz": elif command_type == "ptz":
self._on_ptz_command(camera_name, payload) self._on_ptz_command(camera_name, payload)
except KeyError: except KeyError:
@@ -137,9 +116,6 @@ class Dispatcher:
def handle_request_region_grid() -> Any: def handle_request_region_grid() -> Any:
camera = payload camera = payload
if camera not in self.config.cameras:
return None
grid = get_camera_regions_grid( grid = get_camera_regions_grid(
camera, camera,
self.config.cameras[camera].detect, self.config.cameras[camera].detect,
@@ -204,19 +180,6 @@ class Dispatcher:
def handle_model_state() -> None: def handle_model_state() -> None:
self.publish("model_state", json.dumps(self.model_state.copy())) self.publish("model_state", json.dumps(self.model_state.copy()))
def handle_update_job_state() -> None:
if payload and isinstance(payload, dict):
job_type = payload.get("job_type")
if job_type:
self.job_state[job_type] = payload
self.publish(
"job_state",
json.dumps(self.job_state),
)
def handle_job_state() -> None:
self.publish("job_state", json.dumps(self.job_state.copy()))
def handle_update_audio_transcription_state() -> None: def handle_update_audio_transcription_state() -> None:
if payload: if payload:
self.audio_transcription_state = payload self.audio_transcription_state = payload
@@ -252,11 +215,7 @@ class Dispatcher:
self.publish("birdseye_layout", json.dumps(self.birdseye_layout.copy())) self.publish("birdseye_layout", json.dumps(self.birdseye_layout.copy()))
def handle_on_connect() -> None: def handle_on_connect() -> None:
camera_status = { camera_status = self.camera_activity.last_camera_activity.copy()
camera: status
for camera, status in self.camera_activity.last_camera_activity.copy().items()
if camera in self.config.cameras
}
audio_detections = self.audio_activity.current_audio_detections.copy() audio_detections = self.audio_activity.current_audio_detections.copy()
cameras_with_status = camera_status.keys() cameras_with_status = camera_status.keys()
@@ -301,11 +260,6 @@ class Dispatcher:
) )
self.publish("birdseye_layout", json.dumps(self.birdseye_layout.copy())) self.publish("birdseye_layout", json.dumps(self.birdseye_layout.copy()))
self.publish("audio_detections", json.dumps(audio_detections)) self.publish("audio_detections", json.dumps(audio_detections))
self.publish(
"profile/state",
self.config.active_profile or "none",
retain=True,
)
def handle_notification_test() -> None: def handle_notification_test() -> None:
self.publish("notification_test", "Test notification") self.publish("notification_test", "Test notification")
@@ -323,7 +277,6 @@ class Dispatcher:
UPDATE_EVENT_DESCRIPTION: handle_update_event_description, UPDATE_EVENT_DESCRIPTION: handle_update_event_description,
UPDATE_REVIEW_DESCRIPTION: handle_update_review_description, UPDATE_REVIEW_DESCRIPTION: handle_update_review_description,
UPDATE_MODEL_STATE: handle_update_model_state, UPDATE_MODEL_STATE: handle_update_model_state,
UPDATE_JOB_STATE: handle_update_job_state,
UPDATE_EMBEDDINGS_REINDEX_PROGRESS: handle_update_embeddings_reindex_progress, UPDATE_EMBEDDINGS_REINDEX_PROGRESS: handle_update_embeddings_reindex_progress,
UPDATE_BIRDSEYE_LAYOUT: handle_update_birdseye_layout, UPDATE_BIRDSEYE_LAYOUT: handle_update_birdseye_layout,
UPDATE_AUDIO_TRANSCRIPTION_STATE: handle_update_audio_transcription_state, UPDATE_AUDIO_TRANSCRIPTION_STATE: handle_update_audio_transcription_state,
@@ -331,7 +284,6 @@ class Dispatcher:
"restart": handle_restart, "restart": handle_restart,
"embeddingsReindexProgress": handle_embeddings_reindex_progress, "embeddingsReindexProgress": handle_embeddings_reindex_progress,
"modelState": handle_model_state, "modelState": handle_model_state,
"jobState": handle_job_state,
"audioTranscriptionState": handle_audio_transcription_state, "audioTranscriptionState": handle_audio_transcription_state,
"birdseyeLayout": handle_birdseye_layout, "birdseyeLayout": handle_birdseye_layout,
"onConnect": handle_on_connect, "onConnect": handle_on_connect,
@@ -345,14 +297,6 @@ class Dispatcher:
camera_name = parts[-3] camera_name = parts[-3]
command = parts[-2] command = parts[-2]
handle_camera_command("set", camera_name, command, payload) handle_camera_command("set", camera_name, command, payload)
elif len(parts) == 4 and topic.endswith("set"):
# example /cam_name/motion_mask/mask_name/set payload=ON|OFF
camera_name = parts[-4]
command = parts[-3]
sub_command = parts[-2]
handle_camera_command(
"set", camera_name, command, payload, sub_command
)
elif len(parts) == 2 and topic.endswith("set"): elif len(parts) == 2 and topic.endswith("set"):
command = parts[-2] command = parts[-2]
self._global_settings_handlers[command](payload) self._global_settings_handlers[command](payload)
@@ -364,8 +308,7 @@ class Dispatcher:
# example /cam_name/notifications/suspend payload=duration # example /cam_name/notifications/suspend payload=duration
camera_name = parts[-3] camera_name = parts[-3]
command = parts[-2] command = parts[-2]
if camera_name in self.config.cameras: self._on_camera_notification_suspend(camera_name, payload)
self._on_camera_notification_suspend(camera_name, payload)
except IndexError: except IndexError:
logger.error( logger.error(
f"Received invalid {topic.split('/')[-1]} command: {topic}" f"Received invalid {topic.split('/')[-1]} command: {topic}"
@@ -564,22 +507,6 @@ class Dispatcher:
) )
self.publish("notifications/state", payload, retain=True) self.publish("notifications/state", payload, retain=True)
def _on_profile_command(self, payload: str) -> None:
"""Callback for profile/set topic."""
if self.profile_manager is None:
logger.error("Profile manager not initialized")
return
profile_name = (
payload.strip() if payload.strip() not in ("", "none", "None") else None
)
err = self.profile_manager.activate_profile(profile_name)
if err:
logger.error("Failed to activate profile: %s", err)
return
self.publish("profile/state", payload.strip() or "none", retain=True)
def _on_audio_command(self, camera_name: str, payload: str) -> None: def _on_audio_command(self, camera_name: str, payload: str) -> None:
"""Callback for audio topic.""" """Callback for audio topic."""
audio_settings = self.config.cameras[camera_name].audio audio_settings = self.config.cameras[camera_name].audio
@@ -914,149 +841,3 @@ class Dispatcher:
genai_settings, genai_settings,
) )
self.publish(f"{camera_name}/review_descriptions/state", payload, retain=True) self.publish(f"{camera_name}/review_descriptions/state", payload, retain=True)
def _on_motion_mask_command(
self, camera_name: str, mask_name: str, payload: str
) -> None:
"""Callback for motion mask topic."""
if payload not in ["ON", "OFF"]:
logger.error(f"Invalid payload for motion mask {mask_name}: {payload}")
return
motion_settings = self.config.cameras[camera_name].motion
if mask_name not in motion_settings.mask:
logger.error(f"Unknown motion mask: {mask_name}")
return
mask = motion_settings.mask[mask_name]
if not mask:
logger.error(f"Motion mask {mask_name} is None")
return
if payload == "ON":
if not mask.enabled_in_config:
logger.error(
f"Motion mask {mask_name} must be enabled in the config to be turned on via MQTT."
)
return
mask.enabled = payload == "ON"
# Recreate RuntimeMotionConfig to update rasterized_mask
motion_settings = RuntimeMotionConfig(
frame_shape=self.config.cameras[camera_name].frame_shape,
**motion_settings.model_dump(exclude_unset=True),
)
# Update the dispatcher's own config
self.config.cameras[camera_name].motion = motion_settings
self.config_updater.publish_update(
CameraConfigUpdateTopic(CameraConfigUpdateEnum.motion, camera_name),
motion_settings,
)
self.publish(
f"{camera_name}/motion_mask/{mask_name}/state", payload, retain=True
)
def _on_object_mask_command(
self, camera_name: str, mask_name: str, payload: str
) -> None:
"""Callback for object mask topic."""
if payload not in ["ON", "OFF"]:
logger.error(f"Invalid payload for object mask {mask_name}: {payload}")
return
object_settings = self.config.cameras[camera_name].objects
# Check if this is a global mask
mask_found = False
if mask_name in object_settings.mask:
mask = object_settings.mask[mask_name]
if mask:
if payload == "ON":
if not mask.enabled_in_config:
logger.error(
f"Object mask {mask_name} must be enabled in the config to be turned on via MQTT."
)
return
mask.enabled = payload == "ON"
mask_found = True
# Check if this is a per-object filter mask
for object_name, filter_config in object_settings.filters.items():
if mask_name in filter_config.mask:
mask = filter_config.mask[mask_name]
if mask:
if payload == "ON":
if not mask.enabled_in_config:
logger.error(
f"Object mask {mask_name} must be enabled in the config to be turned on via MQTT."
)
return
mask.enabled = payload == "ON"
mask_found = True
if not mask_found:
logger.error(f"Unknown object mask: {mask_name}")
return
# Recreate RuntimeFilterConfig for each object filter to update rasterized_mask
for object_name, filter_config in object_settings.filters.items():
# Merge global object masks with per-object filter masks
merged_mask = dict(filter_config.mask) # Copy filter-specific masks
# Add global object masks if they exist
if object_settings.mask:
for global_mask_id, global_mask_config in object_settings.mask.items():
# Use a global prefix to avoid key collisions
global_mask_id_prefixed = f"global_{global_mask_id}"
merged_mask[global_mask_id_prefixed] = global_mask_config
object_settings.filters[object_name] = RuntimeFilterConfig(
frame_shape=self.config.cameras[camera_name].frame_shape,
mask=merged_mask,
**filter_config.model_dump(
exclude_unset=True, exclude={"mask", "raw_mask"}
),
)
# Update the dispatcher's own config
self.config.cameras[camera_name].objects = object_settings
self.config_updater.publish_update(
CameraConfigUpdateTopic(CameraConfigUpdateEnum.objects, camera_name),
object_settings,
)
self.publish(
f"{camera_name}/object_mask/{mask_name}/state", payload, retain=True
)
def _on_zone_command(self, camera_name: str, zone_name: str, payload: str) -> None:
"""Callback for zone topic."""
if payload not in ["ON", "OFF"]:
logger.error(f"Invalid payload for zone {zone_name}: {payload}")
return
camera_config = self.config.cameras[camera_name]
if zone_name not in camera_config.zones:
logger.error(f"Unknown zone: {zone_name}")
return
if payload == "ON":
if not camera_config.zones[zone_name].enabled_in_config:
logger.error(
f"Zone {zone_name} must be enabled in the config to be turned on via MQTT."
)
return
camera_config.zones[zone_name].enabled = payload == "ON"
self.config_updater.publish_update(
CameraConfigUpdateTopic(CameraConfigUpdateEnum.zones, camera_name),
camera_config.zones,
)
self.publish(f"{camera_name}/zone/{zone_name}/state", payload, retain=True)
+4 -4
View File
@@ -61,8 +61,8 @@ class InterProcessCommunicator(Communicator):
def stop(self) -> None: def stop(self) -> None:
self.stop_event.set() self.stop_event.set()
self.reader_thread.join() self.reader_thread.join()
self.socket.close(linger=0) self.socket.close()
self.context.destroy(linger=0) self.context.destroy()
class InterProcessRequestor: class InterProcessRequestor:
@@ -82,5 +82,5 @@ class InterProcessRequestor:
return "" return ""
def stop(self) -> None: def stop(self) -> None:
self.socket.close(linger=0) self.socket.close()
self.context.destroy(linger=0) self.context.destroy()
-52
View File
@@ -38,7 +38,6 @@ class MqttClient(Communicator):
) )
def stop(self) -> None: def stop(self) -> None:
self.publish("available", "stopped", retain=True)
self.client.disconnect() self.client.disconnect()
def _set_initial_topics(self) -> None: def _set_initial_topics(self) -> None:
@@ -134,29 +133,6 @@ class MqttClient(Communicator):
retain=True, retain=True,
) )
for mask_name, motion_mask in camera.motion.mask.items():
if motion_mask:
self.publish(
f"{camera_name}/motion_mask/{mask_name}/state",
"ON" if motion_mask.enabled else "OFF",
retain=True,
)
for mask_name, object_mask in camera.objects.mask.items():
if object_mask:
self.publish(
f"{camera_name}/object_mask/{mask_name}/state",
"ON" if object_mask.enabled else "OFF",
retain=True,
)
for zone_name, zone in camera.zones.items():
self.publish(
f"{camera_name}/zone/{zone_name}/state",
"ON" if zone.enabled else "OFF",
retain=True,
)
if self.config.notifications.enabled_in_config: if self.config.notifications.enabled_in_config:
self.publish( self.publish(
"notifications/state", "notifications/state",
@@ -164,11 +140,6 @@ class MqttClient(Communicator):
retain=True, retain=True,
) )
self.publish(
"profile/state",
self.config.active_profile or "none",
retain=True,
)
self.publish("available", "online", retain=True) self.publish("available", "online", retain=True)
def on_mqtt_command( def on_mqtt_command(
@@ -271,35 +242,12 @@ class MqttClient(Communicator):
self.on_mqtt_command, self.on_mqtt_command,
) )
for mask_name in self.config.cameras[name].motion.mask.keys():
self.client.message_callback_add(
f"{self.mqtt_config.topic_prefix}/{name}/motion_mask/{mask_name}/set",
self.on_mqtt_command,
)
for mask_name in self.config.cameras[name].objects.mask.keys():
self.client.message_callback_add(
f"{self.mqtt_config.topic_prefix}/{name}/object_mask/{mask_name}/set",
self.on_mqtt_command,
)
for zone_name in self.config.cameras[name].zones.keys():
self.client.message_callback_add(
f"{self.mqtt_config.topic_prefix}/{name}/zone/{zone_name}/set",
self.on_mqtt_command,
)
if self.config.notifications.enabled_in_config: if self.config.notifications.enabled_in_config:
self.client.message_callback_add( self.client.message_callback_add(
f"{self.mqtt_config.topic_prefix}/notifications/set", f"{self.mqtt_config.topic_prefix}/notifications/set",
self.on_mqtt_command, self.on_mqtt_command,
) )
self.client.message_callback_add(
f"{self.mqtt_config.topic_prefix}/profile/set",
self.on_mqtt_command,
)
self.client.message_callback_add( self.client.message_callback_add(
f"{self.mqtt_config.topic_prefix}/onConnect", self.on_mqtt_command f"{self.mqtt_config.topic_prefix}/onConnect", self.on_mqtt_command
) )
-33
View File
@@ -217,15 +217,6 @@ class WebPushClient(Communicator):
logger.debug(f"Notifications for {camera} are currently suspended.") logger.debug(f"Notifications for {camera} are currently suspended.")
return return
self.send_trigger(decoded) self.send_trigger(decoded)
elif topic == "camera_monitoring":
decoded = json.loads(payload)
camera = decoded["camera"]
if not self.config.cameras[camera].notifications.enabled:
return
if self.is_camera_suspended(camera):
logger.debug(f"Notifications for {camera} are currently suspended.")
return
self.send_camera_monitoring(decoded)
elif topic == "notification_test": elif topic == "notification_test":
if not self.config.notifications.enabled and not any( if not self.config.notifications.enabled and not any(
cam.notifications.enabled for cam in self.config.cameras.values() cam.notifications.enabled for cam in self.config.cameras.values()
@@ -534,30 +525,6 @@ class WebPushClient(Communicator):
self.cleanup_registrations() self.cleanup_registrations()
def send_camera_monitoring(self, payload: dict[str, Any]) -> None:
camera: str = payload["camera"]
camera_name: str = getattr(
self.config.cameras[camera], "friendly_name", None
) or titlecase(camera.replace("_", " "))
self.check_registrations()
reasoning: str = payload.get("reasoning", "")
title = f"{camera_name}: Monitoring Alert"
message = (reasoning[:197] + "...") if len(reasoning) > 200 else reasoning
logger.debug(f"Sending camera monitoring push notification for {camera_name}")
for user in self.web_pushers:
self.send_push_notification(
user=user,
payload=payload,
title=title,
message=message,
)
self.cleanup_registrations()
def stop(self) -> None: def stop(self) -> None:
logger.info("Closing notification queue") logger.info("Closing notification queue")
self.notification_thread.join() self.notification_thread.join()
+132 -3
View File
@@ -17,9 +17,117 @@ from ws4py.websocket import WebSocket as WebSocket_
from frigate.comms.base_communicator import Communicator from frigate.comms.base_communicator import Communicator
from frigate.config import FrigateConfig from frigate.config import FrigateConfig
from frigate.const import (
CLEAR_ONGOING_REVIEW_SEGMENTS,
EXPIRE_AUDIO_ACTIVITY,
INSERT_MANY_RECORDINGS,
INSERT_PREVIEW,
NOTIFICATION_TEST,
REQUEST_REGION_GRID,
UPDATE_AUDIO_ACTIVITY,
UPDATE_AUDIO_TRANSCRIPTION_STATE,
UPDATE_BIRDSEYE_LAYOUT,
UPDATE_CAMERA_ACTIVITY,
UPDATE_EMBEDDINGS_REINDEX_PROGRESS,
UPDATE_EVENT_DESCRIPTION,
UPDATE_MODEL_STATE,
UPDATE_REVIEW_DESCRIPTION,
UPSERT_REVIEW_SEGMENT,
)
from frigate.models import User
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Internal IPC topics — NEVER allowed from WebSocket, regardless of role
_WS_BLOCKED_TOPICS = frozenset(
{
INSERT_MANY_RECORDINGS,
INSERT_PREVIEW,
REQUEST_REGION_GRID,
UPSERT_REVIEW_SEGMENT,
CLEAR_ONGOING_REVIEW_SEGMENTS,
UPDATE_CAMERA_ACTIVITY,
UPDATE_AUDIO_ACTIVITY,
EXPIRE_AUDIO_ACTIVITY,
UPDATE_EVENT_DESCRIPTION,
UPDATE_REVIEW_DESCRIPTION,
UPDATE_MODEL_STATE,
UPDATE_EMBEDDINGS_REINDEX_PROGRESS,
UPDATE_BIRDSEYE_LAYOUT,
UPDATE_AUDIO_TRANSCRIPTION_STATE,
NOTIFICATION_TEST,
}
)
# Read-only topics any authenticated user (including viewer) can send
_WS_VIEWER_TOPICS = frozenset(
{
"onConnect",
"modelState",
"audioTranscriptionState",
"birdseyeLayout",
"embeddingsReindexProgress",
}
)
# Camera-scoped command topics a camera-authorized (non-admin) user may send.
_WS_CAMERA_COMMAND_TOPICS = frozenset({"ptz"})
def _check_ws_authorization(
topic: str,
role_header: str | None,
separator: str,
roles_config: dict[str, list[str]] | None = None,
camera_names: set[str] | None = None,
) -> bool:
"""Check if a WebSocket message is authorized.
Args:
topic: The message topic.
role_header: The HTTP_REMOTE_ROLE header value, or None.
separator: The role separator character from proxy config.
roles_config: The auth.roles mapping (role -> allowed cameras), used to
authorize camera-scoped commands for non-admin users.
camera_names: All configured camera names, used to resolve a role's
allowed cameras.
Returns:
True if authorized, False if blocked.
"""
# Block IPC-only topics unconditionally
if topic in _WS_BLOCKED_TOPICS:
return False
# No role header: default to viewer (fail-closed)
roles = [r.strip() for r in role_header.split(separator)] if role_header else []
# Admin can send anything
if "admin" in roles:
return True
# Read-only topics any authenticated user can send
if topic in _WS_VIEWER_TOPICS:
return True
# Camera-scoped command like "<camera>/ptz": allow when the user's role(s)
# grant access to that camera.
parts = topic.split("/")
if (
roles_config is not None
and len(parts) == 2
and parts[1] in _WS_CAMERA_COMMAND_TOPICS
):
allowed: set[str] = set()
# No role header maps to the default viewer role (e.g. proxy-only setups)
for role in roles or ["viewer"]:
allowed.update(
User.get_allowed_cameras(role, roles_config, camera_names or set())
)
return parts[0] in allowed
return False
class WebSocket(WebSocket_): # type: ignore[misc] class WebSocket(WebSocket_): # type: ignore[misc]
def unhandled_error(self, error: Any) -> None: def unhandled_error(self, error: Any) -> None:
@@ -49,6 +157,9 @@ class WebSocketClient(Communicator):
class _WebSocketHandler(WebSocket): class _WebSocketHandler(WebSocket):
receiver = self._dispatcher receiver = self._dispatcher
role_separator = self.config.proxy.separator or ","
roles_config = self.config.auth.roles
camera_names = set(self.config.cameras.keys())
def received_message(self, message: WebSocket.received_message) -> None: # type: ignore[name-defined] def received_message(self, message: WebSocket.received_message) -> None: # type: ignore[name-defined]
try: try:
@@ -63,11 +174,29 @@ class WebSocketClient(Communicator):
) )
return return
logger.debug( topic = json_message["topic"]
f"Publishing mqtt message from websockets at {json_message['topic']}."
# Authorization check (skip when environ is None — direct internal connection)
role_header = (
self.environ.get("HTTP_REMOTE_ROLE") if self.environ else None
) )
if self.environ is not None and not _check_ws_authorization(
topic,
role_header,
self.role_separator,
self.roles_config,
self.camera_names,
):
logger.warning(
"Blocked unauthorized WebSocket message: topic=%s, role=%s",
topic,
role_header,
)
return
logger.debug(f"Publishing mqtt message from websockets at {topic}.")
self.receiver( self.receiver(
json_message["topic"], topic,
json_message["payload"], json_message["payload"],
) )
+5 -5
View File
@@ -43,7 +43,7 @@ class ZmqProxy:
def stop(self) -> None: def stop(self) -> None:
# destroying the context will tell the proxy to stop # destroying the context will tell the proxy to stop
self.context.destroy(linger=0) self.context.destroy()
self.runner.join() self.runner.join()
@@ -66,8 +66,8 @@ class Publisher(Generic[T]):
self.socket.send_string(f"{self.topic}{sub_topic} {json.dumps(payload)}") self.socket.send_string(f"{self.topic}{sub_topic} {json.dumps(payload)}")
def stop(self) -> None: def stop(self) -> None:
self.socket.close(linger=0) self.socket.close()
self.context.destroy(linger=0) self.context.destroy()
class Subscriber(Generic[T]): class Subscriber(Generic[T]):
@@ -96,8 +96,8 @@ class Subscriber(Generic[T]):
return self._return_object("", None) return self._return_object("", None)
def stop(self) -> None: def stop(self) -> None:
self.socket.close(linger=0) self.socket.close()
self.context.destroy(linger=0) self.context.destroy()
def _return_object(self, topic: str, payload: T | None) -> T | None: def _return_object(self, topic: str, payload: T | None) -> T | None:
return payload return payload
-1
View File
@@ -8,7 +8,6 @@ from .config import * # noqa: F403
from .database import * # noqa: F403 from .database import * # noqa: F403
from .logger import * # noqa: F403 from .logger import * # noqa: F403
from .mqtt import * # noqa: F403 from .mqtt import * # noqa: F403
from .network import * # noqa: F403
from .proxy import * # noqa: F403 from .proxy import * # noqa: F403
from .telemetry import * # noqa: F403 from .telemetry import * # noqa: F403
from .tls import * # noqa: F403 from .tls import * # noqa: F403
+11 -35
View File
@@ -8,63 +8,39 @@ __all__ = ["AuthConfig"]
class AuthConfig(FrigateBaseModel): class AuthConfig(FrigateBaseModel):
enabled: bool = Field( enabled: bool = Field(default=True, title="Enable authentication")
default=True,
title="Enable authentication",
description="Enable native authentication for the Frigate UI.",
)
reset_admin_password: bool = Field( reset_admin_password: bool = Field(
default=False, default=False, title="Reset the admin password on startup"
title="Reset admin password",
description="If true, reset the admin user's password on startup and print the new password in logs.",
) )
cookie_name: str = Field( cookie_name: str = Field(
default="frigate_token", default="frigate_token", title="Name for jwt token cookie", pattern=r"^[a-z_]+$"
title="JWT cookie name",
description="Name of the cookie used to store the JWT token for native authentication.",
pattern=r"^[a-z_]+$",
)
cookie_secure: bool = Field(
default=False,
title="Secure cookie flag",
description="Set the secure flag on the auth cookie; should be true when using TLS.",
) )
cookie_secure: bool = Field(default=False, title="Set secure flag on cookie")
session_length: int = Field( session_length: int = Field(
default=86400, default=86400, title="Session length for jwt session tokens", ge=60
title="Session length",
description="Session duration in seconds for JWT-based sessions.",
ge=60,
) )
refresh_time: int = Field( refresh_time: int = Field(
default=1800, default=1800,
title="Session refresh window", title="Refresh the session if it is going to expire in this many seconds",
description="When a session is within this many seconds of expiring, refresh it back to full length.",
ge=30, ge=30,
) )
failed_login_rate_limit: Optional[str] = Field( failed_login_rate_limit: Optional[str] = Field(
default=None, default=None,
title="Failed login limits", title="Rate limits for failed login attempts.",
description="Rate limiting rules for failed login attempts to reduce brute-force attacks.",
) )
trusted_proxies: list[str] = Field( trusted_proxies: list[str] = Field(
default=[], default=[],
title="Trusted proxies", title="Trusted proxies for determining IP address to rate limit",
description="List of trusted proxy IPs used when determining client IP for rate limiting.",
) )
# As of Feb 2023, OWASP recommends 600000 iterations for PBKDF2-SHA256 # As of Feb 2023, OWASP recommends 600000 iterations for PBKDF2-SHA256
hash_iterations: int = Field( hash_iterations: int = Field(default=600000, title="Password hash iterations")
default=600000,
title="Hash iterations",
description="Number of PBKDF2-SHA256 iterations to use when hashing user passwords.",
)
roles: Dict[str, List[str]] = Field( roles: Dict[str, List[str]] = Field(
default_factory=dict, default_factory=dict,
title="Role mappings", title="Role to camera mappings. Empty list grants access to all cameras.",
description="Map roles to camera lists. An empty list grants access to all cameras for the role.",
) )
admin_first_time_login: Optional[bool] = Field( admin_first_time_login: Optional[bool] = Field(
default=False, default=False,
title="First-time admin flag", title="Internal field to expose first-time admin login flag to the UI",
description=( description=(
"When true the UI may show a help link on the login page informing users how to sign in after an admin password reset. " "When true the UI may show a help link on the login page informing users how to sign in after an admin password reset. "
), ),
+8 -28
View File
@@ -17,45 +17,25 @@ class AudioFilterConfig(FrigateBaseModel):
default=0.8, default=0.8,
ge=AUDIO_MIN_CONFIDENCE, ge=AUDIO_MIN_CONFIDENCE,
lt=1.0, lt=1.0,
title="Minimum audio confidence", title="Minimum detection confidence threshold for audio to be counted.",
description="Minimum confidence threshold for the audio event to be counted.",
) )
class AudioConfig(FrigateBaseModel): class AudioConfig(FrigateBaseModel):
enabled: bool = Field( enabled: bool = Field(default=False, title="Enable audio events.")
default=False,
title="Enable audio detection",
description="Enable or disable audio event detection for all cameras; can be overridden per-camera.",
)
max_not_heard: int = Field( max_not_heard: int = Field(
default=30, default=30, title="Seconds of not hearing the type of audio to end the event."
title="End timeout",
description="Amount of seconds without the configured audio type before the audio event is ended.",
) )
min_volume: int = Field( min_volume: int = Field(
default=500, default=500, title="Min volume required to run audio detection."
title="Minimum volume",
description="Minimum RMS volume threshold required to run audio detection; lower values increase sensitivity (e.g., 200 high, 500 medium, 1000 low).",
) )
listen: list[str] = Field( listen: list[str] = Field(
default=DEFAULT_LISTEN_AUDIO, default=DEFAULT_LISTEN_AUDIO, title="Audio to listen for."
title="Listen types",
description="List of audio event types to detect (for example: bark, fire_alarm, scream, speech, yell).",
) )
filters: Optional[dict[str, AudioFilterConfig]] = Field( filters: Optional[dict[str, AudioFilterConfig]] = Field(
None, None, title="Audio filters."
title="Audio filters",
description="Per-audio-type filter settings such as confidence thresholds used to reduce false positives.",
) )
enabled_in_config: Optional[bool] = Field( enabled_in_config: Optional[bool] = Field(
None, None, title="Keep track of original state of audio detection."
title="Original audio state",
description="Indicates whether audio detection was originally enabled in the static config file.",
)
num_threads: int = Field(
default=2,
title="Detection threads",
description="Number of threads to use for audio detection processing.",
ge=1,
) )
num_threads: int = Field(default=2, title="Number of detection threads", ge=1)
+14 -57
View File
@@ -29,88 +29,45 @@ class BirdseyeModeEnum(str, Enum):
class BirdseyeLayoutConfig(FrigateBaseModel): class BirdseyeLayoutConfig(FrigateBaseModel):
scaling_factor: float = Field( scaling_factor: float = Field(
default=2.0, default=2.0, title="Birdseye Scaling Factor", ge=1.0, le=5.0
title="Scaling factor",
description="Scaling factor used by the layout calculator (range 1.0 to 5.0).",
ge=1.0,
le=5.0,
)
max_cameras: Optional[int] = Field(
default=None,
title="Max cameras",
description="Maximum number of cameras to display at once in Birdseye; shows the most recent cameras.",
) )
max_cameras: Optional[int] = Field(default=None, title="Max cameras")
class BirdseyeConfig(FrigateBaseModel): class BirdseyeConfig(FrigateBaseModel):
enabled: bool = Field( enabled: bool = Field(default=True, title="Enable birdseye view.")
default=True,
title="Enable Birdseye",
description="Enable or disable the Birdseye view feature.",
)
mode: BirdseyeModeEnum = Field( mode: BirdseyeModeEnum = Field(
default=BirdseyeModeEnum.objects, default=BirdseyeModeEnum.objects, title="Tracking mode."
title="Tracking mode",
description="Mode for including cameras in Birdseye: 'objects', 'motion', or 'continuous'.",
) )
restream: bool = Field( restream: bool = Field(default=False, title="Restream birdseye via RTSP.")
default=False, width: int = Field(default=1280, title="Birdseye width.")
title="Restream RTSP", height: int = Field(default=720, title="Birdseye height.")
description="Re-stream the Birdseye output as an RTSP feed; enabling this will keep Birdseye running continuously.",
)
width: int = Field(
default=1280,
title="Width",
description="Output width (pixels) of the composed Birdseye frame.",
)
height: int = Field(
default=720,
title="Height",
description="Output height (pixels) of the composed Birdseye frame.",
)
quality: int = Field( quality: int = Field(
default=8, default=8,
title="Encoding quality", title="Encoding quality.",
description="Encoding quality for the Birdseye mpeg1 feed (1 highest quality, 31 lowest).",
ge=1, ge=1,
le=31, le=31,
) )
inactivity_threshold: int = Field( inactivity_threshold: int = Field(
default=30, default=30, title="Birdseye Inactivity Threshold", gt=0
title="Inactivity threshold",
description="Seconds of inactivity after which a camera will stop being shown in Birdseye.",
gt=0,
) )
layout: BirdseyeLayoutConfig = Field( layout: BirdseyeLayoutConfig = Field(
default_factory=BirdseyeLayoutConfig, default_factory=BirdseyeLayoutConfig, title="Birdseye Layout Config"
title="Layout",
description="Layout options for the Birdseye composition.",
) )
idle_heartbeat_fps: float = Field( idle_heartbeat_fps: float = Field(
default=0.0, default=0.0,
ge=0.0, ge=0.0,
le=10.0, le=10.0,
title="Idle heartbeat FPS", title="Idle heartbeat FPS (0 disables, max 10)",
description="Frames-per-second to resend the last composed Birdseye frame when idle; set to 0 to disable.",
) )
# uses BaseModel because some global attributes are not available at the camera level # uses BaseModel because some global attributes are not available at the camera level
class BirdseyeCameraConfig(BaseModel): class BirdseyeCameraConfig(BaseModel):
enabled: bool = Field( enabled: bool = Field(default=True, title="Enable birdseye view for camera.")
default=True,
title="Enable Birdseye",
description="Enable or disable the Birdseye view feature.",
)
mode: BirdseyeModeEnum = Field( mode: BirdseyeModeEnum = Field(
default=BirdseyeModeEnum.objects, default=BirdseyeModeEnum.objects, title="Tracking mode for camera."
title="Tracking mode",
description="Mode for including cameras in Birdseye: 'objects', 'motion', or 'continuous'.",
) )
order: int = Field( order: int = Field(default=0, title="Position of the camera in the birdseye view.")
default=0,
title="Position",
description="Numeric position controlling the camera's ordering in the Birdseye layout.",
)
+27 -98
View File
@@ -34,7 +34,6 @@ from .mqtt import CameraMqttConfig
from .notification import NotificationConfig from .notification import NotificationConfig
from .objects import ObjectConfig from .objects import ObjectConfig
from .onvif import OnvifConfig from .onvif import OnvifConfig
from .profile import CameraProfileConfig
from .record import RecordConfig from .record import RecordConfig
from .review import ReviewConfig from .review import ReviewConfig
from .snapshots import SnapshotsConfig from .snapshots import SnapshotsConfig
@@ -51,17 +50,10 @@ class CameraTypeEnum(str, Enum):
class CameraConfig(FrigateBaseModel): class CameraConfig(FrigateBaseModel):
name: Optional[str] = Field( name: Optional[str] = Field(None, title="Camera name.", pattern=REGEX_CAMERA_NAME)
None,
title="Camera name",
description="Camera name is required",
pattern=REGEX_CAMERA_NAME,
)
friendly_name: Optional[str] = Field( friendly_name: Optional[str] = Field(
None, None, title="Camera friendly name used in the Frigate UI."
title="Friendly name",
description="Camera friendly name used in the Frigate UI",
) )
@model_validator(mode="before") @model_validator(mode="before")
@@ -71,135 +63,80 @@ class CameraConfig(FrigateBaseModel):
pass pass
return values return values
enabled: bool = Field(default=True, title="Enabled", description="Enabled") enabled: bool = Field(default=True, title="Enable camera.")
# Options with global fallback # Options with global fallback
audio: AudioConfig = Field( audio: AudioConfig = Field(
default_factory=AudioConfig, default_factory=AudioConfig, title="Audio events configuration."
title="Audio events",
description="Settings for audio-based event detection for this camera.",
) )
audio_transcription: CameraAudioTranscriptionConfig = Field( audio_transcription: CameraAudioTranscriptionConfig = Field(
default_factory=CameraAudioTranscriptionConfig, default_factory=CameraAudioTranscriptionConfig,
title="Audio transcription", title="Audio transcription config.",
description="Settings for live and speech audio transcription used for events and live captions.",
) )
birdseye: BirdseyeCameraConfig = Field( birdseye: BirdseyeCameraConfig = Field(
default_factory=BirdseyeCameraConfig, default_factory=BirdseyeCameraConfig, title="Birdseye camera configuration."
title="Birdseye",
description="Settings for the Birdseye composite view that composes multiple camera feeds into a single layout.",
) )
detect: DetectConfig = Field( detect: DetectConfig = Field(
default_factory=DetectConfig, default_factory=DetectConfig, title="Object detection configuration."
title="Object Detection",
description="Settings for the detection/detect role used to run object detection and initialize trackers.",
) )
face_recognition: CameraFaceRecognitionConfig = Field( face_recognition: CameraFaceRecognitionConfig = Field(
default_factory=CameraFaceRecognitionConfig, default_factory=CameraFaceRecognitionConfig, title="Face recognition config."
title="Face recognition",
description="Settings for face detection and recognition for this camera.",
)
ffmpeg: CameraFfmpegConfig = Field(
title="FFmpeg",
description="FFmpeg settings including binary path, args, hwaccel options, and per-role output args.",
) )
ffmpeg: CameraFfmpegConfig = Field(title="FFmpeg configuration for the camera.")
live: CameraLiveConfig = Field( live: CameraLiveConfig = Field(
default_factory=CameraLiveConfig, default_factory=CameraLiveConfig, title="Live playback settings."
title="Live playback",
description="Settings used by the Web UI to control live stream selection, resolution and quality.",
) )
lpr: CameraLicensePlateRecognitionConfig = Field( lpr: CameraLicensePlateRecognitionConfig = Field(
default_factory=CameraLicensePlateRecognitionConfig, default_factory=CameraLicensePlateRecognitionConfig, title="LPR config."
title="License Plate Recognition",
description="License plate recognition settings including detection thresholds, formatting, and known plates.",
)
motion: MotionConfig = Field(
None,
title="Motion detection",
description="Default motion detection settings for this camera.",
) )
motion: MotionConfig = Field(None, title="Motion detection configuration.")
objects: ObjectConfig = Field( objects: ObjectConfig = Field(
default_factory=ObjectConfig, default_factory=ObjectConfig, title="Object configuration."
title="Objects",
description="Object tracking defaults including which labels to track and per-object filters.",
) )
record: RecordConfig = Field( record: RecordConfig = Field(
default_factory=RecordConfig, default_factory=RecordConfig, title="Record configuration."
title="Recording",
description="Recording and retention settings for this camera.",
) )
review: ReviewConfig = Field( review: ReviewConfig = Field(
default_factory=ReviewConfig, default_factory=ReviewConfig, title="Review configuration."
title="Review",
description="Settings that control alerts, detections, and GenAI review summaries used by the UI and storage for this camera.",
) )
semantic_search: CameraSemanticSearchConfig = Field( semantic_search: CameraSemanticSearchConfig = Field(
default_factory=CameraSemanticSearchConfig, default_factory=CameraSemanticSearchConfig,
title="Semantic Search", title="Semantic search configuration.",
description="Settings for semantic search which builds and queries object embeddings to find similar items.",
) )
snapshots: SnapshotsConfig = Field( snapshots: SnapshotsConfig = Field(
default_factory=SnapshotsConfig, default_factory=SnapshotsConfig, title="Snapshot configuration."
title="Snapshots",
description="Settings for API-generated snapshots of tracked objects for this camera.",
) )
timestamp_style: TimestampStyleConfig = Field( timestamp_style: TimestampStyleConfig = Field(
default_factory=TimestampStyleConfig, default_factory=TimestampStyleConfig, title="Timestamp style configuration."
title="Timestamp style",
description="Styling options for in-feed timestamps applied to recordings and snapshots.",
) )
# Options without global fallback # Options without global fallback
best_image_timeout: int = Field( best_image_timeout: int = Field(
default=60, default=60,
title="Best image timeout", title="How long to wait for the image with the highest confidence score.",
description="How long to wait for the image with the highest confidence score.",
) )
mqtt: CameraMqttConfig = Field( mqtt: CameraMqttConfig = Field(
default_factory=CameraMqttConfig, default_factory=CameraMqttConfig, title="MQTT configuration."
title="MQTT",
description="MQTT image publishing settings.",
) )
notifications: NotificationConfig = Field( notifications: NotificationConfig = Field(
default_factory=NotificationConfig, default_factory=NotificationConfig, title="Notifications configuration."
title="Notifications",
description="Settings to enable and control notifications for this camera.",
) )
onvif: OnvifConfig = Field( onvif: OnvifConfig = Field(
default_factory=OnvifConfig, default_factory=OnvifConfig, title="Camera Onvif Configuration."
title="ONVIF",
description="ONVIF connection and PTZ autotracking settings for this camera.",
)
type: CameraTypeEnum = Field(
default=CameraTypeEnum.generic,
title="Camera type",
description="Camera Type",
) )
type: CameraTypeEnum = Field(default=CameraTypeEnum.generic, title="Camera Type")
ui: CameraUiConfig = Field( ui: CameraUiConfig = Field(
default_factory=CameraUiConfig, default_factory=CameraUiConfig, title="Camera UI Modifications."
title="Camera UI",
description="Display ordering and visibility for this camera in the UI. Ordering affects the default dashboard. For more granular control, use camera groups.",
) )
webui_url: Optional[str] = Field( webui_url: Optional[str] = Field(
None, None,
title="Camera URL", title="URL to visit the camera directly from system page",
description="URL to visit the camera directly from system page",
)
profiles: dict[str, CameraProfileConfig] = Field(
default_factory=dict,
title="Profiles",
description="Named config profiles with partial overrides that can be activated at runtime.",
) )
zones: dict[str, ZoneConfig] = Field( zones: dict[str, ZoneConfig] = Field(
default_factory=dict, default_factory=dict, title="Zone configuration."
title="Zones",
description="Zones allow you to define a specific area of the frame so you can determine whether or not an object is within a particular area.",
) )
enabled_in_config: Optional[bool] = Field( enabled_in_config: Optional[bool] = Field(
default=None, default=None, title="Keep track of original state of camera."
title="Original camera state",
description="Keep track of original state of camera.",
) )
_ffmpeg_cmds: list[dict[str, list[str]]] = PrivateAttr() _ffmpeg_cmds: list[dict[str, list[str]]] = PrivateAttr()
@@ -249,14 +186,6 @@ class CameraConfig(FrigateBaseModel):
def create_ffmpeg_cmds(self): def create_ffmpeg_cmds(self):
if "_ffmpeg_cmds" in self: if "_ffmpeg_cmds" in self:
return return
self._build_ffmpeg_cmds()
def recreate_ffmpeg_cmds(self):
"""Force regeneration of ffmpeg commands from current config."""
self._build_ffmpeg_cmds()
def _build_ffmpeg_cmds(self):
"""Build ffmpeg commands from the current ffmpeg config."""
ffmpeg_cmds = [] ffmpeg_cmds = []
for ffmpeg_input in self.ffmpeg.inputs: for ffmpeg_input in self.ffmpeg.inputs:
ffmpeg_cmd = self._get_ffmpeg_cmd(ffmpeg_input) ffmpeg_cmd = self._get_ffmpeg_cmd(ffmpeg_input)
+14 -40
View File
@@ -8,82 +8,56 @@ __all__ = ["DetectConfig", "StationaryConfig", "StationaryMaxFramesConfig"]
class StationaryMaxFramesConfig(FrigateBaseModel): class StationaryMaxFramesConfig(FrigateBaseModel):
default: Optional[int] = Field( default: Optional[int] = Field(default=None, title="Default max frames.", ge=1)
default=None,
title="Default max frames",
description="Default maximum frames to track a stationary object before stopping.",
ge=1,
)
objects: dict[str, int] = Field( objects: dict[str, int] = Field(
default_factory=dict, default_factory=dict, title="Object specific max frames."
title="Object max frames",
description="Per-object overrides for maximum frames to track stationary objects.",
) )
class StationaryConfig(FrigateBaseModel): class StationaryConfig(FrigateBaseModel):
interval: Optional[int] = Field( interval: Optional[int] = Field(
default=None, default=None,
title="Stationary interval", title="Frame interval for checking stationary objects.",
description="How often (in frames) to run a detection check to confirm a stationary object.",
gt=0, gt=0,
) )
threshold: Optional[int] = Field( threshold: Optional[int] = Field(
default=None, default=None,
title="Stationary threshold", title="Number of frames without a position change for an object to be considered stationary",
description="Number of frames with no position change required to mark an object as stationary.",
ge=1, ge=1,
) )
max_frames: StationaryMaxFramesConfig = Field( max_frames: StationaryMaxFramesConfig = Field(
default_factory=StationaryMaxFramesConfig, default_factory=StationaryMaxFramesConfig,
title="Max frames", title="Max frames for stationary objects.",
description="Limits how long stationary objects are tracked before being discarded.",
) )
classifier: bool = Field( classifier: bool = Field(
default=True, default=True,
title="Enable visual classifier", title="Enable visual classifier for determing if objects with jittery bounding boxes are stationary.",
description="Use a visual classifier to detect truly stationary objects even when bounding boxes jitter.",
) )
class DetectConfig(FrigateBaseModel): class DetectConfig(FrigateBaseModel):
enabled: bool = Field( enabled: bool = Field(default=False, title="Detection Enabled.")
default=False,
title="Enable object detection",
description="Enable or disable object detection for all cameras; can be overridden per-camera.",
)
height: Optional[int] = Field( height: Optional[int] = Field(
default=None, default=None, title="Height of the stream for the detect role."
title="Detect height",
description="Height (pixels) of frames used for the detect stream; leave empty to use the native stream resolution.",
) )
width: Optional[int] = Field( width: Optional[int] = Field(
default=None, default=None, title="Width of the stream for the detect role."
title="Detect width",
description="Width (pixels) of frames used for the detect stream; leave empty to use the native stream resolution.",
) )
fps: int = Field( fps: int = Field(
default=5, default=5, title="Number of frames per second to process through detection."
title="Detect FPS",
description="Desired frames per second to run detection on; lower values reduce CPU usage (recommended value is 5, only set higher - at most 10 - if tracking extremely fast moving objects).",
) )
min_initialized: Optional[int] = Field( min_initialized: Optional[int] = Field(
default=None, default=None,
title="Minimum initialization frames", title="Minimum number of consecutive hits for an object to be initialized by the tracker.",
description="Number of consecutive detection hits required before creating a tracked object. Increase to reduce false initializations. Default value is fps divided by 2.",
) )
max_disappeared: Optional[int] = Field( max_disappeared: Optional[int] = Field(
default=None, default=None,
title="Maximum disappeared frames", title="Maximum number of frames the object can disappear before detection ends.",
description="Number of frames without a detection before a tracked object is considered gone.",
) )
stationary: StationaryConfig = Field( stationary: StationaryConfig = Field(
default_factory=StationaryConfig, default_factory=StationaryConfig,
title="Stationary objects config", title="Stationary objects config.",
description="Settings to detect and manage objects that remain stationary for a period of time.",
) )
annotation_offset: int = Field( annotation_offset: int = Field(
default=0, default=0, title="Milliseconds to offset detect annotations by."
title="Annotation offset",
description="Milliseconds to shift detect annotations to better align timeline bounding boxes with recordings; can be positive or negative.",
) )

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