mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-29 19:36:57 +03:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4d25ccbcc | ||
|
|
6d0733d18c | ||
|
|
027bafdb29 | ||
|
|
1355075fda |
@@ -42,89 +42,6 @@ jobs:
|
||||
tags: ${{ steps.setup.outputs.image-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
|
||||
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:
|
||||
runs-on: ubuntu-22.04-arm
|
||||
name: ARM Build
|
||||
|
||||
+3
-25
@@ -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 \
|
||||
/deps/build_intel_media_driver.sh
|
||||
|
||||
FROM wget AS go2rtc
|
||||
FROM scratch AS go2rtc
|
||||
ARG TARGETARCH
|
||||
RUN --mount=type=bind,source=docker/main/install_go2rtc.sh,target=/deps/install_go2rtc.sh \
|
||||
/deps/install_go2rtc.sh
|
||||
WORKDIR /rootfs/usr/local/go2rtc/bin
|
||||
ADD --link --chmod=755 "https://github.com/AlexxIT/go2rtc/releases/download/v1.9.14/go2rtc_linux_${TARGETARCH}" go2rtc
|
||||
|
||||
FROM wget AS tempio
|
||||
ARG TARGETARCH
|
||||
@@ -265,23 +265,6 @@ 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 \
|
||||
/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 INCLUDED_FFMPEG_VERSIONS="${DEFAULT_FFMPEG_VERSION}:7.0:5.0"
|
||||
|
||||
@@ -324,11 +307,6 @@ HEALTHCHECK --start-period=300s --start-interval=5s --interval=15s --timeout=5s
|
||||
# Frigate deps with Node.js and NPM for 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
|
||||
# But start a fake service for simulating the logs
|
||||
COPY docker/main/fake_frigate_run /etc/s6-overlay/s6-rc.d/frigate/run
|
||||
|
||||
+37
-77
@@ -28,13 +28,7 @@ update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.11 1
|
||||
mkdir -p -m 600 /root/.gnupg
|
||||
|
||||
# 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"
|
||||
echo "${edgetpu_checksums[${TARGETARCH}]} /tmp/libedgetpu1-max.deb" | sha256sum -c -
|
||||
unset DEBIAN_FRONTEND
|
||||
yes | dpkg -i /tmp/libedgetpu1-max.deb && export DEBIAN_FRONTEND=noninteractive
|
||||
rm /tmp/libedgetpu1-max.deb
|
||||
@@ -51,41 +45,36 @@ if [[ "${TARGETARCH}" == "arm64" ]]; then
|
||||
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
|
||||
if [[ "${TARGETARCH}" == "amd64" ]]; then
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
mkdir -p /usr/lib/ffmpeg/5.0
|
||||
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"
|
||||
tar -xf ffmpeg.tar.xz -C /usr/lib/ffmpeg/5.0 --strip-components 1 amd64/bin/ffmpeg amd64/bin/ffprobe
|
||||
rm -rf ffmpeg.tar.xz
|
||||
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
|
||||
|
||||
# ffmpeg -> arm64
|
||||
if [[ "${TARGETARCH}" == "arm64" ]]; then
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
mkdir -p /usr/lib/ffmpeg/5.0
|
||||
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"
|
||||
tar -xf ffmpeg.tar.xz -C /usr/lib/ffmpeg/5.0 --strip-components 1 arm64/bin/ffmpeg arm64/bin/ffprobe
|
||||
rm -f ffmpeg.tar.xz
|
||||
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
|
||||
|
||||
# arch specific packages
|
||||
@@ -131,56 +120,27 @@ if [[ "${TARGETARCH}" == "amd64" ]]; then
|
||||
apt-get -qq install -y libtbb12
|
||||
|
||||
# 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
|
||||
# needed core package
|
||||
fetch_intel_deb https://github.com/intel/compute-runtime/releases/download/26.14.37833.4/libigdgmm12_22.9.0_amd64.deb
|
||||
wget 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
|
||||
rm libigdgmm12_22.9.0_amd64.deb
|
||||
|
||||
# legacy compute-runtime packages
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
wget https://github.com/intel/compute-runtime/releases/download/24.35.30872.36/intel-opencl-icd-legacy1_24.35.30872.36_amd64.deb
|
||||
wget https://github.com/intel/compute-runtime/releases/download/24.35.30872.36/intel-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
|
||||
wget https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-core_1.0.17537.24_amd64.deb
|
||||
# standard compute-runtime packages
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
wget 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
|
||||
wget 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
|
||||
# npu packages
|
||||
fetch_intel_deb 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/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-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-level-zero-npu_1.19.0.20250707-16111289554_ubuntu22.04_amd64.deb
|
||||
wget https://github.com/oneapi-src/level-zero/releases/download/v1.28.2/level-zero_1.28.2+u22.04_amd64.deb
|
||||
wget https://github.com/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
|
||||
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
|
||||
|
||||
dpkg -i *.deb
|
||||
rm *.deb
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
#!/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"
|
||||
@@ -4,29 +4,11 @@ set -euxo pipefail
|
||||
|
||||
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
|
||||
arch="x86_64"
|
||||
elif [[ "${TARGETARCH}" == "arm64" ]]; then
|
||||
arch="aarch64"
|
||||
fi
|
||||
|
||||
# downloaded rather than streamed into tar because streaming and verifying the
|
||||
# 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 -
|
||||
wget -qO- "https://github.com/frigate-nvr/hailort/releases/download/v${hailo_version}/hailort-debian12-${TARGETARCH}.tar.gz" | tar -C / -xzf -
|
||||
wget -P /wheels/ "https://github.com/frigate-nvr/hailort/releases/download/v${hailo_version}/hailort-${hailo_version}-cp311-cp311-linux_${arch}.whl"
|
||||
|
||||
@@ -4,15 +4,6 @@ set -euxo pipefail
|
||||
|
||||
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
|
||||
s6_arch="x86_64"
|
||||
elif [[ "${TARGETARCH}" == "arm64" ]]; then
|
||||
@@ -21,15 +12,8 @@ fi
|
||||
|
||||
mkdir -p /rootfs/
|
||||
|
||||
download_and_extract() {
|
||||
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-noarch.tar.xz" |
|
||||
tar -C /rootfs/ -Jxpf -
|
||||
|
||||
download_and_extract "noarch"
|
||||
download_and_extract "${s6_arch}"
|
||||
wget -qO- "https://github.com/just-containers/s6-overlay/releases/download/v${s6_version}/s6-overlay-${s6_arch}.tar.xz" |
|
||||
tar -C /rootfs/ -Jxpf -
|
||||
|
||||
@@ -4,14 +4,6 @@ set -euxo pipefail
|
||||
|
||||
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
|
||||
arch="amd64"
|
||||
elif [[ "${TARGETARCH}" == "arm64" ]]; then
|
||||
@@ -21,5 +13,4 @@ fi
|
||||
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}"
|
||||
echo "${tempio_checksums[${arch}]} /rootfs/usr/local/tempio/bin/tempio" | sha256sum -c -
|
||||
chmod 755 /rootfs/usr/local/tempio/bin/tempio
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
#!/command/with-contenv 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
|
||||
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
|
||||
exec logutil-service /dev/shm/logs/certsync
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
#!/command/with-contenv 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
|
||||
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
|
||||
exec logutil-service /dev/shm/logs/frigate
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
#!/command/with-contenv 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
|
||||
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
|
||||
exec logutil-service /dev/shm/logs/go2rtc
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
#!/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
|
||||
@@ -1 +0,0 @@
|
||||
oneshot
|
||||
@@ -1 +0,0 @@
|
||||
/etc/s6-overlay/s6-rc.d/init-usermod/run
|
||||
@@ -7,12 +7,5 @@ set -o errexit -o nounset -o pipefail
|
||||
dirs=(/dev/shm/logs/frigate /dev/shm/logs/go2rtc /dev/shm/logs/nginx /dev/shm/logs/certsync)
|
||||
|
||||
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[@]}"
|
||||
fi
|
||||
chown nobody:nogroup "${dirs[@]}"
|
||||
chmod 02755 "${dirs[@]}"
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
#!/command/with-contenv 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
|
||||
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
|
||||
exec logutil-service /dev/shm/logs/nginx
|
||||
|
||||
@@ -77,20 +77,15 @@ if [ ! \( -f "$letsencrypt_path/privkey.pem" -a -f "$letsencrypt_path/fullchain.
|
||||
openssl req -new -newkey rsa:4096 -days 365 -nodes -x509 \
|
||||
-subj "/O=FRIGATE DEFAULT CERT/CN=*" \
|
||||
-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
|
||||
|
||||
# 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
|
||||
echo "$nginx_settings" | \
|
||||
python3 /usr/local/nginx/get_nginx_settings.py | \
|
||||
tempio -template /usr/local/nginx/templates/base_path.gotmpl \
|
||||
-out /usr/local/nginx/conf/base_path.conf
|
||||
|
||||
# build templates for additional network settings
|
||||
echo "$nginx_settings" | \
|
||||
python3 /usr/local/nginx/get_nginx_settings.py | \
|
||||
tempio -template /usr/local/nginx/templates/listen.gotmpl \
|
||||
-out /usr/local/nginx/conf/listen.conf
|
||||
|
||||
|
||||
@@ -144,16 +144,3 @@ rm -f /dev/shm/.frigate-is-stopping
|
||||
|
||||
migrate_addon_config_dir
|
||||
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
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
#!/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
|
||||
@@ -189,6 +189,3 @@ if config.get("birdseye", {}).get("restream", False):
|
||||
# Write go2rtc_config to /dev/shm/go2rtc.yaml
|
||||
with open("/dev/shm/go2rtc.yaml", "w") as f:
|
||||
yaml.dump(go2rtc_config, f)
|
||||
|
||||
# config contains camera credentials; do not leave it world-readable
|
||||
os.chmod("/dev/shm/go2rtc.yaml", 0o640)
|
||||
|
||||
@@ -11,7 +11,6 @@ events {
|
||||
|
||||
http {
|
||||
map_hash_bucket_size 256;
|
||||
server_tokens off;
|
||||
|
||||
include mime.types;
|
||||
default_type application/octet-stream;
|
||||
@@ -63,7 +62,6 @@ http {
|
||||
|
||||
server {
|
||||
include listen.conf;
|
||||
include security_headers.conf;
|
||||
|
||||
# enable HTTP/2 for TLS connections to eliminate browser 6-connection limit
|
||||
http2 on;
|
||||
@@ -125,7 +123,6 @@ http {
|
||||
secure_token $args;
|
||||
secure_token_types application/vnd.apple.mpegurl;
|
||||
|
||||
include security_headers.conf;
|
||||
add_header Cache-Control "no-store";
|
||||
expires off;
|
||||
|
||||
@@ -142,7 +139,6 @@ http {
|
||||
|
||||
location /stream/ {
|
||||
include auth_request.conf;
|
||||
include security_headers.conf;
|
||||
add_header Cache-Control "no-store";
|
||||
expires off;
|
||||
|
||||
@@ -164,7 +160,6 @@ http {
|
||||
}
|
||||
|
||||
expires 7d;
|
||||
include security_headers.conf;
|
||||
add_header Cache-Control "public";
|
||||
autoindex on;
|
||||
root /media/frigate;
|
||||
@@ -257,7 +252,6 @@ http {
|
||||
|
||||
location /api/ {
|
||||
include auth_request.conf;
|
||||
include security_headers.conf;
|
||||
add_header Cache-Control "no-store";
|
||||
expires off;
|
||||
proxy_pass http://frigate_api/;
|
||||
@@ -324,34 +318,29 @@ http {
|
||||
|
||||
location / {
|
||||
# do not require auth for static assets
|
||||
include security_headers.conf;
|
||||
add_header Cache-Control "no-store";
|
||||
expires off;
|
||||
|
||||
location /assets/ {
|
||||
access_log off;
|
||||
expires 1y;
|
||||
include security_headers.conf;
|
||||
add_header Cache-Control "public";
|
||||
}
|
||||
|
||||
location /fonts/ {
|
||||
access_log off;
|
||||
expires 1y;
|
||||
include security_headers.conf;
|
||||
add_header Cache-Control "public";
|
||||
}
|
||||
|
||||
location /locales/ {
|
||||
access_log off;
|
||||
include security_headers.conf;
|
||||
add_header Cache-Control "public";
|
||||
}
|
||||
|
||||
location ~ ^/.*-([A-Za-z0-9]+)\.webmanifest$ {
|
||||
access_log off;
|
||||
expires 1y;
|
||||
include security_headers.conf;
|
||||
add_header Cache-Control "public";
|
||||
default_type application/json;
|
||||
proxy_set_header Accept-Encoding "";
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
# 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;
|
||||
@@ -1,45 +0,0 @@
|
||||
#!/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"
|
||||
@@ -120,7 +120,7 @@ environment_vars:
|
||||
|
||||
### `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:
|
||||
A `secrets.yaml` file in your config directory 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
|
||||
@@ -128,38 +128,16 @@ 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.
|
||||
The same `{FRIGATE_VARIABLE_NAME}` placeholder resolves from four sources, listed strongest first. When a name is defined in more than one, the highest wins and a warning is logged:
|
||||
|
||||
| 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.
|
||||
1. Docker secrets or the directory named by `CREDENTIALS_DIRECTORY` (defaults to `/run/secrets`)
|
||||
2. Container environment variables
|
||||
3. `secrets.yaml`
|
||||
4. The `environment_vars` block above
|
||||
|
||||
Referencing a name that no source defines is a config validation error naming the field.
|
||||
|
||||
|
||||
@@ -83,12 +83,11 @@ 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:
|
||||
|
||||
| Role | Description |
|
||||
| ------------ | ------------------------------------------------------------------------------------------------------------ |
|
||||
| `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_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) |
|
||||
| Role | Description |
|
||||
| -------- | ----------------------------------------------------------------------------------- |
|
||||
| `detect` | Main feed for object detection. [docs](object_detectors.md) |
|
||||
| `record` | Saves segments of the video feed based on configuration settings. [docs](record.md) |
|
||||
| `audio` | Feed for audio based detection. [docs](audio_detectors.md) |
|
||||
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
@@ -312,9 +312,8 @@ ffmpeg:
|
||||
|
||||
:::note
|
||||
|
||||
If running Frigate through Docker, map the relevant `/dev/video*` devices into
|
||||
the container. Running in privileged mode also works but grants far more access
|
||||
than needed. With Docker Compose add:
|
||||
If running Frigate through Docker, you either need to run in privileged mode or
|
||||
map the `/dev/video*` devices to Frigate. With Docker Compose add:
|
||||
|
||||
```yaml {4-5}
|
||||
services:
|
||||
|
||||
@@ -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:
|
||||
|
||||
- **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. Timeline previews are kept for as long as either stream still has recordings, so scrubbing works across the whole retained history.
|
||||
- **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.
|
||||
|
||||
### Configuring sub stream recording
|
||||
|
||||
@@ -427,7 +427,7 @@ This table covers only features that read recordings from disk. Tracked object s
|
||||
|
||||
### 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. 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- **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.
|
||||
|
||||
@@ -514,7 +514,7 @@ Generate a Frigate Docker Compose configuration based on your hardware and requi
|
||||
services:
|
||||
frigate:
|
||||
container_name: frigate
|
||||
# privileged: true # ONLY enable if your hardware requires it (see hardware-specific docs); prefer the device mappings below
|
||||
privileged: true # this may not be necessary for all setups
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 30s # allow enough time to shut down the various services
|
||||
image: ghcr.io/blakeblackshear/frigate:stable
|
||||
@@ -546,33 +546,6 @@ services:
|
||||
</TabItem>
|
||||
</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**
|
||||
|
||||
If you can't use Docker Compose, you can run the container with something similar to this:
|
||||
@@ -639,7 +612,7 @@ Home Assistant OS users can install via the App repository.
|
||||
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
|
||||
|
||||
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).
|
||||
App users who can't set container environment variables can put `FRIGATE_` values in a `secrets.yaml` in the config directory instead. See [`secrets.yaml`](../configuration/advanced/system.md#secretsyaml).
|
||||
|
||||
There are several variants of the App available:
|
||||
|
||||
|
||||
@@ -304,7 +304,7 @@ Topic with current state of notifications. Published values are `ON` and `OFF`.
|
||||
|
||||
### `frigate/<camera_name>/status/<role>`
|
||||
|
||||
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:
|
||||
Publishes the current health status of each role that is enabled (`audio`, `detect`, `record`). Possible values are:
|
||||
|
||||
- `online`: Stream is running and being processed
|
||||
- `offline`: Stream is offline and is being restarted
|
||||
|
||||
@@ -219,8 +219,6 @@ hardware:
|
||||
- host: "/run/mxa_manager"
|
||||
container: "/run/mxa_manager"
|
||||
comment: "MemryX manager"
|
||||
privileged: true
|
||||
privilegedReason: "required by MemryX to reach the max-manager"
|
||||
|
||||
- id: "axera"
|
||||
label: "AXERA Accelerator"
|
||||
|
||||
@@ -104,10 +104,6 @@ export interface DeviceConfig {
|
||||
extraHosts?: string[];
|
||||
/** Security options, e.g. ["apparmor=unconfined"] */
|
||||
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 */
|
||||
needsNvidiaConfig?: boolean;
|
||||
}
|
||||
@@ -131,10 +127,6 @@ export interface HardwareOption {
|
||||
volumes?: VolumeMapping[];
|
||||
/** Extra environment variables */
|
||||
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 */
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type {
|
||||
DeviceConfig,
|
||||
DeviceMapping,
|
||||
HardwareOption,
|
||||
VolumeMapping,
|
||||
} from "../config/types";
|
||||
import { hardwareMap } from "../config";
|
||||
@@ -195,32 +194,13 @@ function buildExtraHosts(device: DeviceConfig): string[] {
|
||||
}
|
||||
|
||||
function buildSecurityOpt(device: DeviceConfig): string[] {
|
||||
// no-new-privileges is the baseline for every setup; device-specific entries
|
||||
// are appended so only one security_opt key is ever emitted
|
||||
if (!device.securityOpt?.length) return [];
|
||||
return [
|
||||
" security_opt:",
|
||||
" - no-new-privileges:true",
|
||||
...(device.securityOpt ?? []).map((s) => ` - ${s}`),
|
||||
...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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -237,14 +217,11 @@ export function generateDockerCompose(input: GeneratorInput): string {
|
||||
const hwVolumes: VolumeMapping[] = [];
|
||||
const hwEnv: Record<string, string> = {};
|
||||
|
||||
const selectedHw: HardwareOption[] = [];
|
||||
|
||||
for (const hwId of input.selectedHardware) {
|
||||
const hw = hardwareMap.get(hwId);
|
||||
if (!hw) continue;
|
||||
// Skip GPU device mapping for tensorrt images (it uses deploy instead)
|
||||
if (hw.id === "gpu" && device.imageTag === "stable-tensorrt") continue;
|
||||
selectedHw.push(hw);
|
||||
hwDevices.push(...(hw.devices ?? []));
|
||||
hwVolumes.push(...(hw.volumes ?? []));
|
||||
Object.assign(hwEnv, hw.env ?? {});
|
||||
@@ -254,7 +231,7 @@ export function generateDockerCompose(input: GeneratorInput): string {
|
||||
"services:",
|
||||
" frigate:",
|
||||
" container_name: frigate",
|
||||
...buildPrivileged(device, selectedHw),
|
||||
" privileged: true # This may not be necessary for all setups",
|
||||
" restart: unless-stopped",
|
||||
" stop_grace_period: 30s # Allow enough time to shut down the various services",
|
||||
...buildImage(device),
|
||||
|
||||
Vendored
+3
-5
@@ -1476,12 +1476,10 @@ paths:
|
||||
- Classification
|
||||
summary: Get custom classification attributes
|
||||
description: |-
|
||||
**Access:** Any authenticated user.
|
||||
**Access:** Admin role required.
|
||||
|
||||
Returns custom classification attributes for a given object type.
|
||||
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.
|
||||
If group_by_model is true, returns attributes grouped by model name.
|
||||
operationId: get_custom_attributes_classification_attributes_get
|
||||
@@ -1512,8 +1510,8 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/HTTPValidationError'
|
||||
security:
|
||||
- frigateUserAuth: []
|
||||
x-required-role: any
|
||||
- frigateAdminAuth: []
|
||||
x-required-role: admin
|
||||
/classification/{name}/train:
|
||||
get:
|
||||
tags:
|
||||
|
||||
@@ -86,7 +86,6 @@ def require_admin_by_default():
|
||||
"/categorized_object_names",
|
||||
"/plus/models",
|
||||
"/recognized_license_plates",
|
||||
"/classification/attributes",
|
||||
"/timeline",
|
||||
"/timeline/hourly",
|
||||
"/recordings/storage",
|
||||
@@ -859,12 +858,9 @@ def login(request: Request, body: AppPostLoginBody):
|
||||
user = body.user
|
||||
password = body.password
|
||||
|
||||
remote_addr = get_remote_addr(request)
|
||||
|
||||
try:
|
||||
db_user: User = User.get_by_id(user)
|
||||
except DoesNotExist:
|
||||
logger.warning(f"Login failed for unknown user '{user}' from {remote_addr}")
|
||||
return JSONResponse(content={"message": "Login failed"}, status_code=401)
|
||||
|
||||
password_hash = db_user.password_hash
|
||||
@@ -892,10 +888,6 @@ def login(request: Request, body: AppPostLoginBody):
|
||||
request.app.frigate_config.auth.admin_first_time_login = False
|
||||
|
||||
return response
|
||||
|
||||
logger.warning(
|
||||
f"Login failed for user '{user}' (invalid password) from {remote_addr}"
|
||||
)
|
||||
return JSONResponse(content={"message": "Login failed"}, status_code=401)
|
||||
|
||||
|
||||
|
||||
@@ -11,14 +11,10 @@ from typing import Any
|
||||
import cv2
|
||||
from fastapi import APIRouter, Depends, Request, UploadFile
|
||||
from fastapi.responses import JSONResponse
|
||||
from peewee import DoesNotExist, fn
|
||||
from peewee import DoesNotExist
|
||||
from playhouse.shortcuts import model_to_dict
|
||||
|
||||
from frigate.api.auth import (
|
||||
allow_any_authenticated,
|
||||
get_allowed_cameras_for_filter,
|
||||
require_role,
|
||||
)
|
||||
from frigate.api.auth import require_role
|
||||
from frigate.api.defs.request.classification_body import (
|
||||
AudioTranscriptionBody,
|
||||
DeleteFaceImagesBody,
|
||||
@@ -743,81 +739,18 @@ 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(
|
||||
"/classification/attributes",
|
||||
dependencies=[Depends(allow_any_authenticated())],
|
||||
summary="Get custom classification attributes",
|
||||
description="""Returns custom classification attributes for a given object type.
|
||||
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.
|
||||
If group_by_model is true, returns attributes grouped by model name.""",
|
||||
)
|
||||
def get_custom_attributes(
|
||||
request: Request,
|
||||
object_type: str = None,
|
||||
group_by_model: bool = False,
|
||||
allowed_cameras: list[str] = Depends(get_allowed_cameras_for_filter),
|
||||
request: Request, object_type: str = None, group_by_model: bool = False
|
||||
):
|
||||
models_with_attributes = {}
|
||||
objects_by_model = {}
|
||||
|
||||
for (
|
||||
model_key,
|
||||
@@ -848,32 +781,6 @@ def get_custom_attributes(
|
||||
if attributes:
|
||||
model_name = model_config.name or model_key
|
||||
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:
|
||||
return JSONResponse(content=models_with_attributes)
|
||||
|
||||
@@ -86,7 +86,6 @@ from frigate.timeline import TimelineProcessor
|
||||
from frigate.track.object_processing import TrackedObjectProcessor
|
||||
from frigate.util.builtin import empty_and_close_queue
|
||||
from frigate.util.image import UntrackedSharedMemory
|
||||
from frigate.util.ownership import chown_to_runtime
|
||||
from frigate.util.process import FrigateProcess
|
||||
from frigate.util.services import set_file_limit
|
||||
from frigate.version import VERSION
|
||||
@@ -150,7 +149,6 @@ class FrigateApp:
|
||||
if not os.path.exists(d) and not os.path.islink(d):
|
||||
logger.info(f"Creating directory: {d}")
|
||||
os.makedirs(d, exist_ok=True)
|
||||
chown_to_runtime(d)
|
||||
else:
|
||||
logger.debug(f"Skipping directory: {d}")
|
||||
|
||||
|
||||
@@ -6,8 +6,6 @@ import logging
|
||||
from collections.abc import Callable, Iterable
|
||||
from typing import Any, cast
|
||||
|
||||
from peewee import IntegrityError
|
||||
|
||||
from frigate.camera import PTZMetrics
|
||||
from frigate.camera.activity_manager import AudioActivityManager, CameraActivityManager
|
||||
from frigate.comms.base_communicator import Communicator
|
||||
@@ -256,21 +254,7 @@ class Dispatcher:
|
||||
restart_frigate()
|
||||
|
||||
def handle_insert_many_recordings() -> None:
|
||||
try:
|
||||
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),
|
||||
)
|
||||
Recordings.insert_many(payload).execute()
|
||||
|
||||
def handle_request_region_grid() -> Any:
|
||||
camera = payload
|
||||
|
||||
@@ -18,10 +18,7 @@ class RecordingsDataTypeEnum(str, Enum):
|
||||
|
||||
|
||||
class RecordingsDataPublisher(Publisher[Any]):
|
||||
"""Publishes latest recording data.
|
||||
|
||||
Payloads are (camera, stream_type, timestamp, cache_path) on every topic.
|
||||
"""
|
||||
"""Publishes latest recording data."""
|
||||
|
||||
topic_base = "recordings/"
|
||||
|
||||
|
||||
@@ -220,21 +220,16 @@ class CameraConfig(FrigateBaseModel):
|
||||
|
||||
# add roles to the input if there is only one
|
||||
if len(config["ffmpeg"]["inputs"]) == 1:
|
||||
existing_roles = config["ffmpeg"]["inputs"][0].get("roles", [])
|
||||
has_audio = "audio" in config["ffmpeg"]["inputs"][0].get("roles", [])
|
||||
|
||||
config["ffmpeg"]["inputs"][0]["roles"] = [
|
||||
"record",
|
||||
"detect",
|
||||
]
|
||||
|
||||
if "audio" in existing_roles:
|
||||
if has_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)
|
||||
|
||||
@property
|
||||
|
||||
@@ -2,7 +2,7 @@ from enum import Enum
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from frigate.const import MAX_PRE_CAPTURE, STREAM_TYPE_SUB
|
||||
from frigate.const import MAX_PRE_CAPTURE
|
||||
from frigate.review.types import SeverityEnum
|
||||
|
||||
from ..base import FrigateBaseModel
|
||||
@@ -191,13 +191,6 @@ class RecordConfig(FrigateBaseModel):
|
||||
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
|
||||
def effective_alert_days(self) -> float:
|
||||
"""Alert retention extended to the sub stream window when sub is enabled.
|
||||
|
||||
@@ -269,12 +269,6 @@ 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."
|
||||
)
|
||||
|
||||
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:
|
||||
raise ValueError(
|
||||
f"Camera {camera_config.name} has audio events enabled, but audio is not assigned to an input."
|
||||
|
||||
@@ -28,9 +28,6 @@ REDACTED_CREDENTIAL_SENTINEL = "__FRIGATE_SAVED_CREDENTIAL__"
|
||||
STREAM_TYPE_MAIN = "main"
|
||||
STREAM_TYPE_SUB = "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
|
||||
|
||||
|
||||
@@ -23,7 +23,6 @@ from frigate.const import (
|
||||
ATTRIBUTE_LABEL_DISPLAY_MAP,
|
||||
CACHE_DIR,
|
||||
CLIPS_DIR,
|
||||
STREAM_TYPE_MAIN,
|
||||
UPDATE_REVIEW_DESCRIPTION,
|
||||
)
|
||||
from frigate.data_processing.types import PostProcessDataEnum
|
||||
@@ -442,7 +441,6 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
||||
)
|
||||
.where((ts >= Recordings.start_time) & (ts <= Recordings.end_time))
|
||||
.where(Recordings.camera == camera)
|
||||
.where(Recordings.stream_type == STREAM_TYPE_MAIN)
|
||||
.order_by(Recordings.start_time.desc())
|
||||
.limit(1)
|
||||
.get()
|
||||
|
||||
@@ -714,9 +714,7 @@ class EmbeddingMaintainer(threading.Thread):
|
||||
topic = str(raw_topic)
|
||||
|
||||
if topic.endswith(RecordingsDataTypeEnum.saved.value):
|
||||
camera, _stream_type, recordings_available_through_timestamp, _ = (
|
||||
payload
|
||||
)
|
||||
camera, recordings_available_through_timestamp, _ = payload
|
||||
|
||||
self.recordings_available_through[camera] = (
|
||||
recordings_available_through_timestamp
|
||||
|
||||
@@ -83,7 +83,7 @@ class BaseLocalDetector(ObjectDetector):
|
||||
raw_detections = self.detect_raw(tensor_input) # type: ignore[attr-defined]
|
||||
|
||||
for d in raw_detections:
|
||||
if int(d[0]) not in self.labels:
|
||||
if int(d[0]) < 0 or int(d[0]) >= len(self.labels):
|
||||
logger.warning(f"Raw Detect returned invalid label: {d}")
|
||||
continue
|
||||
if d[1] < threshold:
|
||||
@@ -395,9 +395,6 @@ class RemoteObjectDetector:
|
||||
self.labels = labels
|
||||
self.name = name
|
||||
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.stop_event = stop_event
|
||||
self.shm = UntrackedSharedMemory(name=self.name, create=False)
|
||||
@@ -439,21 +436,9 @@ class RemoteObjectDetector:
|
||||
for d in self.out_np_shm:
|
||||
if d[1] < threshold:
|
||||
break
|
||||
|
||||
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])))
|
||||
detections.append(
|
||||
(self.labels[int(d[0])], float(d[1]), (d[2], d[3], d[4], d[5]))
|
||||
)
|
||||
self.fps.update()
|
||||
return detections
|
||||
|
||||
|
||||
@@ -149,12 +149,8 @@ class RecordingCleanup(threading.Thread):
|
||||
detections_retain_mode: RetainModeEnum,
|
||||
config: CameraConfig,
|
||||
reviews: list[Any],
|
||||
) -> tuple[set[Path], list[tuple[float, float]]]:
|
||||
"""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.
|
||||
"""
|
||||
) -> set[Path]:
|
||||
"""Delete recordings for existing camera based on retention config."""
|
||||
# Get the timestamp for cutoff of retained days
|
||||
|
||||
# Get recordings to check for expiration
|
||||
@@ -261,23 +257,9 @@ class RecordingCleanup(threading.Thread):
|
||||
Recordings.id << deleted_recordings_list[i : i + max_deletes]
|
||||
).execute()
|
||||
|
||||
return maybe_empty_dirs, kept_recordings
|
||||
|
||||
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 follow main retention, so only the main pass expires them
|
||||
if stream_type != STREAM_TYPE_MAIN:
|
||||
return maybe_empty_dirs
|
||||
|
||||
previews = (
|
||||
Previews.select(
|
||||
@@ -456,7 +438,7 @@ class RecordingCleanup(threading.Thread):
|
||||
.namedtuples()
|
||||
)
|
||||
|
||||
main_dirs, main_kept = self.expire_existing_camera_recordings(
|
||||
maybe_empty_dirs |= self.expire_existing_camera_recordings(
|
||||
STREAM_TYPE_MAIN,
|
||||
continuous_expire_date,
|
||||
motion_expire_date,
|
||||
@@ -470,11 +452,10 @@ class RecordingCleanup(threading.Thread):
|
||||
config.record.detections.retain.days,
|
||||
),
|
||||
)
|
||||
maybe_empty_dirs |= main_dirs
|
||||
|
||||
# runs even when sub recording is disabled so old rows still
|
||||
# expire
|
||||
sub_dirs, sub_kept = self.expire_existing_camera_recordings(
|
||||
maybe_empty_dirs |= self.expire_existing_camera_recordings(
|
||||
STREAM_TYPE_SUB,
|
||||
sub_continuous_expire_date,
|
||||
sub_motion_expire_date,
|
||||
@@ -488,14 +469,6 @@ class RecordingCleanup(threading.Thread):
|
||||
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("End all cameras.")
|
||||
|
||||
@@ -79,54 +79,6 @@ def parse_cache_segment_name(basename: str) -> tuple[str, str, str] | None:
|
||||
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:
|
||||
def __init__(
|
||||
self,
|
||||
@@ -289,55 +241,51 @@ class RecordingMaintainer(threading.Thread):
|
||||
and not d.startswith("preview_")
|
||||
]
|
||||
|
||||
# publish newest cached segment per camera stream (including in use files)
|
||||
newest_cache_segments: dict[tuple[str, str], dict[str, Any]] = {}
|
||||
# publish newest cached segment per camera (including in use files)
|
||||
newest_cache_segments: dict[str, dict[str, Any]] = {}
|
||||
for cache in cache_files:
|
||||
cache_path = os.path.join(CACHE_DIR, cache)
|
||||
basename = os.path.splitext(cache)[0]
|
||||
parsed = parse_cache_segment_name(basename)
|
||||
if parsed is None:
|
||||
if not self.unexpected_cache_files_logged:
|
||||
logger.warning(f"Skipping unexpected files in cache, e.g. {cache}")
|
||||
logger.warning("Skipping unexpected files in cache")
|
||||
self.unexpected_cache_files_logged = True
|
||||
continue
|
||||
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(
|
||||
date, CACHE_SEGMENT_FORMAT
|
||||
).astimezone(datetime.UTC)
|
||||
key = (camera, stream_type)
|
||||
if (
|
||||
key not in newest_cache_segments
|
||||
or start_time > newest_cache_segments[key]["start_time"]
|
||||
camera not in newest_cache_segments
|
||||
or start_time > newest_cache_segments[camera]["start_time"]
|
||||
):
|
||||
newest_cache_segments[key] = {
|
||||
newest_cache_segments[camera] = {
|
||||
"start_time": start_time,
|
||||
"cache_path": cache_path,
|
||||
}
|
||||
|
||||
for (camera, stream_type), newest in newest_cache_segments.items():
|
||||
for camera, newest in newest_cache_segments.items():
|
||||
self.recordings_publisher.publish(
|
||||
(
|
||||
camera,
|
||||
stream_type,
|
||||
newest["start_time"].timestamp(),
|
||||
newest["cache_path"],
|
||||
),
|
||||
RecordingsDataTypeEnum.latest.value,
|
||||
)
|
||||
# publish None for streams with no cache files (but only if we know the camera exists)
|
||||
for camera_name, camera_config in self.config.cameras.items():
|
||||
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(
|
||||
(camera_name, stream_type, None, None),
|
||||
RecordingsDataTypeEnum.latest.value,
|
||||
)
|
||||
# publish None for cameras with no cache files (but only if we know the camera exists)
|
||||
for camera_name in self.config.cameras:
|
||||
if camera_name not in newest_cache_segments:
|
||||
self.recordings_publisher.publish(
|
||||
(camera_name, None, None),
|
||||
RecordingsDataTypeEnum.latest.value,
|
||||
)
|
||||
|
||||
files_in_use = []
|
||||
for process in psutil.process_iter():
|
||||
@@ -366,7 +314,7 @@ class RecordingMaintainer(threading.Thread):
|
||||
parsed = parse_cache_segment_name(basename)
|
||||
if parsed is None:
|
||||
if not self.unexpected_cache_files_logged:
|
||||
logger.warning(f"Skipping unexpected files in cache, e.g. {cache}")
|
||||
logger.warning("Skipping unexpected files in cache")
|
||||
self.unexpected_cache_files_logged = True
|
||||
continue
|
||||
camera, stream_type, date = parsed
|
||||
@@ -499,7 +447,6 @@ class RecordingMaintainer(threading.Thread):
|
||||
self.recordings_publisher.publish(
|
||||
(
|
||||
camera,
|
||||
stream_type,
|
||||
recordings[0]["start_time"].timestamp()
|
||||
if camera_cfg and camera_cfg.record.enabled
|
||||
else None,
|
||||
@@ -593,13 +540,13 @@ class RecordingMaintainer(threading.Thread):
|
||||
|
||||
if not segment_info.get("has_valid_video", False):
|
||||
logger.warning(
|
||||
f"Invalid or missing video stream in segment {cache_path} "
|
||||
f"({format_segment_details(cache_path, segment_info)}). Discarding."
|
||||
)
|
||||
self.recordings_publisher.publish(
|
||||
(camera, stream_type, start_time.timestamp(), cache_path),
|
||||
RecordingsDataTypeEnum.invalid.value,
|
||||
f"Invalid or missing video stream in segment {cache_path}. Discarding."
|
||||
)
|
||||
if stream_type == STREAM_TYPE_MAIN:
|
||||
self.recordings_publisher.publish(
|
||||
(camera, start_time.timestamp(), cache_path),
|
||||
RecordingsDataTypeEnum.invalid.value,
|
||||
)
|
||||
self.drop_segment(cache_path)
|
||||
return None
|
||||
|
||||
@@ -636,22 +583,21 @@ class RecordingMaintainer(threading.Thread):
|
||||
if duration == -1:
|
||||
logger.warning(f"Failed to probe corrupt segment {cache_path}")
|
||||
|
||||
logger.warning(
|
||||
f"Discarding a corrupt recording segment: {cache_path} "
|
||||
f"({format_segment_details(cache_path, segment_info)})"
|
||||
)
|
||||
self.recordings_publisher.publish(
|
||||
(camera, stream_type, start_time.timestamp(), cache_path),
|
||||
RecordingsDataTypeEnum.invalid.value,
|
||||
)
|
||||
logger.warning(f"Discarding a corrupt recording segment: {cache_path}")
|
||||
if stream_type == STREAM_TYPE_MAIN:
|
||||
self.recordings_publisher.publish(
|
||||
(camera, start_time.timestamp(), cache_path),
|
||||
RecordingsDataTypeEnum.invalid.value,
|
||||
)
|
||||
self.drop_segment(cache_path)
|
||||
return None
|
||||
|
||||
# this segment has a valid duration and has video data, so publish an update
|
||||
self.recordings_publisher.publish(
|
||||
(camera, stream_type, start_time.timestamp(), cache_path),
|
||||
RecordingsDataTypeEnum.valid.value,
|
||||
)
|
||||
if stream_type == STREAM_TYPE_MAIN:
|
||||
self.recordings_publisher.publish(
|
||||
(camera, start_time.timestamp(), cache_path),
|
||||
RecordingsDataTypeEnum.valid.value,
|
||||
)
|
||||
|
||||
record_config = self.config.cameras[camera].record
|
||||
|
||||
@@ -917,20 +863,18 @@ class RecordingMaintainer(threading.Thread):
|
||||
video_codec: str | None = None,
|
||||
keyframes: list[int] | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
path_time = segment_path_time(cache_path) or start_time
|
||||
|
||||
# directory will be in utc due to path_time being in utc
|
||||
# directory will be in utc due to start_time being in utc
|
||||
# sub segments get a tagged directory to avoid filename collisions
|
||||
directory = os.path.join(
|
||||
RECORD_DIR,
|
||||
path_time.strftime("%Y-%m-%d/%H"),
|
||||
start_time.strftime("%Y-%m-%d/%H"),
|
||||
camera if stream_type == STREAM_TYPE_MAIN else f"{camera}{SUB_CACHE_TAG}",
|
||||
)
|
||||
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
|
||||
# file will be in utc due to path_time being in utc
|
||||
file_name = f"{path_time.strftime('%M.%S.mp4')}"
|
||||
# file will be in utc due to start_time being in utc
|
||||
file_name = f"{start_time.strftime('%M.%S.mp4')}"
|
||||
file_path = os.path.join(directory, file_name)
|
||||
|
||||
try:
|
||||
@@ -1002,9 +946,10 @@ class RecordingMaintainer(threading.Thread):
|
||||
Recordings.video_codec.name: video_codec,
|
||||
Recordings.keyframes.name: keyframes,
|
||||
}
|
||||
except Exception:
|
||||
logger.exception(f"Unable to store recording segment {cache_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"Unable to store recording segment {cache_path}")
|
||||
Path(cache_path).unlink(missing_ok=True)
|
||||
logger.error(e)
|
||||
|
||||
# clear end_time cache
|
||||
self.end_time_cache.pop(cache_path, None)
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
"""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)
|
||||
@@ -1,189 +0,0 @@
|
||||
"""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()
|
||||
@@ -1,220 +0,0 @@
|
||||
"""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
|
||||
@@ -1229,36 +1229,6 @@ class TestConfig(unittest.TestCase):
|
||||
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):
|
||||
config = self._sub_record_config(
|
||||
{
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
"""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)
|
||||
@@ -26,26 +26,6 @@ class TestClassifyKeyframeGaps(unittest.TestCase):
|
||||
self.assertEqual(result["severity"], "warning")
|
||||
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):
|
||||
pts = [0.0, 12.0] # 12s gap > 10s segment
|
||||
result = classify_keyframe_gaps(pts, segment_time=10)
|
||||
@@ -60,7 +40,6 @@ class TestClassifyKeyframeGaps(unittest.TestCase):
|
||||
result = classify_keyframe_gaps([1.0], segment_time=10)
|
||||
self.assertEqual(result["severity"], "unknown")
|
||||
self.assertIsNone(result["max_gap"])
|
||||
self.assertIsNone(result["pattern"])
|
||||
self.assertEqual(result["keyframe_count"], 1)
|
||||
|
||||
def test_unknown_with_no_keyframes(self):
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import numpy as np
|
||||
import zmq
|
||||
from pydantic import parse_obj_as
|
||||
|
||||
import frigate.detectors as detectors
|
||||
@@ -109,13 +108,13 @@ class TestLocalObjectDetector(unittest.TestCase):
|
||||
("label-2", 0.5, (8, 7, 6, 5)),
|
||||
]
|
||||
TEST_LABEL_FILE = "/test_labels.txt"
|
||||
mock_load_labels.return_value = {
|
||||
0: "label-1",
|
||||
1: "label-2",
|
||||
2: "label-3",
|
||||
3: "label-4",
|
||||
4: "label-5",
|
||||
}
|
||||
mock_load_labels.return_value = [
|
||||
"label-1",
|
||||
"label-2",
|
||||
"label-3",
|
||||
"label-4",
|
||||
"label-5",
|
||||
]
|
||||
|
||||
test_cfg = parse_obj_as(DetectorConfig, {"type": "cpu", "model": {}})
|
||||
test_cfg.model = ModelConfig()
|
||||
@@ -137,69 +136,3 @@ class TestLocalObjectDetector(unittest.TestCase):
|
||||
== np.zeros((1, 32, 32, 3)).shape
|
||||
)
|
||||
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, [])
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
"""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()
|
||||
@@ -73,21 +73,6 @@ class TestRecordingCleanupSubRetention(unittest.TestCase):
|
||||
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):
|
||||
# main retention 7 days, sub retention 30 days; rows 10 days old
|
||||
# -> main row deleted, sub row kept
|
||||
@@ -106,60 +91,6 @@ class TestRecordingCleanupSubRetention(unittest.TestCase):
|
||||
assert Recordings.get_or_none(Recordings.id == "m1") is 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):
|
||||
# sub retention 30 days; sub row 40 days old -> deleted
|
||||
cleanup = self._build_cleanup(
|
||||
|
||||
@@ -15,7 +15,6 @@ from frigate.record.maintainer import (
|
||||
RecordingMaintainer,
|
||||
SegmentInfo,
|
||||
parse_cache_segment_name,
|
||||
segment_path_time,
|
||||
)
|
||||
|
||||
|
||||
@@ -350,98 +349,6 @@ class TestSegmentAudioPresence(unittest.IsolatedAsyncioTestCase):
|
||||
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):
|
||||
"""Contiguous segments must chain start times across filename truncation.
|
||||
|
||||
|
||||
+5
-24
@@ -20,12 +20,7 @@ from typing import TYPE_CHECKING, Any
|
||||
import numpy as np
|
||||
from ruamel.yaml import YAML
|
||||
|
||||
from frigate.const import (
|
||||
REGEX_HTTP_CAMERA_USER_PASS,
|
||||
REGEX_RTSP_CAMERA_USER_PASS,
|
||||
STREAM_TYPE_MAIN,
|
||||
STREAM_TYPE_SUB,
|
||||
)
|
||||
from frigate.const import REGEX_HTTP_CAMERA_USER_PASS, REGEX_RTSP_CAMERA_USER_PASS
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from frigate.config import CameraConfig
|
||||
@@ -142,16 +137,9 @@ def get_ffmpeg_arg_list(arg: Any) -> list:
|
||||
DEFAULT_RECORD_SEGMENT_TIME = 10
|
||||
|
||||
|
||||
def get_record_segment_time(
|
||||
config: "CameraConfig", stream_type: str = STREAM_TYPE_MAIN
|
||||
) -> 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)
|
||||
def get_record_segment_time(config: "CameraConfig") -> int:
|
||||
"""Extract -segment_time from the camera's record output args."""
|
||||
record_args = get_ffmpeg_arg_list(config.ffmpeg.output_args.record)
|
||||
|
||||
if record_args and record_args[0].startswith("preset"):
|
||||
return DEFAULT_RECORD_SEGMENT_TIME
|
||||
@@ -164,19 +152,12 @@ def get_record_segment_time(
|
||||
|
||||
|
||||
def load_labels(
|
||||
path: str | None, encoding="utf-8", prefill=0, indexed: bool | None = None
|
||||
path: str | None, encoding="utf-8", prefill=91, indexed: bool | None = None
|
||||
):
|
||||
"""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:
|
||||
path: path to label file.
|
||||
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:
|
||||
Dictionary mapping indices to labels.
|
||||
"""
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
"""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}")
|
||||
@@ -1061,7 +1061,6 @@ def ffprobe_stream(ffmpeg, path: str, detailed: bool = False) -> sp.CompletedPro
|
||||
|
||||
KEYFRAME_PROBE_WINDOW_SECONDS = 20
|
||||
KEYFRAME_GAP_WARNING_SECONDS = 4.0
|
||||
KEYFRAME_GAP_JITTER_SECONDS = 0.5
|
||||
|
||||
|
||||
def parse_keyframe_packets(output: str) -> tuple[list[float], float | None]:
|
||||
@@ -1101,10 +1100,6 @@ def classify_keyframe_gaps(
|
||||
- "error" when the longest gap exceeds the record segment length
|
||||
- "warning" when the longest gap exceeds the warning threshold
|
||||
- "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 = {
|
||||
"warning": KEYFRAME_GAP_WARNING_SECONDS,
|
||||
@@ -1117,7 +1112,6 @@ def classify_keyframe_gaps(
|
||||
"max_gap": None,
|
||||
"mean_gap": None,
|
||||
"min_gap": None,
|
||||
"pattern": None,
|
||||
"segment_time": segment_time,
|
||||
"severity": "unknown",
|
||||
"thresholds": thresholds,
|
||||
@@ -1125,7 +1119,6 @@ def classify_keyframe_gaps(
|
||||
|
||||
gaps = [b - a for a, b in zip(keyframe_pts, keyframe_pts[1:])]
|
||||
max_gap = max(gaps)
|
||||
min_gap = min(gaps)
|
||||
|
||||
if max_gap > segment_time:
|
||||
severity = "error"
|
||||
@@ -1134,16 +1127,11 @@ def classify_keyframe_gaps(
|
||||
else:
|
||||
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 {
|
||||
"keyframe_count": len(keyframe_pts),
|
||||
"max_gap": round(max_gap, 2),
|
||||
"mean_gap": round(sum(gaps) / len(gaps), 2),
|
||||
"min_gap": round(min_gap, 2),
|
||||
"pattern": pattern,
|
||||
"min_gap": round(min(gaps), 2),
|
||||
"segment_time": segment_time,
|
||||
"severity": severity,
|
||||
"thresholds": thresholds,
|
||||
|
||||
+93
-163
@@ -5,7 +5,7 @@ import queue
|
||||
import subprocess as sp
|
||||
import threading
|
||||
import time
|
||||
from collections import defaultdict, deque
|
||||
from collections import deque
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from multiprocessing import Queue, Value
|
||||
from multiprocessing.synchronize import Event as MpEvent
|
||||
@@ -22,14 +22,7 @@ from frigate.config.camera.updater import (
|
||||
CameraConfigUpdateEnum,
|
||||
CameraConfigUpdateSubscriber,
|
||||
)
|
||||
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.const import PROCESS_PRIORITY_HIGH
|
||||
from frigate.log import LogPipe
|
||||
from frigate.util.builtin import EventsPerSecond, get_record_segment_time
|
||||
from frigate.util.ffmpeg import start_or_restart_ffmpeg, stop_ffmpeg
|
||||
@@ -41,8 +34,6 @@ from frigate.util.process import FrigateProcess
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
RECORD_GRACE_SECONDS = 90
|
||||
|
||||
|
||||
def capture_frames(
|
||||
ffmpeg_process: sp.Popen[Any],
|
||||
@@ -159,26 +150,16 @@ class CameraWatchdog(threading.Thread):
|
||||
self.was_record_sub_enabled = self.config.record.sub.enabled
|
||||
|
||||
self.segment_subscriber = RecordingsDataSubscriber(RecordingsDataTypeEnum.all)
|
||||
self.latest_valid_segment_time: dict[str, float] = defaultdict(float)
|
||||
self.latest_invalid_segment_time: dict[str, float] = defaultdict(float)
|
||||
self.latest_cache_segment_time: dict[str, float] = defaultdict(float)
|
||||
self.latest_valid_segment_time: float = 0
|
||||
self.latest_invalid_segment_time: float = 0
|
||||
self.latest_cache_segment_time: float = 0
|
||||
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
|
||||
# gap between consecutive publishes can reach 2 * segment_time. Pad the
|
||||
# staleness threshold so it's never tighter than that worst case.
|
||||
self.record_stale_threshold: dict[str, int] = {
|
||||
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
|
||||
segment_time = get_record_segment_time(self.config)
|
||||
self.record_stale_threshold = max(120, 2 * segment_time + 30)
|
||||
|
||||
# Stall tracking (based on last processed frame)
|
||||
self._stall_timestamps: deque[float] = deque()
|
||||
@@ -186,7 +167,7 @@ class CameraWatchdog(threading.Thread):
|
||||
|
||||
# Status caching to reduce message volume
|
||||
self._last_detect_status: str | None = None
|
||||
self._last_record_status: dict[str, str] = {}
|
||||
self._last_record_status: str | None = None
|
||||
self._last_status_update_time: float = 0.0
|
||||
|
||||
def _send_detect_status(self, status: str, now: float) -> None:
|
||||
@@ -199,78 +180,16 @@ class CameraWatchdog(threading.Thread):
|
||||
self._last_detect_status = status
|
||||
self._last_status_update_time = now
|
||||
|
||||
def _send_record_status(self, stream_type: str, status: str, now: float) -> None:
|
||||
"""Send a record stream's status only if changed or retry_interval has elapsed."""
|
||||
def _send_record_status(self, status: str, now: float) -> None:
|
||||
"""Send record status only if changed or retry_interval has elapsed."""
|
||||
if (
|
||||
status != self._last_record_status.get(stream_type)
|
||||
status != self._last_record_status
|
||||
or (now - self._last_status_update_time) >= self.sleeptime
|
||||
):
|
||||
self.requestor.send_data(
|
||||
f"{self.config.name}/status/{STREAM_TYPE_TO_ROLE[stream_type]}", status
|
||||
)
|
||||
self._last_record_status[stream_type] = status
|
||||
self.requestor.send_data(f"{self.config.name}/status/record", status)
|
||||
self._last_record_status = status
|
||||
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]]:
|
||||
"""Check for config updates and return the update dict."""
|
||||
return self.config_subscriber.check_for_updates()
|
||||
@@ -326,11 +245,6 @@ class CameraWatchdog(threading.Thread):
|
||||
self.logger.info("Restarting ffmpeg...")
|
||||
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:
|
||||
if self._update_enabled_state():
|
||||
self.start_all_ffmpeg()
|
||||
@@ -353,7 +267,9 @@ class CameraWatchdog(threading.Thread):
|
||||
)
|
||||
self.stop_all_ffmpeg()
|
||||
self.start_all_ffmpeg()
|
||||
self._reset_segment_times()
|
||||
self.latest_valid_segment_time = 0
|
||||
self.latest_invalid_segment_time = 0
|
||||
self.latest_cache_segment_time = 0
|
||||
self.record_enable_time = datetime.now().astimezone(UTC)
|
||||
last_restart_time = datetime.now().timestamp()
|
||||
continue
|
||||
@@ -365,7 +281,9 @@ class CameraWatchdog(threading.Thread):
|
||||
self.start_all_ffmpeg()
|
||||
|
||||
# reset all timestamps and record the enable time for grace period
|
||||
self._reset_segment_times()
|
||||
self.latest_valid_segment_time = 0
|
||||
self.latest_invalid_segment_time = 0
|
||||
self.latest_cache_segment_time = 0
|
||||
self.record_enable_time = datetime.now().astimezone(UTC)
|
||||
else:
|
||||
self.logger.debug(f"Disabling camera {self.config.name}")
|
||||
@@ -375,10 +293,7 @@ class CameraWatchdog(threading.Thread):
|
||||
# update camera status
|
||||
now = datetime.now().timestamp()
|
||||
self._send_detect_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._send_record_status("disabled", now)
|
||||
self.was_enabled = enabled
|
||||
continue
|
||||
|
||||
@@ -390,7 +305,9 @@ class CameraWatchdog(threading.Thread):
|
||||
)
|
||||
self.stop_all_ffmpeg()
|
||||
self.start_all_ffmpeg()
|
||||
self._reset_segment_times()
|
||||
self.latest_valid_segment_time = 0
|
||||
self.latest_invalid_segment_time = 0
|
||||
self.latest_cache_segment_time = 0
|
||||
self.record_enable_time = datetime.now().astimezone(UTC)
|
||||
last_restart_time = datetime.now().timestamp()
|
||||
self.was_record_enabled_in_config = record_enabled_in_config
|
||||
@@ -406,7 +323,9 @@ class CameraWatchdog(threading.Thread):
|
||||
)
|
||||
self.stop_all_ffmpeg()
|
||||
self.start_all_ffmpeg()
|
||||
self._reset_segment_times()
|
||||
self.latest_valid_segment_time = 0
|
||||
self.latest_invalid_segment_time = 0
|
||||
self.latest_cache_segment_time = 0
|
||||
self.record_enable_time = datetime.now().astimezone(UTC)
|
||||
last_restart_time = datetime.now().timestamp()
|
||||
self.was_record_sub_enabled = record_sub_enabled
|
||||
@@ -424,25 +343,26 @@ class CameraWatchdog(threading.Thread):
|
||||
raw_topic, payload = update
|
||||
if raw_topic and payload:
|
||||
topic = str(raw_topic)
|
||||
camera, stream_type, segment_time, _ = payload
|
||||
camera, segment_time, _ = payload
|
||||
|
||||
if camera != self.config.name:
|
||||
continue
|
||||
|
||||
if topic.endswith(RecordingsDataTypeEnum.invalid.value):
|
||||
self.logger.warning(
|
||||
f"Invalid recording segment detected for {camera} ({stream_type}) at {segment_time}"
|
||||
f"Invalid recording segment detected for {camera} at {segment_time}"
|
||||
)
|
||||
self.latest_invalid_segment_time[stream_type] = segment_time
|
||||
self.latest_invalid_segment_time = segment_time
|
||||
elif topic.endswith(RecordingsDataTypeEnum.valid.value):
|
||||
self.logger.debug(
|
||||
f"Latest valid recording segment time on {camera} ({stream_type}): {segment_time}"
|
||||
f"Latest valid recording segment time on {camera}: {segment_time}"
|
||||
)
|
||||
self.latest_valid_segment_time[stream_type] = segment_time
|
||||
self.latest_valid_segment_time = segment_time
|
||||
elif topic.endswith(RecordingsDataTypeEnum.latest.value):
|
||||
self.latest_cache_segment_time[stream_type] = (
|
||||
segment_time if segment_time is not None else 0
|
||||
)
|
||||
if segment_time is not None:
|
||||
self.latest_cache_segment_time = segment_time
|
||||
else:
|
||||
self.latest_cache_segment_time = 0
|
||||
|
||||
now = datetime.now().timestamp()
|
||||
|
||||
@@ -489,26 +409,63 @@ class CameraWatchdog(threading.Thread):
|
||||
for p in self.ffmpeg_other_processes:
|
||||
poll = p["process"].poll()
|
||||
|
||||
recorded_streams = self._recorded_streams(p["roles"])
|
||||
|
||||
if recorded_streams:
|
||||
if self.config.record.enabled and "record" in p["roles"]:
|
||||
now_utc = datetime.now().astimezone(UTC)
|
||||
|
||||
# ensure segments are still being created and that they have
|
||||
# valid video data. each stream is tracked separately so a
|
||||
# healthy one can't mask a stalled one.
|
||||
stale_stream = None
|
||||
stale_reason = None
|
||||
for stream_type in recorded_streams:
|
||||
stale_reason = self._stream_staleness(stream_type, now_utc)
|
||||
# Check if we're within the grace period after enabling recording
|
||||
# Grace period: 90 seconds allows time for ffmpeg to start and create first segment
|
||||
in_grace_period = self.record_enable_time is not None and (
|
||||
now_utc - self.record_enable_time
|
||||
) < timedelta(seconds=90)
|
||||
|
||||
if stale_reason is not None:
|
||||
stale_stream = stream_type
|
||||
break
|
||||
latest_cache_dt = (
|
||||
datetime.fromtimestamp(self.latest_cache_segment_time, tz=UTC)
|
||||
if self.latest_cache_segment_time > 0
|
||||
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(
|
||||
f"{stale_reason} for {self.config.name} ({stale_stream}) in the last {self.record_stale_threshold[stale_stream]}s. Restarting the ffmpeg record process..."
|
||||
f"{reason} for {self.config.name} in the last {self.record_stale_threshold}s. Restarting the ffmpeg record process..."
|
||||
)
|
||||
p["process"] = start_or_restart_ffmpeg(
|
||||
p["cmd"],
|
||||
@@ -522,18 +479,10 @@ class CameraWatchdog(threading.Thread):
|
||||
f"{self.config.name}/status/{role.value}", "offline"
|
||||
)
|
||||
|
||||
self._grant_restart_grace(recorded_streams, now_utc)
|
||||
last_restart_time = now
|
||||
|
||||
continue
|
||||
elif stale_stream is None:
|
||||
for stream_type in recorded_streams:
|
||||
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
|
||||
)
|
||||
else:
|
||||
self._send_record_status("online", now)
|
||||
p["latest_segment_time"] = self.latest_cache_segment_time
|
||||
|
||||
if poll is None:
|
||||
continue
|
||||
@@ -548,25 +497,6 @@ class CameraWatchdog(threading.Thread):
|
||||
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
|
||||
now = datetime.now().timestamp()
|
||||
while (
|
||||
@@ -609,9 +539,9 @@ class CameraWatchdog(threading.Thread):
|
||||
self.segment_subscriber.stop()
|
||||
|
||||
def start_ffmpeg_detect(self):
|
||||
detect_cmd = [c for c in self.config.ffmpeg_cmds if "detect" in c["roles"]][0]
|
||||
ffmpeg_cmd = detect_cmd["cmd"]
|
||||
self.detect_process_records_sub = "record_sub" in detect_cmd["roles"]
|
||||
ffmpeg_cmd = [
|
||||
c["cmd"] for c in self.config.ffmpeg_cmds if "detect" in c["roles"]
|
||||
][0]
|
||||
self.ffmpeg_detect_process = start_or_restart_ffmpeg(
|
||||
ffmpeg_cmd, self.logger, self.logpipe, self.frame_size
|
||||
)
|
||||
|
||||
@@ -1519,13 +1519,8 @@
|
||||
"keyPatternError": "Use only letters, numbers, hyphens, and underscores (no spaces)"
|
||||
},
|
||||
"knownPlates": {
|
||||
"assignedTo": "Assigned to {{name}}",
|
||||
"detected": "Detected plates",
|
||||
"namePlaceholder": "e.g., Wife's Car",
|
||||
"noneDetected": "No plates detected yet",
|
||||
"platePlaceholder": "Plate number or regex",
|
||||
"search": "Search or enter a plate",
|
||||
"useCustom": "Use \"{{value}}\""
|
||||
"platePlaceholder": "Plate number or regex"
|
||||
},
|
||||
"liveStreams": {
|
||||
"streamNameLabel": "Stream name",
|
||||
|
||||
@@ -184,10 +184,8 @@
|
||||
"gap": "Keyframe gap (min / avg / max):",
|
||||
"segmentLength": "Recording segment length:",
|
||||
"ok": "Keyframes every ~{{seconds}}s, good for recording and playback.",
|
||||
"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.",
|
||||
"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.",
|
||||
"warning": "Sparse or variable keyframes (longest gap ~{{seconds}}s), likely a smart codec (H.264+/H.265+), this is not recommended.",
|
||||
"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.",
|
||||
"unknown": "Couldn't determine keyframe spacing.",
|
||||
"recordDisabled": "Recording is disabled for this camera."
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { FieldPathList, FieldProps, RJSFSchema } from "@rjsf/utils";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useSWR from "swr";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
@@ -9,24 +8,11 @@ import {
|
||||
} from "@/components/ui/collapsible";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
LuCheck,
|
||||
LuChevronDown,
|
||||
LuChevronRight,
|
||||
LuChevronsUpDown,
|
||||
LuPlus,
|
||||
LuTrash2,
|
||||
} from "react-icons/lu";
|
||||
@@ -37,183 +23,6 @@ import { MapKeyInput } from "../components";
|
||||
|
||||
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) {
|
||||
const { schema, formData, onChange, idSchema, disabled, readonly } = props;
|
||||
const formContext = props.registry?.formContext as
|
||||
@@ -278,35 +87,6 @@ export function KnownPlatesField(props: FieldProps) {
|
||||
}
|
||||
}, [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 next = { ...data, "": [""] };
|
||||
onChange(next, fieldPath);
|
||||
@@ -340,9 +120,8 @@ export function KnownPlatesField(props: FieldProps) {
|
||||
|
||||
const handleAddPlate = useCallback(
|
||||
(key: string) => {
|
||||
const plates = [...(data[key] || []), ""];
|
||||
onChange({ ...data, [key]: plates }, fieldPath);
|
||||
setPendingOpenPlate(`${key}::${plates.length - 1}`);
|
||||
const next = { ...data, [key]: [...(data[key] || []), ""] };
|
||||
onChange(next, fieldPath);
|
||||
},
|
||||
[data, fieldPath, onChange],
|
||||
);
|
||||
@@ -375,6 +154,9 @@ export function KnownPlatesField(props: FieldProps) {
|
||||
const namePlaceholder = t("configForm.knownPlates.namePlaceholder", {
|
||||
ns: "views/settings",
|
||||
});
|
||||
const platePlaceholder = t("configForm.knownPlates.platePlaceholder", {
|
||||
ns: "views/settings",
|
||||
});
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<Collapsible open={open} onOpenChange={setOpen}>
|
||||
@@ -442,20 +224,15 @@ export function KnownPlatesField(props: FieldProps) {
|
||||
<div className="ml-1 space-y-2 border-l-2 border-muted-foreground/20 pl-3">
|
||||
{plates.map((plate, plateIndex) => (
|
||||
<div key={plateIndex} className="flex items-center gap-2">
|
||||
<PlateCombobox
|
||||
<Input
|
||||
id={`${entryId}-plate-${plateIndex}`}
|
||||
value={plate}
|
||||
entryName={key}
|
||||
placeholder={platePlaceholder}
|
||||
disabled={disabled || readonly}
|
||||
detectedPlates={detectedPlates}
|
||||
plateAssignments={plateAssignments}
|
||||
autoOpen={
|
||||
pendingOpenPlate === `${key}::${plateIndex}`
|
||||
}
|
||||
onAutoOpened={clearPendingOpenPlate}
|
||||
onCommit={(next) =>
|
||||
handleUpdatePlate(key, plateIndex, next)
|
||||
onChange={(e) =>
|
||||
handleUpdatePlate(key, plateIndex, e.target.value)
|
||||
}
|
||||
className="flex-1"
|
||||
/>
|
||||
{plates.length > 1 && (
|
||||
<Button
|
||||
|
||||
@@ -89,29 +89,17 @@ export default function KeyframeAnalysisSection({
|
||||
case "warning":
|
||||
summary = (
|
||||
<Row icon="warning">
|
||||
{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,
|
||||
})}
|
||||
{t("cameras.info.keyframes.warning", { seconds: analysis.max_gap })}
|
||||
</Row>
|
||||
);
|
||||
break;
|
||||
case "error":
|
||||
summary = (
|
||||
<Row icon="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,
|
||||
segmentTime: analysis.segment_time,
|
||||
})}
|
||||
{t("cameras.info.keyframes.error", {
|
||||
seconds: analysis.max_gap,
|
||||
segmentTime: analysis.segment_time,
|
||||
})}
|
||||
</Row>
|
||||
);
|
||||
break;
|
||||
|
||||
@@ -161,8 +161,6 @@ export type KeyframeSeverity =
|
||||
| "unknown"
|
||||
| "record_disabled";
|
||||
|
||||
export type KeyframeGapPattern = "fixed" | "variable";
|
||||
|
||||
export type KeyframeAnalysis = {
|
||||
severity: KeyframeSeverity;
|
||||
stream_index?: number;
|
||||
@@ -170,7 +168,6 @@ export type KeyframeAnalysis = {
|
||||
max_gap?: number | null;
|
||||
mean_gap?: number | null;
|
||||
min_gap?: number | null;
|
||||
pattern?: KeyframeGapPattern | null;
|
||||
duration_observed?: number | null;
|
||||
segment_time?: number;
|
||||
thresholds?: { warning: number; error: number };
|
||||
|
||||
Reference in New Issue
Block a user