mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-11-13 17:13:01 +03:00
Compare commits
66
Commits
8d4e6b52d1
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb04e6d49d | ||
|
|
b2f5315e09 | ||
|
|
a655ddcb2e | ||
|
|
806ee66562 | ||
|
|
42f5a7560b | ||
|
|
21836c7444 | ||
|
|
e70eba2fb8 | ||
|
|
e0b5a6c965 | ||
|
|
6733a757e8 | ||
|
|
94e700fbac | ||
|
|
5f59f7c53c | ||
|
|
0e2da4e24a | ||
|
|
dade7a6b3e | ||
|
|
329430760e | ||
|
|
2fbb66e475 | ||
|
|
5019984a33 | ||
|
|
db58f9f5cc | ||
|
|
f83b8b57db | ||
|
|
146d61017e | ||
|
|
86c9b4bf74 | ||
|
|
ed1a515c1b | ||
|
|
c013f6dcb8 | ||
|
|
bb1736bb4d | ||
|
|
68988780cd | ||
|
|
c148063e76 | ||
|
|
37ca717f63 | ||
|
|
b6d16fb6f3 | ||
|
|
e45b7de79b | ||
|
|
a1f8f95497 | ||
|
|
cabbfd5d73 | ||
|
|
20a3bd7ac5 | ||
|
|
e287833e8d | ||
|
|
e150c5d1dd | ||
|
|
60c4e37303 | ||
|
|
3ac35fe777 | ||
|
|
8b0325aefb | ||
|
|
c57b48e977 | ||
|
|
74a2aac866 | ||
|
|
2af6dc34da | ||
|
|
9eb336a3d8 | ||
|
|
bb0e2c0d5e | ||
|
|
06d1365056 | ||
|
|
8a88cefc9a | ||
|
|
fba967c74b | ||
|
|
33c55649a4 | ||
|
|
c88434dc29 | ||
|
|
e7256d58a1 | ||
|
|
b2a7e992c9 | ||
|
|
11f4dd194c | ||
|
|
0e7da1bee4 | ||
|
|
14870f5237 | ||
|
|
ca851a9238 | ||
|
|
81cf7be89a | ||
|
|
52d469ed20 | ||
|
|
a9105e12f1 | ||
|
|
d4ebe64fde | ||
|
|
b55723146d | ||
|
|
d9ce4b061b | ||
|
|
62d775672b | ||
|
|
d7c6e83ae3 | ||
|
|
74ca38dfb9 | ||
|
|
c6b0f4de9f | ||
|
|
98079cbeca | ||
|
|
197e740be7 | ||
|
|
6716cd3d6f | ||
|
|
3eee34ad7f |
@@ -4,6 +4,8 @@ inputs:
|
|||||||
GITHUB_TOKEN:
|
GITHUB_TOKEN:
|
||||||
required: true
|
required: true
|
||||||
outputs:
|
outputs:
|
||||||
|
image-repo:
|
||||||
|
value: ghcr.io/${{ steps.lowercaseRepo.outputs.lowercase }}
|
||||||
image-name:
|
image-name:
|
||||||
value: ghcr.io/${{ steps.lowercaseRepo.outputs.lowercase }}:${{ steps.create-short-sha.outputs.SHORT_SHA }}
|
value: ghcr.io/${{ steps.lowercaseRepo.outputs.lowercase }}:${{ steps.create-short-sha.outputs.SHORT_SHA }}
|
||||||
cache-name:
|
cache-name:
|
||||||
@@ -42,7 +44,7 @@ runs:
|
|||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v4
|
uses: docker/setup-buildx-action@v4
|
||||||
- name: Log in to the Container registry
|
- name: Log in to the Container registry
|
||||||
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6
|
uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
|
|||||||
@@ -136,9 +136,6 @@ updates:
|
|||||||
# Later versions need Node 22 and extract false keys
|
# Later versions need Node 22 and extract false keys
|
||||||
- dependency-name: "i18next-cli"
|
- dependency-name: "i18next-cli"
|
||||||
versions: [">1.5.11"]
|
versions: [">1.5.11"]
|
||||||
# 6.11.0 refills the saved model in the GenAI settings form
|
|
||||||
- dependency-name: "@rjsf/*"
|
|
||||||
versions: ["6.11.0"]
|
|
||||||
|
|
||||||
- package-ecosystem: "npm"
|
- package-ecosystem: "npm"
|
||||||
directory: "/docs"
|
directory: "/docs"
|
||||||
|
|||||||
@@ -423,6 +423,11 @@ jobs:
|
|||||||
run: docker rm -f frigate || true
|
run: docker rm -f frigate || true
|
||||||
arm64_build:
|
arm64_build:
|
||||||
runs-on: ubuntu-22.04-arm
|
runs-on: ubuntu-22.04-arm
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
attestations: write
|
||||||
|
id-token: write
|
||||||
name: ARM Build
|
name: ARM Build
|
||||||
steps:
|
steps:
|
||||||
- name: Check out code
|
- name: Check out code
|
||||||
@@ -435,6 +440,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Build and push arm64 standard build
|
- name: Build and push arm64 standard build
|
||||||
|
id: standard-arm64
|
||||||
uses: docker/build-push-action@v7
|
uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
@@ -445,6 +451,13 @@ jobs:
|
|||||||
tags: |
|
tags: |
|
||||||
${{ steps.setup.outputs.image-name }}-standard-arm64
|
${{ steps.setup.outputs.image-name }}-standard-arm64
|
||||||
cache-from: type=registry,ref=${{ steps.setup.outputs.cache-name }}-arm64
|
cache-from: type=registry,ref=${{ steps.setup.outputs.cache-name }}-arm64
|
||||||
|
- name: Attest arm64 standard build
|
||||||
|
uses: actions/attest@v4
|
||||||
|
with:
|
||||||
|
subject-name: ${{ steps.setup.outputs.image-repo }}
|
||||||
|
subject-digest: ${{ steps.standard-arm64.outputs.digest }}
|
||||||
|
push-to-registry: true
|
||||||
|
create-storage-record: false
|
||||||
- name: Build and push RPi build
|
- name: Build and push RPi build
|
||||||
uses: docker/bake-action@v7
|
uses: docker/bake-action@v7
|
||||||
with:
|
with:
|
||||||
@@ -458,6 +471,11 @@ jobs:
|
|||||||
*.cache-to=type=registry,ref=${{ steps.setup.outputs.cache-name }}-arm64,mode=max
|
*.cache-to=type=registry,ref=${{ steps.setup.outputs.cache-name }}-arm64,mode=max
|
||||||
jetson_jp6_build:
|
jetson_jp6_build:
|
||||||
runs-on: ubuntu-22.04-arm
|
runs-on: ubuntu-22.04-arm
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
attestations: write
|
||||||
|
id-token: write
|
||||||
name: Jetson Jetpack 6
|
name: Jetson Jetpack 6
|
||||||
steps:
|
steps:
|
||||||
- name: Check out code
|
- name: Check out code
|
||||||
@@ -470,6 +488,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Build and push TensorRT (Jetson, Jetpack 6)
|
- name: Build and push TensorRT (Jetson, Jetpack 6)
|
||||||
|
id: tensorrt-jp6
|
||||||
env:
|
env:
|
||||||
ARCH: arm64
|
ARCH: arm64
|
||||||
BASE_IMAGE: nvcr.io/nvidia/tensorrt:23.12-py3-igpu
|
BASE_IMAGE: nvcr.io/nvidia/tensorrt:23.12-py3-igpu
|
||||||
@@ -485,8 +504,20 @@ jobs:
|
|||||||
tensorrt.tags=${{ steps.setup.outputs.image-name }}-tensorrt-jp6
|
tensorrt.tags=${{ steps.setup.outputs.image-name }}-tensorrt-jp6
|
||||||
*.cache-from=type=registry,ref=${{ steps.setup.outputs.cache-name }}-jp6
|
*.cache-from=type=registry,ref=${{ steps.setup.outputs.cache-name }}-jp6
|
||||||
*.cache-to=type=registry,ref=${{ steps.setup.outputs.cache-name }}-jp6,mode=max
|
*.cache-to=type=registry,ref=${{ steps.setup.outputs.cache-name }}-jp6,mode=max
|
||||||
|
- name: Attest TensorRT (Jetson, Jetpack 6)
|
||||||
|
uses: actions/attest@v4
|
||||||
|
with:
|
||||||
|
subject-name: ${{ steps.setup.outputs.image-repo }}
|
||||||
|
subject-digest: ${{ fromJSON(steps.tensorrt-jp6.outputs.metadata)['tensorrt']['containerimage.digest'] }}
|
||||||
|
push-to-registry: true
|
||||||
|
create-storage-record: false
|
||||||
amd64_extra_builds:
|
amd64_extra_builds:
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
attestations: write
|
||||||
|
id-token: write
|
||||||
name: AMD64 Extra Build
|
name: AMD64 Extra Build
|
||||||
needs:
|
needs:
|
||||||
- amd64_build
|
- amd64_build
|
||||||
@@ -501,6 +532,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Build and push TensorRT (x86 GPU)
|
- name: Build and push TensorRT (x86 GPU)
|
||||||
|
id: tensorrt
|
||||||
env:
|
env:
|
||||||
COMPUTE_LEVEL: "50 60 70 80 90"
|
COMPUTE_LEVEL: "50 60 70 80 90"
|
||||||
uses: docker/bake-action@v7
|
uses: docker/bake-action@v7
|
||||||
@@ -513,7 +545,15 @@ jobs:
|
|||||||
tensorrt.tags=${{ steps.setup.outputs.image-name }}-tensorrt
|
tensorrt.tags=${{ steps.setup.outputs.image-name }}-tensorrt
|
||||||
*.cache-from=type=registry,ref=${{ steps.setup.outputs.cache-name }}-tensorrt
|
*.cache-from=type=registry,ref=${{ steps.setup.outputs.cache-name }}-tensorrt
|
||||||
*.cache-to=type=registry,ref=${{ steps.setup.outputs.cache-name }}-tensorrt,mode=max
|
*.cache-to=type=registry,ref=${{ steps.setup.outputs.cache-name }}-tensorrt,mode=max
|
||||||
|
- name: Attest TensorRT (x86 GPU)
|
||||||
|
uses: actions/attest@v4
|
||||||
|
with:
|
||||||
|
subject-name: ${{ steps.setup.outputs.image-repo }}
|
||||||
|
subject-digest: ${{ fromJSON(steps.tensorrt.outputs.metadata)['tensorrt']['containerimage.digest'] }}
|
||||||
|
push-to-registry: true
|
||||||
|
create-storage-record: false
|
||||||
- name: AMD/ROCm general build
|
- name: AMD/ROCm general build
|
||||||
|
id: rocm
|
||||||
env:
|
env:
|
||||||
HSA_OVERRIDE: 0
|
HSA_OVERRIDE: 0
|
||||||
uses: docker/bake-action@v7
|
uses: docker/bake-action@v7
|
||||||
@@ -526,8 +566,20 @@ jobs:
|
|||||||
rocm.tags=${{ steps.setup.outputs.image-name }}-rocm
|
rocm.tags=${{ steps.setup.outputs.image-name }}-rocm
|
||||||
*.cache-to=type=registry,ref=${{ steps.setup.outputs.cache-name }}-rocm,mode=max
|
*.cache-to=type=registry,ref=${{ steps.setup.outputs.cache-name }}-rocm,mode=max
|
||||||
*.cache-from=type=registry,ref=${{ steps.setup.outputs.cache-name }}-rocm
|
*.cache-from=type=registry,ref=${{ steps.setup.outputs.cache-name }}-rocm
|
||||||
|
- name: Attest AMD/ROCm general build
|
||||||
|
uses: actions/attest@v4
|
||||||
|
with:
|
||||||
|
subject-name: ${{ steps.setup.outputs.image-repo }}
|
||||||
|
subject-digest: ${{ fromJSON(steps.rocm.outputs.metadata)['rocm']['containerimage.digest'] }}
|
||||||
|
push-to-registry: true
|
||||||
|
create-storage-record: false
|
||||||
arm64_extra_builds:
|
arm64_extra_builds:
|
||||||
runs-on: ubuntu-22.04-arm
|
runs-on: ubuntu-22.04-arm
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
attestations: write
|
||||||
|
id-token: write
|
||||||
name: ARM Extra Build
|
name: ARM Extra Build
|
||||||
needs:
|
needs:
|
||||||
- arm64_build
|
- arm64_build
|
||||||
@@ -542,6 +594,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Build and push Rockchip build
|
- name: Build and push Rockchip build
|
||||||
|
id: rk
|
||||||
uses: docker/bake-action@v7
|
uses: docker/bake-action@v7
|
||||||
with:
|
with:
|
||||||
source: .
|
source: .
|
||||||
@@ -551,8 +604,20 @@ jobs:
|
|||||||
set: |
|
set: |
|
||||||
rk.tags=${{ steps.setup.outputs.image-name }}-rk
|
rk.tags=${{ steps.setup.outputs.image-name }}-rk
|
||||||
*.cache-from=type=gha
|
*.cache-from=type=gha
|
||||||
|
- name: Attest Rockchip build
|
||||||
|
uses: actions/attest@v4
|
||||||
|
with:
|
||||||
|
subject-name: ${{ steps.setup.outputs.image-repo }}
|
||||||
|
subject-digest: ${{ fromJSON(steps.rk.outputs.metadata)['rk']['containerimage.digest'] }}
|
||||||
|
push-to-registry: true
|
||||||
|
create-storage-record: false
|
||||||
synaptics_build:
|
synaptics_build:
|
||||||
runs-on: ubuntu-22.04-arm
|
runs-on: ubuntu-22.04-arm
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
attestations: write
|
||||||
|
id-token: write
|
||||||
name: Synaptics Build
|
name: Synaptics Build
|
||||||
needs:
|
needs:
|
||||||
- arm64_build
|
- arm64_build
|
||||||
@@ -567,6 +632,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Build and push Synaptics build
|
- name: Build and push Synaptics build
|
||||||
|
id: synaptics
|
||||||
uses: docker/bake-action@v7
|
uses: docker/bake-action@v7
|
||||||
with:
|
with:
|
||||||
source: .
|
source: .
|
||||||
@@ -576,10 +642,22 @@ jobs:
|
|||||||
set: |
|
set: |
|
||||||
synaptics.tags=${{ steps.setup.outputs.image-name }}-synaptics
|
synaptics.tags=${{ steps.setup.outputs.image-name }}-synaptics
|
||||||
*.cache-from=type=gha
|
*.cache-from=type=gha
|
||||||
|
- name: Attest Synaptics build
|
||||||
|
uses: actions/attest@v4
|
||||||
|
with:
|
||||||
|
subject-name: ${{ steps.setup.outputs.image-repo }}
|
||||||
|
subject-digest: ${{ fromJSON(steps.synaptics.outputs.metadata)['synaptics']['containerimage.digest'] }}
|
||||||
|
push-to-registry: true
|
||||||
|
create-storage-record: false
|
||||||
# The majority of users running arm64 are rpi users, so the rpi
|
# The majority of users running arm64 are rpi users, so the rpi
|
||||||
# build should be the primary arm64 image
|
# build should be the primary arm64 image
|
||||||
assemble_default_build:
|
assemble_default_build:
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
attestations: write
|
||||||
|
id-token: write
|
||||||
name: Assemble and push default build
|
name: Assemble and push default build
|
||||||
needs:
|
needs:
|
||||||
- amd64_build
|
- amd64_build
|
||||||
@@ -590,7 +668,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
string: ${{ github.repository }}
|
string: ${{ github.repository }}
|
||||||
- name: Log in to the Container registry
|
- name: Log in to the Container registry
|
||||||
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6
|
uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
@@ -598,8 +676,16 @@ jobs:
|
|||||||
- name: Create short sha
|
- name: Create short sha
|
||||||
run: echo "SHORT_SHA=${GITHUB_SHA::7}" >> $GITHUB_ENV
|
run: echo "SHORT_SHA=${GITHUB_SHA::7}" >> $GITHUB_ENV
|
||||||
- uses: int128/docker-manifest-create-action@v2
|
- uses: int128/docker-manifest-create-action@v2
|
||||||
|
id: manifest
|
||||||
with:
|
with:
|
||||||
tags: ghcr.io/${{ steps.lowercaseRepo.outputs.lowercase }}:${{ env.SHORT_SHA }}
|
tags: ghcr.io/${{ steps.lowercaseRepo.outputs.lowercase }}:${{ env.SHORT_SHA }}
|
||||||
sources: |
|
sources: |
|
||||||
ghcr.io/${{ steps.lowercaseRepo.outputs.lowercase }}:${{ env.SHORT_SHA }}-amd64
|
ghcr.io/${{ steps.lowercaseRepo.outputs.lowercase }}:${{ env.SHORT_SHA }}-amd64
|
||||||
ghcr.io/${{ steps.lowercaseRepo.outputs.lowercase }}:${{ env.SHORT_SHA }}-rpi
|
ghcr.io/${{ steps.lowercaseRepo.outputs.lowercase }}:${{ env.SHORT_SHA }}-rpi
|
||||||
|
- name: Attest default build
|
||||||
|
uses: actions/attest@v4
|
||||||
|
with:
|
||||||
|
subject-name: ghcr.io/${{ steps.lowercaseRepo.outputs.lowercase }}
|
||||||
|
subject-digest: ${{ steps.manifest.outputs.digest }}
|
||||||
|
push-to-registry: true
|
||||||
|
create-storage-record: false
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
string: ${{ github.repository }}
|
string: ${{ github.repository }}
|
||||||
- name: Log in to the Container registry
|
- name: Log in to the Container registry
|
||||||
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6
|
uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
|
|||||||
@@ -46,3 +46,4 @@ make -C /tmp/media-driver/build -j"$(nproc)"
|
|||||||
|
|
||||||
# Install driver to rootfs for COPY --from
|
# Install driver to rootfs for COPY --from
|
||||||
make -C /tmp/media-driver/build install DESTDIR=/rootfs
|
make -C /tmp/media-driver/build install DESTDIR=/rootfs
|
||||||
|
strip --strip-unneeded /rootfs/usr/lib/x86_64-linux-gnu/dri/iHD_drv_video.so
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ set -euxo pipefail
|
|||||||
|
|
||||||
NGINX_VERSION="1.27.4"
|
NGINX_VERSION="1.27.4"
|
||||||
VOD_MODULE_VERSION="v1.9.1"
|
VOD_MODULE_VERSION="v1.9.1"
|
||||||
SECURE_TOKEN_MODULE_VERSION="1.5"
|
|
||||||
SET_MISC_MODULE_VERSION="v0.33"
|
SET_MISC_MODULE_VERSION="v0.33"
|
||||||
NGX_DEVEL_KIT_VERSION="v0.3.3"
|
NGX_DEVEL_KIT_VERSION="v0.3.3"
|
||||||
|
|
||||||
@@ -52,11 +51,6 @@ patch -d /tmp/nginx-vod-module/ -p1 << 'EOF'
|
|||||||
EOF
|
EOF
|
||||||
|
|
||||||
|
|
||||||
mkdir /tmp/nginx-secure-token-module
|
|
||||||
wget https://github.com/kaltura/nginx-secure-token-module/archive/refs/tags/${SECURE_TOKEN_MODULE_VERSION}.tar.gz
|
|
||||||
tar -zxf ${SECURE_TOKEN_MODULE_VERSION}.tar.gz -C /tmp/nginx-secure-token-module --strip-components=1
|
|
||||||
rm ${SECURE_TOKEN_MODULE_VERSION}.tar.gz
|
|
||||||
|
|
||||||
mkdir /tmp/ngx_devel_kit
|
mkdir /tmp/ngx_devel_kit
|
||||||
wget https://github.com/vision5/ngx_devel_kit/archive/refs/tags/${NGX_DEVEL_KIT_VERSION}.tar.gz
|
wget https://github.com/vision5/ngx_devel_kit/archive/refs/tags/${NGX_DEVEL_KIT_VERSION}.tar.gz
|
||||||
tar -zxf ${NGX_DEVEL_KIT_VERSION}.tar.gz -C /tmp/ngx_devel_kit --strip-components=1
|
tar -zxf ${NGX_DEVEL_KIT_VERSION}.tar.gz -C /tmp/ngx_devel_kit --strip-components=1
|
||||||
@@ -80,7 +74,6 @@ cd /tmp/nginx
|
|||||||
--add-module=../ngx_devel_kit \
|
--add-module=../ngx_devel_kit \
|
||||||
--add-module=../nginx-set-misc-module \
|
--add-module=../nginx-set-misc-module \
|
||||||
--add-module=../nginx-vod-module \
|
--add-module=../nginx-vod-module \
|
||||||
--add-module=../nginx-secure-token-module \
|
|
||||||
--with-cc-opt="-O3 -Wno-error=implicit-fallthrough"
|
--with-cc-opt="-O3 -Wno-error=implicit-fallthrough"
|
||||||
|
|
||||||
make CC="ccache gcc" -j$(nproc) && make install
|
make CC="ccache gcc" -j$(nproc) && make install
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
ruff == 0.17.3
|
ruff == 0.17.4
|
||||||
|
|
||||||
# types
|
# types
|
||||||
types-peewee == 4.0.*
|
types-peewee == 4.0.*
|
||||||
|
|||||||
@@ -7,18 +7,18 @@ starlette-context == 0.5.*
|
|||||||
fastapi[standard-no-fastapi-cloud-cli] == 0.162.*
|
fastapi[standard-no-fastapi-cloud-cli] == 0.162.*
|
||||||
uvicorn == 0.54.*
|
uvicorn == 0.54.*
|
||||||
slowapi == 0.1.*
|
slowapi == 0.1.*
|
||||||
joserfc == 1.7.*
|
joserfc == 1.8.*
|
||||||
cryptography == 50.0.*
|
cryptography == 50.0.*
|
||||||
pathvalidate == 3.3.*
|
pathvalidate == 3.3.*
|
||||||
markupsafe == 3.0.*
|
markupsafe == 3.0.*
|
||||||
python-multipart == 0.0.32
|
python-multipart == 0.0.33
|
||||||
# Classification Model Training
|
# Classification Model Training
|
||||||
tensorflow == 2.19.* ; platform_machine == 'aarch64'
|
tensorflow == 2.19.* ; platform_machine == 'aarch64'
|
||||||
tensorflow-cpu == 2.21.* ; platform_machine == 'x86_64'
|
tensorflow-cpu == 2.21.* ; platform_machine == 'x86_64'
|
||||||
# General
|
# General
|
||||||
mypy == 2.4.1
|
mypy == 2.4.1
|
||||||
onvif-zeep-async == 4.3.*
|
onvif-zeep-async == 4.3.*
|
||||||
paho-mqtt == 2.1.*
|
paho-mqtt == 2.2.*
|
||||||
pandas == 3.0.*
|
pandas == 3.0.*
|
||||||
peewee == 4.5.*
|
peewee == 4.5.*
|
||||||
peewee_migrate == 2.3.*
|
peewee_migrate == 2.3.*
|
||||||
@@ -26,7 +26,7 @@ psutil == 7.2.*
|
|||||||
pydantic == 2.14.*
|
pydantic == 2.14.*
|
||||||
git+https://github.com/fbcotter/py3nvml#egg=py3nvml
|
git+https://github.com/fbcotter/py3nvml#egg=py3nvml
|
||||||
tzdata == 2026.6
|
tzdata == 2026.6
|
||||||
pyzmq == 27.2.*
|
pyzmq == 27.3.*
|
||||||
ruamel.yaml == 0.19.*
|
ruamel.yaml == 0.19.*
|
||||||
tzlocal == 5.4.5
|
tzlocal == 5.4.5
|
||||||
requests == 2.34.*
|
requests == 2.34.*
|
||||||
@@ -47,9 +47,9 @@ onnxruntime == 1.31.*
|
|||||||
# Embeddings
|
# Embeddings
|
||||||
transformers == 5.22.*
|
transformers == 5.22.*
|
||||||
# Generative AI
|
# Generative AI
|
||||||
google-genai == 2.34.*
|
google-genai == 3.0.*
|
||||||
ollama == 0.6.*
|
ollama == 0.6.*
|
||||||
openai == 3.41.*
|
openai == 3.46.*
|
||||||
# push notifications
|
# push notifications
|
||||||
py-vapid == 1.9.6
|
py-vapid == 1.9.6
|
||||||
pywebpush == 2.5.*
|
pywebpush == 2.5.*
|
||||||
|
|||||||
@@ -14,11 +14,17 @@ fi
|
|||||||
# Tell S6-Overlay not to restart this service
|
# Tell S6-Overlay not to restart this service
|
||||||
s6-svc -O .
|
s6-svc -O .
|
||||||
|
|
||||||
# Runs as root with root's HOME and LD_LIBRARY_PATH; -I skips user site-packages
|
# The helper runs as root (-I skips user site-packages). The probe runs as the
|
||||||
|
# service user when the service drops privileges; a root probe relies on
|
||||||
|
# resolve_ffmpeg_path refusing runtime-user-writable paths.
|
||||||
function set_libva_version() {
|
function set_libva_version() {
|
||||||
local ffmpeg_path
|
local ffmpeg_path
|
||||||
ffmpeg_path=$(python3 -I /usr/local/ffmpeg/get_ffmpeg_path.py)
|
local probe_as=()
|
||||||
LIBAVFORMAT_VERSION_MAJOR=$("$ffmpeg_path" -version | grep -Po "libavformat\W+\K\d+")
|
if [[ "$(id -u)" -eq 0 && "$runs_as_root" -eq 0 ]]; then
|
||||||
|
probe_as=(s6-setuidgid frigate)
|
||||||
|
fi
|
||||||
|
ffmpeg_path=$(python3 -I /usr/local/ffmpeg/get_ffmpeg_path.py frigate)
|
||||||
|
LIBAVFORMAT_VERSION_MAJOR=$("${probe_as[@]}" "$ffmpeg_path" -version | grep -Po "libavformat\W+\K\d+")
|
||||||
export LIBAVFORMAT_VERSION_MAJOR
|
export LIBAVFORMAT_VERSION_MAJOR
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,10 +57,19 @@ function get_ip_and_port_from_supervisor() {
|
|||||||
export FRIGATE_GO2RTC_WEBRTC_CANDIDATE_INTERNAL="${ip_address}:${webrtc_port}"
|
export FRIGATE_GO2RTC_WEBRTC_CANDIDATE_INTERNAL="${ip_address}:${webrtc_port}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# The probe runs as the service user when the service drops privileges; a root
|
||||||
|
# probe relies on resolve_ffmpeg_path refusing runtime-user-writable paths
|
||||||
function set_libva_version() {
|
function set_libva_version() {
|
||||||
local ffmpeg_path
|
local ffmpeg_path
|
||||||
ffmpeg_path=$(python3 -I /usr/local/ffmpeg/get_ffmpeg_path.py)
|
local probe_as=()
|
||||||
LIBAVFORMAT_VERSION_MAJOR=$("$ffmpeg_path" -version | grep -Po "libavformat\W+\K\d+")
|
if [[ "$(id -u)" -eq 0 && "$runs_as_root" -eq 0 ]]; then
|
||||||
|
probe_as=(s6-setuidgid go2rtc)
|
||||||
|
fi
|
||||||
|
ffmpeg_path=$(python3 -I /usr/local/ffmpeg/get_ffmpeg_path.py go2rtc)
|
||||||
|
if ! LIBAVFORMAT_VERSION_MAJOR=$("${probe_as[@]}" "$ffmpeg_path" -version | grep -Po "libavformat\W+\K\d+"); then
|
||||||
|
echo "[WARN] Could not read the libavformat version from ${ffmpeg_path} as the go2rtc user; using the bundled build's version"
|
||||||
|
LIBAVFORMAT_VERSION_MAJOR=$("${probe_as[@]}" "/usr/lib/ffmpeg/${DEFAULT_FFMPEG_VERSION}/bin/ffmpeg" -version | grep -Po "libavformat\W+\K\d+")
|
||||||
|
fi
|
||||||
export LIBAVFORMAT_VERSION_MAJOR
|
export LIBAVFORMAT_VERSION_MAJOR
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -80,6 +80,11 @@ cp -r /usr/local/nginx/conf/. /tmp/nginx/conf/
|
|||||||
|
|
||||||
set_worker_processes
|
set_worker_processes
|
||||||
|
|
||||||
|
# A non-root master ignores `user` and warns on every start
|
||||||
|
if [[ "$runs_as_root" -eq 0 ]]; then
|
||||||
|
sed -i '/^user root;$/d' /tmp/nginx/conf/nginx.conf
|
||||||
|
fi
|
||||||
|
|
||||||
# TLS certs: user-mounted certs at /etc/letsencrypt/live/frigate (documented
|
# TLS certs: user-mounted certs at /etc/letsencrypt/live/frigate (documented
|
||||||
# contract) always win; otherwise fall back to a self-signed cert persisted in
|
# contract) always win; otherwise fall back to a self-signed cert persisted in
|
||||||
# /config/tls, which stays writable under a read-only root filesystem.
|
# /config/tls, which stays writable under a read-only root filesystem.
|
||||||
|
|||||||
@@ -105,6 +105,8 @@ if [[ -n "$sentinel" ]]; then
|
|||||||
mismatch_expr+=(-a ! -path "$sentinel")
|
mismatch_expr+=(-a ! -path "$sentinel")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
exec 3>&1
|
||||||
|
|
||||||
for path in "$@"; do
|
for path in "$@"; do
|
||||||
# An absent root is an incomplete sweep, not a finished one: /media/frigate
|
# 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
|
# is not in the image, so a boot before the volume is mounted would
|
||||||
@@ -121,7 +123,18 @@ for path in "$@"; do
|
|||||||
# stale mount. Tolerate it rather than aborting under errexit, but never
|
# 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
|
# read a failed scan as "nothing to do": that would record the sweep as
|
||||||
# complete without having looked.
|
# complete without having looked.
|
||||||
if ! count=$(find "$path" -name lost+found -prune -o "${mismatch_expr[@]}" -printf '.' 2>/dev/null | wc -c); then
|
# The walk prints nothing until it ends, so report every 30s through fd 3
|
||||||
|
# (stdout) while the count itself comes back through the substitution.
|
||||||
|
if ! count=$(find "$path" -name lost+found -prune -o "(" "${mismatch_expr[@]}" ")" -printf 'm\n' -o -printf '.\n' 2>/dev/null \
|
||||||
|
| awk -v path="$path" '
|
||||||
|
BEGIN { next_report = systime() + 30 }
|
||||||
|
$0 == "m" { mismatched++ }
|
||||||
|
systime() >= next_report {
|
||||||
|
printf "[INFO] fix-ownership: still scanning %s, %d entries checked, %d need changes\n", path, NR, mismatched > "/dev/fd/3"
|
||||||
|
fflush("/dev/fd/3")
|
||||||
|
next_report = systime() + 30
|
||||||
|
}
|
||||||
|
END { print mismatched + 0 }'); then
|
||||||
swept_clean=0
|
swept_clean=0
|
||||||
echo "[WARN] fix-ownership: could not scan ${path}; will retry on next boot"
|
echo "[WARN] fix-ownership: could not scan ${path}; will retry on next boot"
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -25,4 +25,5 @@ except FileNotFoundError:
|
|||||||
config: dict[str, Any] = {}
|
config: dict[str, Any] = {}
|
||||||
|
|
||||||
path = config.get("ffmpeg", {}).get("path", "default")
|
path = config.get("ffmpeg", {}).get("path", "default")
|
||||||
print(resolve_ffmpeg_path(path, "ffmpeg"))
|
service = sys.argv[1] if len(sys.argv) > 1 else "frigate"
|
||||||
|
print(resolve_ffmpeg_path(path, "ffmpeg", service))
|
||||||
|
|||||||
@@ -40,16 +40,10 @@ try:
|
|||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
config: dict[str, Any] = {}
|
config: dict[str, Any] = {}
|
||||||
|
|
||||||
# No validator runs here, so install environment_vars ourselves. FRIGATE_
|
# No validator runs here, so install environment_vars ourselves.
|
||||||
# names only: anything else lands in os.environ, where the exec gate reads
|
|
||||||
# GO2RTC_ALLOW_ARBITRARY_EXEC.
|
|
||||||
config_env_vars = config.get("environment_vars")
|
config_env_vars = config.get("environment_vars")
|
||||||
apply_config_env_vars(
|
apply_config_env_vars(
|
||||||
{
|
{str(key): value for key, value in config_env_vars.items()}
|
||||||
key: value
|
|
||||||
for key, value in config_env_vars.items()
|
|
||||||
if str(key).startswith("FRIGATE_")
|
|
||||||
}
|
|
||||||
if isinstance(config_env_vars, dict)
|
if isinstance(config_env_vars, dict)
|
||||||
else {}
|
else {}
|
||||||
)
|
)
|
||||||
@@ -103,7 +97,7 @@ if not is_valid_ffmpeg_path(path):
|
|||||||
print(
|
print(
|
||||||
"[ERROR] ffmpeg path contains spaces, quotes or shell characters, using the default ffmpeg instead."
|
"[ERROR] ffmpeg path contains spaces, quotes or shell characters, using the default ffmpeg instead."
|
||||||
)
|
)
|
||||||
ffmpeg_path = resolve_ffmpeg_path(path, "ffmpeg")
|
ffmpeg_path = resolve_ffmpeg_path(path, "ffmpeg", "go2rtc")
|
||||||
|
|
||||||
for key in apply_go2rtc_ffmpeg_config(go2rtc_config, ffmpeg_path):
|
for key in apply_go2rtc_ffmpeg_config(go2rtc_config, ffmpeg_path):
|
||||||
print(
|
print(
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
# file, all other path directives follow --prefix and must stay absolute.
|
# file, all other path directives follow --prefix and must stay absolute.
|
||||||
|
|
||||||
daemon off;
|
daemon off;
|
||||||
# ignored by a non-root master; keeps workers root under FRIGATE_RUN_AS_ROOT
|
# keeps workers root under FRIGATE_RUN_AS_ROOT; the run script strips it otherwise
|
||||||
user root;
|
user root;
|
||||||
worker_processes auto;
|
worker_processes auto;
|
||||||
|
|
||||||
@@ -143,8 +143,14 @@ http {
|
|||||||
# version 6 (RFC 8216 section 7); the module default is 4
|
# version 6 (RFC 8216 section 7); the module default is 4
|
||||||
vod_hls_version 6;
|
vod_hls_version 6;
|
||||||
|
|
||||||
secure_token $args;
|
# Pass the playlist's query args (e.g. Home Assistant's authSig) on
|
||||||
secure_token_types application/vnd.apple.mpegurl;
|
# to every URL in it. These match the only URL endings vod writes
|
||||||
|
# with fmp4 and no subtitles.
|
||||||
|
sub_filter_types application/vnd.apple.mpegurl;
|
||||||
|
sub_filter_once off;
|
||||||
|
sub_filter '.m3u8' '.m3u8$is_args$args';
|
||||||
|
sub_filter '.m4s' '.m4s$is_args$args';
|
||||||
|
sub_filter '.mp4"' '.mp4$is_args$args"';
|
||||||
|
|
||||||
include security_headers.conf;
|
include security_headers.conf;
|
||||||
add_header Cache-Control "no-store";
|
add_header Cache-Control "no-store";
|
||||||
|
|||||||
@@ -240,7 +240,7 @@ logger:
|
|||||||
|
|
||||||
# Optional: set environment variables
|
# Optional: set environment variables
|
||||||
environment_vars:
|
environment_vars:
|
||||||
EXAMPLE_VAR: value
|
LIBVA_DRIVER_NAME: i965
|
||||||
|
|
||||||
# Optional: birdseye configuration
|
# Optional: birdseye configuration
|
||||||
# NOTE: Can (enabled, mode) be overridden at the camera level
|
# NOTE: Can (enabled, mode) be overridden at the camera level
|
||||||
|
|||||||
@@ -65,7 +65,16 @@ go2rtc:
|
|||||||
|
|
||||||
This section sets environment variables in the Frigate process for those unable to modify the environment of the container, like within Home Assistant OS. It's meant for process settings such as `LIBVA_DRIVER_NAME` or the TensorFlow thread counts below. Docker users should set environment variables in their `docker run` command (`-e LIBVA_DRIVER_NAME=i965`) or `docker-compose.yml` file (`environment:` section) instead. Values set here are stored in plain text in your config file, so credentials belong in `secrets.yaml`, Docker environment variables, or Docker secrets instead.
|
This section sets environment variables in the Frigate process for those unable to modify the environment of the container, like within Home Assistant OS. It's meant for process settings such as `LIBVA_DRIVER_NAME` or the TensorFlow thread counts below. Docker users should set environment variables in their `docker run` command (`-e LIBVA_DRIVER_NAME=i965`) or `docker-compose.yml` file (`environment:` section) instead. Values set here are stored in plain text in your config file, so credentials belong in `secrets.yaml`, Docker environment variables, or Docker secrets instead.
|
||||||
|
|
||||||
Names prefixed with `FRIGATE_` set here also take part in `{FRIGATE_VARIABLE_NAME}` substitution (see [below](#substitution-sources-and-precedence)), but `secrets.yaml` is the better home for them.
|
Besides `FRIGATE_` names, only these are accepted, and a variable already set in the container environment keeps its container value:
|
||||||
|
|
||||||
|
- `LIBVA_DRIVER_NAME` and `HSA_OVERRIDE_GFX_VERSION`
|
||||||
|
- `TF_INTRA_OP_PARALLELISM_THREADS`, `TF_INTER_OP_PARALLELISM_THREADS`, `TF_DATASET_THREAD_POOL_SIZE`, and `SHM_MAX_FRAMES`
|
||||||
|
- `USE_FP16`, `HF_HUB_OFFLINE`, and `TRANSFORMERS_OFFLINE`
|
||||||
|
- `HF_ENDPOINT`, `GITHUB_ENDPOINT`, `GITHUB_RAW_ENDPOINT`, and `TF_KERAS_MOBILENET_V2_WEIGHTS_URL` (an `http://` or `https://` URL)
|
||||||
|
|
||||||
|
Any other name is ignored with a warning and must be set in the container environment.
|
||||||
|
|
||||||
|
Names prefixed with `FRIGATE_` set here take part in `{FRIGATE_VARIABLE_NAME}` substitution (see [below](#substitution-sources-and-precedence)), but `secrets.yaml` is the better home for them. Of these, only `FRIGATE_JWT_SECRET` is also set in the process environment.
|
||||||
|
|
||||||
<ConfigTabs>
|
<ConfigTabs>
|
||||||
<TabItem value="ui">
|
<TabItem value="ui">
|
||||||
@@ -398,9 +407,9 @@ To do this:
|
|||||||
2. Update the `ffmpeg.path` in your Frigate config to `/config/custom-ffmpeg`.
|
2. Update the `ffmpeg.path` in your Frigate config to `/config/custom-ffmpeg`.
|
||||||
3. Restart Frigate and the custom version will be used if the steps above were done correctly.
|
3. Restart Frigate and the custom version will be used if the steps above were done correctly.
|
||||||
|
|
||||||
Both binaries have to be executable by Frigate's unprivileged runtime user, so `chmod 755` them after extracting. The startup ownership sweep runs only once, so anything you add to `/config` later keeps whatever ownership and mode you gave it.
|
Both binaries have to be executable by Frigate's unprivileged runtime user, so `chmod 755` them after extracting. go2rtc runs as its own user, so the build must be world-executable for go2rtc to use it (`ffmpeg:` sources, the birdseye restream and transcoded live streams). The startup ownership sweep runs only once, so anything you add to `/config` later keeps whatever ownership and mode you gave it.
|
||||||
|
|
||||||
There is one exception, and it only affects [`FRIGATE_ROOT_SERVICES`](/configuration/non_root#keeping-individual-services-root) listing `frigate`. That mode runs Frigate as root while still handing `/config` to the unprivileged runtime user, so anything running as that user could swap the binary and gain root. A build inside any of Frigate's writable volumes (`/config`, `/media/frigate`, the cache and shm dirs) is ignored there and the bundled one is used, with a warning in the log. Keep the build somewhere root-owned (any absolute `ffmpeg.path` works, so a read-only bind mount such as `/opt/custom-ffmpeg` is enough) if you need both. The default mode and `FRIGATE_RUN_AS_ROOT=true` are unaffected and behave exactly as they always have.
|
There is one exception, and it only affects [`FRIGATE_ROOT_SERVICES`](/configuration/non_root#keeping-individual-services-root) listing `frigate` or `go2rtc`. That mode runs the listed service as root while still handing `/config` to the unprivileged runtime user, so anything running as that user could swap the binary and gain root. A build inside any of Frigate's writable volumes (`/config`, `/media/frigate`, the cache and shm dirs) is ignored by the root service, which uses the bundled one and logs a warning. A service that is not listed keeps using the custom build. Keep the build somewhere root-owned (any absolute `ffmpeg.path` works, so a read-only bind mount such as `/opt/custom-ffmpeg` is enough) if you need both. The default mode and `FRIGATE_RUN_AS_ROOT=true` are unaffected and behave exactly as they always have.
|
||||||
|
|
||||||
### Custom go2rtc version
|
### Custom go2rtc version
|
||||||
|
|
||||||
|
|||||||
@@ -71,13 +71,14 @@ Both the script and the boot sweep report progress, so you can tell a slow sweep
|
|||||||
|
|
||||||
```
|
```
|
||||||
[INFO] fix-ownership: scanning /media/frigate for ownership mismatches; this may take a while on large filesystems
|
[INFO] fix-ownership: scanning /media/frigate for ownership mismatches; this may take a while on large filesystems
|
||||||
|
[INFO] fix-ownership: still scanning /media/frigate, 2481000 entries checked, 2390112 need changes
|
||||||
[WARN] fix-ownership: adjusting ownership of 4823941 entries under /media/frigate
|
[WARN] fix-ownership: adjusting ownership of 4823941 entries under /media/frigate
|
||||||
[INFO] fix-ownership: /media/frigate 5% (241197/4823941 entries)
|
[INFO] fix-ownership: /media/frigate 5% (241197/4823941 entries)
|
||||||
[INFO] fix-ownership: /media/frigate 10% (482394/4823941 entries)
|
[INFO] fix-ownership: /media/frigate 10% (482394/4823941 entries)
|
||||||
[INFO] fix-ownership: finished /media/frigate in 12m 4s
|
[INFO] fix-ownership: finished /media/frigate in 12m 4s
|
||||||
```
|
```
|
||||||
|
|
||||||
The scan has no percentage because the total isn't known until it finishes. Watch the boot sweep with `docker logs -f frigate`.
|
The scan has no percentage because the total isn't known until it finishes, so it reports a running count every 30 seconds instead. Watch the boot sweep with `docker logs -f frigate`.
|
||||||
|
|
||||||
Once the volumes are aligned, start Frigate normally. A file at `/config/.permissions_version` records what was done, so later boots skip the sweep unless you change `PUID`/`PGID`.
|
Once the volumes are aligned, start Frigate normally. A file at `/config/.permissions_version` records what was done, so later boots skip the sweep unless you change `PUID`/`PGID`.
|
||||||
|
|
||||||
|
|||||||
@@ -414,11 +414,12 @@ Avoid removing audio from only one of the two streams (for example with `-an`).
|
|||||||
As a general rule, features that read recordings prefer the main stream and fall back to the sub stream for time ranges where the main recordings have expired. Analytics features use only the main stream.
|
As a general rule, features that read recordings prefer the main stream and fall back to the sub stream for time ranges where the main recordings have expired. Analytics features use only the main stream.
|
||||||
|
|
||||||
| Feature | Stream used |
|
| Feature | Stream used |
|
||||||
| ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
|
| ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| Recording playback (History and Review) | Both (main preferred with sub fallback by default), or exactly one stream when a quality is selected manually |
|
| Recording playback (History and Review) | Both (main preferred with sub fallback by default), or exactly one stream when a quality is selected manually |
|
||||||
| Tracking details and Explore clip playback | Main, falling back to sub where the main recordings have expired |
|
| Tracking details and Explore clip playback | Main, falling back to sub where the main recordings have expired |
|
||||||
| Exports | Both (main preferred with sub fallback by default), or exactly one stream when a quality is selected in the export dialog |
|
| Exports | Both (main preferred with sub fallback by default), or exactly one stream when a quality is selected in the export dialog |
|
||||||
| Clip downloads | Main; sub is used when no main recordings remain in the range (streams are never mixed in one file) |
|
| Clip downloads | Main; sub is used when no main recordings remain in the range (streams are never mixed in one file) |
|
||||||
|
| Debug replay | Main; sub is used when no main recordings remain in the range (streams are never mixed in one file) |
|
||||||
| Frames grabbed from a recording in History (download snapshot, submit frame to Frigate+) | Main preferred, sub fallback |
|
| Frames grabbed from a recording in History (download snapshot, submit frame to Frigate+) | Main preferred, sub fallback |
|
||||||
| Audio extraction (e.g., transcription) | Main preferred, sub fallback |
|
| Audio extraction (e.g., transcription) | Main preferred, sub fallback |
|
||||||
| Motion search | Main only |
|
| Motion search | Main only |
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { Config, PluginConfig } from "@docusaurus/types";
|
|||||||
import type * as OpenApiPlugin from "docusaurus-plugin-openapi-docs";
|
import type * as OpenApiPlugin from "docusaurus-plugin-openapi-docs";
|
||||||
|
|
||||||
// Bump when a new stable release ships
|
// Bump when a new stable release ships
|
||||||
const STABLE_VERSION = "0.18";
|
const STABLE_VERSION = "0.19";
|
||||||
|
|
||||||
const config: Config = {
|
const config: Config = {
|
||||||
title: "Frigate",
|
title: "Frigate",
|
||||||
|
|||||||
+23
-1
@@ -683,6 +683,15 @@
|
|||||||
"title": "FeaturesSection",
|
"title": "FeaturesSection",
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"FfmpegSource": {
|
||||||
|
"enum": [
|
||||||
|
"default",
|
||||||
|
"bundled",
|
||||||
|
"custom"
|
||||||
|
],
|
||||||
|
"title": "FfmpegSource",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"FpsBucket": {
|
"FpsBucket": {
|
||||||
"enum": [
|
"enum": [
|
||||||
"le_5",
|
"le_5",
|
||||||
@@ -1017,6 +1026,17 @@
|
|||||||
"description": "CPU architecture",
|
"description": "CPU architecture",
|
||||||
"x-public": true
|
"x-public": true
|
||||||
},
|
},
|
||||||
|
"custom_go2rtc": {
|
||||||
|
"description": "Whether an executable /config/go2rtc build exists",
|
||||||
|
"title": "Custom Go2Rtc",
|
||||||
|
"type": "boolean",
|
||||||
|
"x-public": true
|
||||||
|
},
|
||||||
|
"ffmpeg": {
|
||||||
|
"$ref": "#/$defs/FfmpegSource",
|
||||||
|
"description": "Configured global ffmpeg.path: the default build, another bundled version, or a custom path",
|
||||||
|
"x-public": true
|
||||||
|
},
|
||||||
"image_variant": {
|
"image_variant": {
|
||||||
"$ref": "#/$defs/ImageVariant",
|
"$ref": "#/$defs/ImageVariant",
|
||||||
"description": "Published image the install runs",
|
"description": "Published image the install runs",
|
||||||
@@ -1054,7 +1074,9 @@
|
|||||||
"install_type",
|
"install_type",
|
||||||
"arch",
|
"arch",
|
||||||
"kernel",
|
"kernel",
|
||||||
"run_as_root"
|
"run_as_root",
|
||||||
|
"custom_go2rtc",
|
||||||
|
"ffmpeg"
|
||||||
],
|
],
|
||||||
"title": "InstallSection",
|
"title": "InstallSection",
|
||||||
"type": "object"
|
"type": "object"
|
||||||
|
|||||||
Vendored
+19
@@ -3148,6 +3148,25 @@ paths:
|
|||||||
- frigateUserAuth: []
|
- frigateUserAuth: []
|
||||||
x-required-role: any
|
x-required-role: any
|
||||||
description: '**Access:** Any authenticated user.'
|
description: '**Access:** Any authenticated user.'
|
||||||
|
/plus/validate:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- App
|
||||||
|
summary: Validate Plus Key
|
||||||
|
description: |-
|
||||||
|
**Access:** Admin role required.
|
||||||
|
|
||||||
|
Validate the configured Frigate+ API key with the Frigate+ server.
|
||||||
|
operationId: validate_plus_key_plus_validate_post
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Successful Response
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: {}
|
||||||
|
security:
|
||||||
|
- frigateAdminAuth: []
|
||||||
|
x-required-role: admin
|
||||||
/recognized_license_plates:
|
/recognized_license_plates:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
|
|||||||
@@ -6,7 +6,15 @@ import re
|
|||||||
|
|
||||||
from frigate.analytics.collectors.common import closed
|
from frigate.analytics.collectors.common import closed
|
||||||
from frigate.analytics.context import ReportContext
|
from frigate.analytics.context import ReportContext
|
||||||
from frigate.analytics.schema import Arch, ImageVariant, InstallSection, InstallType
|
from frigate.analytics.schema import (
|
||||||
|
Arch,
|
||||||
|
FfmpegSource,
|
||||||
|
ImageVariant,
|
||||||
|
InstallSection,
|
||||||
|
InstallType,
|
||||||
|
)
|
||||||
|
from frigate.const import CONFIG_DIR, DEFAULT_FFMPEG_VERSION, INCLUDED_FFMPEG_VERSIONS
|
||||||
|
from frigate.util.config import is_valid_ffmpeg_path
|
||||||
from frigate.version import VERSION
|
from frigate.version import VERSION
|
||||||
|
|
||||||
KERNEL_PATTERN = re.compile(r"^(\d{1,3})\.(\d{1,3})")
|
KERNEL_PATTERN = re.compile(r"^(\d{1,3})\.(\d{1,3})")
|
||||||
@@ -54,6 +62,17 @@ def kernel(release: str) -> str:
|
|||||||
return f"{match.group(1)}.{match.group(2)}" if match else "unknown"
|
return f"{match.group(1)}.{match.group(2)}" if match else "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def ffmpeg_source(path: str) -> FfmpegSource:
|
||||||
|
"""Classify the configured ffmpeg.path, applying resolve_ffmpeg_path's alias fallbacks."""
|
||||||
|
if path.startswith("/") and is_valid_ffmpeg_path(path):
|
||||||
|
return FfmpegSource.custom
|
||||||
|
|
||||||
|
if path != DEFAULT_FFMPEG_VERSION and path in INCLUDED_FFMPEG_VERSIONS:
|
||||||
|
return FfmpegSource.bundled
|
||||||
|
|
||||||
|
return FfmpegSource.default
|
||||||
|
|
||||||
|
|
||||||
def collect(ctx: ReportContext) -> InstallSection:
|
def collect(ctx: ReportContext) -> InstallSection:
|
||||||
"""Describe the Frigate version, image, install method, and platform."""
|
"""Describe the Frigate version, image, install method, and platform."""
|
||||||
return InstallSection(
|
return InstallSection(
|
||||||
@@ -63,4 +82,6 @@ def collect(ctx: ReportContext) -> InstallSection:
|
|||||||
arch=arch(platform.machine()),
|
arch=arch(platform.machine()),
|
||||||
kernel=kernel(platform.release()),
|
kernel=kernel(platform.release()),
|
||||||
run_as_root=os.geteuid() == 0,
|
run_as_root=os.geteuid() == 0,
|
||||||
|
custom_go2rtc=os.access(os.path.join(CONFIG_DIR, "go2rtc"), os.X_OK),
|
||||||
|
ffmpeg=ffmpeg_source(ctx.config.ffmpeg.path),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -47,6 +47,12 @@ class Arch(StrEnum):
|
|||||||
other = "other"
|
other = "other"
|
||||||
|
|
||||||
|
|
||||||
|
class FfmpegSource(StrEnum):
|
||||||
|
default = "default"
|
||||||
|
bundled = "bundled"
|
||||||
|
custom = "custom"
|
||||||
|
|
||||||
|
|
||||||
class GpuVendor(StrEnum):
|
class GpuVendor(StrEnum):
|
||||||
intel = "intel"
|
intel = "intel"
|
||||||
amd = "amd"
|
amd = "amd"
|
||||||
@@ -201,6 +207,10 @@ class InstallSection(AnalyticsModel):
|
|||||||
"Host kernel as major.minor", pattern=r"^(\d{1,3}\.\d{1,3}|unknown)$"
|
"Host kernel as major.minor", pattern=r"^(\d{1,3}\.\d{1,3}|unknown)$"
|
||||||
)
|
)
|
||||||
run_as_root: bool = metric("Whether the main process runs as root")
|
run_as_root: bool = metric("Whether the main process runs as root")
|
||||||
|
custom_go2rtc: bool = metric("Whether an executable /config/go2rtc build exists")
|
||||||
|
ffmpeg: FfmpegSource = metric(
|
||||||
|
"Configured global ffmpeg.path: the default build, another bundled version, or a custom path"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class GpuInfo(AnalyticsModel):
|
class GpuInfo(AnalyticsModel):
|
||||||
|
|||||||
+12
-1
@@ -54,7 +54,7 @@ from frigate.jobs.media_sync import (
|
|||||||
start_media_sync_job,
|
start_media_sync_job,
|
||||||
)
|
)
|
||||||
from frigate.models import Event, Timeline
|
from frigate.models import Event, Timeline
|
||||||
from frigate.plus import load_plus_model_info
|
from frigate.plus import PlusKeyStatus, load_plus_model_info
|
||||||
from frigate.stats.prometheus import get_metrics, update_metrics
|
from frigate.stats.prometheus import get_metrics, update_metrics
|
||||||
from frigate.types import JobStatusTypesEnum
|
from frigate.types import JobStatusTypesEnum
|
||||||
from frigate.util.builtin import (
|
from frigate.util.builtin import (
|
||||||
@@ -627,6 +627,7 @@ def _restricted_go2rtc_response(
|
|||||||
Go2rtcStreamIssue.invalid_placeholder,
|
Go2rtcStreamIssue.invalid_placeholder,
|
||||||
"Invalid variable placeholder in go2rtc stream",
|
"Invalid variable placeholder in go2rtc stream",
|
||||||
),
|
),
|
||||||
|
(Go2rtcStreamIssue.invalid_name, "Invalid go2rtc stream name"),
|
||||||
):
|
):
|
||||||
names = [name for name, found in issues.items() if found == issue]
|
names = [name for name, found in issues.items() if found == issue]
|
||||||
if names:
|
if names:
|
||||||
@@ -1674,6 +1675,16 @@ def plusModels(request: Request, filterByCurrentModelDetector: bool = False):
|
|||||||
return JSONResponse(content=validModels)
|
return JSONResponse(content=validModels)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/plus/validate", dependencies=[Depends(require_role(["admin"]))])
|
||||||
|
async def validate_plus_key(request: Request):
|
||||||
|
"""Validate the configured Frigate+ API key with the Frigate+ server."""
|
||||||
|
status = await request.app.frigate_config.plus_api.validate_key()
|
||||||
|
|
||||||
|
return JSONResponse(
|
||||||
|
content={"success": status == PlusKeyStatus.valid, "status": status.value}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/recognized_license_plates", dependencies=[Depends(allow_any_authenticated())]
|
"/recognized_license_plates", dependencies=[Depends(allow_any_authenticated())]
|
||||||
)
|
)
|
||||||
|
|||||||
+14
-1
@@ -39,6 +39,7 @@ from frigate.config import AuthConfig, ProxyConfig
|
|||||||
from frigate.const import CONFIG_DIR, JWT_SECRET_ENV_VAR, PASSWORD_HASH_ALGORITHM
|
from frigate.const import CONFIG_DIR, JWT_SECRET_ENV_VAR, PASSWORD_HASH_ALGORITHM
|
||||||
from frigate.models import User
|
from frigate.models import User
|
||||||
from frigate.notices import raise_notice
|
from frigate.notices import raise_notice
|
||||||
|
from frigate.util.live_streams import is_transcode_stream_name
|
||||||
from frigate.util.origin import origin_allowed
|
from frigate.util.origin import origin_allowed
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -739,6 +740,13 @@ def auth(request: Request):
|
|||||||
int(request.headers.get("x-server-port", default=0))
|
int(request.headers.get("x-server-port", default=0))
|
||||||
== request.app.auth_internal_port
|
== request.app.auth_internal_port
|
||||||
):
|
):
|
||||||
|
# go2rtc runs the producer an unknown src describes for any caller
|
||||||
|
deny_status = deny_response_for_go2rtc_stream(
|
||||||
|
request.headers.get("x-original-url"), None, request
|
||||||
|
)
|
||||||
|
if deny_status is not None:
|
||||||
|
return Response("", status_code=deny_status)
|
||||||
|
|
||||||
success_response.headers["remote-user"] = "anonymous"
|
success_response.headers["remote-user"] = "anonymous"
|
||||||
success_response.headers["remote-role"] = "admin"
|
success_response.headers["remote-role"] = "admin"
|
||||||
return success_response
|
return success_response
|
||||||
@@ -1275,7 +1283,12 @@ def _is_existing_go2rtc_stream(request: Request, stream_name: str) -> bool:
|
|||||||
"""
|
"""
|
||||||
frigate_config = request.app.frigate_config
|
frigate_config = request.app.frigate_config
|
||||||
|
|
||||||
if _get_stream_owner_cameras(request, stream_name):
|
# live.streams values are free-form config, so only names Frigate controls
|
||||||
|
# skip the lookup
|
||||||
|
if any(
|
||||||
|
stream_name == camera or is_transcode_stream_name(camera, stream_name)
|
||||||
|
for camera in frigate_config.cameras
|
||||||
|
):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
config_streams = getattr(frigate_config.go2rtc, "streams", None) or {}
|
config_streams = getattr(frigate_config.go2rtc, "streams", None) or {}
|
||||||
|
|||||||
+14
-22
@@ -199,18 +199,14 @@ def events(
|
|||||||
sub_label_clauses.append(Event.sub_label.is_null())
|
sub_label_clauses.append(Event.sub_label.is_null())
|
||||||
|
|
||||||
for label in filtered_sub_labels:
|
for label in filtered_sub_labels:
|
||||||
lowered = label.lower()
|
# SQLite LOWER() only folds ASCII, so lower both sides in SQL to
|
||||||
sub_label_clauses.append(
|
# keep non-ASCII names matching exactly
|
||||||
fn.LOWER(Event.sub_label.cast("text")) == lowered
|
stored = fn.LOWER(Event.sub_label.cast("text"))
|
||||||
) # include exact matches (case-insensitive)
|
sub_label_clauses.append(stored == fn.LOWER(label))
|
||||||
|
|
||||||
# include this label when part of a list (LIKE is case-insensitive in sqlite for ASCII)
|
# include this label when part of a list
|
||||||
sub_label_clauses.append(
|
sub_label_clauses.append(stored % fn.LOWER(f"*{label},*"))
|
||||||
fn.LOWER(Event.sub_label.cast("text")) % f"*{lowered},*"
|
sub_label_clauses.append(stored % fn.LOWER(f"*, {label}*"))
|
||||||
)
|
|
||||||
sub_label_clauses.append(
|
|
||||||
fn.LOWER(Event.sub_label.cast("text")) % f"*, {lowered}*"
|
|
||||||
)
|
|
||||||
|
|
||||||
sub_label_clause = reduce(operator.or_, sub_label_clauses)
|
sub_label_clause = reduce(operator.or_, sub_label_clauses)
|
||||||
clauses.append(sub_label_clause)
|
clauses.append(sub_label_clause)
|
||||||
@@ -633,18 +629,14 @@ def events_search(
|
|||||||
sub_label_clauses.append(Event.sub_label.is_null())
|
sub_label_clauses.append(Event.sub_label.is_null())
|
||||||
|
|
||||||
for label in filtered_sub_labels:
|
for label in filtered_sub_labels:
|
||||||
lowered = label.lower()
|
# SQLite LOWER() only folds ASCII, so lower both sides in SQL to
|
||||||
sub_label_clauses.append(
|
# keep non-ASCII names matching exactly
|
||||||
fn.LOWER(Event.sub_label.cast("text")) == lowered
|
stored = fn.LOWER(Event.sub_label.cast("text"))
|
||||||
) # include exact matches (case-insensitive)
|
sub_label_clauses.append(stored == fn.LOWER(label))
|
||||||
|
|
||||||
# include this label when part of a list (LIKE is case-insensitive in sqlite for ASCII)
|
# include this label when part of a list
|
||||||
sub_label_clauses.append(
|
sub_label_clauses.append(stored % fn.LOWER(f"*{label},*"))
|
||||||
fn.LOWER(Event.sub_label.cast("text")) % f"*{lowered},*"
|
sub_label_clauses.append(stored % fn.LOWER(f"*, {label}*"))
|
||||||
)
|
|
||||||
sub_label_clauses.append(
|
|
||||||
fn.LOWER(Event.sub_label.cast("text")) % f"*, {lowered}*"
|
|
||||||
)
|
|
||||||
|
|
||||||
event_filters.append(reduce(operator.or_, sub_label_clauses))
|
event_filters.append(reduce(operator.or_, sub_label_clauses))
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ from frigate.util.image import create_mask
|
|||||||
from frigate.util.live_streams import (
|
from frigate.util.live_streams import (
|
||||||
default_transcode_source,
|
default_transcode_source,
|
||||||
is_transcode_stream_name,
|
is_transcode_stream_name,
|
||||||
|
is_valid_go2rtc_stream_name,
|
||||||
transcode_stream_name,
|
transcode_stream_name,
|
||||||
transcode_streams,
|
transcode_streams,
|
||||||
)
|
)
|
||||||
@@ -314,6 +315,11 @@ def apply_live_transcode_streams(
|
|||||||
f"Camera {camera_config.name} has transcoded streams enabled, but its source {transcode.source} is not a go2rtc stream."
|
f"Camera {camera_config.name} has transcoded streams enabled, but its source {transcode.source} is not a go2rtc stream."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if not is_valid_go2rtc_stream_name(transcode.source):
|
||||||
|
raise ValueError(
|
||||||
|
f"Camera {camera_config.name} transcodes go2rtc stream {transcode.source}, whose name contains '#', whitespace or quotes."
|
||||||
|
)
|
||||||
|
|
||||||
generated = transcode_streams(
|
generated = transcode_streams(
|
||||||
camera_config.name,
|
camera_config.name,
|
||||||
transcode.source,
|
transcode.source,
|
||||||
|
|||||||
@@ -84,3 +84,13 @@ class MqttConfig(FrigateBaseModel):
|
|||||||
if (self.user is None) != (self.password is None):
|
if (self.user is None) != (self.password is None):
|
||||||
raise ValueError("Password must be provided with username.")
|
raise ValueError("Password must be provided with username.")
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def tls_insecure_requires_tls(self) -> Self:
|
||||||
|
"""Reject TLS verification settings without TLS when MQTT is enabled."""
|
||||||
|
if self.enabled and self.tls_insecure is not None and self.tls_ca_certs is None:
|
||||||
|
raise ValueError(
|
||||||
|
"tls_insecure requires tls_ca_certs. Remove tls_insecure for plaintext "
|
||||||
|
"MQTT, or set tls_ca_certs to a CA certificate path to enable TLS."
|
||||||
|
)
|
||||||
|
return self
|
||||||
|
|||||||
@@ -68,6 +68,9 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
|||||||
self.review_desc_dps = EventsPerSecond()
|
self.review_desc_dps = EventsPerSecond()
|
||||||
self.review_desc_dps.start()
|
self.review_desc_dps.start()
|
||||||
|
|
||||||
|
# one review's ffmpeg extraction at a time, as on the maintainer loop
|
||||||
|
self._frame_extraction_lock = threading.Lock()
|
||||||
|
|
||||||
def calculate_frame_count(
|
def calculate_frame_count(
|
||||||
self,
|
self,
|
||||||
camera: str,
|
camera: str,
|
||||||
@@ -179,53 +182,72 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
|||||||
):
|
):
|
||||||
return
|
return
|
||||||
|
|
||||||
image_source = camera_config.review.genai.image_source
|
threading.Thread(
|
||||||
frame_mode = camera_config.review.genai.frame_mode
|
target=self._describe_finished_review,
|
||||||
|
name=f"review_description_{id}",
|
||||||
|
daemon=True,
|
||||||
|
args=(
|
||||||
|
camera_config,
|
||||||
|
final_data,
|
||||||
|
camera_config.review.genai.image_source,
|
||||||
|
),
|
||||||
|
).start()
|
||||||
|
|
||||||
|
def _describe_finished_review(
|
||||||
|
self,
|
||||||
|
camera_config: CameraConfig,
|
||||||
|
final_data: dict[str, Any],
|
||||||
|
image_source: ImageSourceEnum,
|
||||||
|
preview_fallback: bool = True,
|
||||||
|
named_objects: list[tuple[str, str]] | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Gather frames for a finished review item and start its description.
|
||||||
|
|
||||||
|
Runs off the maintainer loop since recording frames shell out to ffmpeg
|
||||||
|
once per frame.
|
||||||
|
"""
|
||||||
|
camera = final_data["camera"]
|
||||||
|
id = final_data["id"]
|
||||||
|
genai_config = camera_config.review.genai
|
||||||
|
frames: list[tuple[bytes, float]] = []
|
||||||
|
|
||||||
if image_source == ImageSourceEnum.recordings:
|
if image_source == ImageSourceEnum.recordings:
|
||||||
buffer_extension = get_recording_buffer_extension(
|
buffer_extension = get_recording_buffer_extension(
|
||||||
final_data["end_time"] - final_data["start_time"]
|
final_data["end_time"] - final_data["start_time"]
|
||||||
)
|
)
|
||||||
final_data["start_time"] -= buffer_extension
|
with self._frame_extraction_lock:
|
||||||
final_data["end_time"] += buffer_extension
|
|
||||||
|
|
||||||
frames = self.get_recording_frames(
|
frames = self.get_recording_frames(
|
||||||
camera,
|
camera,
|
||||||
final_data["start_time"],
|
final_data["start_time"] - buffer_extension,
|
||||||
final_data["end_time"],
|
final_data["end_time"] + buffer_extension,
|
||||||
height=480, # Use 480p for good balance between quality and token usage
|
height=480, # Use 480p for good balance between quality and token usage
|
||||||
frame_mode=frame_mode,
|
frame_mode=genai_config.frame_mode,
|
||||||
|
)
|
||||||
|
|
||||||
|
if frames:
|
||||||
|
if genai_config.debug_save_thumbnails:
|
||||||
|
self.save_debug_recording_frames(id, frames)
|
||||||
|
elif not preview_fallback:
|
||||||
|
logger.error("No recording frames are available for review item %s", id)
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"No recording frames found for %s, falling back to preview frames",
|
||||||
|
camera,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not frames:
|
if not frames:
|
||||||
# Fallback to preview frames if no recordings available
|
|
||||||
logger.warning(
|
|
||||||
f"No recording frames found for {camera}, falling back to preview frames"
|
|
||||||
)
|
|
||||||
frames = self.get_preview_frames_as_bytes(
|
frames = self.get_preview_frames_as_bytes(
|
||||||
camera,
|
camera,
|
||||||
final_data["start_time"],
|
final_data["start_time"],
|
||||||
final_data["end_time"],
|
final_data["end_time"],
|
||||||
final_data["thumb_path"],
|
final_data["thumb_path"],
|
||||||
id,
|
id,
|
||||||
camera_config.review.genai.debug_save_thumbnails,
|
genai_config.debug_save_thumbnails,
|
||||||
frame_mode,
|
genai_config.frame_mode,
|
||||||
)
|
|
||||||
elif camera_config.review.genai.debug_save_thumbnails:
|
|
||||||
self.save_debug_recording_frames(id, frames)
|
|
||||||
else:
|
|
||||||
# Use preview frames
|
|
||||||
frames = self.get_preview_frames_as_bytes(
|
|
||||||
camera,
|
|
||||||
final_data["start_time"],
|
|
||||||
final_data["end_time"],
|
|
||||||
final_data["thumb_path"],
|
|
||||||
id,
|
|
||||||
camera_config.review.genai.debug_save_thumbnails,
|
|
||||||
frame_mode,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
self.start_analysis(camera_config, final_data, frames)
|
self.start_analysis(camera_config, final_data, frames, named_objects)
|
||||||
|
|
||||||
def handle_request(self, topic: str, request_data: dict[str, Any]) -> str | None:
|
def handle_request(self, topic: str, request_data: dict[str, Any]) -> str | None:
|
||||||
if topic == EmbeddingsRequestEnum.regenerate_review_description.value:
|
if topic == EmbeddingsRequestEnum.regenerate_review_description.value:
|
||||||
@@ -419,31 +441,12 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
|||||||
logger.error("Review item %s has not ended yet", review_id)
|
logger.error("Review item %s has not ended yet", review_id)
|
||||||
return
|
return
|
||||||
|
|
||||||
buffer_extension = get_recording_buffer_extension(
|
self._describe_finished_review(
|
||||||
final_data["end_time"] - final_data["start_time"]
|
|
||||||
)
|
|
||||||
frames = self.get_recording_frames(
|
|
||||||
str(review.camera),
|
|
||||||
final_data["start_time"] - buffer_extension,
|
|
||||||
final_data["end_time"] + buffer_extension,
|
|
||||||
height=480,
|
|
||||||
frame_mode=camera_config.review.genai.frame_mode,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not frames:
|
|
||||||
logger.error(
|
|
||||||
"No recording frames are available for review item %s", review_id
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
if camera_config.review.genai.debug_save_thumbnails:
|
|
||||||
self.save_debug_recording_frames(review_id, frames)
|
|
||||||
|
|
||||||
self.start_analysis(
|
|
||||||
camera_config,
|
camera_config,
|
||||||
final_data,
|
final_data,
|
||||||
frames,
|
ImageSourceEnum.recordings,
|
||||||
get_named_objects(
|
preview_fallback=False,
|
||||||
|
named_objects=get_named_objects(
|
||||||
final_data["data"].get("detections") or [],
|
final_data["data"].get("detections") or [],
|
||||||
self.config.all_attributes,
|
self.config.all_attributes,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -16,18 +16,21 @@ from abc import ABC, abstractmethod
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import TYPE_CHECKING, Any, Optional, cast
|
from typing import TYPE_CHECKING, Any, Optional, cast
|
||||||
|
|
||||||
from peewee import ModelSelect
|
|
||||||
|
|
||||||
from frigate.config import FrigateConfig
|
from frigate.config import FrigateConfig
|
||||||
from frigate.config.camera.updater import CameraConfigUpdatePublisher
|
from frigate.config.camera.updater import CameraConfigUpdatePublisher
|
||||||
from frigate.const import REPLAY_CAMERA_PREFIX, REPLAY_DIR
|
from frigate.const import (
|
||||||
|
REPLAY_CAMERA_PREFIX,
|
||||||
|
REPLAY_DIR,
|
||||||
|
STREAM_TYPE_MAIN,
|
||||||
|
STREAM_TYPE_SUB,
|
||||||
|
)
|
||||||
from frigate.jobs.export import JobStatePublisher
|
from frigate.jobs.export import JobStatePublisher
|
||||||
from frigate.jobs.job import Job
|
from frigate.jobs.job import Job
|
||||||
from frigate.jobs.manager import job_is_running, set_current_job
|
from frigate.jobs.manager import job_is_running, set_current_job
|
||||||
from frigate.models import Export, Recordings
|
from frigate.models import Export
|
||||||
from frigate.types import JobStatusTypesEnum
|
from frigate.types import JobStatusTypesEnum
|
||||||
from frigate.util.ffmpeg import run_ffmpeg_with_progress
|
from frigate.util.ffmpeg import run_ffmpeg_with_progress
|
||||||
from frigate.util.recording_coverage import recording_overlap
|
from frigate.util.recording_coverage import resolve_coverage
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from frigate.debug_replay import DebugReplayManager
|
from frigate.debug_replay import DebugReplayManager
|
||||||
@@ -94,24 +97,6 @@ class DebugReplayJob(Job):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def query_recordings(source_camera: str, start_ts: float, end_ts: float) -> ModelSelect:
|
|
||||||
"""Return the Recordings query for the time range.
|
|
||||||
|
|
||||||
Module-level so tests can patch it without instantiating a runner.
|
|
||||||
"""
|
|
||||||
query = (
|
|
||||||
Recordings.select(
|
|
||||||
Recordings.path,
|
|
||||||
Recordings.start_time,
|
|
||||||
Recordings.end_time,
|
|
||||||
)
|
|
||||||
.where(recording_overlap(start_ts, end_ts))
|
|
||||||
.where(Recordings.camera == source_camera)
|
|
||||||
.order_by(Recordings.start_time.asc())
|
|
||||||
)
|
|
||||||
return cast(ModelSelect, query)
|
|
||||||
|
|
||||||
|
|
||||||
class NoRecordingsError(ValueError):
|
class NoRecordingsError(ValueError):
|
||||||
"""Raised when no recordings exist in the requested time range."""
|
"""Raised when no recordings exist in the requested time range."""
|
||||||
|
|
||||||
@@ -156,7 +141,8 @@ class RecordingDebugReplaySource(DebugReplaySource):
|
|||||||
|
|
||||||
Feeds ffmpeg the internal VOD endpoint so segments with mismatched
|
Feeds ffmpeg the internal VOD endpoint so segments with mismatched
|
||||||
SPS/PPS (e.g. across day/night transitions) stitch cleanly via HLS
|
SPS/PPS (e.g. across day/night transitions) stitch cleanly via HLS
|
||||||
discontinuities.
|
discontinuities. The playlist is pinned to one stream because the
|
||||||
|
stream-copied clip cannot change codec or resolution mid-file.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -170,6 +156,7 @@ class RecordingDebugReplaySource(DebugReplaySource):
|
|||||||
self._start_ts = start_ts
|
self._start_ts = start_ts
|
||||||
self._end_ts = end_ts
|
self._end_ts = end_ts
|
||||||
self._internal_port = internal_port
|
self._internal_port = internal_port
|
||||||
|
self._stream_type = STREAM_TYPE_MAIN
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def source_camera(self) -> str:
|
def source_camera(self) -> str:
|
||||||
@@ -187,7 +174,19 @@ class RecordingDebugReplaySource(DebugReplaySource):
|
|||||||
if self._end_ts <= self._start_ts:
|
if self._end_ts <= self._start_ts:
|
||||||
raise ValueError("End time must be after start time")
|
raise ValueError("End time must be after start time")
|
||||||
|
|
||||||
if not query_recordings(self._camera, self._start_ts, self._end_ts).count():
|
# the VOD resolver decides what the pinned playlist serves, so it
|
||||||
|
# also decides the stream; a row merely touching the range has no
|
||||||
|
# interval and must not win
|
||||||
|
intervals = resolve_coverage(self._camera, self._start_ts, self._end_ts)
|
||||||
|
|
||||||
|
if any(interval.main for interval in intervals):
|
||||||
|
self._stream_type = STREAM_TYPE_MAIN
|
||||||
|
return
|
||||||
|
|
||||||
|
if any(interval.sub for interval in intervals):
|
||||||
|
self._stream_type = STREAM_TYPE_SUB
|
||||||
|
return
|
||||||
|
|
||||||
raise NoRecordingsError(
|
raise NoRecordingsError(
|
||||||
f"No recordings found for camera '{self._camera}' in the specified time range"
|
f"No recordings found for camera '{self._camera}' in the specified time range"
|
||||||
)
|
)
|
||||||
@@ -195,7 +194,8 @@ class RecordingDebugReplaySource(DebugReplaySource):
|
|||||||
def ffmpeg_input_args(self, working_dir: str) -> list[str]:
|
def ffmpeg_input_args(self, working_dir: str) -> list[str]:
|
||||||
playlist_url = (
|
playlist_url = (
|
||||||
f"http://127.0.0.1:{self._internal_port}/vod/{self._camera}"
|
f"http://127.0.0.1:{self._internal_port}/vod/{self._camera}"
|
||||||
f"/start/{self._start_ts}/end/{self._end_ts}/index.m3u8"
|
f"/{self._stream_type}/start/{self._start_ts}/end/{self._end_ts}"
|
||||||
|
"/index.m3u8"
|
||||||
)
|
)
|
||||||
return [
|
return [
|
||||||
"-protocol_whitelist",
|
"-protocol_whitelist",
|
||||||
|
|||||||
+81
-1
@@ -3,9 +3,11 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
from enum import Enum
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any, TypeGuard
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
import cv2
|
import cv2
|
||||||
import requests
|
import requests
|
||||||
from numpy import ndarray
|
from numpy import ndarray
|
||||||
@@ -17,6 +19,17 @@ from frigate.const import MODEL_CACHE_DIR, PLUS_API_HOST, PLUS_ENV_VAR
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
PLUS_VALIDATE_TIMEOUT = 10
|
||||||
|
|
||||||
|
|
||||||
|
class PlusKeyStatus(str, Enum):
|
||||||
|
"""Result of validating a Frigate+ API key with the server."""
|
||||||
|
|
||||||
|
valid = "valid"
|
||||||
|
invalid = "invalid"
|
||||||
|
unreachable = "unreachable"
|
||||||
|
not_set = "not_set"
|
||||||
|
|
||||||
|
|
||||||
def add_hailo_alias(model_info: dict[str, Any]) -> dict[str, Any]:
|
def add_hailo_alias(model_info: dict[str, Any]) -> dict[str, Any]:
|
||||||
"""Name the hailo detector by its current key as well as its old one.
|
"""Name the hailo detector by its current key as well as its old one.
|
||||||
@@ -72,6 +85,19 @@ def get_jpg_bytes(image: ndarray, max_dim: int, quality: int) -> bytes:
|
|||||||
return jpg_bytes if isinstance(jpg_bytes, bytes) else b""
|
return jpg_bytes if isinstance(jpg_bytes, bytes) else b""
|
||||||
|
|
||||||
|
|
||||||
|
def _is_usable_token(token_data: Any) -> TypeGuard[dict[str, Any]]:
|
||||||
|
"""Whether a Frigate+ token response has a usable token and expiry."""
|
||||||
|
if not isinstance(token_data, dict):
|
||||||
|
return False
|
||||||
|
|
||||||
|
expires = token_data.get("expires")
|
||||||
|
return (
|
||||||
|
isinstance(token_data.get("accessToken"), str)
|
||||||
|
and isinstance(expires, (int, float))
|
||||||
|
and not isinstance(expires, bool)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class PlusApi:
|
class PlusApi:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.host = PLUS_API_HOST
|
self.host = PLUS_API_HOST
|
||||||
@@ -153,6 +179,60 @@ class PlusApi:
|
|||||||
def is_active(self) -> bool:
|
def is_active(self) -> bool:
|
||||||
return self._is_active
|
return self._is_active
|
||||||
|
|
||||||
|
async def validate_key(self) -> PlusKeyStatus:
|
||||||
|
"""Check the configured API key against the Frigate+ server.
|
||||||
|
|
||||||
|
The key format is checked at startup, but only the server can tell
|
||||||
|
whether the key is actually valid. This requests a fresh token so the
|
||||||
|
result reflects the key's current state rather than a cached token.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The key's status as reported by the Frigate+ server
|
||||||
|
"""
|
||||||
|
if self.key is None:
|
||||||
|
return PlusKeyStatus.not_set
|
||||||
|
|
||||||
|
key_id, _, secret = self.key.partition(":")
|
||||||
|
|
||||||
|
# Use a single request with a timeout instead of the retrying session,
|
||||||
|
# since this is user initiated and should fail fast when unreachable.
|
||||||
|
# trust_env honors proxy settings, matching the other Frigate+ requests.
|
||||||
|
try:
|
||||||
|
async with aiohttp.ClientSession(
|
||||||
|
timeout=aiohttp.ClientTimeout(total=PLUS_VALIDATE_TIMEOUT),
|
||||||
|
trust_env=True,
|
||||||
|
) as session:
|
||||||
|
async with session.get(
|
||||||
|
f"{self.host}/v1/auth/token",
|
||||||
|
auth=aiohttp.BasicAuth(key_id, secret),
|
||||||
|
) as r:
|
||||||
|
status = r.status
|
||||||
|
token_data = await r.json(content_type=None) if r.ok else None
|
||||||
|
except (aiohttp.ClientError, TimeoutError) as err:
|
||||||
|
logger.warning("Unable to reach Frigate+ to validate API key: %s", err)
|
||||||
|
return PlusKeyStatus.unreachable
|
||||||
|
except ValueError:
|
||||||
|
logger.warning("Invalid response validating Frigate+ API key")
|
||||||
|
return PlusKeyStatus.unreachable
|
||||||
|
|
||||||
|
if status in (401, 403):
|
||||||
|
return PlusKeyStatus.invalid
|
||||||
|
|
||||||
|
if status >= 400:
|
||||||
|
logger.warning(
|
||||||
|
"Unexpected response validating Frigate+ API key: %s", status
|
||||||
|
)
|
||||||
|
return PlusKeyStatus.unreachable
|
||||||
|
|
||||||
|
# Keep the cached token unless the reply is a usable one, so an
|
||||||
|
# unexpected response can't break later Frigate+ requests
|
||||||
|
if not _is_usable_token(token_data):
|
||||||
|
logger.warning("Invalid response validating Frigate+ API key")
|
||||||
|
return PlusKeyStatus.unreachable
|
||||||
|
|
||||||
|
self._token_data = token_data
|
||||||
|
return PlusKeyStatus.valid
|
||||||
|
|
||||||
def upload_image(self, image: ndarray, camera: str) -> str:
|
def upload_image(self, image: ndarray, camera: str) -> str:
|
||||||
r = self._get("image/signed_urls")
|
r = self._get("image/signed_urls")
|
||||||
if not r.ok:
|
if not r.ok:
|
||||||
|
|||||||
+40
-51
@@ -585,26 +585,36 @@ class PtzAutoTracker(threading.Thread):
|
|||||||
|
|
||||||
def _predict_movement_time(self, camera, pan, tilt):
|
def _predict_movement_time(self, camera, pan, tilt):
|
||||||
combined_movement = abs(pan) + abs(tilt)
|
combined_movement = abs(pan) + abs(tilt)
|
||||||
input_data = np.array([self.intercept[camera], combined_movement])
|
input_data = np.array([1, combined_movement])
|
||||||
|
|
||||||
return np.dot(self.move_coefficients[camera], input_data)
|
return np.dot(self.move_coefficients[camera], input_data)
|
||||||
|
|
||||||
def _predict_area_after_time(self, camera, time):
|
|
||||||
return np.dot(
|
|
||||||
self.tracked_object_metrics[camera]["area_coefficients"],
|
|
||||||
[self.tracked_object_history[camera][-1]["frame_time"] + time],
|
|
||||||
)
|
|
||||||
|
|
||||||
def _predict_target_box(self, camera, predicted_time):
|
def _predict_target_box(self, camera, predicted_time):
|
||||||
target_box = self.tracked_object_metrics[camera]["target_box"]
|
tom = self.tracked_object_metrics[camera]
|
||||||
|
target_box = tom["target_box"]
|
||||||
|
|
||||||
if not predicted_time:
|
if not predicted_time:
|
||||||
return target_box
|
return target_box
|
||||||
|
|
||||||
frame_shape = self.config.cameras[camera].frame_shape
|
camera_config = self.config.cameras[camera]
|
||||||
|
left, top, right, bottom = self.tracked_object_history[camera][-1]["box"]
|
||||||
|
size = max(right - left, bottom - top)
|
||||||
|
|
||||||
return target_box + self._predict_area_after_time(camera, predicted_time) / (
|
if size <= 0:
|
||||||
frame_shape[0] * frame_shape[1]
|
return target_box
|
||||||
|
|
||||||
|
# the corner velocities diverge as the object approaches the camera
|
||||||
|
frames = camera_config.detect.fps * predicted_time
|
||||||
|
velocity = tom["velocity"]
|
||||||
|
predicted_size = max(
|
||||||
|
right - left + frames * (velocity[2] - velocity[0]),
|
||||||
|
bottom - top + frames * (velocity[3] - velocity[1]),
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
# target_box is the squared size raised to the zoom factor
|
||||||
|
return target_box * (predicted_size / size) ** (
|
||||||
|
2 * camera_config.onvif.autotracking.zoom_factor
|
||||||
)
|
)
|
||||||
|
|
||||||
def _calculate_tracked_object_metrics(self, camera, obj):
|
def _calculate_tracked_object_metrics(self, camera, obj):
|
||||||
@@ -660,26 +670,6 @@ class PtzAutoTracker(threading.Thread):
|
|||||||
|
|
||||||
filtered_areas = remove_outliers(areas) if len(areas) > 3 else areas
|
filtered_areas = remove_outliers(areas) if len(areas) > 3 else areas
|
||||||
|
|
||||||
# Filter entries that are not touching the frame edge
|
|
||||||
filtered_areas_not_touching_edge = [
|
|
||||||
entry
|
|
||||||
for entry in filtered_areas
|
|
||||||
if self._touching_frame_edges(camera, entry["box"]) == 0
|
|
||||||
]
|
|
||||||
|
|
||||||
# Calculate regression for area change predictions
|
|
||||||
if len(filtered_areas_not_touching_edge):
|
|
||||||
X = np.array(
|
|
||||||
[item["frame_time"] for item in filtered_areas_not_touching_edge]
|
|
||||||
)
|
|
||||||
y = np.array([item["area"] for item in filtered_areas_not_touching_edge])
|
|
||||||
|
|
||||||
tom["area_coefficients"] = np.linalg.lstsq(X.reshape(-1, 1), y, rcond=None)[
|
|
||||||
0
|
|
||||||
]
|
|
||||||
else:
|
|
||||||
tom["area_coefficients"] = np.array([0])
|
|
||||||
|
|
||||||
weights = np.arange(1, len(filtered_areas) + 1)
|
weights = np.arange(1, len(filtered_areas) + 1)
|
||||||
weighted_area = np.average(
|
weighted_area = np.average(
|
||||||
[item["area"] for item in filtered_areas], weights=weights
|
[item["area"] for item in filtered_areas], weights=weights
|
||||||
@@ -744,6 +734,8 @@ class PtzAutoTracker(threading.Thread):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
else:
|
else:
|
||||||
|
pan_tilt_times = None
|
||||||
|
|
||||||
if (
|
if (
|
||||||
camera_config.onvif.autotracking.zooming
|
camera_config.onvif.autotracking.zooming
|
||||||
== ZoomingModeEnum.relative
|
== ZoomingModeEnum.relative
|
||||||
@@ -755,17 +747,28 @@ class PtzAutoTracker(threading.Thread):
|
|||||||
|
|
||||||
await self._wait_until_stopped(camera, metrics)
|
await self._wait_until_stopped(camera, metrics)
|
||||||
|
|
||||||
|
# _zoom_absolute resets the metrics timestamps
|
||||||
|
pan_tilt_times = (
|
||||||
|
metrics.start_time.value,
|
||||||
|
metrics.stop_time.value,
|
||||||
|
)
|
||||||
|
|
||||||
if zoom > 0 and metrics.zoom_level.value != zoom:
|
if zoom > 0 and metrics.zoom_level.value != zoom:
|
||||||
await self.onvif._zoom_absolute(camera, zoom, 1)
|
await self.onvif._zoom_absolute(camera, zoom, 1)
|
||||||
|
|
||||||
await self._wait_until_stopped(camera, metrics)
|
await self._wait_until_stopped(camera, metrics)
|
||||||
|
|
||||||
|
start_time, stop_time = pan_tilt_times or (
|
||||||
|
metrics.start_time.value,
|
||||||
|
metrics.stop_time.value,
|
||||||
|
)
|
||||||
|
|
||||||
if camera_config.onvif.autotracking.movement_weights:
|
if camera_config.onvif.autotracking.movement_weights:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"{camera}: Predicted movement time: {self._predict_movement_time(camera, pan, tilt)}"
|
f"{camera}: Predicted movement time: {self._predict_movement_time(camera, pan, tilt)}"
|
||||||
)
|
)
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"{camera}: Actual movement time: {metrics.stop_time.value - metrics.start_time.value}"
|
f"{camera}: Actual movement time: {stop_time - start_time}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# save metrics for better estimate calculations
|
# save metrics for better estimate calculations
|
||||||
@@ -781,8 +784,8 @@ class PtzAutoTracker(threading.Thread):
|
|||||||
{
|
{
|
||||||
"pan": pan,
|
"pan": pan,
|
||||||
"tilt": tilt,
|
"tilt": tilt,
|
||||||
"start_timestamp": metrics.start_time.value,
|
"start_timestamp": start_time,
|
||||||
"end_timestamp": metrics.stop_time.value,
|
"end_timestamp": stop_time,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -845,7 +848,6 @@ class PtzAutoTracker(threading.Thread):
|
|||||||
# Thresholds
|
# Thresholds
|
||||||
x_mags_thresh = camera_width / camera_fps / 2
|
x_mags_thresh = camera_width / camera_fps / 2
|
||||||
y_mags_thresh = camera_height / camera_fps / 2
|
y_mags_thresh = camera_height / camera_fps / 2
|
||||||
dir_thresh = 0.93
|
|
||||||
delta_thresh = 20
|
delta_thresh = 20
|
||||||
var_thresh = 10
|
var_thresh = 10
|
||||||
|
|
||||||
@@ -863,24 +865,12 @@ class PtzAutoTracker(threading.Thread):
|
|||||||
stdev_list = np.std(velocities, axis=0)
|
stdev_list = np.std(velocities, axis=0)
|
||||||
high_variances = np.any(stdev_list > var_thresh)
|
high_variances = np.any(stdev_list > var_thresh)
|
||||||
|
|
||||||
# Check direction difference
|
# the corners of an object moving toward or away from the camera move
|
||||||
|
# in different directions, so the delta bounds how far they may differ
|
||||||
velocities = np.round(velocities)
|
velocities = np.round(velocities)
|
||||||
invalid_dirs = False
|
|
||||||
if np.all(np.linalg.norm(velocities, axis=1)):
|
|
||||||
cosine_sim = np.dot(velocities[0], velocities[1]) / (
|
|
||||||
np.linalg.norm(velocities[0]) * np.linalg.norm(velocities[1])
|
|
||||||
)
|
|
||||||
dir_thresh = 0.6 if np.all(delta < delta_thresh / 2) else dir_thresh
|
|
||||||
invalid_dirs = cosine_sim < dir_thresh
|
|
||||||
|
|
||||||
# Combine
|
# Combine
|
||||||
invalid = (
|
invalid = invalid_x_mags or invalid_y_mags or invalid_delta or high_variances
|
||||||
invalid_x_mags
|
|
||||||
or invalid_y_mags
|
|
||||||
or invalid_dirs
|
|
||||||
or invalid_delta
|
|
||||||
or high_variances
|
|
||||||
)
|
|
||||||
|
|
||||||
if invalid:
|
if invalid:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
@@ -891,7 +881,6 @@ class PtzAutoTracker(threading.Thread):
|
|||||||
for var_name, is_invalid in [
|
for var_name, is_invalid in [
|
||||||
("invalid_x_mags", invalid_x_mags),
|
("invalid_x_mags", invalid_x_mags),
|
||||||
("invalid_y_mags", invalid_y_mags),
|
("invalid_y_mags", invalid_y_mags),
|
||||||
("invalid_dirs", invalid_dirs),
|
|
||||||
("invalid_delta", invalid_delta),
|
("invalid_delta", invalid_delta),
|
||||||
("high_variances", high_variances),
|
("high_variances", high_variances),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import tempfile
|
|||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import MagicMock, Mock, patch
|
from unittest.mock import MagicMock, Mock, patch
|
||||||
|
|
||||||
|
import requests
|
||||||
import ruamel.yaml
|
import ruamel.yaml
|
||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
|
|
||||||
@@ -234,6 +235,72 @@ class TestAuthInternalPort(BaseTestHttp):
|
|||||||
finally:
|
finally:
|
||||||
os.unlink(config_path)
|
os.unlink(config_path)
|
||||||
|
|
||||||
|
@patch(
|
||||||
|
"frigate.api.auth.requests.get",
|
||||||
|
side_effect=requests.ConnectionError("go2rtc unavailable"),
|
||||||
|
)
|
||||||
|
def test_internal_port_rejects_unknown_go2rtc_src(self, _mock_get):
|
||||||
|
app = self._create_app()
|
||||||
|
|
||||||
|
with AuthTestClient(app) as client:
|
||||||
|
for url in (
|
||||||
|
"/live/mse/api/ws?src=exec:id",
|
||||||
|
"/live/webrtc/api/ws?src=ffmpeg:x%23raw=-f",
|
||||||
|
"/api/go2rtc/webrtc?src=front_door&src=unknown",
|
||||||
|
):
|
||||||
|
resp = client.get(
|
||||||
|
"/auth",
|
||||||
|
headers={"x-server-port": "5000", "x-original-url": url},
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, 403, url)
|
||||||
|
|
||||||
|
def test_internal_port_allows_existing_go2rtc_stream(self):
|
||||||
|
app = self._create_app()
|
||||||
|
|
||||||
|
with AuthTestClient(app) as client:
|
||||||
|
resp = client.get(
|
||||||
|
"/auth",
|
||||||
|
headers={
|
||||||
|
"x-server-port": "5000",
|
||||||
|
"x-original-url": "/live/mse/api/ws?src=front_door",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(resp.status_code, 202)
|
||||||
|
self.assertEqual(resp.headers["remote-role"], "admin")
|
||||||
|
|
||||||
|
def test_internal_port_skips_go2rtc_check_for_other_paths(self):
|
||||||
|
app = self._create_app()
|
||||||
|
|
||||||
|
with AuthTestClient(app) as client:
|
||||||
|
for url in ("/api/stats", "/api/go2rtc/streams?src=unknown"):
|
||||||
|
resp = client.get(
|
||||||
|
"/auth",
|
||||||
|
headers={"x-server-port": "5000", "x-original-url": url},
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, 202, url)
|
||||||
|
|
||||||
|
@patch(
|
||||||
|
"frigate.api.auth.requests.get",
|
||||||
|
side_effect=requests.ConnectionError("go2rtc unavailable"),
|
||||||
|
)
|
||||||
|
def test_live_stream_value_is_not_trusted_as_a_go2rtc_stream(self, _mock_get):
|
||||||
|
src = "ffmpeg:x#raw=-f"
|
||||||
|
self.minimal_config["cameras"]["front_door"]["live"] = {
|
||||||
|
"streams": {"Main": src}
|
||||||
|
}
|
||||||
|
app = self._create_app()
|
||||||
|
|
||||||
|
with AuthTestClient(app) as client:
|
||||||
|
resp = client.get(
|
||||||
|
"/auth",
|
||||||
|
headers={
|
||||||
|
"x-server-port": "5000",
|
||||||
|
"x-original-url": "/live/mse/api/ws?src=ffmpeg:x%23raw=-f",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, 403)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main(verbosity=2)
|
unittest.main(verbosity=2)
|
||||||
|
|||||||
@@ -151,6 +151,19 @@ class TestConfigGo2rtcStreamRestrictions(BaseTestHttp):
|
|||||||
assert resp.status_code == 200, resp.text
|
assert resp.status_code == 200, resp.text
|
||||||
assert _EXISTING_SRC in self._read_config()
|
assert _EXISTING_SRC in self._read_config()
|
||||||
|
|
||||||
|
def test_set_rejects_stream_name_with_ffmpeg_params(self):
|
||||||
|
before = self._read_config()
|
||||||
|
resp = self._set_streams(
|
||||||
|
{"x#raw=-f#raw=mpegts#raw=/tmp/x": "rtsp://10.0.0.2:554/video"}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert "Invalid go2rtc stream name" in resp.json()["message"]
|
||||||
|
assert self._read_config() == before
|
||||||
|
|
||||||
|
def test_set_allows_plain_stream_names(self):
|
||||||
|
resp = self._set_streams({"back-yard.main_2": "rtsp://10.0.0.2:554/video"})
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
def test_set_allows_injected_source_when_override_set(self):
|
def test_set_allows_injected_source_when_override_set(self):
|
||||||
with patch("frigate.util.services._GO2RTC_ARBITRARY_EXEC_ENV", "true"):
|
with patch("frigate.util.services._GO2RTC_ARBITRARY_EXEC_ENV", "true"):
|
||||||
resp = self._set_streams({"new_stream": [_INJECTED_SRC]})
|
resp = self._set_streams({"new_stream": [_INJECTED_SRC]})
|
||||||
|
|||||||
@@ -485,6 +485,40 @@ class TestHttpApp(BaseTestHttp):
|
|||||||
assert sub_labels
|
assert sub_labels
|
||||||
assert sub_labels == [sub_label]
|
assert sub_labels == [sub_label]
|
||||||
|
|
||||||
|
def test_get_event_list_filter_sub_labels(self):
|
||||||
|
sub_labels = {
|
||||||
|
"1.cyrillic": "Кузнецова Ира",
|
||||||
|
"2.joined": "Ира, Bob",
|
||||||
|
"3.ascii": "Alice",
|
||||||
|
"4.none": None,
|
||||||
|
}
|
||||||
|
mock_embeddings = Mock()
|
||||||
|
mock_embeddings.search_thumbnail.return_value = [(id, 0.1) for id in sub_labels]
|
||||||
|
|
||||||
|
self.app.frigate_config.semantic_search.enabled = True
|
||||||
|
self.app.embeddings = mock_embeddings
|
||||||
|
|
||||||
|
with AuthTestClient(self.app) as client:
|
||||||
|
for id, sub_label in sub_labels.items():
|
||||||
|
super().insert_mock_event(id)
|
||||||
|
Event.update(sub_label=sub_label).where(Event.id == id).execute()
|
||||||
|
|
||||||
|
search_params = {"search_type": "similarity", "event_id": "1.cyrillic"}
|
||||||
|
|
||||||
|
for path, params in (("/events", {}), ("/events/search", search_params)):
|
||||||
|
|
||||||
|
def ids(query: str) -> set[str]:
|
||||||
|
events = client.get(
|
||||||
|
path, params={**params, "sub_labels": query}
|
||||||
|
).json()
|
||||||
|
return {event["id"] for event in events}
|
||||||
|
|
||||||
|
assert ids("Кузнецова Ира") == {"1.cyrillic"}
|
||||||
|
assert ids("Ира") == {"2.joined"}
|
||||||
|
assert ids("bob") == {"2.joined"}
|
||||||
|
assert ids("ALICE") == {"3.ascii"}
|
||||||
|
assert ids("None") == {"4.none"}
|
||||||
|
|
||||||
####################################################################################################################
|
####################################################################################################################
|
||||||
################################### GET /metrics Endpoint #########################################################
|
################################### GET /metrics Endpoint #########################################################
|
||||||
####################################################################################################################
|
####################################################################################################################
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""Tests for the Frigate+ API endpoints."""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from frigate.models import Event
|
||||||
|
from frigate.plus import PlusKeyStatus
|
||||||
|
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
|
||||||
|
|
||||||
|
|
||||||
|
class TestHttpPlusValidate(BaseTestHttp):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp([Event])
|
||||||
|
|
||||||
|
def _validate(self, status: PlusKeyStatus) -> dict:
|
||||||
|
app = self.create_app()
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
app.frigate_config.plus_api, "validate_key", return_value=status
|
||||||
|
):
|
||||||
|
with AuthTestClient(app) as client:
|
||||||
|
response = client.post("/plus/validate")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
def test_a_valid_key(self):
|
||||||
|
self.assertEqual(
|
||||||
|
self._validate(PlusKeyStatus.valid), {"success": True, "status": "valid"}
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_an_invalid_key(self):
|
||||||
|
self.assertEqual(
|
||||||
|
self._validate(PlusKeyStatus.invalid),
|
||||||
|
{"success": False, "status": "invalid"},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_viewers_cannot_validate(self):
|
||||||
|
app = self.create_app()
|
||||||
|
|
||||||
|
with AuthTestClient(app) as client:
|
||||||
|
response = client.post(
|
||||||
|
"/plus/validate",
|
||||||
|
headers={"remote-user": "viewer", "remote-role": "viewer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main(verbosity=2)
|
||||||
@@ -12,6 +12,7 @@ from frigate.analytics.collectors.common import closed, histogram, rate
|
|||||||
from frigate.analytics.schema import (
|
from frigate.analytics.schema import (
|
||||||
Arch,
|
Arch,
|
||||||
DecodeFamily,
|
DecodeFamily,
|
||||||
|
FfmpegSource,
|
||||||
GpuVendor,
|
GpuVendor,
|
||||||
HardwareKey,
|
HardwareKey,
|
||||||
ImageVariant,
|
ImageVariant,
|
||||||
@@ -56,6 +57,36 @@ class TestInstallCollector(unittest.TestCase):
|
|||||||
self.assertEqual(section.arch, Arch.aarch64)
|
self.assertEqual(section.arch, Arch.aarch64)
|
||||||
self.assertEqual(section.kernel, "6.8")
|
self.assertEqual(section.kernel, "6.8")
|
||||||
self.assertFalse(section.run_as_root)
|
self.assertFalse(section.run_as_root)
|
||||||
|
self.assertEqual(section.ffmpeg, FfmpegSource.default)
|
||||||
|
|
||||||
|
def test_ffmpeg_source_follows_path_resolution(self):
|
||||||
|
with (
|
||||||
|
patch.object(install, "DEFAULT_FFMPEG_VERSION", "8.0"),
|
||||||
|
patch.object(install, "INCLUDED_FFMPEG_VERSIONS", ["8.0", "7.0"]),
|
||||||
|
):
|
||||||
|
self.assertEqual(install.ffmpeg_source("default"), FfmpegSource.default)
|
||||||
|
self.assertEqual(install.ffmpeg_source("8.0"), FfmpegSource.default)
|
||||||
|
self.assertEqual(install.ffmpeg_source("4.4"), FfmpegSource.default)
|
||||||
|
self.assertEqual(install.ffmpeg_source("7.0"), FfmpegSource.bundled)
|
||||||
|
self.assertEqual(
|
||||||
|
install.ffmpeg_source("/config/custom-ffmpeg"), FfmpegSource.custom
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
install.ffmpeg_source("/config/a b; rm"), FfmpegSource.default
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_custom_go2rtc_needs_an_executable_binary(self):
|
||||||
|
with tempfile.TemporaryDirectory() as config_dir:
|
||||||
|
binary = os.path.join(config_dir, "go2rtc")
|
||||||
|
|
||||||
|
with patch.object(install, "CONFIG_DIR", config_dir):
|
||||||
|
self.assertFalse(install.collect(make_context()).custom_go2rtc)
|
||||||
|
|
||||||
|
open(binary, "w").close()
|
||||||
|
self.assertFalse(install.collect(make_context()).custom_go2rtc)
|
||||||
|
|
||||||
|
os.chmod(binary, 0o755)
|
||||||
|
self.assertTrue(install.collect(make_context()).custom_go2rtc)
|
||||||
|
|
||||||
def test_image_variant_is_dev_when_unset_and_other_when_unknown(self):
|
def test_image_variant_is_dev_when_unset_and_other_when_unknown(self):
|
||||||
self.assertEqual(install.image_variant(None), ImageVariant.dev)
|
self.assertEqual(install.image_variant(None), ImageVariant.dev)
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ INSTALL = InstallSection(
|
|||||||
arch="x86_64",
|
arch="x86_64",
|
||||||
kernel="6.8",
|
kernel="6.8",
|
||||||
run_as_root=False,
|
run_as_root=False,
|
||||||
|
custom_go2rtc=False,
|
||||||
|
ffmpeg="default",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Tests for migrating detectors and model into the models list."""
|
"""Tests for migrating detectors and model into the models list."""
|
||||||
|
|
||||||
|
import errno
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -240,6 +241,42 @@ class TestMigrateConfigFile(unittest.TestCase):
|
|||||||
with open(self.config_file) as f:
|
with open(self.config_file) as f:
|
||||||
return YAML().load(f)
|
return YAML().load(f)
|
||||||
|
|
||||||
|
def test_a_failed_write_leaves_the_original_config(self):
|
||||||
|
original = "mqtt:\n enabled: false\ncameras: {}\n"
|
||||||
|
with open(self.config_file, "w") as f:
|
||||||
|
f.write(original)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(YAML, "dump", side_effect=RuntimeError),
|
||||||
|
self.assertRaises(RuntimeError),
|
||||||
|
):
|
||||||
|
migrate_frigate_config(self.config_file)
|
||||||
|
|
||||||
|
with open(self.config_file) as f:
|
||||||
|
self.assertEqual(f.read(), original)
|
||||||
|
|
||||||
|
def test_a_file_bind_mount_is_written_in_place(self):
|
||||||
|
with patch(
|
||||||
|
"frigate.util.config.os.replace",
|
||||||
|
side_effect=OSError(errno.EBUSY, "Device or resource busy"),
|
||||||
|
):
|
||||||
|
migrated = self._migrate("mqtt:\n enabled: false\ncameras: {}\n")
|
||||||
|
|
||||||
|
self.assertEqual(migrated["version"], CURRENT_CONFIG_VERSION)
|
||||||
|
self.assertFalse(os.path.exists(f"{self.config_file}.tmp"))
|
||||||
|
|
||||||
|
def test_a_symlinked_config_migrates_its_target(self):
|
||||||
|
target = os.path.join(self.temp_dir.name, "frigate.yml")
|
||||||
|
with open(target, "w") as f:
|
||||||
|
f.write("mqtt:\n enabled: false\ncameras: {}\n")
|
||||||
|
os.symlink(target, self.config_file)
|
||||||
|
|
||||||
|
migrate_frigate_config(self.config_file)
|
||||||
|
|
||||||
|
self.assertTrue(os.path.islink(self.config_file))
|
||||||
|
with open(target) as f:
|
||||||
|
self.assertEqual(YAML().load(f)["version"], CURRENT_CONFIG_VERSION)
|
||||||
|
|
||||||
def test_migrates_a_config_already_stamped_with_the_current_version(self):
|
def test_migrates_a_config_already_stamped_with_the_current_version(self):
|
||||||
# 0.19 is unreleased, so a dev config can be current and still use
|
# 0.19 is unreleased, so a dev config can be current and still use
|
||||||
# the pre-models keys
|
# the pre-models keys
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from frigate.jobs.debug_replay import (
|
|||||||
from frigate.jobs.export import JobStatePublisher
|
from frigate.jobs.export import JobStatePublisher
|
||||||
from frigate.jobs.manager import _completed_jobs, _current_jobs
|
from frigate.jobs.manager import _completed_jobs, _current_jobs
|
||||||
from frigate.types import JobStatusTypesEnum
|
from frigate.types import JobStatusTypesEnum
|
||||||
|
from frigate.util.recording_coverage import CoverageInterval
|
||||||
|
|
||||||
|
|
||||||
def _reset_job_manager() -> None:
|
def _reset_job_manager() -> None:
|
||||||
@@ -77,6 +78,31 @@ class TestDebugReplayJob(unittest.TestCase):
|
|||||||
self.assertEqual(results["end_ts"], 200.0)
|
self.assertEqual(results["end_ts"], 200.0)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRecordingDebugReplaySource(unittest.TestCase):
|
||||||
|
def _source_url(self, intervals: list[CoverageInterval]) -> str:
|
||||||
|
source = RecordingDebugReplaySource(
|
||||||
|
source_camera="front", start_ts=100.0, end_ts=200.0, internal_port=5000
|
||||||
|
)
|
||||||
|
with patch(
|
||||||
|
"frigate.jobs.debug_replay.resolve_coverage", return_value=intervals
|
||||||
|
):
|
||||||
|
source.validate()
|
||||||
|
return source.ffmpeg_input_args("/tmp")[-1]
|
||||||
|
|
||||||
|
def test_pins_main_when_main_has_recordings(self) -> None:
|
||||||
|
url = self._source_url(
|
||||||
|
[
|
||||||
|
CoverageInterval(100.0, 150.0, None, MagicMock()),
|
||||||
|
CoverageInterval(150.0, 200.0, MagicMock(), MagicMock()),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
self.assertIn("/vod/front/main/start/100.0/end/200.0/", url)
|
||||||
|
|
||||||
|
def test_falls_back_to_sub_without_main_recordings(self) -> None:
|
||||||
|
url = self._source_url([CoverageInterval(100.0, 200.0, None, MagicMock())])
|
||||||
|
self.assertIn("/vod/front/sub/start/100.0/end/200.0/", url)
|
||||||
|
|
||||||
|
|
||||||
class TestStartDebugReplayJob(unittest.TestCase):
|
class TestStartDebugReplayJob(unittest.TestCase):
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
_reset_job_manager()
|
_reset_job_manager()
|
||||||
@@ -87,9 +113,7 @@ class TestStartDebugReplayJob(unittest.TestCase):
|
|||||||
self.frigate_config.ffmpeg.ffmpeg_path = "/bin/true"
|
self.frigate_config.ffmpeg.ffmpeg_path = "/bin/true"
|
||||||
self.publisher = MagicMock()
|
self.publisher = MagicMock()
|
||||||
|
|
||||||
self.recordings_qs = MagicMock()
|
self.coverage = [CoverageInterval(100.0, 200.0, MagicMock(), None)]
|
||||||
self.recordings_qs.count.return_value = 1
|
|
||||||
self.recordings_qs.__iter__.return_value = iter([MagicMock(path="/tmp/r1.mp4")])
|
|
||||||
|
|
||||||
def tearDown(self) -> None:
|
def tearDown(self) -> None:
|
||||||
runner = get_active_runner()
|
runner = get_active_runner()
|
||||||
@@ -127,9 +151,7 @@ class TestStartDebugReplayJob(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_rejects_when_no_recordings(self) -> None:
|
def test_rejects_when_no_recordings(self) -> None:
|
||||||
empty_qs = MagicMock()
|
with patch("frigate.jobs.debug_replay.resolve_coverage", return_value=[]):
|
||||||
empty_qs.count.return_value = 0
|
|
||||||
with patch("frigate.jobs.debug_replay.query_recordings", return_value=empty_qs):
|
|
||||||
with self.assertRaises(NoRecordingsError):
|
with self.assertRaises(NoRecordingsError):
|
||||||
start_debug_replay_job(
|
start_debug_replay_job(
|
||||||
source=RecordingDebugReplaySource(
|
source=RecordingDebugReplaySource(
|
||||||
@@ -152,8 +174,8 @@ class TestStartDebugReplayJob(unittest.TestCase):
|
|||||||
|
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"frigate.jobs.debug_replay.query_recordings",
|
"frigate.jobs.debug_replay.resolve_coverage",
|
||||||
return_value=self.recordings_qs,
|
return_value=self.coverage,
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"frigate.jobs.debug_replay.run_ffmpeg_with_progress",
|
"frigate.jobs.debug_replay.run_ffmpeg_with_progress",
|
||||||
@@ -192,8 +214,8 @@ class TestStartDebugReplayJob(unittest.TestCase):
|
|||||||
|
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"frigate.jobs.debug_replay.query_recordings",
|
"frigate.jobs.debug_replay.resolve_coverage",
|
||||||
return_value=self.recordings_qs,
|
return_value=self.coverage,
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"frigate.jobs.debug_replay.run_ffmpeg_with_progress",
|
"frigate.jobs.debug_replay.run_ffmpeg_with_progress",
|
||||||
@@ -242,9 +264,7 @@ class TestRunnerHappyPath(unittest.TestCase):
|
|||||||
self.frigate_config.ffmpeg.ffmpeg_path = "/bin/true"
|
self.frigate_config.ffmpeg.ffmpeg_path = "/bin/true"
|
||||||
self.publisher = MagicMock()
|
self.publisher = MagicMock()
|
||||||
|
|
||||||
self.recordings_qs = MagicMock()
|
self.coverage = [CoverageInterval(100.0, 200.0, MagicMock(), None)]
|
||||||
self.recordings_qs.count.return_value = 1
|
|
||||||
self.recordings_qs.__iter__.return_value = iter([MagicMock(path="/tmp/r1.mp4")])
|
|
||||||
|
|
||||||
def tearDown(self) -> None:
|
def tearDown(self) -> None:
|
||||||
runner = get_active_runner()
|
runner = get_active_runner()
|
||||||
@@ -272,8 +292,8 @@ class TestRunnerHappyPath(unittest.TestCase):
|
|||||||
|
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"frigate.jobs.debug_replay.query_recordings",
|
"frigate.jobs.debug_replay.resolve_coverage",
|
||||||
return_value=self.recordings_qs,
|
return_value=self.coverage,
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"frigate.jobs.debug_replay.run_ffmpeg_with_progress",
|
"frigate.jobs.debug_replay.run_ffmpeg_with_progress",
|
||||||
@@ -325,9 +345,7 @@ class TestRunnerFailurePath(unittest.TestCase):
|
|||||||
self.frigate_config.cameras = {"front": MagicMock()}
|
self.frigate_config.cameras = {"front": MagicMock()}
|
||||||
self.frigate_config.ffmpeg.ffmpeg_path = "/bin/true"
|
self.frigate_config.ffmpeg.ffmpeg_path = "/bin/true"
|
||||||
self.publisher = MagicMock()
|
self.publisher = MagicMock()
|
||||||
self.recordings_qs = MagicMock()
|
self.coverage = [CoverageInterval(100.0, 200.0, MagicMock(), None)]
|
||||||
self.recordings_qs.count.return_value = 1
|
|
||||||
self.recordings_qs.__iter__.return_value = iter([MagicMock(path="/tmp/r1.mp4")])
|
|
||||||
|
|
||||||
def tearDown(self) -> None:
|
def tearDown(self) -> None:
|
||||||
runner = get_active_runner()
|
runner = get_active_runner()
|
||||||
@@ -350,8 +368,8 @@ class TestRunnerFailurePath(unittest.TestCase):
|
|||||||
|
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"frigate.jobs.debug_replay.query_recordings",
|
"frigate.jobs.debug_replay.resolve_coverage",
|
||||||
return_value=self.recordings_qs,
|
return_value=self.coverage,
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"frigate.jobs.debug_replay.run_ffmpeg_with_progress",
|
"frigate.jobs.debug_replay.run_ffmpeg_with_progress",
|
||||||
@@ -399,9 +417,7 @@ class TestRunnerCancellation(unittest.TestCase):
|
|||||||
self.frigate_config.cameras = {"front": MagicMock()}
|
self.frigate_config.cameras = {"front": MagicMock()}
|
||||||
self.frigate_config.ffmpeg.ffmpeg_path = "/bin/true"
|
self.frigate_config.ffmpeg.ffmpeg_path = "/bin/true"
|
||||||
self.publisher = MagicMock()
|
self.publisher = MagicMock()
|
||||||
self.recordings_qs = MagicMock()
|
self.coverage = [CoverageInterval(100.0, 200.0, MagicMock(), None)]
|
||||||
self.recordings_qs.count.return_value = 1
|
|
||||||
self.recordings_qs.__iter__.return_value = iter([MagicMock(path="/tmp/r1.mp4")])
|
|
||||||
|
|
||||||
def tearDown(self) -> None:
|
def tearDown(self) -> None:
|
||||||
runner = get_active_runner()
|
runner = get_active_runner()
|
||||||
@@ -431,8 +447,8 @@ class TestRunnerCancellation(unittest.TestCase):
|
|||||||
|
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"frigate.jobs.debug_replay.query_recordings",
|
"frigate.jobs.debug_replay.resolve_coverage",
|
||||||
return_value=self.recordings_qs,
|
return_value=self.coverage,
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"frigate.jobs.debug_replay.run_ffmpeg_with_progress",
|
"frigate.jobs.debug_replay.run_ffmpeg_with_progress",
|
||||||
|
|||||||
+80
-14
@@ -194,15 +194,16 @@ class TestEnvVars(unittest.TestCase):
|
|||||||
def setUp(self):
|
def setUp(self):
|
||||||
self._original_env_vars = dict(FRIGATE_ENV_VARS)
|
self._original_env_vars = dict(FRIGATE_ENV_VARS)
|
||||||
self._original_environ = os.environ.copy()
|
self._original_environ = os.environ.copy()
|
||||||
|
names_patch = patch.object(env, "_CONTAINER_ENV_NAMES", frozenset())
|
||||||
|
names_patch.start()
|
||||||
|
self.addCleanup(names_patch.stop)
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
FRIGATE_ENV_VARS.clear()
|
FRIGATE_ENV_VARS.clear()
|
||||||
FRIGATE_ENV_VARS.update(self._original_env_vars)
|
FRIGATE_ENV_VARS.update(self._original_env_vars)
|
||||||
env._CONFIG_ENV_VARS.clear()
|
env._CONFIG_ENV_VARS.clear()
|
||||||
# Clean up any env vars we set
|
os.environ.clear()
|
||||||
for key in list(os.environ.keys()):
|
os.environ.update(self._original_environ)
|
||||||
if key not in self._original_environ:
|
|
||||||
del os.environ[key]
|
|
||||||
|
|
||||||
def _make_context(self, install: bool):
|
def _make_context(self, install: bool):
|
||||||
"""Create a mock ValidationInfo with the given install flag."""
|
"""Create a mock ValidationInfo with the given install flag."""
|
||||||
@@ -215,10 +216,10 @@ class TestEnvVars(unittest.TestCase):
|
|||||||
return mock
|
return mock
|
||||||
|
|
||||||
def test_install_sets_os_environ(self):
|
def test_install_sets_os_environ(self):
|
||||||
"""validate_env_vars with install=True sets os.environ."""
|
"""validate_env_vars with install=True sets allowed names in os.environ."""
|
||||||
ctx = self._make_context(install=True)
|
ctx = self._make_context(install=True)
|
||||||
validate_env_vars({"MY_CUSTOM_VAR": "value123"}, ctx)
|
validate_env_vars({"SHM_MAX_FRAMES": "75"}, ctx)
|
||||||
self.assertEqual(os.environ.get("MY_CUSTOM_VAR"), "value123")
|
self.assertEqual(os.environ.get("SHM_MAX_FRAMES"), "75")
|
||||||
|
|
||||||
def test_install_updates_frigate_env_vars(self):
|
def test_install_updates_frigate_env_vars(self):
|
||||||
"""validate_env_vars with install=True updates FRIGATE_ENV_VARS for FRIGATE_ keys."""
|
"""validate_env_vars with install=True updates FRIGATE_ENV_VARS for FRIGATE_ keys."""
|
||||||
@@ -229,9 +230,9 @@ class TestEnvVars(unittest.TestCase):
|
|||||||
def test_install_skips_non_frigate_in_env_vars_dict(self):
|
def test_install_skips_non_frigate_in_env_vars_dict(self):
|
||||||
"""Non-FRIGATE_ keys are set in os.environ but not in FRIGATE_ENV_VARS."""
|
"""Non-FRIGATE_ keys are set in os.environ but not in FRIGATE_ENV_VARS."""
|
||||||
ctx = self._make_context(install=True)
|
ctx = self._make_context(install=True)
|
||||||
validate_env_vars({"OTHER_VAR": "value"}, ctx)
|
validate_env_vars({"USE_FP16": "False"}, ctx)
|
||||||
self.assertEqual(os.environ.get("OTHER_VAR"), "value")
|
self.assertEqual(os.environ.get("USE_FP16"), "False")
|
||||||
self.assertNotIn("OTHER_VAR", FRIGATE_ENV_VARS)
|
self.assertNotIn("USE_FP16", FRIGATE_ENV_VARS)
|
||||||
|
|
||||||
def test_no_install_does_not_set(self):
|
def test_no_install_does_not_set(self):
|
||||||
"""validate_env_vars without install=True does not modify state."""
|
"""validate_env_vars without install=True does not modify state."""
|
||||||
@@ -258,10 +259,10 @@ class TestEnvVars(unittest.TestCase):
|
|||||||
ctx = self._make_context(install=True)
|
ctx = self._make_context(install=True)
|
||||||
with patch("frigate.util.services.os.path.isfile", return_value=False):
|
with patch("frigate.util.services.os.path.isfile", return_value=False):
|
||||||
for key in ("GO2RTC_ALLOW_ARBITRARY_EXEC", "go2rtc_allow_arbitrary_exec"):
|
for key in ("GO2RTC_ALLOW_ARBITRARY_EXEC", "go2rtc_allow_arbitrary_exec"):
|
||||||
validate_env_vars({key: "true", "OTHER_VAR": "value"}, ctx)
|
validate_env_vars({key: "true", "USE_FP16": "False"}, ctx)
|
||||||
self.assertNotIn(key, os.environ)
|
self.assertNotIn(key, os.environ)
|
||||||
self.assertFalse(is_go2rtc_arbitrary_exec_allowed())
|
self.assertFalse(is_go2rtc_arbitrary_exec_allowed())
|
||||||
self.assertEqual(os.environ.get("OTHER_VAR"), "value")
|
self.assertEqual(os.environ.get("USE_FP16"), "False")
|
||||||
|
|
||||||
def test_install_keeps_container_go2rtc_exec_override(self):
|
def test_install_keeps_container_go2rtc_exec_override(self):
|
||||||
"""An override set in the container environment is left untouched."""
|
"""An override set in the container environment is left untouched."""
|
||||||
@@ -307,17 +308,82 @@ class TestVariableSources(unittest.TestCase):
|
|||||||
self.assertEqual(env.FRIGATE_ENV_VARS["FRIGATE_CAM_PASS"], "hunter2")
|
self.assertEqual(env.FRIGATE_ENV_VARS["FRIGATE_CAM_PASS"], "hunter2")
|
||||||
|
|
||||||
def test_config_env_vars_do_not_become_container_env(self):
|
def test_config_env_vars_do_not_become_container_env(self):
|
||||||
"""environment_vars writes os.environ but must not gain env precedence."""
|
"""environment_vars FRIGATE_ keys stay out of os.environ and env precedence."""
|
||||||
env.apply_config_env_vars({"FRIGATE_CAM_PASS": "from_config"})
|
env.apply_config_env_vars({"FRIGATE_CAM_PASS": "from_config"})
|
||||||
self.assertEqual(os.environ["FRIGATE_CAM_PASS"], "from_config")
|
self.assertNotIn("FRIGATE_CAM_PASS", os.environ)
|
||||||
self.assertNotIn("FRIGATE_CAM_PASS", env._CONTAINER_ENV)
|
self.assertNotIn("FRIGATE_CAM_PASS", env._CONTAINER_ENV)
|
||||||
|
|
||||||
def test_non_frigate_config_env_vars_only_set_os_environ(self):
|
def test_non_frigate_config_env_vars_only_set_os_environ(self):
|
||||||
"""Unprefixed environment_vars keys reach os.environ but not substitution."""
|
"""Unprefixed environment_vars keys reach os.environ but not substitution."""
|
||||||
|
with patch.object(env, "_CONTAINER_ENV_NAMES", frozenset()):
|
||||||
env.apply_config_env_vars({"LIBVA_DRIVER_NAME": "i965"})
|
env.apply_config_env_vars({"LIBVA_DRIVER_NAME": "i965"})
|
||||||
self.assertEqual(os.environ["LIBVA_DRIVER_NAME"], "i965")
|
self.assertEqual(os.environ["LIBVA_DRIVER_NAME"], "i965")
|
||||||
self.assertNotIn("LIBVA_DRIVER_NAME", env.FRIGATE_ENV_VARS)
|
self.assertNotIn("LIBVA_DRIVER_NAME", env.FRIGATE_ENV_VARS)
|
||||||
|
|
||||||
|
def test_rejected_names_warn_and_are_not_exported(self):
|
||||||
|
"""Names off the allowlist log a warning without the value."""
|
||||||
|
for key in ("MY_CUSTOM_VAR", "LD_PRELOAD", "LIGHTER_ANE_EP", "PATH"):
|
||||||
|
with self.subTest(key=key):
|
||||||
|
original = os.environ.get(key)
|
||||||
|
with self.assertLogs("frigate.util.env", level="WARNING") as logs:
|
||||||
|
env.apply_config_env_vars({key: "/config/evil.so"})
|
||||||
|
self.assertEqual(os.environ.get(key), original)
|
||||||
|
self.assertEqual(len(logs.output), 1)
|
||||||
|
self.assertIn(key, logs.output[0])
|
||||||
|
self.assertIn("container environment", logs.output[0])
|
||||||
|
self.assertNotIn("evil", logs.output[0])
|
||||||
|
|
||||||
|
def test_allowed_name_with_bad_value_is_rejected(self):
|
||||||
|
"""LIBVA_DRIVER_NAME cannot carry a path."""
|
||||||
|
with patch.object(env, "_CONTAINER_ENV_NAMES", frozenset()):
|
||||||
|
with self.assertLogs("frigate.util.env", level="WARNING"):
|
||||||
|
env.apply_config_env_vars({"LIBVA_DRIVER_NAME": "../x"})
|
||||||
|
self.assertNotEqual(os.environ.get("LIBVA_DRIVER_NAME"), "../x")
|
||||||
|
|
||||||
|
def test_unsupported_values_are_rejected_without_crashing(self):
|
||||||
|
"""Non-ASCII digits and NUL bytes never reach os.environ."""
|
||||||
|
os.environ.pop("SHM_MAX_FRAMES", None)
|
||||||
|
os.environ.pop("HF_ENDPOINT", None)
|
||||||
|
with patch.object(env, "_CONTAINER_ENV_NAMES", frozenset()):
|
||||||
|
with self.assertLogs("frigate.util.env", level="WARNING") as logs:
|
||||||
|
env.apply_config_env_vars(
|
||||||
|
{"SHM_MAX_FRAMES": "\u0663\u0664", "HF_ENDPOINT": "https://x\x00"}
|
||||||
|
)
|
||||||
|
self.assertEqual(len(logs.output), 2)
|
||||||
|
self.assertNotIn("SHM_MAX_FRAMES", os.environ)
|
||||||
|
self.assertNotIn("HF_ENDPOINT", os.environ)
|
||||||
|
|
||||||
|
def test_container_env_wins_over_allowed_name(self):
|
||||||
|
"""A name the container set is left as the container set it."""
|
||||||
|
os.environ["LIBVA_DRIVER_NAME"] = "iHD"
|
||||||
|
with patch.object(
|
||||||
|
env, "_CONTAINER_ENV_NAMES", frozenset({"LIBVA_DRIVER_NAME"})
|
||||||
|
):
|
||||||
|
env.apply_config_env_vars({"LIBVA_DRIVER_NAME": "i965"})
|
||||||
|
self.assertEqual(os.environ["LIBVA_DRIVER_NAME"], "iHD")
|
||||||
|
|
||||||
|
def test_second_apply_is_not_blocked_by_first(self):
|
||||||
|
"""A safe-mode reload can change a value the first load exported."""
|
||||||
|
with patch.object(env, "_CONTAINER_ENV_NAMES", frozenset()):
|
||||||
|
env.apply_config_env_vars({"LIBVA_DRIVER_NAME": "i965"})
|
||||||
|
env.apply_config_env_vars({"LIBVA_DRIVER_NAME": "radeonsi"})
|
||||||
|
self.assertEqual(os.environ["LIBVA_DRIVER_NAME"], "radeonsi")
|
||||||
|
|
||||||
|
def test_run_as_root_is_substitutable_but_not_exported(self):
|
||||||
|
"""FRIGATE_RUN_AS_ROOT from the config never reaches os.environ."""
|
||||||
|
os.environ.pop("FRIGATE_RUN_AS_ROOT", None)
|
||||||
|
with patch.object(env, "_CONTAINER_ENV_NAMES", frozenset()):
|
||||||
|
env.apply_config_env_vars({"FRIGATE_RUN_AS_ROOT": "true"})
|
||||||
|
self.assertNotIn("FRIGATE_RUN_AS_ROOT", os.environ)
|
||||||
|
self.assertEqual(env.substitute_frigate_vars("{FRIGATE_RUN_AS_ROOT}"), "true")
|
||||||
|
|
||||||
|
def test_jwt_secret_is_exported(self):
|
||||||
|
"""Auth reads FRIGATE_JWT_SECRET from os.environ."""
|
||||||
|
os.environ.pop("FRIGATE_JWT_SECRET", None)
|
||||||
|
with patch.object(env, "_CONTAINER_ENV_NAMES", frozenset()):
|
||||||
|
env.apply_config_env_vars({"FRIGATE_JWT_SECRET": "s3cret"})
|
||||||
|
self.assertEqual(os.environ["FRIGATE_JWT_SECRET"], "s3cret")
|
||||||
|
|
||||||
def test_collision_warns_once_naming_the_winner(self):
|
def test_collision_warns_once_naming_the_winner(self):
|
||||||
"""A key from two sources logs one warning naming the source that won."""
|
"""A key from two sources logs one warning naming the source that won."""
|
||||||
with patch.dict(env._CONTAINER_ENV, {"FRIGATE_MQTT_HOST": "from_env"}):
|
with patch.dict(env._CONTAINER_ENV, {"FRIGATE_MQTT_HOST": "from_env"}):
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
"""Tests for custom ffmpeg path resolution and the root-mode guard."""
|
"""Tests for custom ffmpeg path resolution and the root-mode guard."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from frigate.const import DEFAULT_FFMPEG_VERSION
|
from frigate.const import DEFAULT_FFMPEG_VERSION
|
||||||
from frigate.util.config import (
|
from frigate.util.config import (
|
||||||
_warn_ignored_ffmpeg_path,
|
_warn_ignored_ffmpeg_path,
|
||||||
frigate_service_is_granular_root,
|
|
||||||
resolve_ffmpeg_path,
|
resolve_ffmpeg_path,
|
||||||
|
service_is_granular_root,
|
||||||
)
|
)
|
||||||
|
|
||||||
BUNDLED = f"/usr/lib/ffmpeg/{DEFAULT_FFMPEG_VERSION}/bin/ffmpeg"
|
BUNDLED = f"/usr/lib/ffmpeg/{DEFAULT_FFMPEG_VERSION}/bin/ffmpeg"
|
||||||
@@ -21,12 +23,20 @@ class TestConfigFfmpegRootGuard(unittest.TestCase):
|
|||||||
# the warning is memoized so it fires once per path, not per camera
|
# the warning is memoized so it fires once per path, not per camera
|
||||||
_warn_ignored_ffmpeg_path.cache_clear()
|
_warn_ignored_ffmpeg_path.cache_clear()
|
||||||
|
|
||||||
def _resolve(self, path: str, *, euid: int, env: dict, binary: str = "ffmpeg"):
|
def _resolve(
|
||||||
|
self,
|
||||||
|
path: str,
|
||||||
|
*,
|
||||||
|
euid: int,
|
||||||
|
env: dict,
|
||||||
|
binary: str = "ffmpeg",
|
||||||
|
service: str = "frigate",
|
||||||
|
):
|
||||||
with (
|
with (
|
||||||
patch("os.geteuid", return_value=euid),
|
patch("os.geteuid", return_value=euid),
|
||||||
patch.dict("os.environ", env, clear=True),
|
patch.dict("os.environ", env, clear=True),
|
||||||
):
|
):
|
||||||
return resolve_ffmpeg_path(path, binary)
|
return resolve_ffmpeg_path(path, binary, service)
|
||||||
|
|
||||||
def test_custom_path_used_when_service_is_unprivileged(self) -> None:
|
def test_custom_path_used_when_service_is_unprivileged(self) -> None:
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
@@ -65,6 +75,59 @@ class TestConfigFfmpegRootGuard(unittest.TestCase):
|
|||||||
f"{CUSTOM}/bin/ffmpeg",
|
f"{CUSTOM}/bin/ffmpeg",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_custom_path_ignored_for_go2rtc_when_go2rtc_is_a_root_service(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
self.assertEqual(
|
||||||
|
self._resolve(
|
||||||
|
CUSTOM,
|
||||||
|
euid=0,
|
||||||
|
env={"FRIGATE_ROOT_SERVICES": "go2rtc"},
|
||||||
|
service="go2rtc",
|
||||||
|
),
|
||||||
|
BUNDLED,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_go2rtc_keeps_custom_path_when_only_frigate_is_root(self) -> None:
|
||||||
|
self.assertEqual(
|
||||||
|
self._resolve(
|
||||||
|
CUSTOM,
|
||||||
|
euid=0,
|
||||||
|
env={"FRIGATE_ROOT_SERVICES": "frigate"},
|
||||||
|
service="go2rtc",
|
||||||
|
),
|
||||||
|
f"{CUSTOM}/bin/ffmpeg",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_go2rtc_custom_path_used_in_default_drop_mode(self) -> None:
|
||||||
|
# the run scripts resolve as root but probe as the service user
|
||||||
|
self.assertEqual(
|
||||||
|
self._resolve(CUSTOM, euid=0, env={}, service="go2rtc"),
|
||||||
|
f"{CUSTOM}/bin/ffmpeg",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_escape_hatch_keeps_go2rtc_custom_path(self) -> None:
|
||||||
|
self.assertEqual(
|
||||||
|
self._resolve(
|
||||||
|
CUSTOM,
|
||||||
|
euid=0,
|
||||||
|
env={"FRIGATE_RUN_AS_ROOT": "true", "FRIGATE_ROOT_SERVICES": "go2rtc"},
|
||||||
|
service="go2rtc",
|
||||||
|
),
|
||||||
|
f"{CUSTOM}/bin/ffmpeg",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_go2rtc_root_owned_path_is_left_alone(self) -> None:
|
||||||
|
self.assertEqual(
|
||||||
|
self._resolve(
|
||||||
|
"/opt/custom-ffmpeg",
|
||||||
|
euid=0,
|
||||||
|
env={"FRIGATE_ROOT_SERVICES": "go2rtc"},
|
||||||
|
service="go2rtc",
|
||||||
|
),
|
||||||
|
"/opt/custom-ffmpeg/bin/ffmpeg",
|
||||||
|
)
|
||||||
|
|
||||||
def test_root_services_has_no_effect_under_docker_user(self) -> None:
|
def test_root_services_has_no_effect_under_docker_user(self) -> None:
|
||||||
# docker's own user: means the service never had root to keep, which is
|
# docker's own user: means the service never had root to keep, which is
|
||||||
# also the case for get_ffmpeg_path.py in a --user container
|
# also the case for get_ffmpeg_path.py in a --user container
|
||||||
@@ -115,15 +178,65 @@ class TestConfigFfmpegRootGuard(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestFrigateServiceIsGranularRoot(unittest.TestCase):
|
class TestConfigFfmpegRootGuardSymlinks(unittest.TestCase):
|
||||||
|
"""A root service gets the resolved path, so a link cannot be retargeted."""
|
||||||
|
|
||||||
|
def setUp(self) -> None:
|
||||||
|
_warn_ignored_ffmpeg_path.cache_clear()
|
||||||
|
tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(tmp.cleanup)
|
||||||
|
root = os.path.realpath(tmp.name)
|
||||||
|
self.writable = os.path.join(root, "config")
|
||||||
|
self.root_owned = os.path.join(root, "opt", "ffmpeg")
|
||||||
|
os.makedirs(self.writable)
|
||||||
|
os.makedirs(self.root_owned)
|
||||||
|
self.link = os.path.join(self.writable, "link")
|
||||||
|
dirs = patch("frigate.util.config.RUNTIME_USER_WRITABLE_DIRS", (self.writable,))
|
||||||
|
dirs.start()
|
||||||
|
self.addCleanup(dirs.stop)
|
||||||
|
|
||||||
|
def _resolve(self, env: dict, service: str = "frigate") -> str:
|
||||||
|
with (
|
||||||
|
patch("os.geteuid", return_value=0),
|
||||||
|
patch.dict("os.environ", env, clear=True),
|
||||||
|
):
|
||||||
|
return resolve_ffmpeg_path(self.link, "ffmpeg", service)
|
||||||
|
|
||||||
|
def test_link_to_a_root_owned_tree_resolves_to_its_target(self) -> None:
|
||||||
|
os.symlink(self.root_owned, self.link)
|
||||||
|
for service in ("frigate", "go2rtc"):
|
||||||
|
self.assertEqual(
|
||||||
|
self._resolve({"FRIGATE_ROOT_SERVICES": service}, service),
|
||||||
|
f"{self.root_owned}/bin/ffmpeg",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_link_to_a_writable_tree_is_refused(self) -> None:
|
||||||
|
evil = os.path.join(self.writable, "evil")
|
||||||
|
os.makedirs(evil)
|
||||||
|
os.symlink(evil, self.link)
|
||||||
|
self.assertEqual(self._resolve({"FRIGATE_ROOT_SERVICES": "frigate"}), BUNDLED)
|
||||||
|
|
||||||
|
def test_bin_linked_into_a_writable_tree_is_refused(self) -> None:
|
||||||
|
evil = os.path.join(self.writable, "evil")
|
||||||
|
os.makedirs(evil)
|
||||||
|
os.symlink(evil, os.path.join(self.root_owned, "bin"))
|
||||||
|
os.symlink(self.root_owned, self.link)
|
||||||
|
self.assertEqual(self._resolve({"FRIGATE_ROOT_SERVICES": "frigate"}), BUNDLED)
|
||||||
|
|
||||||
|
def test_unprivileged_service_keeps_the_path_as_written(self) -> None:
|
||||||
|
os.symlink(self.root_owned, self.link)
|
||||||
|
self.assertEqual(self._resolve({}), f"{self.link}/bin/ffmpeg")
|
||||||
|
|
||||||
|
|
||||||
|
class TestServiceIsGranularRoot(unittest.TestCase):
|
||||||
"""Root via FRIGATE_ROOT_SERVICES only, never via the escape hatch."""
|
"""Root via FRIGATE_ROOT_SERVICES only, never via the escape hatch."""
|
||||||
|
|
||||||
def _check(self, *, euid: int, env: dict) -> bool:
|
def _check(self, *, euid: int, env: dict, service: str = "frigate") -> bool:
|
||||||
with (
|
with (
|
||||||
patch("os.geteuid", return_value=euid),
|
patch("os.geteuid", return_value=euid),
|
||||||
patch.dict("os.environ", env, clear=True),
|
patch.dict("os.environ", env, clear=True),
|
||||||
):
|
):
|
||||||
return frigate_service_is_granular_root()
|
return service_is_granular_root(service)
|
||||||
|
|
||||||
def test_false_without_any_root_signal(self) -> None:
|
def test_false_without_any_root_signal(self) -> None:
|
||||||
self.assertFalse(self._check(euid=0, env={}))
|
self.assertFalse(self._check(euid=0, env={}))
|
||||||
@@ -146,6 +259,12 @@ class TestFrigateServiceIsGranularRoot(unittest.TestCase):
|
|||||||
self._check(euid=0, env={"FRIGATE_ROOT_SERVICES": "go2rtc,nginx"})
|
self._check(euid=0, env={"FRIGATE_ROOT_SERVICES": "go2rtc,nginx"})
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_matches_the_named_service(self) -> None:
|
||||||
|
env = {"FRIGATE_ROOT_SERVICES": "go2rtc"}
|
||||||
|
self.assertTrue(self._check(euid=0, env=env, service="go2rtc"))
|
||||||
|
self.assertFalse(self._check(euid=0, env=env, service="frigate"))
|
||||||
|
self.assertFalse(self._check(euid=1000, env=env, service="go2rtc"))
|
||||||
|
|
||||||
def test_substring_of_a_service_name_does_not_match(self) -> None:
|
def test_substring_of_a_service_name_does_not_match(self) -> None:
|
||||||
self.assertFalse(self._check(euid=0, env={"FRIGATE_ROOT_SERVICES": "frigatee"}))
|
self.assertFalse(self._check(euid=0, env={"FRIGATE_ROOT_SERVICES": "frigatee"}))
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ per-camera gating those processors apply once they exist.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
|
import threading
|
||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
@@ -19,6 +20,8 @@ for mod in _MOCK_MODULES:
|
|||||||
if mod not in sys.modules:
|
if mod not in sys.modules:
|
||||||
sys.modules[mod] = MagicMock()
|
sys.modules[mod] = MagicMock()
|
||||||
|
|
||||||
|
from frigate.config.camera.review import ImageSourceEnum # noqa: E402
|
||||||
|
|
||||||
# imported from the maintainer to avoid tripping the circular import between
|
# imported from the maintainer to avoid tripping the circular import between
|
||||||
# the maintainer and the processor modules
|
# the maintainer and the processor modules
|
||||||
from frigate.embeddings.maintainer import ( # noqa: E402
|
from frigate.embeddings.maintainer import ( # noqa: E402
|
||||||
@@ -211,3 +214,58 @@ class TestObjectDescriptionCameraGating(unittest.TestCase):
|
|||||||
|
|
||||||
mock_create_thumbnail.assert_called_once()
|
mock_create_thumbnail.assert_called_once()
|
||||||
self.assertEqual(len(processor.tracked_events["1234.5-abcdef"]), 1)
|
self.assertEqual(len(processor.tracked_events["1234.5-abcdef"]), 1)
|
||||||
|
|
||||||
|
|
||||||
|
class TestReviewDescriptionOffLoop(unittest.TestCase):
|
||||||
|
"""Frame extraction for a finished review must not block process_data."""
|
||||||
|
|
||||||
|
def test_recording_frames_extracted_off_the_caller_thread(self):
|
||||||
|
camera = MagicMock()
|
||||||
|
camera.review.genai.enabled = True
|
||||||
|
camera.review.genai.alerts = True
|
||||||
|
camera.review.genai.image_source = ImageSourceEnum.recordings
|
||||||
|
camera.review.genai.debug_save_thumbnails = False
|
||||||
|
config = MagicMock()
|
||||||
|
config.cameras = {"front": camera}
|
||||||
|
processor = ReviewDescriptionProcessor(
|
||||||
|
config, MagicMock(), MagicMock(), MagicMock()
|
||||||
|
)
|
||||||
|
|
||||||
|
release = threading.Event()
|
||||||
|
extracted = threading.Event()
|
||||||
|
analyzed = threading.Event()
|
||||||
|
|
||||||
|
def slow_frames(*args, **kwargs):
|
||||||
|
release.wait(5)
|
||||||
|
extracted.set()
|
||||||
|
return [(b"jpg", 1.0)]
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(processor, "get_recording_frames", side_effect=slow_frames),
|
||||||
|
patch.object(
|
||||||
|
processor, "start_analysis", side_effect=lambda *a, **k: analyzed.set()
|
||||||
|
) as start_analysis,
|
||||||
|
):
|
||||||
|
processor.process_data(
|
||||||
|
{
|
||||||
|
"type": "end",
|
||||||
|
"after": {
|
||||||
|
"id": "1234.5-abcdef",
|
||||||
|
"camera": "front",
|
||||||
|
"severity": "alert",
|
||||||
|
"start_time": 100.0,
|
||||||
|
"end_time": 110.0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
PostProcessDataEnum.review,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(extracted.is_set())
|
||||||
|
release.set()
|
||||||
|
self.assertTrue(extracted.wait(5))
|
||||||
|
|
||||||
|
self.assertTrue(analyzed.wait(5))
|
||||||
|
start_analysis.assert_called_once()
|
||||||
|
final_data = start_analysis.call_args.args[1]
|
||||||
|
self.assertEqual(final_data["start_time"], 100.0)
|
||||||
|
self.assertEqual(final_data["end_time"], 110.0)
|
||||||
|
|||||||
@@ -23,7 +23,12 @@ _CONFIG = {
|
|||||||
"dual_user": ["front_door", "back_door"],
|
"dual_user": ["front_door", "back_door"],
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"go2rtc": {"streams": {"doorbell_audio": ["rtsp://10.0.0.4:554/audio"]}},
|
"go2rtc": {
|
||||||
|
"streams": {
|
||||||
|
"doorbell_audio": ["rtsp://10.0.0.4:554/audio"],
|
||||||
|
"front_door_sub": ["rtsp://10.0.0.1:554/sub"],
|
||||||
|
}
|
||||||
|
},
|
||||||
"cameras": {
|
"cameras": {
|
||||||
"front_door": {
|
"front_door": {
|
||||||
"ffmpeg": {
|
"ffmpeg": {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from frigate.util.live_streams import (
|
|||||||
default_transcode_source,
|
default_transcode_source,
|
||||||
generated_transcode_streams,
|
generated_transcode_streams,
|
||||||
is_transcode_stream_name,
|
is_transcode_stream_name,
|
||||||
|
is_valid_go2rtc_stream_name,
|
||||||
measure_stream_bitrate,
|
measure_stream_bitrate,
|
||||||
raw_transcode_streams,
|
raw_transcode_streams,
|
||||||
sync_transcode_streams,
|
sync_transcode_streams,
|
||||||
@@ -37,6 +38,13 @@ class TestTranscodeNaming(unittest.TestCase):
|
|||||||
"#raw=-b:v#raw=500k#raw=-maxrate#raw=500k#raw=-bufsize#raw=1000k",
|
"#raw=-b:v#raw=500k#raw=-maxrate#raw=500k#raw=-bufsize#raw=1000k",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_valid_go2rtc_stream_names(self):
|
||||||
|
for name in ("front", "front_sub", "back-yard.main_2", "Haustür"):
|
||||||
|
self.assertTrue(is_valid_go2rtc_stream_name(name), name)
|
||||||
|
|
||||||
|
for name in ("x#raw=-f", "front door", "front\tdoor", "a'b", 'a"b', "a${X}"):
|
||||||
|
self.assertFalse(is_valid_go2rtc_stream_name(name), name)
|
||||||
|
|
||||||
def test_default_source_skips_transcoded_streams(self):
|
def test_default_source_skips_transcoded_streams(self):
|
||||||
streams = {"Low": "front_transcode_360p", "Main": "front_main"}
|
streams = {"Low": "front_transcode_360p", "Main": "front_main"}
|
||||||
self.assertEqual(default_transcode_source("front", streams), "front_main")
|
self.assertEqual(default_transcode_source("front", streams), "front_main")
|
||||||
@@ -121,6 +129,22 @@ class TestRawTranscodeStreams(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertEqual(list(raw_transcode_streams(config)), ["front_transcode_360p"])
|
self.assertEqual(list(raw_transcode_streams(config)), ["front_transcode_360p"])
|
||||||
|
|
||||||
|
def test_unsafe_source_name_is_skipped(self):
|
||||||
|
config = {
|
||||||
|
"cameras": {
|
||||||
|
"front": {
|
||||||
|
"live": {
|
||||||
|
"transcode": {"enabled": True, "source": "x#raw=-f#raw=null"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.assertEqual(raw_transcode_streams(config), {})
|
||||||
|
|
||||||
|
config["cameras"]["front"]["live"]["transcode"]["source"] = "front_main"
|
||||||
|
self.assertEqual(len(raw_transcode_streams(config)), 3)
|
||||||
|
|
||||||
def test_disabled_or_missing_generates_nothing(self):
|
def test_disabled_or_missing_generates_nothing(self):
|
||||||
self.assertEqual(raw_transcode_streams({}), {})
|
self.assertEqual(raw_transcode_streams({}), {})
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
@@ -286,6 +310,17 @@ class TestLiveTranscodeConfig(unittest.TestCase):
|
|||||||
with self.assertRaises(ValidationError):
|
with self.assertRaises(ValidationError):
|
||||||
self.streams({"transcode": {"enabled": True, "source": "missing"}})
|
self.streams({"transcode": {"enabled": True, "source": "missing"}})
|
||||||
|
|
||||||
|
def test_source_name_with_ffmpeg_params_fails(self):
|
||||||
|
name = "x#raw=-f#raw=null"
|
||||||
|
with self.assertRaisesRegex(ValidationError, "contains '#'"):
|
||||||
|
self.streams(
|
||||||
|
{"transcode": {"enabled": True, "source": name}},
|
||||||
|
go2rtc={
|
||||||
|
"front": ["rtsp://10.0.0.1:554/main"],
|
||||||
|
name: ["rtsp://10.0.0.1:554/main"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
def test_duplicate_heights_fail(self):
|
def test_duplicate_heights_fail(self):
|
||||||
with self.assertRaises(ValidationError):
|
with self.assertRaises(ValidationError):
|
||||||
self.streams(
|
self.streams(
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""Tests for MQTT TLS configuration validation."""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from contextlib import nullcontext
|
||||||
|
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from frigate.config import MqttConfig
|
||||||
|
|
||||||
|
|
||||||
|
class TestMqttTls(unittest.TestCase):
|
||||||
|
def test_tls_insecure_requires_tls_only_when_enabled(self) -> None:
|
||||||
|
"""Require TLS for either explicit verification flag only if MQTT is enabled."""
|
||||||
|
for enabled in (True, False):
|
||||||
|
for insecure in (True, False):
|
||||||
|
expected = (
|
||||||
|
self.assertRaisesRegex(
|
||||||
|
ValidationError, "tls_insecure requires tls_ca_certs"
|
||||||
|
)
|
||||||
|
if enabled
|
||||||
|
else nullcontext()
|
||||||
|
)
|
||||||
|
with self.subTest(enabled=enabled, tls_insecure=insecure), expected:
|
||||||
|
MqttConfig(enabled=enabled, tls_insecure=insecure)
|
||||||
+138
-1
@@ -1,12 +1,17 @@
|
|||||||
"""Tests for Frigate+ model metadata."""
|
"""Tests for Frigate+ model metadata."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
from frigate.plus import PlusApi, load_plus_model_info
|
import aiohttp
|
||||||
|
from aiohttp import web
|
||||||
|
from aiohttp.test_utils import TestServer
|
||||||
|
|
||||||
|
from frigate.plus import PlusApi, PlusKeyStatus, load_plus_model_info
|
||||||
|
|
||||||
|
|
||||||
class TestHailoAlias(unittest.TestCase):
|
class TestHailoAlias(unittest.TestCase):
|
||||||
@@ -75,5 +80,137 @@ class TestLoadPlusModelInfo(unittest.TestCase):
|
|||||||
self.assertIsNone(load_plus_model_info("bad"))
|
self.assertIsNone(load_plus_model_info("bad"))
|
||||||
|
|
||||||
|
|
||||||
|
class TestValidateKey(unittest.IsolatedAsyncioTestCase):
|
||||||
|
"""The key format is checked locally, but only the server knows if it is valid."""
|
||||||
|
|
||||||
|
CACHED_TOKEN = {"accessToken": "cached", "expires": 2000000000}
|
||||||
|
|
||||||
|
async def _serve(self, handler) -> str:
|
||||||
|
"""Serve the token endpoint locally and return its base url."""
|
||||||
|
app = web.Application()
|
||||||
|
app.router.add_get("/v1/auth/token", handler)
|
||||||
|
server = TestServer(app)
|
||||||
|
await server.start_server()
|
||||||
|
self.addAsyncCleanup(server.close)
|
||||||
|
return str(server.make_url("")).rstrip("/")
|
||||||
|
|
||||||
|
def _api(self, host: str, key: str | None = "id:secret") -> PlusApi:
|
||||||
|
api = PlusApi.__new__(PlusApi)
|
||||||
|
api.host = host
|
||||||
|
api.key = key
|
||||||
|
api._token_data = dict(self.CACHED_TOKEN)
|
||||||
|
return api
|
||||||
|
|
||||||
|
async def _validate(self, handler) -> tuple[PlusKeyStatus, PlusApi]:
|
||||||
|
api = self._api(await self._serve(handler))
|
||||||
|
return await api.validate_key(), api
|
||||||
|
|
||||||
|
def _replies(self, status: int = 200, body: object = None, text: str | None = None):
|
||||||
|
async def handler(request: web.Request) -> web.Response:
|
||||||
|
self.auth = request.headers.get("Authorization")
|
||||||
|
if text is not None:
|
||||||
|
return web.Response(status=status, text=text)
|
||||||
|
return web.json_response(body, status=status)
|
||||||
|
|
||||||
|
return handler
|
||||||
|
|
||||||
|
async def test_an_accepted_key_is_valid(self):
|
||||||
|
token = {"accessToken": "fresh", "expires": 2000000500.5}
|
||||||
|
status, api = await self._validate(self._replies(body=token))
|
||||||
|
|
||||||
|
self.assertEqual(status, PlusKeyStatus.valid)
|
||||||
|
self.assertEqual(api._token_data, token)
|
||||||
|
self.assertEqual(self.auth, aiohttp.BasicAuth("id", "secret").encode())
|
||||||
|
|
||||||
|
async def test_a_rejected_key_is_invalid(self):
|
||||||
|
for code in (401, 403):
|
||||||
|
status, api = await self._validate(self._replies(code, {"error": "no"}))
|
||||||
|
|
||||||
|
self.assertEqual(status, PlusKeyStatus.invalid)
|
||||||
|
self.assertEqual(api._token_data, self.CACHED_TOKEN)
|
||||||
|
|
||||||
|
async def test_a_server_error_is_not_reported_as_invalid(self):
|
||||||
|
status, _ = await self._validate(self._replies(500, {"error": "down"}))
|
||||||
|
|
||||||
|
self.assertEqual(status, PlusKeyStatus.unreachable)
|
||||||
|
|
||||||
|
async def test_unusable_replies_keep_the_cached_token(self):
|
||||||
|
# a successful reply without a usable token must not replace one that
|
||||||
|
# later Frigate+ requests depend on
|
||||||
|
for body in (
|
||||||
|
None,
|
||||||
|
[],
|
||||||
|
{},
|
||||||
|
{"accessToken": "t"},
|
||||||
|
{"expires": 2000000000},
|
||||||
|
{"accessToken": 1, "expires": 2000000000},
|
||||||
|
{"accessToken": "t", "expires": "soon"},
|
||||||
|
{"accessToken": "t", "expires": True},
|
||||||
|
):
|
||||||
|
with self.subTest(body=body):
|
||||||
|
status, api = await self._validate(self._replies(body=body))
|
||||||
|
|
||||||
|
self.assertEqual(status, PlusKeyStatus.unreachable)
|
||||||
|
self.assertEqual(api._token_data, self.CACHED_TOKEN)
|
||||||
|
|
||||||
|
async def test_a_non_json_reply_keeps_the_cached_token(self):
|
||||||
|
status, api = await self._validate(self._replies(text="<html>"))
|
||||||
|
|
||||||
|
self.assertEqual(status, PlusKeyStatus.unreachable)
|
||||||
|
self.assertEqual(api._token_data, self.CACHED_TOKEN)
|
||||||
|
|
||||||
|
async def test_a_connection_failure_is_unreachable(self):
|
||||||
|
# serve and stop, so the port refuses connections
|
||||||
|
server = TestServer(web.Application())
|
||||||
|
await server.start_server()
|
||||||
|
host = str(server.make_url("")).rstrip("/")
|
||||||
|
await server.close()
|
||||||
|
api = self._api(host)
|
||||||
|
|
||||||
|
self.assertEqual(await api.validate_key(), PlusKeyStatus.unreachable)
|
||||||
|
|
||||||
|
async def test_a_slow_server_times_out_as_unreachable(self):
|
||||||
|
async def slow(request: web.Request) -> web.Response:
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
return web.json_response(self.CACHED_TOKEN)
|
||||||
|
|
||||||
|
with patch("frigate.plus.PLUS_VALIDATE_TIMEOUT", 0.1):
|
||||||
|
status, _ = await self._validate(slow)
|
||||||
|
|
||||||
|
self.assertEqual(status, PlusKeyStatus.unreachable)
|
||||||
|
|
||||||
|
async def test_the_request_goes_through_a_configured_proxy(self):
|
||||||
|
# installs that reach Frigate+ through a proxy must validate the same way
|
||||||
|
token = {"accessToken": "proxied", "expires": 2000000000}
|
||||||
|
proxied = []
|
||||||
|
|
||||||
|
async def proxy(request: web.Request) -> web.Response:
|
||||||
|
proxied.append(str(request.url))
|
||||||
|
return web.json_response(token)
|
||||||
|
|
||||||
|
proxy_url = await self._serve(proxy)
|
||||||
|
api = self._api("http://plus.invalid")
|
||||||
|
|
||||||
|
with patch.dict(
|
||||||
|
os.environ, {"HTTP_PROXY": proxy_url, "NO_PROXY": ""}, clear=False
|
||||||
|
):
|
||||||
|
status = await api.validate_key()
|
||||||
|
|
||||||
|
self.assertEqual(status, PlusKeyStatus.valid)
|
||||||
|
self.assertEqual(proxied, ["http://plus.invalid/v1/auth/token"])
|
||||||
|
|
||||||
|
async def test_no_key_is_not_sent_to_the_server(self):
|
||||||
|
requests_seen = []
|
||||||
|
|
||||||
|
async def handler(request: web.Request) -> web.Response:
|
||||||
|
requests_seen.append(request)
|
||||||
|
return web.json_response(self.CACHED_TOKEN)
|
||||||
|
|
||||||
|
api = self._api(await self._serve(handler), key=None)
|
||||||
|
|
||||||
|
self.assertEqual(await api.validate_key(), PlusKeyStatus.not_set)
|
||||||
|
self.assertEqual(requests_seen, [])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main(verbosity=2)
|
unittest.main(verbosity=2)
|
||||||
|
|||||||
@@ -11,11 +11,14 @@ KeyError on the autotracker thread or silently keep the wrong state:
|
|||||||
running its motion estimator for a camera that could not autotrack.
|
running its motion estimator for a camera that could not autotrack.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
from frigate.camera import PTZMetrics
|
from frigate.camera import PTZMetrics
|
||||||
from frigate.config import FrigateConfig
|
from frigate.config import FrigateConfig, ZoomingModeEnum
|
||||||
from frigate.config.camera.updater import CameraConfigUpdateEnum
|
from frigate.config.camera.updater import CameraConfigUpdateEnum
|
||||||
from frigate.ptz.autotrack import PtzAutoTracker, calculate_max_target_box
|
from frigate.ptz.autotrack import PtzAutoTracker, calculate_max_target_box
|
||||||
|
|
||||||
@@ -123,6 +126,118 @@ class TestAutotrackerDisable(unittest.TestCase):
|
|||||||
self.assertIs(payload, autotracking)
|
self.assertIs(payload, autotracking)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAutotrackerPrediction(unittest.TestCase):
|
||||||
|
def test_movement_time_uses_the_fitted_intercept_unchanged(self) -> None:
|
||||||
|
tracker = _make_tracker()
|
||||||
|
tracker.intercept = {CAMERA: 0.5}
|
||||||
|
tracker.move_coefficients = {CAMERA: [0.3, 0.8]}
|
||||||
|
|
||||||
|
self.assertAlmostEqual(tracker._predict_movement_time(CAMERA, 0.25, 0.25), 0.7)
|
||||||
|
|
||||||
|
def test_shrinking_box_keeps_a_positive_target_box(self) -> None:
|
||||||
|
# 60 px shrinking 6 px/frame reaches zero after 2 s at 5 fps
|
||||||
|
target_box = self._target_box_for((0, 0, 60, 60), [3, 3, -3, -3], 2.0)
|
||||||
|
|
||||||
|
self.assertGreater(target_box, 0)
|
||||||
|
self.assertTrue(np.isfinite(target_box))
|
||||||
|
|
||||||
|
def test_approaching_object_keeps_its_velocity(self) -> None:
|
||||||
|
# the box grows, so the corners move in opposite directions
|
||||||
|
tracker = _make_tracker()
|
||||||
|
obj = MagicMock()
|
||||||
|
obj.obj_data = {"estimate_velocity": np.array([[-3.0, -4.0], [3.0, 1.0]])}
|
||||||
|
|
||||||
|
valid, velocity = tracker._get_valid_velocity(CAMERA, obj)
|
||||||
|
|
||||||
|
self.assertTrue(valid)
|
||||||
|
self.assertEqual(list(velocity), [-3, -4, 3, 1])
|
||||||
|
|
||||||
|
def test_corners_that_disagree_too_much_are_rejected(self) -> None:
|
||||||
|
tracker = _make_tracker()
|
||||||
|
obj = MagicMock()
|
||||||
|
obj.obj_data = {"estimate_velocity": np.array([[-15.0, 0.0], [15.0, 0.0]])}
|
||||||
|
|
||||||
|
valid, velocity = tracker._get_valid_velocity(CAMERA, obj)
|
||||||
|
|
||||||
|
self.assertFalse(valid)
|
||||||
|
self.assertFalse(np.any(velocity))
|
||||||
|
|
||||||
|
def _target_box(self, velocity: list[float], predicted_time: float) -> float:
|
||||||
|
return self._target_box_for((900, 400, 1000, 600), velocity, predicted_time)
|
||||||
|
|
||||||
|
def _target_box_for(
|
||||||
|
self,
|
||||||
|
box: tuple[int, int, int, int],
|
||||||
|
velocity: list[float],
|
||||||
|
predicted_time: float,
|
||||||
|
) -> float:
|
||||||
|
tracker = _make_tracker()
|
||||||
|
tracker.tracked_object_history = {CAMERA: [{"box": box}]}
|
||||||
|
tracker.tracked_object_metrics = {
|
||||||
|
CAMERA: {"target_box": 0.2, "velocity": np.array(velocity)}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tracker._predict_target_box(CAMERA, predicted_time)
|
||||||
|
|
||||||
|
def test_target_box_grows_with_an_approaching_object(self) -> None:
|
||||||
|
# the 200px height gains 4px per frame at 5 fps for 1 second
|
||||||
|
zoom_factor = 0.3
|
||||||
|
|
||||||
|
self.assertAlmostEqual(
|
||||||
|
self._target_box([-1, -2, 1, 2], 1.0),
|
||||||
|
0.2 * (220 / 200) ** (2 * zoom_factor),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_target_box_is_unchanged_without_growth_or_time(self) -> None:
|
||||||
|
self.assertAlmostEqual(self._target_box([5, 0, 5, 0], 1.0), 0.2)
|
||||||
|
self.assertEqual(self._target_box([-1, -2, 1, 2], 0), 0.2)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAutotrackerMoveQueue(unittest.IsolatedAsyncioTestCase):
|
||||||
|
async def test_absolute_zoom_does_not_overwrite_the_pan_tilt_sample(self) -> None:
|
||||||
|
tracker = _make_tracker()
|
||||||
|
autotracking = tracker.config.cameras[CAMERA].onvif.autotracking
|
||||||
|
autotracking.zooming = ZoomingModeEnum.absolute
|
||||||
|
autotracking.calibrate_on_startup = True
|
||||||
|
metrics = tracker.ptz_metrics[CAMERA]
|
||||||
|
|
||||||
|
tracker.stop_event = MagicMock()
|
||||||
|
tracker.stop_event.is_set.side_effect = [False, True]
|
||||||
|
tracker.move_queues = {CAMERA: asyncio.Queue()}
|
||||||
|
tracker.move_queue_locks = {CAMERA: asyncio.Lock()}
|
||||||
|
tracker.intercept = {CAMERA: 0.5}
|
||||||
|
tracker.move_metrics = {CAMERA: []}
|
||||||
|
tracker._wait_until_stopped = AsyncMock()
|
||||||
|
tracker._calculate_move_coefficients = MagicMock()
|
||||||
|
|
||||||
|
async def move_relative(*_args) -> None:
|
||||||
|
metrics.start_time.value = 100.0
|
||||||
|
metrics.stop_time.value = 102.0
|
||||||
|
|
||||||
|
async def zoom_absolute(*_args) -> None:
|
||||||
|
metrics.start_time.value = 103.0
|
||||||
|
metrics.stop_time.value = 0
|
||||||
|
|
||||||
|
tracker.onvif._move_relative = AsyncMock(side_effect=move_relative)
|
||||||
|
tracker.onvif._zoom_absolute = AsyncMock(side_effect=zoom_absolute)
|
||||||
|
|
||||||
|
tracker.move_queues[CAMERA].put_nowait((50.0, 0.5, 0.5, 0.5))
|
||||||
|
await tracker._process_move_queue(CAMERA)
|
||||||
|
|
||||||
|
tracker.onvif._zoom_absolute.assert_awaited_once()
|
||||||
|
self.assertEqual(
|
||||||
|
tracker.move_metrics[CAMERA],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"pan": 0.5,
|
||||||
|
"tilt": 0.5,
|
||||||
|
"start_timestamp": 100.0,
|
||||||
|
"end_timestamp": 102.0,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestMaxTargetBox(unittest.TestCase):
|
class TestMaxTargetBox(unittest.TestCase):
|
||||||
def test_follows_zoom_factor(self) -> None:
|
def test_follows_zoom_factor(self) -> None:
|
||||||
self.assertAlmostEqual(calculate_max_target_box(0.5), 0.6**2)
|
self.assertAlmostEqual(calculate_max_target_box(0.5), 0.6**2)
|
||||||
|
|||||||
@@ -321,6 +321,36 @@ class TestLighterANE(unittest.TestCase):
|
|||||||
ane.assert_not_called()
|
ane.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
class TestTensorRTProvider(unittest.TestCase):
|
||||||
|
def _trt_options(self, env):
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
model_util.ort,
|
||||||
|
"get_available_providers",
|
||||||
|
return_value=["TensorrtExecutionProvider", "CPUExecutionProvider"],
|
||||||
|
),
|
||||||
|
patch.object(model_util.os, "makedirs"),
|
||||||
|
patch.dict(os.environ, env, clear=False),
|
||||||
|
):
|
||||||
|
if "USE_FP16" not in env:
|
||||||
|
os.environ.pop("USE_FP16", None)
|
||||||
|
providers, options = model_util.get_ort_providers(device="Tensorrt")
|
||||||
|
|
||||||
|
return options[providers.index("TensorrtExecutionProvider")]
|
||||||
|
|
||||||
|
def test_fp16_is_disabled_by_default(self):
|
||||||
|
self.assertFalse(self._trt_options({})["trt_fp16_enable"])
|
||||||
|
|
||||||
|
def test_use_fp16_enables_fp16(self):
|
||||||
|
for value in ("true", "True", "TRUE"):
|
||||||
|
with self.subTest(value=value):
|
||||||
|
options = self._trt_options({"USE_FP16": value})
|
||||||
|
self.assertTrue(options["trt_fp16_enable"])
|
||||||
|
|
||||||
|
def test_use_fp16_false_keeps_fp16_disabled(self):
|
||||||
|
self.assertFalse(self._trt_options({"USE_FP16": "False"})["trt_fp16_enable"])
|
||||||
|
|
||||||
|
|
||||||
class TestMIGraphX(unittest.TestCase):
|
class TestMIGraphX(unittest.TestCase):
|
||||||
"""AMD's MIGraphX plugin provider, selected through its devices in the ROCm image."""
|
"""AMD's MIGraphX plugin provider, selected through its devices in the ROCm image."""
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import queue
|
|||||||
import re
|
import re
|
||||||
import shlex
|
import shlex
|
||||||
import struct
|
import struct
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
from collections import deque
|
from collections import deque
|
||||||
@@ -42,6 +43,7 @@ class EventsPerSecond:
|
|||||||
self._max_events = max_events
|
self._max_events = max_events
|
||||||
self._last_n_seconds = last_n_seconds
|
self._last_n_seconds = last_n_seconds
|
||||||
self._timestamps: deque[float] = deque(maxlen=max_events)
|
self._timestamps: deque[float] = deque(maxlen=max_events)
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
def start(self) -> None:
|
def start(self) -> None:
|
||||||
self._start = time.monotonic()
|
self._start = time.monotonic()
|
||||||
@@ -71,6 +73,9 @@ class EventsPerSecond:
|
|||||||
# remove aged out timestamps
|
# remove aged out timestamps
|
||||||
def expire_timestamps(self, now: float) -> None:
|
def expire_timestamps(self, now: float) -> None:
|
||||||
threshold = now - self._last_n_seconds
|
threshold = now - self._last_n_seconds
|
||||||
|
|
||||||
|
# update() and eps() can run on different threads
|
||||||
|
with self._lock:
|
||||||
while self._timestamps and self._timestamps[0] < threshold:
|
while self._timestamps and self._timestamps[0] < threshold:
|
||||||
self._timestamps.popleft()
|
self._timestamps.popleft()
|
||||||
|
|
||||||
|
|||||||
+57
-33
@@ -1,6 +1,8 @@
|
|||||||
"""configuration utils."""
|
"""configuration utils."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
import errno
|
||||||
import ipaddress
|
import ipaddress
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
@@ -46,14 +48,14 @@ DROPPED_DETECTOR_OPTIONS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# Trees the unprivileged runtime user can write. A root frigate service must
|
# Trees the unprivileged runtime user can write. A root service must not
|
||||||
# not execute a binary from any of them; a compromised uid-1000 process could
|
# execute a binary from any of them; a compromised uid-1000 process could
|
||||||
# plant one and be root after the next restart.
|
# plant one and be root after the next restart.
|
||||||
RUNTIME_USER_WRITABLE_DIRS = (CONFIG_DIR, BASE_DIR, CACHE_DIR, "/dev/shm", "/tmp")
|
RUNTIME_USER_WRITABLE_DIRS = (CONFIG_DIR, BASE_DIR, CACHE_DIR, "/dev/shm", "/tmp")
|
||||||
|
|
||||||
|
|
||||||
def frigate_service_is_granular_root() -> bool:
|
def service_is_granular_root(service: str) -> bool:
|
||||||
"""Report whether FRIGATE_ROOT_SERVICES runs frigate as root.
|
"""Report whether FRIGATE_ROOT_SERVICES runs the given service as root.
|
||||||
|
|
||||||
The escape hatch is excluded: it never sweeps /config and leaves no
|
The escape hatch is excluded: it never sweeps /config and leaves no
|
||||||
unprivileged service running, so custom binaries stay as safe as they
|
unprivileged service running, so custom binaries stay as safe as they
|
||||||
@@ -66,7 +68,7 @@ def frigate_service_is_granular_root() -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
entries = os.environ.get("FRIGATE_ROOT_SERVICES", "").split(",")
|
entries = os.environ.get("FRIGATE_ROOT_SERVICES", "").split(",")
|
||||||
return any("".join(entry.split()) == "frigate" for entry in entries)
|
return any("".join(entry.split()) == service for entry in entries)
|
||||||
|
|
||||||
|
|
||||||
def is_runtime_user_writable(path: str) -> bool:
|
def is_runtime_user_writable(path: str) -> bool:
|
||||||
@@ -79,11 +81,12 @@ def is_runtime_user_writable(path: str) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
@cache
|
@cache
|
||||||
def _warn_ignored_ffmpeg_path(path: str) -> None:
|
def _warn_ignored_ffmpeg_path(path: str, service: str) -> None:
|
||||||
"""Warn once per path; resolution runs per camera and per binary."""
|
"""Warn once per path; resolution runs per camera and per binary."""
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Ignoring ffmpeg.path %s because FRIGATE_ROOT_SERVICES runs frigate as root and that location is writable by the unprivileged user; using the bundled build",
|
"Ignoring ffmpeg.path %s because FRIGATE_ROOT_SERVICES runs %s as root and that location is writable by the unprivileged user; using the bundled build",
|
||||||
path,
|
path,
|
||||||
|
service,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -97,16 +100,19 @@ def is_valid_ffmpeg_path(path: str) -> bool:
|
|||||||
return not path.startswith("/") or _CUSTOM_FFMPEG_PATH.fullmatch(path) is not None
|
return not path.startswith("/") or _CUSTOM_FFMPEG_PATH.fullmatch(path) is not None
|
||||||
|
|
||||||
|
|
||||||
def resolve_ffmpeg_path(path: str, binary: str = "ffmpeg") -> str:
|
def resolve_ffmpeg_path(
|
||||||
|
path: str, binary: str = "ffmpeg", service: str = "frigate"
|
||||||
|
) -> str:
|
||||||
"""Resolve an ffmpeg version alias or custom path to a binary path.
|
"""Resolve an ffmpeg version alias or custom path to a binary path.
|
||||||
|
|
||||||
A bare version alias that is no longer bundled (for example one that was
|
A bare version alias that is no longer bundled (for example one that was
|
||||||
dropped when the default version changed) falls back to the default
|
dropped when the default version changed) falls back to the default
|
||||||
bundled version so existing configs keep working across an upgrade or a
|
bundled version so existing configs keep working across an upgrade or a
|
||||||
revert. Custom install paths (anything absolute) are used as-is, unless
|
revert. Custom install paths (anything absolute) are used as-is. While
|
||||||
they fail `is_valid_ffmpeg_path` (which falls back to the default), or
|
FRIGATE_ROOT_SERVICES makes the service that execs it root, a custom path
|
||||||
they are in a runtime-user-writable tree while FRIGATE_ROOT_SERVICES
|
is resolved to its real location and refused inside a runtime-user-writable
|
||||||
makes frigate root; see RUNTIME_USER_WRITABLE_DIRS.
|
tree; see RUNTIME_USER_WRITABLE_DIRS. Paths that fail
|
||||||
|
`is_valid_ffmpeg_path` also fall back to the default.
|
||||||
"""
|
"""
|
||||||
if (
|
if (
|
||||||
path == "default"
|
path == "default"
|
||||||
@@ -116,11 +122,16 @@ def resolve_ffmpeg_path(path: str, binary: str = "ffmpeg") -> str:
|
|||||||
version = DEFAULT_FFMPEG_VERSION
|
version = DEFAULT_FFMPEG_VERSION
|
||||||
elif path in INCLUDED_FFMPEG_VERSIONS:
|
elif path in INCLUDED_FFMPEG_VERSIONS:
|
||||||
version = path
|
version = path
|
||||||
else:
|
elif not service_is_granular_root(service):
|
||||||
if not (frigate_service_is_granular_root() and is_runtime_user_writable(path)):
|
|
||||||
return f"{path}/bin/{binary}"
|
return f"{path}/bin/{binary}"
|
||||||
|
else:
|
||||||
|
# return the resolved form so a link in the path cannot be retargeted
|
||||||
|
# after the check
|
||||||
|
real = os.path.realpath(f"{path}/bin/{binary}")
|
||||||
|
if is_valid_ffmpeg_path(real) and not is_runtime_user_writable(real):
|
||||||
|
return real
|
||||||
|
|
||||||
_warn_ignored_ffmpeg_path(path)
|
_warn_ignored_ffmpeg_path(path, service)
|
||||||
version = DEFAULT_FFMPEG_VERSION
|
version = DEFAULT_FFMPEG_VERSION
|
||||||
|
|
||||||
return f"/usr/lib/ffmpeg/{version}/bin/{binary}"
|
return f"/usr/lib/ffmpeg/{version}/bin/{binary}"
|
||||||
@@ -203,6 +214,28 @@ def find_config_file() -> str:
|
|||||||
return config_path
|
return config_path
|
||||||
|
|
||||||
|
|
||||||
|
def _write_config(config_file: str, yaml: YAML, config: dict[str, Any]) -> None:
|
||||||
|
config_file = os.path.realpath(config_file)
|
||||||
|
st = os.stat(config_file)
|
||||||
|
tmp_file = f"{config_file}.tmp"
|
||||||
|
with open(tmp_file, "w") as f:
|
||||||
|
yaml.dump(config, f)
|
||||||
|
os.chmod(tmp_file, st.st_mode)
|
||||||
|
|
||||||
|
with contextlib.suppress(PermissionError):
|
||||||
|
os.chown(tmp_file, st.st_uid, st.st_gid)
|
||||||
|
|
||||||
|
try:
|
||||||
|
os.replace(tmp_file, config_file)
|
||||||
|
except OSError as e:
|
||||||
|
if e.errno != errno.EBUSY:
|
||||||
|
raise
|
||||||
|
|
||||||
|
# a file bind mount can't be renamed over, so write it in place
|
||||||
|
shutil.copyfile(tmp_file, config_file)
|
||||||
|
os.remove(tmp_file)
|
||||||
|
|
||||||
|
|
||||||
def migrate_frigate_config(config_file: str):
|
def migrate_frigate_config(config_file: str):
|
||||||
"""handle migrating the frigate config."""
|
"""handle migrating the frigate config."""
|
||||||
logger.info("Checking if frigate config needs migration...")
|
logger.info("Checking if frigate config needs migration...")
|
||||||
@@ -250,8 +283,7 @@ def migrate_frigate_config(config_file: str):
|
|||||||
if previous_version < "0.14":
|
if previous_version < "0.14":
|
||||||
logger.info(f"Migrating frigate config from {previous_version} to 0.14...")
|
logger.info(f"Migrating frigate config from {previous_version} to 0.14...")
|
||||||
new_config = migrate_014(new_config)
|
new_config = migrate_014(new_config)
|
||||||
with open(config_file, "w") as f:
|
_write_config(config_file, yaml, new_config)
|
||||||
yaml.dump(new_config, f)
|
|
||||||
previous_version = "0.14"
|
previous_version = "0.14"
|
||||||
|
|
||||||
logger.info("Migrating export file names...")
|
logger.info("Migrating export file names...")
|
||||||
@@ -268,56 +300,48 @@ def migrate_frigate_config(config_file: str):
|
|||||||
if previous_version < "0.15-0":
|
if previous_version < "0.15-0":
|
||||||
logger.info(f"Migrating frigate config from {previous_version} to 0.15-0...")
|
logger.info(f"Migrating frigate config from {previous_version} to 0.15-0...")
|
||||||
new_config = migrate_015_0(new_config)
|
new_config = migrate_015_0(new_config)
|
||||||
with open(config_file, "w") as f:
|
_write_config(config_file, yaml, new_config)
|
||||||
yaml.dump(new_config, f)
|
|
||||||
previous_version = "0.15-0"
|
previous_version = "0.15-0"
|
||||||
|
|
||||||
if previous_version < "0.15-1":
|
if previous_version < "0.15-1":
|
||||||
logger.info(f"Migrating frigate config from {previous_version} to 0.15-1...")
|
logger.info(f"Migrating frigate config from {previous_version} to 0.15-1...")
|
||||||
new_config = migrate_015_1(new_config)
|
new_config = migrate_015_1(new_config)
|
||||||
with open(config_file, "w") as f:
|
_write_config(config_file, yaml, new_config)
|
||||||
yaml.dump(new_config, f)
|
|
||||||
previous_version = "0.15-1"
|
previous_version = "0.15-1"
|
||||||
|
|
||||||
if previous_version < "0.16-0":
|
if previous_version < "0.16-0":
|
||||||
logger.info(f"Migrating frigate config from {previous_version} to 0.16-0...")
|
logger.info(f"Migrating frigate config from {previous_version} to 0.16-0...")
|
||||||
new_config = migrate_016_0(new_config)
|
new_config = migrate_016_0(new_config)
|
||||||
with open(config_file, "w") as f:
|
_write_config(config_file, yaml, new_config)
|
||||||
yaml.dump(new_config, f)
|
|
||||||
previous_version = "0.16-0"
|
previous_version = "0.16-0"
|
||||||
|
|
||||||
if previous_version < "0.17-0":
|
if previous_version < "0.17-0":
|
||||||
logger.info(f"Migrating frigate config from {previous_version} to 0.17-0...")
|
logger.info(f"Migrating frigate config from {previous_version} to 0.17-0...")
|
||||||
new_config = migrate_017_0(new_config)
|
new_config = migrate_017_0(new_config)
|
||||||
with open(config_file, "w") as f:
|
_write_config(config_file, yaml, new_config)
|
||||||
yaml.dump(new_config, f)
|
|
||||||
previous_version = "0.17-0"
|
previous_version = "0.17-0"
|
||||||
|
|
||||||
if previous_version < "0.18-0":
|
if previous_version < "0.18-0":
|
||||||
logger.info(f"Migrating frigate config from {previous_version} to 0.18-0...")
|
logger.info(f"Migrating frigate config from {previous_version} to 0.18-0...")
|
||||||
new_config = migrate_018_0(new_config)
|
new_config = migrate_018_0(new_config)
|
||||||
with open(config_file, "w") as f:
|
_write_config(config_file, yaml, new_config)
|
||||||
yaml.dump(new_config, f)
|
|
||||||
previous_version = "0.18-0"
|
previous_version = "0.18-0"
|
||||||
|
|
||||||
if previous_version < "0.19-0":
|
if previous_version < "0.19-0":
|
||||||
logger.info(f"Migrating frigate config from {previous_version} to 0.19-0...")
|
logger.info(f"Migrating frigate config from {previous_version} to 0.19-0...")
|
||||||
new_config = migrate_019_0(new_config)
|
new_config = migrate_019_0(new_config)
|
||||||
with open(config_file, "w") as f:
|
_write_config(config_file, yaml, new_config)
|
||||||
yaml.dump(new_config, f)
|
|
||||||
previous_version = "0.19-0"
|
previous_version = "0.19-0"
|
||||||
|
|
||||||
if needs_models:
|
if needs_models:
|
||||||
logger.info("Migrating frigate detectors and model to models...")
|
logger.info("Migrating frigate detectors and model to models...")
|
||||||
new_config = migrate_models(new_config)
|
new_config = migrate_models(new_config)
|
||||||
with open(config_file, "w") as f:
|
_write_config(config_file, yaml, new_config)
|
||||||
yaml.dump(new_config, f)
|
|
||||||
|
|
||||||
if needs_detector_rename:
|
if needs_detector_rename:
|
||||||
logger.info("Migrating renamed frigate detectors...")
|
logger.info("Migrating renamed frigate detectors...")
|
||||||
new_config = rename_hailo_detector(new_config)
|
new_config = rename_hailo_detector(new_config)
|
||||||
with open(config_file, "w") as f:
|
_write_config(config_file, yaml, new_config)
|
||||||
yaml.dump(new_config, f)
|
|
||||||
|
|
||||||
logger.info("Finished frigate config migration...")
|
logger.info("Finished frigate config migration...")
|
||||||
|
|
||||||
|
|||||||
+59
-15
@@ -26,6 +26,8 @@ _SECRETS_FILE: dict[str, str] = {}
|
|||||||
_CONTAINER_ENV: dict[str, str] = {
|
_CONTAINER_ENV: dict[str, str] = {
|
||||||
k: v for k, v in os.environ.items() if k.startswith("FRIGATE_")
|
k: v for k, v in os.environ.items() if k.startswith("FRIGATE_")
|
||||||
}
|
}
|
||||||
|
# Empty image defaults, like ROCm's HSA_OVERRIDE_GFX_VERSION, don't count as set.
|
||||||
|
_CONTAINER_ENV_NAMES = frozenset(k for k, v in os.environ.items() if v)
|
||||||
_CREDENTIALS_DIR: dict[str, str] = {}
|
_CREDENTIALS_DIR: dict[str, str] = {}
|
||||||
|
|
||||||
_SOURCES: tuple[tuple[str, dict[str, str]], ...] = (
|
_SOURCES: tuple[tuple[str, dict[str, str]], ...] = (
|
||||||
@@ -165,38 +167,80 @@ def reload_sources(warn: bool = True) -> None:
|
|||||||
_rebuild(warn)
|
_rebuild(warn)
|
||||||
|
|
||||||
|
|
||||||
# Security overrides that must come from the container environment, which is
|
_INT = re.compile(r"\d+", re.ASCII)
|
||||||
# controlled by the operator, rather than from the config, which admins can edit
|
_WORD = re.compile(r"\w+", re.ASCII)
|
||||||
_PROTECTED_ENV_VARS = frozenset({"GO2RTC_ALLOW_ARBITRARY_EXEC"})
|
_URL = re.compile(r"https?://\S+")
|
||||||
|
|
||||||
|
# The only unprefixed names the config may export, with the values each accepts.
|
||||||
|
# Anything else, such as LD_PRELOAD, can load code into Frigate or its children.
|
||||||
|
_ALLOWED_ENV_VARS: dict[str, re.Pattern[str]] = {
|
||||||
|
"LIBVA_DRIVER_NAME": re.compile(r"[A-Za-z0-9_]+"),
|
||||||
|
"TF_INTRA_OP_PARALLELISM_THREADS": _INT,
|
||||||
|
"TF_INTER_OP_PARALLELISM_THREADS": _INT,
|
||||||
|
"TF_DATASET_THREAD_POOL_SIZE": _INT,
|
||||||
|
"SHM_MAX_FRAMES": _INT,
|
||||||
|
"USE_FP16": _WORD,
|
||||||
|
"HF_HUB_OFFLINE": _WORD,
|
||||||
|
"TRANSFORMERS_OFFLINE": _WORD,
|
||||||
|
"HSA_OVERRIDE_GFX_VERSION": re.compile(r"[0-9.]+"),
|
||||||
|
"HF_ENDPOINT": _URL,
|
||||||
|
"GITHUB_ENDPOINT": _URL,
|
||||||
|
"GITHUB_RAW_ENDPOINT": _URL,
|
||||||
|
"TF_KERAS_MOBILENET_V2_WEIGHTS_URL": _URL,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Auth reads it from os.environ; every other FRIGATE_ name only feeds substitution.
|
||||||
|
_EXPORTED_FRIGATE_VARS = frozenset({"FRIGATE_JWT_SECRET"})
|
||||||
|
|
||||||
|
|
||||||
def apply_config_env_vars(values: Mapping[str, object]) -> None:
|
def apply_config_env_vars(values: Mapping[str, object]) -> None:
|
||||||
"""Install the environment_vars block as the lowest priority source.
|
"""Install the environment_vars block as the lowest priority source.
|
||||||
|
|
||||||
Unprefixed keys only set os.environ. Protected security overrides are
|
FRIGATE_ keys feed substitution. Allowlisted unprefixed keys with valid
|
||||||
skipped.
|
values are exported to os.environ. A name the container already set is
|
||||||
|
never overwritten.
|
||||||
"""
|
"""
|
||||||
|
exports: dict[str, str] = {}
|
||||||
|
|
||||||
for key, value in values.items():
|
for key, value in values.items():
|
||||||
if key.upper() in _PROTECTED_ENV_VARS:
|
resolved = str(value)
|
||||||
|
|
||||||
|
if key.startswith("FRIGATE_"):
|
||||||
|
_CONFIG_ENV_VARS[key] = resolved
|
||||||
|
continue
|
||||||
|
|
||||||
|
pattern = _ALLOWED_ENV_VARS.get(key)
|
||||||
|
|
||||||
|
if pattern is None:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Ignoring %s in environment_vars, it must be set in the container environment",
|
"Ignoring %s in environment_vars, it must be set in the container environment",
|
||||||
key,
|
key,
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
resolved = str(value)
|
if not pattern.fullmatch(resolved):
|
||||||
|
logger.warning("Ignoring %s in environment_vars, unsupported value", key)
|
||||||
|
continue
|
||||||
|
|
||||||
if key.startswith("FRIGATE_"):
|
exports[key] = resolved
|
||||||
_CONFIG_ENV_VARS[key] = resolved
|
|
||||||
else:
|
|
||||||
os.environ[key] = resolved
|
|
||||||
|
|
||||||
_rebuild()
|
_rebuild()
|
||||||
|
|
||||||
# Export the winning value; auth reads FRIGATE_JWT_SECRET from os.environ.
|
for key in _EXPORTED_FRIGATE_VARS & values.keys():
|
||||||
for key in values:
|
exports[key] = FRIGATE_ENV_VARS[key]
|
||||||
if key.startswith("FRIGATE_"):
|
|
||||||
os.environ[key] = FRIGATE_ENV_VARS[key]
|
for key, value in exports.items():
|
||||||
|
if key in _CONTAINER_ENV_NAMES:
|
||||||
|
logger.info(
|
||||||
|
"Ignoring %s in environment_vars, the container environment sets it",
|
||||||
|
key,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
os.environ[key] = value
|
||||||
|
except ValueError:
|
||||||
|
logger.warning("Ignoring %s in environment_vars, unsupported value", key)
|
||||||
|
|
||||||
|
|
||||||
reload_sources(warn=False)
|
reload_sources(warn=False)
|
||||||
|
|||||||
@@ -31,6 +31,16 @@ def is_transcode_stream_name(camera: str, name: str) -> bool:
|
|||||||
return re.fullmatch(rf"{re.escape(camera)}_transcode_\d+p", name) is not None
|
return re.fullmatch(rf"{re.escape(camera)}_transcode_\d+p", name) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def is_valid_go2rtc_stream_name(name: str) -> bool:
|
||||||
|
"""Return whether a stream name is safe to splice into a go2rtc source.
|
||||||
|
|
||||||
|
go2rtc splits sources on `#` and ffmpeg commands on whitespace, and expands
|
||||||
|
`${VAR}` at load, so a name with any of these, or with quotes, could add its
|
||||||
|
own parameters.
|
||||||
|
"""
|
||||||
|
return re.search(r"[\s#$'\"]", name) is None
|
||||||
|
|
||||||
|
|
||||||
def transcode_stream_source(source: str, height: int, bitrate: int) -> str:
|
def transcode_stream_source(source: str, height: int, bitrate: int) -> str:
|
||||||
"""Build the go2rtc ffmpeg source that transcodes a stream to H.264.
|
"""Build the go2rtc ffmpeg source that transcodes a stream to H.264.
|
||||||
|
|
||||||
@@ -101,7 +111,9 @@ def raw_transcode_streams(config: dict[str, Any]) -> dict[str, str]:
|
|||||||
camera, live_streams or {camera: camera}
|
camera, live_streams or {camera: camera}
|
||||||
)
|
)
|
||||||
|
|
||||||
if source is None:
|
# an unsafe name fails Frigate's validation; skipping it here keeps
|
||||||
|
# it out of go2rtc
|
||||||
|
if source is None or not is_valid_go2rtc_stream_name(str(source)):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
qualities = transcode.get("qualities")
|
qualities = transcode.get("qualities")
|
||||||
|
|||||||
@@ -318,7 +318,6 @@ def post_process_yolox(
|
|||||||
def get_ort_providers(
|
def get_ort_providers(
|
||||||
force_cpu: bool = False,
|
force_cpu: bool = False,
|
||||||
device: str | None = "AUTO",
|
device: str | None = "AUTO",
|
||||||
requires_fp16: bool = False,
|
|
||||||
) -> tuple[list[str], list[dict[str, Any]]]:
|
) -> tuple[list[str], list[dict[str, Any]]]:
|
||||||
if force_cpu:
|
if force_cpu:
|
||||||
return (
|
return (
|
||||||
@@ -357,8 +356,8 @@ def get_ort_providers(
|
|||||||
options.append(
|
options.append(
|
||||||
{
|
{
|
||||||
"device_id": device_id,
|
"device_id": device_id,
|
||||||
"trt_fp16_enable": requires_fp16
|
"trt_fp16_enable": os.environ.get("USE_FP16", "False").lower()
|
||||||
and os.environ.get("USE_FP16", "True") != "False",
|
== "true",
|
||||||
"trt_timing_cache_enable": True,
|
"trt_timing_cache_enable": True,
|
||||||
"trt_engine_cache_enable": True,
|
"trt_engine_cache_enable": True,
|
||||||
"trt_timing_cache_path": os.path.join(
|
"trt_timing_cache_path": os.path.join(
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ from pathlib import Path
|
|||||||
|
|
||||||
from frigate.const import MODEL_CACHE_DIR
|
from frigate.const import MODEL_CACHE_DIR
|
||||||
from frigate.util.config import (
|
from frigate.util.config import (
|
||||||
frigate_service_is_granular_root,
|
|
||||||
is_runtime_user_writable,
|
is_runtime_user_writable,
|
||||||
|
service_is_granular_root,
|
||||||
)
|
)
|
||||||
from frigate.util.file import FileLock
|
from frigate.util.file import FileLock
|
||||||
|
|
||||||
@@ -163,7 +163,7 @@ def _usable_reason() -> str | None:
|
|||||||
|
|
||||||
base = user_base()
|
base = user_base()
|
||||||
|
|
||||||
if frigate_service_is_granular_root() and is_runtime_user_writable(str(base)):
|
if service_is_granular_root("frigate") and is_runtime_user_writable(str(base)):
|
||||||
return (
|
return (
|
||||||
f"{base} is writable by the unprivileged user while "
|
f"{base} is writable by the unprivileged user while "
|
||||||
"FRIGATE_ROOT_SERVICES runs frigate as root"
|
"FRIGATE_ROOT_SERVICES runs frigate as root"
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ from frigate.const import (
|
|||||||
)
|
)
|
||||||
from frigate.util.builtin import clean_camera_user_pass, escape_special_characters
|
from frigate.util.builtin import clean_camera_user_pass, escape_special_characters
|
||||||
from frigate.util.env import UnknownVariableError, substitute_frigate_vars
|
from frigate.util.env import UnknownVariableError, substitute_frigate_vars
|
||||||
|
from frigate.util.live_streams import is_valid_go2rtc_stream_name
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -1177,6 +1178,7 @@ class Go2rtcStreamIssue(str, Enum):
|
|||||||
restricted = "restricted"
|
restricted = "restricted"
|
||||||
unknown_variable = "unknown_variable"
|
unknown_variable = "unknown_variable"
|
||||||
invalid_placeholder = "invalid_placeholder"
|
invalid_placeholder = "invalid_placeholder"
|
||||||
|
invalid_name = "invalid_name"
|
||||||
|
|
||||||
|
|
||||||
def find_restricted_go2rtc_stream_changes(
|
def find_restricted_go2rtc_stream_changes(
|
||||||
@@ -1188,12 +1190,17 @@ def find_restricted_go2rtc_stream_changes(
|
|||||||
in the saved config for the same stream is left alone, so streams the
|
in the saved config for the same stream is left alone, so streams the
|
||||||
operator configured on disk keep working; any other source must resolve
|
operator configured on disk keep working; any other source must resolve
|
||||||
its {FRIGATE_*} placeholders and pass `is_restricted_go2rtc_api_source`.
|
its {FRIGATE_*} placeholders and pass `is_restricted_go2rtc_api_source`.
|
||||||
|
New stream names must pass `is_valid_go2rtc_stream_name`.
|
||||||
"""
|
"""
|
||||||
old_streams = old_streams if isinstance(old_streams, dict) else {}
|
old_streams = old_streams if isinstance(old_streams, dict) else {}
|
||||||
new_streams = new_streams if isinstance(new_streams, dict) else {}
|
new_streams = new_streams if isinstance(new_streams, dict) else {}
|
||||||
issues: dict[str, Go2rtcStreamIssue] = {}
|
issues: dict[str, Go2rtcStreamIssue] = {}
|
||||||
|
|
||||||
for name, stream in new_streams.items():
|
for name, stream in new_streams.items():
|
||||||
|
if name not in old_streams and not is_valid_go2rtc_stream_name(str(name)):
|
||||||
|
issues[name] = Go2rtcStreamIssue.invalid_name
|
||||||
|
continue
|
||||||
|
|
||||||
existing = _go2rtc_stream_sources(old_streams.get(name))
|
existing = _go2rtc_stream_sources(old_streams.get(name))
|
||||||
|
|
||||||
for source in _go2rtc_stream_sources(stream):
|
for source in _go2rtc_stream_sources(stream):
|
||||||
|
|||||||
Generated
+199
-181
@@ -9,8 +9,8 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cycjimmy/jsmpeg-player": "^6.1.8",
|
"@cycjimmy/jsmpeg-player": "^7.0.0",
|
||||||
"@hookform/resolvers": "^6.0.2",
|
"@hookform/resolvers": "^7.0.3",
|
||||||
"@radix-ui/react-alert-dialog": "^1.2.1",
|
"@radix-ui/react-alert-dialog": "^1.2.1",
|
||||||
"@radix-ui/react-aspect-ratio": "^1.2.0",
|
"@radix-ui/react-aspect-ratio": "^1.2.0",
|
||||||
"@radix-ui/react-checkbox": "^1.4.1",
|
"@radix-ui/react-checkbox": "^1.4.1",
|
||||||
@@ -33,11 +33,11 @@
|
|||||||
"@radix-ui/react-toggle": "^1.2.1",
|
"@radix-ui/react-toggle": "^1.2.1",
|
||||||
"@radix-ui/react-toggle-group": "^1.2.1",
|
"@radix-ui/react-toggle-group": "^1.2.1",
|
||||||
"@radix-ui/react-tooltip": "^1.4.1",
|
"@radix-ui/react-tooltip": "^1.4.1",
|
||||||
"@rjsf/core": "^6.10.1",
|
"@rjsf/core": "^6.11.1",
|
||||||
"@rjsf/shadcn": "^6.10.1",
|
"@rjsf/shadcn": "^6.11.1",
|
||||||
"@rjsf/utils": "^6.10.1",
|
"@rjsf/utils": "^6.10.1",
|
||||||
"@rjsf/validator-ajv8": "^6.10.1",
|
"@rjsf/validator-ajv8": "^6.11.1",
|
||||||
"apexcharts": "^8.6.0",
|
"apexcharts": "^8.7.0",
|
||||||
"axios": "^1.21.0",
|
"axios": "^1.21.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@@ -45,15 +45,15 @@
|
|||||||
"copy-to-clipboard": "^4.0.2",
|
"copy-to-clipboard": "^4.0.2",
|
||||||
"date-fns": "^4.4.0",
|
"date-fns": "^4.4.0",
|
||||||
"date-fns-tz": "^3.2.0",
|
"date-fns-tz": "^3.2.0",
|
||||||
"framer-motion": "^14.9.0",
|
"framer-motion": "^14.10.0",
|
||||||
"hls.js": "^1.7.4",
|
"hls.js": "^1.7.4",
|
||||||
"i18next": "^26.5.12",
|
"i18next": "^26.7.2",
|
||||||
"i18next-http-backend": "^4.0.7",
|
"i18next-http-backend": "^4.0.10",
|
||||||
"idb-keyval": "^6.3.0",
|
"idb-keyval": "^6.3.0",
|
||||||
"js-yaml": "^5.5.0",
|
"js-yaml": "^5.5.0",
|
||||||
"konva": "^10.9.0",
|
"konva": "^10.9.1",
|
||||||
"lodash": "^4.18.1",
|
"lodash": "^4.18.1",
|
||||||
"lucide-react": "^1.65.0",
|
"lucide-react": "^1.70.0",
|
||||||
"monaco-yaml": "^5.5.1",
|
"monaco-yaml": "^5.5.1",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"nosleep.js": "^0.12.0",
|
"nosleep.js": "^0.12.0",
|
||||||
@@ -65,7 +65,7 @@
|
|||||||
"react-dropzone": "^20.1.4",
|
"react-dropzone": "^20.1.4",
|
||||||
"react-grid-layout": "^2.3.0",
|
"react-grid-layout": "^2.3.0",
|
||||||
"react-hook-form": "^7.91.0",
|
"react-hook-form": "^7.91.0",
|
||||||
"react-i18next": "^17.1.9",
|
"react-i18next": "^17.1.13",
|
||||||
"react-icons": "^5.7.0",
|
"react-icons": "^5.7.0",
|
||||||
"react-konva": "^19.3.2",
|
"react-konva": "^19.3.2",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
@@ -81,19 +81,19 @@
|
|||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"use-long-press": "^3.3.0",
|
"use-long-press": "^3.3.0",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
"virtua": "^0.54.0",
|
"virtua": "^0.54.2",
|
||||||
"vite-plugin-monaco-editor": "^1.1.0",
|
"vite-plugin-monaco-editor": "^1.1.0",
|
||||||
"zod": "^4.6.7"
|
"zod": "^4.6.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@playwright/test": "^1.64.1",
|
"@playwright/test": "^1.65.0",
|
||||||
"@types/js-yaml": "^4.0.9",
|
"@types/js-yaml": "^4.0.9",
|
||||||
"@types/lodash": "^4.17.27",
|
"@types/lodash": "^4.17.27",
|
||||||
"@types/node": "^26.6.6",
|
"@types/node": "^26.6.6",
|
||||||
"@types/react": "^19.3.0",
|
"@types/react": "^19.3.0",
|
||||||
"@types/react-dom": "^19.3.0",
|
"@types/react-dom": "^19.3.0",
|
||||||
"@vitejs/plugin-react": "^6.1.2",
|
"@vitejs/plugin-react": "^6.2.1",
|
||||||
"autoprefixer": "^10.6.2",
|
"autoprefixer": "^10.6.2",
|
||||||
"esbuild": "^0.28.2",
|
"esbuild": "^0.28.2",
|
||||||
"eslint": "^10.14.0",
|
"eslint": "^10.14.0",
|
||||||
@@ -101,7 +101,7 @@
|
|||||||
"eslint-plugin-prettier": "^5.5.7",
|
"eslint-plugin-prettier": "^5.5.7",
|
||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
"eslint-plugin-react-refresh": "^0.5.7",
|
"eslint-plugin-react-refresh": "^0.5.7",
|
||||||
"globals": "^17.13.0",
|
"globals": "^17.14.0",
|
||||||
"i18next-cli": "^1.5.11",
|
"i18next-cli": "^1.5.11",
|
||||||
"monaco-editor": "^0.53.0",
|
"monaco-editor": "^0.53.0",
|
||||||
"patch-package": "^8.0.1",
|
"patch-package": "^8.0.1",
|
||||||
@@ -111,7 +111,7 @@
|
|||||||
"tailwindcss": "^3.4.19",
|
"tailwindcss": "^3.4.19",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"typescript-eslint": "^8.72.1",
|
"typescript-eslint": "^8.72.1",
|
||||||
"vite": "^8.3.5"
|
"vite": "^8.3.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@aashutoshrathi/word-wrap": {
|
"node_modules/@aashutoshrathi/word-wrap": {
|
||||||
@@ -428,9 +428,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@cycjimmy/jsmpeg-player": {
|
"node_modules/@cycjimmy/jsmpeg-player": {
|
||||||
"version": "6.1.8",
|
"version": "7.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@cycjimmy/jsmpeg-player/-/jsmpeg-player-6.1.8.tgz",
|
"resolved": "https://registry.npmjs.org/@cycjimmy/jsmpeg-player/-/jsmpeg-player-7.0.0.tgz",
|
||||||
"integrity": "sha512-ccG7JvidQWWPvmmpu6Ob6TUnbyaz6WaTHl9V/5BD4vBVndxnxchLmVU3rpJguB2lWEoEAhFPBIU3GoeXtc3RaQ==",
|
"integrity": "sha512-aPKrOKaon5ncMSN5ec2O6yqiIotPQr0KsSchIC4pnTnebA30bq4D3hKk4g4JynZzSIqBRWhd2TnyZh1vvTXxEA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@date-fns/tz": {
|
"node_modules/@date-fns/tz": {
|
||||||
@@ -1059,15 +1059,14 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@hookform/resolvers": {
|
"node_modules/@hookform/resolvers": {
|
||||||
"version": "6.0.2",
|
"version": "7.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-6.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-7.0.3.tgz",
|
||||||
"integrity": "sha512-e2dqUOlaGuirKVOd/iZWk/Yg5zeaGw7t0hSc3LzxL3o7eZGwZkTyEbBL8H52m0wRKEsNLWoMGOiXGHbPHTXlEQ==",
|
"integrity": "sha512-beGY1JY6K5cyWlswD4BVf7UTb5MxSjKkjljrRyLPT3KIQzsTU0jOibBKqtdcKqIZYQUUyURed7roLWZtSdAtiQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@standard-schema/utils": "^0.3.0"
|
"@standard-schema/utils": "^0.3.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@sinclair/typebox": ">=0.25.24",
|
|
||||||
"@standard-schema/spec": "^1.0.0",
|
"@standard-schema/spec": "^1.0.0",
|
||||||
"@vinejs/vine": "^2.0.0 || ^3.0.0 || ^4.0.0",
|
"@vinejs/vine": "^2.0.0 || ^3.0.0 || ^4.0.0",
|
||||||
"ajv": "^8.12.0",
|
"ajv": "^8.12.0",
|
||||||
@@ -1087,15 +1086,13 @@
|
|||||||
"react-hook-form": "^7.55.0",
|
"react-hook-form": "^7.55.0",
|
||||||
"superstruct": ">=0.12.0",
|
"superstruct": ">=0.12.0",
|
||||||
"typanion": "^3.3.2",
|
"typanion": "^3.3.2",
|
||||||
|
"typebox": "^1.0.0",
|
||||||
"valibot": ">=0.31.0 || ^1.0.0-beta.4 || ^1.0.0-rc",
|
"valibot": ">=0.31.0 || ^1.0.0-beta.4 || ^1.0.0-rc",
|
||||||
"vest": ">=6.0.0",
|
"vest": ">=6.0.0",
|
||||||
"yup": "^1.0.0",
|
"yup": "^1.0.0",
|
||||||
"zod": "^3.25.0 || ^4.0.0"
|
"zod": "^3.25.0 || ^4.0.0"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@sinclair/typebox": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"@standard-schema/spec": {
|
"@standard-schema/spec": {
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
@@ -1150,6 +1147,9 @@
|
|||||||
"typanion": {
|
"typanion": {
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
|
"typebox": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"valibot": {
|
"valibot": {
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
@@ -1691,9 +1691,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@oxc-project/types": {
|
"node_modules/@oxc-project/types": {
|
||||||
"version": "0.155.0",
|
"version": "0.156.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.155.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.156.0.tgz",
|
||||||
"integrity": "sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==",
|
"integrity": "sha512-1Duyg48TdJNdyhEB8Xn1L36bT9Pv6GUtlKjHxOys8xkaF/rQq3XJE+bAurSK/AgIz5mGFsb77cn9Pb20+WCScw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
@@ -1714,13 +1714,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@playwright/test": {
|
"node_modules/@playwright/test": {
|
||||||
"version": "1.64.1",
|
"version": "1.65.0",
|
||||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz",
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.65.0.tgz",
|
||||||
"integrity": "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==",
|
"integrity": "sha512-iGURLGuKDLakSEl7Pgfv8h7f+XLT+V861Wfhz0zlEl5nyFuKUopdkFBPKqTllTUp06aOwPuH8iuUEb3yp3MpbQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright": "1.64.1"
|
"playwright": "1.65.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright": "cli.js"
|
"playwright": "cli.js"
|
||||||
@@ -2903,47 +2903,47 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@rjsf/core": {
|
"node_modules/@rjsf/core": {
|
||||||
"version": "6.10.1",
|
"version": "6.11.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rjsf/core/-/core-6.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rjsf/core/-/core-6.11.1.tgz",
|
||||||
"integrity": "sha512-LHLbXYEq1+c2CpynBlf5JBlUhPNxOkVV+txf1BvamgJSt4QOisXFw5Uv+mBdLRi4BI+FfT0ofMVOgGWnuwG7gw==",
|
"integrity": "sha512-2kH39lspUUQf552l+vSTj9vnwdv1gSOyDKqSDmFuTo16+92zbJ381knrQpk3KKowjp/sbxu4qPiBptgXH6i4UQ==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"markdown-to-jsx": "^9.8.2"
|
"markdown-to-jsx": "^9.10.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@rjsf/utils": "^6.10.1",
|
"@rjsf/utils": "^6.11.1",
|
||||||
"react": ">=18"
|
"react": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rjsf/shadcn": {
|
"node_modules/@rjsf/shadcn": {
|
||||||
"version": "6.10.1",
|
"version": "6.11.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rjsf/shadcn/-/shadcn-6.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rjsf/shadcn/-/shadcn-6.11.1.tgz",
|
||||||
"integrity": "sha512-URVLkYcf3V8hNo1ZvcwR9zdMx+2z2k5IhDpfmQqPqWrMvY469BL+Wgyp+P7PEF01JhlQA3GOB6EwfKTMTkpoAw==",
|
"integrity": "sha512-W00s4kLx5f4kerYtsE/kLLABqh3eg4Iih1D9llxKp0FDVnW6BEuJmKP8G9VRVphMdMQVkrZNeNBjlOE9Bc7ZDw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.11",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.23",
|
||||||
"@radix-ui/react-icons": "^1.3.2",
|
"@radix-ui/react-icons": "^1.3.2",
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
"@radix-ui/react-label": "^2.1.15",
|
||||||
"@radix-ui/react-radio-group": "^1.3.8",
|
"@radix-ui/react-radio-group": "^1.4.7",
|
||||||
"@radix-ui/react-separator": "^1.1.8",
|
"@radix-ui/react-separator": "^1.1.15",
|
||||||
"@radix-ui/react-slider": "^1.3.6",
|
"@radix-ui/react-slider": "^1.4.7",
|
||||||
"@radix-ui/react-slot": "^1.2.0",
|
"@radix-ui/react-slot": "^1.3.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"lucide-react": "^1.16.0",
|
"lucide-react": "^1.49.0",
|
||||||
"tailwind-merge": "^3.6.0"
|
"tailwind-merge": "^3.7.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@rjsf/core": "^6.10.1",
|
"@rjsf/core": "^6.11.1",
|
||||||
"@rjsf/utils": "^6.10.1",
|
"@rjsf/utils": "^6.11.1",
|
||||||
"react": ">=18"
|
"react": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -2958,14 +2958,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rjsf/utils": {
|
"node_modules/@rjsf/utils": {
|
||||||
"version": "6.10.1",
|
"version": "6.11.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rjsf/utils/-/utils-6.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rjsf/utils/-/utils-6.11.1.tgz",
|
||||||
"integrity": "sha512-dlbQUCoTl/+/TenyYs1zAKXf9sLTebtY+6pB+bUFsaN4xkdAtR8YBLwmpa1N5yNiRzf3fbxveISNuZmDrZE/yQ==",
|
"integrity": "sha512-rHQ4jNkhws3YUjvM/6RyW78l0mVfnt6w2j3/giUcS5F0cendCLYykFuSnE2fQxBwmknv4ANMOugwXOgcIbfvnA==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@x0k/json-schema-merge": "^1.0.6",
|
"@x0k/json-schema-merge": "^1.1.0",
|
||||||
"fast-equals": "^6.0.0",
|
"fast-equals": "^6.0.4",
|
||||||
"fast-uri": "^4.1.4",
|
"fast-uri": "^4.2.1",
|
||||||
"jsonpointer": "^5.0.1",
|
"jsonpointer": "^5.0.1",
|
||||||
"react-is": "^18.3.1"
|
"react-is": "^18.3.1"
|
||||||
},
|
},
|
||||||
@@ -2977,9 +2977,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rjsf/utils/node_modules/fast-equals": {
|
"node_modules/@rjsf/utils/node_modules/fast-equals": {
|
||||||
"version": "6.0.0",
|
"version": "6.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-6.2.2.tgz",
|
||||||
"integrity": "sha512-PFhhIGgdM79r5Uztdj9Zb6Tt1zKafqVfdMGwVca1z5z6fbX7DmsySSuJd8HiP6I1j505DCS83cLxo5rmSNeVEA==",
|
"integrity": "sha512-n+XqoarWCgcar0O3om3DeNYx6xTuLWGTUF1bRZ9iuO00tsPepzP1L4V7rqtxxZ4PEya34UCESReimFvxmEVwIA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
@@ -3002,9 +3002,9 @@
|
|||||||
"license": "BSD-3-Clause"
|
"license": "BSD-3-Clause"
|
||||||
},
|
},
|
||||||
"node_modules/@rjsf/validator-ajv8": {
|
"node_modules/@rjsf/validator-ajv8": {
|
||||||
"version": "6.10.1",
|
"version": "6.11.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rjsf/validator-ajv8/-/validator-ajv8-6.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rjsf/validator-ajv8/-/validator-ajv8-6.11.1.tgz",
|
||||||
"integrity": "sha512-Jyim45UKd39SgWn4ryaydTigZ0dddNEeqZUfTlLgsy2v23uMDLyeKjNCItC09dy00dtKzwM3Q4WA0FtQ5IjOgA==",
|
"integrity": "sha512-LV7ibrep/kaRzkwzhLAmJY9SHOIZuX9fQO+0VCfm0FOGXneGU9xQEJvHft7R84uqtDTTQZqRq40HcQjqY0rsyw==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ajv": "^8.20.0",
|
"ajv": "^8.20.0",
|
||||||
@@ -3014,13 +3014,13 @@
|
|||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@rjsf/utils": "^6.10.1"
|
"@rjsf/utils": "^6.11.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-android-arm-eabi": {
|
"node_modules/@rolldown/binding-android-arm-eabi": {
|
||||||
"version": "1.2.15",
|
"version": "1.2.16",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.15.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.16.tgz",
|
||||||
"integrity": "sha512-aLcgK9gIJ3YRvrrtfSBbRa27F5WrpiTaPulnFpsHPxpUHgFlSgnU4854MHwoRGEBhfRJDWCS2WQ7lIjskE9Olw==",
|
"integrity": "sha512-ia3DwAs0OsMGS7+IConptSiyk9pEXkOx/AbJ2n/lAGxH3QkWQus1t8d7gnAT38FCU2sQu8lUbeR6AiZFv4JxaA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -3035,9 +3035,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-android-arm64": {
|
"node_modules/@rolldown/binding-android-arm64": {
|
||||||
"version": "1.2.15",
|
"version": "1.2.16",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.15.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.16.tgz",
|
||||||
"integrity": "sha512-xD7DdZCrbThKuxNg7ooQmvp07ys4J/pnSEMTG3/OdOWl6daC67m/Q4fL78sRHMxTH/N4fGUbYhcPM0MF5WFHkg==",
|
"integrity": "sha512-kx5wBiMB6E7TEvpl2B9gjLU004BkvEm2Dr941BFjx/SontPwSzNuQUIoVKxHHoSjpFIfHMmcguyLOtyn0hObRg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -3052,9 +3052,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||||
"version": "1.2.15",
|
"version": "1.2.16",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.15.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.16.tgz",
|
||||||
"integrity": "sha512-m2WuQ6r0B4OTYxyPpNLtXaMRbRDUbG8YQV2Q3KTdSSK8Th6KHCnC8jVCJt8bvYeSB5WivkkNbc85mKadaSdOxA==",
|
"integrity": "sha512-qslZrXnKJU4cUpjoAmtAo/KQM9e2IRMlmpig6Zpwj+8KWxW8v+nkAf9216Bsek9lOC1jvg2yCjygCFo2f83msQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -3069,9 +3069,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-darwin-x64": {
|
"node_modules/@rolldown/binding-darwin-x64": {
|
||||||
"version": "1.2.15",
|
"version": "1.2.16",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.15.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.16.tgz",
|
||||||
"integrity": "sha512-5RtnsHM36nodM9WbMy+zip0LGS/G+y5wro5RHrpOlr4ac24+1zSi9kiV2MP1myZeZ/ifEHfulQcWjnAXzc2C2A==",
|
"integrity": "sha512-FdF+y48ND13eAyM2mQn+85kvAqNlvRWROy2rhgA463X/0Gv4vGtazZtfaF1mRBLscYvtOlh4QnLjOWiFzX69OQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -3086,9 +3086,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||||
"version": "1.2.15",
|
"version": "1.2.16",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.15.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.16.tgz",
|
||||||
"integrity": "sha512-g3l09pyFqhu+135JKkbkwiKGQmwU5hUMUPJ4VdBADvuqR8d33L6H578rh8m7rtxbdp+VmX/+nzI7nvhSZlzpUQ==",
|
"integrity": "sha512-ddjqFM5e95AgLbUPtKIS7V1KTVlA0RUgCVr5mn+/IEhsdy3pr+Vuj/HZ7nlMN2FyxIwUktOH1LEPIQXO1v9y1Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -3103,9 +3103,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||||
"version": "1.2.15",
|
"version": "1.2.16",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.15.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.16.tgz",
|
||||||
"integrity": "sha512-nIe3VugI97nGQtspPxDqA4sXMUpeiocKIP/CqpY/u/vgbT+alX+cVxu1C/oVBPKg4feNTmYovE91v4DgXvXNwQ==",
|
"integrity": "sha512-tHdKWbFkFBI687iim3LIyIvyL7j66VvQYkDPss5Stn/qyE2tWkR1YD0dXmXPFKKqT/o5ZEJVsaRsWS2meQwGOA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -3120,13 +3120,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||||
"version": "1.2.15",
|
"version": "1.2.16",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.15.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.16.tgz",
|
||||||
"integrity": "sha512-iTBjXx+B5QSvgI89lJfmqh4wTh8NabR1YZIIN3hNx4zSG0EVfVqkOvLulNpG4251pbd6/IAmZ/LefVk1Y4rPhw==",
|
"integrity": "sha512-FJiLGAblPBMlBEdbOqw6kW6QcXDWAf/oJu7gYs9ZJZwmg+K+/zjFLxPTIY9izJUzxzc24FHvmseyA0IvJSl17w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3137,13 +3140,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||||
"version": "1.2.15",
|
"version": "1.2.16",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.15.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.16.tgz",
|
||||||
"integrity": "sha512-uN26XOnVR7rqQupG3V9oAG2EGGatWjcJHOeDOHUFCh4TBWmP2tHTFoVN13aYbwfm0aHqJAEmcwhAu6sooIjfqQ==",
|
"integrity": "sha512-hrJpblMsrQ58U9veWpbXzkhV5zStunds6HWLprqq1qauvgBr1E6P+24kLj6lSLI9vjRn/RYpjprhkHQPKGWpNg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3154,13 +3160,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||||
"version": "1.2.15",
|
"version": "1.2.16",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.15.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.16.tgz",
|
||||||
"integrity": "sha512-9nWkcdd+H+XhaA3fQIwpHnKIrfx/PQ6rO0pivr78Og07tXlUlctBBJoEYLPfhTmCR/QxEVdn6uPjHV5SymPhdw==",
|
"integrity": "sha512-qJd/dC6j6CwLG9/s/RP2nb1RZ0GIgj7Ioj7x/SYa3RzjVrlzrmH0fhIOpfvqQDeIsdP0loi+kHaMhJzY1PBIaA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3171,13 +3180,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||||
"version": "1.2.15",
|
"version": "1.2.16",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.15.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.16.tgz",
|
||||||
"integrity": "sha512-Ggh0ciFjLwIusLSLz7LZYEaGdxOAUJXI+Qu5JCbhPJJHEt8vP+IjRATTJheiJsdzzTYgUiHUiw3X1Ea3ZpMBdA==",
|
"integrity": "sha512-Vx7Xg5uUJuNcujJjI70OICaH0EYj80BtOLZTSN7uOYg8QOLlyKnaG0IW7Fn/J0IpJsaKzmDKMhOQv1JBM9m9vg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3188,13 +3200,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||||
"version": "1.2.15",
|
"version": "1.2.16",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.15.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.16.tgz",
|
||||||
"integrity": "sha512-FHg60rS6DBePAYZ9X9F67x+JObFdmgn6tupA1C/a3B+CufFmTD4mX+e8M75tCiAO3foR/6xwVBI0KQiI7sn/rw==",
|
"integrity": "sha512-TNKhgE3uJTsTzatUSSYt0Kgo+Oa2uSlSjz4r1aEIea7HX6ULBym4fAlYUhG1i6pjNf2njfCUAfKwr6C3E9Xg1w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3205,13 +3220,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||||
"version": "1.2.15",
|
"version": "1.2.16",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.15.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.16.tgz",
|
||||||
"integrity": "sha512-/cvV/7v5VGz/moiz0XKRj2dE8DzsKyhIu1KpOvJGPjRywg3yscON889UGvAe8zfiJLHup7VLjBduoGWi3HusWQ==",
|
"integrity": "sha512-7chXdpjXZ8qG0+mIxECN3cT9kvhZ3/oZhMX6Pp876P7CDrIy8nJswva6Mq357JR7eYXfuskwZxMeV1SzoZdHrA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3222,9 +3240,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||||
"version": "1.2.15",
|
"version": "1.2.16",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.15.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.16.tgz",
|
||||||
"integrity": "sha512-MyzC5Hrv/sm+Z7TSGKP/j8001RO2xQm1zOpCNVL4j0cY/VhBZviludA2YFQ7i4lH6ZAXxQm0lcMdBzM+99mE7w==",
|
"integrity": "sha512-Kwjt+7uNTJK5/MDkGWGVO2KQPnj8yAV75dMpfwyHZrJSXWHrniOB0aUEWlVYf3H1hOqFLIStLdM5QXq6bnKmOQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -3239,9 +3257,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||||
"version": "1.2.15",
|
"version": "1.2.16",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.15.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.16.tgz",
|
||||||
"integrity": "sha512-+PgYiS+AH5omy6L45ZLxybasauJaj3M8buoT83D+nzPmCayXlxUV9hydhciCKVyIg0B/v0BtXPDE1uGqNwCPpg==",
|
"integrity": "sha512-/LH6WfjDRXXJz/p5xl4TmU+jpcnwcxbKEQBW/GZE0JHfN0lGIyTtsUEGaQ/q/GikkGJ2yLKe6pwm7AnEWO8pdw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -3256,9 +3274,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||||
"version": "1.2.15",
|
"version": "1.2.16",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.15.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.16.tgz",
|
||||||
"integrity": "sha512-HFZd2QNQYn1JbIi+Hpd7H+r/lkWxcfIu28nirZ6eCn2LafuvmExMlb/P96Sfx5tf7pPArw39IdhmCF8vmV+NhQ==",
|
"integrity": "sha512-1lv1KvX0Z871zfk7vw+dnXMMnHNyDwMzWG8Kv3HxDXMOhzCX4SHc7HK319fInP0i+0p9D8brkfzL7pJOaGl+tw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -3893,9 +3911,9 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/@vitejs/plugin-react": {
|
"node_modules/@vitejs/plugin-react": {
|
||||||
"version": "6.1.2",
|
"version": "6.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.2.1.tgz",
|
||||||
"integrity": "sha512-fhJNGUdscQa4HkI0xFxwonWyhnVReCPDbXI7FH7jmzpnVCrFQI7PRrhWecO263610vOrs9tr2Y4xcf8uAdFxbw==",
|
"integrity": "sha512-9KyWCij3r0+qLG3Q62vBmv0VyevmYibAtxaCIVK9o0k6CXgaajZRIe8tpfchQcdZArJ5b/7OrIz4W3b2uxVRDA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -3905,9 +3923,9 @@
|
|||||||
"node": "^20.19.0 || >=22.12.0"
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
|
"@rolldown/plugin-babel": "^0.1.7 || ^0.2.0 || ^0.3.0",
|
||||||
"babel-plugin-react-compiler": "^1.0.0",
|
"babel-plugin-react-compiler": "^1.0.0",
|
||||||
"oxc-transform-react": "^0.152.0",
|
"oxc-transform-react": ">=0.152.0 <0.157.0",
|
||||||
"vite": "^8.0.0"
|
"vite": "^8.0.0"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
@@ -4063,9 +4081,9 @@
|
|||||||
"license": "SEE LICENSE IN LICENSE.md"
|
"license": "SEE LICENSE IN LICENSE.md"
|
||||||
},
|
},
|
||||||
"node_modules/apexcharts": {
|
"node_modules/apexcharts": {
|
||||||
"version": "8.6.0",
|
"version": "8.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-8.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-8.7.0.tgz",
|
||||||
"integrity": "sha512-5524MLoDei2n8EcVMK+u3eOZTWrTpT1BIPRWu4AK8kkJdkODWs1hp3IEax8pMz78sSNAtsD4Tp7tr0s0SW/npQ==",
|
"integrity": "sha512-HHpMlM4QvLXFLL6hyn1iivF+3IyoCrBEXYcV9elJuT+Dc4At9P5+RAaSYA1onOrcXg59+VTAsnyUDdm4GRhUfA==",
|
||||||
"license": "SEE LICENSE IN LICENSE.md",
|
"license": "SEE LICENSE IN LICENSE.md",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"apex-commons": "^0.8.3"
|
"apex-commons": "^0.8.3"
|
||||||
@@ -5578,12 +5596,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/framer-motion": {
|
"node_modules/framer-motion": {
|
||||||
"version": "14.9.0",
|
"version": "14.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-14.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-14.10.0.tgz",
|
||||||
"integrity": "sha512-g1hZu8WoTwgsoVAaqEEcefhvR03cKGtkmrKaRrWpXhnM3LGR8jl1u2qyJ1PMVlO7LKQHE8TTRQYUlMmnrY4L+g==",
|
"integrity": "sha512-e8MjIet+IJKohYxBj8TAk9Mg5cD0HiOX87qde1YTPL67gI2ao074O6yBtQnIcWCk+bTX7XX4litepufLiYPrLg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"motion-dom": "14.9.0",
|
"motion-dom": "14.10.0",
|
||||||
"motion-utils": "14.7.0",
|
"motion-utils": "14.7.0",
|
||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
},
|
},
|
||||||
@@ -5786,9 +5804,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/globals": {
|
"node_modules/globals": {
|
||||||
"version": "17.13.0",
|
"version": "17.14.0",
|
||||||
"resolved": "https://registry.npmjs.org/globals/-/globals-17.13.0.tgz",
|
"resolved": "https://registry.npmjs.org/globals/-/globals-17.14.0.tgz",
|
||||||
"integrity": "sha512-RwMTC61u7hrG3ZJMkZQOK4JLJrKS8QtoTii63EmWvFXHqycY9FObBJQCbsLxSO8kjKhWE5kV1GC+Vb1g3RI0Zg==",
|
"integrity": "sha512-UtdkTVyAJePQv0J2M9Xe4L0ruNSDV314XiIJsADI2Zf40bcdAonE/D/OhAU8AT99oij9mhLByb+NvA3QWZYgCg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -5991,9 +6009,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/i18next": {
|
"node_modules/i18next": {
|
||||||
"version": "26.5.12",
|
"version": "26.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.5.12.tgz",
|
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.7.2.tgz",
|
||||||
"integrity": "sha512-vTt8tc3y5axoU7wwTYg8vjpILLyP33a5GyGJAVI7FDRx1XueYxZVIkcVeStoftrVnAM/o28hKvK0sslucDI9aQ==",
|
"integrity": "sha512-RsOY3BeaCDdkk52NeJSFAXELQ2rQXBO/tOub6L8ue2ICPfR/FRDWDOfEDg4xMPd1SqO5goVYQ6i72qWEUB/PPw==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "individual",
|
"type": "individual",
|
||||||
@@ -6106,9 +6124,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/i18next-http-backend": {
|
"node_modules/i18next-http-backend": {
|
||||||
"version": "4.0.7",
|
"version": "4.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-4.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-4.0.10.tgz",
|
||||||
"integrity": "sha512-SQy3OPy66YzumNJ43boHDBtzzMISnoPhQ0Zqjetk+luW74Tok8Ez9ujJJbJHKSHkG5sjDGldHznjuPtSxBwvuw==",
|
"integrity": "sha512-CfdQuwIVijbrS/fmiTAQYcYyje4nMbH9aIjKYhhm5bgVmWKWLOM1F7qrA1o63Jl1JxQcRjdtWhKlvDzyqV9aPg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
@@ -6585,9 +6603,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/konva": {
|
"node_modules/konva": {
|
||||||
"version": "10.9.0",
|
"version": "10.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/konva/-/konva-10.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/konva/-/konva-10.9.1.tgz",
|
||||||
"integrity": "sha512-3NOOlcbimFDp4+/DfXGTBkfn3cn3VP1BESPySQ6ayCzgMA9JSMGJNNxeIKw5jpHL6SAl6gHsrv10DUEjNRL0Qg==",
|
"integrity": "sha512-m49x+GatSxEUSvqahYhxpTjgKbtrLtw4P4o4B4iux953ZFpceaK52Ea+CFsAsd7W4I0Y5u6mMoRUsY/eltNZmw==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "patreon",
|
"type": "patreon",
|
||||||
@@ -6978,9 +6996,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lucide-react": {
|
"node_modules/lucide-react": {
|
||||||
"version": "1.65.0",
|
"version": "1.70.0",
|
||||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.65.0.tgz",
|
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.70.0.tgz",
|
||||||
"integrity": "sha512-roSdtzudEY39i2hn5LLjA8toYfhF2z4S10WSqZF4ubXpHlOmNTvaxMxZLdJfQDi6pEifQWX+cRmYihKSsvAxfA==",
|
"integrity": "sha512-gCTPPN1MlQUBtUgdsUv+0CAUj+X8hifRUiRBw4E5v7lvDIOIpx0EZezdGQMSimS8pKA6esvullJub4GYr0mCHA==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "*",
|
"@types/react": "*",
|
||||||
@@ -7003,9 +7021,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/markdown-to-jsx": {
|
"node_modules/markdown-to-jsx": {
|
||||||
"version": "9.10.2",
|
"version": "9.10.5",
|
||||||
"resolved": "https://registry.npmjs.org/markdown-to-jsx/-/markdown-to-jsx-9.10.2.tgz",
|
"resolved": "https://registry.npmjs.org/markdown-to-jsx/-/markdown-to-jsx-9.10.5.tgz",
|
||||||
"integrity": "sha512-iR9GadlIox0q1uXnpqdxpF02Vb1WDmZ/QIXWjBR5htzjUEEhyIWbM65LuVKavdwJaVo4q95C/F2OOyNjWe91ig==",
|
"integrity": "sha512-m/OFdkxlmWY13o3R0Yy6STooQRzJ0Q5IqqDqQeHOb7pAA2LNgLplEI+jhDyMdxpdr1pceFH6ykrBWGD6oRvW/A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 18"
|
"node": ">= 18"
|
||||||
@@ -8055,9 +8073,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/motion-dom": {
|
"node_modules/motion-dom": {
|
||||||
"version": "14.9.0",
|
"version": "14.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-14.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-14.10.0.tgz",
|
||||||
"integrity": "sha512-wGjKbNDMHd75aLStkxLCKZ+T8/wusZHklmNLK1Ak68UrdBt2X8I4yweYR/0WonxGnBsw6oloDTIV8pN+UTvzzA==",
|
"integrity": "sha512-k0LwJZ1R5xCvqrScszO4ywuZP52lRcT3QO0IUovgczom9ZHb0BaRLOX6M2O40q7TrSLvzX/fIHHmDm8TUMt3fg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"motion-utils": "14.7.0"
|
"motion-utils": "14.7.0"
|
||||||
@@ -8519,13 +8537,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright": {
|
"node_modules/playwright": {
|
||||||
"version": "1.64.1",
|
"version": "1.65.0",
|
||||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz",
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.65.0.tgz",
|
||||||
"integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==",
|
"integrity": "sha512-F3AMWcR44qMBt4kq+6Kb6mX7bjGXshktKFOPULd7reED6ZJWevjbNRqysP7q+AATE9kdPEZBibex1BYsvpVoeA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright-core": "1.64.1"
|
"playwright-core": "1.65.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright": "cli.js"
|
"playwright": "cli.js"
|
||||||
@@ -8535,9 +8553,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright-core": {
|
"node_modules/playwright-core": {
|
||||||
"version": "1.64.1",
|
"version": "1.65.0",
|
||||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz",
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.65.0.tgz",
|
||||||
"integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==",
|
"integrity": "sha512-vNSKgH6YqtN7cdT/L334zo0a+YwkrpicyrHJlWWPIm/Q+Nn2SWInil01oT0mbu96U4GgoRtCYQD9CzJQgju+lA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -9059,9 +9077,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-i18next": {
|
"node_modules/react-i18next": {
|
||||||
"version": "17.1.9",
|
"version": "17.1.13",
|
||||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.1.9.tgz",
|
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.1.13.tgz",
|
||||||
"integrity": "sha512-OgYqIH13XHPlWTaaI795Dz+k2AdKnXcOxNQVnf750T8Ij0TYWp1nYvXRpoJBulPqlyk2OAbn1PM4tFKiIj/byA==",
|
"integrity": "sha512-ZxhOSlyKSKPtbUNPLwYMmE3w3bhoc0jaZJZ6bc7wGOT6cLbrbs//6m01HxCLtFdSjHvFpwFwoPhxxzIlHSs9cw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.29.7",
|
"@babel/runtime": "^7.29.7",
|
||||||
@@ -9477,13 +9495,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/rolldown": {
|
"node_modules/rolldown": {
|
||||||
"version": "1.2.15",
|
"version": "1.2.16",
|
||||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.15.tgz",
|
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.16.tgz",
|
||||||
"integrity": "sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g==",
|
"integrity": "sha512-bWvlMpFKiTfDKfnK7FC2MPgPeWCf/Kpwsq49ApdW+FQaBZb6O6dZ4MAPbPUjp7hYQrw/RobqCzn64BRrQecGHw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@oxc-project/types": "=0.155.0",
|
"@oxc-project/types": "=0.156.0",
|
||||||
"@rolldown/pluginutils": "^1.0.0"
|
"@rolldown/pluginutils": "^1.0.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -9493,21 +9511,21 @@
|
|||||||
"node": "^20.19.0 || >=22.12.0"
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@rolldown/binding-android-arm-eabi": "1.2.15",
|
"@rolldown/binding-android-arm-eabi": "1.2.16",
|
||||||
"@rolldown/binding-android-arm64": "1.2.15",
|
"@rolldown/binding-android-arm64": "1.2.16",
|
||||||
"@rolldown/binding-darwin-arm64": "1.2.15",
|
"@rolldown/binding-darwin-arm64": "1.2.16",
|
||||||
"@rolldown/binding-darwin-x64": "1.2.15",
|
"@rolldown/binding-darwin-x64": "1.2.16",
|
||||||
"@rolldown/binding-freebsd-x64": "1.2.15",
|
"@rolldown/binding-freebsd-x64": "1.2.16",
|
||||||
"@rolldown/binding-linux-arm-gnueabihf": "1.2.15",
|
"@rolldown/binding-linux-arm-gnueabihf": "1.2.16",
|
||||||
"@rolldown/binding-linux-arm64-gnu": "1.2.15",
|
"@rolldown/binding-linux-arm64-gnu": "1.2.16",
|
||||||
"@rolldown/binding-linux-arm64-musl": "1.2.15",
|
"@rolldown/binding-linux-arm64-musl": "1.2.16",
|
||||||
"@rolldown/binding-linux-ppc64-gnu": "1.2.15",
|
"@rolldown/binding-linux-ppc64-gnu": "1.2.16",
|
||||||
"@rolldown/binding-linux-s390x-gnu": "1.2.15",
|
"@rolldown/binding-linux-s390x-gnu": "1.2.16",
|
||||||
"@rolldown/binding-linux-x64-gnu": "1.2.15",
|
"@rolldown/binding-linux-x64-gnu": "1.2.16",
|
||||||
"@rolldown/binding-linux-x64-musl": "1.2.15",
|
"@rolldown/binding-linux-x64-musl": "1.2.16",
|
||||||
"@rolldown/binding-openharmony-arm64": "1.2.15",
|
"@rolldown/binding-openharmony-arm64": "1.2.16",
|
||||||
"@rolldown/binding-win32-arm64-msvc": "1.2.15",
|
"@rolldown/binding-win32-arm64-msvc": "1.2.16",
|
||||||
"@rolldown/binding-win32-x64-msvc": "1.2.15"
|
"@rolldown/binding-win32-x64-msvc": "1.2.16"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/run-async": {
|
"node_modules/run-async": {
|
||||||
@@ -10364,9 +10382,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/virtua": {
|
"node_modules/virtua": {
|
||||||
"version": "0.54.0",
|
"version": "0.54.2",
|
||||||
"resolved": "https://registry.npmjs.org/virtua/-/virtua-0.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/virtua/-/virtua-0.54.2.tgz",
|
||||||
"integrity": "sha512-8nGGrDhX61MDVeqgyIaWrJqhRm9z2Nqa1WDqt0tk1m+ZWrvLesMP0iL0vL6xNsLCF2jE2ohwY+PqufdbVmRgEw==",
|
"integrity": "sha512-lYCAxyXCGkhXVvuKv8a+LklZquy0E9dIObYcOUyo6nP4VSvLeiIsiz94mA3ZKYuM5egkke5RIi2nXi5OIATc7A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@angular/common": ">=20.0.0",
|
"@angular/common": ">=20.0.0",
|
||||||
@@ -10402,16 +10420,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "8.3.5",
|
"version": "8.3.6",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.6.tgz",
|
||||||
"integrity": "sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==",
|
"integrity": "sha512-UI7dK29NnS54Fk+mmo7o+jI6U8Fp7hDl+X5Q1k45WH+d4dSlbLVUETGQ+hfkNgvLQGyoElPj837nZyv4MBc0VA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lightningcss": "^1.33.0",
|
"lightningcss": "^1.33.0",
|
||||||
"picomatch": "^4.0.7",
|
"picomatch": "^4.0.7",
|
||||||
"postcss": "^8.5.30",
|
"postcss": "^8.5.32",
|
||||||
"rolldown": "~1.2.14",
|
"rolldown": "~1.2.16",
|
||||||
"tinyglobby": "^0.2.17"
|
"tinyglobby": "^0.2.17"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|||||||
+17
-17
@@ -21,8 +21,8 @@
|
|||||||
"i18n:status": "i18next-cli status"
|
"i18n:status": "i18next-cli status"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cycjimmy/jsmpeg-player": "^6.1.8",
|
"@cycjimmy/jsmpeg-player": "^7.0.0",
|
||||||
"@hookform/resolvers": "^6.0.2",
|
"@hookform/resolvers": "^7.0.3",
|
||||||
"@radix-ui/react-alert-dialog": "^1.2.1",
|
"@radix-ui/react-alert-dialog": "^1.2.1",
|
||||||
"@radix-ui/react-aspect-ratio": "^1.2.0",
|
"@radix-ui/react-aspect-ratio": "^1.2.0",
|
||||||
"@radix-ui/react-checkbox": "^1.4.1",
|
"@radix-ui/react-checkbox": "^1.4.1",
|
||||||
@@ -45,11 +45,11 @@
|
|||||||
"@radix-ui/react-toggle": "^1.2.1",
|
"@radix-ui/react-toggle": "^1.2.1",
|
||||||
"@radix-ui/react-toggle-group": "^1.2.1",
|
"@radix-ui/react-toggle-group": "^1.2.1",
|
||||||
"@radix-ui/react-tooltip": "^1.4.1",
|
"@radix-ui/react-tooltip": "^1.4.1",
|
||||||
"@rjsf/core": "^6.10.1",
|
"@rjsf/core": "^6.11.1",
|
||||||
"@rjsf/shadcn": "^6.10.1",
|
"@rjsf/shadcn": "^6.11.1",
|
||||||
"@rjsf/utils": "^6.10.1",
|
"@rjsf/utils": "^6.10.1",
|
||||||
"@rjsf/validator-ajv8": "^6.10.1",
|
"@rjsf/validator-ajv8": "^6.11.1",
|
||||||
"apexcharts": "^8.6.0",
|
"apexcharts": "^8.7.0",
|
||||||
"axios": "^1.21.0",
|
"axios": "^1.21.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@@ -57,15 +57,15 @@
|
|||||||
"copy-to-clipboard": "^4.0.2",
|
"copy-to-clipboard": "^4.0.2",
|
||||||
"date-fns": "^4.4.0",
|
"date-fns": "^4.4.0",
|
||||||
"date-fns-tz": "^3.2.0",
|
"date-fns-tz": "^3.2.0",
|
||||||
"framer-motion": "^14.9.0",
|
"framer-motion": "^14.10.0",
|
||||||
"hls.js": "^1.7.4",
|
"hls.js": "^1.7.4",
|
||||||
"i18next": "^26.5.12",
|
"i18next": "^26.7.2",
|
||||||
"i18next-http-backend": "^4.0.7",
|
"i18next-http-backend": "^4.0.10",
|
||||||
"idb-keyval": "^6.3.0",
|
"idb-keyval": "^6.3.0",
|
||||||
"js-yaml": "^5.5.0",
|
"js-yaml": "^5.5.0",
|
||||||
"konva": "^10.9.0",
|
"konva": "^10.9.1",
|
||||||
"lodash": "^4.18.1",
|
"lodash": "^4.18.1",
|
||||||
"lucide-react": "^1.65.0",
|
"lucide-react": "^1.70.0",
|
||||||
"monaco-yaml": "^5.5.1",
|
"monaco-yaml": "^5.5.1",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"nosleep.js": "^0.12.0",
|
"nosleep.js": "^0.12.0",
|
||||||
@@ -77,7 +77,7 @@
|
|||||||
"react-dropzone": "^20.1.4",
|
"react-dropzone": "^20.1.4",
|
||||||
"react-grid-layout": "^2.3.0",
|
"react-grid-layout": "^2.3.0",
|
||||||
"react-hook-form": "^7.91.0",
|
"react-hook-form": "^7.91.0",
|
||||||
"react-i18next": "^17.1.9",
|
"react-i18next": "^17.1.13",
|
||||||
"react-icons": "^5.7.0",
|
"react-icons": "^5.7.0",
|
||||||
"react-konva": "^19.3.2",
|
"react-konva": "^19.3.2",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
@@ -93,19 +93,19 @@
|
|||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"use-long-press": "^3.3.0",
|
"use-long-press": "^3.3.0",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
"virtua": "^0.54.0",
|
"virtua": "^0.54.2",
|
||||||
"vite-plugin-monaco-editor": "^1.1.0",
|
"vite-plugin-monaco-editor": "^1.1.0",
|
||||||
"zod": "^4.6.7"
|
"zod": "^4.6.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@playwright/test": "^1.64.1",
|
"@playwright/test": "^1.65.0",
|
||||||
"@types/js-yaml": "^4.0.9",
|
"@types/js-yaml": "^4.0.9",
|
||||||
"@types/lodash": "^4.17.27",
|
"@types/lodash": "^4.17.27",
|
||||||
"@types/node": "^26.6.6",
|
"@types/node": "^26.6.6",
|
||||||
"@types/react": "^19.3.0",
|
"@types/react": "^19.3.0",
|
||||||
"@types/react-dom": "^19.3.0",
|
"@types/react-dom": "^19.3.0",
|
||||||
"@vitejs/plugin-react": "^6.1.2",
|
"@vitejs/plugin-react": "^6.2.1",
|
||||||
"autoprefixer": "^10.6.2",
|
"autoprefixer": "^10.6.2",
|
||||||
"esbuild": "^0.28.2",
|
"esbuild": "^0.28.2",
|
||||||
"eslint": "^10.14.0",
|
"eslint": "^10.14.0",
|
||||||
@@ -113,7 +113,7 @@
|
|||||||
"eslint-plugin-prettier": "^5.5.7",
|
"eslint-plugin-prettier": "^5.5.7",
|
||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
"eslint-plugin-react-refresh": "^0.5.7",
|
"eslint-plugin-react-refresh": "^0.5.7",
|
||||||
"globals": "^17.13.0",
|
"globals": "^17.14.0",
|
||||||
"i18next-cli": "^1.5.11",
|
"i18next-cli": "^1.5.11",
|
||||||
"monaco-editor": "^0.53.0",
|
"monaco-editor": "^0.53.0",
|
||||||
"patch-package": "^8.0.1",
|
"patch-package": "^8.0.1",
|
||||||
@@ -123,7 +123,7 @@
|
|||||||
"tailwindcss": "^3.4.19",
|
"tailwindcss": "^3.4.19",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"typescript-eslint": "^8.72.1",
|
"typescript-eslint": "^8.72.1",
|
||||||
"vite": "^8.3.5"
|
"vite": "^8.3.6"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"@radix-ui/react-compose-refs": "1.1.2",
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
|||||||
@@ -1289,8 +1289,6 @@
|
|||||||
},
|
},
|
||||||
"apiKey": {
|
"apiKey": {
|
||||||
"title": "Ключ API Frigate+",
|
"title": "Ключ API Frigate+",
|
||||||
"validated": "Ключ API Frigate+ выяўлены і правераны",
|
|
||||||
"notValidated": "Ключ API Frigate+ не выяўлены або не правераны",
|
|
||||||
"desc": "Ключ API Frigate+ уключае інтэграцыю з сэрвісам Frigate+.",
|
"desc": "Ключ API Frigate+ уключае інтэграцыю з сэрвісам Frigate+.",
|
||||||
"plusLink": "Падрабязней пра Frigate+"
|
"plusLink": "Падрабязней пра Frigate+"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1086,8 +1086,6 @@
|
|||||||
},
|
},
|
||||||
"apiKey": {
|
"apiKey": {
|
||||||
"title": "Frigate+ API ključ",
|
"title": "Frigate+ API ključ",
|
||||||
"validated": "Frigate+ API ključ je detektovan i validiran",
|
|
||||||
"notValidated": "Frigate+ API ključ nije detektovan ili nije validiran",
|
|
||||||
"desc": "Frigate+ API ključ omogućava integraciju sa uslugom Frigate+.",
|
"desc": "Frigate+ API ključ omogućava integraciju sa uslugom Frigate+.",
|
||||||
"plusLink": "Pročitajte više o Frigate+"
|
"plusLink": "Pročitajte više o Frigate+"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -714,9 +714,14 @@
|
|||||||
"apiKey": {
|
"apiKey": {
|
||||||
"plusLink": "Llegeix més sobre Frigate+",
|
"plusLink": "Llegeix més sobre Frigate+",
|
||||||
"title": "Clau API de Frigate+",
|
"title": "Clau API de Frigate+",
|
||||||
"validated": "La clau API de Frigate+ ha estat detectada i validada",
|
"desc": "La clau API de Frigate+ habilita la integració amb el servei Frigate+.",
|
||||||
"notValidated": "La clau API de Frigate+ no ha estat detectada o no ha estat validada",
|
"formatValid": "S'ha detectat la clau API Frigate+ i coincideix amb el format esperat",
|
||||||
"desc": "La clau API de Frigate+ habilita la integració amb el servei Frigate+."
|
"formatInvalid": "La clau API Frigate+ no es detecta o no coincideix amb el format esperat",
|
||||||
|
"validate": "Valida amb Frigate+",
|
||||||
|
"validating": "S'està validant…",
|
||||||
|
"serverValid": "Frigate+ ha acceptat aquesta clau API",
|
||||||
|
"serverInvalid": "Frigate+ ha rebutjat aquesta clau API",
|
||||||
|
"serverUnreachable": "No s'ha pogut arribar a Frigate+ per validar aquesta clau API"
|
||||||
},
|
},
|
||||||
"unsavedChanges": "Canvis dels paràmetres de Frigate+ sense desar",
|
"unsavedChanges": "Canvis dels paràmetres de Frigate+ sense desar",
|
||||||
"title": "Paràmetres de Frigate+",
|
"title": "Paràmetres de Frigate+",
|
||||||
|
|||||||
@@ -58,9 +58,7 @@
|
|||||||
"cleanCopyWarning": "Některé kamery mají snímky zakázané"
|
"cleanCopyWarning": "Některé kamery mají snímky zakázané"
|
||||||
},
|
},
|
||||||
"apiKey": {
|
"apiKey": {
|
||||||
"notValidated": "API klíč Frigate+ nebyl rozpoznán nebo nebyl ověřen",
|
|
||||||
"plusLink": "Přečtěte si více o Frigate+",
|
"plusLink": "Přečtěte si více o Frigate+",
|
||||||
"validated": "API klíč Frigate+ byl rozpoznán a ověřen",
|
|
||||||
"desc": "API klíč Frigate+ umožňuje integraci se službou Frigate+.",
|
"desc": "API klíč Frigate+ umožňuje integraci se službou Frigate+.",
|
||||||
"title": "Frigate+ API Klíč"
|
"title": "Frigate+ API Klíč"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -639,8 +639,6 @@
|
|||||||
"apiKey": {
|
"apiKey": {
|
||||||
"title": "Frigate+ API Key",
|
"title": "Frigate+ API Key",
|
||||||
"desc": "Der Frigate+ API Key aktiviert die Integration des Frigate+ Dienstes.",
|
"desc": "Der Frigate+ API Key aktiviert die Integration des Frigate+ Dienstes.",
|
||||||
"validated": "Frigate+ API Key erkannt und validiert",
|
|
||||||
"notValidated": "Frigate+ API Key nicht erkannt und validiert",
|
|
||||||
"plusLink": "Lese mehr zu Frigate+"
|
"plusLink": "Lese mehr zu Frigate+"
|
||||||
},
|
},
|
||||||
"snapshotConfig": {
|
"snapshotConfig": {
|
||||||
|
|||||||
@@ -1285,8 +1285,13 @@
|
|||||||
},
|
},
|
||||||
"apiKey": {
|
"apiKey": {
|
||||||
"title": "Frigate+ API Key",
|
"title": "Frigate+ API Key",
|
||||||
"validated": "Frigate+ API key is detected and validated",
|
"formatValid": "Frigate+ API key is detected and matches the expected format",
|
||||||
"notValidated": "Frigate+ API key is not detected or not validated",
|
"formatInvalid": "Frigate+ API key is not detected or does not match the expected format",
|
||||||
|
"validate": "Validate with Frigate+",
|
||||||
|
"validating": "Validating…",
|
||||||
|
"serverValid": "Frigate+ accepted this API key",
|
||||||
|
"serverInvalid": "Frigate+ rejected this API key",
|
||||||
|
"serverUnreachable": "Unable to reach Frigate+ to validate this API key",
|
||||||
"desc": "The Frigate+ API key enables integration with the Frigate+ service.",
|
"desc": "The Frigate+ API key enables integration with the Frigate+ service.",
|
||||||
"plusLink": "Read more about Frigate+"
|
"plusLink": "Read more about Frigate+"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -621,10 +621,8 @@
|
|||||||
"title": "Configuración de Frigate+",
|
"title": "Configuración de Frigate+",
|
||||||
"apiKey": {
|
"apiKey": {
|
||||||
"title": "Clave API de Frigate+",
|
"title": "Clave API de Frigate+",
|
||||||
"notValidated": "La clave API de Frigate+ no ha sido detectada o no ha sido validada",
|
|
||||||
"plusLink": "Lee más sobre Frigate+",
|
"plusLink": "Lee más sobre Frigate+",
|
||||||
"desc": "La clave API de Frigate+ permite la integración con el servicio Frigate+.",
|
"desc": "La clave API de Frigate+ permite la integración con el servicio Frigate+."
|
||||||
"validated": "La clave API de Frigate+ ha sido detectada y validada"
|
|
||||||
},
|
},
|
||||||
"snapshotConfig": {
|
"snapshotConfig": {
|
||||||
"title": "Configuración de instantáneas",
|
"title": "Configuración de instantáneas",
|
||||||
|
|||||||
@@ -76,7 +76,8 @@
|
|||||||
"24hour": "dd. MMM yyyy, HH:mm"
|
"24hour": "dd. MMM yyyy, HH:mm"
|
||||||
},
|
},
|
||||||
"formattedTimestampMonthDay": "dd. MMM",
|
"formattedTimestampMonthDay": "dd. MMM",
|
||||||
"never": "Mitte kunagi"
|
"never": "Mitte kunagi",
|
||||||
|
"custom": "Kohanadatud…"
|
||||||
},
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"user": {
|
"user": {
|
||||||
|
|||||||
@@ -1143,11 +1143,9 @@
|
|||||||
},
|
},
|
||||||
"frigatePlus": {
|
"frigatePlus": {
|
||||||
"apiKey": {
|
"apiKey": {
|
||||||
"notValidated": "کلید API Frigate+ شناسایی نشده یا معتبرسازی نشده است",
|
|
||||||
"desc": "کلید API Frigate+ امکان یکپارچهسازی با سرویس Frigate+ را فراهم میکند.",
|
"desc": "کلید API Frigate+ امکان یکپارچهسازی با سرویس Frigate+ را فراهم میکند.",
|
||||||
"plusLink": "دربارهٔ Frigate+ بیشتر بخوانید",
|
"plusLink": "دربارهٔ Frigate+ بیشتر بخوانید",
|
||||||
"title": "کلید API فرigate+",
|
"title": "کلید API فرigate+"
|
||||||
"validated": "کلید API فرigate+ شناسایی و تأیید شد"
|
|
||||||
},
|
},
|
||||||
"snapshotConfig": {
|
"snapshotConfig": {
|
||||||
"title": "پیکربندی عکس فوری",
|
"title": "پیکربندی عکس فوری",
|
||||||
|
|||||||
@@ -82,7 +82,8 @@
|
|||||||
"inProgress": "En cours",
|
"inProgress": "En cours",
|
||||||
"invalidStartTime": "Heure de début invalide",
|
"invalidStartTime": "Heure de début invalide",
|
||||||
"invalidEndTime": "Heure de fin invalide",
|
"invalidEndTime": "Heure de fin invalide",
|
||||||
"never": "Jamais"
|
"never": "Jamais",
|
||||||
|
"custom": "Personnalisé…"
|
||||||
},
|
},
|
||||||
"button": {
|
"button": {
|
||||||
"apply": "Appliquer",
|
"apply": "Appliquer",
|
||||||
|
|||||||
@@ -7,11 +7,11 @@
|
|||||||
"label": "Supprimer le groupe de caméras",
|
"label": "Supprimer le groupe de caméras",
|
||||||
"confirm": {
|
"confirm": {
|
||||||
"title": "Confirmez la suppression",
|
"title": "Confirmez la suppression",
|
||||||
"desc": "Êtes-vous sûr de vouloir supprimer le groupe de caméras <em>{{name}}</em> ?"
|
"desc": "Êtes-vous sûr de vouloir supprimer le groupe de caméras <em>{{name}}</em>?"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"name": {
|
"name": {
|
||||||
"placeholder": "Saisissez un nom.",
|
"placeholder": "Saisissez un nom…",
|
||||||
"label": "Nom",
|
"label": "Nom",
|
||||||
"errorMessage": {
|
"errorMessage": {
|
||||||
"mustLeastCharacters": "Le nom du groupe de caméras doit comporter au moins 2 caractères.",
|
"mustLeastCharacters": "Le nom du groupe de caméras doit comporter au moins 2 caractères.",
|
||||||
|
|||||||
@@ -87,7 +87,8 @@
|
|||||||
"placeholder": "Sélectionner un dossier",
|
"placeholder": "Sélectionner un dossier",
|
||||||
"newCaseOption": "Créer un nouveau cas",
|
"newCaseOption": "Créer un nouveau cas",
|
||||||
"newCaseNamePlaceholder": "Nouveau nom de cas",
|
"newCaseNamePlaceholder": "Nouveau nom de cas",
|
||||||
"newCaseDescriptionPlaceholder": "Description de cas"
|
"newCaseDescriptionPlaceholder": "Description de cas",
|
||||||
|
"nameRequired": "Nom du dossier requis"
|
||||||
},
|
},
|
||||||
"queueing": "Mise en file d'attente de l'export...",
|
"queueing": "Mise en file d'attente de l'export...",
|
||||||
"tabs": {
|
"tabs": {
|
||||||
@@ -116,7 +117,9 @@
|
|||||||
"selectWithActivity": "Caméras avec des objets suivis",
|
"selectWithActivity": "Caméras avec des objets suivis",
|
||||||
"selectGroup": "Sélectionner un groupe",
|
"selectGroup": "Sélectionner un groupe",
|
||||||
"noMatchingCameras": "Aucune caméra ne correspond à votre recherche",
|
"noMatchingCameras": "Aucune caméra ne correspond à votre recherche",
|
||||||
"selectedCount": "{{selected}} / {{total}} sélectionnées"
|
"selectedCount": "{{selected}} / {{total}} sélectionnées",
|
||||||
|
"noCamerasSelected": "Sélectionner au moins une caméra",
|
||||||
|
"caseNameDefault": "Export Caméra multiple - {{date}}"
|
||||||
},
|
},
|
||||||
"multi": {
|
"multi": {
|
||||||
"title_one": "Export {{count}} revue",
|
"title_one": "Export {{count}} revue",
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
"title": "Toutes les dates",
|
"title": "Toutes les dates",
|
||||||
"short": "Dates"
|
"short": "Dates"
|
||||||
},
|
},
|
||||||
"selectPreset": "Sélectionnez un préréglage."
|
"selectPreset": "Sélectionnez un préréglage…"
|
||||||
},
|
},
|
||||||
"more": "Plus de filtres",
|
"more": "Plus de filtres",
|
||||||
"reset": {
|
"reset": {
|
||||||
@@ -105,7 +105,7 @@
|
|||||||
"allLogs": "Tous les journaux"
|
"allLogs": "Tous les journaux"
|
||||||
},
|
},
|
||||||
"recognizedLicensePlates": {
|
"recognizedLicensePlates": {
|
||||||
"placeholder": "Tapez pour rechercher des plaques d'immatriculation.",
|
"placeholder": "Tapez pour rechercher des plaques d'immatriculation…",
|
||||||
"noLicensePlatesFound": "Aucune plaque d'immatriculation trouvée",
|
"noLicensePlatesFound": "Aucune plaque d'immatriculation trouvée",
|
||||||
"loading": "Chargement des plaques d'immatriculation reconnues…",
|
"loading": "Chargement des plaques d'immatriculation reconnues…",
|
||||||
"title": "Plaques d'immatriculation reconnues",
|
"title": "Plaques d'immatriculation reconnues",
|
||||||
|
|||||||
@@ -48,7 +48,7 @@
|
|||||||
},
|
},
|
||||||
"labelmap": {
|
"labelmap": {
|
||||||
"label": "Customisation Audio",
|
"label": "Customisation Audio",
|
||||||
"description": "Remplacements ou entrées de remappage à fusionner avec la table standard des libellés audio."
|
"description": "Remplacements ou réassignations à fusionner aux étiquettes audio standard."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"audio_transcription": {
|
"audio_transcription": {
|
||||||
@@ -78,8 +78,8 @@
|
|||||||
"description": "Emplacement numérique contrôlant l'ordre de la caméra dans la disposition en vue à vol d'oiseau."
|
"description": "Emplacement numérique contrôlant l'ordre de la caméra dans la disposition en vue à vol d'oiseau."
|
||||||
},
|
},
|
||||||
"modes": {
|
"modes": {
|
||||||
"label": "Types d'activité",
|
"label": "Types d'événements",
|
||||||
"description": "Types d’activité qui incluent des caméras dans Birdseye."
|
"description": "Types d'activité qui incluent les caméras dans Birdseye."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"detect": {
|
"detect": {
|
||||||
@@ -143,7 +143,7 @@
|
|||||||
},
|
},
|
||||||
"scene": {
|
"scene": {
|
||||||
"label": "Détecter la scène",
|
"label": "Détecter la scène",
|
||||||
"description": "L'environnement filmé par cette caméra, utilisé pour déterminer lequel des modèles configurés doit être exécuté sur celle-ci. Les caméras laissées sur « default » utilisent le modèle configuré avec une scène définie sur « default »."
|
"description": "L'environnement filmé par cette caméra, utilisé pour choisir le modèle configuré à exécuter. Les caméras laissées sur « default » exécutent le modèle configuré avec la scène « default »."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"face_recognition": {
|
"face_recognition": {
|
||||||
@@ -240,6 +240,30 @@
|
|||||||
"quality": {
|
"quality": {
|
||||||
"label": "Qualité de la diffusion en direct",
|
"label": "Qualité de la diffusion en direct",
|
||||||
"description": "Qualité de l'encodage pour le flux jsmpeg (1 étant la plus élevée, 31 la plus faible)."
|
"description": "Qualité de l'encodage pour le flux jsmpeg (1 étant la plus élevée, 31 la plus faible)."
|
||||||
|
},
|
||||||
|
"transcode": {
|
||||||
|
"label": "Flux transcodés",
|
||||||
|
"description": "Flux en direct transcodé en qualité inférieur à la demande de go2rtc.",
|
||||||
|
"enabled": {
|
||||||
|
"label": "Activé les flux transcodés",
|
||||||
|
"description": "ajouter un flux en direct de qualité inférieur que go2rtc transcode en temps réel pendant que quelqu'un regarde."
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"label": "Flux source",
|
||||||
|
"description": "flux à décoder par go2rtc. Par défaut le premier flux en direct."
|
||||||
|
},
|
||||||
|
"qualities": {
|
||||||
|
"label": "Qualités",
|
||||||
|
"description": "Un flux transcodé est ajouté par qualité.",
|
||||||
|
"height": {
|
||||||
|
"label": "Taille",
|
||||||
|
"description": "Taille de sortie en pixels; le forma respect le ratio de la source."
|
||||||
|
},
|
||||||
|
"bitrate": {
|
||||||
|
"label": "Bitrate",
|
||||||
|
"description": "Cible et bitrate maximum de la vidéo en kbps."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"lpr": {
|
"lpr": {
|
||||||
@@ -517,6 +541,41 @@
|
|||||||
"enabled_in_config": {
|
"enabled_in_config": {
|
||||||
"label": "État original de l'enregistrement",
|
"label": "État original de l'enregistrement",
|
||||||
"description": "Indique si l'enregistrement était activé dans la configuration statique originale."
|
"description": "Indique si l'enregistrement était activé dans la configuration statique originale."
|
||||||
|
},
|
||||||
|
"sub": {
|
||||||
|
"label": "Enregistrement flux secondaire",
|
||||||
|
"description": "Paramètres pour enregistrer un second flux, de qualité moindre.",
|
||||||
|
"enabled": {
|
||||||
|
"label": "Activer l'enregistrement des flux secondaires",
|
||||||
|
"description": "Activer l'enregistrement d'un flux secondaire, de qualité moindre pour une qualité adaptative et une rétention étendue."
|
||||||
|
},
|
||||||
|
"continuous": {
|
||||||
|
"label": "Rétention continue des flux secondaires",
|
||||||
|
"description": "Nombre de jour de rétention des enregistrements des flux secondaire sans prise en compte d'objet traqué ou de mouvement.",
|
||||||
|
"days": {
|
||||||
|
"label": "Jour de rétention",
|
||||||
|
"description": "Combien de jour conserver les enregistrements."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"motion": {
|
||||||
|
"label": "Rétention des flux secondaires liés au mouvement",
|
||||||
|
"description": "Nombre de jours de conservation des enregistrements des flux secondaires déclenchés par un mouvement.",
|
||||||
|
"days": {
|
||||||
|
"label": "Jours de rétention",
|
||||||
|
"description": "Combien de jours conserver les enregistrements."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"alerts": {
|
||||||
|
"label": "Alerte rétention flux secondaires",
|
||||||
|
"description": "Paramètres de conservation pour les flux secondaires liés aux alertes.",
|
||||||
|
"days": {
|
||||||
|
"label": "Jours de rétention",
|
||||||
|
"description": "Nombre de jour de conservation des enregistrements lié à la détection d'événements."
|
||||||
|
},
|
||||||
|
"mode": {
|
||||||
|
"label": "Mode de rétention"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"review": {
|
"review": {
|
||||||
@@ -534,7 +593,7 @@
|
|||||||
},
|
},
|
||||||
"required_zones": {
|
"required_zones": {
|
||||||
"label": "Zones requises",
|
"label": "Zones requises",
|
||||||
"description": "Zones qu'un objet doit entrer pour être considéré comme une alerte. (Laisser vide afin d'autoriser n'importe quelle zone.)"
|
"description": "Zones dans lesquelles un objet doit entrer pour être considéré comme une alarme; laisser vide pour définir l'entièreté de la zone"
|
||||||
},
|
},
|
||||||
"enabled_in_config": {
|
"enabled_in_config": {
|
||||||
"label": "État des alertes originales",
|
"label": "État des alertes originales",
|
||||||
@@ -544,13 +603,13 @@
|
|||||||
"label": "Temps de coupure des alertes",
|
"label": "Temps de coupure des alertes",
|
||||||
"description": "Délai en secondes après l'absence d'activité déclenchant une alerte avant de la couper."
|
"description": "Délai en secondes après l'absence d'activité déclenchant une alerte avant de la couper."
|
||||||
},
|
},
|
||||||
"description": "Paramètres pour lesquels les objets suivis génèrent des alertes et comment ces alertes sont conservées."
|
"description": "Paramètres définissant quels objets suivis génèrent des alertes et comment ces alertes sont conservées."
|
||||||
},
|
},
|
||||||
"detections": {
|
"detections": {
|
||||||
"label": "Configuration des détections",
|
"label": "Configuration des détections",
|
||||||
"description": "Paramètres pour lesquels les objets suivis génèrent des détections (hors alerte) et comment les détections sont conservées.",
|
"description": "Paramètres pour lesquels les objets suivis génèrent des détections (hors alerte) et comment les détections sont conservées.",
|
||||||
"enabled": {
|
"enabled": {
|
||||||
"label": "Activer les détections",
|
"label": "Activation des détections",
|
||||||
"description": "Activer ou désactiver les événements de détection pour cette caméra."
|
"description": "Activer ou désactiver les événements de détection pour cette caméra."
|
||||||
},
|
},
|
||||||
"labels": {
|
"labels": {
|
||||||
|
|||||||
@@ -88,8 +88,8 @@
|
|||||||
"label": "Qualité d'encodage"
|
"label": "Qualité d'encodage"
|
||||||
},
|
},
|
||||||
"modes": {
|
"modes": {
|
||||||
"label": "Types d'activité",
|
"label": "Types d'événements",
|
||||||
"description": "Types d’activité qui incluent des caméras dans Birdseye."
|
"description": "Types d'activité qui incluent les caméras dans Birdseye."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"detect": {
|
"detect": {
|
||||||
@@ -98,7 +98,7 @@
|
|||||||
},
|
},
|
||||||
"scene": {
|
"scene": {
|
||||||
"label": "Détecter la scène",
|
"label": "Détecter la scène",
|
||||||
"description": "L'environnement filmé par cette caméra, utilisé pour déterminer lequel des modèles configurés doit être exécuté sur celle-ci. Les caméras laissées sur « default » utilisent le modèle configuré avec une scène définie sur « default »."
|
"description": "L'environnement filmé par cette caméra, utilisé pour choisir le modèle configuré à exécuter. Les caméras laissées sur « default » exécutent le modèle configuré avec la scène « default »."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"motion": {
|
"motion": {
|
||||||
@@ -222,7 +222,7 @@
|
|||||||
},
|
},
|
||||||
"labelmap": {
|
"labelmap": {
|
||||||
"label": "Customisation Audio",
|
"label": "Customisation Audio",
|
||||||
"description": "Remplacements ou entrées de remappage à fusionner avec la table standard des libellés audio."
|
"description": "Remplacements ou réassignations à fusionner aux étiquettes audio standard."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ffmpeg": {
|
"ffmpeg": {
|
||||||
@@ -341,6 +341,41 @@
|
|||||||
"enabled_in_config": {
|
"enabled_in_config": {
|
||||||
"label": "État original de l'enregistrement",
|
"label": "État original de l'enregistrement",
|
||||||
"description": "Indique si l'enregistrement était activé dans la configuration statique originale."
|
"description": "Indique si l'enregistrement était activé dans la configuration statique originale."
|
||||||
|
},
|
||||||
|
"sub": {
|
||||||
|
"label": "Enregistrement flux secondaire",
|
||||||
|
"description": "Paramètres pour enregistrer un second flux, de qualité moindre.",
|
||||||
|
"enabled": {
|
||||||
|
"label": "Activer l'enregistrement des flux secondaires",
|
||||||
|
"description": "Activer l'enregistrement d'un flux secondaire, de qualité moindre pour une qualité adaptative et une rétention étendue."
|
||||||
|
},
|
||||||
|
"continuous": {
|
||||||
|
"label": "Rétention continue des flux secondaires",
|
||||||
|
"description": "Nombre de jour de rétention des enregistrements des flux secondaire sans prise en compte d'objet traqué ou de mouvement.",
|
||||||
|
"days": {
|
||||||
|
"label": "Jour de rétention",
|
||||||
|
"description": "Combien de jour conserver les enregistrements."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"motion": {
|
||||||
|
"label": "Rétention des flux secondaires liés au mouvement",
|
||||||
|
"description": "Nombre de jours de conservation des enregistrements des flux secondaires déclenchés par un mouvement.",
|
||||||
|
"days": {
|
||||||
|
"label": "Jours de rétention",
|
||||||
|
"description": "Combien de jours conserver les enregistrements."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"alerts": {
|
||||||
|
"label": "Alerte rétention flux secondaires",
|
||||||
|
"description": "Paramètres de conservation pour les flux secondaires liés aux alertes.",
|
||||||
|
"days": {
|
||||||
|
"label": "Jours de rétention",
|
||||||
|
"description": "Nombre de jour de conservation des enregistrements lié à la détection d'événements."
|
||||||
|
},
|
||||||
|
"mode": {
|
||||||
|
"label": "Mode de rétention"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"review": {
|
"review": {
|
||||||
@@ -356,7 +391,7 @@
|
|||||||
},
|
},
|
||||||
"required_zones": {
|
"required_zones": {
|
||||||
"label": "Zones requises",
|
"label": "Zones requises",
|
||||||
"description": "Zones qu'un objet doit entrer pour être considéré comme une alerte. (Laisser vide afin d'autoriser n'importe quelle zone.)"
|
"description": "Zones dans lesquelles un objet doit entrer pour être considéré comme une alarme; laisser vide pour définir l'entièreté de la zone"
|
||||||
},
|
},
|
||||||
"enabled_in_config": {
|
"enabled_in_config": {
|
||||||
"label": "État des alertes originales",
|
"label": "État des alertes originales",
|
||||||
@@ -366,13 +401,13 @@
|
|||||||
"label": "Temps de coupure des alertes",
|
"label": "Temps de coupure des alertes",
|
||||||
"description": "Délai en secondes après l'absence d'activité déclenchant une alerte avant de la couper."
|
"description": "Délai en secondes après l'absence d'activité déclenchant une alerte avant de la couper."
|
||||||
},
|
},
|
||||||
"description": "Paramètres pour lesquels les objets suivis génèrent des alertes et comment ces alertes sont conservées."
|
"description": "Paramètres définissant quels objets suivis génèrent des alertes et comment ces alertes sont conservées."
|
||||||
},
|
},
|
||||||
"detections": {
|
"detections": {
|
||||||
"label": "Configuration des détections",
|
"label": "Configuration des détections",
|
||||||
"description": "Paramètres pour lesquels les objets suivis génèrent des détections (hors alerte) et comment les détections sont conservées.",
|
"description": "Paramètres pour lesquels les objets suivis génèrent des détections (hors alerte) et comment les détections sont conservées.",
|
||||||
"enabled": {
|
"enabled": {
|
||||||
"label": "Activer les détections"
|
"label": "Activation des détections"
|
||||||
},
|
},
|
||||||
"labels": {
|
"labels": {
|
||||||
"label": "Étiquettes de détection",
|
"label": "Étiquettes de détection",
|
||||||
@@ -415,5 +450,31 @@
|
|||||||
"description": "Une liste de préoccupations ou de notes supplémentaires que la GenAI devrait prendre en compte lors de l'évaluation de l'activité sur cette caméra."
|
"description": "Une liste de préoccupations ou de notes supplémentaires que la GenAI devrait prendre en compte lors de l'évaluation de l'activité sur cette caméra."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"live": {
|
||||||
|
"transcode": {
|
||||||
|
"label": "Flux transcodés",
|
||||||
|
"description": "Flux en direct transcodé en qualité inférieur à la demande de go2rtc.",
|
||||||
|
"enabled": {
|
||||||
|
"label": "Activé les flux transcodés",
|
||||||
|
"description": "ajouter un flux en direct de qualité inférieur que go2rtc transcode en temps réel pendant que quelqu'un regarde."
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"label": "Flux source",
|
||||||
|
"description": "flux à décoder par go2rtc. Par défaut le premier flux en direct."
|
||||||
|
},
|
||||||
|
"qualities": {
|
||||||
|
"label": "Qualités",
|
||||||
|
"description": "Un flux transcodé est ajouté par qualité.",
|
||||||
|
"height": {
|
||||||
|
"label": "Taille",
|
||||||
|
"description": "Taille de sortie en pixels; le forma respect le ratio de la source."
|
||||||
|
},
|
||||||
|
"bitrate": {
|
||||||
|
"label": "Bitrate",
|
||||||
|
"description": "Cible et bitrate maximum de la vidéo en kbps."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -209,9 +209,7 @@
|
|||||||
},
|
},
|
||||||
"frigatePlus": {
|
"frigatePlus": {
|
||||||
"apiKey": {
|
"apiKey": {
|
||||||
"notValidated": "La clé API Frigate+ n'est pas détectée ou n'est pas validée.",
|
|
||||||
"title": "Clé API Frigate+",
|
"title": "Clé API Frigate+",
|
||||||
"validated": "La clé API Frigate+ est détectée et validée",
|
|
||||||
"desc": "La clé API Frigate+ permet l'intégration avec le service Frigate+.",
|
"desc": "La clé API Frigate+ permet l'intégration avec le service Frigate+.",
|
||||||
"plusLink": "En savoir plus sur Frigate+"
|
"plusLink": "En savoir plus sur Frigate+"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -573,8 +573,6 @@
|
|||||||
"title": "הגדרות +Frigate",
|
"title": "הגדרות +Frigate",
|
||||||
"apiKey": {
|
"apiKey": {
|
||||||
"title": "Frigate+ API מפתח",
|
"title": "Frigate+ API מפתח",
|
||||||
"validated": "מפתח ה-API של Frigate+ זוהה ואושר",
|
|
||||||
"notValidated": "מפתח ה-API של Frigate+ לא זוהה או לא אומת",
|
|
||||||
"desc": "מפתח ה-API של Frigate+ מאפשר אינטגרציה עם שירות Frigate+.",
|
"desc": "מפתח ה-API של Frigate+ מאפשר אינטגרציה עם שירות Frigate+.",
|
||||||
"plusLink": "קרא עוד על Frigate+"
|
"plusLink": "קרא עוד על Frigate+"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -141,5 +141,7 @@
|
|||||||
"aircraft": "विमान",
|
"aircraft": "विमान",
|
||||||
"bicycle": "साइकिल",
|
"bicycle": "साइकिल",
|
||||||
"bellow": "गर्जना करना",
|
"bellow": "गर्जना करना",
|
||||||
"motorcycle": "मोटरसाइकिल"
|
"motorcycle": "मोटरसाइकिल",
|
||||||
|
"snicker": "खिलखिलाना",
|
||||||
|
"choir": "गायक-वृन्द"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -897,8 +897,6 @@
|
|||||||
"title": "Frigate+ postavke",
|
"title": "Frigate+ postavke",
|
||||||
"apiKey": {
|
"apiKey": {
|
||||||
"title": "Frigate+ API ključ",
|
"title": "Frigate+ API ključ",
|
||||||
"validated": "Frigate+ API ključ je detektiran i validiran",
|
|
||||||
"notValidated": "Frigate+ API ključ nije detektiran ili nije validiran",
|
|
||||||
"desc": "Frigate+ API ključ omogućuje integraciju s Frigate+ uslugom.",
|
"desc": "Frigate+ API ključ omogućuje integraciju s Frigate+ uslugom.",
|
||||||
"plusLink": "Pročitajte više o Frigate+"
|
"plusLink": "Pročitajte više o Frigate+"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -246,8 +246,6 @@
|
|||||||
"apiKey": {
|
"apiKey": {
|
||||||
"plusLink": "Tudjon meg többet a Frigate+-ról",
|
"plusLink": "Tudjon meg többet a Frigate+-ról",
|
||||||
"title": "Frigate+ API kulcs",
|
"title": "Frigate+ API kulcs",
|
||||||
"validated": "Frigate+ API kulcs észlelve és ellenőrizve",
|
|
||||||
"notValidated": "Frigate+ API kulcs nem található vagy nem ellenőrzött",
|
|
||||||
"desc": "A Frigate+ API kulcs lehetővé teszi az integrációt a Frigate+ szolgáltatással."
|
"desc": "A Frigate+ API kulcs lehetővé teszi az integrációt a Frigate+ szolgáltatással."
|
||||||
},
|
},
|
||||||
"snapshotConfig": {
|
"snapshotConfig": {
|
||||||
|
|||||||
@@ -1310,8 +1310,6 @@
|
|||||||
},
|
},
|
||||||
"apiKey": {
|
"apiKey": {
|
||||||
"title": "Kunci API Frigate+",
|
"title": "Kunci API Frigate+",
|
||||||
"validated": "Kunci API Frigate+ terdeteksi dan tervalidasi",
|
|
||||||
"notValidated": "Kunci API Frigate+ tidak terdeteksi atau tidak tervalidasi",
|
|
||||||
"desc": "Kunci API Frigate+ memungkinkan integrasi dengan layanan Frigate+.",
|
"desc": "Kunci API Frigate+ memungkinkan integrasi dengan layanan Frigate+.",
|
||||||
"plusLink": "Baca lebih lanjut tentang Frigate+"
|
"plusLink": "Baca lebih lanjut tentang Frigate+"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -29,8 +29,6 @@
|
|||||||
},
|
},
|
||||||
"apiKey": {
|
"apiKey": {
|
||||||
"title": "Chiave API Frigate+",
|
"title": "Chiave API Frigate+",
|
||||||
"validated": "La chiave API Frigate+ è stata rilevata e convalidata",
|
|
||||||
"notValidated": "La chiave API Frigate+ non è stata rilevata o non è stata convalidata",
|
|
||||||
"desc": "La chiave API Frigate+ consente l'integrazione con il servizio Frigate+.",
|
"desc": "La chiave API Frigate+ consente l'integrazione con il servizio Frigate+.",
|
||||||
"plusLink": "Scopri di più su Frigate+"
|
"plusLink": "Scopri di più su Frigate+"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -734,8 +734,6 @@
|
|||||||
"title": "Frigate+ 設定",
|
"title": "Frigate+ 設定",
|
||||||
"apiKey": {
|
"apiKey": {
|
||||||
"title": "Frigate+ API キー",
|
"title": "Frigate+ API キー",
|
||||||
"validated": "Frigate+ API キーが検出され、検証されました",
|
|
||||||
"notValidated": "Frigate+ API キーが検出されないか、検証されていません",
|
|
||||||
"desc": "Frigate+ API キーは Frigate+ サービスとの統合を有効にします。",
|
"desc": "Frigate+ API キーは Frigate+ サービスとの統合を有効にします。",
|
||||||
"plusLink": "Frigate+ の詳細を読む"
|
"plusLink": "Frigate+ の詳細を読む"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1193,8 +1193,6 @@
|
|||||||
},
|
},
|
||||||
"apiKey": {
|
"apiKey": {
|
||||||
"title": "Frigate+ API Key",
|
"title": "Frigate+ API Key",
|
||||||
"validated": "Frigate+ API key ត្រូវបានរកឃើញ និងផ្ទៀងផ្ទាត់",
|
|
||||||
"notValidated": "Frigate+ API key មិនត្រូវបានរកឃើញ ឬមិនបានផ្ទៀងផ្ទាត់",
|
|
||||||
"desc": "Frigate+ API key អនុញ្ញាតឱ្យមានការរួមបញ្ចូលជាមួយសេវាកម្ម Frigate+។",
|
"desc": "Frigate+ API key អនុញ្ញាតឱ្យមានការរួមបញ្ចូលជាមួយសេវាកម្ម Frigate+។",
|
||||||
"plusLink": "អានបន្ថែមអំពី Frigate+"
|
"plusLink": "អានបន្ថែមអំពី Frigate+"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1373,8 +1373,6 @@
|
|||||||
},
|
},
|
||||||
"apiKey": {
|
"apiKey": {
|
||||||
"title": "Frigate+ API 키",
|
"title": "Frigate+ API 키",
|
||||||
"validated": "Frigate+ API 키가 확인 및 검증되었습니다",
|
|
||||||
"notValidated": "Frigate+ API 키가 감지되지 않았거나 유효하지 않습니다",
|
|
||||||
"desc": "Frigate+ API 키를 통해 Frigate+ 서비스와의 연동을 활성화합니다.",
|
"desc": "Frigate+ API 키를 통해 Frigate+ 서비스와의 연동을 활성화합니다.",
|
||||||
"plusLink": "Frigate+ 자세히 알아보기"
|
"plusLink": "Frigate+ 자세히 알아보기"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -657,8 +657,6 @@
|
|||||||
"title": "Frigate+ Nustatymai",
|
"title": "Frigate+ Nustatymai",
|
||||||
"apiKey": {
|
"apiKey": {
|
||||||
"title": "Frigate+ API raktas",
|
"title": "Frigate+ API raktas",
|
||||||
"validated": "Frigate+ API raktas aptiktas ir patvirtintas",
|
|
||||||
"notValidated": "Frigate+ API raktas neaptiktas ir nepatvirtintas",
|
|
||||||
"desc": "Frigate+ API raktas įgalina integraciją su Frigate+ paslauga.",
|
"desc": "Frigate+ API raktas įgalina integraciją su Frigate+ paslauga.",
|
||||||
"plusLink": "Skaityti daugiau apie Frigate+"
|
"plusLink": "Skaityti daugiau apie Frigate+"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -616,9 +616,7 @@
|
|||||||
},
|
},
|
||||||
"frigatePlus": {
|
"frigatePlus": {
|
||||||
"apiKey": {
|
"apiKey": {
|
||||||
"notValidated": "Frigate+ API-nøkkel er ikke detektert eller validert",
|
|
||||||
"title": "Frigate+ API-nøkkel",
|
"title": "Frigate+ API-nøkkel",
|
||||||
"validated": "Frigate+ API-nøkkel er detektert og validert",
|
|
||||||
"desc": "Frigate+ API-nøkkelen muliggjør integrasjon med Frigate+ tjenesten.",
|
"desc": "Frigate+ API-nøkkelen muliggjør integrasjon med Frigate+ tjenesten.",
|
||||||
"plusLink": "Les mer om Frigate+"
|
"plusLink": "Les mer om Frigate+"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -618,9 +618,7 @@
|
|||||||
"apiKey": {
|
"apiKey": {
|
||||||
"title": "Frigate+ API-sleutel",
|
"title": "Frigate+ API-sleutel",
|
||||||
"plusLink": "Lees meer over Frigate+",
|
"plusLink": "Lees meer over Frigate+",
|
||||||
"validated": "Frigate+ API-sleutel is gedetecteerd en gevalideerd",
|
"desc": "Met de Frigate+ API-sleutel is integratie met de Frigate+ service mogelijk."
|
||||||
"desc": "Met de Frigate+ API-sleutel is integratie met de Frigate+ service mogelijk.",
|
|
||||||
"notValidated": "Frigate+ API-sleutel wordt niet gedetecteerd of niet gevalideerd"
|
|
||||||
},
|
},
|
||||||
"snapshotConfig": {
|
"snapshotConfig": {
|
||||||
"title": "Snapshot-configuratie",
|
"title": "Snapshot-configuratie",
|
||||||
|
|||||||
@@ -621,9 +621,7 @@
|
|||||||
"title": "Ustawienia Frigate+",
|
"title": "Ustawienia Frigate+",
|
||||||
"apiKey": {
|
"apiKey": {
|
||||||
"title": "Klucz API Frigate+",
|
"title": "Klucz API Frigate+",
|
||||||
"validated": "Klucz API Frigate+ został wykryty i zweryfikowany",
|
|
||||||
"plusLink": "Dowiedz się więcej o Frigate+",
|
"plusLink": "Dowiedz się więcej o Frigate+",
|
||||||
"notValidated": "Klucz API Frigate+ nie został wykryty lub nie został zweryfikowany",
|
|
||||||
"desc": "Klucz API Frigate+ umożliwia integrację z usługą Frigate+."
|
"desc": "Klucz API Frigate+ umożliwia integrację z usługą Frigate+."
|
||||||
},
|
},
|
||||||
"snapshotConfig": {
|
"snapshotConfig": {
|
||||||
|
|||||||
@@ -719,8 +719,6 @@
|
|||||||
"title": "Configurações do Frigate+",
|
"title": "Configurações do Frigate+",
|
||||||
"apiKey": {
|
"apiKey": {
|
||||||
"title": "Chave de API do Frigate+",
|
"title": "Chave de API do Frigate+",
|
||||||
"validated": "A chave de API do Frigate+ detectada e validada",
|
|
||||||
"notValidated": "Chave de API do Frigate+ não detectada ou não validada",
|
|
||||||
"desc": "A chave de API do Frigate+ habilita a integração com o serviço do Frigate+.",
|
"desc": "A chave de API do Frigate+ habilita a integração com o serviço do Frigate+.",
|
||||||
"plusLink": "Leia mais sobre o Frigate+"
|
"plusLink": "Leia mais sobre o Frigate+"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -173,8 +173,6 @@
|
|||||||
},
|
},
|
||||||
"title": "Configurações Frigate+",
|
"title": "Configurações Frigate+",
|
||||||
"apiKey": {
|
"apiKey": {
|
||||||
"validated": "A chave da API do Frigate+ foi detectada e validada",
|
|
||||||
"notValidated": "A chave da API do Frigate+ não foi detectada ou não foi validada",
|
|
||||||
"desc": "A chave de API do Frigate+ permite a integração com o serviço Frigate+.",
|
"desc": "A chave de API do Frigate+ permite a integração com o serviço Frigate+.",
|
||||||
"plusLink": "Saiba mais sobre o Frigate+",
|
"plusLink": "Saiba mais sobre o Frigate+",
|
||||||
"title": "Chave de API do Frigate+"
|
"title": "Chave de API do Frigate+"
|
||||||
|
|||||||
@@ -715,9 +715,14 @@
|
|||||||
"apiKey": {
|
"apiKey": {
|
||||||
"plusLink": "Citește mai multe despre Frigate+",
|
"plusLink": "Citește mai multe despre Frigate+",
|
||||||
"desc": "Cheia API Frigate+ permite integrarea cu serviciul Frigate+.",
|
"desc": "Cheia API Frigate+ permite integrarea cu serviciul Frigate+.",
|
||||||
"validated": "Cheia API Frigate+ a fost detectată și validată",
|
|
||||||
"title": "Cheie API Frigate+",
|
"title": "Cheie API Frigate+",
|
||||||
"notValidated": "Cheia API Frigate+ nu este detectată sau validată"
|
"formatValid": "Cheia API Frigate+ este detectată și corespunde formatului așteptat",
|
||||||
|
"formatInvalid": "Cheia API Frigate+ nu este detectată sau nu corespunde formatului așteptat",
|
||||||
|
"validate": "Validează cu Frigate+",
|
||||||
|
"validating": "Se validează…",
|
||||||
|
"serverValid": "Frigate+ a acceptat această cheie API",
|
||||||
|
"serverInvalid": "Frigate+ a respins această cheie API",
|
||||||
|
"serverUnreachable": "Nu s-a putut contacta Frigate+ pentru a valida această cheie API"
|
||||||
},
|
},
|
||||||
"snapshotConfig": {
|
"snapshotConfig": {
|
||||||
"title": "Configurare snapshot-uri",
|
"title": "Configurare snapshot-uri",
|
||||||
|
|||||||
@@ -631,8 +631,6 @@
|
|||||||
"title": "Настройки Frigate+",
|
"title": "Настройки Frigate+",
|
||||||
"apiKey": {
|
"apiKey": {
|
||||||
"title": "Ключ API Frigate+",
|
"title": "Ключ API Frigate+",
|
||||||
"validated": "Ключ API Frigate+ найден и проверен",
|
|
||||||
"notValidated": "Ключ API Frigate+ не найден или не проверен",
|
|
||||||
"desc": "Ключ API Frigate+ включает интеграцию с сервисом Frigate+.",
|
"desc": "Ключ API Frigate+ включает интеграцию с сервисом Frigate+.",
|
||||||
"plusLink": "Подробнее про Frigate+"
|
"plusLink": "Подробнее про Frigate+"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -883,8 +883,6 @@
|
|||||||
"title": "Nastavenie Frigate+",
|
"title": "Nastavenie Frigate+",
|
||||||
"apiKey": {
|
"apiKey": {
|
||||||
"title": "Frigate + API kľúč",
|
"title": "Frigate + API kľúč",
|
||||||
"validated": "Frigate + API kľúč je detekovaný a overený",
|
|
||||||
"notValidated": "Frigate + API kľúč nie je detekovaný alebo nie je overený",
|
|
||||||
"desc": "Frigate+ API kľúč umožňuje integráciu s Frigate+ služby.",
|
"desc": "Frigate+ API kľúč umožňuje integráciu s Frigate+ služby.",
|
||||||
"plusLink": "Prečítajte si viac o Frigate+"
|
"plusLink": "Prečítajte si viac o Frigate+"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1142,8 +1142,6 @@
|
|||||||
},
|
},
|
||||||
"apiKey": {
|
"apiKey": {
|
||||||
"title": "Frigate+ API ključ",
|
"title": "Frigate+ API ključ",
|
||||||
"validated": "API ključ je zaznan in potrjen",
|
|
||||||
"notValidated": "API ključ ni zaznan ali ni veljaven",
|
|
||||||
"desc": "API ključ omogoča integracijo s storitvijo Frigate+.",
|
"desc": "API ključ omogoča integracijo s storitvijo Frigate+.",
|
||||||
"plusLink": "Preberi več o Frigate+"
|
"plusLink": "Preberi več o Frigate+"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1 +1,368 @@
|
|||||||
{}
|
{
|
||||||
|
"time": {
|
||||||
|
"untilForTime": "Do {{time}}",
|
||||||
|
"untilForRestart": "Dok se Frigate ne ponovo pokrene.",
|
||||||
|
"untilRestart": "Do ponovnog pokretanja",
|
||||||
|
"never": "Nikada",
|
||||||
|
"ago": "Pre {{timeAgo}}",
|
||||||
|
"justNow": "Upravo sada",
|
||||||
|
"today": "Danas",
|
||||||
|
"yesterday": "Juče",
|
||||||
|
"last7": "Poslednjih 7 dana",
|
||||||
|
"last14": "Poslednjih 14 dana",
|
||||||
|
"last30": "Poslednjih 30 dana",
|
||||||
|
"thisWeek": "Ove nedelje",
|
||||||
|
"lastWeek": "Prošle nedelje",
|
||||||
|
"thisMonth": "Ovog meseca",
|
||||||
|
"lastMonth": "Prošlog meseca",
|
||||||
|
"5minutes": "5 minuta",
|
||||||
|
"10minutes": "10 minuta",
|
||||||
|
"30minutes": "30 minuta",
|
||||||
|
"1hour": "1 sat",
|
||||||
|
"12hours": "12 sati",
|
||||||
|
"24hours": "24 sata",
|
||||||
|
"custom": "Prilagođeno...",
|
||||||
|
"pm": "popodne",
|
||||||
|
"am": "pre podne",
|
||||||
|
"yr": "{{time}} god.",
|
||||||
|
"year_one": "{{time}} godina",
|
||||||
|
"year_few": "{{time}} godine",
|
||||||
|
"year_other": "{{time}} godina",
|
||||||
|
"mo": "{{time}} mes.",
|
||||||
|
"month_one": "{{time}} mesec",
|
||||||
|
"month_few": "{{time}} meseca",
|
||||||
|
"month_other": "{{time}} meseci",
|
||||||
|
"d": "{{time}} d.",
|
||||||
|
"day_one": "{{time}} dan",
|
||||||
|
"day_few": "{{time}} dana",
|
||||||
|
"day_other": "{{time}} dana",
|
||||||
|
"h": "{{time}} č.",
|
||||||
|
"hour_one": "{{time}} sat",
|
||||||
|
"hour_few": "{{time}} sata",
|
||||||
|
"hour_other": "{{time}} sati",
|
||||||
|
"m": "{{time}} min.",
|
||||||
|
"minute_one": "{{time}} minut",
|
||||||
|
"minute_few": "{{time}} minuta",
|
||||||
|
"minute_other": "{{time}} minuta",
|
||||||
|
"s": "{{time}} s",
|
||||||
|
"second_one": "{{time}} sekunda",
|
||||||
|
"second_few": "{{time}} sekunde",
|
||||||
|
"second_other": "{{time}} sekundi",
|
||||||
|
"formattedTimestamp": {
|
||||||
|
"12hour": "d. MMM, h:mm:ss aaa",
|
||||||
|
"24hour": "d. MMM, HH:mm:ss"
|
||||||
|
},
|
||||||
|
"formattedTimestamp2": {
|
||||||
|
"12hour": "dd.MM. h:mm:ssa",
|
||||||
|
"24hour": "d. MMM, HH:mm:ss"
|
||||||
|
},
|
||||||
|
"formattedTimestampHourMinute": {
|
||||||
|
"12hour": "h:mm aaa",
|
||||||
|
"24hour": "HH:mm"
|
||||||
|
},
|
||||||
|
"formattedTimestampHourMinuteSecond": {
|
||||||
|
"12hour": "h:mm:ss aaa",
|
||||||
|
"24hour": "HH:mm:ss"
|
||||||
|
},
|
||||||
|
"formattedTimestampMonthDayHourMinute": {
|
||||||
|
"12hour": "d. MMM, h:mm aaa",
|
||||||
|
"24hour": "d. MMM, HH:mm"
|
||||||
|
},
|
||||||
|
"formattedTimestampMonthDayYear": {
|
||||||
|
"12hour": "d. MMM yyyy.",
|
||||||
|
"24hour": "d. MMM yyyy."
|
||||||
|
},
|
||||||
|
"formattedTimestampMonthDayYearHourMinute": {
|
||||||
|
"12hour": "d. MMM yyyy, h:mm aaa",
|
||||||
|
"24hour": "d. MMM yyyy, HH:mm"
|
||||||
|
},
|
||||||
|
"formattedTimestampMonthDay": "d. MMM",
|
||||||
|
"formattedTimestampFilename": {
|
||||||
|
"12hour": "dd-MM-yy-h-mm-ss-a",
|
||||||
|
"24hour": "dd-MM-yy-HH-mm-ss"
|
||||||
|
},
|
||||||
|
"inProgress": "U toku",
|
||||||
|
"invalidStartTime": "Nevažeće vreme početka",
|
||||||
|
"invalidEndTime": "Nevažeće vreme završetka"
|
||||||
|
},
|
||||||
|
"commandMenu": {
|
||||||
|
"action": {
|
||||||
|
"toggleTheme": "Prebaci na svetli ili tamni režim"
|
||||||
|
},
|
||||||
|
"camera": {
|
||||||
|
"review": "Pregled",
|
||||||
|
"live": "Uživo"
|
||||||
|
},
|
||||||
|
"section": {
|
||||||
|
"actions": "Akcije",
|
||||||
|
"settings": "Podešavanja",
|
||||||
|
"cameraGroups": "Grupe kamera",
|
||||||
|
"cameras": "Kamere",
|
||||||
|
"pages": "Stranice",
|
||||||
|
"recent": "Nedavno"
|
||||||
|
},
|
||||||
|
"hint": "{{key}} ili / za otvaranje, Enter za pokretanje",
|
||||||
|
"empty": "Nema rezultata",
|
||||||
|
"placeholder": "Pretražite stranice, kamere, podešavanja i akcije",
|
||||||
|
"description": "Pretražite stranice, kamere, podešavanja i akcije",
|
||||||
|
"title": "Komandni meni"
|
||||||
|
},
|
||||||
|
"credentialField": {
|
||||||
|
"savedPlaceholder": "Sačuvano — ostavite prazno da zadržite trenutno stanje"
|
||||||
|
},
|
||||||
|
"validation_errors": "Greške pri validaciji",
|
||||||
|
"unit": {
|
||||||
|
"speed": {
|
||||||
|
"kph": "km/h",
|
||||||
|
"mph": "mph"
|
||||||
|
},
|
||||||
|
"length": {
|
||||||
|
"feet": "stopa",
|
||||||
|
"meters": "metri"
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"kbps": "kB/s",
|
||||||
|
"mbps": "MB/s",
|
||||||
|
"gbps": "GB/s",
|
||||||
|
"kbph": "kB/sat",
|
||||||
|
"mbph": "MB/sat",
|
||||||
|
"gbph": "GB/sat"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"label": {
|
||||||
|
"back": "Idi nazad",
|
||||||
|
"hide": "Sakrij {{item}}",
|
||||||
|
"show": "Prikaži {{item}}",
|
||||||
|
"ID": "ID",
|
||||||
|
"none": "Ništa",
|
||||||
|
"all": "Sve",
|
||||||
|
"other": "Ostalo"
|
||||||
|
},
|
||||||
|
"list": {
|
||||||
|
"two": "{{0}} i {{1}}",
|
||||||
|
"many": "{{items}} i {{last}}",
|
||||||
|
"separatorWithSpace": ", "
|
||||||
|
},
|
||||||
|
"field": {
|
||||||
|
"optional": "Opciono",
|
||||||
|
"internalID": "Interni ID koji Frigate koristi u konfiguraciji i bazi podataka"
|
||||||
|
},
|
||||||
|
"button": {
|
||||||
|
"add": "Dodaj",
|
||||||
|
"apply": "Primeni",
|
||||||
|
"applying": "Primenjuje se…",
|
||||||
|
"reset": "Resetuj",
|
||||||
|
"undo": "Opozovi",
|
||||||
|
"done": "Završeno",
|
||||||
|
"enabled": "Omogućeno",
|
||||||
|
"enable": "Omogući",
|
||||||
|
"disabled": "Onemogućeno",
|
||||||
|
"disable": "Onemogući",
|
||||||
|
"save": "Sačuvaj",
|
||||||
|
"saving": "Čuvanje…",
|
||||||
|
"cancel": "Otkaži",
|
||||||
|
"close": "Zatvori",
|
||||||
|
"expand": "Proširi",
|
||||||
|
"collapse": "Skupi",
|
||||||
|
"clear": "Očisti",
|
||||||
|
"copy": "Kopiraj",
|
||||||
|
"copiedToClipboard": "Kopirano u privremenu memoriju",
|
||||||
|
"back": "Nazad",
|
||||||
|
"history": "Istorija",
|
||||||
|
"fullscreen": "Ceo ekran",
|
||||||
|
"exitFullscreen": "Izađi iz celog ekrana",
|
||||||
|
"pictureInPicture": "Slika u slici",
|
||||||
|
"twoWayTalk": "Dvosmerna komunikacija",
|
||||||
|
"cameraAudio": "Zvuk kamere",
|
||||||
|
"on": "Uključeno",
|
||||||
|
"off": "Isključeno",
|
||||||
|
"edit": "Izmeni",
|
||||||
|
"copyCoordinates": "Kopiraj koordinate",
|
||||||
|
"delete": "Obriši",
|
||||||
|
"yes": "Da",
|
||||||
|
"no": "Ne",
|
||||||
|
"download": "Preuzmi",
|
||||||
|
"info": "Informacije",
|
||||||
|
"suspended": "Suspendovano",
|
||||||
|
"unsuspended": "Aktiviraj ponovo",
|
||||||
|
"play": "Pusti",
|
||||||
|
"unselect": "Poništi izbor",
|
||||||
|
"export": "Izvezi",
|
||||||
|
"deleteNow": "Obriši odmah",
|
||||||
|
"next": "Sledeće",
|
||||||
|
"continue": "Nastavi",
|
||||||
|
"modified": "Izmenjeno",
|
||||||
|
"overridden": "Nadjačano",
|
||||||
|
"resetToGlobal": "Vrati na globalna podešavanja",
|
||||||
|
"resetToDefault": "Vrati na podrazumevano",
|
||||||
|
"saveAll": "Sačuvaj sve",
|
||||||
|
"savingAll": "Čuvanje svega…",
|
||||||
|
"undoAll": "Opozovi sve",
|
||||||
|
"retry": "Pokušaj ponovo",
|
||||||
|
"reload": "Osveži"
|
||||||
|
},
|
||||||
|
"menu": {
|
||||||
|
"system": "Sistem",
|
||||||
|
"systemMetrics": "Stanje i metrike",
|
||||||
|
"configuration": "Konfiguracija",
|
||||||
|
"systemLogs": "Dnevnik",
|
||||||
|
"profiles": "Profili",
|
||||||
|
"settings": "Podešavanja",
|
||||||
|
"configurationEditor": "Editor konfiguracije",
|
||||||
|
"languages": "Jezici",
|
||||||
|
"language": {
|
||||||
|
"en": "Engleski (English)",
|
||||||
|
"es": "Španski (Español)",
|
||||||
|
"zhCN": "Pojednostavljeni kineski (简体中文)",
|
||||||
|
"zhHant": "Tradicionalni kineski (繁體中文)",
|
||||||
|
"hi": "Hindi (हिन्दी)",
|
||||||
|
"fr": "Francuski (Français)",
|
||||||
|
"ar": "Arapski (العربية)",
|
||||||
|
"pt": "Portugalski (Português)",
|
||||||
|
"ptBR": "Brazilski portugalski (Português brasileiro)",
|
||||||
|
"ru": "Ruski (Русский)",
|
||||||
|
"de": "Nemački (Deutsch)",
|
||||||
|
"ja": "Japanski (日本語)",
|
||||||
|
"tr": "Turski (Türkçe)",
|
||||||
|
"it": "Italijanski (Italiano)",
|
||||||
|
"nl": "Holandski (Nederlands)",
|
||||||
|
"sv": "Švedski (Svenska)",
|
||||||
|
"cs": "Češki (Čeština)",
|
||||||
|
"nb": "Norveški bukmol (Norsk Bokmål)",
|
||||||
|
"ko": "Korejski (한국어)",
|
||||||
|
"vi": "Vijetnamski (Tiếng Việt)",
|
||||||
|
"fa": "Persijski (فارسی)",
|
||||||
|
"pl": "Poljski (Polski)",
|
||||||
|
"uk": "Ukrajinski (Українська)",
|
||||||
|
"be": "Beloruski (Беларуская)",
|
||||||
|
"he": "Hebrejski (עברית)",
|
||||||
|
"el": "Grčki (Ελληνικά)",
|
||||||
|
"ro": "Rumunski (Română)",
|
||||||
|
"hu": "Mađarski (Magyar)",
|
||||||
|
"fi": "Finski (Suomi)",
|
||||||
|
"da": "Danski (Dansk)",
|
||||||
|
"sk": "Slovački (Slovenčina)",
|
||||||
|
"yue": "Kantonski (粵語)",
|
||||||
|
"th": "Tajlandski (ไทย)",
|
||||||
|
"ca": "Katalonski (Català)",
|
||||||
|
"hr": "Hrvatski (Hrvatski)",
|
||||||
|
"bs": "Bosanski (Bosanski)",
|
||||||
|
"sr": "Srpski (Српски)",
|
||||||
|
"sl": "Slovenački (Slovenščina)",
|
||||||
|
"lt": "Litvanski (Lietuvių)",
|
||||||
|
"bg": "Bugarski (Български)",
|
||||||
|
"gl": "Galicijski (Galego)",
|
||||||
|
"id": "Indonežanski (Bahasa Indonesia)",
|
||||||
|
"ur": "Urdu (اردو)",
|
||||||
|
"withSystem": {
|
||||||
|
"label": "Koristi sistemska podešavanja za jezik"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"appearance": "Izgled",
|
||||||
|
"darkMode": {
|
||||||
|
"label": "Tamni režim",
|
||||||
|
"light": "Svetli",
|
||||||
|
"dark": "Tamni",
|
||||||
|
"withSystem": {
|
||||||
|
"label": "Koristi sistemska podešavanja za svetli ili tamni režim"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"withSystem": "Sistem",
|
||||||
|
"theme": {
|
||||||
|
"label": "Tema",
|
||||||
|
"blue": "Plava",
|
||||||
|
"green": "Zelena",
|
||||||
|
"nord": "Nord",
|
||||||
|
"red": "Crvena",
|
||||||
|
"highcontrast": "Visok kontrast",
|
||||||
|
"default": "Podrazumevano"
|
||||||
|
},
|
||||||
|
"help": "Pomoć",
|
||||||
|
"documentation": {
|
||||||
|
"title": "Dokumentacija",
|
||||||
|
"label": "Frigate dokumentacija"
|
||||||
|
},
|
||||||
|
"restart": "Ponovo pokreni Frigate",
|
||||||
|
"live": {
|
||||||
|
"title": "Uživo",
|
||||||
|
"allCameras": "Sve kamere",
|
||||||
|
"cameras": {
|
||||||
|
"title": "Kamere",
|
||||||
|
"count_one": "{{count}} kamera",
|
||||||
|
"count_few": "{{count}} kamere",
|
||||||
|
"count_other": "{{count}} kamera"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"review": "Pregled",
|
||||||
|
"explore": "Istraži",
|
||||||
|
"export": "Izvezi",
|
||||||
|
"actions": "Akcije",
|
||||||
|
"uiPlayground": "Korisničko okruženje za testiranje",
|
||||||
|
"features": "Funkcije",
|
||||||
|
"faceLibrary": "Biblioteka lica",
|
||||||
|
"classification": "Klasifikacija",
|
||||||
|
"chat": "Ćaskanje",
|
||||||
|
"user": {
|
||||||
|
"title": "Korisnik",
|
||||||
|
"account": "Nalog",
|
||||||
|
"current": "Trenutni korisnik: {{user}}",
|
||||||
|
"anonymous": "anoniman",
|
||||||
|
"logout": "Odjavi se",
|
||||||
|
"setPassword": "Postavi lozinku"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"toast": {
|
||||||
|
"copyUrlToClipboard": "URL je kopiran u privremenu memoriju.",
|
||||||
|
"save": {
|
||||||
|
"title": "Sačuvaj",
|
||||||
|
"error": {
|
||||||
|
"title": "Neuspešno čuvanje promena u konfiguraciji: {{errorMessage}}",
|
||||||
|
"noMessage": "Neuspešno čuvanje promena u konfiguraciji"
|
||||||
|
},
|
||||||
|
"success": "Promene u konfiguraciji su uspešno sačuvane."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"role": {
|
||||||
|
"title": "Uloga",
|
||||||
|
"admin": "Administrator",
|
||||||
|
"viewer": "Posmatrač",
|
||||||
|
"desc": "Administratori imaju potpun pristup svim funkcijama u Frigate korisničkom interfejsu. Gledaci su ograničeni na gledanje kamera, pregled stavki i istorijskih snimaka u korisničkom interfejsu."
|
||||||
|
},
|
||||||
|
"pagination": {
|
||||||
|
"label": "straničenje",
|
||||||
|
"previous": {
|
||||||
|
"title": "Prethodna",
|
||||||
|
"label": "Idi na prethodnu stranicu"
|
||||||
|
},
|
||||||
|
"next": {
|
||||||
|
"title": "Sledeća",
|
||||||
|
"label": "Idi na sledeću stranicu"
|
||||||
|
},
|
||||||
|
"more": "Više stranica"
|
||||||
|
},
|
||||||
|
"accessDenied": {
|
||||||
|
"documentTitle": "Pristup odbijen - Frigate",
|
||||||
|
"title": "Pristup odbijen",
|
||||||
|
"desc": "Nemate dozvolu za pregled ove stranice."
|
||||||
|
},
|
||||||
|
"notFound": {
|
||||||
|
"documentTitle": "Nije pronađeno - Frigate",
|
||||||
|
"title": "404",
|
||||||
|
"desc": "Stranica nije pronađena"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"title": "Ova stranica je prestala sa radom",
|
||||||
|
"desc": "Došlo je do neočekivane greške na stranici. Osvežavanje obično rešava problem. Ako se greška ponavlja, kopirajte detalje i priložite ih diskusiji na GitHub-u.",
|
||||||
|
"staleTitle": "Potrebno je osvežavanje",
|
||||||
|
"staleDesc": "Deo aplikacije nije mogao da se učita, što obično znači da je Frigate ažuriran dok je ova kartica bila otvorena. Osvežite stranicu da biste prešli na novu verziju.",
|
||||||
|
"partial": "Deo aplikacije je prestao sa radom.",
|
||||||
|
"copyDetails": "Kopiraj detalje",
|
||||||
|
"copyFailed": "Neuspešno kopiranje detalja u privremenu memoriju"
|
||||||
|
},
|
||||||
|
"selectItem": "Izaberi {{item}}",
|
||||||
|
"readTheDocumentation": "Pročitaj dokumentaciju",
|
||||||
|
"information": {
|
||||||
|
"pixels": "{{area}} px"
|
||||||
|
},
|
||||||
|
"no_items": "Nema elemenata"
|
||||||
|
}
|
||||||
|
|||||||
@@ -1 +1,90 @@
|
|||||||
{}
|
{
|
||||||
|
"group": {
|
||||||
|
"label": "Grupe kamera",
|
||||||
|
"add": "Dodaj grupu kamera",
|
||||||
|
"showAll": "Prikaži sve grupe kamera",
|
||||||
|
"showLess": "Prikaži manje",
|
||||||
|
"edit": "Izmeni grupu kamera",
|
||||||
|
"editGroups": "Izmeni grupe kamera",
|
||||||
|
"delete": {
|
||||||
|
"label": "Obriši grupu kamera",
|
||||||
|
"confirm": {
|
||||||
|
"title": "Potvrdite brisanje",
|
||||||
|
"desc": "Da li ste sigurni da želite da obrišete grupu kamera <em>{{name}}</em>?"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"label": "Naziv",
|
||||||
|
"placeholder": "Unesite naziv…",
|
||||||
|
"errorMessage": {
|
||||||
|
"mustLeastCharacters": "Naziv grupe kamera mora imati najmanje 2 karaktera.",
|
||||||
|
"exists": "Naziv grupe kamera već postoji.",
|
||||||
|
"nameMustNotPeriod": "Naziv grupe kamera ne sme da sadrži tačku.",
|
||||||
|
"invalid": "Nevažeći naziv grupe kamera."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cameras": {
|
||||||
|
"label": "Kamere",
|
||||||
|
"desc": "Izaberite kamere za ovu grupu."
|
||||||
|
},
|
||||||
|
"icon": "Ikona",
|
||||||
|
"success": "Grupa kamera ({{name}}) je sačuvana.",
|
||||||
|
"camera": {
|
||||||
|
"birdseye": "Birdseye",
|
||||||
|
"setting": {
|
||||||
|
"label": "Podešavanja strimovanja kamere",
|
||||||
|
"title": "Podešavanja strimovanja za kameru {{cameraName}}",
|
||||||
|
"desc": "Promenite opcije strimovanja uživo za kontrolnu tablu ove grupe kamera. <em>Ova podešavanja su specifična za uređaj/veb-pregledač.</em>",
|
||||||
|
"audioIsAvailable": "Zvuk je dostupan za ovaj strim",
|
||||||
|
"audioIsUnavailable": "Zvuk je nedostupan za ovaj strim",
|
||||||
|
"audio": {
|
||||||
|
"tips": {
|
||||||
|
"title": "Zvuk mora biti poslat sa vaše kamere i konfigurisan u go2rtc-u za ovaj strim."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"stream": "Strim",
|
||||||
|
"placeholder": "Izaberite strim",
|
||||||
|
"streamMethod": {
|
||||||
|
"label": "Metod strimovanja",
|
||||||
|
"placeholder": "Izaberite metod strimovanja",
|
||||||
|
"method": {
|
||||||
|
"noStreaming": {
|
||||||
|
"label": "Bez strimovanja",
|
||||||
|
"desc": "Slike sa kamere će se osvežavati samo jednom u minuti i neće biti strimovanja uživo."
|
||||||
|
},
|
||||||
|
"smartStreaming": {
|
||||||
|
"label": "Pametno strimovanje (preporučeno)",
|
||||||
|
"desc": "Pametno strimovanje će osvežavati sliku sa vaše kamere jednom u minuti kada nema uočljivih aktivnosti, kako bi se uštedeo protok podataka i resursi. Kada se detektuje aktivnost, prikaz se neprimetno prebacuje na strim uživo."
|
||||||
|
},
|
||||||
|
"continuousStreaming": {
|
||||||
|
"label": "Neprekidno strimovanje",
|
||||||
|
"desc": {
|
||||||
|
"title": "Slika sa kamere će uvek biti strim uživo kada je vidljiva na kontrolnoj tabli, čak i ako se ne detektuje nikakva aktivnost.",
|
||||||
|
"warning": "Neprekidno strimovanje može izazvati visoku potrošnju protoka podataka i probleme sa performansama. Koristite oprezno."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compatibilityMode": {
|
||||||
|
"label": "Režim kompatibilnosti",
|
||||||
|
"desc": "Omogućite ovu opciju samo ako strim uživo sa vaše kamere prikazuje izobličenja boja (artefakte) i ima dijagonalnu liniju na desnoj strani slike."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"debug": {
|
||||||
|
"options": {
|
||||||
|
"label": "Podešavanja",
|
||||||
|
"title": "Opcije",
|
||||||
|
"showOptions": "Prikaži opcije",
|
||||||
|
"hideOptions": "Sakrij opcije"
|
||||||
|
},
|
||||||
|
"boundingBox": "Uokvireni prostor",
|
||||||
|
"timestamp": "Vremenska oznaka",
|
||||||
|
"zones": "Zone",
|
||||||
|
"mask": "Maska",
|
||||||
|
"motion": "Pokret",
|
||||||
|
"regions": "Regioni",
|
||||||
|
"paths": "Putanje"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1 +1,222 @@
|
|||||||
{}
|
{
|
||||||
|
"restart": {
|
||||||
|
"title": "Da li ste sigurni da želite da ponovo pokrenete Frigate?",
|
||||||
|
"description": "Ovo će nakratko zaustaviti Frigate dok se ponovo pokreće.",
|
||||||
|
"button": "Ponovo pokreni",
|
||||||
|
"restarting": {
|
||||||
|
"title": "Frigate se ponovo pokreće",
|
||||||
|
"content": "Ova stranica će se ponovo učitati za {{countdown}} sekundi.",
|
||||||
|
"button": "Osveži odmah"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"explore": {
|
||||||
|
"plus": {
|
||||||
|
"submitToPlus": {
|
||||||
|
"label": "Pošalji na Frigate+",
|
||||||
|
"desc": "Objekti na lokacijama koje želite da izbegnete nisu lažno pozitivni rezultati. Njihovo slanje kao lažno pozitivnih zbuniće model."
|
||||||
|
},
|
||||||
|
"review": {
|
||||||
|
"question": {
|
||||||
|
"label": "Potvrdite ovu oznaku za Frigate Plu",
|
||||||
|
"ask_a": "Da li je ovaj objekat <code>{{label}}</code>?",
|
||||||
|
"ask_an": "Da li je ovaj objekat <code>{{label}}</code>?",
|
||||||
|
"ask_full": "Da li je ovaj objekat <code>{{untranslatedLabel}}</code> ({{translatedLabel}})?"
|
||||||
|
},
|
||||||
|
"state": {
|
||||||
|
"submitted": "Poslato"
|
||||||
|
},
|
||||||
|
"toast": {
|
||||||
|
"error": "Slanje na Frigate+ nije uspelo. Proverite mrežnu vezu i pokušajte ponovo."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"video": {
|
||||||
|
"viewInHistory": "Prikaži u istoriji"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"export": {
|
||||||
|
"name": {
|
||||||
|
"placeholder": "Imenujte izvezeni snimak"
|
||||||
|
},
|
||||||
|
"stream": {
|
||||||
|
"label": "Kvalitet",
|
||||||
|
"auto": "Automatski",
|
||||||
|
"main": "Izvorni",
|
||||||
|
"sub": "Nizak",
|
||||||
|
"autoDesc": "Koristi glavni strim, a u slučaju njegove nedostupnosti prebacuje se na sporedni strim nižeg kvaliteta.",
|
||||||
|
"mainDesc": "Izvozi isključivo glavni strim izvornog kvaliteta. Vremenski periodi u kojima glavni strim nije bio dostupan biće izostavljeni.",
|
||||||
|
"subDesc": "Izvozi isključivo sporedni strim nižeg kvaliteta. Vremenski periodi u kojima sporedni strim nije bio dostupan biće izostavljeni."
|
||||||
|
},
|
||||||
|
"case": {
|
||||||
|
"newCaseOption": "Kreiraj novi slučaj",
|
||||||
|
"newCaseNamePlaceholder": "Naziv novog slučaja",
|
||||||
|
"newCaseDescriptionPlaceholder": "Opis slučaja",
|
||||||
|
"label": "Slučaj",
|
||||||
|
"placeholder": "Izaberite slučaj",
|
||||||
|
"nameRequired": "Naziv slučaja je obavezan"
|
||||||
|
},
|
||||||
|
"select": "Izaberi",
|
||||||
|
"export": "Izvezi",
|
||||||
|
"queueing": "Dodavanje izvoza u red...",
|
||||||
|
"selectOrExport": "Izaberi ili izvezi",
|
||||||
|
"tabs": {
|
||||||
|
"export": "Jedna kamera",
|
||||||
|
"multiCamera": "Više kamera"
|
||||||
|
},
|
||||||
|
"multiCamera": {
|
||||||
|
"timeRange": "Vremenski opseg",
|
||||||
|
"selectFromTimeline": "Izaberite sa vremenske linije",
|
||||||
|
"cameraSelection": "Kamere",
|
||||||
|
"checkingActivity": "Provera aktivnosti kamera...",
|
||||||
|
"cameraSelectionHelp": "Kamere sa praćenim objektima u ovom vremenskom rasponu su unapred izabrane",
|
||||||
|
"searchOrSelectGroup": "Pretražite ili izaberite grupu kamera...",
|
||||||
|
"selectAll": "Izaberi sve kamere",
|
||||||
|
"clearSelection": "Poništi izbor",
|
||||||
|
"selectWithActivity": "Kamere sa praćenim objektima",
|
||||||
|
"selectGroup": "Izaberi grupu",
|
||||||
|
"noMatchingCameras": "Nema kamera koje odgovaraju pretrazi",
|
||||||
|
"selectedCount": "Izabrano: {{selected}} od {{total}}",
|
||||||
|
"noCameras": "Nema dostupnih kamera",
|
||||||
|
"noCamerasSelected": "Izaberite najmanje jednu kameru",
|
||||||
|
"detectionCount_one": "1 praćeni objekat",
|
||||||
|
"detectionCount_few": "{{count}} praćena objekata",
|
||||||
|
"detectionCount_other": "{{count}} praćenih objekata",
|
||||||
|
"nameLabel": "Naziv izvoza",
|
||||||
|
"caseNameDefault": "Izvoz sa više kamera - {{date}}",
|
||||||
|
"namePlaceholder": "Opcioni osnovni naziv za ove izvoze",
|
||||||
|
"queueingButton": "Dodavanje izvoza u red...",
|
||||||
|
"exportButton_one": "Izvezi 1 kameru",
|
||||||
|
"exportButton_few": "Izvezi {{count}} kamere",
|
||||||
|
"exportButton_other": "Izvezi {{count}} kamera"
|
||||||
|
},
|
||||||
|
"time": {
|
||||||
|
"fromTimeline": "Izaberite sa vremenske linije",
|
||||||
|
"lastHour_one": "Poslednji sat",
|
||||||
|
"lastHour_few": "Poslednjih {{count}} sata",
|
||||||
|
"lastHour_other": "Poslednjih {{count}} sati",
|
||||||
|
"custom": "Prilagođeno",
|
||||||
|
"start": {
|
||||||
|
"title": "Vreme početka",
|
||||||
|
"label": "Izaberite vreme početka"
|
||||||
|
},
|
||||||
|
"end": {
|
||||||
|
"title": "Vreme završetka",
|
||||||
|
"label": "Izaberite vreme završetka"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"multi": {
|
||||||
|
"title_one": "Izvezi 1 pregled",
|
||||||
|
"title_few": "Izvezi {{count}} pregleda",
|
||||||
|
"title_other": "Izvezi {{count}} pregleda",
|
||||||
|
"description": "Izvezite svaki izabrani pregled. Svi izvezeni snimci biće grupisani pod jednim slučajem.",
|
||||||
|
"descriptionNoCase": "Izvezite svaki izabrani pregled.",
|
||||||
|
"caseNamePlaceholder": "Izvoz pregleda - {{date}}",
|
||||||
|
"exportButton_one": "Izvezi 1 pregled",
|
||||||
|
"exportButton_few": "Izvezi {{count}} pregleda",
|
||||||
|
"exportButton_other": "Izvezi {{count}} pregleda",
|
||||||
|
"exportingButton": "Izvoženje...",
|
||||||
|
"toast": {
|
||||||
|
"started_one": "Pokrenut 1 izvoz.",
|
||||||
|
"started_few": "Pokrenuto {{count}} izvoza.",
|
||||||
|
"started_other": "okrenuto {{count}} izvoza.",
|
||||||
|
"partial": "Pokrenuto je {{successful}} od {{total}} izvoza. Neuspešno: {{failedItems}}",
|
||||||
|
"failed": "Pokretanje {{total}} izvoza nije uspelo. Neuspešno: {{failedItems}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"toast": {
|
||||||
|
"success": "Izvoz je uspešno pokrenut. Pogledajte datoteku na stranici sa izvezenim snimcima.",
|
||||||
|
"queued": "Izvoz je dodat u red. Pratite napredak na stranici sa izvezenim snimcima.",
|
||||||
|
"view": "Prikaži",
|
||||||
|
"batchSuccess_one": "Pokrenut 1 izvoz. Otvaranje slučaja...",
|
||||||
|
"batchSuccess_few": "Pokrenuto {{count}} izvoza. Otvaranje slučaja...",
|
||||||
|
"batchSuccess_other": "Pokrenuto {{count}} izvoza. Otvaranje slučaja...",
|
||||||
|
"batchPartial": "Pokrenuto je {{successful}} od {{total}} izvoza. Neuspešne kamere: {{failedCameras}}",
|
||||||
|
"batchFailed": "Pokretanje {{total}} izvoza nije uspelo. Neuspešne kamere: {{failedCameras}}",
|
||||||
|
"batchQueuedSuccess_one": "Dodat 1 izvoz u red.",
|
||||||
|
"batchQueuedSuccess_few": "Dodato {{count}} izvoza u red.",
|
||||||
|
"batchQueuedSuccess_other": "Dodato {{count}} izvoza u red.",
|
||||||
|
"batchQueuedPartial": "Dodato je {{successful}} od {{total}} izvoza u red. Neuspešne kamere: {{failedCameras}}",
|
||||||
|
"batchQueueFailed": "Dodavanje {{total}} izvoza u red nije uspelo. Neuspešne kamere: {{failedCameras}}",
|
||||||
|
"error": {
|
||||||
|
"failed": "Dodavanje izvoza u red nije uspelo: {{error}}",
|
||||||
|
"endTimeMustAfterStartTime": "Vreme završetka mora biti nakon vremena početka",
|
||||||
|
"noValidTimeSelected": "Nije izabran važeći vremenski opseg"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fromTimeline": {
|
||||||
|
"saveExport": "Sačuvaj izvezeni snimak",
|
||||||
|
"queueingExport": "Dodavanje izvoza u red...",
|
||||||
|
"previewExport": "Pregledaj izvezeni snimak",
|
||||||
|
"useThisRange": "Koristi ovaj opseg"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"streaming": {
|
||||||
|
"label": "Strim",
|
||||||
|
"restreaming": {
|
||||||
|
"disabled": "Ponovno strimovanje nije omogućeno za ovu kameru.",
|
||||||
|
"desc": {
|
||||||
|
"title": "Podesite go2rtc za dodatne opcije prikaza uživo i zvuk za ovu kameru."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"showStats": {
|
||||||
|
"label": "Prikaži statistiku strima",
|
||||||
|
"desc": "Omogućite ovu opciju da biste prikazali statistiku strima kao sloj preko video-izvora kamere."
|
||||||
|
},
|
||||||
|
"debugView": "Prikaz za otklanjanje grešaka"
|
||||||
|
},
|
||||||
|
"search": {
|
||||||
|
"saveSearch": {
|
||||||
|
"label": "Sačuvaj pretragu",
|
||||||
|
"desc": "Unesite naziv za ovu sačuvanu pretragu.",
|
||||||
|
"placeholder": "Unesite naziv za svoju pretragu",
|
||||||
|
"overwrite": "{{searchName}} već postoji. Čuvanje će prepisati postojeću vrednost.",
|
||||||
|
"success": "Pretraga ({{searchName}}) je sačuvana.",
|
||||||
|
"button": {
|
||||||
|
"save": {
|
||||||
|
"label": "Sačuvaj ovu pretragu"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"recording": {
|
||||||
|
"shareTimestamp": {
|
||||||
|
"label": "Deli vremensku oznaku",
|
||||||
|
"title": "Deli vremensku oznaku",
|
||||||
|
"description": "Podelite URL sa vremenskom oznakom trenutne pozicije plejera ili izaberite prilagođenu vremensku oznaku. Napomena: ovo nije javni URL za deljenje i dostupan je samo korisnicima koji imaju pristup Frigate-u i ovoj kameri.",
|
||||||
|
"custom": "Prilagođena vremenska oznaka",
|
||||||
|
"button": "URL za deljenje vremenske oznake",
|
||||||
|
"shareTitle": "Frigate pregled vremenske oznake: {{camera}}"
|
||||||
|
},
|
||||||
|
"confirmDelete": {
|
||||||
|
"title": "Potvrdite brisanje",
|
||||||
|
"desc": {
|
||||||
|
"selected": "Da li ste sigurni da želite da obrišete sav snimljeni video povezan sa ovom stavkom pregleda?<br /><br />Držite taster <em>Shift</em> da biste ubuduće zaobišli ovaj prozor."
|
||||||
|
},
|
||||||
|
"toast": {
|
||||||
|
"success": "Video-snimci povezani sa izabranim stavkama pregleda su uspešno obrisani.",
|
||||||
|
"error": "Neuspešno brisanje: {{error}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"button": {
|
||||||
|
"export": "Izvezi",
|
||||||
|
"markAsReviewed": "Označi kao pregledano",
|
||||||
|
"markAsUnreviewed": "Označi kao nepregledano",
|
||||||
|
"deleteNow": "Obriši odmah",
|
||||||
|
"generateDescription": "Generiši opis"
|
||||||
|
},
|
||||||
|
"genaiDescription": {
|
||||||
|
"toast": {
|
||||||
|
"success": "Zatražen je GenAI opis za ovu stavku pregleda.",
|
||||||
|
"error": "Slanje zahteva za opis nije uspelo: {{error}}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"imagePicker": {
|
||||||
|
"selectImage": "Izaberite sličicu praćenog objekta",
|
||||||
|
"unknownLabel": "Sačuvana slika pokretača",
|
||||||
|
"search": {
|
||||||
|
"placeholder": "Pretražite po oznaci ili podoznaci..."
|
||||||
|
},
|
||||||
|
"noImages": "Nema pronađenih sličica za ovu kameru"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1 +1,140 @@
|
|||||||
{}
|
{
|
||||||
|
"filter": "Filtriraj",
|
||||||
|
"classes": {
|
||||||
|
"label": "Klase",
|
||||||
|
"all": {
|
||||||
|
"title": "Sve klase"
|
||||||
|
},
|
||||||
|
"count_one": "{{count}} klasa",
|
||||||
|
"count_other": "{{count}} klase"
|
||||||
|
},
|
||||||
|
"labels": {
|
||||||
|
"label": "Oznake",
|
||||||
|
"all": {
|
||||||
|
"title": "Sve oznake",
|
||||||
|
"short": "Oznake"
|
||||||
|
},
|
||||||
|
"count_one": "{{count}} oznaka",
|
||||||
|
"count_other": "{{count}} oznake"
|
||||||
|
},
|
||||||
|
"zones": {
|
||||||
|
"label": "Zone",
|
||||||
|
"all": {
|
||||||
|
"title": "Sve zone",
|
||||||
|
"short": "Zone"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dates": {
|
||||||
|
"selectPreset": "Izaberite unapred podešenu poziciju…",
|
||||||
|
"all": {
|
||||||
|
"title": "Svi datumi",
|
||||||
|
"short": "Datumi"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"more": "Više filtera",
|
||||||
|
"reset": {
|
||||||
|
"label": "Vrati filtere na podrazumevane vrednosti"
|
||||||
|
},
|
||||||
|
"timeRange": "Vremenski raspon",
|
||||||
|
"subLabels": {
|
||||||
|
"label": "Podoznake",
|
||||||
|
"all": "Sve podoznake"
|
||||||
|
},
|
||||||
|
"attributes": {
|
||||||
|
"label": "Atributi klasifikacije",
|
||||||
|
"all": "Svi atributi"
|
||||||
|
},
|
||||||
|
"score": "Rezultat",
|
||||||
|
"estimatedSpeed": "Procenjena brzina ({{unit}})",
|
||||||
|
"features": {
|
||||||
|
"label": "Funkcije",
|
||||||
|
"hasSnapshot": "Ima snimak",
|
||||||
|
"hasVideoClip": "Ima video-snimak",
|
||||||
|
"submittedToFrigatePlus": {
|
||||||
|
"label": "Poslato na Frigate+",
|
||||||
|
"tips": "Morate prvo filtrirati praćene objekte koji imaju snimak.<br /><br />Praćeni objekti bez snimka ne mogu se poslati na Frigate+."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sort": {
|
||||||
|
"label": "Sortiranje",
|
||||||
|
"dateAsc": "Datum (Rastuće)",
|
||||||
|
"dateDesc": "Datum (Opadajuće)",
|
||||||
|
"scoreAsc": "Rezultat objekta (Rastuće)",
|
||||||
|
"scoreDesc": "Rezultat objekta (Opadajuće)",
|
||||||
|
"speedAsc": "Procenjena brzina (Rastuće)",
|
||||||
|
"speedDesc": "Procenjena brzina (Opadajuće)",
|
||||||
|
"relevance": "Relevantnost"
|
||||||
|
},
|
||||||
|
"cameras": {
|
||||||
|
"label": "Filter kamera",
|
||||||
|
"all": {
|
||||||
|
"title": "Sve kamere",
|
||||||
|
"short": "Kamere"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"review": {
|
||||||
|
"showReviewed": "Prikaži pregledano"
|
||||||
|
},
|
||||||
|
"motion": {
|
||||||
|
"showMotionOnly": "Prikaži samo kretanje"
|
||||||
|
},
|
||||||
|
"explore": {
|
||||||
|
"settings": {
|
||||||
|
"title": "Podešavanja",
|
||||||
|
"defaultView": {
|
||||||
|
"title": "Podrazumevani prikaz",
|
||||||
|
"desc": "Kada nijedan filter nije izabran, prikaži sažetak najnovijih praćenih objekata po oznaci ili prikaži nefiltriranu mrežu.",
|
||||||
|
"summary": "Sažetak",
|
||||||
|
"unfilteredGrid": "Nefiltrirana mreža"
|
||||||
|
},
|
||||||
|
"gridColumns": {
|
||||||
|
"title": "Kolone u mreži",
|
||||||
|
"desc": "Izaberite broj kolona u prikazu mreže."
|
||||||
|
},
|
||||||
|
"searchSource": {
|
||||||
|
"label": "Izvor pretrage",
|
||||||
|
"desc": "Izaberite da li želite da pretražujete sličice (umanjene prikaze) ili opise vaših praćenih objekata.",
|
||||||
|
"options": {
|
||||||
|
"thumbnailImage": "Sličica",
|
||||||
|
"description": "Opis"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"date": {
|
||||||
|
"selectDateBy": {
|
||||||
|
"label": "Izaberite datum za filtriranje"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"logSettings": {
|
||||||
|
"label": "Filtriraj nivo dnevnika",
|
||||||
|
"filterBySeverity": "Filtriraj dnevnik prema ozbiljnosti",
|
||||||
|
"loading": {
|
||||||
|
"title": "Učitavanje",
|
||||||
|
"desc": "Kada se panel dnevnika pomeri do samog dna, novi zapisi se automatski strimuju čim se dodaju."
|
||||||
|
},
|
||||||
|
"disableLogStreaming": "Onemogući strimovanje dnevnika",
|
||||||
|
"allLogs": "Ceo dnevnik"
|
||||||
|
},
|
||||||
|
"trackedObjectDelete": {
|
||||||
|
"title": "Potvrdite brisanje",
|
||||||
|
"desc": "Brisanjem ovih {{objectLength}} praćenih objekata uklanja se snimak, svi sačuvani vektorski prikazi i svi povezani unosi o životnom ciklusu objekta. Snimljeni materijal ovih praćenih objekata u prikazu istorije <em>NEĆE</em> biti obrisan.<br /><br />Da li ste sigurni da želite da nastavite?<br /><br />Držite taster <em>Shift</em> da biste ubuduće zaobišli ovaj prozor.",
|
||||||
|
"toast": {
|
||||||
|
"success": "Praćeni objekti su uspešno obrisani.",
|
||||||
|
"error": "Neuspešno brisanje praćenih objekata: {{errorMessage}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zoneMask": {
|
||||||
|
"filterBy": "Filtriraj prema maski zone"
|
||||||
|
},
|
||||||
|
"recognizedLicensePlates": {
|
||||||
|
"title": "Prepoznate registarske tablice",
|
||||||
|
"loadFailed": "Učitavanje prepoznatih registarskih tablica nije uspelo.",
|
||||||
|
"loading": "Učitavanje prepoznatih registarskih tablica…",
|
||||||
|
"placeholder": "Unesite tekst za pretragu tablica…",
|
||||||
|
"noLicensePlatesFound": "Nema pronađenih registarskih tablica.",
|
||||||
|
"selectPlatesFromList": "Izaberite jednu ili više tablica sa liste.",
|
||||||
|
"selectAll": "Izaberi sve",
|
||||||
|
"clearAll": "Poništi sve"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1 +1,61 @@
|
|||||||
{}
|
{
|
||||||
|
"noRecordingsFoundForThisTime": "Nema pronađenih snimaka za ovaj vremenski period",
|
||||||
|
"noPreviewFound": "Pregled nije pronađen",
|
||||||
|
"noPreviewFoundFor": "Pregled nije pronađen za kameru {{cameraName}}",
|
||||||
|
"submitFrigatePlus": {
|
||||||
|
"title": "Pošalji ovaj kadar na Frigate+?",
|
||||||
|
"submit": "Pošalji",
|
||||||
|
"previewError": "Učitavanje predpregleda snimka nije uspelo. Snimak možda nije dostupan u ovom trenutku."
|
||||||
|
},
|
||||||
|
"livePlayerRequiredIOSVersion": "Za ovaj tip strima uživo potreban je iOS 17.1 ili noviji.",
|
||||||
|
"streamOffline": {
|
||||||
|
"title": "Strim je van mreže",
|
||||||
|
"desc": "Nijedan kadar nije primljen na strimu za detekciju <code>detect</code> kamere {{cameraName}}, proverite dnevnik grešaka"
|
||||||
|
},
|
||||||
|
"cameraOff": "Kamera je isključena",
|
||||||
|
"quality": {
|
||||||
|
"auto": "Automatski",
|
||||||
|
"autoLow": "Reprodukcija u niskom kvalitetu (ograničen protok podataka)",
|
||||||
|
"autoLowCodec": "Reprodukcija u niskom kvalitetu (izvorni kvalitet nije podržan u ovom veb-pregledaču)",
|
||||||
|
"autoLowSaveData": "Reprodukcija u niskom kvalitetu (ušteda podataka)",
|
||||||
|
"main": "Izvorni",
|
||||||
|
"sub": "Nizak",
|
||||||
|
"notSupportedBrowser": "Nije podržano u ovom veb-pregledaču",
|
||||||
|
"label": "Kvalitet",
|
||||||
|
"noAudio": "Nema zvuka",
|
||||||
|
"audioRate": "{{rate}} kHz audio",
|
||||||
|
"audioCodecRate": "{{codec}} {{rate}} kHz",
|
||||||
|
"noRecordings": "Nema snimaka u ovom vremenskom opsegu"
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"streamType": {
|
||||||
|
"title": "Tip strima:",
|
||||||
|
"short": "Tip"
|
||||||
|
},
|
||||||
|
"bandwidth": {
|
||||||
|
"title": "Propusni opseg:",
|
||||||
|
"short": "Propusni opseg"
|
||||||
|
},
|
||||||
|
"totalFrames": "Ukupno kadrova:",
|
||||||
|
"droppedFrames": {
|
||||||
|
"title": "Odbačeni kadrovi:",
|
||||||
|
"short": {
|
||||||
|
"title": "Odbačeno",
|
||||||
|
"value": "{{droppedFrames}} kadrova"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"decodedFrames": "Dekodirani kadrovi:",
|
||||||
|
"droppedFrameRate": "Brzina odbacivanja kadrova:",
|
||||||
|
"stream": "Strim:",
|
||||||
|
"streamAuto": "{{stream}} (automatski)"
|
||||||
|
},
|
||||||
|
"toast": {
|
||||||
|
"success": {
|
||||||
|
"submittedFrigatePlus": "Kadar je uspešno poslat na Frigate+"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"submitFrigatePlusFailed": "Slanje kadra na Frigate+ nije uspelo",
|
||||||
|
"playRecordingsFailed": "Reprodukcija snimaka nije uspela (greška {{code}}): {{message}}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1 +1,40 @@
|
|||||||
{}
|
{
|
||||||
|
"minimum": "Mora biti najmanje {{limit}}",
|
||||||
|
"maximum": "Mora biti najviše {{limit}}",
|
||||||
|
"exclusiveMinimum": "Mora biti veće od {{limit}}",
|
||||||
|
"exclusiveMaximum": "Mora biti manje od {{limit}}",
|
||||||
|
"minLength": "Mora imati najmanje {{limit}} karakter(a)",
|
||||||
|
"maxLength": "Mora imati najviše {{limit}} karakter(a)",
|
||||||
|
"minItems": "Mora imati najmanje {{limit}} stavki",
|
||||||
|
"maxItems": "Mora imati najviše {{limit}} stavki",
|
||||||
|
"pattern": "Nevažeći format",
|
||||||
|
"required": "Ovo polje je obavezno",
|
||||||
|
"type": "Nevažeći tip vrednosti",
|
||||||
|
"enum": "Mora biti jedna od dozvoljenih vrednosti",
|
||||||
|
"const": "Vrednost se ne poklapa sa očekivanom konstantom",
|
||||||
|
"uniqueItems": "Sve stavke moraju biti jedinstvene",
|
||||||
|
"format": "Nevažeći format",
|
||||||
|
"additionalProperties": "Nepoznato svojstvo nije dozvoljeno",
|
||||||
|
"oneOf": "Mora se podudarati sa tačno jednom od dozvoljenih šema",
|
||||||
|
"anyOf": "Mora se podudarati sa najmanje jednom od dozvoljenih šema",
|
||||||
|
"proxy": {
|
||||||
|
"header_map": {
|
||||||
|
"roleHeaderRequired": "Zaglavlje uloge je obavezno kada su konfigurisana mapiranja uloga."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ffmpeg": {
|
||||||
|
"inputs": {
|
||||||
|
"rolesUnique": "Svaka uloga može biti dodeljena samo jednom ulaznom strimu.",
|
||||||
|
"detectRequired": "Najmanje jednom ulaznom strimu mora biti dodeljena uloga „detect“.",
|
||||||
|
"hwaccelDetectOnly": "Samo ulazni strim sa ulogom „detect“ može definisati argumente za hardversko ubrzanje."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"detect": {
|
||||||
|
"dimensionMustBeEven": "Mora biti paran broj."
|
||||||
|
},
|
||||||
|
"models": {
|
||||||
|
"defaultRequired": "Jedan model mora koristiti podrazumevanu scenu. Bez nje, svaka kamera koja ne izabere scenu neće imati rezervni model na koji može da se osloni.",
|
||||||
|
"sceneDuplicate": "Svaki model mora koristiti različitu scenu.",
|
||||||
|
"sameModel": "Modeli za scenu {{scene}} i scenu {{other}} koriste istu datoteku modela. Pokretanje jednog modela pod više različitih scena samo deli posao detekcije na zasebne detektore, što je sporije nego pokretanje jednog modela sa svim hardverskim resursima navedenim pod njim."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user