mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-11-09 23:23:02 +03:00
Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d42db3638 | ||
|
|
78a3174238 | ||
|
|
c30cda9ff2 | ||
|
|
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:
|
||||
required: true
|
||||
outputs:
|
||||
image-repo:
|
||||
value: ghcr.io/${{ steps.lowercaseRepo.outputs.lowercase }}
|
||||
image-name:
|
||||
value: ghcr.io/${{ steps.lowercaseRepo.outputs.lowercase }}:${{ steps.create-short-sha.outputs.SHORT_SHA }}
|
||||
cache-name:
|
||||
|
||||
@@ -136,9 +136,6 @@ updates:
|
||||
# Later versions need Node 22 and extract false keys
|
||||
- dependency-name: "i18next-cli"
|
||||
versions: [">1.5.11"]
|
||||
# 6.11.0 refills the saved model in the GenAI settings form
|
||||
- dependency-name: "@rjsf/*"
|
||||
versions: ["6.11.0"]
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/docs"
|
||||
|
||||
@@ -423,6 +423,11 @@ jobs:
|
||||
run: docker rm -f frigate || true
|
||||
arm64_build:
|
||||
runs-on: ubuntu-22.04-arm
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
attestations: write
|
||||
id-token: write
|
||||
name: ARM Build
|
||||
steps:
|
||||
- name: Check out code
|
||||
@@ -435,6 +440,7 @@ jobs:
|
||||
with:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build and push arm64 standard build
|
||||
id: standard-arm64
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
@@ -445,6 +451,13 @@ jobs:
|
||||
tags: |
|
||||
${{ steps.setup.outputs.image-name }}-standard-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
|
||||
uses: docker/bake-action@v7
|
||||
with:
|
||||
@@ -458,6 +471,11 @@ jobs:
|
||||
*.cache-to=type=registry,ref=${{ steps.setup.outputs.cache-name }}-arm64,mode=max
|
||||
jetson_jp6_build:
|
||||
runs-on: ubuntu-22.04-arm
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
attestations: write
|
||||
id-token: write
|
||||
name: Jetson Jetpack 6
|
||||
steps:
|
||||
- name: Check out code
|
||||
@@ -470,6 +488,7 @@ jobs:
|
||||
with:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build and push TensorRT (Jetson, Jetpack 6)
|
||||
id: tensorrt-jp6
|
||||
env:
|
||||
ARCH: arm64
|
||||
BASE_IMAGE: nvcr.io/nvidia/tensorrt:23.12-py3-igpu
|
||||
@@ -485,8 +504,20 @@ jobs:
|
||||
tensorrt.tags=${{ steps.setup.outputs.image-name }}-tensorrt-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
|
||||
- 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:
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
attestations: write
|
||||
id-token: write
|
||||
name: AMD64 Extra Build
|
||||
needs:
|
||||
- amd64_build
|
||||
@@ -501,6 +532,7 @@ jobs:
|
||||
with:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build and push TensorRT (x86 GPU)
|
||||
id: tensorrt
|
||||
env:
|
||||
COMPUTE_LEVEL: "50 60 70 80 90"
|
||||
uses: docker/bake-action@v7
|
||||
@@ -513,7 +545,15 @@ jobs:
|
||||
tensorrt.tags=${{ steps.setup.outputs.image-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
|
||||
- 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
|
||||
id: rocm
|
||||
env:
|
||||
HSA_OVERRIDE: 0
|
||||
uses: docker/bake-action@v7
|
||||
@@ -526,8 +566,20 @@ jobs:
|
||||
rocm.tags=${{ steps.setup.outputs.image-name }}-rocm
|
||||
*.cache-to=type=registry,ref=${{ steps.setup.outputs.cache-name }}-rocm,mode=max
|
||||
*.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:
|
||||
runs-on: ubuntu-22.04-arm
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
attestations: write
|
||||
id-token: write
|
||||
name: ARM Extra Build
|
||||
needs:
|
||||
- arm64_build
|
||||
@@ -542,6 +594,7 @@ jobs:
|
||||
with:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build and push Rockchip build
|
||||
id: rk
|
||||
uses: docker/bake-action@v7
|
||||
with:
|
||||
source: .
|
||||
@@ -551,8 +604,20 @@ jobs:
|
||||
set: |
|
||||
rk.tags=${{ steps.setup.outputs.image-name }}-rk
|
||||
*.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:
|
||||
runs-on: ubuntu-22.04-arm
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
attestations: write
|
||||
id-token: write
|
||||
name: Synaptics Build
|
||||
needs:
|
||||
- arm64_build
|
||||
@@ -567,6 +632,7 @@ jobs:
|
||||
with:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build and push Synaptics build
|
||||
id: synaptics
|
||||
uses: docker/bake-action@v7
|
||||
with:
|
||||
source: .
|
||||
@@ -576,10 +642,22 @@ jobs:
|
||||
set: |
|
||||
synaptics.tags=${{ steps.setup.outputs.image-name }}-synaptics
|
||||
*.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
|
||||
# build should be the primary arm64 image
|
||||
assemble_default_build:
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
attestations: write
|
||||
id-token: write
|
||||
name: Assemble and push default build
|
||||
needs:
|
||||
- amd64_build
|
||||
@@ -598,8 +676,16 @@ jobs:
|
||||
- name: Create short sha
|
||||
run: echo "SHORT_SHA=${GITHUB_SHA::7}" >> $GITHUB_ENV
|
||||
- uses: int128/docker-manifest-create-action@v2
|
||||
id: manifest
|
||||
with:
|
||||
tags: ghcr.io/${{ steps.lowercaseRepo.outputs.lowercase }}:${{ env.SHORT_SHA }}
|
||||
sources: |
|
||||
ghcr.io/${{ steps.lowercaseRepo.outputs.lowercase }}:${{ env.SHORT_SHA }}-amd64
|
||||
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
|
||||
|
||||
@@ -46,3 +46,4 @@ make -C /tmp/media-driver/build -j"$(nproc)"
|
||||
|
||||
# Install driver to rootfs for COPY --from
|
||||
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"
|
||||
VOD_MODULE_VERSION="v1.9.1"
|
||||
SECURE_TOKEN_MODULE_VERSION="1.5"
|
||||
SET_MISC_MODULE_VERSION="v0.33"
|
||||
NGX_DEVEL_KIT_VERSION="v0.3.3"
|
||||
|
||||
@@ -52,11 +51,6 @@ patch -d /tmp/nginx-vod-module/ -p1 << '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
|
||||
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
|
||||
@@ -80,7 +74,6 @@ cd /tmp/nginx
|
||||
--add-module=../ngx_devel_kit \
|
||||
--add-module=../nginx-set-misc-module \
|
||||
--add-module=../nginx-vod-module \
|
||||
--add-module=../nginx-secure-token-module \
|
||||
--with-cc-opt="-O3 -Wno-error=implicit-fallthrough"
|
||||
|
||||
make CC="ccache gcc" -j$(nproc) && make install
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
ruff == 0.17.3
|
||||
ruff == 0.17.4
|
||||
|
||||
# types
|
||||
types-peewee == 4.0.*
|
||||
|
||||
@@ -7,18 +7,18 @@ starlette-context == 0.5.*
|
||||
fastapi[standard-no-fastapi-cloud-cli] == 0.162.*
|
||||
uvicorn == 0.54.*
|
||||
slowapi == 0.1.*
|
||||
joserfc == 1.7.*
|
||||
joserfc == 1.8.*
|
||||
cryptography == 50.0.*
|
||||
pathvalidate == 3.3.*
|
||||
markupsafe == 3.0.*
|
||||
python-multipart == 0.0.32
|
||||
python-multipart == 0.0.33
|
||||
# Classification Model Training
|
||||
tensorflow == 2.19.* ; platform_machine == 'aarch64'
|
||||
tensorflow-cpu == 2.21.* ; platform_machine == 'x86_64'
|
||||
# General
|
||||
mypy == 2.4.1
|
||||
onvif-zeep-async == 4.3.*
|
||||
paho-mqtt == 2.1.*
|
||||
paho-mqtt == 2.2.*
|
||||
pandas == 3.0.*
|
||||
peewee == 4.5.*
|
||||
peewee_migrate == 2.3.*
|
||||
@@ -26,7 +26,7 @@ psutil == 7.2.*
|
||||
pydantic == 2.14.*
|
||||
git+https://github.com/fbcotter/py3nvml#egg=py3nvml
|
||||
tzdata == 2026.6
|
||||
pyzmq == 27.2.*
|
||||
pyzmq == 27.3.*
|
||||
ruamel.yaml == 0.19.*
|
||||
tzlocal == 5.4.5
|
||||
requests == 2.34.*
|
||||
@@ -47,9 +47,9 @@ onnxruntime == 1.31.*
|
||||
# Embeddings
|
||||
transformers == 5.22.*
|
||||
# Generative AI
|
||||
google-genai == 2.34.*
|
||||
google-genai == 3.0.*
|
||||
ollama == 0.6.*
|
||||
openai == 3.41.*
|
||||
openai == 3.46.*
|
||||
# push notifications
|
||||
py-vapid == 1.9.6
|
||||
pywebpush == 2.5.*
|
||||
|
||||
@@ -14,11 +14,17 @@ fi
|
||||
# Tell S6-Overlay not to restart this service
|
||||
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() {
|
||||
local ffmpeg_path
|
||||
ffmpeg_path=$(python3 -I /usr/local/ffmpeg/get_ffmpeg_path.py)
|
||||
LIBAVFORMAT_VERSION_MAJOR=$("$ffmpeg_path" -version | grep -Po "libavformat\W+\K\d+")
|
||||
local probe_as=()
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -57,10 +57,19 @@ function get_ip_and_port_from_supervisor() {
|
||||
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() {
|
||||
local ffmpeg_path
|
||||
ffmpeg_path=$(python3 -I /usr/local/ffmpeg/get_ffmpeg_path.py)
|
||||
LIBAVFORMAT_VERSION_MAJOR=$("$ffmpeg_path" -version | grep -Po "libavformat\W+\K\d+")
|
||||
local probe_as=()
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -80,6 +80,11 @@ cp -r /usr/local/nginx/conf/. /tmp/nginx/conf/
|
||||
|
||||
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
|
||||
# contract) always win; otherwise fall back to a self-signed cert persisted in
|
||||
# /config/tls, which stays writable under a read-only root filesystem.
|
||||
|
||||
@@ -25,4 +25,5 @@ except FileNotFoundError:
|
||||
config: dict[str, Any] = {}
|
||||
|
||||
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:
|
||||
config: dict[str, Any] = {}
|
||||
|
||||
# No validator runs here, so install environment_vars ourselves. FRIGATE_
|
||||
# names only: anything else lands in os.environ, where the exec gate reads
|
||||
# GO2RTC_ALLOW_ARBITRARY_EXEC.
|
||||
# No validator runs here, so install environment_vars ourselves.
|
||||
config_env_vars = config.get("environment_vars")
|
||||
apply_config_env_vars(
|
||||
{
|
||||
key: value
|
||||
for key, value in config_env_vars.items()
|
||||
if str(key).startswith("FRIGATE_")
|
||||
}
|
||||
{str(key): value for key, value in config_env_vars.items()}
|
||||
if isinstance(config_env_vars, dict)
|
||||
else {}
|
||||
)
|
||||
@@ -103,7 +97,7 @@ if not is_valid_ffmpeg_path(path):
|
||||
print(
|
||||
"[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):
|
||||
print(
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# file, all other path directives follow --prefix and must stay absolute.
|
||||
|
||||
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;
|
||||
worker_processes auto;
|
||||
|
||||
@@ -143,8 +143,14 @@ http {
|
||||
# version 6 (RFC 8216 section 7); the module default is 4
|
||||
vod_hls_version 6;
|
||||
|
||||
secure_token $args;
|
||||
secure_token_types application/vnd.apple.mpegurl;
|
||||
# Pass the playlist's query args (e.g. Home Assistant's authSig) on
|
||||
# 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;
|
||||
add_header Cache-Control "no-store";
|
||||
|
||||
@@ -240,7 +240,7 @@ logger:
|
||||
|
||||
# Optional: set environment variables
|
||||
environment_vars:
|
||||
EXAMPLE_VAR: value
|
||||
LIBVA_DRIVER_NAME: i965
|
||||
|
||||
# Optional: birdseye configuration
|
||||
# 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.
|
||||
|
||||
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>
|
||||
<TabItem value="ui">
|
||||
@@ -398,9 +407,9 @@ To do this:
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { Config, PluginConfig } from "@docusaurus/types";
|
||||
import type * as OpenApiPlugin from "docusaurus-plugin-openapi-docs";
|
||||
|
||||
// Bump when a new stable release ships
|
||||
const STABLE_VERSION = "0.18";
|
||||
const STABLE_VERSION = "0.19";
|
||||
|
||||
const config: Config = {
|
||||
title: "Frigate",
|
||||
|
||||
Vendored
+19
@@ -3148,6 +3148,25 @@ paths:
|
||||
- frigateUserAuth: []
|
||||
x-required-role: any
|
||||
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:
|
||||
get:
|
||||
tags:
|
||||
|
||||
+12
-1
@@ -54,7 +54,7 @@ from frigate.jobs.media_sync import (
|
||||
start_media_sync_job,
|
||||
)
|
||||
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.types import JobStatusTypesEnum
|
||||
from frigate.util.builtin import (
|
||||
@@ -627,6 +627,7 @@ def _restricted_go2rtc_response(
|
||||
Go2rtcStreamIssue.invalid_placeholder,
|
||||
"Invalid variable placeholder in go2rtc stream",
|
||||
),
|
||||
(Go2rtcStreamIssue.invalid_name, "Invalid go2rtc stream name"),
|
||||
):
|
||||
names = [name for name, found in issues.items() if found == issue]
|
||||
if names:
|
||||
@@ -1674,6 +1675,16 @@ def plusModels(request: Request, filterByCurrentModelDetector: bool = False):
|
||||
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(
|
||||
"/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.models import User
|
||||
from frigate.notices import raise_notice
|
||||
from frigate.util.live_streams import is_transcode_stream_name
|
||||
from frigate.util.origin import origin_allowed
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -739,6 +740,13 @@ def auth(request: Request):
|
||||
int(request.headers.get("x-server-port", default=0))
|
||||
== 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-role"] = "admin"
|
||||
return success_response
|
||||
@@ -1275,7 +1283,12 @@ def _is_existing_go2rtc_stream(request: Request, stream_name: str) -> bool:
|
||||
"""
|
||||
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
|
||||
|
||||
config_streams = getattr(frigate_config.go2rtc, "streams", None) or {}
|
||||
|
||||
@@ -39,6 +39,7 @@ from frigate.util.image import create_mask
|
||||
from frigate.util.live_streams import (
|
||||
default_transcode_source,
|
||||
is_transcode_stream_name,
|
||||
is_valid_go2rtc_stream_name,
|
||||
transcode_stream_name,
|
||||
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."
|
||||
)
|
||||
|
||||
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(
|
||||
camera_config.name,
|
||||
transcode.source,
|
||||
|
||||
+81
-1
@@ -3,9 +3,11 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Any, TypeGuard
|
||||
|
||||
import aiohttp
|
||||
import cv2
|
||||
import requests
|
||||
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__)
|
||||
|
||||
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]:
|
||||
"""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""
|
||||
|
||||
|
||||
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:
|
||||
def __init__(self) -> None:
|
||||
self.host = PLUS_API_HOST
|
||||
@@ -153,6 +179,60 @@ class PlusApi:
|
||||
def is_active(self) -> bool:
|
||||
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:
|
||||
r = self._get("image/signed_urls")
|
||||
if not r.ok:
|
||||
|
||||
@@ -5,6 +5,7 @@ import tempfile
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import requests
|
||||
import ruamel.yaml
|
||||
from fastapi import Request
|
||||
|
||||
@@ -234,6 +235,72 @@ class TestAuthInternalPort(BaseTestHttp):
|
||||
finally:
|
||||
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__":
|
||||
unittest.main(verbosity=2)
|
||||
|
||||
@@ -151,6 +151,19 @@ class TestConfigGo2rtcStreamRestrictions(BaseTestHttp):
|
||||
assert resp.status_code == 200, resp.text
|
||||
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):
|
||||
with patch("frigate.util.services._GO2RTC_ARBITRARY_EXEC_ENV", "true"):
|
||||
resp = self._set_streams({"new_stream": [_INJECTED_SRC]})
|
||||
|
||||
@@ -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)
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Tests for migrating detectors and model into the models list."""
|
||||
|
||||
import errno
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
@@ -240,6 +241,42 @@ class TestMigrateConfigFile(unittest.TestCase):
|
||||
with open(self.config_file) as 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):
|
||||
# 0.19 is unreleased, so a dev config can be current and still use
|
||||
# the pre-models keys
|
||||
|
||||
+81
-15
@@ -194,15 +194,16 @@ class TestEnvVars(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._original_env_vars = dict(FRIGATE_ENV_VARS)
|
||||
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):
|
||||
FRIGATE_ENV_VARS.clear()
|
||||
FRIGATE_ENV_VARS.update(self._original_env_vars)
|
||||
env._CONFIG_ENV_VARS.clear()
|
||||
# Clean up any env vars we set
|
||||
for key in list(os.environ.keys()):
|
||||
if key not in self._original_environ:
|
||||
del os.environ[key]
|
||||
os.environ.clear()
|
||||
os.environ.update(self._original_environ)
|
||||
|
||||
def _make_context(self, install: bool):
|
||||
"""Create a mock ValidationInfo with the given install flag."""
|
||||
@@ -215,10 +216,10 @@ class TestEnvVars(unittest.TestCase):
|
||||
return mock
|
||||
|
||||
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)
|
||||
validate_env_vars({"MY_CUSTOM_VAR": "value123"}, ctx)
|
||||
self.assertEqual(os.environ.get("MY_CUSTOM_VAR"), "value123")
|
||||
validate_env_vars({"SHM_MAX_FRAMES": "75"}, ctx)
|
||||
self.assertEqual(os.environ.get("SHM_MAX_FRAMES"), "75")
|
||||
|
||||
def test_install_updates_frigate_env_vars(self):
|
||||
"""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):
|
||||
"""Non-FRIGATE_ keys are set in os.environ but not in FRIGATE_ENV_VARS."""
|
||||
ctx = self._make_context(install=True)
|
||||
validate_env_vars({"OTHER_VAR": "value"}, ctx)
|
||||
self.assertEqual(os.environ.get("OTHER_VAR"), "value")
|
||||
self.assertNotIn("OTHER_VAR", FRIGATE_ENV_VARS)
|
||||
validate_env_vars({"USE_FP16": "False"}, ctx)
|
||||
self.assertEqual(os.environ.get("USE_FP16"), "False")
|
||||
self.assertNotIn("USE_FP16", FRIGATE_ENV_VARS)
|
||||
|
||||
def test_no_install_does_not_set(self):
|
||||
"""validate_env_vars without install=True does not modify state."""
|
||||
@@ -258,10 +259,10 @@ class TestEnvVars(unittest.TestCase):
|
||||
ctx = self._make_context(install=True)
|
||||
with patch("frigate.util.services.os.path.isfile", return_value=False):
|
||||
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.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):
|
||||
"""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")
|
||||
|
||||
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"})
|
||||
self.assertEqual(os.environ["FRIGATE_CAM_PASS"], "from_config")
|
||||
self.assertNotIn("FRIGATE_CAM_PASS", os.environ)
|
||||
self.assertNotIn("FRIGATE_CAM_PASS", env._CONTAINER_ENV)
|
||||
|
||||
def test_non_frigate_config_env_vars_only_set_os_environ(self):
|
||||
"""Unprefixed environment_vars keys reach os.environ but not substitution."""
|
||||
env.apply_config_env_vars({"LIBVA_DRIVER_NAME": "i965"})
|
||||
with patch.object(env, "_CONTAINER_ENV_NAMES", frozenset()):
|
||||
env.apply_config_env_vars({"LIBVA_DRIVER_NAME": "i965"})
|
||||
self.assertEqual(os.environ["LIBVA_DRIVER_NAME"], "i965")
|
||||
self.assertNotIn("LIBVA_DRIVER_NAME", env.FRIGATE_ENV_VARS)
|
||||
|
||||
def test_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):
|
||||
"""A key from two sources logs one warning naming the source that won."""
|
||||
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."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from frigate.const import DEFAULT_FFMPEG_VERSION
|
||||
from frigate.util.config import (
|
||||
_warn_ignored_ffmpeg_path,
|
||||
frigate_service_is_granular_root,
|
||||
resolve_ffmpeg_path,
|
||||
service_is_granular_root,
|
||||
)
|
||||
|
||||
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
|
||||
_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 (
|
||||
patch("os.geteuid", return_value=euid),
|
||||
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:
|
||||
self.assertEqual(
|
||||
@@ -65,6 +75,59 @@ class TestConfigFfmpegRootGuard(unittest.TestCase):
|
||||
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:
|
||||
# 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
|
||||
@@ -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."""
|
||||
|
||||
def _check(self, *, euid: int, env: dict) -> bool:
|
||||
def _check(self, *, euid: int, env: dict, service: str = "frigate") -> bool:
|
||||
with (
|
||||
patch("os.geteuid", return_value=euid),
|
||||
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:
|
||||
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"})
|
||||
)
|
||||
|
||||
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:
|
||||
self.assertFalse(self._check(euid=0, env={"FRIGATE_ROOT_SERVICES": "frigatee"}))
|
||||
|
||||
|
||||
@@ -23,7 +23,12 @@ _CONFIG = {
|
||||
"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": {
|
||||
"front_door": {
|
||||
"ffmpeg": {
|
||||
|
||||
@@ -12,6 +12,7 @@ from frigate.util.live_streams import (
|
||||
default_transcode_source,
|
||||
generated_transcode_streams,
|
||||
is_transcode_stream_name,
|
||||
is_valid_go2rtc_stream_name,
|
||||
measure_stream_bitrate,
|
||||
raw_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",
|
||||
)
|
||||
|
||||
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):
|
||||
streams = {"Low": "front_transcode_360p", "Main": "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"])
|
||||
|
||||
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):
|
||||
self.assertEqual(raw_transcode_streams({}), {})
|
||||
self.assertEqual(
|
||||
@@ -286,6 +310,17 @@ class TestLiveTranscodeConfig(unittest.TestCase):
|
||||
with self.assertRaises(ValidationError):
|
||||
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):
|
||||
with self.assertRaises(ValidationError):
|
||||
self.streams(
|
||||
|
||||
+138
-1
@@ -1,12 +1,17 @@
|
||||
"""Tests for Frigate+ model metadata."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
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):
|
||||
@@ -75,5 +80,137 @@ class TestLoadPlusModelInfo(unittest.TestCase):
|
||||
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__":
|
||||
unittest.main(verbosity=2)
|
||||
|
||||
@@ -321,6 +321,36 @@ class TestLighterANE(unittest.TestCase):
|
||||
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):
|
||||
"""AMD's MIGraphX plugin provider, selected through its devices in the ROCm image."""
|
||||
|
||||
|
||||
+57
-33
@@ -1,6 +1,8 @@
|
||||
"""configuration utils."""
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import errno
|
||||
import ipaddress
|
||||
import logging
|
||||
import os
|
||||
@@ -46,14 +48,14 @@ DROPPED_DETECTOR_OPTIONS = {
|
||||
}
|
||||
|
||||
|
||||
# Trees the unprivileged runtime user can write. A root frigate service must
|
||||
# not execute a binary from any of them; a compromised uid-1000 process could
|
||||
# Trees the unprivileged runtime user can write. A root service must not
|
||||
# execute a binary from any of them; a compromised uid-1000 process could
|
||||
# plant one and be root after the next restart.
|
||||
RUNTIME_USER_WRITABLE_DIRS = (CONFIG_DIR, BASE_DIR, CACHE_DIR, "/dev/shm", "/tmp")
|
||||
|
||||
|
||||
def frigate_service_is_granular_root() -> bool:
|
||||
"""Report whether FRIGATE_ROOT_SERVICES runs frigate as root.
|
||||
def service_is_granular_root(service: str) -> bool:
|
||||
"""Report whether FRIGATE_ROOT_SERVICES runs the given service as root.
|
||||
|
||||
The escape hatch is excluded: it never sweeps /config and leaves no
|
||||
unprivileged service running, so custom binaries stay as safe as they
|
||||
@@ -66,7 +68,7 @@ def frigate_service_is_granular_root() -> bool:
|
||||
return False
|
||||
|
||||
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:
|
||||
@@ -79,11 +81,12 @@ def is_runtime_user_writable(path: str) -> bool:
|
||||
|
||||
|
||||
@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."""
|
||||
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,
|
||||
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
|
||||
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
bundled version so existing configs keep working across an upgrade or a
|
||||
revert. Custom install paths (anything absolute) are used as-is, unless
|
||||
they fail `is_valid_ffmpeg_path` (which falls back to the default), or
|
||||
they are in a runtime-user-writable tree while FRIGATE_ROOT_SERVICES
|
||||
makes frigate root; see RUNTIME_USER_WRITABLE_DIRS.
|
||||
revert. Custom install paths (anything absolute) are used as-is. While
|
||||
FRIGATE_ROOT_SERVICES makes the service that execs it root, a custom path
|
||||
is resolved to its real location and refused inside a runtime-user-writable
|
||||
tree; see RUNTIME_USER_WRITABLE_DIRS. Paths that fail
|
||||
`is_valid_ffmpeg_path` also fall back to the default.
|
||||
"""
|
||||
if (
|
||||
path == "default"
|
||||
@@ -116,11 +122,16 @@ def resolve_ffmpeg_path(path: str, binary: str = "ffmpeg") -> str:
|
||||
version = DEFAULT_FFMPEG_VERSION
|
||||
elif path in INCLUDED_FFMPEG_VERSIONS:
|
||||
version = path
|
||||
elif not service_is_granular_root(service):
|
||||
return f"{path}/bin/{binary}"
|
||||
else:
|
||||
if not (frigate_service_is_granular_root() and is_runtime_user_writable(path)):
|
||||
return f"{path}/bin/{binary}"
|
||||
# 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
|
||||
|
||||
return f"/usr/lib/ffmpeg/{version}/bin/{binary}"
|
||||
@@ -203,6 +214,28 @@ def find_config_file() -> str:
|
||||
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):
|
||||
"""handle migrating the frigate config."""
|
||||
logger.info("Checking if frigate config needs migration...")
|
||||
@@ -250,8 +283,7 @@ def migrate_frigate_config(config_file: str):
|
||||
if previous_version < "0.14":
|
||||
logger.info(f"Migrating frigate config from {previous_version} to 0.14...")
|
||||
new_config = migrate_014(new_config)
|
||||
with open(config_file, "w") as f:
|
||||
yaml.dump(new_config, f)
|
||||
_write_config(config_file, yaml, new_config)
|
||||
previous_version = "0.14"
|
||||
|
||||
logger.info("Migrating export file names...")
|
||||
@@ -268,56 +300,48 @@ def migrate_frigate_config(config_file: str):
|
||||
if previous_version < "0.15-0":
|
||||
logger.info(f"Migrating frigate config from {previous_version} to 0.15-0...")
|
||||
new_config = migrate_015_0(new_config)
|
||||
with open(config_file, "w") as f:
|
||||
yaml.dump(new_config, f)
|
||||
_write_config(config_file, yaml, new_config)
|
||||
previous_version = "0.15-0"
|
||||
|
||||
if previous_version < "0.15-1":
|
||||
logger.info(f"Migrating frigate config from {previous_version} to 0.15-1...")
|
||||
new_config = migrate_015_1(new_config)
|
||||
with open(config_file, "w") as f:
|
||||
yaml.dump(new_config, f)
|
||||
_write_config(config_file, yaml, new_config)
|
||||
previous_version = "0.15-1"
|
||||
|
||||
if previous_version < "0.16-0":
|
||||
logger.info(f"Migrating frigate config from {previous_version} to 0.16-0...")
|
||||
new_config = migrate_016_0(new_config)
|
||||
with open(config_file, "w") as f:
|
||||
yaml.dump(new_config, f)
|
||||
_write_config(config_file, yaml, new_config)
|
||||
previous_version = "0.16-0"
|
||||
|
||||
if previous_version < "0.17-0":
|
||||
logger.info(f"Migrating frigate config from {previous_version} to 0.17-0...")
|
||||
new_config = migrate_017_0(new_config)
|
||||
with open(config_file, "w") as f:
|
||||
yaml.dump(new_config, f)
|
||||
_write_config(config_file, yaml, new_config)
|
||||
previous_version = "0.17-0"
|
||||
|
||||
if previous_version < "0.18-0":
|
||||
logger.info(f"Migrating frigate config from {previous_version} to 0.18-0...")
|
||||
new_config = migrate_018_0(new_config)
|
||||
with open(config_file, "w") as f:
|
||||
yaml.dump(new_config, f)
|
||||
_write_config(config_file, yaml, new_config)
|
||||
previous_version = "0.18-0"
|
||||
|
||||
if previous_version < "0.19-0":
|
||||
logger.info(f"Migrating frigate config from {previous_version} to 0.19-0...")
|
||||
new_config = migrate_019_0(new_config)
|
||||
with open(config_file, "w") as f:
|
||||
yaml.dump(new_config, f)
|
||||
_write_config(config_file, yaml, new_config)
|
||||
previous_version = "0.19-0"
|
||||
|
||||
if needs_models:
|
||||
logger.info("Migrating frigate detectors and model to models...")
|
||||
new_config = migrate_models(new_config)
|
||||
with open(config_file, "w") as f:
|
||||
yaml.dump(new_config, f)
|
||||
_write_config(config_file, yaml, new_config)
|
||||
|
||||
if needs_detector_rename:
|
||||
logger.info("Migrating renamed frigate detectors...")
|
||||
new_config = rename_hailo_detector(new_config)
|
||||
with open(config_file, "w") as f:
|
||||
yaml.dump(new_config, f)
|
||||
_write_config(config_file, yaml, new_config)
|
||||
|
||||
logger.info("Finished frigate config migration...")
|
||||
|
||||
|
||||
+59
-15
@@ -26,6 +26,8 @@ _SECRETS_FILE: dict[str, str] = {}
|
||||
_CONTAINER_ENV: dict[str, str] = {
|
||||
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] = {}
|
||||
|
||||
_SOURCES: tuple[tuple[str, dict[str, str]], ...] = (
|
||||
@@ -165,38 +167,80 @@ def reload_sources(warn: bool = True) -> None:
|
||||
_rebuild(warn)
|
||||
|
||||
|
||||
# Security overrides that must come from the container environment, which is
|
||||
# controlled by the operator, rather than from the config, which admins can edit
|
||||
_PROTECTED_ENV_VARS = frozenset({"GO2RTC_ALLOW_ARBITRARY_EXEC"})
|
||||
_INT = re.compile(r"\d+", re.ASCII)
|
||||
_WORD = re.compile(r"\w+", re.ASCII)
|
||||
_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:
|
||||
"""Install the environment_vars block as the lowest priority source.
|
||||
|
||||
Unprefixed keys only set os.environ. Protected security overrides are
|
||||
skipped.
|
||||
FRIGATE_ keys feed substitution. Allowlisted unprefixed keys with valid
|
||||
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():
|
||||
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(
|
||||
"Ignoring %s in environment_vars, it must be set in the container environment",
|
||||
key,
|
||||
)
|
||||
continue
|
||||
|
||||
resolved = str(value)
|
||||
if not pattern.fullmatch(resolved):
|
||||
logger.warning("Ignoring %s in environment_vars, unsupported value", key)
|
||||
continue
|
||||
|
||||
if key.startswith("FRIGATE_"):
|
||||
_CONFIG_ENV_VARS[key] = resolved
|
||||
else:
|
||||
os.environ[key] = resolved
|
||||
exports[key] = resolved
|
||||
|
||||
_rebuild()
|
||||
|
||||
# Export the winning value; auth reads FRIGATE_JWT_SECRET from os.environ.
|
||||
for key in values:
|
||||
if key.startswith("FRIGATE_"):
|
||||
os.environ[key] = FRIGATE_ENV_VARS[key]
|
||||
for key in _EXPORTED_FRIGATE_VARS & values.keys():
|
||||
exports[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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
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:
|
||||
"""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}
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
qualities = transcode.get("qualities")
|
||||
|
||||
@@ -318,7 +318,6 @@ def post_process_yolox(
|
||||
def get_ort_providers(
|
||||
force_cpu: bool = False,
|
||||
device: str | None = "AUTO",
|
||||
requires_fp16: bool = False,
|
||||
) -> tuple[list[str], list[dict[str, Any]]]:
|
||||
if force_cpu:
|
||||
return (
|
||||
@@ -357,8 +356,8 @@ def get_ort_providers(
|
||||
options.append(
|
||||
{
|
||||
"device_id": device_id,
|
||||
"trt_fp16_enable": requires_fp16
|
||||
and os.environ.get("USE_FP16", "True") != "False",
|
||||
"trt_fp16_enable": os.environ.get("USE_FP16", "False").lower()
|
||||
== "true",
|
||||
"trt_timing_cache_enable": True,
|
||||
"trt_engine_cache_enable": True,
|
||||
"trt_timing_cache_path": os.path.join(
|
||||
|
||||
@@ -32,8 +32,8 @@ from pathlib import Path
|
||||
|
||||
from frigate.const import MODEL_CACHE_DIR
|
||||
from frigate.util.config import (
|
||||
frigate_service_is_granular_root,
|
||||
is_runtime_user_writable,
|
||||
service_is_granular_root,
|
||||
)
|
||||
from frigate.util.file import FileLock
|
||||
|
||||
@@ -163,7 +163,7 @@ def _usable_reason() -> str | None:
|
||||
|
||||
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 (
|
||||
f"{base} is writable by the unprivileged user while "
|
||||
"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.env import UnknownVariableError, substitute_frigate_vars
|
||||
from frigate.util.live_streams import is_valid_go2rtc_stream_name
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -1177,6 +1178,7 @@ class Go2rtcStreamIssue(str, Enum):
|
||||
restricted = "restricted"
|
||||
unknown_variable = "unknown_variable"
|
||||
invalid_placeholder = "invalid_placeholder"
|
||||
invalid_name = "invalid_name"
|
||||
|
||||
|
||||
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
|
||||
operator configured on disk keep working; any other source must resolve
|
||||
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 {}
|
||||
new_streams = new_streams if isinstance(new_streams, dict) else {}
|
||||
issues: dict[str, Go2rtcStreamIssue] = {}
|
||||
|
||||
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))
|
||||
|
||||
for source in _go2rtc_stream_sources(stream):
|
||||
|
||||
Generated
+199
-181
@@ -9,8 +9,8 @@
|
||||
"version": "0.0.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@cycjimmy/jsmpeg-player": "^6.1.8",
|
||||
"@hookform/resolvers": "^6.0.2",
|
||||
"@cycjimmy/jsmpeg-player": "^7.0.0",
|
||||
"@hookform/resolvers": "^7.0.3",
|
||||
"@radix-ui/react-alert-dialog": "^1.2.1",
|
||||
"@radix-ui/react-aspect-ratio": "^1.2.0",
|
||||
"@radix-ui/react-checkbox": "^1.4.1",
|
||||
@@ -33,11 +33,11 @@
|
||||
"@radix-ui/react-toggle": "^1.2.1",
|
||||
"@radix-ui/react-toggle-group": "^1.2.1",
|
||||
"@radix-ui/react-tooltip": "^1.4.1",
|
||||
"@rjsf/core": "^6.10.1",
|
||||
"@rjsf/shadcn": "^6.10.1",
|
||||
"@rjsf/core": "^6.11.1",
|
||||
"@rjsf/shadcn": "^6.11.1",
|
||||
"@rjsf/utils": "^6.10.1",
|
||||
"@rjsf/validator-ajv8": "^6.10.1",
|
||||
"apexcharts": "^8.6.0",
|
||||
"@rjsf/validator-ajv8": "^6.11.1",
|
||||
"apexcharts": "^8.7.0",
|
||||
"axios": "^1.21.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -45,15 +45,15 @@
|
||||
"copy-to-clipboard": "^4.0.2",
|
||||
"date-fns": "^4.4.0",
|
||||
"date-fns-tz": "^3.2.0",
|
||||
"framer-motion": "^14.9.0",
|
||||
"framer-motion": "^14.10.0",
|
||||
"hls.js": "^1.7.4",
|
||||
"i18next": "^26.5.12",
|
||||
"i18next-http-backend": "^4.0.7",
|
||||
"i18next": "^26.7.2",
|
||||
"i18next-http-backend": "^4.0.10",
|
||||
"idb-keyval": "^6.3.0",
|
||||
"js-yaml": "^5.5.0",
|
||||
"konva": "^10.9.0",
|
||||
"konva": "^10.9.1",
|
||||
"lodash": "^4.18.1",
|
||||
"lucide-react": "^1.65.0",
|
||||
"lucide-react": "^1.70.0",
|
||||
"monaco-yaml": "^5.5.1",
|
||||
"next-themes": "^0.4.6",
|
||||
"nosleep.js": "^0.12.0",
|
||||
@@ -65,7 +65,7 @@
|
||||
"react-dropzone": "^20.1.4",
|
||||
"react-grid-layout": "^2.3.0",
|
||||
"react-hook-form": "^7.91.0",
|
||||
"react-i18next": "^17.1.9",
|
||||
"react-i18next": "^17.1.13",
|
||||
"react-icons": "^5.7.0",
|
||||
"react-konva": "^19.3.2",
|
||||
"react-markdown": "^10.1.0",
|
||||
@@ -81,19 +81,19 @@
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"use-long-press": "^3.3.0",
|
||||
"vaul": "^1.1.2",
|
||||
"virtua": "^0.54.0",
|
||||
"virtua": "^0.54.2",
|
||||
"vite-plugin-monaco-editor": "^1.1.0",
|
||||
"zod": "^4.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@playwright/test": "^1.64.1",
|
||||
"@playwright/test": "^1.65.0",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/lodash": "^4.17.27",
|
||||
"@types/node": "^26.6.6",
|
||||
"@types/react": "^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",
|
||||
"esbuild": "^0.28.2",
|
||||
"eslint": "^10.14.0",
|
||||
@@ -101,7 +101,7 @@
|
||||
"eslint-plugin-prettier": "^5.5.7",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.7",
|
||||
"globals": "^17.13.0",
|
||||
"globals": "^17.14.0",
|
||||
"i18next-cli": "^1.5.11",
|
||||
"monaco-editor": "^0.53.0",
|
||||
"patch-package": "^8.0.1",
|
||||
@@ -111,7 +111,7 @@
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.72.1",
|
||||
"vite": "^8.3.5"
|
||||
"vite": "^8.3.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@aashutoshrathi/word-wrap": {
|
||||
@@ -428,9 +428,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@cycjimmy/jsmpeg-player": {
|
||||
"version": "6.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@cycjimmy/jsmpeg-player/-/jsmpeg-player-6.1.8.tgz",
|
||||
"integrity": "sha512-ccG7JvidQWWPvmmpu6Ob6TUnbyaz6WaTHl9V/5BD4vBVndxnxchLmVU3rpJguB2lWEoEAhFPBIU3GoeXtc3RaQ==",
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@cycjimmy/jsmpeg-player/-/jsmpeg-player-7.0.0.tgz",
|
||||
"integrity": "sha512-aPKrOKaon5ncMSN5ec2O6yqiIotPQr0KsSchIC4pnTnebA30bq4D3hKk4g4JynZzSIqBRWhd2TnyZh1vvTXxEA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@date-fns/tz": {
|
||||
@@ -1059,15 +1059,14 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@hookform/resolvers": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-6.0.2.tgz",
|
||||
"integrity": "sha512-e2dqUOlaGuirKVOd/iZWk/Yg5zeaGw7t0hSc3LzxL3o7eZGwZkTyEbBL8H52m0wRKEsNLWoMGOiXGHbPHTXlEQ==",
|
||||
"version": "7.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-7.0.3.tgz",
|
||||
"integrity": "sha512-beGY1JY6K5cyWlswD4BVf7UTb5MxSjKkjljrRyLPT3KIQzsTU0jOibBKqtdcKqIZYQUUyURed7roLWZtSdAtiQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/utils": "^0.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@sinclair/typebox": ">=0.25.24",
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
"@vinejs/vine": "^2.0.0 || ^3.0.0 || ^4.0.0",
|
||||
"ajv": "^8.12.0",
|
||||
@@ -1087,15 +1086,13 @@
|
||||
"react-hook-form": "^7.55.0",
|
||||
"superstruct": ">=0.12.0",
|
||||
"typanion": "^3.3.2",
|
||||
"typebox": "^1.0.0",
|
||||
"valibot": ">=0.31.0 || ^1.0.0-beta.4 || ^1.0.0-rc",
|
||||
"vest": ">=6.0.0",
|
||||
"yup": "^1.0.0",
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@sinclair/typebox": {
|
||||
"optional": true
|
||||
},
|
||||
"@standard-schema/spec": {
|
||||
"optional": true
|
||||
},
|
||||
@@ -1150,6 +1147,9 @@
|
||||
"typanion": {
|
||||
"optional": true
|
||||
},
|
||||
"typebox": {
|
||||
"optional": true
|
||||
},
|
||||
"valibot": {
|
||||
"optional": true
|
||||
},
|
||||
@@ -1691,9 +1691,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.155.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.155.0.tgz",
|
||||
"integrity": "sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==",
|
||||
"version": "0.156.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.156.0.tgz",
|
||||
"integrity": "sha512-1Duyg48TdJNdyhEB8Xn1L36bT9Pv6GUtlKjHxOys8xkaF/rQq3XJE+bAurSK/AgIz5mGFsb77cn9Pb20+WCScw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -1714,13 +1714,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.64.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz",
|
||||
"integrity": "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==",
|
||||
"version": "1.65.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.65.0.tgz",
|
||||
"integrity": "sha512-iGURLGuKDLakSEl7Pgfv8h7f+XLT+V861Wfhz0zlEl5nyFuKUopdkFBPKqTllTUp06aOwPuH8iuUEb3yp3MpbQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.64.1"
|
||||
"playwright": "1.65.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
@@ -2903,47 +2903,47 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rjsf/core": {
|
||||
"version": "6.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@rjsf/core/-/core-6.10.1.tgz",
|
||||
"integrity": "sha512-LHLbXYEq1+c2CpynBlf5JBlUhPNxOkVV+txf1BvamgJSt4QOisXFw5Uv+mBdLRi4BI+FfT0ofMVOgGWnuwG7gw==",
|
||||
"version": "6.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@rjsf/core/-/core-6.11.1.tgz",
|
||||
"integrity": "sha512-2kH39lspUUQf552l+vSTj9vnwdv1gSOyDKqSDmFuTo16+92zbJ381knrQpk3KKowjp/sbxu4qPiBptgXH6i4UQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"markdown-to-jsx": "^9.8.2"
|
||||
"markdown-to-jsx": "^9.10.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@rjsf/utils": "^6.10.1",
|
||||
"@rjsf/utils": "^6.11.1",
|
||||
"react": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@rjsf/shadcn": {
|
||||
"version": "6.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@rjsf/shadcn/-/shadcn-6.10.1.tgz",
|
||||
"integrity": "sha512-URVLkYcf3V8hNo1ZvcwR9zdMx+2z2k5IhDpfmQqPqWrMvY469BL+Wgyp+P7PEF01JhlQA3GOB6EwfKTMTkpoAw==",
|
||||
"version": "6.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@rjsf/shadcn/-/shadcn-6.11.1.tgz",
|
||||
"integrity": "sha512-W00s4kLx5f4kerYtsE/kLLABqh3eg4Iih1D9llxKp0FDVnW6BEuJmKP8G9VRVphMdMQVkrZNeNBjlOE9Bc7ZDw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-checkbox": "^1.3.11",
|
||||
"@radix-ui/react-dialog": "^1.1.23",
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "^1.2.0",
|
||||
"@radix-ui/react-label": "^2.1.15",
|
||||
"@radix-ui/react-radio-group": "^1.4.7",
|
||||
"@radix-ui/react-separator": "^1.1.15",
|
||||
"@radix-ui/react-slider": "^1.4.7",
|
||||
"@radix-ui/react-slot": "^1.3.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"lucide-react": "^1.16.0",
|
||||
"tailwind-merge": "^3.6.0"
|
||||
"lucide-react": "^1.49.0",
|
||||
"tailwind-merge": "^3.7.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@rjsf/core": "^6.10.1",
|
||||
"@rjsf/utils": "^6.10.1",
|
||||
"@rjsf/core": "^6.11.1",
|
||||
"@rjsf/utils": "^6.11.1",
|
||||
"react": ">=18"
|
||||
}
|
||||
},
|
||||
@@ -2958,14 +2958,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rjsf/utils": {
|
||||
"version": "6.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@rjsf/utils/-/utils-6.10.1.tgz",
|
||||
"integrity": "sha512-dlbQUCoTl/+/TenyYs1zAKXf9sLTebtY+6pB+bUFsaN4xkdAtR8YBLwmpa1N5yNiRzf3fbxveISNuZmDrZE/yQ==",
|
||||
"version": "6.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@rjsf/utils/-/utils-6.11.1.tgz",
|
||||
"integrity": "sha512-rHQ4jNkhws3YUjvM/6RyW78l0mVfnt6w2j3/giUcS5F0cendCLYykFuSnE2fQxBwmknv4ANMOugwXOgcIbfvnA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@x0k/json-schema-merge": "^1.0.6",
|
||||
"fast-equals": "^6.0.0",
|
||||
"fast-uri": "^4.1.4",
|
||||
"@x0k/json-schema-merge": "^1.1.0",
|
||||
"fast-equals": "^6.0.4",
|
||||
"fast-uri": "^4.2.1",
|
||||
"jsonpointer": "^5.0.1",
|
||||
"react-is": "^18.3.1"
|
||||
},
|
||||
@@ -2977,9 +2977,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rjsf/utils/node_modules/fast-equals": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-6.0.0.tgz",
|
||||
"integrity": "sha512-PFhhIGgdM79r5Uztdj9Zb6Tt1zKafqVfdMGwVca1z5z6fbX7DmsySSuJd8HiP6I1j505DCS83cLxo5rmSNeVEA==",
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-6.2.2.tgz",
|
||||
"integrity": "sha512-n+XqoarWCgcar0O3om3DeNYx6xTuLWGTUF1bRZ9iuO00tsPepzP1L4V7rqtxxZ4PEya34UCESReimFvxmEVwIA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
@@ -3002,9 +3002,9 @@
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@rjsf/validator-ajv8": {
|
||||
"version": "6.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@rjsf/validator-ajv8/-/validator-ajv8-6.10.1.tgz",
|
||||
"integrity": "sha512-Jyim45UKd39SgWn4ryaydTigZ0dddNEeqZUfTlLgsy2v23uMDLyeKjNCItC09dy00dtKzwM3Q4WA0FtQ5IjOgA==",
|
||||
"version": "6.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@rjsf/validator-ajv8/-/validator-ajv8-6.11.1.tgz",
|
||||
"integrity": "sha512-LV7ibrep/kaRzkwzhLAmJY9SHOIZuX9fQO+0VCfm0FOGXneGU9xQEJvHft7R84uqtDTTQZqRq40HcQjqY0rsyw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"ajv": "^8.20.0",
|
||||
@@ -3014,13 +3014,13 @@
|
||||
"node": ">=20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@rjsf/utils": "^6.10.1"
|
||||
"@rjsf/utils": "^6.11.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm-eabi": {
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.15.tgz",
|
||||
"integrity": "sha512-aLcgK9gIJ3YRvrrtfSBbRa27F5WrpiTaPulnFpsHPxpUHgFlSgnU4854MHwoRGEBhfRJDWCS2WQ7lIjskE9Olw==",
|
||||
"version": "1.2.16",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.16.tgz",
|
||||
"integrity": "sha512-ia3DwAs0OsMGS7+IConptSiyk9pEXkOx/AbJ2n/lAGxH3QkWQus1t8d7gnAT38FCU2sQu8lUbeR6AiZFv4JxaA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -3035,9 +3035,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.15.tgz",
|
||||
"integrity": "sha512-xD7DdZCrbThKuxNg7ooQmvp07ys4J/pnSEMTG3/OdOWl6daC67m/Q4fL78sRHMxTH/N4fGUbYhcPM0MF5WFHkg==",
|
||||
"version": "1.2.16",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.16.tgz",
|
||||
"integrity": "sha512-kx5wBiMB6E7TEvpl2B9gjLU004BkvEm2Dr941BFjx/SontPwSzNuQUIoVKxHHoSjpFIfHMmcguyLOtyn0hObRg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -3052,9 +3052,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.15.tgz",
|
||||
"integrity": "sha512-m2WuQ6r0B4OTYxyPpNLtXaMRbRDUbG8YQV2Q3KTdSSK8Th6KHCnC8jVCJt8bvYeSB5WivkkNbc85mKadaSdOxA==",
|
||||
"version": "1.2.16",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.16.tgz",
|
||||
"integrity": "sha512-qslZrXnKJU4cUpjoAmtAo/KQM9e2IRMlmpig6Zpwj+8KWxW8v+nkAf9216Bsek9lOC1jvg2yCjygCFo2f83msQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -3069,9 +3069,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-x64": {
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.15.tgz",
|
||||
"integrity": "sha512-5RtnsHM36nodM9WbMy+zip0LGS/G+y5wro5RHrpOlr4ac24+1zSi9kiV2MP1myZeZ/ifEHfulQcWjnAXzc2C2A==",
|
||||
"version": "1.2.16",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.16.tgz",
|
||||
"integrity": "sha512-FdF+y48ND13eAyM2mQn+85kvAqNlvRWROy2rhgA463X/0Gv4vGtazZtfaF1mRBLscYvtOlh4QnLjOWiFzX69OQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -3086,9 +3086,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.15.tgz",
|
||||
"integrity": "sha512-g3l09pyFqhu+135JKkbkwiKGQmwU5hUMUPJ4VdBADvuqR8d33L6H578rh8m7rtxbdp+VmX/+nzI7nvhSZlzpUQ==",
|
||||
"version": "1.2.16",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.16.tgz",
|
||||
"integrity": "sha512-ddjqFM5e95AgLbUPtKIS7V1KTVlA0RUgCVr5mn+/IEhsdy3pr+Vuj/HZ7nlMN2FyxIwUktOH1LEPIQXO1v9y1Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -3103,9 +3103,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.15.tgz",
|
||||
"integrity": "sha512-nIe3VugI97nGQtspPxDqA4sXMUpeiocKIP/CqpY/u/vgbT+alX+cVxu1C/oVBPKg4feNTmYovE91v4DgXvXNwQ==",
|
||||
"version": "1.2.16",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.16.tgz",
|
||||
"integrity": "sha512-tHdKWbFkFBI687iim3LIyIvyL7j66VvQYkDPss5Stn/qyE2tWkR1YD0dXmXPFKKqT/o5ZEJVsaRsWS2meQwGOA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -3120,13 +3120,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.15.tgz",
|
||||
"integrity": "sha512-iTBjXx+B5QSvgI89lJfmqh4wTh8NabR1YZIIN3hNx4zSG0EVfVqkOvLulNpG4251pbd6/IAmZ/LefVk1Y4rPhw==",
|
||||
"version": "1.2.16",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.16.tgz",
|
||||
"integrity": "sha512-FJiLGAblPBMlBEdbOqw6kW6QcXDWAf/oJu7gYs9ZJZwmg+K+/zjFLxPTIY9izJUzxzc24FHvmseyA0IvJSl17w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3137,13 +3140,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.15.tgz",
|
||||
"integrity": "sha512-uN26XOnVR7rqQupG3V9oAG2EGGatWjcJHOeDOHUFCh4TBWmP2tHTFoVN13aYbwfm0aHqJAEmcwhAu6sooIjfqQ==",
|
||||
"version": "1.2.16",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.16.tgz",
|
||||
"integrity": "sha512-hrJpblMsrQ58U9veWpbXzkhV5zStunds6HWLprqq1qauvgBr1E6P+24kLj6lSLI9vjRn/RYpjprhkHQPKGWpNg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3154,13 +3160,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.15.tgz",
|
||||
"integrity": "sha512-9nWkcdd+H+XhaA3fQIwpHnKIrfx/PQ6rO0pivr78Og07tXlUlctBBJoEYLPfhTmCR/QxEVdn6uPjHV5SymPhdw==",
|
||||
"version": "1.2.16",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.16.tgz",
|
||||
"integrity": "sha512-qJd/dC6j6CwLG9/s/RP2nb1RZ0GIgj7Ioj7x/SYa3RzjVrlzrmH0fhIOpfvqQDeIsdP0loi+kHaMhJzY1PBIaA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3171,13 +3180,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.15.tgz",
|
||||
"integrity": "sha512-Ggh0ciFjLwIusLSLz7LZYEaGdxOAUJXI+Qu5JCbhPJJHEt8vP+IjRATTJheiJsdzzTYgUiHUiw3X1Ea3ZpMBdA==",
|
||||
"version": "1.2.16",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.16.tgz",
|
||||
"integrity": "sha512-Vx7Xg5uUJuNcujJjI70OICaH0EYj80BtOLZTSN7uOYg8QOLlyKnaG0IW7Fn/J0IpJsaKzmDKMhOQv1JBM9m9vg==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3188,13 +3200,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.15.tgz",
|
||||
"integrity": "sha512-FHg60rS6DBePAYZ9X9F67x+JObFdmgn6tupA1C/a3B+CufFmTD4mX+e8M75tCiAO3foR/6xwVBI0KQiI7sn/rw==",
|
||||
"version": "1.2.16",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.16.tgz",
|
||||
"integrity": "sha512-TNKhgE3uJTsTzatUSSYt0Kgo+Oa2uSlSjz4r1aEIea7HX6ULBym4fAlYUhG1i6pjNf2njfCUAfKwr6C3E9Xg1w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3205,13 +3220,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.15.tgz",
|
||||
"integrity": "sha512-/cvV/7v5VGz/moiz0XKRj2dE8DzsKyhIu1KpOvJGPjRywg3yscON889UGvAe8zfiJLHup7VLjBduoGWi3HusWQ==",
|
||||
"version": "1.2.16",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.16.tgz",
|
||||
"integrity": "sha512-7chXdpjXZ8qG0+mIxECN3cT9kvhZ3/oZhMX6Pp876P7CDrIy8nJswva6Mq357JR7eYXfuskwZxMeV1SzoZdHrA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3222,9 +3240,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.15.tgz",
|
||||
"integrity": "sha512-MyzC5Hrv/sm+Z7TSGKP/j8001RO2xQm1zOpCNVL4j0cY/VhBZviludA2YFQ7i4lH6ZAXxQm0lcMdBzM+99mE7w==",
|
||||
"version": "1.2.16",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.16.tgz",
|
||||
"integrity": "sha512-Kwjt+7uNTJK5/MDkGWGVO2KQPnj8yAV75dMpfwyHZrJSXWHrniOB0aUEWlVYf3H1hOqFLIStLdM5QXq6bnKmOQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -3239,9 +3257,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.15.tgz",
|
||||
"integrity": "sha512-+PgYiS+AH5omy6L45ZLxybasauJaj3M8buoT83D+nzPmCayXlxUV9hydhciCKVyIg0B/v0BtXPDE1uGqNwCPpg==",
|
||||
"version": "1.2.16",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.16.tgz",
|
||||
"integrity": "sha512-/LH6WfjDRXXJz/p5xl4TmU+jpcnwcxbKEQBW/GZE0JHfN0lGIyTtsUEGaQ/q/GikkGJ2yLKe6pwm7AnEWO8pdw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -3256,9 +3274,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.15.tgz",
|
||||
"integrity": "sha512-HFZd2QNQYn1JbIi+Hpd7H+r/lkWxcfIu28nirZ6eCn2LafuvmExMlb/P96Sfx5tf7pPArw39IdhmCF8vmV+NhQ==",
|
||||
"version": "1.2.16",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.16.tgz",
|
||||
"integrity": "sha512-1lv1KvX0Z871zfk7vw+dnXMMnHNyDwMzWG8Kv3HxDXMOhzCX4SHc7HK319fInP0i+0p9D8brkfzL7pJOaGl+tw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -3893,9 +3911,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react": {
|
||||
"version": "6.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.2.tgz",
|
||||
"integrity": "sha512-fhJNGUdscQa4HkI0xFxwonWyhnVReCPDbXI7FH7jmzpnVCrFQI7PRrhWecO263610vOrs9tr2Y4xcf8uAdFxbw==",
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.2.1.tgz",
|
||||
"integrity": "sha512-9KyWCij3r0+qLG3Q62vBmv0VyevmYibAtxaCIVK9o0k6CXgaajZRIe8tpfchQcdZArJ5b/7OrIz4W3b2uxVRDA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -3905,9 +3923,9 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"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",
|
||||
"oxc-transform-react": "^0.152.0",
|
||||
"oxc-transform-react": ">=0.152.0 <0.157.0",
|
||||
"vite": "^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
@@ -4063,9 +4081,9 @@
|
||||
"license": "SEE LICENSE IN LICENSE.md"
|
||||
},
|
||||
"node_modules/apexcharts": {
|
||||
"version": "8.6.0",
|
||||
"resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-8.6.0.tgz",
|
||||
"integrity": "sha512-5524MLoDei2n8EcVMK+u3eOZTWrTpT1BIPRWu4AK8kkJdkODWs1hp3IEax8pMz78sSNAtsD4Tp7tr0s0SW/npQ==",
|
||||
"version": "8.7.0",
|
||||
"resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-8.7.0.tgz",
|
||||
"integrity": "sha512-HHpMlM4QvLXFLL6hyn1iivF+3IyoCrBEXYcV9elJuT+Dc4At9P5+RAaSYA1onOrcXg59+VTAsnyUDdm4GRhUfA==",
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"dependencies": {
|
||||
"apex-commons": "^0.8.3"
|
||||
@@ -5578,12 +5596,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/framer-motion": {
|
||||
"version": "14.9.0",
|
||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-14.9.0.tgz",
|
||||
"integrity": "sha512-g1hZu8WoTwgsoVAaqEEcefhvR03cKGtkmrKaRrWpXhnM3LGR8jl1u2qyJ1PMVlO7LKQHE8TTRQYUlMmnrY4L+g==",
|
||||
"version": "14.10.0",
|
||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-14.10.0.tgz",
|
||||
"integrity": "sha512-e8MjIet+IJKohYxBj8TAk9Mg5cD0HiOX87qde1YTPL67gI2ao074O6yBtQnIcWCk+bTX7XX4litepufLiYPrLg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"motion-dom": "14.9.0",
|
||||
"motion-dom": "14.10.0",
|
||||
"motion-utils": "14.7.0",
|
||||
"tslib": "^2.4.0"
|
||||
},
|
||||
@@ -5786,9 +5804,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/globals": {
|
||||
"version": "17.13.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-17.13.0.tgz",
|
||||
"integrity": "sha512-RwMTC61u7hrG3ZJMkZQOK4JLJrKS8QtoTii63EmWvFXHqycY9FObBJQCbsLxSO8kjKhWE5kV1GC+Vb1g3RI0Zg==",
|
||||
"version": "17.14.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-17.14.0.tgz",
|
||||
"integrity": "sha512-UtdkTVyAJePQv0J2M9Xe4L0ruNSDV314XiIJsADI2Zf40bcdAonE/D/OhAU8AT99oij9mhLByb+NvA3QWZYgCg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -5991,9 +6009,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "26.5.12",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.5.12.tgz",
|
||||
"integrity": "sha512-vTt8tc3y5axoU7wwTYg8vjpILLyP33a5GyGJAVI7FDRx1XueYxZVIkcVeStoftrVnAM/o28hKvK0sslucDI9aQ==",
|
||||
"version": "26.7.2",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.7.2.tgz",
|
||||
"integrity": "sha512-RsOY3BeaCDdkk52NeJSFAXELQ2rQXBO/tOub6L8ue2ICPfR/FRDWDOfEDg4xMPd1SqO5goVYQ6i72qWEUB/PPw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -6106,9 +6124,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/i18next-http-backend": {
|
||||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-4.0.7.tgz",
|
||||
"integrity": "sha512-SQy3OPy66YzumNJ43boHDBtzzMISnoPhQ0Zqjetk+luW74Tok8Ez9ujJJbJHKSHkG5sjDGldHznjuPtSxBwvuw==",
|
||||
"version": "4.0.10",
|
||||
"resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-4.0.10.tgz",
|
||||
"integrity": "sha512-CfdQuwIVijbrS/fmiTAQYcYyje4nMbH9aIjKYhhm5bgVmWKWLOM1F7qrA1o63Jl1JxQcRjdtWhKlvDzyqV9aPg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -6585,9 +6603,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/konva": {
|
||||
"version": "10.9.0",
|
||||
"resolved": "https://registry.npmjs.org/konva/-/konva-10.9.0.tgz",
|
||||
"integrity": "sha512-3NOOlcbimFDp4+/DfXGTBkfn3cn3VP1BESPySQ6ayCzgMA9JSMGJNNxeIKw5jpHL6SAl6gHsrv10DUEjNRL0Qg==",
|
||||
"version": "10.9.1",
|
||||
"resolved": "https://registry.npmjs.org/konva/-/konva-10.9.1.tgz",
|
||||
"integrity": "sha512-m49x+GatSxEUSvqahYhxpTjgKbtrLtw4P4o4B4iux953ZFpceaK52Ea+CFsAsd7W4I0Y5u6mMoRUsY/eltNZmw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "patreon",
|
||||
@@ -6978,9 +6996,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "1.65.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.65.0.tgz",
|
||||
"integrity": "sha512-roSdtzudEY39i2hn5LLjA8toYfhF2z4S10WSqZF4ubXpHlOmNTvaxMxZLdJfQDi6pEifQWX+cRmYihKSsvAxfA==",
|
||||
"version": "1.70.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.70.0.tgz",
|
||||
"integrity": "sha512-gCTPPN1MlQUBtUgdsUv+0CAUj+X8hifRUiRBw4E5v7lvDIOIpx0EZezdGQMSimS8pKA6esvullJub4GYr0mCHA==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
@@ -7003,9 +7021,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/markdown-to-jsx": {
|
||||
"version": "9.10.2",
|
||||
"resolved": "https://registry.npmjs.org/markdown-to-jsx/-/markdown-to-jsx-9.10.2.tgz",
|
||||
"integrity": "sha512-iR9GadlIox0q1uXnpqdxpF02Vb1WDmZ/QIXWjBR5htzjUEEhyIWbM65LuVKavdwJaVo4q95C/F2OOyNjWe91ig==",
|
||||
"version": "9.10.5",
|
||||
"resolved": "https://registry.npmjs.org/markdown-to-jsx/-/markdown-to-jsx-9.10.5.tgz",
|
||||
"integrity": "sha512-m/OFdkxlmWY13o3R0Yy6STooQRzJ0Q5IqqDqQeHOb7pAA2LNgLplEI+jhDyMdxpdr1pceFH6ykrBWGD6oRvW/A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
@@ -8055,9 +8073,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/motion-dom": {
|
||||
"version": "14.9.0",
|
||||
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-14.9.0.tgz",
|
||||
"integrity": "sha512-wGjKbNDMHd75aLStkxLCKZ+T8/wusZHklmNLK1Ak68UrdBt2X8I4yweYR/0WonxGnBsw6oloDTIV8pN+UTvzzA==",
|
||||
"version": "14.10.0",
|
||||
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-14.10.0.tgz",
|
||||
"integrity": "sha512-k0LwJZ1R5xCvqrScszO4ywuZP52lRcT3QO0IUovgczom9ZHb0BaRLOX6M2O40q7TrSLvzX/fIHHmDm8TUMt3fg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"motion-utils": "14.7.0"
|
||||
@@ -8519,13 +8537,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.64.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz",
|
||||
"integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==",
|
||||
"version": "1.65.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.65.0.tgz",
|
||||
"integrity": "sha512-F3AMWcR44qMBt4kq+6Kb6mX7bjGXshktKFOPULd7reED6ZJWevjbNRqysP7q+AATE9kdPEZBibex1BYsvpVoeA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.64.1"
|
||||
"playwright-core": "1.65.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
@@ -8535,9 +8553,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.64.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz",
|
||||
"integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==",
|
||||
"version": "1.65.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.65.0.tgz",
|
||||
"integrity": "sha512-vNSKgH6YqtN7cdT/L334zo0a+YwkrpicyrHJlWWPIm/Q+Nn2SWInil01oT0mbu96U4GgoRtCYQD9CzJQgju+lA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@@ -9059,9 +9077,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-i18next": {
|
||||
"version": "17.1.9",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.1.9.tgz",
|
||||
"integrity": "sha512-OgYqIH13XHPlWTaaI795Dz+k2AdKnXcOxNQVnf750T8Ij0TYWp1nYvXRpoJBulPqlyk2OAbn1PM4tFKiIj/byA==",
|
||||
"version": "17.1.13",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.1.13.tgz",
|
||||
"integrity": "sha512-ZxhOSlyKSKPtbUNPLwYMmE3w3bhoc0jaZJZ6bc7wGOT6cLbrbs//6m01HxCLtFdSjHvFpwFwoPhxxzIlHSs9cw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.29.7",
|
||||
@@ -9477,13 +9495,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.15.tgz",
|
||||
"integrity": "sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g==",
|
||||
"version": "1.2.16",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.16.tgz",
|
||||
"integrity": "sha512-bWvlMpFKiTfDKfnK7FC2MPgPeWCf/Kpwsq49ApdW+FQaBZb6O6dZ4MAPbPUjp7hYQrw/RobqCzn64BRrQecGHw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "=0.155.0",
|
||||
"@oxc-project/types": "=0.156.0",
|
||||
"@rolldown/pluginutils": "^1.0.0"
|
||||
},
|
||||
"bin": {
|
||||
@@ -9493,21 +9511,21 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rolldown/binding-android-arm-eabi": "1.2.15",
|
||||
"@rolldown/binding-android-arm64": "1.2.15",
|
||||
"@rolldown/binding-darwin-arm64": "1.2.15",
|
||||
"@rolldown/binding-darwin-x64": "1.2.15",
|
||||
"@rolldown/binding-freebsd-x64": "1.2.15",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.2.15",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.2.15",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.2.15",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.2.15",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.2.15",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.2.15",
|
||||
"@rolldown/binding-linux-x64-musl": "1.2.15",
|
||||
"@rolldown/binding-openharmony-arm64": "1.2.15",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.2.15",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.2.15"
|
||||
"@rolldown/binding-android-arm-eabi": "1.2.16",
|
||||
"@rolldown/binding-android-arm64": "1.2.16",
|
||||
"@rolldown/binding-darwin-arm64": "1.2.16",
|
||||
"@rolldown/binding-darwin-x64": "1.2.16",
|
||||
"@rolldown/binding-freebsd-x64": "1.2.16",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.2.16",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.2.16",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.2.16",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.2.16",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.2.16",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.2.16",
|
||||
"@rolldown/binding-linux-x64-musl": "1.2.16",
|
||||
"@rolldown/binding-openharmony-arm64": "1.2.16",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.2.16",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.2.16"
|
||||
}
|
||||
},
|
||||
"node_modules/run-async": {
|
||||
@@ -10364,9 +10382,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/virtua": {
|
||||
"version": "0.54.0",
|
||||
"resolved": "https://registry.npmjs.org/virtua/-/virtua-0.54.0.tgz",
|
||||
"integrity": "sha512-8nGGrDhX61MDVeqgyIaWrJqhRm9z2Nqa1WDqt0tk1m+ZWrvLesMP0iL0vL6xNsLCF2jE2ohwY+PqufdbVmRgEw==",
|
||||
"version": "0.54.2",
|
||||
"resolved": "https://registry.npmjs.org/virtua/-/virtua-0.54.2.tgz",
|
||||
"integrity": "sha512-lYCAxyXCGkhXVvuKv8a+LklZquy0E9dIObYcOUyo6nP4VSvLeiIsiz94mA3ZKYuM5egkke5RIi2nXi5OIATc7A==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@angular/common": ">=20.0.0",
|
||||
@@ -10402,16 +10420,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.3.5",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz",
|
||||
"integrity": "sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==",
|
||||
"version": "8.3.6",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.6.tgz",
|
||||
"integrity": "sha512-UI7dK29NnS54Fk+mmo7o+jI6U8Fp7hDl+X5Q1k45WH+d4dSlbLVUETGQ+hfkNgvLQGyoElPj837nZyv4MBc0VA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.33.0",
|
||||
"picomatch": "^4.0.7",
|
||||
"postcss": "^8.5.30",
|
||||
"rolldown": "~1.2.14",
|
||||
"postcss": "^8.5.32",
|
||||
"rolldown": "~1.2.16",
|
||||
"tinyglobby": "^0.2.17"
|
||||
},
|
||||
"bin": {
|
||||
|
||||
+17
-17
@@ -21,8 +21,8 @@
|
||||
"i18n:status": "i18next-cli status"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cycjimmy/jsmpeg-player": "^6.1.8",
|
||||
"@hookform/resolvers": "^6.0.2",
|
||||
"@cycjimmy/jsmpeg-player": "^7.0.0",
|
||||
"@hookform/resolvers": "^7.0.3",
|
||||
"@radix-ui/react-alert-dialog": "^1.2.1",
|
||||
"@radix-ui/react-aspect-ratio": "^1.2.0",
|
||||
"@radix-ui/react-checkbox": "^1.4.1",
|
||||
@@ -45,11 +45,11 @@
|
||||
"@radix-ui/react-toggle": "^1.2.1",
|
||||
"@radix-ui/react-toggle-group": "^1.2.1",
|
||||
"@radix-ui/react-tooltip": "^1.4.1",
|
||||
"@rjsf/core": "^6.10.1",
|
||||
"@rjsf/shadcn": "^6.10.1",
|
||||
"@rjsf/core": "^6.11.1",
|
||||
"@rjsf/shadcn": "^6.11.1",
|
||||
"@rjsf/utils": "^6.10.1",
|
||||
"@rjsf/validator-ajv8": "^6.10.1",
|
||||
"apexcharts": "^8.6.0",
|
||||
"@rjsf/validator-ajv8": "^6.11.1",
|
||||
"apexcharts": "^8.7.0",
|
||||
"axios": "^1.21.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -57,15 +57,15 @@
|
||||
"copy-to-clipboard": "^4.0.2",
|
||||
"date-fns": "^4.4.0",
|
||||
"date-fns-tz": "^3.2.0",
|
||||
"framer-motion": "^14.9.0",
|
||||
"framer-motion": "^14.10.0",
|
||||
"hls.js": "^1.7.4",
|
||||
"i18next": "^26.5.12",
|
||||
"i18next-http-backend": "^4.0.7",
|
||||
"i18next": "^26.7.2",
|
||||
"i18next-http-backend": "^4.0.10",
|
||||
"idb-keyval": "^6.3.0",
|
||||
"js-yaml": "^5.5.0",
|
||||
"konva": "^10.9.0",
|
||||
"konva": "^10.9.1",
|
||||
"lodash": "^4.18.1",
|
||||
"lucide-react": "^1.65.0",
|
||||
"lucide-react": "^1.70.0",
|
||||
"monaco-yaml": "^5.5.1",
|
||||
"next-themes": "^0.4.6",
|
||||
"nosleep.js": "^0.12.0",
|
||||
@@ -77,7 +77,7 @@
|
||||
"react-dropzone": "^20.1.4",
|
||||
"react-grid-layout": "^2.3.0",
|
||||
"react-hook-form": "^7.91.0",
|
||||
"react-i18next": "^17.1.9",
|
||||
"react-i18next": "^17.1.13",
|
||||
"react-icons": "^5.7.0",
|
||||
"react-konva": "^19.3.2",
|
||||
"react-markdown": "^10.1.0",
|
||||
@@ -93,19 +93,19 @@
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"use-long-press": "^3.3.0",
|
||||
"vaul": "^1.1.2",
|
||||
"virtua": "^0.54.0",
|
||||
"virtua": "^0.54.2",
|
||||
"vite-plugin-monaco-editor": "^1.1.0",
|
||||
"zod": "^4.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@playwright/test": "^1.64.1",
|
||||
"@playwright/test": "^1.65.0",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/lodash": "^4.17.27",
|
||||
"@types/node": "^26.6.6",
|
||||
"@types/react": "^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",
|
||||
"esbuild": "^0.28.2",
|
||||
"eslint": "^10.14.0",
|
||||
@@ -113,7 +113,7 @@
|
||||
"eslint-plugin-prettier": "^5.5.7",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.7",
|
||||
"globals": "^17.13.0",
|
||||
"globals": "^17.14.0",
|
||||
"i18next-cli": "^1.5.11",
|
||||
"monaco-editor": "^0.53.0",
|
||||
"patch-package": "^8.0.1",
|
||||
@@ -123,7 +123,7 @@
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.72.1",
|
||||
"vite": "^8.3.5"
|
||||
"vite": "^8.3.6"
|
||||
},
|
||||
"overrides": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
|
||||
@@ -1285,8 +1285,13 @@
|
||||
},
|
||||
"apiKey": {
|
||||
"title": "Frigate+ API Key",
|
||||
"validated": "Frigate+ API key is detected and validated",
|
||||
"notValidated": "Frigate+ API key is not detected or not validated",
|
||||
"formatValid": "Frigate+ API key is detected and matches the expected format",
|
||||
"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.",
|
||||
"plusLink": "Read more about Frigate+"
|
||||
},
|
||||
|
||||
@@ -169,7 +169,7 @@ export function GenAIModelWidget(props: WidgetProps) {
|
||||
|
||||
if (previous === formProvider) return;
|
||||
if (formProvider === savedProvider && value === savedModel) return;
|
||||
if (typeof value === "string" && value) onChange("");
|
||||
if (typeof value === "string" && value) onChange(undefined);
|
||||
}, [formProvider, savedProvider, savedModel, value, onChange]);
|
||||
|
||||
const probe = async () => {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useEffect } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import useSWR from "swr";
|
||||
import { CheckCircle2, XCircle } from "lucide-react";
|
||||
import axios from "axios";
|
||||
import { AlertTriangle, CheckCircle2, XCircle } from "lucide-react";
|
||||
import { LuExternalLink } from "react-icons/lu";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -18,12 +19,34 @@ import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { isReplayCamera } from "@/utils/cameraUtil";
|
||||
import type { SettingsPageProps } from "@/views/settings/SingleSectionPage";
|
||||
import { getPrimaryModel } from "@/utils/modelUtil";
|
||||
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||
|
||||
type PlusKeyStatus = "valid" | "invalid" | "unreachable" | "not_set";
|
||||
|
||||
type PlusValidateResponse = { success: boolean; status: PlusKeyStatus };
|
||||
|
||||
export default function FrigatePlusSettingsView(_props: SettingsPageProps) {
|
||||
const { t } = useTranslation("views/settings");
|
||||
const { getLocaleDocUrl } = useDocDomain();
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const navigate = useNavigate();
|
||||
const isAdmin = useIsAdmin();
|
||||
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
const [keyStatus, setKeyStatus] = useState<PlusKeyStatus>();
|
||||
|
||||
const validateKey = useCallback(async () => {
|
||||
setIsValidating(true);
|
||||
|
||||
try {
|
||||
const response = await axios.post<PlusValidateResponse>("plus/validate");
|
||||
setKeyStatus(response.data.status);
|
||||
} catch {
|
||||
setKeyStatus("unreachable");
|
||||
} finally {
|
||||
setIsValidating(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = t("documentTitle.frigatePlus");
|
||||
@@ -68,17 +91,55 @@ export default function FrigatePlusSettingsView(_props: SettingsPageProps) {
|
||||
</>
|
||||
}
|
||||
content={
|
||||
<div className="flex items-center gap-2">
|
||||
{config?.plus?.enabled ? (
|
||||
<CheckCircle2 className="h-5 w-5 text-green-500" />
|
||||
) : (
|
||||
<XCircle className="h-5 w-5 text-red-500" />
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{config?.plus?.enabled ? (
|
||||
<CheckCircle2 className="size-5 shrink-0 text-green-500" />
|
||||
) : (
|
||||
<XCircle className="size-5 shrink-0 text-red-500" />
|
||||
)}
|
||||
<span className="text-sm">
|
||||
{config?.plus?.enabled
|
||||
? t("frigatePlus.apiKey.formatValid")
|
||||
: t("frigatePlus.apiKey.formatInvalid")}
|
||||
</span>
|
||||
</div>
|
||||
{config?.plus?.enabled && isAdmin && (
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={isValidating}
|
||||
onClick={validateKey}
|
||||
aria-label={t("frigatePlus.apiKey.validate")}
|
||||
>
|
||||
{isValidating && (
|
||||
<ActivityIndicator className="mr-2" size={16} />
|
||||
)}
|
||||
{isValidating
|
||||
? t("frigatePlus.apiKey.validating")
|
||||
: t("frigatePlus.apiKey.validate")}
|
||||
</Button>
|
||||
{keyStatus && !isValidating && (
|
||||
<div className="flex items-center gap-2">
|
||||
{keyStatus === "valid" ? (
|
||||
<CheckCircle2 className="size-5 shrink-0 text-green-500" />
|
||||
) : keyStatus === "unreachable" ? (
|
||||
<AlertTriangle className="size-5 shrink-0 text-yellow-500" />
|
||||
) : (
|
||||
<XCircle className="size-5 shrink-0 text-red-500" />
|
||||
)}
|
||||
<span className="text-sm">
|
||||
{keyStatus === "valid"
|
||||
? t("frigatePlus.apiKey.serverValid")
|
||||
: keyStatus === "unreachable"
|
||||
? t("frigatePlus.apiKey.serverUnreachable")
|
||||
: t("frigatePlus.apiKey.serverInvalid")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<span className="text-sm">
|
||||
{config?.plus?.enabled
|
||||
? t("frigatePlus.apiKey.validated")
|
||||
: t("frigatePlus.apiKey.notValidated")}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user