Compare commits

...
Author SHA1 Message Date
Josh Hawkins fe4dca2bbd scope classification attributes to allowed cameras 2026-08-25 14:40:13 -05:00
Josh HawkinsandGitHub 3109e7539e Recording fixes (#24072)
* pin genai review frames to the main stream

* retain previews as long as either stream has recordings

* watch sub stream recording health separately from main

* reject record_sub on the same input as record and document the role

* derive recording paths from the cache segment timestamp

Recording paths carry one second of resolution, but since sub stream recording start times are resolved to fractional wall clock, anchored to the cache file mtime and chained to the previous segment's end. A stream cutting segments faster than once a second resolves consecutive segments into the same second, so two rows collide on the unique path index and the batch insert fails. The cache segment name is unique per camera stream and second by construction because ffmpeg names segments with strftime, so the recording path is now built from that timestamp while the row keeps the resolved start time. This also restores the path semantics from before sub stream recording, when start times came straight from the cache filename.

Nothing derives times from recording paths: playback offsets, stream switching, and export all use the row's start time, which is unchanged, and the recordings sync matches files by exact path string.

* keep the rest of a recording batch when one row conflicts

* only publish record_sub status when a sub stream is configured

* don't shadow camera_cfg when publishing empty cache streams

* back off restarts when a recording stream goes stale

* give the shared sub stream grace on any capture thread reset

* include segment details in recording discard warnings
2026-08-25 11:51:58 -06:00
Josh HawkinsandGitHub 2347f954bb Container security hardening (phase 2) (#24068)
* Create frigate and go2rtc runtime users in the image

* Add single fix-ownership helper for volume permission migration

* Add init-usermod oneshot for PUID and PGID remapping

* Chown newly created runtime directories to the frigate user

* Run sentinel-guarded ownership sweep during prepare

* Add host-side volume permission migration script

* Guard log directory ownership for user-mode startup

* Fall back to plain s6-log when running without root

* Assert PUID remapping and sweep sentinel in CI smoke test

* Skip the ownership sweep in the devcontainer

* Pin FRIGATE_RUN_AS_ROOT in ownership tests

* Do not record the sweep as complete when a chown failed

* Validate PUID and PGID in the migration script

* Treat a failed ownership scan as an incomplete sweep

* Reject PUID and PGID of 0 during remapping

* Handle symlinks, dry runs, and sentinel write failures in the sweep

* Treat an absent sweep root as an incomplete sweep
2026-08-23 14:40:32 -06:00
Josh HawkinsandGitHub 2638729c56 Container security hardening (phase 1) (#24061)
* Verify s6-overlay downloads against pinned checksums

* Verify go2rtc download against pinned checksums

The v1.9.14 release publishes no checksums file, just the bare per-platform binaries, so these digests come from a one-time fetch rather than upstream. That pins the artifact against later substitution, which is the realistic threat for a version we stay on for months, but it does not verify the original download. The stage moves from `ADD --link` to a script because `ADD --checksum` can't express an architecture-dependent URL.

* Verify main image downloads against pinned checksums

Covers everything the main image downloads on the default path: tempio, the hailort runtime tarball and wheel, the six ffmpeg builds, the libedgetpu deb, and the thirteen Intel driver debs. The hailort tarball was streamed straight into `tar`, which can't be verified before extraction, so it downloads to `/tmp` first. The three ffmpeg blocks per arch collapse into one `install_ffmpeg` helper since they only differed by URL and install dir, and the Intel debs go through a `fetch_intel_deb` helper for the same reason.

The Intel debs are the ones that mattered most here. They're installed as root with `dpkg` on the default amd64 path and had no verification at all. compute-runtime publishes a `ww<week>.sum` asset with every release and npu-driver published `checksum.sha256` on v1.19.0, so those eight digests came from upstream rather than from us. intel-graphics-compiler and level-zero publish none, so those five and everything else here come from a one-time fetch, which pins the artifact against later substitution but doesn't verify the original download. The comment above the map says which is which and how to refresh them, since npu-driver has stopped publishing sums since v1.19.0 and that provenance won't survive the next bump.

Still unpinned: `get-pip.py`, which is a rolling URL where a digest would just break the build on pypa's next edit, and the per-variant artifacts for Axera, Synaptics, and Jetson. apt repositories are out of scope since apt already verifies signatures.

* Restrict generated TLS key permissions

OpenSSL 3.x already writes the key at 600 on its own, so this pins the guarantee rather than fixing an observed leak: the mode no longer depends on the openssl version or the umask the service happens to start with. Only the generated pair is touched. User-mounted certs take the other branch and are never chmod'd, which matters when they're mounted read-only.

* Add security headers and server_tokens off

Adds `X-Content-Type-Options: nosniff` and `Referrer-Policy: strict-origin-when-cross-origin`, and turns off nginx version disclosure.

No `X-Frame-Options` and no CSP `frame-ancestors`. HA's Webpage card and iframe panels frame Frigate's own address cross-origin, and either header would break them silently with nothing in Frigate's logs to explain it. Ingress is same-origin and would survive `SAMEORIGIN`, but Frigate can't tell the two apart from inside the container. `security_headers.conf` is a plain file in the image rather than a generated one, so anyone who does want framing restrictions can bind-mount it.

`add_header` doesn't inherit into a block that declares its own, so the include goes in per block, all nine of them, including the four nested static-asset locations that serve the JS bundles. Those are the ones nosniff actually matters for.

The run script now reads `get_nginx_settings.py` once into a variable instead of shelling out per template. That script imports the frigate config machinery, which is noticeable on an SBC.

Not fixed here: `listen.conf` is included at server level and carries `Strict-Transport-Security`, so those same nine blocks already drop HSTS under TLS today. Folding it into this file would change existing TLS behavior on nine paths, so it needs its own PR.

* Restrict go2rtc config file permissions

* Log failed login attempts with source address

Failed logins returned a bare 401 and left nothing behind, so credential stuffing was invisible unless you were already watching nginx access logs. Both failure branches now log a warning with the attempted username and the client address.

The address comes from `get_remote_addr()`, the same helper the login rate limiter keys on, so the two agree on who the client is and the trusted-proxy handling is consistent. Logging a raw `x-forwarded-for` instead would let an attacker forge the source address in the very log line meant to catch them.

The response is unchanged and identical either way. Which factor failed is only visible in the log, never to the client, and the password is never logged.

* Recommend least-privilege container options in install docs

The compose generator pushed `privileged: true` into every file it produced, no matter what hardware you picked, and it's the default tab on the install page so it's what most people copy. It now emits `security_opt: no-new-privileges:true` instead, and only adds `privileged: true` for hardware that actually needs it, with the reason inline. MemryX is the only one today, since it needs to reach the max-manager. Rockchip and Synaptics only want privileged during initial setup and their documented end state is device mappings, so neither gets it.

`no-new-privileges` merges into the same `security_opt` block as any device-specific entries, so Rockchip still gets its `apparmor=unconfined` and `systempaths=unconfined` without a duplicate key.

The static example now has `privileged` commented out, and there's a short section on the options worth adding, with a note that `cap_drop: ALL` breaks `telemetry.stats.network_bandwidth` since nethogs needs NET_ADMIN/NET_RAW.

* Add amd64 container smoke test to CI

Boots the built amd64 image against a minimal config and asserts the two security headers, that the Server header no longer carries a version, that no frame-ancestors is present, that nginx accepts its own config, and the two file modes. This is also the harness the rest of the hardening work extends.

The two negative assertions are written as `if grep; then exit 1; fi` rather than `! grep`. Bash exempts a negated command from `set -e`, so the `!` form would have passed even with the version and frame-ancestors both present, which is the opposite of what a regression net is for.
2026-08-23 10:50:03 -06:00
Josh HawkinsandGitHub 7a49eb4bbb Tweaks (#24067)
* improve keyframes messages

* don't pad the labelmap with unknown

`load_labels()` prefilled 91 `unknown` entries before reading the label file, so any model with fewer than 91 classes kept that padding in `merged_labelmap` and `unknown` showed up as a selectable object type in the objects settings UI. The padding only existed so `RemoteObjectDetector.detect` could index the labelmap without a KeyError, and it didn't even cover the empty-file case or Frigate+, which never had a prefill. Both lookups now skip class ids the labelmap doesn't name and warn once per id.
2026-08-23 10:31:31 -06:00
Josh HawkinsandGitHub 468258a7c3 Add secrets.yaml and unify variable substitution sources (#24044)
* add secrets.yaml and merge substitution sources by precedence

FRIGATE_ENV_VARS was built once at import from container env and /run/secrets, and the environment_vars validator overwrote it unconditionally, so the block beat the deployment and nothing could be re-read. Sources are now separate dicts merged lowest to highest (environment_vars, secrets.yaml, container env, credentials directory), re-read at the top of every parse, and a collision warns once naming the winner. An undefined {FRIGATE_*} raises a ValueError subclass so pydantic reports the field instead of a KeyError traceback.

* use the shared substitution namespace in go2rtc config

The generator rebuilt the namespace itself from os.environ and a hardcoded /run/secrets, so it never saw environment_vars or CREDENTIALS_DIRECTORY, and str.format made any stray brace fatal. It now installs the FRIGATE_ names from environment_vars and substitutes streams the same way every other field does.

* read the exec override from an import time snapshot

environment_vars is exported into os.environ, and is_go2rtc_arbitrary_exec_allowed read os.environ live, so the config file could enable exec sources. Snapshot the variable at import, which runs before any config is loaded.

* docs

* clarify docs
2026-08-23 11:08:32 -05:00
Josh HawkinsandGitHub c1f9896443 add recognized plate picker to lpr known plates in settings (#24059) 2026-08-22 18:01:38 -06:00
73 changed files with 2941 additions and 320 deletions
+83
View File
@@ -42,6 +42,89 @@ jobs:
tags: ${{ steps.setup.outputs.image-name }}-amd64 tags: ${{ steps.setup.outputs.image-name }}-amd64
cache-from: type=registry,ref=${{ steps.setup.outputs.cache-name }}-amd64 cache-from: type=registry,ref=${{ steps.setup.outputs.cache-name }}-amd64
cache-to: type=registry,ref=${{ steps.setup.outputs.cache-name }}-amd64,mode=max cache-to: type=registry,ref=${{ steps.setup.outputs.cache-name }}-amd64,mode=max
smoke_test:
runs-on: ubuntu-22.04
name: AMD64 Smoke Test
needs:
- amd64_build
steps:
- name: Check out code
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Set up QEMU and Buildx
id: setup
uses: ./.github/actions/setup
with:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Start container
run: |
mkdir -p /tmp/frigate-config
printf 'mqtt:\n enabled: false\ncameras: {}\n' > /tmp/frigate-config/config.yml
docker run -d --name frigate --shm-size 256m \
-v /tmp/frigate-config:/config \
-p 5000:5000 -p 8971:8971 \
${{ steps.setup.outputs.image-name }}-amd64
- name: Wait for API
run: |
for i in $(seq 1 60); do
curl -fs http://127.0.0.1:5000/api/version && exit 0
sleep 5
done
echo "API never came up"; docker logs frigate; exit 1
- name: Assert security headers and permissions
run: |
headers=$(curl -ksI https://127.0.0.1:8971/)
echo "$headers"
echo "$headers" | grep -qi "x-content-type-options: nosniff"
echo "$headers" | grep -qi "referrer-policy: strict-origin-when-cross-origin"
# server_tokens off: Server header must not include a version.
# written as an if rather than "! grep", because bash exempts a
# negated command from set -e and the assertion would never fail
if echo "$headers" | grep -qiE "^server: nginx/[0-9]"; then
echo "Server header leaks the nginx version; server_tokens is not off"
exit 1
fi
# Frigate never ships frame-ancestors: HA's Webpage card and iframe
# panels frame it cross-origin and it would break them silently
if echo "$headers" | grep -qi "frame-ancestors"; then
echo "response carries frame-ancestors, which breaks cross-origin iframe embedding"
exit 1
fi
docker exec frigate /usr/local/nginx/sbin/nginx -t
docker exec frigate stat -c %a /etc/letsencrypt/live/frigate/privkey.pem | grep -qx 600
docker exec frigate stat -c %a /dev/shm/go2rtc.yaml | grep -qx 640
- name: Assert PUID/PGID remapping
run: |
mkdir -p /tmp/frigate-config-puid
printf 'mqtt:\n enabled: false\ncameras: {}\n' > /tmp/frigate-config-puid/config.yml
docker run -d --name frigate-puid --shm-size 256m \
-e PUID=1500 -e PGID=1500 \
-v /tmp/frigate-config-puid:/config \
${{ steps.setup.outputs.image-name }}-amd64
up=0
for i in $(seq 1 60); do
docker exec frigate-puid curl -fs http://127.0.0.1:5000/api/version && up=1 && break
sleep 5
done
if [ "$up" -ne 1 ]; then echo "PUID container never became healthy"; docker logs frigate-puid; exit 1; fi
docker exec frigate-puid id -u frigate | grep -qx 1500
docker exec frigate-puid id -g frigate | grep -qx 1500
docker exec frigate-puid cat /config/.permissions_version | grep -qx "1:1500:1500"
# second boot must skip the sweep (sentinel hit). Poll rather than
# sleep: the string can only come from the second boot (the first
# had no sentinel), so grepping the full log is unambiguous.
docker restart frigate-puid
ok=0
for i in $(seq 1 30); do
docker logs frigate-puid 2>&1 | grep -q "already applied" && ok=1 && break
sleep 2
done
if [ "$ok" -ne 1 ]; then echo "sentinel skip never logged"; docker logs frigate-puid; exit 1; fi
docker rm -f frigate-puid
- name: Teardown
if: always()
run: docker rm -f frigate || true
arm64_build: arm64_build:
runs-on: ubuntu-22.04-arm runs-on: ubuntu-22.04-arm
name: ARM Build name: ARM Build
+25 -3
View File
@@ -60,10 +60,10 @@ ARG DEBIAN_FRONTEND
RUN --mount=type=bind,source=docker/main/build_intel_media_driver.sh,target=/deps/build_intel_media_driver.sh \ RUN --mount=type=bind,source=docker/main/build_intel_media_driver.sh,target=/deps/build_intel_media_driver.sh \
/deps/build_intel_media_driver.sh /deps/build_intel_media_driver.sh
FROM scratch AS go2rtc FROM wget AS go2rtc
ARG TARGETARCH ARG TARGETARCH
WORKDIR /rootfs/usr/local/go2rtc/bin RUN --mount=type=bind,source=docker/main/install_go2rtc.sh,target=/deps/install_go2rtc.sh \
ADD --link --chmod=755 "https://github.com/AlexxIT/go2rtc/releases/download/v1.9.14/go2rtc_linux_${TARGETARCH}" go2rtc /deps/install_go2rtc.sh
FROM wget AS tempio FROM wget AS tempio
ARG TARGETARCH ARG TARGETARCH
@@ -265,6 +265,23 @@ ENV PATH="/usr/local/go2rtc/bin:/usr/local/tempio/bin:/usr/local/nginx/sbin:${PA
RUN --mount=type=bind,source=docker/main/install_deps.sh,target=/deps/install_deps.sh \ RUN --mount=type=bind,source=docker/main/install_deps.sh,target=/deps/install_deps.sh \
/deps/install_deps.sh /deps/install_deps.sh
# Runtime users. frigate may be remapped at start via PUID/PGID (init-usermod)
# or replaced entirely with docker's --user. go2rtc is intentionally separate
# and more restricted. frigate-data is the shared group for /config access.
# -o tolerates variant base images that already contain uid/gid 1000.
RUN groupadd -o --gid 1000 frigate \
&& useradd -o --uid 1000 --gid frigate --no-create-home --shell /usr/sbin/nologin frigate \
&& groupadd --system go2rtc \
&& useradd --system --gid go2rtc --no-create-home --shell /usr/sbin/nologin go2rtc \
&& groupadd --system frigate-data \
&& usermod -aG frigate-data frigate \
&& usermod -aG frigate-data go2rtc \
&& for grp in video render plugdev audio; do \
if getent group "$grp" >/dev/null; then \
usermod -aG "$grp" frigate && usermod -aG "$grp" go2rtc; \
fi; \
done
ENV DEFAULT_FFMPEG_VERSION="8.0" ENV DEFAULT_FFMPEG_VERSION="8.0"
ENV INCLUDED_FFMPEG_VERSIONS="${DEFAULT_FFMPEG_VERSION}:7.0:5.0" ENV INCLUDED_FFMPEG_VERSIONS="${DEFAULT_FFMPEG_VERSION}:7.0:5.0"
@@ -307,6 +324,11 @@ HEALTHCHECK --start-period=300s --start-interval=5s --interval=15s --timeout=5s
# Frigate deps with Node.js and NPM for devcontainer # Frigate deps with Node.js and NPM for devcontainer
FROM deps AS devcontainer FROM deps AS devcontainer
# /config here is the developer's bind-mounted checkout, not a data volume, so
# the prepare ownership sweep must not run: it would chown the source tree to
# the runtime uid and lock out any container user that isn't 1000.
ENV FRIGATE_RUN_AS_ROOT=true
# Do not start the actual Frigate service on devcontainer as it will be started by VS Code # Do not start the actual Frigate service on devcontainer as it will be started by VS Code
# But start a fake service for simulating the logs # But start a fake service for simulating the logs
COPY docker/main/fake_frigate_run /etc/s6-overlay/s6-rc.d/frigate/run COPY docker/main/fake_frigate_run /etc/s6-overlay/s6-rc.d/frigate/run
+77 -37
View File
@@ -28,7 +28,13 @@ update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.11 1
mkdir -p -m 600 /root/.gnupg mkdir -p -m 600 /root/.gnupg
# install coral runtime # install coral runtime
# sha256 digests of the release debs; update when bumping the libedgetpu release.
declare -A edgetpu_checksums=(
["amd64"]="63fd00989d29160fa9894e115156a9abe456e88751fc9be89d26e4696200441b"
["arm64"]="eab8aa4576b4dbf738135d8094f32270b24117f77147d25cbe0f49d0144d85f2"
)
wget -q -O /tmp/libedgetpu1-max.deb "https://github.com/feranick/libedgetpu/releases/download/16.0TF2.17.1-1/libedgetpu1-max_16.0tf2.17.1-1.bookworm_${TARGETARCH}.deb" wget -q -O /tmp/libedgetpu1-max.deb "https://github.com/feranick/libedgetpu/releases/download/16.0TF2.17.1-1/libedgetpu1-max_16.0tf2.17.1-1.bookworm_${TARGETARCH}.deb"
echo "${edgetpu_checksums[${TARGETARCH}]} /tmp/libedgetpu1-max.deb" | sha256sum -c -
unset DEBIAN_FRONTEND unset DEBIAN_FRONTEND
yes | dpkg -i /tmp/libedgetpu1-max.deb && export DEBIAN_FRONTEND=noninteractive yes | dpkg -i /tmp/libedgetpu1-max.deb && export DEBIAN_FRONTEND=noninteractive
rm /tmp/libedgetpu1-max.deb rm /tmp/libedgetpu1-max.deb
@@ -45,36 +51,41 @@ if [[ "${TARGETARCH}" == "arm64" ]]; then
fi fi
fi fi
# sha256 digests of the ffmpeg builds, keyed "<install dir>-<arch>".
# Upstream publishes no checksums; these come from a one-time fetch and guard
# against later substitution. Update when bumping a build URL.
declare -A ffmpeg_checksums=(
["5.0-amd64"]="377abec133f9d9e8014dee1b91c9684ac8bb0b5b7d80100a57116ff837c4c0d4"
["7.0-amd64"]="e13860eb90409c8218319c928067834ce450128e86f24cfed5cfe91ce6e31037"
["8.0-amd64"]="9bac85054d351cdc89c0a4f45c8ea5c44df94009aabd964b719bbadd56aedae9"
["5.0-arm64"]="57ee475407bad49910ba9b946428396e30cf075ea28a7912fbe1aa2578085af0"
["7.0-arm64"]="16c8b04e9d0ea9c769ad964c4c453fcf05121a1947237329d2e9d8a5e43e2a3c"
["8.0-arm64"]="cd91948468d0f11ce795a2cdaa0c69911bd1db313b49bb19c22512beb88cde69"
)
# the tarballs nest their binaries under a directory named for the arch, which
# matches TARGETARCH for both builds we consume
install_ffmpeg() {
local dir="$1" url="$2"
mkdir -p "/usr/lib/ffmpeg/${dir}"
wget -qO ffmpeg.tar.xz "${url}"
echo "${ffmpeg_checksums[${dir}-${TARGETARCH}]} ffmpeg.tar.xz" | sha256sum -c -
tar -xf ffmpeg.tar.xz -C "/usr/lib/ffmpeg/${dir}" --strip-components 1 "${TARGETARCH}/bin/ffmpeg" "${TARGETARCH}/bin/ffprobe"
rm -f ffmpeg.tar.xz
}
# ffmpeg -> amd64 # ffmpeg -> amd64
if [[ "${TARGETARCH}" == "amd64" ]]; then if [[ "${TARGETARCH}" == "amd64" ]]; then
mkdir -p /usr/lib/ffmpeg/5.0 install_ffmpeg 5.0 "https://github.com/NickM-27/FFmpeg-Builds/releases/download/autobuild-2022-07-31-12-37/ffmpeg-n5.1-2-g915ef932a3-linux64-gpl-5.1.tar.xz"
wget -qO ffmpeg.tar.xz "https://github.com/NickM-27/FFmpeg-Builds/releases/download/autobuild-2022-07-31-12-37/ffmpeg-n5.1-2-g915ef932a3-linux64-gpl-5.1.tar.xz" install_ffmpeg 7.0 "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/5.0 --strip-components 1 amd64/bin/ffmpeg amd64/bin/ffprobe install_ffmpeg 8.0 "https://github.com/NickM-27/FFmpeg-Builds/releases/download/autobuild-2026-06-02-14-20/ffmpeg-n8.1.1-9-g58d4114d36-linux64-gpl-8.1.tar.xz"
rm -rf ffmpeg.tar.xz
mkdir -p /usr/lib/ffmpeg/7.0
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
rm -rf ffmpeg.tar.xz
mkdir -p /usr/lib/ffmpeg/8.0
wget -qO ffmpeg.tar.xz "https://github.com/NickM-27/FFmpeg-Builds/releases/download/autobuild-2026-06-02-14-20/ffmpeg-n8.1.1-9-g58d4114d36-linux64-gpl-8.1.tar.xz"
tar -xf ffmpeg.tar.xz -C /usr/lib/ffmpeg/8.0 --strip-components 1 amd64/bin/ffmpeg amd64/bin/ffprobe
rm -rf ffmpeg.tar.xz
fi fi
# ffmpeg -> arm64 # ffmpeg -> arm64
if [[ "${TARGETARCH}" == "arm64" ]]; then if [[ "${TARGETARCH}" == "arm64" ]]; then
mkdir -p /usr/lib/ffmpeg/5.0 install_ffmpeg 5.0 "https://github.com/NickM-27/FFmpeg-Builds/releases/download/autobuild-2022-07-31-12-37/ffmpeg-n5.1-2-g915ef932a3-linuxarm64-gpl-5.1.tar.xz"
wget -qO ffmpeg.tar.xz "https://github.com/NickM-27/FFmpeg-Builds/releases/download/autobuild-2022-07-31-12-37/ffmpeg-n5.1-2-g915ef932a3-linuxarm64-gpl-5.1.tar.xz" install_ffmpeg 7.0 "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/5.0 --strip-components 1 arm64/bin/ffmpeg arm64/bin/ffprobe install_ffmpeg 8.0 "https://github.com/NickM-27/FFmpeg-Builds/releases/download/autobuild-2026-06-02-14-20/ffmpeg-n8.1.1-9-g58d4114d36-linuxarm64-gpl-8.1.tar.xz"
rm -f ffmpeg.tar.xz
mkdir -p /usr/lib/ffmpeg/7.0
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
rm -f ffmpeg.tar.xz
mkdir -p /usr/lib/ffmpeg/8.0
wget -qO ffmpeg.tar.xz "https://github.com/NickM-27/FFmpeg-Builds/releases/download/autobuild-2026-06-02-14-20/ffmpeg-n8.1.1-9-g58d4114d36-linuxarm64-gpl-8.1.tar.xz"
tar -xf ffmpeg.tar.xz -C /usr/lib/ffmpeg/8.0 --strip-components 1 arm64/bin/ffmpeg arm64/bin/ffprobe
rm -f ffmpeg.tar.xz
fi fi
# arch specific packages # arch specific packages
@@ -120,27 +131,56 @@ if [[ "${TARGETARCH}" == "amd64" ]]; then
apt-get -qq install -y libtbb12 apt-get -qq install -y libtbb12
# install legacy and standard intel compute packages # install legacy and standard intel compute packages
# sha256 digests of the driver debs, taken from the ww<week>.sum asset
# compute-runtime ships per release and the checksum.sha256 on npu-driver
# v1.19.0; intel-graphics-compiler and level-zero publish none, so those
# five are hash-what-you-get. Refresh after a version bump with
# `curl -sL <url> | sha256sum`, cross-checking upstream's sum where the
# release still has one. npu-driver stopped publishing them after v1.19.0.
declare -A intel_checksums=(
["libigdgmm12_22.9.0_amd64.deb"]="9d712f71c18baee076de9961dda71e8089291e1bd0deb5d649ab5ba5de114f97"
["intel-opencl-icd-legacy1_24.35.30872.36_amd64.deb"]="bbe71e4f414259e06a10cde72c29a2bd78d41b2bb2f6f8463b1806797fe66e85"
["intel-level-zero-gpu-legacy1_1.5.30872.36_amd64.deb"]="40dfbd15ab62de036a00824b304a2aa1fa2d81ad60ef83da09cfe3c5a80c429f"
["intel-igc-opencl_1.0.17537.24_amd64.deb"]="dd016400f87fa2b6a9fa9fbcca7eb4a2629174a29de679709f9bec5cede88b0e"
["intel-igc-core_1.0.17537.24_amd64.deb"]="c1e1ecdfe2064c047c552651cfdcdafc504f2033afafba65654338b880048b67"
["intel-opencl-icd_26.14.37833.4-0_amd64.deb"]="2e15eeb4fe9c1bba467a655967373eec6a20dd04cc7159de53c359f17ab53e41"
["libze-intel-gpu1_26.14.37833.4-0_amd64.deb"]="34ce5791160d87ce6d54edb558a4030858ee1dad2afb067b9c5c58d4cde774c6"
["intel-igc-opencl-2_2.32.7+21184_amd64.deb"]="3c9bddbfe558279402bbeaabcf9c63b8de46b956b0ad9625415fd35dda53ad52"
["intel-igc-core-2_2.32.7+21184_amd64.deb"]="64e5230788e3a31e611e8d815a141b1facb91e5f0ef239233ef3f0614bfe3fd6"
["level-zero_1.28.2+u22.04_amd64.deb"]="9015a579abef960166f8e943858d5c81fd4199a960f07260c1da66038257effb"
["intel-driver-compiler-npu_1.19.0.20250707-16111289554_ubuntu22.04_amd64.deb"]="8087bfcc0872d7976d0163203c7c783a4176f813c473766587e86c7b34135dff"
["intel-fw-npu_1.19.0.20250707-16111289554_ubuntu22.04_amd64.deb"]="740219c03495f8812c03ab74baf8199acf17d13929001105418d4ba226ba2290"
["intel-level-zero-npu_1.19.0.20250707-16111289554_ubuntu22.04_amd64.deb"]="f4f5eb97aa7da52c7fec97e4ddfb43aae01703bbadc767bae1f2d4faf342ba42"
)
fetch_intel_deb() {
local url="$1" name
name=$(basename "$url")
wget -q "$url"
echo "${intel_checksums[${name}]} ${name}" | sha256sum -c -
}
# 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/26.14.37833.4/libigdgmm12_22.9.0_amd64.deb fetch_intel_deb https://github.com/intel/compute-runtime/releases/download/26.14.37833.4/libigdgmm12_22.9.0_amd64.deb
dpkg -i libigdgmm12_22.9.0_amd64.deb dpkg -i libigdgmm12_22.9.0_amd64.deb
rm libigdgmm12_22.9.0_amd64.deb rm libigdgmm12_22.9.0_amd64.deb
# legacy compute-runtime packages # legacy compute-runtime packages
wget https://github.com/intel/compute-runtime/releases/download/24.35.30872.36/intel-opencl-icd-legacy1_24.35.30872.36_amd64.deb fetch_intel_deb 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-level-zero-gpu-legacy1_1.5.30872.36_amd64.deb fetch_intel_deb https://github.com/intel/compute-runtime/releases/download/24.35.30872.36/intel-level-zero-gpu-legacy1_1.5.30872.36_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 fetch_intel_deb 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 fetch_intel_deb https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-core_1.0.17537.24_amd64.deb
# standard compute-runtime packages # standard compute-runtime packages
wget https://github.com/intel/compute-runtime/releases/download/26.14.37833.4/intel-opencl-icd_26.14.37833.4-0_amd64.deb fetch_intel_deb https://github.com/intel/compute-runtime/releases/download/26.14.37833.4/intel-opencl-icd_26.14.37833.4-0_amd64.deb
wget https://github.com/intel/compute-runtime/releases/download/26.14.37833.4/libze-intel-gpu1_26.14.37833.4-0_amd64.deb fetch_intel_deb https://github.com/intel/compute-runtime/releases/download/26.14.37833.4/libze-intel-gpu1_26.14.37833.4-0_amd64.deb
wget https://github.com/intel/intel-graphics-compiler/releases/download/v2.32.7/intel-igc-opencl-2_2.32.7+21184_amd64.deb fetch_intel_deb https://github.com/intel/intel-graphics-compiler/releases/download/v2.32.7/intel-igc-opencl-2_2.32.7+21184_amd64.deb
wget https://github.com/intel/intel-graphics-compiler/releases/download/v2.32.7/intel-igc-core-2_2.32.7+21184_amd64.deb fetch_intel_deb https://github.com/intel/intel-graphics-compiler/releases/download/v2.32.7/intel-igc-core-2_2.32.7+21184_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 fetch_intel_deb 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/intel/linux-npu-driver/releases/download/v1.19.0/intel-driver-compiler-npu_1.19.0.20250707-16111289554_ubuntu22.04_amd64.deb fetch_intel_deb 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.19.0/intel-fw-npu_1.19.0.20250707-16111289554_ubuntu22.04_amd64.deb fetch_intel_deb 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.19.0/intel-level-zero-npu_1.19.0.20250707-16111289554_ubuntu22.04_amd64.deb fetch_intel_deb 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
dpkg -i *.deb dpkg -i *.deb
rm *.deb rm *.deb
+19
View File
@@ -0,0 +1,19 @@
#!/bin/bash
set -euxo pipefail
go2rtc_version="1.9.14"
# sha256 digests of the release binaries; update when bumping go2rtc_version.
declare -A go2rtc_checksums=(
["amd64"]="32d616af226bd731678ffde328b94cfb94e30339bfefc469cfb76323144615a6"
["arm64"]="359fabade8a7a51e81a55fe6df6b0ef81764a5e1d63179577534eaaa71904b50"
)
dest_dir="/rootfs/usr/local/go2rtc/bin"
mkdir -p "${dest_dir}"
wget -qO "${dest_dir}/go2rtc" \
"https://github.com/AlexxIT/go2rtc/releases/download/v${go2rtc_version}/go2rtc_linux_${TARGETARCH}"
echo "${go2rtc_checksums[${TARGETARCH}]} ${dest_dir}/go2rtc" | sha256sum -c -
chmod 755 "${dest_dir}/go2rtc"
+20 -2
View File
@@ -4,11 +4,29 @@ set -euxo pipefail
hailo_version="4.21.0" hailo_version="4.21.0"
# sha256 digests of the release artifacts; update when bumping hailo_version.
# The runtime tarball is keyed by TARGETARCH, the wheel by the python arch tag.
declare -A hailort_checksums=(
["amd64"]="0a57ac5f7cc8c2c3668133189d9285b55f498e8cb219797e203f6f5015fec4b3"
["arm64"]="dd840548eb5d0d147c99aee2cb013d39d64be09c5bc63061171fcfacf4547b3f"
["x86_64"]="8112a973ab48095399b29d883f31987828df5861b8553f614c89f098a67b3fb6"
["aarch64"]="658432a43573280d472f6402d7934669effe7f163ba3dffa31c50bbeeaa7c01d"
)
if [[ "${TARGETARCH}" == "amd64" ]]; then if [[ "${TARGETARCH}" == "amd64" ]]; then
arch="x86_64" arch="x86_64"
elif [[ "${TARGETARCH}" == "arm64" ]]; then elif [[ "${TARGETARCH}" == "arm64" ]]; then
arch="aarch64" arch="aarch64"
fi fi
wget -qO- "https://github.com/frigate-nvr/hailort/releases/download/v${hailo_version}/hailort-debian12-${TARGETARCH}.tar.gz" | tar -C / -xzf - # downloaded rather than streamed into tar because streaming and verifying the
wget -P /wheels/ "https://github.com/frigate-nvr/hailort/releases/download/v${hailo_version}/hailort-${hailo_version}-cp311-cp311-linux_${arch}.whl" # digest before extraction are mutually exclusive
wget -qO /tmp/hailort.tar.gz "https://github.com/frigate-nvr/hailort/releases/download/v${hailo_version}/hailort-debian12-${TARGETARCH}.tar.gz"
echo "${hailort_checksums[${TARGETARCH}]} /tmp/hailort.tar.gz" | sha256sum -c -
tar -C / -xzf /tmp/hailort.tar.gz
rm -f /tmp/hailort.tar.gz
wheel="/wheels/hailort-${hailo_version}-cp311-cp311-linux_${arch}.whl"
mkdir -p /wheels
wget -qO "${wheel}" "https://github.com/frigate-nvr/hailort/releases/download/v${hailo_version}/hailort-${hailo_version}-cp311-cp311-linux_${arch}.whl"
echo "${hailort_checksums[${arch}]} ${wheel}" | sha256sum -c -
+20 -4
View File
@@ -4,6 +4,15 @@ set -euxo pipefail
s6_version="3.2.1.0" s6_version="3.2.1.0"
# sha256 digests of the release artifacts, from the .sha256 files published at
# https://github.com/just-containers/s6-overlay/releases/tag/v3.2.1.0
# Update these when bumping s6_version.
declare -A s6_checksums=(
["noarch"]="42e038a9a00fc0fef70bf0bc42f625a9c14f8ecdfe77d4ad93281edf717e10c5"
["x86_64"]="8bcbc2cada58426f976b159dcc4e06cbb1454d5f39252b3bb0c778ccf71c9435"
["aarch64"]="c8fd6b1f0380d399422fc986a1e6799f6a287e2cfa24813ad0b6a4fb4fa755cc"
)
if [[ "${TARGETARCH}" == "amd64" ]]; then if [[ "${TARGETARCH}" == "amd64" ]]; then
s6_arch="x86_64" s6_arch="x86_64"
elif [[ "${TARGETARCH}" == "arm64" ]]; then elif [[ "${TARGETARCH}" == "arm64" ]]; then
@@ -12,8 +21,15 @@ fi
mkdir -p /rootfs/ mkdir -p /rootfs/
wget -qO- "https://github.com/just-containers/s6-overlay/releases/download/v${s6_version}/s6-overlay-noarch.tar.xz" | download_and_extract() {
tar -C /rootfs/ -Jxpf - local arch="$1"
local tarball="/tmp/s6-overlay-${arch}.tar.xz"
wget -qO "${tarball}" \
"https://github.com/just-containers/s6-overlay/releases/download/v${s6_version}/s6-overlay-${arch}.tar.xz"
echo "${s6_checksums[${arch}]} ${tarball}" | sha256sum -c -
tar -C /rootfs/ -Jxpf "${tarball}"
rm -f "${tarball}"
}
wget -qO- "https://github.com/just-containers/s6-overlay/releases/download/v${s6_version}/s6-overlay-${s6_arch}.tar.xz" | download_and_extract "noarch"
tar -C /rootfs/ -Jxpf - download_and_extract "${s6_arch}"
+9
View File
@@ -4,6 +4,14 @@ set -euxo pipefail
tempio_version="2021.09.0" tempio_version="2021.09.0"
# sha256 digests of the release binaries; update when bumping tempio_version.
# Upstream publishes no checksums, so these come from a one-time fetch and
# guard against later substitution rather than the original download.
declare -A tempio_checksums=(
["amd64"]="b7b93ebfd24c1161cec7aecfad62ab51f2241149358cef354b86cdbc6a60546f"
["aarch64"]="3a5c32981ba68b75ed9b28497429e5a5cecbeb74c3b821b035a48b37609bb895"
)
if [[ "${TARGETARCH}" == "amd64" ]]; then if [[ "${TARGETARCH}" == "amd64" ]]; then
arch="amd64" arch="amd64"
elif [[ "${TARGETARCH}" == "arm64" ]]; then elif [[ "${TARGETARCH}" == "arm64" ]]; then
@@ -13,4 +21,5 @@ fi
mkdir -p /rootfs/usr/local/tempio/bin mkdir -p /rootfs/usr/local/tempio/bin
wget -q -O /rootfs/usr/local/tempio/bin/tempio "https://github.com/home-assistant/tempio/releases/download/${tempio_version}/tempio_${arch}" wget -q -O /rootfs/usr/local/tempio/bin/tempio "https://github.com/home-assistant/tempio/releases/download/${tempio_version}/tempio_${arch}"
echo "${tempio_checksums[${arch}]} /rootfs/usr/local/tempio/bin/tempio" | sha256sum -c -
chmod 755 /rootfs/usr/local/tempio/bin/tempio chmod 755 /rootfs/usr/local/tempio/bin/tempio
@@ -1,4 +1,12 @@
#!/command/with-contenv bash #!/command/with-contenv bash
# shellcheck shell=bash # shellcheck shell=bash
if [[ "$(id -u)" -eq 0 ]]; then
# logutil-service drops to nobody and applies S6_LOGGING_SCRIPT
exec logutil-service /dev/shm/logs/certsync exec logutil-service /dev/shm/logs/certsync
fi
# Non-root (--user) fallback: logutil-service cannot change UID, so run
# s6-log directly with the same directives S6_LOGGING_SCRIPT configures.
# shellcheck disable=SC2086
exec s6-log ${S6_LOGGING_SCRIPT:-T 1 n0 s10000000 T} /dev/shm/logs/certsync
@@ -1,4 +1,12 @@
#!/command/with-contenv bash #!/command/with-contenv bash
# shellcheck shell=bash # shellcheck shell=bash
if [[ "$(id -u)" -eq 0 ]]; then
# logutil-service drops to nobody and applies S6_LOGGING_SCRIPT
exec logutil-service /dev/shm/logs/frigate exec logutil-service /dev/shm/logs/frigate
fi
# Non-root (--user) fallback: logutil-service cannot change UID, so run
# s6-log directly with the same directives S6_LOGGING_SCRIPT configures.
# shellcheck disable=SC2086
exec s6-log ${S6_LOGGING_SCRIPT:-T 1 n0 s10000000 T} /dev/shm/logs/frigate
@@ -1,4 +1,12 @@
#!/command/with-contenv bash #!/command/with-contenv bash
# shellcheck shell=bash # shellcheck shell=bash
if [[ "$(id -u)" -eq 0 ]]; then
# logutil-service drops to nobody and applies S6_LOGGING_SCRIPT
exec logutil-service /dev/shm/logs/go2rtc exec logutil-service /dev/shm/logs/go2rtc
fi
# Non-root (--user) fallback: logutil-service cannot change UID, so run
# s6-log directly with the same directives S6_LOGGING_SCRIPT configures.
# shellcheck disable=SC2086
exec s6-log ${S6_LOGGING_SCRIPT:-T 1 n0 s10000000 T} /dev/shm/logs/go2rtc
+61
View File
@@ -0,0 +1,61 @@
#!/command/with-contenv bash
# shellcheck shell=bash
# Remap the frigate user to PUID/PGID and register EXTRA_GROUPS.
# No-op when: started with --user (euid != 0), FRIGATE_RUN_AS_ROOT=true,
# or PUID/PGID already match.
set -o errexit -o nounset -o pipefail
if [[ "$(id -u)" -ne 0 ]]; then
# Started with docker --user; the host owns UID mapping entirely.
exit 0
fi
if [[ "${FRIGATE_RUN_AS_ROOT:-false}" == "true" ]]; then
echo "[INFO] FRIGATE_RUN_AS_ROOT=true: skipping user remapping"
exit 0
fi
puid="${PUID:-1000}"
pgid="${PGID:-1000}"
if ! [[ "$puid" =~ ^[0-9]+$ && "$pgid" =~ ^[0-9]+$ ]]; then
echo "[ERROR] PUID and PGID must be numeric, got '${puid}' and '${pgid}'" >&2
exit 1
fi
# Remapping to 0 would make the frigate user root, so every service would keep
# full privilege while reporting a successful migration.
if [[ "$puid" -eq 0 || "$pgid" -eq 0 ]]; then
echo "[ERROR] PUID/PGID 0 would run the services as root and defeat the privilege separation." >&2
echo "[ERROR] Set FRIGATE_RUN_AS_ROOT=true if you want to keep running as root." >&2
exit 1
fi
current_uid="$(id -u frigate)"
current_gid="$(id -g frigate)"
if [[ "$puid" != "$current_uid" || "$pgid" != "$current_gid" ]]; then
if [[ ! -w /etc/passwd ]]; then
echo "[ERROR] PUID/PGID remapping needs a writable /etc and is not compatible with read_only: true." >&2
echo "[ERROR] Either remove read_only and keep PUID, or drop PUID/PGID and use docker's user: ${puid}:${pgid} instead." >&2
echo "[ERROR] See https://docs.frigate.video/configuration/non_root for the compatibility matrix." >&2
exit 1
fi
echo "[INFO] Remapping frigate user to ${puid}:${pgid}"
groupmod -o -g "$pgid" frigate
usermod -o -u "$puid" frigate
fi
# EXTRA_GROUPS: numeric host GIDs granting device access (e.g. host render/video)
if [[ -n "${EXTRA_GROUPS:-}" ]]; then
for gid in ${EXTRA_GROUPS//,/ }; do
if ! getent group "$gid" >/dev/null; then
groupadd -o -g "$gid" "frigate-extra-${gid}"
fi
group_name="$(getent group "$gid" | cut -d: -f1)"
usermod -aG "$group_name" frigate
usermod -aG "$group_name" go2rtc
echo "[INFO] Added frigate and go2rtc to supplementary group ${group_name} (gid ${gid})"
done
fi
@@ -0,0 +1 @@
oneshot
@@ -0,0 +1 @@
/etc/s6-overlay/s6-rc.d/init-usermod/run
@@ -7,5 +7,12 @@ set -o errexit -o nounset -o pipefail
dirs=(/dev/shm/logs/frigate /dev/shm/logs/go2rtc /dev/shm/logs/nginx /dev/shm/logs/certsync) dirs=(/dev/shm/logs/frigate /dev/shm/logs/go2rtc /dev/shm/logs/nginx /dev/shm/logs/certsync)
mkdir -p "${dirs[@]}" mkdir -p "${dirs[@]}"
# logutil-service drops s6-log to nobody, so the dirs must stay nobody-owned
# in root mode. Under docker --user we are already the (only) target user,
# chown would fail, and the plain s6-log fallback in the *-log services
# writes as us (the mkdir above is sufficient, /dev/shm is 1777).
if [[ "$(id -u)" -eq 0 ]]; then
chown nobody:nogroup "${dirs[@]}" chown nobody:nogroup "${dirs[@]}"
fi
chmod 02755 "${dirs[@]}" chmod 02755 "${dirs[@]}"
@@ -1,4 +1,12 @@
#!/command/with-contenv bash #!/command/with-contenv bash
# shellcheck shell=bash # shellcheck shell=bash
if [[ "$(id -u)" -eq 0 ]]; then
# logutil-service drops to nobody and applies S6_LOGGING_SCRIPT
exec logutil-service /dev/shm/logs/nginx exec logutil-service /dev/shm/logs/nginx
fi
# Non-root (--user) fallback: logutil-service cannot change UID, so run
# s6-log directly with the same directives S6_LOGGING_SCRIPT configures.
# shellcheck disable=SC2086
exec s6-log ${S6_LOGGING_SCRIPT:-T 1 n0 s10000000 T} /dev/shm/logs/nginx
@@ -77,15 +77,20 @@ if [ ! \( -f "$letsencrypt_path/privkey.pem" -a -f "$letsencrypt_path/fullchain.
openssl req -new -newkey rsa:4096 -days 365 -nodes -x509 \ openssl req -new -newkey rsa:4096 -days 365 -nodes -x509 \
-subj "/O=FRIGATE DEFAULT CERT/CN=*" \ -subj "/O=FRIGATE DEFAULT CERT/CN=*" \
-keyout "$letsencrypt_path/privkey.pem" -out "$letsencrypt_path/fullchain.pem" 2>/dev/null -keyout "$letsencrypt_path/privkey.pem" -out "$letsencrypt_path/fullchain.pem" 2>/dev/null
chmod 600 "$letsencrypt_path/privkey.pem"
chmod 644 "$letsencrypt_path/fullchain.pem"
fi fi
# nginx settings are read once; both templates consume them
nginx_settings=$(python3 /usr/local/nginx/get_nginx_settings.py)
# 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 | \ echo "$nginx_settings" | \
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 additional network settings
python3 /usr/local/nginx/get_nginx_settings.py | \ echo "$nginx_settings" | \
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
@@ -144,3 +144,16 @@ rm -f /dev/shm/.frigate-is-stopping
migrate_addon_config_dir migrate_addon_config_dir
migrate_db_from_media_to_config migrate_db_from_media_to_config
# Align volume ownership with the runtime user (one sweep per PUID/schema
# change, guarded by the sentinel; see fix-ownership). The escape hatch
# deletes the sentinel instead: ownership is never mutated while it is on,
# so the next non-root boot must re-sweep whatever root created meanwhile.
if [[ "$(id -u)" -eq 0 ]]; then
if [[ "${FRIGATE_RUN_AS_ROOT:-false}" == "true" ]]; then
rm -f /config/.permissions_version
else
/usr/local/bin/fix-ownership --sentinel /config/.permissions_version \
"${PUID:-1000}" "${PGID:-1000}" /config /media/frigate
fi
fi
+129
View File
@@ -0,0 +1,129 @@
#!/bin/bash
# Single source of truth for aligning volume ownership with the runtime user.
#
# Usage: fix-ownership [--dry-run] [--sentinel FILE] UID GID PATH [PATH...]
#
# --dry-run report what would change, touch nothing
# --sentinel skip entirely when FILE already records "SCHEMA:UID:GID";
# write it after a successful run (used by the boot path so
# multi-TB volumes are swept once per UID/schema change, not
# on every boot)
#
# Only files whose uid OR gid differs are touched, so re-runs are cheap.
# Top-level /config additionally grants group frigate-data TRAVERSE ONLY
# (g+rx) so the separate go2rtc user can reach its pre-created HomeKit file
# on hosts where /config is mounted 0700. Never g+w: directory write means
# unlink rights over frigate.db/config.yml, and would let a compromised
# go2rtc plant /config/go2rtc, which the go2rtc run script executes
# preferentially, as root under the escape hatch.
set -o errexit -o nounset -o pipefail
# Permissions-layout epoch. Bump to force a one-time re-sweep on upgrade
# (e.g. when the privilege-drop release must capture files created as root
# since the previous sweep).
schema=1
dry_run=0
sentinel=""
while [[ "${1:-}" == --* ]]; do
case "$1" in
--dry-run) dry_run=1; shift ;;
--sentinel)
if [[ -z "${2:-}" ]]; then
echo "[ERROR] fix-ownership: --sentinel requires a file argument" >&2
exit 2
fi
sentinel="$2"; shift 2 ;;
*) echo "[ERROR] fix-ownership: unknown option $1" >&2; exit 2 ;;
esac
done
if [[ $# -lt 3 ]]; then
echo "Usage: fix-ownership [--dry-run] [--sentinel FILE] UID GID PATH..." >&2
exit 2
fi
target_uid="$1"
target_gid="$2"
shift 2
if [[ "$(id -u)" -ne 0 ]]; then
echo "[INFO] fix-ownership: not running as root, skipping (ownership is managed by the host in --user mode)"
exit 0
fi
# A dry run always inspects: the sentinel records what a past sweep did, not
# what the volume looks like now, and reporting from it would hide later drift.
if [[ "$dry_run" -eq 0 && -n "$sentinel" && -f "$sentinel" && "$(cat "$sentinel")" == "${schema}:${target_uid}:${target_gid}" ]]; then
echo "[INFO] fix-ownership: ${target_uid}:${target_gid} (schema ${schema}) already applied, skipping"
exit 0
fi
# A sweep that could not chown everything must not be recorded as complete:
# the sentinel would make every later boot skip it and the entries would stay
# unreachable once services run unprivileged.
swept_clean=1
for path in "$@"; do
# An absent root is an incomplete sweep, not a finished one: /media/frigate
# is not in the image, so a boot before the volume is mounted would
# otherwise record success and the volume would never be swept once added.
if [[ ! -d "$path" ]]; then
swept_clean=0
echo "[WARN] fix-ownership: $path does not exist, skipping; will retry on next boot"
continue
fi
# find may fail mid-walk on a live volume (file deleted under it) or on a
# stale mount. Tolerate it rather than aborting under errexit, but never
# read a failed scan as "nothing to do": that would record the sweep as
# complete without having looked.
if ! count=$(find "$path" \( -not -uid "$target_uid" -o -not -gid "$target_gid" \) -printf '.' 2>/dev/null | wc -c); then
swept_clean=0
echo "[WARN] fix-ownership: could not scan ${path}; will retry on next boot"
continue
fi
if [[ "$count" -eq 0 ]]; then
echo "[INFO] fix-ownership: $path already owned by ${target_uid}:${target_gid}, nothing to do"
continue
fi
# find does not descend symlinks and chown -h retargets the link itself, so
# anything behind a symlinked directory is outside this sweep. Following
# them is not an option: a link could walk the chown out of the volume.
if [[ -n "$(find "$path" -type l -xtype d -print -quit 2>/dev/null)" ]]; then
echo "[WARN] fix-ownership: ${path} contains symlinked directories; ownership behind them is not managed and must be aligned by hand"
fi
echo "[WARN] fix-ownership: adjusting ownership of ${count} entries under ${path}; on large recordings volumes this can take a long time"
if [[ "$dry_run" -eq 1 ]]; then
echo "[INFO] fix-ownership: dry run, not changing ${path}"
continue
fi
find "$path" \( -not -uid "$target_uid" -o -not -gid "$target_gid" \) \
-exec chown -h "${target_uid}:${target_gid}" {} + || {
swept_clean=0
echo "[WARN] fix-ownership: some entries under ${path} could not be updated (deleted mid-sweep or chown denied); will retry on next mismatch"
}
done
# go2rtc (separate user) must be able to REACH its HomeKit state in /config.
# Write access is per-file, not per-directory: go2rtc's PatchConfig rewrites
# the first -config file via os.WriteFile (in-place truncate, no rename,
# verified against go2rtc v1.9.14 internal/app/config.go), and the file is
# always pre-created by setup_homekit_config before go2rtc starts, so
# O_CREATE never needs directory write. See header comment for why g+w is
# forbidden here.
if [[ "$dry_run" -eq 0 && -d /config ]]; then
chgrp frigate-data /config 2>/dev/null || true
chmod g+rx /config 2>/dev/null || true
fi
if [[ "$dry_run" -eq 0 && -n "$sentinel" && "$swept_clean" -eq 1 ]]; then
echo "${schema}:${target_uid}:${target_gid}" > "$sentinel" || \
echo "[WARN] fix-ownership: could not write ${sentinel}; the sweep will run again on next boot"
fi
@@ -3,13 +3,12 @@
import json import json
import os import os
import sys import sys
from pathlib import Path
from typing import Any from typing import Any
from ruamel.yaml import YAML from ruamel.yaml import YAML
sys.path.insert(0, "/opt/frigate") sys.path.insert(0, "/opt/frigate")
from frigate.config.env import substitute_frigate_vars from frigate.config.env import apply_config_env_vars, substitute_frigate_vars
from frigate.const import ( from frigate.const import (
BIRDSEYE_PIPE, BIRDSEYE_PIPE,
LIBAVFORMAT_VERSION_MAJOR, LIBAVFORMAT_VERSION_MAJOR,
@@ -25,15 +24,6 @@ sys.path.remove("/opt/frigate")
yaml = YAML() yaml = YAML()
FRIGATE_ENV_VARS = {k: v for k, v in os.environ.items() if k.startswith("FRIGATE_")}
# read docker secret files as env vars too
if os.path.isdir("/run/secrets"):
for secret_file in os.listdir("/run/secrets"):
if secret_file.startswith("FRIGATE_"):
FRIGATE_ENV_VARS[secret_file] = (
Path(os.path.join("/run/secrets", secret_file)).read_text().strip()
)
config_file = find_config_file() config_file = find_config_file()
try: try:
@@ -47,6 +37,20 @@ try:
except FileNotFoundError: except FileNotFoundError:
config: dict[str, Any] = {} config: dict[str, Any] = {}
# No validator runs here, so install environment_vars ourselves. FRIGATE_
# names only: anything else lands in os.environ, where the exec gate reads
# GO2RTC_ALLOW_ARBITRARY_EXEC.
config_env_vars = config.get("environment_vars")
apply_config_env_vars(
{
key: value
for key, value in config_env_vars.items()
if str(key).startswith("FRIGATE_")
}
if isinstance(config_env_vars, dict)
else {}
)
go2rtc_config: dict[str, Any] = config.get("go2rtc", {}) go2rtc_config: dict[str, Any] = config.get("go2rtc", {})
# Need to enable CORS for go2rtc so the frigate integration / card work automatically # Need to enable CORS for go2rtc so the frigate integration / card work automatically
@@ -113,7 +117,7 @@ for name in list(go2rtc_config.get("streams", {})):
if isinstance(stream, str): if isinstance(stream, str):
try: try:
formatted_stream = stream.format(**FRIGATE_ENV_VARS) formatted_stream = substitute_frigate_vars(stream)
if is_restricted_go2rtc_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. "
@@ -122,7 +126,7 @@ for name in list(go2rtc_config.get("streams", {})):
del go2rtc_config["streams"][name] del go2rtc_config["streams"][name]
continue continue
go2rtc_config["streams"][name] = formatted_stream go2rtc_config["streams"][name] = formatted_stream
except KeyError as e: except ValueError as e:
print( print(
"[ERROR] Invalid substitution found, see https://docs.frigate.video/configuration/restream#advanced-restream-configurations for more info." "[ERROR] Invalid substitution found, see https://docs.frigate.video/configuration/restream#advanced-restream-configurations for more info."
) )
@@ -132,7 +136,7 @@ for name in list(go2rtc_config.get("streams", {})):
filtered_streams = [] filtered_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 = substitute_frigate_vars(stream_item)
if is_restricted_go2rtc_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. "
@@ -141,7 +145,7 @@ for name in list(go2rtc_config.get("streams", {})):
continue continue
filtered_streams.append(formatted_stream) filtered_streams.append(formatted_stream)
except KeyError as e: except ValueError as e:
print( print(
"[ERROR] Invalid substitution found, see https://docs.frigate.video/configuration/restream#advanced-restream-configurations for more info." "[ERROR] Invalid substitution found, see https://docs.frigate.video/configuration/restream#advanced-restream-configurations for more info."
) )
@@ -185,3 +189,6 @@ if config.get("birdseye", {}).get("restream", False):
# Write go2rtc_config to /dev/shm/go2rtc.yaml # Write go2rtc_config to /dev/shm/go2rtc.yaml
with open("/dev/shm/go2rtc.yaml", "w") as f: with open("/dev/shm/go2rtc.yaml", "w") as f:
yaml.dump(go2rtc_config, f) yaml.dump(go2rtc_config, f)
# config contains camera credentials; do not leave it world-readable
os.chmod("/dev/shm/go2rtc.yaml", 0o640)
@@ -11,6 +11,7 @@ events {
http { http {
map_hash_bucket_size 256; map_hash_bucket_size 256;
server_tokens off;
include mime.types; include mime.types;
default_type application/octet-stream; default_type application/octet-stream;
@@ -62,6 +63,7 @@ http {
server { server {
include listen.conf; include listen.conf;
include security_headers.conf;
# enable HTTP/2 for TLS connections to eliminate browser 6-connection limit # enable HTTP/2 for TLS connections to eliminate browser 6-connection limit
http2 on; http2 on;
@@ -123,6 +125,7 @@ http {
secure_token $args; secure_token $args;
secure_token_types application/vnd.apple.mpegurl; secure_token_types application/vnd.apple.mpegurl;
include security_headers.conf;
add_header Cache-Control "no-store"; add_header Cache-Control "no-store";
expires off; expires off;
@@ -139,6 +142,7 @@ http {
location /stream/ { location /stream/ {
include auth_request.conf; include auth_request.conf;
include security_headers.conf;
add_header Cache-Control "no-store"; add_header Cache-Control "no-store";
expires off; expires off;
@@ -160,6 +164,7 @@ http {
} }
expires 7d; expires 7d;
include security_headers.conf;
add_header Cache-Control "public"; add_header Cache-Control "public";
autoindex on; autoindex on;
root /media/frigate; root /media/frigate;
@@ -252,6 +257,7 @@ http {
location /api/ { location /api/ {
include auth_request.conf; include auth_request.conf;
include security_headers.conf;
add_header Cache-Control "no-store"; add_header Cache-Control "no-store";
expires off; expires off;
proxy_pass http://frigate_api/; proxy_pass http://frigate_api/;
@@ -318,29 +324,34 @@ http {
location / { location / {
# do not require auth for static assets # do not require auth for static assets
include security_headers.conf;
add_header Cache-Control "no-store"; add_header Cache-Control "no-store";
expires off; expires off;
location /assets/ { location /assets/ {
access_log off; access_log off;
expires 1y; expires 1y;
include security_headers.conf;
add_header Cache-Control "public"; add_header Cache-Control "public";
} }
location /fonts/ { location /fonts/ {
access_log off; access_log off;
expires 1y; expires 1y;
include security_headers.conf;
add_header Cache-Control "public"; add_header Cache-Control "public";
} }
location /locales/ { location /locales/ {
access_log off; access_log off;
include security_headers.conf;
add_header Cache-Control "public"; add_header Cache-Control "public";
} }
location ~ ^/.*-([A-Za-z0-9]+)\.webmanifest$ { location ~ ^/.*-([A-Za-z0-9]+)\.webmanifest$ {
access_log off; access_log off;
expires 1y; expires 1y;
include security_headers.conf;
add_header Cache-Control "public"; add_header Cache-Control "public";
default_type application/json; default_type application/json;
proxy_set_header Accept-Encoding ""; proxy_set_header Accept-Encoding "";
@@ -0,0 +1,5 @@
# Deliberately no X-Frame-Options or CSP frame-ancestors: HA's Webpage card and
# iframe panels frame Frigate cross-origin, and either would break them
# silently. Bind-mount this file to add your own.
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
+45
View File
@@ -0,0 +1,45 @@
#!/bin/bash
# Ahead-of-time volume ownership migration for switching Frigate to non-root.
# Run from the host BEFORE enabling PUID/PGID or --user:
#
# ./fix-permissions.sh [--dry-run] <config_dir> <media_dir> [PUID] [PGID]
#
# Wraps the image's fix-ownership helper so there is exactly one
# implementation of the chown logic. Requires an image that contains the
# helper (any release that includes non-root support).
set -o errexit -o nounset -o pipefail
IMAGE="${FRIGATE_IMAGE:-ghcr.io/blakeblackshear/frigate:stable}"
dry_run_flag=""
if [[ "${1:-}" == "--dry-run" ]]; then
dry_run_flag="--dry-run"
shift
fi
if [[ $# -lt 2 ]]; then
echo "Usage: $0 [--dry-run] <config_dir> <media_dir> [PUID] [PGID]" >&2
exit 2
fi
config_dir="$1"
media_dir="$2"
puid="${3:-1000}"
pgid="${4:-1000}"
# The ids are interpolated into the container's bash -c source below, so
# anything but digits would be reparsed as shell rather than passed through
if ! [[ "$puid" =~ ^[0-9]+$ && "$pgid" =~ ^[0-9]+$ ]]; then
echo "[ERROR] PUID and PGID must be numeric, got '${puid}' and '${pgid}'" >&2
exit 2
fi
echo "[INFO] Using image ${IMAGE} (override with FRIGATE_IMAGE=...)"
# shellcheck disable=SC2086
docker run --rm \
-v "${config_dir}:/config" \
-v "${media_dir}:/media/frigate" \
--entrypoint bash \
"${IMAGE}" \
-c "command -v fix-ownership >/dev/null || { echo '[ERROR] this Frigate image predates non-root support; set FRIGATE_IMAGE to a release that includes it' >&2; exit 1; }; exec fix-ownership ${dry_run_flag} ${puid} ${pgid} /config /media/frigate"
+50 -17
View File
@@ -63,15 +63,9 @@ go2rtc:
### `environment_vars` ### `environment_vars`
This section can be used to set environment variables for those unable to modify the environment of the container, like within Home Assistant OS. Docker users should set environment variables in their `docker run` command (`-e FRIGATE_MQTT_PASSWORD=secret`) or `docker-compose.yml` file (`environment:` section) instead. Note that values set here are stored in plain text in your config file, so if the goal is to keep credentials out of your configuration, use Docker environment variables or Docker secrets instead. This section sets environment variables in the Frigate process for those unable to modify the environment of the container, like within Home Assistant OS. It's meant for process settings such as `LIBVA_DRIVER_NAME` or the TensorFlow thread counts below. Docker users should set environment variables in their `docker run` command (`-e LIBVA_DRIVER_NAME=i965`) or `docker-compose.yml` file (`environment:` section) instead. Values set here are stored in plain text in your config file, so credentials belong in `secrets.yaml`, Docker environment variables, or Docker secrets instead.
Variables prefixed with `FRIGATE_` can be referenced in config fields that support environment variable substitution (such as MQTT host and credentials, camera stream URLs, and ONVIF host and credentials) using the `{FRIGATE_VARIABLE_NAME}` syntax. Names prefixed with `FRIGATE_` set here also take part in `{FRIGATE_VARIABLE_NAME}` substitution (see [below](#substitution-sources-and-precedence)), but `secrets.yaml` is the better home for them.
:::note
The `go2rtc` section is an exception. go2rtc runs as a separate process, so its stream definitions can only be substituted with variables that exist in the container's environment (set via Docker `-e`, the `environment:` section of `docker-compose.yml`, or Docker secrets). Variables defined in the `environment_vars` block above are not available to go2rtc streams. Home Assistant app users, who cannot set container environment variables, must instead put credentials directly in their go2rtc stream URLs.
:::
<ConfigTabs> <ConfigTabs>
<TabItem value="ui"> <TabItem value="ui">
@@ -80,23 +74,17 @@ Navigate to <NavPath path="Settings > System > Environment variables" /> to add
| Field | Description | | Field | Description |
| ----------------- | --------------------------------------------------------- | | ----------------- | --------------------------------------------------------- |
| **Variable name** | The environment variable name (e.g., `FRIGATE_MQTT_USER`) | | **Variable name** | The environment variable name (e.g., `LIBVA_DRIVER_NAME`) |
| **Value** | The value for the variable | | **Value** | The value for the variable |
Variables defined here can be referenced elsewhere in your configuration using the `{FRIGATE_VARIABLE_NAME}` syntax. Names prefixed with `FRIGATE_` can also be referenced elsewhere in your configuration using the `{FRIGATE_VARIABLE_NAME}` syntax.
</TabItem> </TabItem>
<TabItem value="yaml"> <TabItem value="yaml">
```yaml ```yaml
environment_vars: environment_vars:
FRIGATE_MQTT_USER: my_mqtt_user LIBVA_DRIVER_NAME: i965
FRIGATE_MQTT_PASSWORD: my_mqtt_password
mqtt:
host: "{FRIGATE_MQTT_HOST}"
user: "{FRIGATE_MQTT_USER}"
password: "{FRIGATE_MQTT_PASSWORD}"
``` ```
</TabItem> </TabItem>
@@ -130,6 +118,51 @@ environment_vars:
</TabItem> </TabItem>
</ConfigTabs> </ConfigTabs>
### `secrets.yaml`
A `secrets.yaml` file next to your `config.yml` is an additional source of `FRIGATE_` variables, for installs that can't set container environment variables or mount Docker secrets. It's a flat map of names to values, and it is never read or written by the Frigate UI:
```yaml
FRIGATE_CAM_USER: viewer
FRIGATE_CAM_PASS: "p@ss w0rd"
FRIGATE_MQTT_HOST: mqtt.internal.example
```
For Docker this is `/config/secrets.yaml` inside the container, so it lives in whatever host directory you mounted at `/config`. For the Home Assistant App it's `/addon_configs/<addon_directory>/secrets.yaml`, in the same folder as your `config.yml`; see [the App config directory](../config.md#accessing-app-config-dir) for the directory name for your variant.
Names must start with `FRIGATE_`, and nesting is not supported. `secrets.yaml` feeds `{FRIGATE_VARIABLE_NAME}` substitution, so the handful of variables Frigate reads straight from the process environment, such as `FRIGATE_JWT_SECRET`, still need a container environment variable or a Docker secret.
### Substitution sources and precedence
The same `{FRIGATE_VARIABLE_NAME}` placeholder resolves from four sources. When a name is defined in more than one, the higher one wins and a warning at startup names which source was used.
| Priority | Source | Where it's set | Who can use it |
| ----------- | --------------------- | -------------------------------------------------------------------------- | ------------------------------ |
| 1 (highest) | Docker secrets | Files in `/run/secrets`, or the directory named by `CREDENTIALS_DIRECTORY` | Docker, systemd |
| 2 | Container environment | `docker run -e`, the `environment:` section of `docker-compose.yml` | Docker |
| 3 | `secrets.yaml` | Next to `config.yml`, see above | Everyone, including the HA App |
| 4 (lowest) | `environment_vars` | The block in `config.yml` described above | Everyone, including the HA App |
For example, with this `secrets.yaml`:
```yaml
FRIGATE_MQTT_PASSWORD: from_secrets
```
and this `config.yml`:
```yaml
environment_vars:
FRIGATE_MQTT_PASSWORD: from_config
mqtt:
password: "{FRIGATE_MQTT_PASSWORD}"
```
the password resolves to `from_secrets`, and the log shows `FRIGATE_MQTT_PASSWORD is defined in more than one place, using the value from secrets.yaml`. Add `-e FRIGATE_MQTT_PASSWORD=from_env` to the container and it resolves to `from_env` instead.
Referencing a name that no source defines is a config validation error naming the field.
### `database` ### `database`
Tracked object and recording information is managed in a sqlite database at `/config/frigate.db`. If that database is deleted, recordings will be orphaned and will need to be cleaned up manually. They also won't show up in the Media Browser within Home Assistant. Tracked object and recording information is managed in a sqlite database at `/config/frigate.db`. If that database is deleted, recordings will be orphaned and will need to be cleaned up manually. They also won't show up in the Media Browser within Home Assistant.
+2 -1
View File
@@ -84,9 +84,10 @@ A camera is enabled by default but can be disabled by using `enabled: False`. Ca
Each role can only be assigned to one input per camera. The options for roles are as follows: Each role can only be assigned to one input per camera. The options for roles are as follows:
| Role | Description | | Role | Description |
| -------- | ----------------------------------------------------------------------------------- | | ------------ | ------------------------------------------------------------------------------------------------------------ |
| `detect` | Main feed for object detection. [docs](object_detectors.md) | | `detect` | Main feed for object detection. [docs](object_detectors.md) |
| `record` | Saves segments of the video feed based on configuration settings. [docs](record.md) | | `record` | Saves segments of the video feed based on configuration settings. [docs](record.md) |
| `record_sub` | Saves segments of a second, lower quality stream with its own retention. [docs](record.md#sub-stream-recording) |
| `audio` | Feed for audio based detection. [docs](audio_detectors.md) | | `audio` | Feed for audio based detection. [docs](audio_detectors.md) |
<ConfigTabs> <ConfigTabs>
+1 -1
View File
@@ -100,7 +100,7 @@ VS Code supports JSON schemas for automatically validating configuration files.
## Environment Variable Substitution ## Environment Variable Substitution
Frigate supports the use of environment variables starting with `FRIGATE_` **only** where specifically indicated in the [reference config](./advanced/reference.md). For example, the following values can be replaced at runtime by using environment variables: Frigate supports the use of environment variables starting with `FRIGATE_` **only** where specifically indicated in the [reference config](./advanced/reference.md). See [substitution sources and precedence](./advanced/system.md#substitution-sources-and-precedence) for where those values can come from, including `secrets.yaml`. For example, the following values can be replaced at runtime by using environment variables:
```yaml ```yaml
mqtt: mqtt:
@@ -312,8 +312,9 @@ ffmpeg:
:::note :::note
If running Frigate through Docker, you either need to run in privileged mode or If running Frigate through Docker, map the relevant `/dev/video*` devices into
map the `/dev/video*` devices to Frigate. With Docker Compose add: the container. Running in privileged mode also works but grants far more access
than needed. With Docker Compose add:
```yaml {4-5} ```yaml {4-5}
services: services:
+2 -2
View File
@@ -280,7 +280,7 @@ This configuration will retain recording segments that overlap with alerts and d
In addition to the main recording stream, Frigate can record a second, lower quality stream for each camera. This serves two purposes: In addition to the main recording stream, Frigate can record a second, lower quality stream for each camera. This serves two purposes:
- **Quality selection during playback**: A quality selector (`Auto`, `Original`, or `Low`) appears in History view for cameras with sub stream recording enabled. `Original` and `Low` play only that stream's recordings. Time ranges where the selected stream has no footage are skipped during playback, and the selector notes when the selected stream has no recordings at all in the viewed time range. With `Auto` (the default), playback prefers the original quality and automatically falls back to the low quality stream when the connection cannot keep up, or for time ranges where the original recordings have expired. The selector shows each stream's video codec and audio details beneath the options; footage recorded by older Frigate versions shows no details. - **Quality selection during playback**: A quality selector (`Auto`, `Original`, or `Low`) appears in History view for cameras with sub stream recording enabled. `Original` and `Low` play only that stream's recordings. Time ranges where the selected stream has no footage are skipped during playback, and the selector notes when the selected stream has no recordings at all in the viewed time range. With `Auto` (the default), playback prefers the original quality and automatically falls back to the low quality stream when the connection cannot keep up, or for time ranges where the original recordings have expired. The selector shows each stream's video codec and audio details beneath the options; footage recorded by older Frigate versions shows no details.
- **Extended retention**: Sub stream recordings have their own retention settings, fully independent of the main recordings. By giving the low quality recordings a longer retention period, you can keep weeks or months of low quality history using a fraction of the storage, and that history remains playable after the main recordings expire. Playback falls back to the low quality recordings automatically, and the timeline shows a muted treatment for time ranges where only low quality footage remains. - **Extended retention**: Sub stream recordings have their own retention settings, fully independent of the main recordings. By giving the low quality recordings a longer retention period, you can keep weeks or months of low quality history using a fraction of the storage, and that history remains playable after the main recordings expire. Playback falls back to the low quality recordings automatically, and the timeline shows a muted treatment for time ranges where only low quality footage remains. Timeline previews are kept for as long as either stream still has recordings, so scrubbing works across the whole retained history.
### Configuring sub stream recording ### Configuring sub stream recording
@@ -427,7 +427,7 @@ This table covers only features that read recordings from disk. Tracked object s
### Trade-offs ### Trade-offs
- Recording a second stream increases overall storage use. The increase is typically small relative to the main recordings, since the low quality stream is much smaller. - Recording a second stream increases overall storage use. The increase is typically small relative to the main recordings, since the low quality stream is much smaller. Both streams are cached before being written to disk, so cache use goes up as well. See [the `/tmp/cache` area is separate](#the-tmpcache-area-is-separate) if you start seeing `No space left on device` errors after enabling it.
- The go2rtc transcode approach continuously encodes the low quality stream, which uses CPU or GPU resources. This cost only applies to the transcode path; recording the camera's native sub stream does not re-encode. See the [go2rtc hardware acceleration documentation](https://github.com/AlexxIT/go2rtc?tab=readme-ov-file#source-ffmpeg) for accelerating the transcode. - The go2rtc transcode approach continuously encodes the low quality stream, which uses CPU or GPU resources. This cost only applies to the transcode path; recording the camera's native sub stream does not re-encode. See the [go2rtc hardware acceleration documentation](https://github.com/AlexxIT/go2rtc?tab=readme-ov-file#source-ffmpeg) for accelerating the transcode.
- Many camera sub streams do not include audio. If the source stream has no audio, the low quality recordings will not have audio. - Many camera sub streams do not include audio. If the source stream has no audio, the low quality recordings will not have audio.
- **Matching video codecs and audio settings between the two streams gives the smoothest playback.** When playback combines both qualities on one timeline (the default `Auto` behavior: for example original quality during events with low quality in between, or low quality history after the original recordings expire) and the streams use different video codecs or audio settings, for example H.265 on the main stream and H.264 on the sub stream, or 16 kHz audio on one and 8 kHz on the other, playback still works: Frigate inserts a decoder reset at each quality transition, which can cause a barely-perceptible pause there. Configuring both streams in the camera's firmware to use the same video codec, audio codec, and sample rate makes transitions fully seamless, and a mismatched audio sample rate can also be corrected with [sub stream output args](#sub-stream-output-args). If one stream has audio and the other does not, combined time ranges play **without audio**; selecting a single quality with the playback selector always keeps that stream's audio. - **Matching video codecs and audio settings between the two streams gives the smoothest playback.** When playback combines both qualities on one timeline (the default `Auto` behavior: for example original quality during events with low quality in between, or low quality history after the original recordings expire) and the streams use different video codecs or audio settings, for example H.265 on the main stream and H.264 on the sub stream, or 16 kHz audio on one and 8 kHz on the other, playback still works: Frigate inserts a decoder reset at each quality transition, which can cause a barely-perceptible pause there. Configuring both streams in the camera's firmware to use the same video codec, audio codec, and sample rate makes transitions fully seamless, and a mismatched audio sample rate can also be corrected with [sub stream output args](#sub-stream-output-args). If one stream has audio and the other does not, combined time ranges play **without audio**; selecting a single quality with the playback selector always keeps that stream's audio.
+1 -1
View File
@@ -221,7 +221,7 @@ For security reasons, the `echo:`, `expr:`, and `exec:` stream sources are disab
If you attempt to use these sources in your configuration, the streams will be removed and an error message will be printed in the logs. If you attempt to use these sources in your configuration, the streams will be removed and an error message will be printed in the logs.
To enable these sources, you must set the environment variable `GO2RTC_ALLOW_ARBITRARY_EXEC=true`. This can be done in your Docker Compose file or container environment: To enable these sources, you must set the environment variable `GO2RTC_ALLOW_ARBITRARY_EXEC=true`. This can be done in your Docker Compose file or container environment, or for Home Assistant App users with the `go2rtc_allow_arbitrary_exec` option in the App's configuration. The `environment_vars` section of the Frigate config can't enable it:
```yaml ```yaml
environment: environment:
+30 -1
View File
@@ -514,7 +514,7 @@ Generate a Frigate Docker Compose configuration based on your hardware and requi
services: services:
frigate: frigate:
container_name: frigate container_name: frigate
privileged: true # this may not be necessary for all setups # privileged: true # ONLY enable if your hardware requires it (see hardware-specific docs); prefer the device mappings below
restart: unless-stopped restart: unless-stopped
stop_grace_period: 30s # allow enough time to shut down the various services stop_grace_period: 30s # allow enough time to shut down the various services
image: ghcr.io/blakeblackshear/frigate:stable image: ghcr.io/blakeblackshear/frigate:stable
@@ -546,6 +546,33 @@ services:
</TabItem> </TabItem>
</Tabs> </Tabs>
### Recommended security options
Frigate does not need elevated container privileges for most setups. The
following hardens the container; add the `devices`/`group_add` entries your
hardware requires (see the hardware acceleration docs):
```yaml
services:
frigate:
...
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
```
:::note
`telemetry.stats.network_bandwidth` uses nethogs, which requires root with
NET_ADMIN/NET_RAW capabilities. If you enable that stat, omit `cap_drop: [ALL]`
or add `cap_add: [NET_ADMIN, NET_RAW]`.
Platforms that genuinely require `privileged: true` (MemryX, some QNAP setups)
are called out in their own sections and are unaffected by this guidance.
:::
**Docker CLI** **Docker CLI**
If you can't use Docker Compose, you can run the container with something similar to this: If you can't use Docker Compose, you can run the container with something similar to this:
@@ -612,6 +639,8 @@ Home Assistant OS users can install via the App repository.
5. Start the App 5. Start the App
6. Use the _Open Web UI_ button to access the Frigate UI, then click in the _cog icon_ > _Configuration editor_ and configure Frigate to your liking 6. Use the _Open Web UI_ button to access the Frigate UI, then click in the _cog icon_ > _Configuration editor_ and configure Frigate to your liking
App users who can't set container environment variables can put `FRIGATE_` values in a `secrets.yaml` next to `config.yml` in `/addon_configs/<addon_directory>` instead. See [`secrets.yaml`](../configuration/advanced/system.md#secretsyaml).
There are several variants of the App available: There are several variants of the App available:
| App Variant | Description | | App Variant | Description |
+1 -1
View File
@@ -304,7 +304,7 @@ Topic with current state of notifications. Published values are `ON` and `OFF`.
### `frigate/<camera_name>/status/<role>` ### `frigate/<camera_name>/status/<role>`
Publishes the current health status of each role that is enabled (`audio`, `detect`, `record`). Possible values are: Publishes the current health status of each role that is enabled (`audio`, `detect`, `record`, `record_sub`). `record_sub` is only published for cameras with [sub stream recording](/configuration/record#sub-stream-recording) enabled, and is tracked separately from `record` so a healthy main stream can't hide a stalled sub stream. Possible values are:
- `online`: Stream is running and being processed - `online`: Stream is running and being processed
- `offline`: Stream is offline and is being restarted - `offline`: Stream is offline and is being restarted
@@ -219,6 +219,8 @@ hardware:
- host: "/run/mxa_manager" - host: "/run/mxa_manager"
container: "/run/mxa_manager" container: "/run/mxa_manager"
comment: "MemryX manager" comment: "MemryX manager"
privileged: true
privilegedReason: "required by MemryX to reach the max-manager"
- id: "axera" - id: "axera"
label: "AXERA Accelerator" label: "AXERA Accelerator"
@@ -104,6 +104,10 @@ export interface DeviceConfig {
extraHosts?: string[]; extraHosts?: string[];
/** Security options, e.g. ["apparmor=unconfined"] */ /** Security options, e.g. ["apparmor=unconfined"] */
securityOpt?: string[]; securityOpt?: string[];
/** Set only when this device type cannot work without full privileged mode */
privileged?: boolean;
/** Why privileged mode is required, rendered as an inline comment */
privilegedReason?: string;
/** Whether this device type needs the NVIDIA GPU config UI */ /** Whether this device type needs the NVIDIA GPU config UI */
needsNvidiaConfig?: boolean; needsNvidiaConfig?: boolean;
} }
@@ -127,6 +131,10 @@ export interface HardwareOption {
volumes?: VolumeMapping[]; volumes?: VolumeMapping[];
/** Extra environment variables */ /** Extra environment variables */
env?: Record<string, string>; env?: Record<string, string>;
/** Set only when this hardware cannot work without full privileged mode */
privileged?: boolean;
/** Why privileged mode is required, rendered as an inline comment */
privilegedReason?: string;
} }
/** Port definition */ /** Port definition */
@@ -1,6 +1,7 @@
import type { import type {
DeviceConfig, DeviceConfig,
DeviceMapping, DeviceMapping,
HardwareOption,
VolumeMapping, VolumeMapping,
} from "../config/types"; } from "../config/types";
import { hardwareMap } from "../config"; import { hardwareMap } from "../config";
@@ -194,13 +195,32 @@ function buildExtraHosts(device: DeviceConfig): string[] {
} }
function buildSecurityOpt(device: DeviceConfig): string[] { function buildSecurityOpt(device: DeviceConfig): string[] {
if (!device.securityOpt?.length) return []; // no-new-privileges is the baseline for every setup; device-specific entries
// are appended so only one security_opt key is ever emitted
return [ return [
" security_opt:", " security_opt:",
...device.securityOpt.map((s) => ` - ${s}`), " - no-new-privileges:true",
...(device.securityOpt ?? []).map((s) => ` - ${s}`),
]; ];
} }
/**
* Emit privileged mode only for hardware that genuinely cannot work without it.
* Everything else gets device mappings, which grant far less access.
*/
function buildPrivileged(
device: DeviceConfig,
selectedHardware: HardwareOption[]
): string[] {
const requiring = [device, ...selectedHardware].filter((c) => c.privileged);
if (!requiring.length) return [];
const reasons = requiring
.map((c) => c.privilegedReason)
.filter((r): r is string => Boolean(r));
const comment = reasons.length ? ` # ${reasons.join("; ")}` : "";
return [` privileged: true${comment}`];
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Public API // Public API
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -217,11 +237,14 @@ export function generateDockerCompose(input: GeneratorInput): string {
const hwVolumes: VolumeMapping[] = []; const hwVolumes: VolumeMapping[] = [];
const hwEnv: Record<string, string> = {}; const hwEnv: Record<string, string> = {};
const selectedHw: HardwareOption[] = [];
for (const hwId of input.selectedHardware) { for (const hwId of input.selectedHardware) {
const hw = hardwareMap.get(hwId); const hw = hardwareMap.get(hwId);
if (!hw) continue; if (!hw) continue;
// Skip GPU device mapping for tensorrt images (it uses deploy instead) // Skip GPU device mapping for tensorrt images (it uses deploy instead)
if (hw.id === "gpu" && device.imageTag === "stable-tensorrt") continue; if (hw.id === "gpu" && device.imageTag === "stable-tensorrt") continue;
selectedHw.push(hw);
hwDevices.push(...(hw.devices ?? [])); hwDevices.push(...(hw.devices ?? []));
hwVolumes.push(...(hw.volumes ?? [])); hwVolumes.push(...(hw.volumes ?? []));
Object.assign(hwEnv, hw.env ?? {}); Object.assign(hwEnv, hw.env ?? {});
@@ -231,7 +254,7 @@ export function generateDockerCompose(input: GeneratorInput): string {
"services:", "services:",
" frigate:", " frigate:",
" container_name: frigate", " container_name: frigate",
" privileged: true # This may not be necessary for all setups", ...buildPrivileged(device, selectedHw),
" restart: unless-stopped", " restart: unless-stopped",
" stop_grace_period: 30s # Allow enough time to shut down the various services", " stop_grace_period: 30s # Allow enough time to shut down the various services",
...buildImage(device), ...buildImage(device),
+5 -3
View File
@@ -1476,10 +1476,12 @@ paths:
- Classification - Classification
summary: Get custom classification attributes summary: Get custom classification attributes
description: |- description: |-
**Access:** Admin role required. **Access:** Any authenticated user.
Returns custom classification attributes for a given object type. Returns custom classification attributes for a given object type.
Only includes models with classification_type set to 'attribute'. Only includes models with classification_type set to 'attribute'.
Callers without access to every camera only receive values that have been
recorded on the cameras they can access.
By default returns a flat sorted list of all attribute labels. By default returns a flat sorted list of all attribute labels.
If group_by_model is true, returns attributes grouped by model name. If group_by_model is true, returns attributes grouped by model name.
operationId: get_custom_attributes_classification_attributes_get operationId: get_custom_attributes_classification_attributes_get
@@ -1510,8 +1512,8 @@ paths:
schema: schema:
$ref: '#/components/schemas/HTTPValidationError' $ref: '#/components/schemas/HTTPValidationError'
security: security:
- frigateAdminAuth: [] - frigateUserAuth: []
x-required-role: admin x-required-role: any
/classification/{name}/train: /classification/{name}/train:
get: get:
tags: tags:
+8
View File
@@ -86,6 +86,7 @@ def require_admin_by_default():
"/categorized_object_names", "/categorized_object_names",
"/plus/models", "/plus/models",
"/recognized_license_plates", "/recognized_license_plates",
"/classification/attributes",
"/timeline", "/timeline",
"/timeline/hourly", "/timeline/hourly",
"/recordings/storage", "/recordings/storage",
@@ -858,9 +859,12 @@ def login(request: Request, body: AppPostLoginBody):
user = body.user user = body.user
password = body.password password = body.password
remote_addr = get_remote_addr(request)
try: try:
db_user: User = User.get_by_id(user) db_user: User = User.get_by_id(user)
except DoesNotExist: except DoesNotExist:
logger.warning(f"Login failed for unknown user '{user}' from {remote_addr}")
return JSONResponse(content={"message": "Login failed"}, status_code=401) return JSONResponse(content={"message": "Login failed"}, status_code=401)
password_hash = db_user.password_hash password_hash = db_user.password_hash
@@ -888,6 +892,10 @@ def login(request: Request, body: AppPostLoginBody):
request.app.frigate_config.auth.admin_first_time_login = False request.app.frigate_config.auth.admin_first_time_login = False
return response return response
logger.warning(
f"Login failed for user '{user}' (invalid password) from {remote_addr}"
)
return JSONResponse(content={"message": "Login failed"}, status_code=401) return JSONResponse(content={"message": "Login failed"}, status_code=401)
+2 -2
View File
@@ -33,7 +33,7 @@ from frigate.config.camera.updater import (
CameraConfigUpdateEnum, CameraConfigUpdateEnum,
CameraConfigUpdateTopic, CameraConfigUpdateTopic,
) )
from frigate.config.env import substitute_frigate_vars from frigate.config.env import UnknownVariableError, substitute_frigate_vars
from frigate.models import User from frigate.models import User
from frigate.util.builtin import clean_camera_user_pass, get_record_segment_time from frigate.util.builtin import clean_camera_user_pass, get_record_segment_time
from frigate.util.camera_cleanup import cleanup_camera_db, cleanup_camera_files from frigate.util.camera_cleanup import cleanup_camera_db, cleanup_camera_files
@@ -166,7 +166,7 @@ def go2rtc_add_stream(request: Request, stream_name: str, src: str = ""):
if src: if src:
try: try:
resolved_src = substitute_frigate_vars(src) resolved_src = substitute_frigate_vars(src)
except KeyError: except UnknownVariableError:
resolved_src = src resolved_src = src
if is_restricted_go2rtc_source(resolved_src): if is_restricted_go2rtc_source(resolved_src):
+96 -3
View File
@@ -11,10 +11,14 @@ from typing import Any
import cv2 import cv2
from fastapi import APIRouter, Depends, Request, UploadFile from fastapi import APIRouter, Depends, Request, UploadFile
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from peewee import DoesNotExist from peewee import DoesNotExist, fn
from playhouse.shortcuts import model_to_dict from playhouse.shortcuts import model_to_dict
from frigate.api.auth import require_role from frigate.api.auth import (
allow_any_authenticated,
get_allowed_cameras_for_filter,
require_role,
)
from frigate.api.defs.request.classification_body import ( from frigate.api.defs.request.classification_body import (
AudioTranscriptionBody, AudioTranscriptionBody,
DeleteFaceImagesBody, DeleteFaceImagesBody,
@@ -739,18 +743,81 @@ def get_classification_dataset(name: str):
) )
def get_observed_attributes(
model_attributes: dict[str, list[str]],
object_labels: set[str],
allowed_cameras: list[str],
) -> dict[str, set[str]]:
"""Get the attribute values recorded on the given cameras.
Args:
model_attributes: Labels each attribute model can emit, keyed by model name
object_labels: Object types those models run on
allowed_cameras: Cameras the caller has access to
Returns:
Values seen for each model, keyed by model name
"""
if not model_attributes or not object_labels or not allowed_cameras:
return {}
model_names = list(model_attributes.keys())
query = (
Event.select(
*[
fn.json_extract(Event.data, f'$."{model_name}"')
for model_name in model_names
]
)
.where(
(Event.camera << allowed_cameras) & (Event.label << sorted(object_labels))
)
.distinct()
.tuples()
)
targets = {
model_name: set(attributes)
for model_name, attributes in model_attributes.items()
}
observed: dict[str, set[str]] = {model_name: set() for model_name in model_names}
for row in query.iterator():
found = False
for model_name, value in zip(model_names, row):
if isinstance(value, str) and value not in observed[model_name]:
observed[model_name].add(value)
found = True
if found and all(
observed[model_name] >= targets[model_name] for model_name in model_names
):
break
return observed
@router.get( @router.get(
"/classification/attributes", "/classification/attributes",
dependencies=[Depends(allow_any_authenticated())],
summary="Get custom classification attributes", summary="Get custom classification attributes",
description="""Returns custom classification attributes for a given object type. description="""Returns custom classification attributes for a given object type.
Only includes models with classification_type set to 'attribute'. Only includes models with classification_type set to 'attribute'.
Callers without access to every camera only receive values that have been
recorded on the cameras they can access.
By default returns a flat sorted list of all attribute labels. By default returns a flat sorted list of all attribute labels.
If group_by_model is true, returns attributes grouped by model name.""", If group_by_model is true, returns attributes grouped by model name.""",
) )
def get_custom_attributes( def get_custom_attributes(
request: Request, object_type: str = None, group_by_model: bool = False request: Request,
object_type: str = None,
group_by_model: bool = False,
allowed_cameras: list[str] = Depends(get_allowed_cameras_for_filter),
): ):
models_with_attributes = {} models_with_attributes = {}
objects_by_model = {}
for ( for (
model_key, model_key,
@@ -781,6 +848,32 @@ def get_custom_attributes(
if attributes: if attributes:
model_name = model_config.name or model_key model_name = model_config.name or model_key
models_with_attributes[model_name] = sorted(attributes) models_with_attributes[model_name] = sorted(attributes)
objects_by_model[model_name] = model_objects
# the dataset holds every label a model can emit, including ones never
# applied to an event, so callers without full camera access are limited to
# the values actually recorded on the cameras they can see
all_cameras = set(request.app.frigate_config.cameras.keys())
if models_with_attributes and not all_cameras.issubset(allowed_cameras):
observed = get_observed_attributes(
models_with_attributes,
set().union(*objects_by_model.values()),
allowed_cameras,
)
models_with_attributes = {
model_name: [
attribute
for attribute in attributes
if attribute in observed.get(model_name, set())
]
for model_name, attributes in models_with_attributes.items()
}
models_with_attributes = {
model_name: attributes
for model_name, attributes in models_with_attributes.items()
if attributes
}
if group_by_model: if group_by_model:
return JSONResponse(content=models_with_attributes) return JSONResponse(content=models_with_attributes)
+2
View File
@@ -86,6 +86,7 @@ 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.ownership import chown_to_runtime
from frigate.util.process import FrigateProcess 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
@@ -149,6 +150,7 @@ class FrigateApp:
if not os.path.exists(d) and not os.path.islink(d): if not os.path.exists(d) and not os.path.islink(d):
logger.info(f"Creating directory: {d}") logger.info(f"Creating directory: {d}")
os.makedirs(d, exist_ok=True) os.makedirs(d, exist_ok=True)
chown_to_runtime(d)
else: else:
logger.debug(f"Skipping directory: {d}") logger.debug(f"Skipping directory: {d}")
+16
View File
@@ -6,6 +6,8 @@ import logging
from collections.abc import Callable, Iterable from collections.abc import Callable, Iterable
from typing import Any, cast from typing import Any, cast
from peewee import IntegrityError
from frigate.camera import PTZMetrics from frigate.camera import PTZMetrics
from frigate.camera.activity_manager import AudioActivityManager, CameraActivityManager from frigate.camera.activity_manager import AudioActivityManager, CameraActivityManager
from frigate.comms.base_communicator import Communicator from frigate.comms.base_communicator import Communicator
@@ -254,7 +256,21 @@ class Dispatcher:
restart_frigate() restart_frigate()
def handle_insert_many_recordings() -> None: def handle_insert_many_recordings() -> None:
try:
Recordings.insert_many(payload).execute() Recordings.insert_many(payload).execute()
except IntegrityError:
logger.warning(
"Batch recording insert failed, inserting rows individually"
)
for recording in payload:
try:
Recordings.insert(recording).execute()
except IntegrityError:
logger.warning(
"Skipping recording that is already stored: %s",
recording.get(Recordings.path.name),
)
def handle_request_region_grid() -> Any: def handle_request_region_grid() -> Any:
camera = payload camera = payload
+4 -1
View File
@@ -18,7 +18,10 @@ class RecordingsDataTypeEnum(str, Enum):
class RecordingsDataPublisher(Publisher[Any]): class RecordingsDataPublisher(Publisher[Any]):
"""Publishes latest recording data.""" """Publishes latest recording data.
Payloads are (camera, stream_type, timestamp, cache_path) on every topic.
"""
topic_base = "recordings/" topic_base = "recordings/"
+7 -2
View File
@@ -220,16 +220,21 @@ class CameraConfig(FrigateBaseModel):
# add roles to the input if there is only one # add roles to the input if there is only one
if len(config["ffmpeg"]["inputs"]) == 1: if len(config["ffmpeg"]["inputs"]) == 1:
has_audio = "audio" in config["ffmpeg"]["inputs"][0].get("roles", []) existing_roles = config["ffmpeg"]["inputs"][0].get("roles", [])
config["ffmpeg"]["inputs"][0]["roles"] = [ config["ffmpeg"]["inputs"][0]["roles"] = [
"record", "record",
"detect", "detect",
] ]
if has_audio: if "audio" in existing_roles:
config["ffmpeg"]["inputs"][0]["roles"].append("audio") config["ffmpeg"]["inputs"][0]["roles"].append("audio")
# kept so role validation can report the real problem rather than
# claiming the role was never assigned
if "record_sub" in existing_roles:
config["ffmpeg"]["inputs"][0]["roles"].append("record_sub")
super().__init__(**config) super().__init__(**config)
@property @property
+8 -1
View File
@@ -2,7 +2,7 @@ from enum import Enum
from pydantic import Field from pydantic import Field
from frigate.const import MAX_PRE_CAPTURE from frigate.const import MAX_PRE_CAPTURE, STREAM_TYPE_SUB
from frigate.review.types import SeverityEnum from frigate.review.types import SeverityEnum
from ..base import FrigateBaseModel from ..base import FrigateBaseModel
@@ -191,6 +191,13 @@ class RecordConfig(FrigateBaseModel):
description="Indicates whether recording was enabled in the original static configuration.", description="Indicates whether recording was enabled in the original static configuration.",
) )
def stream_enabled(self, stream_type: str) -> bool:
"""Whether the given record stream type should currently be recording."""
if stream_type == STREAM_TYPE_SUB:
return self.enabled and self.sub.enabled
return self.enabled
@property @property
def effective_alert_days(self) -> float: def effective_alert_days(self) -> float:
"""Alert retention extended to the sub stream window when sub is enabled. """Alert retention extended to the sub stream window when sub is enabled.
+10 -1
View File
@@ -63,7 +63,7 @@ from .classification import (
SemanticSearchModelEnum, SemanticSearchModelEnum,
) )
from .database import DatabaseConfig from .database import DatabaseConfig
from .env import EnvVars from .env import EnvVars, reload_sources
from .logger import LoggerConfig from .logger import LoggerConfig
from .mqtt import MqttConfig from .mqtt import MqttConfig
from .network import NetworkingConfig from .network import NetworkingConfig
@@ -269,6 +269,12 @@ def verify_config_roles(camera_config: CameraConfig) -> None:
f"Camera {camera_config.name} has sub stream recording enabled, but record_sub is not assigned to an input." f"Camera {camera_config.name} has sub stream recording enabled, but record_sub is not assigned to an input."
) )
for ffmpeg_input in camera_config.ffmpeg.inputs:
if "record" in ffmpeg_input.roles and "record_sub" in ffmpeg_input.roles:
raise ValueError(
f"Camera {camera_config.name} has record and record_sub assigned to the same input, which would record the same stream twice."
)
if camera_config.audio.enabled and "audio" not in assigned_roles: if camera_config.audio.enabled and "audio" not in assigned_roles:
raise ValueError( raise ValueError(
f"Camera {camera_config.name} has audio events enabled, but audio is not assigned to an input." f"Camera {camera_config.name} has audio events enabled, but audio is not assigned to an input."
@@ -1307,6 +1313,9 @@ class FrigateConfig(FrigateBaseModel):
@classmethod @classmethod
def parse(cls, config, *, is_json=None, safe_load=False, **context): def parse(cls, config, *, is_json=None, safe_load=False, **context):
# Pick up secrets.yaml edits without a restart.
reload_sources()
# If config is a file, read its contents. # If config is a file, read its contents.
if hasattr(config, "read"): if hasattr(config, "read"):
fname = getattr(config, "name", None) fname = getattr(config, "name", None)
+192 -18
View File
@@ -1,20 +1,193 @@
"""Environment variable and secrets handling for the Frigate config."""
import logging
import os import os
import re import re
from collections.abc import Mapping
from pathlib import Path from pathlib import Path
from typing import Annotated from typing import Annotated, Any
from pydantic import AfterValidator, ValidationInfo from pydantic import AfterValidator, ValidationInfo
from ruamel.yaml import YAML, YAMLError
FRIGATE_ENV_VARS = {k: v for k, v in os.environ.items() if k.startswith("FRIGATE_")} from frigate.const import CONFIG_DIR
secrets_dir = os.environ.get("CREDENTIALS_DIRECTORY", "/run/secrets")
# read secret files as env vars too logger = logging.getLogger(__name__)
if os.path.isdir(secrets_dir) and os.access(secrets_dir, os.R_OK):
for secret_file in os.listdir(secrets_dir):
if secret_file.startswith("FRIGATE_"): class UnknownVariableError(ValueError):
FRIGATE_ENV_VARS[secret_file] = ( """Undefined {FRIGATE_*} placeholder. ValueError so pydantic names the field."""
Path(os.path.join(secrets_dir, secret_file)).read_text().strip()
# Substitution sources, lowest precedence first.
_CONFIG_ENV_VARS: dict[str, str] = {}
_SECRETS_FILE: dict[str, str] = {}
# Snapshot: apply_config_env_vars() writes os.environ after import.
_CONTAINER_ENV: dict[str, str] = {
k: v for k, v in os.environ.items() if k.startswith("FRIGATE_")
}
_CREDENTIALS_DIR: dict[str, str] = {}
_SOURCES: tuple[tuple[str, dict[str, str]], ...] = (
("environment_vars config block", _CONFIG_ENV_VARS),
("secrets.yaml", _SECRETS_FILE),
("container environment", _CONTAINER_ENV),
("credentials directory", _CREDENTIALS_DIR),
) )
FRIGATE_ENV_VARS: dict[str, str] = {}
_WARNED_COLLISIONS: set[str] = set()
def _rebuild(warn: bool = True) -> None:
"""Merge the sources into FRIGATE_ENV_VARS.
warn=False is for the import-time call, before logging is configured.
"""
merged: dict[str, str] = {}
origin: dict[str, str] = {}
duplicated: set[str] = set()
for label, source in _SOURCES:
for key, value in source.items():
if key in merged and merged[key] != value:
duplicated.add(key)
merged[key] = value
origin[key] = label
if warn:
for key in sorted(duplicated - _WARNED_COLLISIONS):
_WARNED_COLLISIONS.add(key)
logger.warning(
"%s is defined in more than one place, using the value from %s",
key,
origin[key],
)
# In place: tests hold a reference to this dict.
FRIGATE_ENV_VARS.clear()
FRIGATE_ENV_VARS.update(merged)
def _load_credentials_dir() -> dict[str, str]:
"""Read FRIGATE_* files from the Docker or systemd credentials directory."""
directory = os.environ.get("CREDENTIALS_DIRECTORY", "/run/secrets")
values: dict[str, str] = {}
if not (os.path.isdir(directory) and os.access(directory, os.R_OK)):
return values
for name in os.listdir(directory):
if not name.startswith("FRIGATE_"):
continue
try:
values[name] = Path(os.path.join(directory, name)).read_text().strip()
except (OSError, UnicodeDecodeError):
logger.warning("Unable to read %s in %s, skipping", name, directory)
return values
def _secrets_file_path() -> str | None:
"""Locate secrets.yaml next to the config file."""
config_file = os.environ.get("CONFIG_FILE")
config_dir = os.path.dirname(config_file) if config_file else CONFIG_DIR
for name in ("secrets.yaml", "secrets.yml"):
path = os.path.join(config_dir, name)
if os.path.isfile(path):
return path
return None
def _load_secrets_file() -> dict[str, str]:
"""Read the flat FRIGATE_* map from secrets.yaml, if it exists."""
path = _secrets_file_path()
if path is None:
return {}
try:
with open(path) as f:
raw: Any = YAML(typ="safe").load(f)
except OSError as err:
raise ValueError(f"Unable to read {path}: {err.strerror}") from err
except YAMLError as err:
# The parser message can quote values, so only name a position.
mark = getattr(err, "problem_mark", None)
where = f" near line {mark.line + 1}" if mark is not None else ""
raise ValueError(f"{path} is not valid YAML{where}") from err
if raw is None:
return {}
if not isinstance(raw, dict):
raise ValueError(f"{path} must be a flat map of names to values")
values: dict[str, str] = {}
for key, value in raw.items():
name = str(key)
if isinstance(value, (dict, list)):
raise ValueError(f"{path} value for {name} must be a single value")
if not name.startswith("FRIGATE_"):
logger.warning(
"Ignoring %s in %s, names must start with FRIGATE_", name, path
)
continue
values[name] = "" if value is None else str(value)
return values
def reload_sources(warn: bool = True) -> None:
"""Re-read the file backed sources and rebuild the namespace."""
_CREDENTIALS_DIR.clear()
_CREDENTIALS_DIR.update(_load_credentials_dir())
try:
secrets = _load_secrets_file()
except ValueError as err:
# Keep the last good values; this runs at import and on every parse.
logger.error("Ignoring secrets file, %s", err)
else:
_SECRETS_FILE.clear()
_SECRETS_FILE.update(secrets)
_rebuild(warn)
def apply_config_env_vars(values: Mapping[str, object]) -> None:
"""Install the environment_vars block as the lowest priority source.
Unprefixed keys only set os.environ.
"""
for key, value in values.items():
resolved = str(value)
if key.startswith("FRIGATE_"):
_CONFIG_ENV_VARS[key] = resolved
else:
os.environ[key] = resolved
_rebuild()
# Export the winning value; auth reads FRIGATE_JWT_SECRET from os.environ.
for key in values:
if key.startswith("FRIGATE_"):
os.environ[key] = FRIGATE_ENV_VARS[key]
reload_sources(warn=False)
# Matches a FRIGATE_* identifier following an opening brace. # Matches a FRIGATE_* identifier following an opening brace.
_FRIGATE_IDENT_RE = re.compile(r"FRIGATE_[A-Za-z0-9_]+") _FRIGATE_IDENT_RE = re.compile(r"FRIGATE_[A-Za-z0-9_]+")
@@ -29,12 +202,13 @@ def substitute_frigate_vars(value: str) -> str:
* `{{` and `}}` collapse to literal `{` / `}` (the documented escape). * `{{` and `}}` collapse to literal `{` / `}` (the documented escape).
* `{FRIGATE_NAME}` is replaced from `FRIGATE_ENV_VARS`; an unknown name * `{FRIGATE_NAME}` is replaced from `FRIGATE_ENV_VARS`; an unknown name
raises `KeyError` to preserve the existing "Invalid substitution" raises `UnknownVariableError` to preserve the existing "Invalid
error path. substitution" error path.
* A `{` that begins `{FRIGATE_` but is not a well-formed * A `{` that begins `{FRIGATE_` but is not a well-formed
`{FRIGATE_NAME}` placeholder raises `ValueError` (malformed `{FRIGATE_NAME}` placeholder raises `ValueError` (malformed
placeholder). Callers that catch `KeyError` to allow unknown-var placeholder). Callers that catch `UnknownVariableError` to allow
passthrough will still surface malformed syntax as an error. unknown-var passthrough will still surface malformed syntax as an
error.
* Any other `{` or `}` is treated as a literal and passed through. * Any other `{` or `}` is treated as a literal and passed through.
""" """
out: list[str] = [] out: list[str] = []
@@ -58,7 +232,10 @@ def substitute_frigate_vars(value: str) -> str:
): ):
key = ident_match.group(0) key = ident_match.group(0)
if key not in FRIGATE_ENV_VARS: if key not in FRIGATE_ENV_VARS:
raise KeyError(key) raise UnknownVariableError(
f"{key} is not defined in the environment, "
"secrets.yaml, or the environment_vars config"
)
out.append(FRIGATE_ENV_VARS[key]) out.append(FRIGATE_ENV_VARS[key])
i = ident_match.end() + 1 i = ident_match.end() + 1
continue continue
@@ -94,10 +271,7 @@ EnvString = Annotated[str, AfterValidator(validate_env_string)]
def validate_env_vars(v: dict[str, str], info: ValidationInfo) -> dict[str, str]: def validate_env_vars(v: dict[str, str], info: ValidationInfo) -> dict[str, str]:
if isinstance(info.context, dict) and info.context.get("install", False): if isinstance(info.context, dict) and info.context.get("install", False):
for k, val in v.items(): apply_config_env_vars(v)
os.environ[k] = val
if k.startswith("FRIGATE_"):
FRIGATE_ENV_VARS[k] = val
return v return v
+3
View File
@@ -28,6 +28,9 @@ REDACTED_CREDENTIAL_SENTINEL = "__FRIGATE_SAVED_CREDENTIAL__"
STREAM_TYPE_MAIN = "main" STREAM_TYPE_MAIN = "main"
STREAM_TYPE_SUB = "sub" STREAM_TYPE_SUB = "sub"
SUB_CACHE_TAG = "@sub" SUB_CACHE_TAG = "@sub"
RECORD_STREAM_TYPES = (STREAM_TYPE_MAIN, STREAM_TYPE_SUB)
ROLE_TO_STREAM_TYPE = {"record": STREAM_TYPE_MAIN, "record_sub": STREAM_TYPE_SUB}
STREAM_TYPE_TO_ROLE = {v: k for k, v in ROLE_TO_STREAM_TYPE.items()}
# Attribute & Object constants # Attribute & Object constants
@@ -23,6 +23,7 @@ from frigate.const import (
ATTRIBUTE_LABEL_DISPLAY_MAP, ATTRIBUTE_LABEL_DISPLAY_MAP,
CACHE_DIR, CACHE_DIR,
CLIPS_DIR, CLIPS_DIR,
STREAM_TYPE_MAIN,
UPDATE_REVIEW_DESCRIPTION, UPDATE_REVIEW_DESCRIPTION,
) )
from frigate.data_processing.types import PostProcessDataEnum from frigate.data_processing.types import PostProcessDataEnum
@@ -441,6 +442,7 @@ class ReviewDescriptionProcessor(PostProcessorApi):
) )
.where((ts >= Recordings.start_time) & (ts <= Recordings.end_time)) .where((ts >= Recordings.start_time) & (ts <= Recordings.end_time))
.where(Recordings.camera == camera) .where(Recordings.camera == camera)
.where(Recordings.stream_type == STREAM_TYPE_MAIN)
.order_by(Recordings.start_time.desc()) .order_by(Recordings.start_time.desc())
.limit(1) .limit(1)
.get() .get()
+3 -1
View File
@@ -714,7 +714,9 @@ class EmbeddingMaintainer(threading.Thread):
topic = str(raw_topic) topic = str(raw_topic)
if topic.endswith(RecordingsDataTypeEnum.saved.value): if topic.endswith(RecordingsDataTypeEnum.saved.value):
camera, recordings_available_through_timestamp, _ = payload camera, _stream_type, recordings_available_through_timestamp, _ = (
payload
)
self.recordings_available_through[camera] = ( self.recordings_available_through[camera] = (
recordings_available_through_timestamp recordings_available_through_timestamp
+18 -3
View File
@@ -83,7 +83,7 @@ class BaseLocalDetector(ObjectDetector):
raw_detections = self.detect_raw(tensor_input) # type: ignore[attr-defined] raw_detections = self.detect_raw(tensor_input) # type: ignore[attr-defined]
for d in raw_detections: for d in raw_detections:
if int(d[0]) < 0 or int(d[0]) >= len(self.labels): if int(d[0]) not in self.labels:
logger.warning(f"Raw Detect returned invalid label: {d}") logger.warning(f"Raw Detect returned invalid label: {d}")
continue continue
if d[1] < threshold: if d[1] < threshold:
@@ -395,6 +395,9 @@ class RemoteObjectDetector:
self.labels = labels self.labels = labels
self.name = name self.name = name
self.fps = EventsPerSecond() self.fps = EventsPerSecond()
# class ids already warned about, so an incomplete labelmap logs once
# per id instead of once per frame
self.unnamed_class_ids: set[int] = set()
self.detection_queue = detection_queue self.detection_queue = detection_queue
self.stop_event = stop_event self.stop_event = stop_event
self.shm = UntrackedSharedMemory(name=self.name, create=False) self.shm = UntrackedSharedMemory(name=self.name, create=False)
@@ -436,9 +439,21 @@ class RemoteObjectDetector:
for d in self.out_np_shm: for d in self.out_np_shm:
if d[1] < threshold: if d[1] < threshold:
break break
detections.append(
(self.labels[int(d[0])], float(d[1]), (d[2], d[3], d[4], d[5])) class_id = int(d[0])
label = self.labels.get(class_id)
if label is None:
if class_id not in self.unnamed_class_ids:
self.unnamed_class_ids.add(class_id)
logger.warning(
"Detector returned class id %d for %s, which the labelmap does not name. Check that labelmap_path matches the model",
class_id,
self.name,
) )
continue
detections.append((label, float(d[1]), (d[2], d[3], d[4], d[5])))
self.fps.update() self.fps.update()
return detections return detections
+34 -7
View File
@@ -149,8 +149,12 @@ class RecordingCleanup(threading.Thread):
detections_retain_mode: RetainModeEnum, detections_retain_mode: RetainModeEnum,
config: CameraConfig, config: CameraConfig,
reviews: list[Any], reviews: list[Any],
) -> set[Path]: ) -> tuple[set[Path], list[tuple[float, float]]]:
"""Delete recordings for existing camera based on retention config.""" """Delete recordings for one stream of an existing camera based on retention config.
Returns the directories to check for emptiness and the segments that
were kept, which the caller feeds to expire_camera_previews.
"""
# Get the timestamp for cutoff of retained days # Get the timestamp for cutoff of retained days
# Get recordings to check for expiration # Get recordings to check for expiration
@@ -257,9 +261,23 @@ class RecordingCleanup(threading.Thread):
Recordings.id << deleted_recordings_list[i : i + max_deletes] Recordings.id << deleted_recordings_list[i : i + max_deletes]
).execute() ).execute()
# previews follow main retention, so only the main pass expires them return maybe_empty_dirs, kept_recordings
if stream_type != STREAM_TYPE_MAIN:
return maybe_empty_dirs def expire_camera_previews(
self,
config: CameraConfig,
continuous_expire_date: float,
motion_expire_date: float,
kept_recordings: list[tuple[float, float]],
) -> set[Path]:
"""Delete previews that no longer have recordings on any stream.
Previews aren't recorded per stream, so the cutoffs must be the oldest
of the per stream values and kept_recordings must cover every stream,
sorted by start time. Otherwise a short main retention expires previews
the sub recordings still need.
"""
maybe_empty_dirs: set[Path] = set()
previews = ( previews = (
Previews.select( Previews.select(
@@ -438,7 +456,7 @@ class RecordingCleanup(threading.Thread):
.namedtuples() .namedtuples()
) )
maybe_empty_dirs |= self.expire_existing_camera_recordings( main_dirs, main_kept = self.expire_existing_camera_recordings(
STREAM_TYPE_MAIN, STREAM_TYPE_MAIN,
continuous_expire_date, continuous_expire_date,
motion_expire_date, motion_expire_date,
@@ -452,10 +470,11 @@ class RecordingCleanup(threading.Thread):
config.record.detections.retain.days, config.record.detections.retain.days,
), ),
) )
maybe_empty_dirs |= main_dirs
# runs even when sub recording is disabled so old rows still # runs even when sub recording is disabled so old rows still
# expire # expire
maybe_empty_dirs |= self.expire_existing_camera_recordings( sub_dirs, sub_kept = self.expire_existing_camera_recordings(
STREAM_TYPE_SUB, STREAM_TYPE_SUB,
sub_continuous_expire_date, sub_continuous_expire_date,
sub_motion_expire_date, sub_motion_expire_date,
@@ -469,6 +488,14 @@ class RecordingCleanup(threading.Thread):
config.record.sub.detections.days, config.record.sub.detections.days,
), ),
) )
maybe_empty_dirs |= sub_dirs
maybe_empty_dirs |= self.expire_camera_previews(
config,
min(continuous_expire_date, sub_continuous_expire_date),
min(motion_expire_date, sub_motion_expire_date),
sorted(main_kept + sub_kept),
)
logger.debug(f"End camera: {camera}.") logger.debug(f"End camera: {camera}.")
logger.debug("End all cameras.") logger.debug("End all cameras.")
+86 -31
View File
@@ -79,6 +79,54 @@ def parse_cache_segment_name(basename: str) -> tuple[str, str, str] | None:
return (prefix, STREAM_TYPE_MAIN, date) return (prefix, STREAM_TYPE_MAIN, date)
def format_segment_details(cache_path: str, segment_info: dict[str, Any]) -> str:
"""Comma separated facts about a segment, for discard warnings."""
details: list[str] = []
duration = segment_info.get("duration", -1)
if duration != -1:
details.append(f"duration: {duration:.2f}s")
try:
details.append(f"size: {os.path.getsize(cache_path) / 1024:.1f} KB")
except OSError:
pass
details.append(f"video: {segment_info.get('video_codec') or 'none'}")
if segment_info.get("has_audio"):
audio = segment_info.get("audio_codec") or "unknown"
rate = segment_info.get("audio_rate")
details.append(f"audio: {audio} {rate}Hz" if rate else f"audio: {audio}")
else:
details.append("audio: none")
return ", ".join(details)
def segment_path_time(cache_path: str) -> datetime.datetime | None:
"""Timestamp a segment's recording path is built from, or None if unparsable.
Recording paths carry one second of resolution, and so does ffmpeg's cache
segment template, which makes a cache file name unique per camera stream
and second. Resolved start times are not: a stream cutting segments faster
than once a second resolves consecutive segments into the same second, and
building the path from those collides on the unique path index.
"""
parsed = parse_cache_segment_name(Path(cache_path).stem)
if parsed is None:
return None
try:
return datetime.datetime.strptime(parsed[2], CACHE_SEGMENT_FORMAT).astimezone(
datetime.UTC
)
except ValueError:
return None
class SegmentInfo: class SegmentInfo:
def __init__( def __init__(
self, self,
@@ -241,49 +289,53 @@ class RecordingMaintainer(threading.Thread):
and not d.startswith("preview_") and not d.startswith("preview_")
] ]
# publish newest cached segment per camera (including in use files) # publish newest cached segment per camera stream (including in use files)
newest_cache_segments: dict[str, dict[str, Any]] = {} newest_cache_segments: dict[tuple[str, str], dict[str, Any]] = {}
for cache in cache_files: for cache in cache_files:
cache_path = os.path.join(CACHE_DIR, cache) cache_path = os.path.join(CACHE_DIR, cache)
basename = os.path.splitext(cache)[0] basename = os.path.splitext(cache)[0]
parsed = parse_cache_segment_name(basename) parsed = parse_cache_segment_name(basename)
if parsed is None: if parsed is None:
if not self.unexpected_cache_files_logged: if not self.unexpected_cache_files_logged:
logger.warning("Skipping unexpected files in cache") logger.warning(f"Skipping unexpected files in cache, e.g. {cache}")
self.unexpected_cache_files_logged = True self.unexpected_cache_files_logged = True
continue continue
camera, stream_type, date = parsed camera, stream_type, date = parsed
# this topic feeds main-stream health/sync consumers only
if stream_type == STREAM_TYPE_SUB:
continue
start_time = datetime.datetime.strptime( start_time = datetime.datetime.strptime(
date, CACHE_SEGMENT_FORMAT date, CACHE_SEGMENT_FORMAT
).astimezone(datetime.UTC) ).astimezone(datetime.UTC)
key = (camera, stream_type)
if ( if (
camera not in newest_cache_segments key not in newest_cache_segments
or start_time > newest_cache_segments[camera]["start_time"] or start_time > newest_cache_segments[key]["start_time"]
): ):
newest_cache_segments[camera] = { newest_cache_segments[key] = {
"start_time": start_time, "start_time": start_time,
"cache_path": cache_path, "cache_path": cache_path,
} }
for camera, newest in newest_cache_segments.items(): for (camera, stream_type), newest in newest_cache_segments.items():
self.recordings_publisher.publish( self.recordings_publisher.publish(
( (
camera, camera,
stream_type,
newest["start_time"].timestamp(), newest["start_time"].timestamp(),
newest["cache_path"], newest["cache_path"],
), ),
RecordingsDataTypeEnum.latest.value, RecordingsDataTypeEnum.latest.value,
) )
# publish None for cameras with no cache files (but only if we know the camera exists) # publish None for streams with no cache files (but only if we know the camera exists)
for camera_name in self.config.cameras: for camera_name, camera_config in self.config.cameras.items():
if camera_name not in newest_cache_segments: stream_types = [STREAM_TYPE_MAIN]
if camera_config.record.sub.enabled:
stream_types.append(STREAM_TYPE_SUB)
for stream_type in stream_types:
if (camera_name, stream_type) not in newest_cache_segments:
self.recordings_publisher.publish( self.recordings_publisher.publish(
(camera_name, None, None), (camera_name, stream_type, None, None),
RecordingsDataTypeEnum.latest.value, RecordingsDataTypeEnum.latest.value,
) )
@@ -314,7 +366,7 @@ class RecordingMaintainer(threading.Thread):
parsed = parse_cache_segment_name(basename) parsed = parse_cache_segment_name(basename)
if parsed is None: if parsed is None:
if not self.unexpected_cache_files_logged: if not self.unexpected_cache_files_logged:
logger.warning("Skipping unexpected files in cache") logger.warning(f"Skipping unexpected files in cache, e.g. {cache}")
self.unexpected_cache_files_logged = True self.unexpected_cache_files_logged = True
continue continue
camera, stream_type, date = parsed camera, stream_type, date = parsed
@@ -447,6 +499,7 @@ class RecordingMaintainer(threading.Thread):
self.recordings_publisher.publish( self.recordings_publisher.publish(
( (
camera, camera,
stream_type,
recordings[0]["start_time"].timestamp() recordings[0]["start_time"].timestamp()
if camera_cfg and camera_cfg.record.enabled if camera_cfg and camera_cfg.record.enabled
else None, else None,
@@ -540,11 +593,11 @@ class RecordingMaintainer(threading.Thread):
if not segment_info.get("has_valid_video", False): if not segment_info.get("has_valid_video", False):
logger.warning( logger.warning(
f"Invalid or missing video stream in segment {cache_path}. Discarding." f"Invalid or missing video stream in segment {cache_path} "
f"({format_segment_details(cache_path, segment_info)}). Discarding."
) )
if stream_type == STREAM_TYPE_MAIN:
self.recordings_publisher.publish( self.recordings_publisher.publish(
(camera, start_time.timestamp(), cache_path), (camera, stream_type, start_time.timestamp(), cache_path),
RecordingsDataTypeEnum.invalid.value, RecordingsDataTypeEnum.invalid.value,
) )
self.drop_segment(cache_path) self.drop_segment(cache_path)
@@ -583,19 +636,20 @@ class RecordingMaintainer(threading.Thread):
if duration == -1: if duration == -1:
logger.warning(f"Failed to probe corrupt segment {cache_path}") logger.warning(f"Failed to probe corrupt segment {cache_path}")
logger.warning(f"Discarding a corrupt recording segment: {cache_path}") logger.warning(
if stream_type == STREAM_TYPE_MAIN: f"Discarding a corrupt recording segment: {cache_path} "
f"({format_segment_details(cache_path, segment_info)})"
)
self.recordings_publisher.publish( self.recordings_publisher.publish(
(camera, start_time.timestamp(), cache_path), (camera, stream_type, start_time.timestamp(), cache_path),
RecordingsDataTypeEnum.invalid.value, RecordingsDataTypeEnum.invalid.value,
) )
self.drop_segment(cache_path) self.drop_segment(cache_path)
return None return None
# this segment has a valid duration and has video data, so publish an update # this segment has a valid duration and has video data, so publish an update
if stream_type == STREAM_TYPE_MAIN:
self.recordings_publisher.publish( self.recordings_publisher.publish(
(camera, start_time.timestamp(), cache_path), (camera, stream_type, start_time.timestamp(), cache_path),
RecordingsDataTypeEnum.valid.value, RecordingsDataTypeEnum.valid.value,
) )
@@ -863,18 +917,20 @@ class RecordingMaintainer(threading.Thread):
video_codec: str | None = None, video_codec: str | None = None,
keyframes: list[int] | None = None, keyframes: list[int] | None = None,
) -> dict[str, Any] | None: ) -> dict[str, Any] | None:
# directory will be in utc due to start_time being in utc path_time = segment_path_time(cache_path) or start_time
# directory will be in utc due to path_time being in utc
# sub segments get a tagged directory to avoid filename collisions # sub segments get a tagged directory to avoid filename collisions
directory = os.path.join( directory = os.path.join(
RECORD_DIR, RECORD_DIR,
start_time.strftime("%Y-%m-%d/%H"), path_time.strftime("%Y-%m-%d/%H"),
camera if stream_type == STREAM_TYPE_MAIN else f"{camera}{SUB_CACHE_TAG}", camera if stream_type == STREAM_TYPE_MAIN else f"{camera}{SUB_CACHE_TAG}",
) )
os.makedirs(directory, exist_ok=True) os.makedirs(directory, exist_ok=True)
# file will be in utc due to start_time being in utc # file will be in utc due to path_time being in utc
file_name = f"{start_time.strftime('%M.%S.mp4')}" file_name = f"{path_time.strftime('%M.%S.mp4')}"
file_path = os.path.join(directory, file_name) file_path = os.path.join(directory, file_name)
try: try:
@@ -946,10 +1002,9 @@ class RecordingMaintainer(threading.Thread):
Recordings.video_codec.name: video_codec, Recordings.video_codec.name: video_codec,
Recordings.keyframes.name: keyframes, Recordings.keyframes.name: keyframes,
} }
except Exception as e: except Exception:
logger.error(f"Unable to store recording segment {cache_path}") logger.exception(f"Unable to store recording segment {cache_path}")
Path(cache_path).unlink(missing_ok=True) Path(cache_path).unlink(missing_ok=True)
logger.error(e)
# clear end_time cache # clear end_time cache
self.end_time_cache.pop(cache_path, None) self.end_time_cache.pop(cache_path, None)
+45
View File
@@ -0,0 +1,45 @@
"""Tests for authentication endpoints."""
import os
from unittest.mock import patch
from frigate.api.auth import hash_password
from frigate.const import JWT_SECRET_ENV_VAR
from frigate.models import User
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
@patch.dict(os.environ, {JWT_SECRET_ENV_VAR: "test-secret"})
class TestHttpAuth(BaseTestHttp):
def setUp(self):
super().setUp([User])
self.app = super().create_app()
def tearDown(self):
User.delete().execute()
super().tearDown()
def test_login_unknown_user_logs_warning(self):
with self.assertLogs("frigate.api.auth", level="WARNING") as logs:
with AuthTestClient(self.app) as client:
response = client.post(
"/login", json={"user": "ghost", "password": "irrelevant"}
)
assert response.status_code == 401
assert any("Login failed" in m and "ghost" in m for m in logs.output)
def test_login_bad_password_logs_warning(self):
password_hash = hash_password("correct-horse-battery", iterations=1000)
User.insert(
username="admin",
password_hash=password_hash,
role="admin",
notification_tokens=[],
).execute()
with self.assertLogs("frigate.api.auth", level="WARNING") as logs:
with AuthTestClient(self.app) as client:
response = client.post(
"/login", json={"user": "admin", "password": "wrong"}
)
assert response.status_code == 401
assert any("Login failed" in m and "admin" in m for m in logs.output)
@@ -390,7 +390,7 @@ class TestGo2rtcStreamAccess(BaseTestHttp):
intent and forward the request to go2rtc instead of short-circuiting with 400.""" intent and forward the request to go2rtc instead of short-circuiting with 400."""
app = self._make_app(_MULTI_CAMERA_CONFIG) app = self._make_app(_MULTI_CAMERA_CONFIG)
mock_response = type("R", (), {"ok": True, "status_code": 200, "text": "ok"})() mock_response = type("R", (), {"ok": True, "status_code": 200, "text": "ok"})()
with patch.dict(os.environ, {"GO2RTC_ALLOW_ARBITRARY_EXEC": "true"}): with patch("frigate.util.services._GO2RTC_ARBITRARY_EXEC_ENV", "true"):
with patch( with patch(
"frigate.api.camera.requests.put", return_value=mock_response "frigate.api.camera.requests.put", return_value=mock_response
) as mock_put: ) as mock_put:
@@ -403,6 +403,20 @@ class TestGo2rtcStreamAccess(BaseTestHttp):
forwarded_src = mock_put.call_args.kwargs["params"]["src"] forwarded_src = mock_put.call_args.kwargs["params"]["src"]
assert forwarded_src == "exec:/tmp/something" assert forwarded_src == "exec:/tmp/something"
def test_add_stream_ignores_override_written_after_import(self):
"""The override is read once at import. A value written into os.environ
later, which is what the config's environment_vars block does, must not
unlock restricted sources."""
app = self._make_app(_MULTI_CAMERA_CONFIG)
with patch.dict(os.environ, {"GO2RTC_ALLOW_ARBITRARY_EXEC": "true"}):
with patch("frigate.api.camera.requests.put") as mock_put:
with AuthTestClient(app) as client:
resp = client.put("/go2rtc/streams/legit?src=exec:/tmp/something")
# A live go2rtc would also answer 400, so assert on the forward.
mock_put.assert_not_called()
assert resp.status_code == 400
assert resp.json().get("success") is False
def test_stream_alias_blocked_when_owning_camera_disallowed(self): def test_stream_alias_blocked_when_owning_camera_disallowed(self):
"""limited_user cannot access a stream alias that belongs to a camera they """limited_user cannot access a stream alias that belongs to a camera they
are not allowed to see.""" are not allowed to see."""
@@ -0,0 +1,189 @@
"""Tests for GET /classification/attributes."""
import os
import shutil
import unittest
from frigate.api.auth import get_allowed_cameras_for_filter
from frigate.const import CLIPS_DIR
from frigate.models import Event, Recordings, ReviewSegment
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
# "limited_user" only reaches front_door, so it never sees the values that were
# recorded on back_door.
_CONFIG = {
"mqtt": {"host": "mqtt"},
"auth": {"roles": {"limited_user": ["front_door"]}},
"classification": {
"custom": {
"delivery_service": {
"enabled": True,
"object_config": {
"objects": ["car"],
"classification_type": "attribute",
},
}
}
},
"cameras": {
"front_door": {
"ffmpeg": {
"inputs": [{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}]
},
"detect": {"height": 1080, "width": 1920, "fps": 5},
},
"back_door": {
"ffmpeg": {
"inputs": [{"path": "rtsp://10.0.0.2:554/video", "roles": ["detect"]}]
},
"detect": {"height": 1080, "width": 1920, "fps": 5},
},
},
}
class TestClassificationAttributesAccess(BaseTestHttp):
"""The attribute list is read from the training dataset on disk, which holds
every label a model can emit regardless of which camera recorded it. Callers
without full camera access are cut back to the values on their own cameras,
so these tests pin that scoping.
"""
def setUp(self):
super().setUp([Event, ReviewSegment, Recordings])
self.minimal_config = _CONFIG
self.app = super().create_app()
self.model_dir = os.path.join(CLIPS_DIR, "delivery_service")
for category in ("DHL", "Amazon", "Hermes", "none"):
os.makedirs(
os.path.join(self.model_dir, "dataset", category), exist_ok=True
)
def tearDown(self):
shutil.rmtree(self.model_dir, ignore_errors=True)
self.app.dependency_overrides.clear()
super().tearDown()
def _insert_event(self, event_id: str, camera: str, attribute: str | None):
data = {"type": "object", "score": 0.9}
if attribute is not None:
data["delivery_service"] = attribute
Event.insert(
id=event_id,
label="car",
camera=camera,
start_time=100,
end_time=200,
top_score=0.9,
score=0.9,
false_positive=False,
zones=[],
thumbnail="",
has_clip=True,
has_snapshot=True,
region=[],
box=[],
area=0,
retain_indefinitely=False,
ratio=1.0,
plus_id=None,
model_hash="",
detector_type="cpu",
model_type="ssd",
data=data,
).execute()
def _get(self, role: str, **params):
# the base class resolves every camera by default, so drop the override
# to exercise the real role to allowed-cameras resolution
self.app.dependency_overrides.pop(get_allowed_cameras_for_filter, None)
with AuthTestClient(self.app) as client:
return client.get(
"/classification/attributes",
params=params,
headers={"remote-user": "test", "remote-role": role},
)
def _insert_split_events(self):
self._insert_event("front", "front_door", "DHL")
self._insert_event("back", "back_door", "Amazon")
def test_admin_gets_every_trained_label(self):
self._insert_split_events()
assert self._get("admin").json() == ["Amazon", "DHL", "Hermes"]
def test_viewer_gets_every_trained_label(self):
self._insert_split_events()
assert self._get("viewer").json() == ["Amazon", "DHL", "Hermes"]
def test_restricted_role_only_gets_its_own_cameras(self):
self._insert_split_events()
assert self._get("limited_user").json() == ["DHL"]
def test_restricted_role_grouped_by_model(self):
self._insert_split_events()
assert self._get("limited_user", group_by_model="true").json() == {
"delivery_service": ["DHL"]
}
def test_restricted_role_with_no_recorded_values(self):
self._insert_event("back", "back_door", "Amazon")
assert self._get("limited_user").json() == []
assert self._get("limited_user", group_by_model="true").json() == {}
def test_restricted_role_ignores_events_without_the_attribute(self):
self._insert_event("front", "front_door", None)
assert self._get("limited_user").json() == []
def test_restricted_role_with_a_dotted_model_name(self):
# model names are unrestricted config keys, and an unquoted "." in the
# json path would be read as a nested lookup and match nothing
self.app.frigate_config.classification.custom["delivery.service"] = (
self.app.frigate_config.classification.custom.pop("delivery_service")
)
self.app.frigate_config.classification.custom[
"delivery.service"
].name = "delivery.service"
os.rename(self.model_dir, os.path.join(CLIPS_DIR, "delivery.service"))
self.model_dir = os.path.join(CLIPS_DIR, "delivery.service")
data = {"type": "object", "score": 0.9, "delivery.service": "DHL"}
Event.insert(
id="front",
label="car",
camera="front_door",
start_time=100,
end_time=200,
top_score=0.9,
score=0.9,
false_positive=False,
zones=[],
thumbnail="",
has_clip=True,
has_snapshot=True,
region=[],
box=[],
area=0,
retain_indefinitely=False,
ratio=1.0,
plus_id=None,
model_hash="",
detector_type="cpu",
model_type="ssd",
data=data,
).execute()
assert self._get("limited_user").json() == ["DHL"]
def test_object_type_filters_out_unrelated_models(self):
self._insert_split_events()
assert self._get("limited_user", object_type="person").json() == []
assert self._get("limited_user", object_type="car").json() == ["DHL"]
if __name__ == "__main__":
unittest.main()
+220
View File
@@ -0,0 +1,220 @@
"""Tests for per stream recording health tracking in the camera watchdog."""
import unittest
from datetime import UTC, datetime, timedelta
from unittest.mock import MagicMock, patch
from frigate.config import FrigateConfig
from frigate.const import STREAM_TYPE_MAIN, STREAM_TYPE_SUB
from frigate.video.ffmpeg import CameraWatchdog
class TestCameraWatchdogStreamHealth(unittest.TestCase):
def _build_watchdog(
self, sub_enabled: bool = True, output_args: dict | None = None
) -> CameraWatchdog:
config = FrigateConfig(
**{
"mqtt": {"host": "mqtt"},
"cameras": {
"front_door": {
"ffmpeg": {
"output_args": output_args or {},
"inputs": [
{
"path": "rtsp://10.0.0.1:554/video",
"roles": ["record"],
},
{
"path": "rtsp://10.0.0.1:554/video2",
"roles": ["detect", "record_sub"],
},
],
},
"record": {
"enabled": True,
"sub": {"enabled": sub_enabled},
},
}
},
}
)
camera_config = config.cameras["front_door"]
with (
patch("frigate.video.ffmpeg.LogPipe"),
patch("frigate.video.ffmpeg.InterProcessRequestor"),
patch("frigate.video.ffmpeg.RecordingsDataSubscriber"),
patch("frigate.video.ffmpeg.CameraConfigUpdateSubscriber"),
):
watchdog = CameraWatchdog(
camera_config,
1,
MagicMock(),
MagicMock(),
MagicMock(),
MagicMock(),
MagicMock(),
MagicMock(),
MagicMock(),
MagicMock(),
)
watchdog.requestor = MagicMock()
return watchdog
def test_stale_sub_does_not_mark_main_stale(self):
watchdog = self._build_watchdog()
now = datetime.now().astimezone(UTC)
stale = (now - timedelta(hours=1)).timestamp()
watchdog.latest_cache_segment_time[STREAM_TYPE_MAIN] = now.timestamp()
watchdog.latest_valid_segment_time[STREAM_TYPE_MAIN] = now.timestamp()
watchdog.latest_cache_segment_time[STREAM_TYPE_SUB] = stale
watchdog.latest_valid_segment_time[STREAM_TYPE_SUB] = stale
assert watchdog._stream_staleness(STREAM_TYPE_MAIN, now) is None
assert watchdog._stream_staleness(STREAM_TYPE_SUB, now) is not None
def test_stale_main_does_not_mark_sub_stale(self):
watchdog = self._build_watchdog()
now = datetime.now().astimezone(UTC)
stale = (now - timedelta(hours=1)).timestamp()
watchdog.latest_cache_segment_time[STREAM_TYPE_MAIN] = stale
watchdog.latest_valid_segment_time[STREAM_TYPE_MAIN] = stale
watchdog.latest_cache_segment_time[STREAM_TYPE_SUB] = now.timestamp()
watchdog.latest_valid_segment_time[STREAM_TYPE_SUB] = now.timestamp()
assert watchdog._stream_staleness(STREAM_TYPE_MAIN, now) is not None
assert watchdog._stream_staleness(STREAM_TYPE_SUB, now) is None
def test_grace_period_suppresses_staleness(self):
watchdog = self._build_watchdog()
now = datetime.now().astimezone(UTC)
watchdog.record_enable_time = now - timedelta(seconds=10)
watchdog.latest_cache_segment_time[STREAM_TYPE_SUB] = (
now - timedelta(hours=1)
).timestamp()
assert watchdog._stream_staleness(STREAM_TYPE_SUB, now) is None
def test_status_goes_to_the_matching_role_topic(self):
watchdog = self._build_watchdog()
watchdog._send_record_status(STREAM_TYPE_MAIN, "online", 100.0)
watchdog._send_record_status(STREAM_TYPE_SUB, "offline", 100.0)
watchdog.requestor.send_data.assert_any_call(
"front_door/status/record", "online"
)
watchdog.requestor.send_data.assert_any_call(
"front_door/status/record_sub", "offline"
)
def test_status_is_cached_per_stream(self):
watchdog = self._build_watchdog()
watchdog._send_record_status(STREAM_TYPE_MAIN, "online", 100.0)
watchdog._send_record_status(STREAM_TYPE_SUB, "online", 100.0)
watchdog._send_record_status(STREAM_TYPE_MAIN, "online", 100.0)
assert watchdog.requestor.send_data.call_count == 2
def test_recorded_streams_follows_config(self):
watchdog = self._build_watchdog()
assert watchdog._recorded_streams(["record"]) == [STREAM_TYPE_MAIN]
assert watchdog._recorded_streams(["detect", "record_sub"]) == [STREAM_TYPE_SUB]
assert watchdog._recorded_streams(["detect"]) == []
disabled = self._build_watchdog(sub_enabled=False)
assert disabled._recorded_streams(["detect", "record_sub"]) == []
def test_restart_grace_suppresses_repeat_staleness(self):
watchdog = self._build_watchdog()
now = datetime.now().astimezone(UTC)
stale = (now - timedelta(hours=1)).timestamp()
watchdog.latest_cache_segment_time[STREAM_TYPE_MAIN] = stale
watchdog.latest_valid_segment_time[STREAM_TYPE_MAIN] = stale
assert watchdog._stream_staleness(STREAM_TYPE_MAIN, now) is not None
watchdog._grant_restart_grace([STREAM_TYPE_MAIN], now)
assert watchdog._stream_staleness(STREAM_TYPE_MAIN, now) is None
assert (
watchdog._stream_staleness(STREAM_TYPE_MAIN, now + timedelta(seconds=89))
is None
)
assert (
watchdog._stream_staleness(STREAM_TYPE_MAIN, now + timedelta(seconds=91))
is not None
)
def test_restart_grace_is_per_stream(self):
watchdog = self._build_watchdog()
now = datetime.now().astimezone(UTC)
stale = (now - timedelta(hours=1)).timestamp()
for stream_type in (STREAM_TYPE_MAIN, STREAM_TYPE_SUB):
watchdog.latest_cache_segment_time[stream_type] = stale
watchdog.latest_valid_segment_time[stream_type] = stale
watchdog._grant_restart_grace([STREAM_TYPE_MAIN], now)
assert watchdog._stream_staleness(STREAM_TYPE_MAIN, now) is None
assert watchdog._stream_staleness(STREAM_TYPE_SUB, now) is not None
def test_detect_reset_grants_the_shared_sub_stream_grace(self):
watchdog = self._build_watchdog()
watchdog.detect_process_records_sub = True
watchdog.ffmpeg_detect_process = MagicMock()
watchdog.capture_thread = MagicMock()
watchdog.capture_thread.is_alive.return_value = False
watchdog.start_ffmpeg_detect = MagicMock()
now = datetime.now().astimezone(UTC)
stale = (now - timedelta(hours=1)).timestamp()
watchdog.latest_cache_segment_time[STREAM_TYPE_SUB] = stale
watchdog.latest_valid_segment_time[STREAM_TYPE_SUB] = stale
assert watchdog._stream_staleness(STREAM_TYPE_SUB, now) is not None
watchdog.reset_capture_thread(terminate=False)
# the sub check runs later in the same tick against a stale can_restart,
# so without this grace it would kill the just-restarted process again
assert (
watchdog._stream_staleness(STREAM_TYPE_SUB, datetime.now().astimezone(UTC))
is None
)
def test_detect_reset_leaves_sub_alone_when_not_shared(self):
watchdog = self._build_watchdog()
watchdog.detect_process_records_sub = False
watchdog.ffmpeg_detect_process = MagicMock()
watchdog.capture_thread = MagicMock()
watchdog.capture_thread.is_alive.return_value = False
watchdog.start_ffmpeg_detect = MagicMock()
now = datetime.now().astimezone(UTC)
stale = (now - timedelta(hours=1)).timestamp()
watchdog.latest_cache_segment_time[STREAM_TYPE_SUB] = stale
watchdog.latest_valid_segment_time[STREAM_TYPE_SUB] = stale
watchdog.reset_capture_thread(terminate=False)
assert (
watchdog._stream_staleness(STREAM_TYPE_SUB, datetime.now().astimezone(UTC))
is not None
)
def test_stale_threshold_follows_each_stream_segment_time(self):
watchdog = self._build_watchdog(
output_args={
"record": "-f segment -segment_time 10 -c copy",
"record_sub": "-f segment -segment_time 60 -c copy",
}
)
assert watchdog.record_stale_threshold[STREAM_TYPE_MAIN] == 120
assert watchdog.record_stale_threshold[STREAM_TYPE_SUB] == 150
+30
View File
@@ -1229,6 +1229,36 @@ class TestConfig(unittest.TestCase):
lambda: FrigateConfig(**config).cameras, lambda: FrigateConfig(**config).cameras,
) )
def test_fails_on_record_and_record_sub_on_same_input(self):
config = self._sub_record_config()
config["cameras"]["back"]["ffmpeg"]["inputs"] = [
{
"path": "rtsp://10.0.0.1:554/video",
"roles": ["detect", "record", "record_sub"],
},
{"path": "rtsp://10.0.0.1:554/video2", "roles": ["audio"]},
]
self.assertRaisesRegex(
ValueError,
"record and record_sub assigned to the same input",
lambda: FrigateConfig(**config).cameras,
)
def test_fails_on_record_sub_with_a_single_input(self):
# the single input case has record forced onto it, so record_sub can
# only ever duplicate that same stream
config = self._sub_record_config()
config["cameras"]["back"]["ffmpeg"]["inputs"] = [
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect", "record_sub"]},
]
self.assertRaisesRegex(
ValueError,
"record and record_sub assigned to the same input",
lambda: FrigateConfig(**config).cameras,
)
def test_record_sub_segment_time_not_checked_when_disabled(self): def test_record_sub_segment_time_not_checked_when_disabled(self):
config = self._sub_record_config( config = self._sub_record_config(
{ {
@@ -0,0 +1,67 @@
"""Tests for the recordings batch insert handler."""
import unittest
from unittest.mock import MagicMock, patch
from playhouse.sqlite_ext import SqliteExtDatabase
from frigate.comms.dispatcher import Dispatcher
from frigate.const import INSERT_MANY_RECORDINGS
from frigate.models import Recordings
def _recording(id: str, path: str) -> dict:
return {
Recordings.id.name: id,
Recordings.camera.name: "front_door",
Recordings.stream_type.name: "main",
Recordings.path.name: path,
Recordings.start_time.name: 1000.0,
Recordings.end_time.name: 1010.0,
Recordings.duration.name: 10.0,
Recordings.motion.name: 0,
Recordings.objects.name: 0,
Recordings.dBFS.name: 0,
Recordings.segment_size.name: 1.0,
}
class TestInsertManyRecordings(unittest.TestCase):
"""A duplicate path must not cost the rest of the batch."""
def setUp(self):
self.db = SqliteExtDatabase(":memory:")
self.db.bind([Recordings])
self.db.create_tables([Recordings])
with (
patch("frigate.comms.dispatcher.CameraActivityManager"),
patch("frigate.comms.dispatcher.AudioActivityManager"),
):
self.dispatcher = Dispatcher(MagicMock(), MagicMock(), MagicMock(), {}, [])
def tearDown(self):
self.db.close()
def test_batch_with_duplicate_keeps_the_other_rows(self):
Recordings.insert(_recording("existing", "/rec/00.10.mp4")).execute()
self.dispatcher._receive(
INSERT_MANY_RECORDINGS,
[
_recording("a", "/rec/00.20.mp4"),
_recording("b", "/rec/00.10.mp4"),
_recording("c", "/rec/00.30.mp4"),
],
)
paths = {r.path for r in Recordings.select()}
self.assertEqual(paths, {"/rec/00.10.mp4", "/rec/00.20.mp4", "/rec/00.30.mp4"})
def test_clean_batch_inserts_every_row(self):
self.dispatcher._receive(
INSERT_MANY_RECORDINGS,
[_recording("a", "/rec/00.20.mp4"), _recording("b", "/rec/00.30.mp4")],
)
self.assertEqual(Recordings.select().count(), 2)
+311 -2
View File
@@ -1,9 +1,13 @@
"""Tests for environment variable handling.""" """Tests for environment variable handling."""
import os import os
import tempfile
import unittest import unittest
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from pydantic import ValidationError
from frigate.config import FrigateConfig, env
from frigate.config.env import ( from frigate.config.env import (
FRIGATE_ENV_VARS, FRIGATE_ENV_VARS,
validate_env_string, validate_env_string,
@@ -105,9 +109,10 @@ class TestEnvString(unittest.TestCase):
self.assertEqual(result, "192.168.1.1") self.assertEqual(result, "192.168.1.1")
def test_unknown_var_raises(self): def test_unknown_var_raises(self):
"""Referencing an unknown var raises KeyError.""" """Referencing an unknown var raises UnknownVariableError."""
with self.assertRaises(KeyError): with self.assertRaises(env.UnknownVariableError) as ctx:
validate_env_string("{FRIGATE_NONEXISTENT_VAR}") validate_env_string("{FRIGATE_NONEXISTENT_VAR}")
self.assertIn("FRIGATE_NONEXISTENT_VAR", str(ctx.exception))
def test_non_frigate_braces_passthrough(self): def test_non_frigate_braces_passthrough(self):
"""Braces that are not {FRIGATE_*} placeholders pass through untouched. """Braces that are not {FRIGATE_*} placeholders pass through untouched.
@@ -176,6 +181,18 @@ class TestEnvString(unittest.TestCase):
validate_env_string("{FRIGATE_FOO!r}") validate_env_string("{FRIGATE_FOO!r}")
class TestUnknownVariableSurfacing(unittest.TestCase):
"""An undefined variable must reach the user as a config validation error."""
def test_unknown_var_is_a_validation_error(self):
"""Pydantic reports the field path instead of raising KeyError."""
with self.assertRaises(ValidationError) as ctx:
FrigateConfig.parse_object(
{"mqtt": {"host": "{FRIGATE_NOT_SET_ANYWHERE}"}, "cameras": {}}
)
self.assertIn("FRIGATE_NOT_SET_ANYWHERE", str(ctx.exception))
class TestEnvVars(unittest.TestCase): class TestEnvVars(unittest.TestCase):
def setUp(self): def setUp(self):
self._original_env_vars = dict(FRIGATE_ENV_VARS) self._original_env_vars = dict(FRIGATE_ENV_VARS)
@@ -184,6 +201,7 @@ class TestEnvVars(unittest.TestCase):
def tearDown(self): def tearDown(self):
FRIGATE_ENV_VARS.clear() FRIGATE_ENV_VARS.clear()
FRIGATE_ENV_VARS.update(self._original_env_vars) FRIGATE_ENV_VARS.update(self._original_env_vars)
env._CONFIG_ENV_VARS.clear()
# Clean up any env vars we set # Clean up any env vars we set
for key in list(os.environ.keys()): for key in list(os.environ.keys()):
if key not in self._original_environ: if key not in self._original_environ:
@@ -233,5 +251,296 @@ class TestEnvVars(unittest.TestCase):
self.assertEqual(result, "mqtt.local") self.assertEqual(result, "mqtt.local")
class TestVariableSources(unittest.TestCase):
"""Precedence between the sources that feed FRIGATE_ENV_VARS."""
def setUp(self):
self._original_env_vars = dict(env.FRIGATE_ENV_VARS)
self._original_os_environ = dict(os.environ)
def tearDown(self):
env.FRIGATE_ENV_VARS.clear()
env.FRIGATE_ENV_VARS.update(self._original_env_vars)
env._CONFIG_ENV_VARS.clear()
env._WARNED_COLLISIONS.clear()
os.environ.clear()
os.environ.update(self._original_os_environ)
def test_container_env_beats_config_env_vars(self):
"""A container env var wins over the same key in environment_vars."""
with patch.dict(env._CONTAINER_ENV, {"FRIGATE_MQTT_HOST": "from_env"}):
env.apply_config_env_vars({"FRIGATE_MQTT_HOST": "from_config"})
self.assertEqual(env.FRIGATE_ENV_VARS["FRIGATE_MQTT_HOST"], "from_env")
def test_credentials_dir_beats_container_env(self):
"""A credentials directory file wins over a container env var."""
with (
patch.dict(env._CONTAINER_ENV, {"FRIGATE_MQTT_HOST": "from_env"}),
patch.dict(env._CREDENTIALS_DIR, {"FRIGATE_MQTT_HOST": "from_creds"}),
):
env._rebuild()
self.assertEqual(env.FRIGATE_ENV_VARS["FRIGATE_MQTT_HOST"], "from_creds")
def test_config_env_vars_used_when_no_other_source(self):
"""environment_vars still resolves when nothing else defines the key."""
env.apply_config_env_vars({"FRIGATE_CAM_PASS": "hunter2"})
self.assertEqual(env.FRIGATE_ENV_VARS["FRIGATE_CAM_PASS"], "hunter2")
def test_config_env_vars_do_not_become_container_env(self):
"""environment_vars writes os.environ but must not gain env precedence."""
env.apply_config_env_vars({"FRIGATE_CAM_PASS": "from_config"})
self.assertEqual(os.environ["FRIGATE_CAM_PASS"], "from_config")
self.assertNotIn("FRIGATE_CAM_PASS", env._CONTAINER_ENV)
def test_non_frigate_config_env_vars_only_set_os_environ(self):
"""Unprefixed environment_vars keys reach os.environ but not substitution."""
env.apply_config_env_vars({"LIBVA_DRIVER_NAME": "i965"})
self.assertEqual(os.environ["LIBVA_DRIVER_NAME"], "i965")
self.assertNotIn("LIBVA_DRIVER_NAME", env.FRIGATE_ENV_VARS)
def test_collision_warns_once_naming_the_winner(self):
"""A key from two sources logs one warning naming the source that won."""
with patch.dict(env._CONTAINER_ENV, {"FRIGATE_MQTT_HOST": "from_env"}):
with self.assertLogs("frigate.config.env", level="WARNING") as logs:
env.apply_config_env_vars({"FRIGATE_MQTT_HOST": "from_config"})
self.assertEqual(len(logs.output), 1)
self.assertIn("FRIGATE_MQTT_HOST", logs.output[0])
self.assertIn("using the value from container environment", logs.output[0])
self.assertNotIn("environment_vars", logs.output[0])
with self.assertNoLogs("frigate.config.env", level="WARNING"):
env._rebuild()
class TestSecretsFile(unittest.TestCase):
"""Reading <config dir>/secrets.yaml."""
def setUp(self):
self._original_env_vars = dict(env.FRIGATE_ENV_VARS)
self._original_os_environ = dict(os.environ)
self._dir = tempfile.TemporaryDirectory()
self.addCleanup(self._dir.cleanup)
os.environ["CONFIG_FILE"] = os.path.join(self._dir.name, "config.yml")
def tearDown(self):
env.FRIGATE_ENV_VARS.clear()
env.FRIGATE_ENV_VARS.update(self._original_env_vars)
env._SECRETS_FILE.clear()
env._CONFIG_ENV_VARS.clear()
env._WARNED_COLLISIONS.clear()
os.environ.clear()
os.environ.update(self._original_os_environ)
def _write(self, contents: str, name: str = "secrets.yaml") -> None:
with open(os.path.join(self._dir.name, name), "w") as f:
f.write(contents)
def test_missing_file_is_not_an_error(self):
"""No secrets.yaml means no values and no exception."""
self.assertEqual(env._load_secrets_file(), {})
def test_flat_map_is_read(self):
"""A flat FRIGATE_* map loads."""
self._write("FRIGATE_CAM_USER: viewer\nFRIGATE_CAM_PASS: 'p@ss w0rd'\n")
self.assertEqual(
env._load_secrets_file(),
{"FRIGATE_CAM_USER": "viewer", "FRIGATE_CAM_PASS": "p@ss w0rd"},
)
def test_yml_extension_is_read(self):
"""secrets.yml works the same as secrets.yaml."""
self._write("FRIGATE_CAM_USER: viewer\n", name="secrets.yml")
self.assertEqual(env._load_secrets_file(), {"FRIGATE_CAM_USER": "viewer"})
def test_numeric_value_is_coerced_to_string(self):
"""Unquoted numbers become strings so they can be substituted."""
self._write("FRIGATE_MQTT_PORT: 1883\n")
self.assertEqual(env._load_secrets_file(), {"FRIGATE_MQTT_PORT": "1883"})
def test_unprefixed_key_is_ignored_with_a_warning(self):
"""Names must start with FRIGATE_, matching the credentials directory."""
self._write("cam_pass: hunter2\nFRIGATE_CAM_PASS: hunter2\n")
with self.assertLogs("frigate.config.env", level="WARNING") as logs:
values = env._load_secrets_file()
self.assertEqual(values, {"FRIGATE_CAM_PASS": "hunter2"})
self.assertIn("cam_pass", logs.output[0])
def test_non_mapping_document_raises(self):
"""A list or scalar document is a config error."""
self._write("- FRIGATE_CAM_PASS\n")
with self.assertRaises(ValueError):
env._load_secrets_file()
def test_nested_value_raises_naming_the_key(self):
"""Nesting is not supported and the error names the key."""
self._write("FRIGATE_CAMS:\n alley: hunter2\n")
with self.assertRaises(ValueError) as ctx:
env._load_secrets_file()
self.assertIn("FRIGATE_CAMS", str(ctx.exception))
def test_secrets_file_beats_config_env_vars(self):
"""secrets.yaml outranks the environment_vars block."""
self._write("FRIGATE_CAM_PASS: from_secrets\n")
env.apply_config_env_vars({"FRIGATE_CAM_PASS": "from_config"})
env._SECRETS_FILE.update(env._load_secrets_file())
env._rebuild()
self.assertEqual(env.FRIGATE_ENV_VARS["FRIGATE_CAM_PASS"], "from_secrets")
def test_container_env_beats_secrets_file(self):
"""The container environment outranks secrets.yaml."""
self._write("FRIGATE_CAM_PASS: from_secrets\n")
env._SECRETS_FILE.update(env._load_secrets_file())
with patch.dict(env._CONTAINER_ENV, {"FRIGATE_CAM_PASS": "from_env"}):
env._rebuild()
self.assertEqual(env.FRIGATE_ENV_VARS["FRIGATE_CAM_PASS"], "from_env")
class TestSecretsReload(unittest.TestCase):
"""secrets.yaml is re-read when a config is parsed."""
def setUp(self):
self._original_env_vars = dict(env.FRIGATE_ENV_VARS)
self._original_os_environ = dict(os.environ)
self._dir = tempfile.TemporaryDirectory()
self.addCleanup(self._dir.cleanup)
os.environ["CONFIG_FILE"] = os.path.join(self._dir.name, "config.yml")
def tearDown(self):
env.FRIGATE_ENV_VARS.clear()
env.FRIGATE_ENV_VARS.update(self._original_env_vars)
env._SECRETS_FILE.clear()
env._CONFIG_ENV_VARS.clear()
env._WARNED_COLLISIONS.clear()
os.environ.clear()
os.environ.update(self._original_os_environ)
env.reload_sources()
def test_new_secret_resolves_without_restart(self):
"""A key written after import is picked up by the next parse."""
with open(os.path.join(self._dir.name, "secrets.yaml"), "w") as f:
f.write("FRIGATE_MQTT_HOST: mqtt.internal\n")
config = FrigateConfig.parse_yaml(
'mqtt:\n host: "{FRIGATE_MQTT_HOST}"\ncameras: {}\n'
)
self.assertEqual(config.mqtt.host, "mqtt.internal")
def test_config_env_vars_survive_the_reload(self):
"""environment_vars is only installed when install=True, so it has to
outlive the reload that a later non-install parse triggers. This is
the /config/save path: a config that starts fine must still validate.
"""
env.apply_config_env_vars({"FRIGATE_MQTT_HOST": "from_config"})
config = FrigateConfig.parse_yaml(
'mqtt:\n host: "{FRIGATE_MQTT_HOST}"\ncameras: {}\n'
)
self.assertEqual(config.mqtt.host, "from_config")
self.assertEqual(env._CONFIG_ENV_VARS["FRIGATE_MQTT_HOST"], "from_config")
class TestSourceRobustness(unittest.TestCase):
"""Reload behavior, bad input, and the os.environ export."""
def setUp(self):
self._original_env_vars = dict(env.FRIGATE_ENV_VARS)
self._original_os_environ = dict(os.environ)
self._dir = tempfile.TemporaryDirectory()
self._creds = tempfile.TemporaryDirectory()
self.addCleanup(self._dir.cleanup)
self.addCleanup(self._creds.cleanup)
os.environ["CONFIG_FILE"] = os.path.join(self._dir.name, "config.yml")
os.environ["CREDENTIALS_DIRECTORY"] = self._creds.name
def tearDown(self):
env.FRIGATE_ENV_VARS.clear()
env.FRIGATE_ENV_VARS.update(self._original_env_vars)
env._SECRETS_FILE.clear()
env._CONFIG_ENV_VARS.clear()
env._CREDENTIALS_DIR.clear()
env._WARNED_COLLISIONS.clear()
os.environ.clear()
os.environ.update(self._original_os_environ)
env.reload_sources()
def _write_secrets(self, contents: str) -> None:
with open(os.path.join(self._dir.name, "secrets.yaml"), "w") as f:
f.write(contents)
def test_os_environ_gets_the_winning_value(self):
"""FRIGATE_JWT_SECRET and friends are read straight from os.environ."""
self._write_secrets("FRIGATE_JWT_SECRET: from_secrets\n")
env.reload_sources()
env.apply_config_env_vars({"FRIGATE_JWT_SECRET": "from_config"})
self.assertEqual(os.environ["FRIGATE_JWT_SECRET"], "from_secrets")
self.assertEqual(
os.environ["FRIGATE_JWT_SECRET"],
env.FRIGATE_ENV_VARS["FRIGATE_JWT_SECRET"],
)
def test_rebuild_without_warn_stays_quiet_then_warns_later(self):
"""The import-time rebuild must not consume the one-shot warning."""
self._write_secrets("FRIGATE_DUPE: from_secrets\n")
env.reload_sources()
env._CONFIG_ENV_VARS["FRIGATE_DUPE"] = "from_config"
with self.assertNoLogs("frigate.config.env", level="WARNING"):
env._rebuild(warn=False)
with self.assertLogs("frigate.config.env", level="WARNING") as logs:
env._rebuild()
self.assertIn("FRIGATE_DUPE", logs.output[0])
def test_malformed_secrets_file_keeps_last_good_values(self):
"""A typo must not raise, since this runs at import and on every parse."""
self._write_secrets("FRIGATE_CAM_PASS: hunter2\n")
env.reload_sources()
self._write_secrets("FRIGATE_CAMS:\n alley: hunter2\n")
with self.assertLogs("frigate.config.env", level="ERROR") as logs:
env.reload_sources()
self.assertIn("FRIGATE_CAMS", logs.output[0])
self.assertEqual(env.FRIGATE_ENV_VARS["FRIGATE_CAM_PASS"], "hunter2")
def test_duplicate_key_error_does_not_log_the_values(self):
"""ruamel's duplicate key message quotes both values; the log must not."""
self._write_secrets("FRIGATE_CAM_PASS: hunter2\nFRIGATE_CAM_PASS: hunter3\n")
with self.assertLogs("frigate.config.env", level="ERROR") as logs:
env.reload_sources()
self.assertNotIn("hunter2", logs.output[0])
self.assertNotIn("hunter3", logs.output[0])
self.assertIn("secrets.yaml", logs.output[0])
def test_deleted_secret_stops_resolving(self):
"""Removing a name takes effect on the next parse, not on restart."""
self._write_secrets("FRIGATE_GONE: hunter2\n")
env.reload_sources()
self.assertEqual(env.FRIGATE_ENV_VARS["FRIGATE_GONE"], "hunter2")
os.remove(os.path.join(self._dir.name, "secrets.yaml"))
env.reload_sources()
self.assertNotIn("FRIGATE_GONE", env.FRIGATE_ENV_VARS)
def test_reload_rereads_the_credentials_directory(self):
"""The credentials directory is refreshed, not just secrets.yaml."""
with open(os.path.join(self._creds.name, "FRIGATE_CRED"), "w") as f:
f.write("from_creds\n")
env.reload_sources()
self.assertEqual(env.FRIGATE_ENV_VARS["FRIGATE_CRED"], "from_creds")
def test_unreadable_credentials_entry_is_skipped(self):
"""A subdirectory must not take down validation on every parse."""
os.mkdir(os.path.join(self._creds.name, "FRIGATE_NOT_A_FILE"))
with open(os.path.join(self._creds.name, "FRIGATE_CRED"), "w") as f:
f.write("from_creds\n")
with self.assertLogs("frigate.config.env", level="WARNING") as logs:
env.reload_sources()
self.assertIn("FRIGATE_NOT_A_FILE", logs.output[0])
self.assertEqual(env.FRIGATE_ENV_VARS["FRIGATE_CRED"], "from_creds")
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+21
View File
@@ -26,6 +26,26 @@ class TestClassifyKeyframeGaps(unittest.TestCase):
self.assertEqual(result["severity"], "warning") self.assertEqual(result["severity"], "warning")
self.assertEqual(result["max_gap"], 5.5) self.assertEqual(result["max_gap"], 5.5)
def test_fixed_pattern_for_regular_gop(self):
# a 5s GOP with normal encoder jitter is sparse but not variable
pts = [0.0, 4.98, 10.01, 15.0]
result = classify_keyframe_gaps(pts, segment_time=10)
self.assertEqual(result["severity"], "warning")
self.assertEqual(result["pattern"], "fixed")
def test_variable_pattern_for_smart_codec(self):
# keyframes bunched up then a long stretch without one
pts = [0.0, 1.0, 2.0, 8.0]
result = classify_keyframe_gaps(pts, segment_time=10)
self.assertEqual(result["severity"], "warning")
self.assertEqual(result["pattern"], "variable")
def test_fixed_pattern_for_short_regular_gop(self):
pts = [0.0, 1.0, 2.0, 3.0]
result = classify_keyframe_gaps(pts, segment_time=10)
self.assertEqual(result["severity"], "ok")
self.assertEqual(result["pattern"], "fixed")
def test_error_when_gap_exceeds_segment_time(self): def test_error_when_gap_exceeds_segment_time(self):
pts = [0.0, 12.0] # 12s gap > 10s segment pts = [0.0, 12.0] # 12s gap > 10s segment
result = classify_keyframe_gaps(pts, segment_time=10) result = classify_keyframe_gaps(pts, segment_time=10)
@@ -40,6 +60,7 @@ class TestClassifyKeyframeGaps(unittest.TestCase):
result = classify_keyframe_gaps([1.0], segment_time=10) result = classify_keyframe_gaps([1.0], segment_time=10)
self.assertEqual(result["severity"], "unknown") self.assertEqual(result["severity"], "unknown")
self.assertIsNone(result["max_gap"]) self.assertIsNone(result["max_gap"])
self.assertIsNone(result["pattern"])
self.assertEqual(result["keyframe_count"], 1) self.assertEqual(result["keyframe_count"], 1)
def test_unknown_with_no_keyframes(self): def test_unknown_with_no_keyframes(self):
+75 -8
View File
@@ -1,7 +1,8 @@
import unittest import unittest
from unittest.mock import Mock, patch from unittest.mock import MagicMock, Mock, patch
import numpy as np import numpy as np
import zmq
from pydantic import parse_obj_as from pydantic import parse_obj_as
import frigate.detectors as detectors import frigate.detectors as detectors
@@ -108,13 +109,13 @@ class TestLocalObjectDetector(unittest.TestCase):
("label-2", 0.5, (8, 7, 6, 5)), ("label-2", 0.5, (8, 7, 6, 5)),
] ]
TEST_LABEL_FILE = "/test_labels.txt" TEST_LABEL_FILE = "/test_labels.txt"
mock_load_labels.return_value = [ mock_load_labels.return_value = {
"label-1", 0: "label-1",
"label-2", 1: "label-2",
"label-3", 2: "label-3",
"label-4", 3: "label-4",
"label-5", 4: "label-5",
] }
test_cfg = parse_obj_as(DetectorConfig, {"type": "cpu", "model": {}}) test_cfg = parse_obj_as(DetectorConfig, {"type": "cpu", "model": {}})
test_cfg.model = ModelConfig() test_cfg.model = ModelConfig()
@@ -136,3 +137,69 @@ class TestLocalObjectDetector(unittest.TestCase):
== np.zeros((1, 32, 32, 3)).shape == np.zeros((1, 32, 32, 3)).shape
) )
assert test_result == TEST_DETECT_RESULT assert test_result == TEST_DETECT_RESULT
class TestRemoteObjectDetector(unittest.TestCase):
"""Cover the label lookup that turns raw class ids into detections."""
def _build_detector(self, labels, rows):
detector = frigate.object_detection.base.RemoteObjectDetector.__new__(
frigate.object_detection.base.RemoteObjectDetector
)
detector.labels = labels
detector.name = "front_door"
detector.fps = MagicMock()
detector.stop_event = MagicMock()
detector.stop_event.is_set.return_value = False
detector.unnamed_class_ids = set()
detector.np_shm = np.zeros((1, 320, 320, 3), np.uint8)
detector.out_np_shm = np.array(rows, np.float32)
detector.detection_queue = MagicMock()
detector.detector_subscriber = MagicMock()
detector.detector_subscriber.socket.recv_string.side_effect = zmq.Again()
detector.detector_subscriber.check_for_update.return_value = "front_door"
return detector
def test_maps_class_ids_to_labels(self):
rows = [[2, 0.9, 0.1, 0.2, 0.3, 0.4], [0, 0.8, 0.5, 0.6, 0.7, 0.8]] + [
[0, 0, 0, 0, 0, 0]
] * 18
detector = self._build_detector({0: "person", 2: "car"}, rows)
results = detector.detect(np.zeros((1, 320, 320, 3), np.uint8))
self.assertEqual([r[0] for r in results], ["car", "person"])
def test_skips_class_ids_the_labelmap_does_not_name(self):
# a labelmap that names fewer classes than the model emits
rows = [[7, 0.9, 0.1, 0.2, 0.3, 0.4], [0, 0.8, 0.5, 0.6, 0.7, 0.8]] + [
[0, 0, 0, 0, 0, 0]
] * 18
detector = self._build_detector({0: "person"}, rows)
results = detector.detect(np.zeros((1, 320, 320, 3), np.uint8))
self.assertEqual([r[0] for r in results], ["person"])
self.assertEqual(detector.unnamed_class_ids, {7})
def test_warns_once_per_unnamed_class_id(self):
rows = [
[7, 0.9, 0.1, 0.2, 0.3, 0.4],
[7, 0.8, 0.1, 0.2, 0.3, 0.4],
[9, 0.7, 0.1, 0.2, 0.3, 0.4],
] + [[0, 0, 0, 0, 0, 0]] * 17
detector = self._build_detector({0: "person"}, rows)
with self.assertLogs("frigate.object_detection.base", level="WARNING") as logs:
detector.detect(np.zeros((1, 320, 320, 3), np.uint8))
self.assertEqual(len(logs.output), 2)
self.assertEqual(detector.unnamed_class_ids, {7, 9})
def test_empty_labelmap_drops_detections_instead_of_raising(self):
rows = [[0, 0.9, 0.1, 0.2, 0.3, 0.4]] + [[0, 0, 0, 0, 0, 0]] * 19
detector = self._build_detector({}, rows)
results = detector.detect(np.zeros((1, 320, 320, 3), np.uint8))
self.assertEqual(results, [])
+59
View File
@@ -0,0 +1,59 @@
"""Tests for runtime ownership helpers."""
import unittest
from unittest.mock import patch
from frigate.util import ownership
class FakePwEntry:
pw_uid = 1500
pw_gid = 1500
# The devcontainer image exports FRIGATE_RUN_AS_ROOT, so any test that has to
# reach past the escape-hatch check pins the variable instead of inheriting it.
class TestGetRuntimeIds(unittest.TestCase):
@patch("frigate.util.ownership.os.geteuid", return_value=1000)
def test_returns_none_when_not_root(self, _):
assert ownership.get_runtime_ids() is None
@patch.dict("os.environ", {"FRIGATE_RUN_AS_ROOT": "true"})
@patch("frigate.util.ownership.os.geteuid", return_value=0)
def test_returns_none_with_escape_hatch(self, _):
assert ownership.get_runtime_ids() is None
@patch.dict("os.environ", {"FRIGATE_RUN_AS_ROOT": "false"})
@patch("frigate.util.ownership.pwd.getpwnam", side_effect=KeyError)
@patch("frigate.util.ownership.os.geteuid", return_value=0)
def test_returns_none_outside_frigate_image(self, *_):
assert ownership.get_runtime_ids() is None
@patch.dict("os.environ", {"FRIGATE_RUN_AS_ROOT": "false"})
@patch("frigate.util.ownership.pwd.getpwnam", return_value=FakePwEntry())
@patch("frigate.util.ownership.os.geteuid", return_value=0)
def test_returns_frigate_ids_as_root(self, *_):
assert ownership.get_runtime_ids() == (1500, 1500)
class TestChownToRuntime(unittest.TestCase):
@patch("frigate.util.ownership.os.chown")
@patch("frigate.util.ownership.get_runtime_ids", return_value=None)
def test_noop_when_no_runtime_ids(self, _, chown):
ownership.chown_to_runtime("/config/test")
chown.assert_not_called()
@patch("frigate.util.ownership.os.chown")
@patch("frigate.util.ownership.get_runtime_ids", return_value=(1500, 1500))
def test_chowns_to_runtime_ids(self, _, chown):
ownership.chown_to_runtime("/config/test")
chown.assert_called_once_with("/config/test", 1500, 1500)
@patch("frigate.util.ownership.os.chown", side_effect=OSError("ro fs"))
@patch("frigate.util.ownership.get_runtime_ids", return_value=(1500, 1500))
def test_swallows_oserror(self, *_):
ownership.chown_to_runtime("/config/test") # must not raise
if __name__ == "__main__":
unittest.main()
+69
View File
@@ -73,6 +73,21 @@ class TestRecordingCleanupSubRetention(unittest.TestCase):
stream_type=stream_type, stream_type=stream_type,
) )
def _insert_preview(
self, id: str, age_days: float, camera: str = "front_door"
) -> None:
end_time = (
datetime.datetime.now() - datetime.timedelta(days=age_days)
).timestamp()
Previews.create(
id=id,
camera=camera,
path=f"/media/frigate/previews/{id}.mp4",
start_time=end_time - 10,
end_time=end_time,
duration=10,
)
def test_sub_recordings_expire_independently(self): def test_sub_recordings_expire_independently(self):
# main retention 7 days, sub retention 30 days; rows 10 days old # main retention 7 days, sub retention 30 days; rows 10 days old
# -> main row deleted, sub row kept # -> main row deleted, sub row kept
@@ -91,6 +106,60 @@ class TestRecordingCleanupSubRetention(unittest.TestCase):
assert Recordings.get_or_none(Recordings.id == "m1") is None assert Recordings.get_or_none(Recordings.id == "m1") is None
assert Recordings.get_or_none(Recordings.id == "s1") is not None assert Recordings.get_or_none(Recordings.id == "s1") is not None
def test_previews_survive_while_sub_recordings_remain(self):
# main retention 7 days, sub retention 30 days; only the sub row
# survives at 10 days, and the preview covering it must survive too
cleanup = self._build_cleanup(
{
"enabled": True,
"continuous": {"days": 7},
"sub": {"enabled": True, "continuous": {"days": 30}},
}
)
self._insert_recording("m1", "main", 10)
self._insert_recording("s1", "sub", 10)
self._insert_preview("p1", 10)
cleanup.expire_recordings()
assert Recordings.get_or_none(Recordings.id == "m1") is None
assert Previews.get_or_none(Previews.id == "p1") is not None
def test_previews_expire_once_every_stream_has(self):
# both streams expired at 40 days -> the preview goes with them
cleanup = self._build_cleanup(
{
"enabled": True,
"continuous": {"days": 7},
"sub": {"enabled": True, "continuous": {"days": 30}},
}
)
self._insert_recording("m1", "main", 40)
self._insert_recording("s1", "sub", 40)
self._insert_preview("p1", 40)
cleanup.expire_recordings()
assert Recordings.get_or_none(Recordings.id == "s1") is None
assert Previews.get_or_none(Previews.id == "p1") is None
def test_preview_retention_unchanged_when_sub_disabled(self):
cleanup = self._build_cleanup(
{
"enabled": True,
"continuous": {"days": 7},
"sub": {"enabled": False},
}
)
self._insert_recording("m1", "main", 10)
self._insert_preview("p_old", 10)
self._insert_preview("p_new", 1)
cleanup.expire_recordings()
assert Previews.get_or_none(Previews.id == "p_old") is None
assert Previews.get_or_none(Previews.id == "p_new") is not None
def test_sub_recordings_expire_after_sub_retention(self): def test_sub_recordings_expire_after_sub_retention(self):
# sub retention 30 days; sub row 40 days old -> deleted # sub retention 30 days; sub row 40 days old -> deleted
cleanup = self._build_cleanup( cleanup = self._build_cleanup(
@@ -15,6 +15,7 @@ from frigate.record.maintainer import (
RecordingMaintainer, RecordingMaintainer,
SegmentInfo, SegmentInfo,
parse_cache_segment_name, parse_cache_segment_name,
segment_path_time,
) )
@@ -349,6 +350,98 @@ class TestSegmentAudioPresence(unittest.IsolatedAsyncioTestCase):
self.assertEqual(result[Recordings.video_codec.name], video_codec) self.assertEqual(result[Recordings.video_codec.name], video_codec)
class TestSegmentPathTime(unittest.IsolatedAsyncioTestCase):
"""The recording path must stay unique when segments are shorter than a second."""
def _build_maintainer(self) -> RecordingMaintainer:
camera_config = MagicMock()
camera_config.record.enabled = True
camera_config.record.continuous.days = 1
camera_config.record.motion.days = 0
config = MagicMock()
config.cameras = {"test_cam": camera_config}
maintainer = RecordingMaintainer.__new__(RecordingMaintainer)
maintainer.config = config
maintainer.end_time_cache = {}
maintainer.object_recordings_info = defaultdict(list)
maintainer.audio_recordings_info = defaultdict(list)
maintainer.recordings_publisher = MagicMock()
maintainer.last_segment_end = {("test_cam", "main"): 0.0}
return maintainer
def test_parses_main_and_sub_names(self):
expected = datetime.datetime(2026, 6, 10, 14, 30, 22, tzinfo=datetime.UTC)
self.assertEqual(
segment_path_time("/tmp/cache/test_cam@20260610143022+0000.mp4"), expected
)
self.assertEqual(
segment_path_time("/tmp/cache/test_cam@sub@20260610143022+0000.mp4"),
expected,
)
def test_returns_none_for_unparsable_names(self):
self.assertIsNone(segment_path_time("/tmp/cache/garbage.mp4"))
self.assertIsNone(segment_path_time("/tmp/cache/test_cam@notadate.mp4"))
async def test_sub_second_segments_get_distinct_paths(self):
# two cache files a second apart whose resolved starts both land in
# second 22; deriving the path from the resolved start collides
segments = [
("test_cam@20260610143022+0000.mp4", 100_000),
("test_cam@20260610143023+0000.mp4", 980_000),
]
paths = []
with tempfile.TemporaryDirectory() as tmpdir:
for name, microsecond in segments:
maintainer = self._build_maintainer()
maintainer.config.ffmpeg.ffmpeg_path = "ffmpeg"
start_time = datetime.datetime(
2026, 6, 10, 14, 30, 22, microsecond, tzinfo=datetime.UTC
)
cache_path = os.path.join(tmpdir, name)
with open(cache_path, "wb") as f:
f.write(b"\x00" * 16)
proc = MagicMock()
proc.returncode = 0
proc.wait = AsyncMock(return_value=0)
with (
patch(
"frigate.record.maintainer.RECORD_DIR",
os.path.join(tmpdir, "recordings"),
),
patch(
"frigate.record.maintainer.asyncio.create_subprocess_exec",
AsyncMock(return_value=proc),
),
):
result = await maintainer.move_segment(
"test_cam",
"main",
start_time,
start_time + datetime.timedelta(seconds=0.96),
0.96,
cache_path,
SegmentInfo(0, 0, 0, 0),
)
self.assertIsNotNone(result)
paths.append(result[Recordings.path.name])
# the row keeps the resolved start even though the path doesn't
self.assertEqual(
result[Recordings.start_time.name], start_time.timestamp()
)
self.assertEqual(len(set(paths)), 2, paths)
self.assertTrue(paths[0].endswith("30.22.mp4"), paths[0])
self.assertTrue(paths[1].endswith("30.23.mp4"), paths[1])
class TestSegmentStartChaining(unittest.IsolatedAsyncioTestCase): class TestSegmentStartChaining(unittest.IsolatedAsyncioTestCase):
"""Contiguous segments must chain start times across filename truncation. """Contiguous segments must chain start times across filename truncation.
+24 -5
View File
@@ -20,7 +20,12 @@ from typing import TYPE_CHECKING, Any
import numpy as np import numpy as np
from ruamel.yaml import YAML from ruamel.yaml import YAML
from frigate.const import REGEX_HTTP_CAMERA_USER_PASS, REGEX_RTSP_CAMERA_USER_PASS from frigate.const import (
REGEX_HTTP_CAMERA_USER_PASS,
REGEX_RTSP_CAMERA_USER_PASS,
STREAM_TYPE_MAIN,
STREAM_TYPE_SUB,
)
if TYPE_CHECKING: if TYPE_CHECKING:
from frigate.config import CameraConfig from frigate.config import CameraConfig
@@ -137,9 +142,16 @@ def get_ffmpeg_arg_list(arg: Any) -> list:
DEFAULT_RECORD_SEGMENT_TIME = 10 DEFAULT_RECORD_SEGMENT_TIME = 10
def get_record_segment_time(config: "CameraConfig") -> int: def get_record_segment_time(
"""Extract -segment_time from the camera's record output args.""" config: "CameraConfig", stream_type: str = STREAM_TYPE_MAIN
record_args = get_ffmpeg_arg_list(config.ffmpeg.output_args.record) ) -> int:
"""Extract -segment_time from the camera's record output args for a stream."""
output_args = (
config.ffmpeg.output_args.effective_record_sub
if stream_type == STREAM_TYPE_SUB
else config.ffmpeg.output_args.record
)
record_args = get_ffmpeg_arg_list(output_args)
if record_args and record_args[0].startswith("preset"): if record_args and record_args[0].startswith("preset"):
return DEFAULT_RECORD_SEGMENT_TIME return DEFAULT_RECORD_SEGMENT_TIME
@@ -152,12 +164,19 @@ def get_record_segment_time(config: "CameraConfig") -> int:
def load_labels( def load_labels(
path: str | None, encoding="utf-8", prefill=91, indexed: bool | None = None path: str | None, encoding="utf-8", prefill=0, indexed: bool | None = None
): ):
"""Loads labels from file (with or without index numbers). """Loads labels from file (with or without index numbers).
Only the indices the file defines are returned, so the result describes
exactly the classes a model can name. Callers must treat a missing index
as an unnamed class rather than assuming a contiguous range.
Args: Args:
path: path to label file. path: path to label file.
encoding: label file encoding. encoding: label file encoding.
prefill: pad indices below this with "unknown" before reading the file.
indexed: whether lines start with an index; auto-detected when None.
Returns: Returns:
Dictionary mapping indices to labels. Dictionary mapping indices to labels.
""" """
+43
View File
@@ -0,0 +1,43 @@
"""Helpers for aligning created files with the non-root runtime user."""
import logging
import os
import pwd
logger = logging.getLogger(__name__)
RUNTIME_USER = "frigate"
def get_runtime_ids() -> tuple[int, int] | None:
"""Return (uid, gid) that services run as, or None when chown is not applicable.
None when: not root (docker --user, so the host already mapped us),
FRIGATE_RUN_AS_ROOT=true (escape hatch must not mutate ownership),
or outside the Frigate container image (no frigate user).
"""
if os.geteuid() != 0:
return None
if os.environ.get("FRIGATE_RUN_AS_ROOT", "false") == "true":
return None
try:
user = pwd.getpwnam(RUNTIME_USER)
except KeyError:
return None
return (user.pw_uid, user.pw_gid)
def chown_to_runtime(path: str) -> None:
"""Best-effort chown of path to the runtime user."""
ids = get_runtime_ids()
if ids is None:
return
try:
os.chown(path, *ids)
except OSError as err:
logger.warning(f"Unable to set ownership of {path}: {err}")
+20 -3
View File
@@ -965,12 +965,17 @@ def get_hailo_temps() -> dict[str, float]:
return temps return temps
# Snapshot: environment_vars lands in os.environ after import and must not
# be able to enable this.
_GO2RTC_ARBITRARY_EXEC_ENV = os.environ.get("GO2RTC_ALLOW_ARBITRARY_EXEC")
def is_go2rtc_arbitrary_exec_allowed() -> bool: def is_go2rtc_arbitrary_exec_allowed() -> bool:
"""Read the GO2RTC_ALLOW_ARBITRARY_EXEC override from env, docker """Read the GO2RTC_ALLOW_ARBITRARY_EXEC override from env, docker
secrets, or the Home Assistant add-on options file.""" secrets, or the Home Assistant add-on options file."""
raw: str | None = None raw: str | None = None
if "GO2RTC_ALLOW_ARBITRARY_EXEC" in os.environ: if _GO2RTC_ARBITRARY_EXEC_ENV is not None:
raw = os.environ.get("GO2RTC_ALLOW_ARBITRARY_EXEC") raw = _GO2RTC_ARBITRARY_EXEC_ENV
elif ( elif (
os.path.isdir("/run/secrets") os.path.isdir("/run/secrets")
and os.access("/run/secrets", os.R_OK) and os.access("/run/secrets", os.R_OK)
@@ -1056,6 +1061,7 @@ def ffprobe_stream(ffmpeg, path: str, detailed: bool = False) -> sp.CompletedPro
KEYFRAME_PROBE_WINDOW_SECONDS = 20 KEYFRAME_PROBE_WINDOW_SECONDS = 20
KEYFRAME_GAP_WARNING_SECONDS = 4.0 KEYFRAME_GAP_WARNING_SECONDS = 4.0
KEYFRAME_GAP_JITTER_SECONDS = 0.5
def parse_keyframe_packets(output: str) -> tuple[list[float], float | None]: def parse_keyframe_packets(output: str) -> tuple[list[float], float | None]:
@@ -1095,6 +1101,10 @@ def classify_keyframe_gaps(
- "error" when the longest gap exceeds the record segment length - "error" when the longest gap exceeds the record segment length
- "warning" when the longest gap exceeds the warning threshold - "warning" when the longest gap exceeds the warning threshold
- "ok" otherwise - "ok" otherwise
The "pattern" key separates the two causes so callers can give accurate
advice: "fixed" is a regular GOP that is simply too long, "variable" is
the irregular spacing a smart/+ codec produces.
""" """
thresholds = { thresholds = {
"warning": KEYFRAME_GAP_WARNING_SECONDS, "warning": KEYFRAME_GAP_WARNING_SECONDS,
@@ -1107,6 +1117,7 @@ def classify_keyframe_gaps(
"max_gap": None, "max_gap": None,
"mean_gap": None, "mean_gap": None,
"min_gap": None, "min_gap": None,
"pattern": None,
"segment_time": segment_time, "segment_time": segment_time,
"severity": "unknown", "severity": "unknown",
"thresholds": thresholds, "thresholds": thresholds,
@@ -1114,6 +1125,7 @@ def classify_keyframe_gaps(
gaps = [b - a for a, b in zip(keyframe_pts, keyframe_pts[1:])] gaps = [b - a for a, b in zip(keyframe_pts, keyframe_pts[1:])]
max_gap = max(gaps) max_gap = max(gaps)
min_gap = min(gaps)
if max_gap > segment_time: if max_gap > segment_time:
severity = "error" severity = "error"
@@ -1122,11 +1134,16 @@ def classify_keyframe_gaps(
else: else:
severity = "ok" severity = "ok"
# allow for encoder jitter and probe rounding before calling a GOP variable
tolerance = max(KEYFRAME_GAP_JITTER_SECONDS, min_gap * 0.25)
pattern = "variable" if (max_gap - min_gap) > tolerance else "fixed"
return { return {
"keyframe_count": len(keyframe_pts), "keyframe_count": len(keyframe_pts),
"max_gap": round(max_gap, 2), "max_gap": round(max_gap, 2),
"mean_gap": round(sum(gaps) / len(gaps), 2), "mean_gap": round(sum(gaps) / len(gaps), 2),
"min_gap": round(min(gaps), 2), "min_gap": round(min_gap, 2),
"pattern": pattern,
"segment_time": segment_time, "segment_time": segment_time,
"severity": severity, "severity": severity,
"thresholds": thresholds, "thresholds": thresholds,
+163 -93
View File
@@ -5,7 +5,7 @@ import queue
import subprocess as sp import subprocess as sp
import threading import threading
import time import time
from collections import deque from collections import defaultdict, deque
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from multiprocessing import Queue, Value from multiprocessing import Queue, Value
from multiprocessing.synchronize import Event as MpEvent from multiprocessing.synchronize import Event as MpEvent
@@ -22,7 +22,14 @@ from frigate.config.camera.updater import (
CameraConfigUpdateEnum, CameraConfigUpdateEnum,
CameraConfigUpdateSubscriber, CameraConfigUpdateSubscriber,
) )
from frigate.const import PROCESS_PRIORITY_HIGH from frigate.const import (
PROCESS_PRIORITY_HIGH,
RECORD_STREAM_TYPES,
ROLE_TO_STREAM_TYPE,
STREAM_TYPE_MAIN,
STREAM_TYPE_SUB,
STREAM_TYPE_TO_ROLE,
)
from frigate.log import LogPipe from frigate.log import LogPipe
from frigate.util.builtin import EventsPerSecond, get_record_segment_time from frigate.util.builtin import EventsPerSecond, get_record_segment_time
from frigate.util.ffmpeg import start_or_restart_ffmpeg, stop_ffmpeg from frigate.util.ffmpeg import start_or_restart_ffmpeg, stop_ffmpeg
@@ -34,6 +41,8 @@ from frigate.util.process import FrigateProcess
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
RECORD_GRACE_SECONDS = 90
def capture_frames( def capture_frames(
ffmpeg_process: sp.Popen[Any], ffmpeg_process: sp.Popen[Any],
@@ -150,16 +159,26 @@ class CameraWatchdog(threading.Thread):
self.was_record_sub_enabled = self.config.record.sub.enabled self.was_record_sub_enabled = self.config.record.sub.enabled
self.segment_subscriber = RecordingsDataSubscriber(RecordingsDataTypeEnum.all) self.segment_subscriber = RecordingsDataSubscriber(RecordingsDataTypeEnum.all)
self.latest_valid_segment_time: float = 0 self.latest_valid_segment_time: dict[str, float] = defaultdict(float)
self.latest_invalid_segment_time: float = 0 self.latest_invalid_segment_time: dict[str, float] = defaultdict(float)
self.latest_cache_segment_time: float = 0 self.latest_cache_segment_time: dict[str, float] = defaultdict(float)
self.record_enable_time: datetime | None = None self.record_enable_time: datetime | None = None
self.stream_grace_until: dict[str, datetime] = {}
# `valid` segments are published with the segment's start time, so the # `valid` segments are published with the segment's start time, so the
# gap between consecutive publishes can reach 2 * segment_time. Pad the # gap between consecutive publishes can reach 2 * segment_time. Pad the
# staleness threshold so it's never tighter than that worst case. # staleness threshold so it's never tighter than that worst case.
segment_time = get_record_segment_time(self.config) self.record_stale_threshold: dict[str, int] = {
self.record_stale_threshold = max(120, 2 * segment_time + 30) stream_type: max(
120, 2 * get_record_segment_time(self.config, stream_type) + 30
)
for stream_type in RECORD_STREAM_TYPES
}
# the sub stream usually shares its input, and therefore its ffmpeg
# process, with detect, so it isn't in ffmpeg_other_processes and needs
# its own staleness check
self.detect_process_records_sub = False
# Stall tracking (based on last processed frame) # Stall tracking (based on last processed frame)
self._stall_timestamps: deque[float] = deque() self._stall_timestamps: deque[float] = deque()
@@ -167,7 +186,7 @@ class CameraWatchdog(threading.Thread):
# Status caching to reduce message volume # Status caching to reduce message volume
self._last_detect_status: str | None = None self._last_detect_status: str | None = None
self._last_record_status: str | None = None self._last_record_status: dict[str, str] = {}
self._last_status_update_time: float = 0.0 self._last_status_update_time: float = 0.0
def _send_detect_status(self, status: str, now: float) -> None: def _send_detect_status(self, status: str, now: float) -> None:
@@ -180,16 +199,78 @@ class CameraWatchdog(threading.Thread):
self._last_detect_status = status self._last_detect_status = status
self._last_status_update_time = now self._last_status_update_time = now
def _send_record_status(self, status: str, now: float) -> None: def _send_record_status(self, stream_type: str, status: str, now: float) -> None:
"""Send record status only if changed or retry_interval has elapsed.""" """Send a record stream's status only if changed or retry_interval has elapsed."""
if ( if (
status != self._last_record_status status != self._last_record_status.get(stream_type)
or (now - self._last_status_update_time) >= self.sleeptime or (now - self._last_status_update_time) >= self.sleeptime
): ):
self.requestor.send_data(f"{self.config.name}/status/record", status) self.requestor.send_data(
self._last_record_status = status f"{self.config.name}/status/{STREAM_TYPE_TO_ROLE[stream_type]}", status
)
self._last_record_status[stream_type] = status
self._last_status_update_time = now self._last_status_update_time = now
def _reset_segment_times(self) -> None:
self.latest_valid_segment_time.clear()
self.latest_invalid_segment_time.clear()
self.latest_cache_segment_time.clear()
self.stream_grace_until.clear()
def _grant_restart_grace(self, stream_types: list[str], now_utc: datetime) -> None:
for stream_type in stream_types:
self.stream_grace_until[stream_type] = now_utc + timedelta(
seconds=RECORD_GRACE_SECONDS
)
def _stream_staleness(self, stream_type: str, now_utc: datetime) -> str | None:
"""Return why the stream's segments are stale, or None if they're healthy."""
# ffmpeg needs time to create a first segment after recording is
# enabled and after a restart, per stream
in_grace_period = (
self.record_enable_time is not None
and (now_utc - self.record_enable_time)
< timedelta(seconds=RECORD_GRACE_SECONDS)
) or now_utc < self.stream_grace_until.get(stream_type, now_utc)
if in_grace_period:
return None
latest_cache = self.latest_cache_segment_time[stream_type]
latest_valid = self.latest_valid_segment_time[stream_type]
latest_invalid = self.latest_invalid_segment_time[stream_type]
def as_dt(timestamp: float) -> datetime:
if timestamp > 0:
return datetime.fromtimestamp(timestamp, tz=UTC)
return now_utc - timedelta(seconds=1)
stale_window = timedelta(seconds=self.record_stale_threshold[stream_type])
if now_utc > (as_dt(latest_cache) + stale_window):
return "No new recording segments were created"
if now_utc > (as_dt(latest_valid) + stale_window):
return "No new valid recording segments were created"
if (
latest_invalid > 0
and now_utc > (as_dt(latest_invalid) + stale_window)
and latest_valid <= latest_invalid
):
return "No valid segments created since last invalid segment"
return None
def _recorded_streams(self, roles: list[Any]) -> list[str]:
"""Record stream types the given roles cover that are currently recording."""
return [
stream_type
for role, stream_type in ROLE_TO_STREAM_TYPE.items()
if role in roles and self.config.record.stream_enabled(stream_type)
]
def _check_config_updates(self) -> dict[str, list[str]]: def _check_config_updates(self) -> dict[str, list[str]]:
"""Check for config updates and return the update dict.""" """Check for config updates and return the update dict."""
return self.config_subscriber.check_for_updates() return self.config_subscriber.check_for_updates()
@@ -245,6 +326,11 @@ class CameraWatchdog(threading.Thread):
self.logger.info("Restarting ffmpeg...") self.logger.info("Restarting ffmpeg...")
self.start_ffmpeg_detect() self.start_ffmpeg_detect()
# this process produces the sub stream's segments too, so it gets the
# same startup grace however the reset was triggered
if self.detect_process_records_sub:
self._grant_restart_grace([STREAM_TYPE_SUB], datetime.now().astimezone(UTC))
def run(self) -> None: def run(self) -> None:
if self._update_enabled_state(): if self._update_enabled_state():
self.start_all_ffmpeg() self.start_all_ffmpeg()
@@ -267,9 +353,7 @@ class CameraWatchdog(threading.Thread):
) )
self.stop_all_ffmpeg() self.stop_all_ffmpeg()
self.start_all_ffmpeg() self.start_all_ffmpeg()
self.latest_valid_segment_time = 0 self._reset_segment_times()
self.latest_invalid_segment_time = 0
self.latest_cache_segment_time = 0
self.record_enable_time = datetime.now().astimezone(UTC) self.record_enable_time = datetime.now().astimezone(UTC)
last_restart_time = datetime.now().timestamp() last_restart_time = datetime.now().timestamp()
continue continue
@@ -281,9 +365,7 @@ class CameraWatchdog(threading.Thread):
self.start_all_ffmpeg() self.start_all_ffmpeg()
# reset all timestamps and record the enable time for grace period # reset all timestamps and record the enable time for grace period
self.latest_valid_segment_time = 0 self._reset_segment_times()
self.latest_invalid_segment_time = 0
self.latest_cache_segment_time = 0
self.record_enable_time = datetime.now().astimezone(UTC) self.record_enable_time = datetime.now().astimezone(UTC)
else: else:
self.logger.debug(f"Disabling camera {self.config.name}") self.logger.debug(f"Disabling camera {self.config.name}")
@@ -293,7 +375,10 @@ class CameraWatchdog(threading.Thread):
# update camera status # update camera status
now = datetime.now().timestamp() now = datetime.now().timestamp()
self._send_detect_status("disabled", now) self._send_detect_status("disabled", now)
self._send_record_status("disabled", now) self._send_record_status(STREAM_TYPE_MAIN, "disabled", now)
# cameras without a sub stream never get a record_sub topic
if self.config.record.sub.enabled:
self._send_record_status(STREAM_TYPE_SUB, "disabled", now)
self.was_enabled = enabled self.was_enabled = enabled
continue continue
@@ -305,9 +390,7 @@ class CameraWatchdog(threading.Thread):
) )
self.stop_all_ffmpeg() self.stop_all_ffmpeg()
self.start_all_ffmpeg() self.start_all_ffmpeg()
self.latest_valid_segment_time = 0 self._reset_segment_times()
self.latest_invalid_segment_time = 0
self.latest_cache_segment_time = 0
self.record_enable_time = datetime.now().astimezone(UTC) self.record_enable_time = datetime.now().astimezone(UTC)
last_restart_time = datetime.now().timestamp() last_restart_time = datetime.now().timestamp()
self.was_record_enabled_in_config = record_enabled_in_config self.was_record_enabled_in_config = record_enabled_in_config
@@ -323,9 +406,7 @@ class CameraWatchdog(threading.Thread):
) )
self.stop_all_ffmpeg() self.stop_all_ffmpeg()
self.start_all_ffmpeg() self.start_all_ffmpeg()
self.latest_valid_segment_time = 0 self._reset_segment_times()
self.latest_invalid_segment_time = 0
self.latest_cache_segment_time = 0
self.record_enable_time = datetime.now().astimezone(UTC) self.record_enable_time = datetime.now().astimezone(UTC)
last_restart_time = datetime.now().timestamp() last_restart_time = datetime.now().timestamp()
self.was_record_sub_enabled = record_sub_enabled self.was_record_sub_enabled = record_sub_enabled
@@ -343,26 +424,25 @@ class CameraWatchdog(threading.Thread):
raw_topic, payload = update raw_topic, payload = update
if raw_topic and payload: if raw_topic and payload:
topic = str(raw_topic) topic = str(raw_topic)
camera, segment_time, _ = payload camera, stream_type, segment_time, _ = payload
if camera != self.config.name: if camera != self.config.name:
continue continue
if topic.endswith(RecordingsDataTypeEnum.invalid.value): if topic.endswith(RecordingsDataTypeEnum.invalid.value):
self.logger.warning( self.logger.warning(
f"Invalid recording segment detected for {camera} at {segment_time}" f"Invalid recording segment detected for {camera} ({stream_type}) at {segment_time}"
) )
self.latest_invalid_segment_time = segment_time self.latest_invalid_segment_time[stream_type] = segment_time
elif topic.endswith(RecordingsDataTypeEnum.valid.value): elif topic.endswith(RecordingsDataTypeEnum.valid.value):
self.logger.debug( self.logger.debug(
f"Latest valid recording segment time on {camera}: {segment_time}" f"Latest valid recording segment time on {camera} ({stream_type}): {segment_time}"
) )
self.latest_valid_segment_time = segment_time self.latest_valid_segment_time[stream_type] = segment_time
elif topic.endswith(RecordingsDataTypeEnum.latest.value): elif topic.endswith(RecordingsDataTypeEnum.latest.value):
if segment_time is not None: self.latest_cache_segment_time[stream_type] = (
self.latest_cache_segment_time = segment_time segment_time if segment_time is not None else 0
else: )
self.latest_cache_segment_time = 0
now = datetime.now().timestamp() now = datetime.now().timestamp()
@@ -409,63 +489,26 @@ class CameraWatchdog(threading.Thread):
for p in self.ffmpeg_other_processes: for p in self.ffmpeg_other_processes:
poll = p["process"].poll() poll = p["process"].poll()
if self.config.record.enabled and "record" in p["roles"]: recorded_streams = self._recorded_streams(p["roles"])
if recorded_streams:
now_utc = datetime.now().astimezone(UTC) now_utc = datetime.now().astimezone(UTC)
# Check if we're within the grace period after enabling recording # ensure segments are still being created and that they have
# Grace period: 90 seconds allows time for ffmpeg to start and create first segment # valid video data. each stream is tracked separately so a
in_grace_period = self.record_enable_time is not None and ( # healthy one can't mask a stalled one.
now_utc - self.record_enable_time stale_stream = None
) < timedelta(seconds=90) stale_reason = None
for stream_type in recorded_streams:
stale_reason = self._stream_staleness(stream_type, now_utc)
latest_cache_dt = ( if stale_reason is not None:
datetime.fromtimestamp(self.latest_cache_segment_time, tz=UTC) stale_stream = stream_type
if self.latest_cache_segment_time > 0 break
else now_utc - timedelta(seconds=1)
)
latest_valid_dt = (
datetime.fromtimestamp(self.latest_valid_segment_time, tz=UTC)
if self.latest_valid_segment_time > 0
else now_utc - timedelta(seconds=1)
)
latest_invalid_dt = (
datetime.fromtimestamp(self.latest_invalid_segment_time, tz=UTC)
if self.latest_invalid_segment_time > 0
else now_utc - timedelta(seconds=1)
)
# ensure segments are still being created and that they have valid video data
# Skip checks during grace period to allow segments to start being created
stale_window = timedelta(seconds=self.record_stale_threshold)
cache_stale = not in_grace_period and now_utc > (
latest_cache_dt + stale_window
)
valid_stale = not in_grace_period and now_utc > (
latest_valid_dt + stale_window
)
invalid_stale_condition = (
self.latest_invalid_segment_time > 0
and not in_grace_period
and now_utc > (latest_invalid_dt + stale_window)
and self.latest_valid_segment_time
<= self.latest_invalid_segment_time
)
invalid_stale = invalid_stale_condition
if cache_stale or valid_stale or invalid_stale:
if cache_stale:
reason = "No new recording segments were created"
elif valid_stale:
reason = "No new valid recording segments were created"
else: # invalid_stale
reason = (
"No valid segments created since last invalid segment"
)
if stale_stream is not None and can_restart:
self.logger.error( self.logger.error(
f"{reason} for {self.config.name} in the last {self.record_stale_threshold}s. Restarting the ffmpeg record process..." f"{stale_reason} for {self.config.name} ({stale_stream}) in the last {self.record_stale_threshold[stale_stream]}s. Restarting the ffmpeg record process..."
) )
p["process"] = start_or_restart_ffmpeg( p["process"] = start_or_restart_ffmpeg(
p["cmd"], p["cmd"],
@@ -479,10 +522,18 @@ class CameraWatchdog(threading.Thread):
f"{self.config.name}/status/{role.value}", "offline" f"{self.config.name}/status/{role.value}", "offline"
) )
self._grant_restart_grace(recorded_streams, now_utc)
last_restart_time = now
continue continue
else: elif stale_stream is None:
self._send_record_status("online", now) for stream_type in recorded_streams:
p["latest_segment_time"] = self.latest_cache_segment_time self._send_record_status(stream_type, "online", now)
p["latest_segment_time"] = max(
self.latest_cache_segment_time[stream_type]
for stream_type in recorded_streams
)
if poll is None: if poll is None:
continue continue
@@ -497,6 +548,25 @@ class CameraWatchdog(threading.Thread):
p["cmd"], self.logger, p["logpipe"], ffmpeg_process=p["process"] p["cmd"], self.logger, p["logpipe"], ffmpeg_process=p["process"]
) )
if (
self.detect_process_records_sub
and self.config.record.stream_enabled(STREAM_TYPE_SUB)
and self.capture_thread is not None
and self.capture_thread.is_alive()
):
now_utc = datetime.now().astimezone(UTC)
stale_reason = self._stream_staleness(STREAM_TYPE_SUB, now_utc)
if stale_reason is None:
self._send_record_status(STREAM_TYPE_SUB, "online", now)
elif can_restart:
self.logger.error(
f"{stale_reason} for {self.config.name} (sub, shared with detect) in the last {self.record_stale_threshold[STREAM_TYPE_SUB]}s. Restarting ffmpeg..."
)
self._send_record_status(STREAM_TYPE_SUB, "offline", now)
self.reset_capture_thread()
last_restart_time = now
# Prune expired reconnect timestamps # Prune expired reconnect timestamps
now = datetime.now().timestamp() now = datetime.now().timestamp()
while ( while (
@@ -539,9 +609,9 @@ class CameraWatchdog(threading.Thread):
self.segment_subscriber.stop() self.segment_subscriber.stop()
def start_ffmpeg_detect(self): def start_ffmpeg_detect(self):
ffmpeg_cmd = [ detect_cmd = [c for c in self.config.ffmpeg_cmds if "detect" in c["roles"]][0]
c["cmd"] for c in self.config.ffmpeg_cmds if "detect" in c["roles"] ffmpeg_cmd = detect_cmd["cmd"]
][0] self.detect_process_records_sub = "record_sub" in detect_cmd["roles"]
self.ffmpeg_detect_process = start_or_restart_ffmpeg( self.ffmpeg_detect_process = start_or_restart_ffmpeg(
ffmpeg_cmd, self.logger, self.logpipe, self.frame_size ffmpeg_cmd, self.logger, self.logpipe, self.frame_size
) )
+6 -1
View File
@@ -1519,8 +1519,13 @@
"keyPatternError": "Use only letters, numbers, hyphens, and underscores (no spaces)" "keyPatternError": "Use only letters, numbers, hyphens, and underscores (no spaces)"
}, },
"knownPlates": { "knownPlates": {
"assignedTo": "Assigned to {{name}}",
"detected": "Detected plates",
"namePlaceholder": "e.g., Wife's Car", "namePlaceholder": "e.g., Wife's Car",
"platePlaceholder": "Plate number or regex" "noneDetected": "No plates detected yet",
"platePlaceholder": "Plate number or regex",
"search": "Search or enter a plate",
"useCustom": "Use \"{{value}}\""
}, },
"liveStreams": { "liveStreams": {
"streamNameLabel": "Stream name", "streamNameLabel": "Stream name",
+4 -2
View File
@@ -184,8 +184,10 @@
"gap": "Keyframe gap (min / avg / max):", "gap": "Keyframe gap (min / avg / max):",
"segmentLength": "Recording segment length:", "segmentLength": "Recording segment length:",
"ok": "Keyframes every ~{{seconds}}s, good for recording and playback.", "ok": "Keyframes every ~{{seconds}}s, good for recording and playback.",
"warning": "Sparse or variable keyframes (longest gap ~{{seconds}}s), likely a smart codec (H.264+/H.265+), this is not recommended.", "warningFixed": "Keyframes are evenly spaced but sparse (every ~{{seconds}}s). Recording still works, but live playback and seeking start more slowly. Set the camera's I-frame (keyframe) interval to match its frame rate.",
"error": "Keyframe gap (~{{seconds}}s) exceeds the recording segment length ({{segmentTime}}s). Some segments may have no keyframe, which breaks playback. Disable the smart/+ codec on the camera or shorten its keyframe interval.", "warningVariable": "Keyframe spacing is inconsistent ({{minSeconds}}s to {{maxSeconds}}s), which usually means a smart codec (H.264+/H.265+) is enabled. This is not recommended.",
"errorFixed": "Keyframes every ~{{seconds}}s is longer than the recording segment length ({{segmentTime}}s), so some segments have no keyframe and will not play back. Shorten the camera's I-frame (keyframe) interval to match its frame rate.",
"errorVariable": "Keyframe gaps reach ~{{seconds}}s, longer than the recording segment length ({{segmentTime}}s). Some segments will have no keyframe, which breaks playback. Disable the smart/+ codec on the camera or shorten its keyframe interval.",
"unknown": "Couldn't determine keyframe spacing.", "unknown": "Couldn't determine keyframe spacing.",
"recordDisabled": "Recording is disabled for this camera." "recordDisabled": "Recording is disabled for this camera."
} }
@@ -1,6 +1,7 @@
import type { FieldPathList, FieldProps, RJSFSchema } from "@rjsf/utils"; import type { FieldPathList, FieldProps, RJSFSchema } from "@rjsf/utils";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import useSWR from "swr";
import { import {
Collapsible, Collapsible,
CollapsibleContent, CollapsibleContent,
@@ -8,11 +9,24 @@ import {
} from "@/components/ui/collapsible"; } from "@/components/ui/collapsible";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import {
Command,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
LuCheck,
LuChevronDown, LuChevronDown,
LuChevronRight, LuChevronRight,
LuChevronsUpDown,
LuPlus, LuPlus,
LuTrash2, LuTrash2,
} from "react-icons/lu"; } from "react-icons/lu";
@@ -23,6 +37,183 @@ import { MapKeyInput } from "../components";
type KnownPlatesData = Record<string, string[]>; type KnownPlatesData = Record<string, string[]>;
type PlateComboboxProps = {
id: string;
value: string;
entryName: string;
disabled?: boolean;
detectedPlates: string[];
plateAssignments: Map<string, string>;
autoOpen: boolean;
onAutoOpened: () => void;
onCommit: (next: string) => void;
};
/**
* Plate entry that doubles as a picker for plates Frigate has already
* recognized. Free text is still accepted so regexes remain typeable.
*/
function PlateCombobox({
id,
value,
entryName,
disabled,
detectedPlates,
plateAssignments,
autoOpen,
onAutoOpened,
onCommit,
}: PlateComboboxProps) {
const { t } = useTranslation(["views/settings"]);
const [open, setOpen] = useState(false);
const [searchValue, setSearchValue] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!autoOpen) return;
setOpen(true);
onAutoOpened();
}, [autoOpen, onAutoOpened]);
// Seed the search box with the current plate and select it, so the first
// keystroke replaces the plate instead of appending to it.
useEffect(() => {
if (!open) {
setSearchValue("");
return;
}
setSearchValue(value);
const frame = requestAnimationFrame(() => inputRef.current?.select());
return () => cancelAnimationFrame(frame);
}, [open, value]);
const trimmedSearch = searchValue.trim();
const matchesDetected = useMemo(
() =>
detectedPlates.some(
(plate) => plate.toLowerCase() === trimmedSearch.toLowerCase(),
),
[detectedPlates, trimmedSearch],
);
const showCustomOption = trimmedSearch.length > 0 && !matchesDetected;
const commit = useCallback(
(next: string) => {
onCommit(next);
setOpen(false);
},
[onCommit],
);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
id={id}
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled}
className={cn(
"min-w-0 flex-1 justify-between font-normal",
!value && "text-muted-foreground",
)}
>
<span className="truncate">
{value ||
t("configForm.knownPlates.platePlaceholder", {
ns: "views/settings",
})}
</span>
<LuChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-[--radix-popover-trigger-width] p-0"
>
<Command>
<CommandInput
ref={inputRef}
placeholder={t("configForm.knownPlates.search", {
ns: "views/settings",
})}
value={searchValue}
onValueChange={setSearchValue}
/>
<CommandList>
{showCustomOption && (
<CommandGroup>
<CommandItem
value={trimmedSearch}
onSelect={() => commit(trimmedSearch)}
>
<LuPlus className="mr-2 h-4 w-4 shrink-0" />
<span className="truncate">
{t("configForm.knownPlates.useCustom", {
ns: "views/settings",
value: trimmedSearch,
})}
</span>
</CommandItem>
</CommandGroup>
)}
{detectedPlates.length > 0 ? (
<CommandGroup
heading={t("configForm.knownPlates.detected", {
ns: "views/settings",
})}
>
{detectedPlates.map((plate) => {
const assignedTo = plateAssignments.get(plate);
const showAssignedTo =
!!assignedTo && assignedTo !== entryName;
return (
<CommandItem
key={plate}
value={plate}
onSelect={() => commit(plate)}
>
<LuCheck
className={cn(
"mr-2 h-4 w-4 shrink-0",
value === plate ? "opacity-100" : "opacity-0",
)}
/>
<span className="truncate">{plate}</span>
{showAssignedTo && (
<span className="ml-auto shrink-0 pl-2 text-xs text-muted-foreground">
{t("configForm.knownPlates.assignedTo", {
ns: "views/settings",
name: assignedTo,
})}
</span>
)}
</CommandItem>
);
})}
</CommandGroup>
) : (
!showCustomOption && (
<div className="p-4 text-center text-sm text-muted-foreground">
{t("configForm.knownPlates.noneDetected", {
ns: "views/settings",
})}
</div>
)
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
export function KnownPlatesField(props: FieldProps) { export function KnownPlatesField(props: FieldProps) {
const { schema, formData, onChange, idSchema, disabled, readonly } = props; const { schema, formData, onChange, idSchema, disabled, readonly } = props;
const formContext = props.registry?.formContext as const formContext = props.registry?.formContext as
@@ -87,6 +278,35 @@ export function KnownPlatesField(props: FieldProps) {
} }
}, [hasItems]); }, [hasItems]);
const { data: recognizedPlates } = useSWR<string[]>(
open ? ["recognized_license_plates", { split_joined: 1 }] : null,
{ revalidateOnFocus: false },
);
const detectedPlates = useMemo(
() => recognizedPlates ?? [],
[recognizedPlates],
);
const plateAssignments = useMemo(() => {
const assignments = new Map<string, string>();
for (const [name, plates] of entries) {
for (const plate of plates) {
const trimmed = plate.trim();
if (trimmed && !assignments.has(trimmed)) {
assignments.set(trimmed, name);
}
}
}
return assignments;
}, [entries]);
const [pendingOpenPlate, setPendingOpenPlate] = useState<string | null>(null);
const clearPendingOpenPlate = useCallback(
() => setPendingOpenPlate(null),
[],
);
const handleAddEntry = useCallback(() => { const handleAddEntry = useCallback(() => {
const next = { ...data, "": [""] }; const next = { ...data, "": [""] };
onChange(next, fieldPath); onChange(next, fieldPath);
@@ -120,8 +340,9 @@ export function KnownPlatesField(props: FieldProps) {
const handleAddPlate = useCallback( const handleAddPlate = useCallback(
(key: string) => { (key: string) => {
const next = { ...data, [key]: [...(data[key] || []), ""] }; const plates = [...(data[key] || []), ""];
onChange(next, fieldPath); onChange({ ...data, [key]: plates }, fieldPath);
setPendingOpenPlate(`${key}::${plates.length - 1}`);
}, },
[data, fieldPath, onChange], [data, fieldPath, onChange],
); );
@@ -154,9 +375,6 @@ export function KnownPlatesField(props: FieldProps) {
const namePlaceholder = t("configForm.knownPlates.namePlaceholder", { const namePlaceholder = t("configForm.knownPlates.namePlaceholder", {
ns: "views/settings", ns: "views/settings",
}); });
const platePlaceholder = t("configForm.knownPlates.platePlaceholder", {
ns: "views/settings",
});
return ( return (
<Card className="w-full"> <Card className="w-full">
<Collapsible open={open} onOpenChange={setOpen}> <Collapsible open={open} onOpenChange={setOpen}>
@@ -224,15 +442,20 @@ export function KnownPlatesField(props: FieldProps) {
<div className="ml-1 space-y-2 border-l-2 border-muted-foreground/20 pl-3"> <div className="ml-1 space-y-2 border-l-2 border-muted-foreground/20 pl-3">
{plates.map((plate, plateIndex) => ( {plates.map((plate, plateIndex) => (
<div key={plateIndex} className="flex items-center gap-2"> <div key={plateIndex} className="flex items-center gap-2">
<Input <PlateCombobox
id={`${entryId}-plate-${plateIndex}`} id={`${entryId}-plate-${plateIndex}`}
value={plate} value={plate}
placeholder={platePlaceholder} entryName={key}
disabled={disabled || readonly} disabled={disabled || readonly}
onChange={(e) => detectedPlates={detectedPlates}
handleUpdatePlate(key, plateIndex, e.target.value) plateAssignments={plateAssignments}
autoOpen={
pendingOpenPlate === `${key}::${plateIndex}`
}
onAutoOpened={clearPendingOpenPlate}
onCommit={(next) =>
handleUpdatePlate(key, plateIndex, next)
} }
className="flex-1"
/> />
{plates.length > 1 && ( {plates.length > 1 && (
<Button <Button
@@ -89,14 +89,26 @@ export default function KeyframeAnalysisSection({
case "warning": case "warning":
summary = ( summary = (
<Row icon="warning"> <Row icon="warning">
{t("cameras.info.keyframes.warning", { seconds: analysis.max_gap })} {analysis.pattern === "fixed"
? t("cameras.info.keyframes.warningFixed", {
seconds: analysis.mean_gap,
})
: t("cameras.info.keyframes.warningVariable", {
minSeconds: analysis.min_gap,
maxSeconds: analysis.max_gap,
})}
</Row> </Row>
); );
break; break;
case "error": case "error":
summary = ( summary = (
<Row icon="error"> <Row icon="error">
{t("cameras.info.keyframes.error", { {analysis.pattern === "fixed"
? t("cameras.info.keyframes.errorFixed", {
seconds: analysis.mean_gap,
segmentTime: analysis.segment_time,
})
: t("cameras.info.keyframes.errorVariable", {
seconds: analysis.max_gap, seconds: analysis.max_gap,
segmentTime: analysis.segment_time, segmentTime: analysis.segment_time,
})} })}
+3
View File
@@ -161,6 +161,8 @@ export type KeyframeSeverity =
| "unknown" | "unknown"
| "record_disabled"; | "record_disabled";
export type KeyframeGapPattern = "fixed" | "variable";
export type KeyframeAnalysis = { export type KeyframeAnalysis = {
severity: KeyframeSeverity; severity: KeyframeSeverity;
stream_index?: number; stream_index?: number;
@@ -168,6 +170,7 @@ export type KeyframeAnalysis = {
max_gap?: number | null; max_gap?: number | null;
mean_gap?: number | null; mean_gap?: number | null;
min_gap?: number | null; min_gap?: number | null;
pattern?: KeyframeGapPattern | null;
duration_observed?: number | null; duration_observed?: number | null;
segment_time?: number; segment_time?: number;
thresholds?: { warning: number; error: number }; thresholds?: { warning: number; error: number };