mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-07-21 03:09:02 +03:00
Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
416a9b7692 | ||
|
|
d11c26970d | ||
|
|
e78da2758d | ||
|
|
ae9b307dfc | ||
|
|
01c16a9250 | ||
|
|
d4731c1dea | ||
|
|
65ca90db20 | ||
|
|
3ec2305e6a | ||
|
|
8b035be132 | ||
|
|
be79ad89b6 | ||
|
|
b147b53522 | ||
|
|
d2b2faa2d7 | ||
|
|
614a6b39d4 | ||
|
|
f29ee53fb4 | ||
|
|
544d3c6139 | ||
|
|
104e623923 | ||
|
|
59fc8449ed | ||
|
|
19480867fb | ||
|
|
1188d87588 | ||
|
|
c4a5ac0e77 | ||
|
|
41e290449e | ||
|
|
b6f78bd1f2 | ||
|
|
bde518e861 |
@@ -55,7 +55,7 @@ function setup_homekit_config() {
|
||||
|
||||
if [[ ! -f "${config_path}" ]]; then
|
||||
echo "[INFO] Creating empty config file for HomeKit..."
|
||||
echo '{}' > "${config_path}"
|
||||
: > "${config_path}"
|
||||
fi
|
||||
|
||||
# Convert YAML to JSON for jq processing
|
||||
@@ -65,23 +65,25 @@ function setup_homekit_config() {
|
||||
return 0
|
||||
}
|
||||
|
||||
# Use jq to filter and keep only the homekit section
|
||||
local cleaned_json="/tmp/cache/homekit_cleaned.json"
|
||||
jq '
|
||||
# Keep only the homekit section if it exists, otherwise empty object
|
||||
if has("homekit") then {homekit: .homekit} else {} end
|
||||
' "${temp_json}" > "${cleaned_json}" 2>/dev/null || {
|
||||
echo '{}' > "${cleaned_json}"
|
||||
}
|
||||
# Use jq to extract the homekit section, if it exists
|
||||
local homekit_json
|
||||
homekit_json=$(jq '
|
||||
if has("homekit") then {homekit: .homekit} else null end
|
||||
' "${temp_json}" 2>/dev/null) || homekit_json="null"
|
||||
|
||||
# Convert back to YAML and write to the config file
|
||||
yq eval -P "${cleaned_json}" > "${config_path}" 2>/dev/null || {
|
||||
echo "[WARNING] Failed to convert cleaned config to YAML, creating minimal config"
|
||||
echo '{}' > "${config_path}"
|
||||
}
|
||||
# If no homekit section, write an empty config file
|
||||
if [[ "${homekit_json}" == "null" ]]; then
|
||||
: > "${config_path}"
|
||||
else
|
||||
# Convert homekit JSON back to YAML and write to the config file
|
||||
echo "${homekit_json}" | yq eval -P - > "${config_path}" 2>/dev/null || {
|
||||
echo "[WARNING] Failed to convert cleaned config to YAML, creating minimal config"
|
||||
: > "${config_path}"
|
||||
}
|
||||
fi
|
||||
|
||||
# Clean up temp files
|
||||
rm -f "${temp_json}" "${cleaned_json}"
|
||||
rm -f "${temp_json}"
|
||||
}
|
||||
|
||||
set_libva_version
|
||||
|
||||
@@ -44,13 +44,21 @@ go2rtc:
|
||||
|
||||
### `environment_vars`
|
||||
|
||||
This section can be used to set environment variables for those unable to modify the environment of the container, like within Home Assistant OS.
|
||||
This section can be used to set environment variables for those unable to modify the environment of the container, like within Home Assistant OS. Docker users should set environment variables in their `docker run` command (`-e FRIGATE_MQTT_PASSWORD=secret`) or `docker-compose.yml` file (`environment:` section) instead. Note that values set here are stored in plain text in your config file, so if the goal is to keep credentials out of your configuration, use Docker environment variables or Docker secrets instead.
|
||||
|
||||
Variables prefixed with `FRIGATE_` can be referenced in config fields that support environment variable substitution (such as MQTT host and credentials, camera stream URLs, and ONVIF host and credentials) using the `{FRIGATE_VARIABLE_NAME}` syntax.
|
||||
|
||||
Example:
|
||||
|
||||
```yaml
|
||||
environment_vars:
|
||||
VARIABLE_NAME: variable_value
|
||||
FRIGATE_MQTT_USER: my_mqtt_user
|
||||
FRIGATE_MQTT_PASSWORD: my_mqtt_password
|
||||
|
||||
mqtt:
|
||||
host: "{FRIGATE_MQTT_HOST}"
|
||||
user: "{FRIGATE_MQTT_USER}"
|
||||
password: "{FRIGATE_MQTT_PASSWORD}"
|
||||
```
|
||||
|
||||
#### TensorFlow Thread Configuration
|
||||
|
||||
@@ -232,7 +232,7 @@ The viewer role provides read-only access to all cameras in the UI and API. Cust
|
||||
|
||||
### Role Configuration Example
|
||||
|
||||
```yaml
|
||||
```yaml {11-16}
|
||||
cameras:
|
||||
front_door:
|
||||
# ... camera config
|
||||
|
||||
@@ -24,7 +24,7 @@ A custom icon can be added to the birdseye background by providing a 180x180 ima
|
||||
|
||||
If you want to include a camera in Birdseye view only for specific circumstances, or just don't include it at all, the Birdseye setting can be set at the camera level.
|
||||
|
||||
```yaml
|
||||
```yaml {8-10,12-14}
|
||||
# Include all cameras by default in Birdseye view
|
||||
birdseye:
|
||||
enabled: True
|
||||
@@ -48,6 +48,7 @@ By default birdseye shows all cameras that have had the configured activity in t
|
||||
```yaml
|
||||
birdseye:
|
||||
enabled: True
|
||||
# highlight-next-line
|
||||
inactivity_threshold: 15
|
||||
```
|
||||
|
||||
@@ -78,9 +79,11 @@ birdseye:
|
||||
cameras:
|
||||
front:
|
||||
birdseye:
|
||||
# highlight-next-line
|
||||
order: 1
|
||||
back:
|
||||
birdseye:
|
||||
# highlight-next-line
|
||||
order: 2
|
||||
```
|
||||
|
||||
@@ -92,7 +95,7 @@ It is possible to limit the number of cameras shown on birdseye at one time. Whe
|
||||
|
||||
For example, this can be configured to only show the most recently active camera.
|
||||
|
||||
```yaml
|
||||
```yaml {3-4}
|
||||
birdseye:
|
||||
enabled: True
|
||||
layout:
|
||||
@@ -103,7 +106,7 @@ birdseye:
|
||||
|
||||
By default birdseye tries to fit 2 cameras in each row and then double in size until a suitable layout is found. The scaling can be configured with a value between 1.0 and 5.0 depending on use case.
|
||||
|
||||
```yaml
|
||||
```yaml {3-4}
|
||||
birdseye:
|
||||
enabled: True
|
||||
layout:
|
||||
|
||||
@@ -23,6 +23,7 @@ Some cameras support h265 with different formats, but Safari only supports the a
|
||||
cameras:
|
||||
h265_cam: # <------ Doesn't matter what the camera is called
|
||||
ffmpeg:
|
||||
# highlight-next-line
|
||||
apple_compatibility: true # <- Adds compatibility with MacOS and iPhone
|
||||
```
|
||||
|
||||
@@ -30,7 +31,7 @@ cameras:
|
||||
|
||||
Note that mjpeg cameras require encoding the video into h264 for recording, and restream roles. This will use significantly more CPU than if the cameras supported h264 feeds directly. It is recommended to use the restream role to create an h264 restream and then use that as the source for ffmpeg.
|
||||
|
||||
```yaml
|
||||
```yaml {3,10}
|
||||
go2rtc:
|
||||
streams:
|
||||
mjpeg_cam: "ffmpeg:http://your_mjpeg_stream_url#video=h264#hardware" # <- use hardware acceleration to create an h264 stream usable for other components.
|
||||
@@ -96,6 +97,7 @@ This camera is H.265 only. To be able to play clips on some devices (like MacOs
|
||||
cameras:
|
||||
annkec800: # <------ Name the camera
|
||||
ffmpeg:
|
||||
# highlight-next-line
|
||||
apple_compatibility: true # <- Adds compatibility with MacOS and iPhone
|
||||
output_args:
|
||||
record: preset-record-generic-audio-aac
|
||||
@@ -274,7 +276,7 @@ To use a USB camera (webcam) with Frigate, the recommendation is to use go2rtc's
|
||||
|
||||
- In your Frigate Configuration File, add the go2rtc stream and roles as appropriate:
|
||||
|
||||
```
|
||||
```yaml {4,11-12}
|
||||
go2rtc:
|
||||
streams:
|
||||
usb_camera:
|
||||
|
||||
@@ -66,7 +66,7 @@ Not every PTZ supports ONVIF, which is the standard protocol Frigate uses to com
|
||||
|
||||
Add the onvif section to your camera in your configuration file:
|
||||
|
||||
```yaml
|
||||
```yaml {4-8}
|
||||
cameras:
|
||||
back:
|
||||
ffmpeg: ...
|
||||
|
||||
@@ -118,6 +118,7 @@ Enable debug logs for classification models by adding `frigate.data_processing.r
|
||||
logger:
|
||||
default: info
|
||||
logs:
|
||||
# highlight-next-line
|
||||
frigate.data_processing.real_time.custom_classification: debug
|
||||
```
|
||||
|
||||
|
||||
@@ -85,6 +85,7 @@ Enable debug logs for classification models by adding `frigate.data_processing.r
|
||||
logger:
|
||||
default: info
|
||||
logs:
|
||||
# highlight-next-line
|
||||
frigate.data_processing.real_time.custom_classification: debug
|
||||
```
|
||||
|
||||
|
||||
@@ -109,7 +109,7 @@ genai:
|
||||
|
||||
To use a different Gemini-compatible API endpoint, set the `provider_options` with the `base_url` key to your provider's API URL. For example:
|
||||
|
||||
```
|
||||
```yaml {4,5}
|
||||
genai:
|
||||
provider: gemini
|
||||
...
|
||||
@@ -152,7 +152,7 @@ To use a different OpenAI-compatible API endpoint, set the `OPENAI_BASE_URL` env
|
||||
|
||||
For OpenAI-compatible servers (such as llama.cpp) that don't expose the configured context size in the API response, you can manually specify the context size in `provider_options`:
|
||||
|
||||
```yaml
|
||||
```yaml {5,6}
|
||||
genai:
|
||||
provider: openai
|
||||
base_url: http://your-llama-server
|
||||
|
||||
@@ -80,6 +80,7 @@ By default, review summaries use preview images (cached preview frames) which ha
|
||||
review:
|
||||
genai:
|
||||
enabled: true
|
||||
# highlight-next-line
|
||||
image_source: recordings # Options: "preview" (default) or "recordings"
|
||||
```
|
||||
|
||||
@@ -104,7 +105,7 @@ If recordings are not available for a given time period, the system will automat
|
||||
|
||||
Along with the concern of suspicious activity or immediate threat, you may have concerns such as animals in your garden or a gate being left open. These concerns can be configured so that the review summaries will make note of them if the activity requires additional review. For example:
|
||||
|
||||
```yaml
|
||||
```yaml {4,5}
|
||||
review:
|
||||
genai:
|
||||
enabled: true
|
||||
@@ -116,7 +117,7 @@ review:
|
||||
|
||||
By default, review summaries are generated in English. You can configure Frigate to generate summaries in your preferred language by setting the `preferred_language` option:
|
||||
|
||||
```yaml
|
||||
```yaml {4}
|
||||
review:
|
||||
genai:
|
||||
enabled: true
|
||||
|
||||
@@ -117,12 +117,13 @@ services:
|
||||
frigate:
|
||||
...
|
||||
image: ghcr.io/blakeblackshear/frigate:stable
|
||||
# highlight-next-line
|
||||
privileged: true
|
||||
```
|
||||
|
||||
##### Docker Run CLI - Privileged
|
||||
|
||||
```bash
|
||||
```bash {4}
|
||||
docker run -d \
|
||||
--name frigate \
|
||||
...
|
||||
@@ -136,7 +137,7 @@ Only recent versions of Docker support the `CAP_PERFMON` capability. You can tes
|
||||
|
||||
##### Docker Compose - CAP_PERFMON
|
||||
|
||||
```yaml
|
||||
```yaml {5,6}
|
||||
services:
|
||||
frigate:
|
||||
...
|
||||
@@ -147,7 +148,7 @@ services:
|
||||
|
||||
##### Docker Run CLI - CAP_PERFMON
|
||||
|
||||
```bash
|
||||
```bash {4}
|
||||
docker run -d \
|
||||
--name frigate \
|
||||
...
|
||||
@@ -214,7 +215,7 @@ Additional configuration is needed for the Docker container to be able to access
|
||||
|
||||
#### Docker Compose - Nvidia GPU
|
||||
|
||||
```yaml
|
||||
```yaml {5-12}
|
||||
services:
|
||||
frigate:
|
||||
...
|
||||
@@ -231,7 +232,7 @@ services:
|
||||
|
||||
#### Docker Run CLI - Nvidia GPU
|
||||
|
||||
```bash
|
||||
```bash {4}
|
||||
docker run -d \
|
||||
--name frigate \
|
||||
...
|
||||
@@ -310,7 +311,7 @@ ffmpeg:
|
||||
If running Frigate through Docker, you either need to run in privileged mode or
|
||||
map the `/dev/video*` devices to Frigate. With Docker Compose add:
|
||||
|
||||
```yaml
|
||||
```yaml {4-5}
|
||||
services:
|
||||
frigate:
|
||||
...
|
||||
@@ -320,7 +321,7 @@ services:
|
||||
|
||||
Or with `docker run`:
|
||||
|
||||
```bash
|
||||
```bash {4}
|
||||
docker run -d \
|
||||
--name frigate \
|
||||
...
|
||||
@@ -352,7 +353,7 @@ You will need to use the image with the nvidia container runtime:
|
||||
|
||||
### Docker Run CLI - Jetson
|
||||
|
||||
```bash
|
||||
```bash {3}
|
||||
docker run -d \
|
||||
...
|
||||
--runtime nvidia
|
||||
@@ -361,7 +362,7 @@ docker run -d \
|
||||
|
||||
### Docker Compose - Jetson
|
||||
|
||||
```yaml
|
||||
```yaml {5}
|
||||
services:
|
||||
frigate:
|
||||
...
|
||||
@@ -452,14 +453,14 @@ Restarting ffmpeg...
|
||||
|
||||
you should try to uprade to FFmpeg 7. This can be done using this config option:
|
||||
|
||||
```
|
||||
```yaml
|
||||
ffmpeg:
|
||||
path: "7.0"
|
||||
```
|
||||
|
||||
You can set this option globally to use FFmpeg 7 for all cameras or on camera level to use it only for specific cameras. Do not confuse this option with:
|
||||
|
||||
```
|
||||
```yaml
|
||||
cameras:
|
||||
name:
|
||||
ffmpeg:
|
||||
@@ -481,7 +482,7 @@ Make sure to follow the [Synaptics specific installation instructions](/frigate/
|
||||
|
||||
Add one of the following FFmpeg presets to your `config.yml` to enable hardware video processing:
|
||||
|
||||
```yaml
|
||||
```yaml {2}
|
||||
ffmpeg:
|
||||
hwaccel_args: -c:v h264_v4l2m2m
|
||||
input_args: preset-rtsp-restream
|
||||
|
||||
@@ -50,6 +50,7 @@ Frigate supports the use of environment variables starting with `FRIGATE_` **onl
|
||||
|
||||
```yaml
|
||||
mqtt:
|
||||
host: "{FRIGATE_MQTT_HOST}"
|
||||
user: "{FRIGATE_MQTT_USER}"
|
||||
password: "{FRIGATE_MQTT_PASSWORD}"
|
||||
```
|
||||
@@ -60,7 +61,7 @@ mqtt:
|
||||
|
||||
```yaml
|
||||
onvif:
|
||||
host: 10.0.10.10
|
||||
host: "192.168.1.12"
|
||||
port: 8000
|
||||
user: "{FRIGATE_RTSP_USER}"
|
||||
password: "{FRIGATE_RTSP_PASSWORD}"
|
||||
|
||||
@@ -43,7 +43,7 @@ lpr:
|
||||
|
||||
Like other enrichments in Frigate, LPR **must be enabled globally** to use the feature. You should disable it for specific cameras at the camera level if you don't want to run LPR on cars on those cameras:
|
||||
|
||||
```yaml
|
||||
```yaml {4,5}
|
||||
cameras:
|
||||
garage:
|
||||
...
|
||||
@@ -391,6 +391,7 @@ Start with ["Why isn't my license plate being detected and recognized?"](#why-is
|
||||
logger:
|
||||
default: info
|
||||
logs:
|
||||
# highlight-next-line
|
||||
frigate.data_processing.common.license_plate: debug
|
||||
```
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ Configure the `streams` option with a "friendly name" for your stream followed b
|
||||
|
||||
Using Frigate's internal version of go2rtc is required to use this feature. You cannot specify paths in the `streams` configuration, only go2rtc stream names.
|
||||
|
||||
```yaml
|
||||
```yaml {3,6,8,25-29}
|
||||
go2rtc:
|
||||
streams:
|
||||
test_cam:
|
||||
@@ -116,7 +116,7 @@ WebRTC works by creating a TCP or UDP connection on port `8555`. However, it req
|
||||
- For external access, over the internet, setup your router to forward port `8555` to port `8555` on the Frigate device, for both TCP and UDP.
|
||||
- For internal/local access, unless you are running through the HA App, you will also need to set the WebRTC candidates list in the go2rtc config. For example, if `192.168.1.10` is the local IP of the device running Frigate:
|
||||
|
||||
```yaml title="config.yml"
|
||||
```yaml title="config.yml" {4-7}
|
||||
go2rtc:
|
||||
streams:
|
||||
test_cam: ...
|
||||
@@ -154,7 +154,7 @@ If not running in host mode, port 8555 will need to be mapped for the container:
|
||||
|
||||
docker-compose.yml
|
||||
|
||||
```yaml
|
||||
```yaml {4-6}
|
||||
services:
|
||||
frigate:
|
||||
...
|
||||
|
||||
@@ -572,7 +572,7 @@ $ docker run --device=/dev/kfd --device=/dev/dri \
|
||||
|
||||
When using Docker Compose:
|
||||
|
||||
```yaml
|
||||
```yaml {4-6}
|
||||
services:
|
||||
frigate:
|
||||
...
|
||||
@@ -603,7 +603,7 @@ $ docker run -e HSA_OVERRIDE_GFX_VERSION=10.0.0 \
|
||||
|
||||
When using Docker Compose:
|
||||
|
||||
```yaml
|
||||
```yaml {4-5}
|
||||
services:
|
||||
frigate:
|
||||
...
|
||||
|
||||
@@ -130,7 +130,7 @@ When exporting a time-lapse the default speed-up is 25x with 30 FPS. This means
|
||||
|
||||
To configure the speed-up factor, the frame rate and further custom settings, the configuration parameter `timelapse_args` can be used. The below configuration example would change the time-lapse speed to 60x (for fitting 1 hour of recording into 1 minute of time-lapse) with 25 FPS:
|
||||
|
||||
```yaml
|
||||
```yaml {3-4}
|
||||
record:
|
||||
enabled: True
|
||||
export:
|
||||
|
||||
@@ -16,6 +16,8 @@ mqtt:
|
||||
# Optional: Enable mqtt server (default: shown below)
|
||||
enabled: True
|
||||
# Required: host name
|
||||
# NOTE: MQTT host can be specified with an environment variable or docker secrets that must begin with 'FRIGATE_'.
|
||||
# e.g. host: '{FRIGATE_MQTT_HOST}'
|
||||
host: mqtt.server.com
|
||||
# Optional: port (default: shown below)
|
||||
port: 1883
|
||||
@@ -906,6 +908,8 @@ cameras:
|
||||
onvif:
|
||||
# Required: host of the camera being connected to.
|
||||
# NOTE: HTTP is assumed by default; HTTPS is supported if you specify the scheme, ex: "https://0.0.0.0".
|
||||
# NOTE: ONVIF user, and password can be specified with environment variables or docker secrets
|
||||
# that must begin with 'FRIGATE_'. e.g. host: '{FRIGATE_ONVIF_USERNAME}'
|
||||
host: 0.0.0.0
|
||||
# Optional: ONVIF port for device (default: shown below).
|
||||
port: 8000
|
||||
|
||||
@@ -34,7 +34,7 @@ To improve connection speed when using Birdseye via restream you can enable a sm
|
||||
|
||||
The go2rtc restream can be secured with RTSP based username / password authentication. Ex:
|
||||
|
||||
```yaml
|
||||
```yaml {2-4}
|
||||
go2rtc:
|
||||
rtsp:
|
||||
username: "admin"
|
||||
@@ -147,6 +147,7 @@ For example:
|
||||
```yaml
|
||||
go2rtc:
|
||||
streams:
|
||||
# highlight-error-line
|
||||
my_camera: rtsp://username:$@foo%@192.168.1.100
|
||||
```
|
||||
|
||||
@@ -155,6 +156,7 @@ becomes
|
||||
```yaml
|
||||
go2rtc:
|
||||
streams:
|
||||
# highlight-next-line
|
||||
my_camera: rtsp://username:$%40foo%25@192.168.1.100
|
||||
```
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ To exclude a specific camera from alerts or detections, simply provide an empty
|
||||
|
||||
For example, to exclude objects on the camera _gatecamera_ from any detections, include this in your config:
|
||||
|
||||
```yaml
|
||||
```yaml {3-5}
|
||||
cameras:
|
||||
gatecamera:
|
||||
review:
|
||||
|
||||
@@ -20,7 +20,7 @@ tls:
|
||||
|
||||
TLS certificates can be mounted at `/etc/letsencrypt/live/frigate` using a bind mount or docker volume.
|
||||
|
||||
```yaml
|
||||
```yaml {3-4}
|
||||
frigate:
|
||||
...
|
||||
volumes:
|
||||
@@ -32,7 +32,7 @@ Within the folder, the private key is expected to be named `privkey.pem` and the
|
||||
|
||||
Note that certbot uses symlinks, and those can't be followed by the container unless it has access to the targets as well, so if using certbot you'll also have to mount the `archive` folder for your domain, e.g.:
|
||||
|
||||
```yaml
|
||||
```yaml {3-5}
|
||||
frigate:
|
||||
...
|
||||
volumes:
|
||||
@@ -46,7 +46,7 @@ Frigate automatically compares the fingerprint of the certificate at `/etc/letse
|
||||
|
||||
If you issue Frigate valid certificates you will likely want to configure it to run on port 443 so you can access it without a port number like `https://your-frigate-domain.com` by mapping 8971 to 443.
|
||||
|
||||
```yaml
|
||||
```yaml {3-4}
|
||||
frigate:
|
||||
...
|
||||
ports:
|
||||
|
||||
@@ -18,7 +18,7 @@ To create a zone, follow [the steps for a "Motion mask"](masks.md), but use the
|
||||
|
||||
Often you will only want alerts to be created when an object enters areas of interest. This is done using zones along with setting required_zones. Let's say you only want to have an alert created when an object enters your entire_yard zone, the config would be:
|
||||
|
||||
```yaml
|
||||
```yaml {6,8}
|
||||
cameras:
|
||||
name_of_your_camera:
|
||||
review:
|
||||
@@ -104,6 +104,7 @@ cameras:
|
||||
name_of_your_camera:
|
||||
zones:
|
||||
sidewalk:
|
||||
# highlight-next-line
|
||||
loitering_time: 4 # unit is in seconds
|
||||
objects:
|
||||
- person
|
||||
@@ -118,6 +119,7 @@ cameras:
|
||||
name_of_your_camera:
|
||||
zones:
|
||||
front_yard:
|
||||
# highlight-next-line
|
||||
inertia: 3
|
||||
objects:
|
||||
- person
|
||||
@@ -130,6 +132,7 @@ cameras:
|
||||
name_of_your_camera:
|
||||
zones:
|
||||
driveway_entrance:
|
||||
# highlight-next-line
|
||||
inertia: 1
|
||||
objects:
|
||||
- car
|
||||
@@ -192,5 +195,6 @@ cameras:
|
||||
coordinates: ...
|
||||
distances: ...
|
||||
inertia: 1
|
||||
# highlight-next-line
|
||||
speed_threshold: 20 # unit is in kph or mph, depending on how unit_system is set (see above)
|
||||
```
|
||||
|
||||
@@ -89,6 +89,14 @@ After closing VS Code, you may still have containers running. To close everythin
|
||||
|
||||
### Testing
|
||||
|
||||
#### Unit Tests
|
||||
|
||||
GitHub will execute unit tests on new PRs. You must ensure that all tests pass.
|
||||
|
||||
```shell
|
||||
python3 -u -m unittest
|
||||
```
|
||||
|
||||
#### FFMPEG Hardware Acceleration
|
||||
|
||||
The following commands are used inside the container to ensure hardware acceleration is working properly.
|
||||
@@ -125,6 +133,28 @@ ffmpeg -hwaccel vaapi -hwaccel_device /dev/dri/renderD128 -hwaccel_output_format
|
||||
ffmpeg -c:v h264_qsv -re -stream_loop -1 -i https://streams.videolan.org/ffmpeg/incoming/720p60.mp4 -f rawvideo -pix_fmt yuv420p pipe: > /dev/null
|
||||
```
|
||||
|
||||
### Submitting a pull request
|
||||
|
||||
Code must be formatted, linted and type-tested. GitHub will run these checks on pull requests, so it is advised to run them yourself prior to opening.
|
||||
|
||||
**Formatting**
|
||||
|
||||
```shell
|
||||
ruff format frigate migrations docker *.py
|
||||
```
|
||||
|
||||
**Linting**
|
||||
|
||||
```shell
|
||||
ruff check frigate migrations docker *.py
|
||||
```
|
||||
|
||||
**MyPy Static Typing**
|
||||
|
||||
```shell
|
||||
python3 -u -m mypy --config-file frigate/mypy.ini frigate
|
||||
```
|
||||
|
||||
## Web Interface
|
||||
|
||||
### Prerequisites
|
||||
|
||||
@@ -150,7 +150,7 @@ Here is an example configuration with hardware acceleration configured to work w
|
||||
|
||||
`docker-compose.yml` (after modifying, you will need to run `docker compose up -d` to apply changes)
|
||||
|
||||
```yaml
|
||||
```yaml {4,5}
|
||||
services:
|
||||
frigate:
|
||||
...
|
||||
@@ -168,6 +168,7 @@ cameras:
|
||||
name_of_your_camera:
|
||||
ffmpeg:
|
||||
inputs: ...
|
||||
# highlight-next-line
|
||||
hwaccel_args: preset-vaapi
|
||||
detect: ...
|
||||
```
|
||||
@@ -183,7 +184,7 @@ In many cases, the integrated graphics on Intel CPUs provides sufficient perform
|
||||
|
||||
You need to refer to **Configure hardware acceleration** above to enable the container to use the GPU.
|
||||
|
||||
```yaml
|
||||
```yaml {3-6,9-15,20-21}
|
||||
mqtt: ...
|
||||
|
||||
detectors: # <---- add detectors
|
||||
@@ -217,7 +218,7 @@ If you have a USB Coral, you will need to add a detectors section to your config
|
||||
|
||||
`docker-compose.yml` (after modifying, you will need to run `docker compose up -d` to apply changes)
|
||||
|
||||
```yaml
|
||||
```yaml {4-6}
|
||||
services:
|
||||
frigate:
|
||||
...
|
||||
@@ -227,7 +228,7 @@ services:
|
||||
...
|
||||
```
|
||||
|
||||
```yaml
|
||||
```yaml {3-6,11-12}
|
||||
mqtt: ...
|
||||
|
||||
detectors: # <---- add detectors
|
||||
@@ -263,7 +264,7 @@ Note that motion masks should not be used to mark out areas where you do not wan
|
||||
|
||||
Your configuration should look similar to this now.
|
||||
|
||||
```yaml
|
||||
```yaml {16-18}
|
||||
mqtt:
|
||||
enabled: False
|
||||
|
||||
@@ -290,7 +291,7 @@ In order to review activity in the Frigate UI, recordings need to be enabled.
|
||||
|
||||
To enable recording video, add the `record` role to a stream and enable it in the config. If record is disabled in the config, it won't be possible to enable it in the UI.
|
||||
|
||||
```yaml
|
||||
```yaml {16-17}
|
||||
mqtt: ...
|
||||
|
||||
detectors: ...
|
||||
|
||||
@@ -42,3 +42,7 @@ This is a fork (with fixed errors and new features) of [original Double Take](ht
|
||||
## [Scrypted - Frigate bridge plugin](https://github.com/apocaliss92/scrypted-frigate-bridge)
|
||||
|
||||
[Scrypted - Frigate bridge](https://github.com/apocaliss92/scrypted-frigate-bridge) is an plugin that allows to ingest Frigate detections, motion, videoclips on Scrypted as well as provide templates to export rebroadcast configurations on Frigate.
|
||||
|
||||
## [Strix](https://github.com/eduard256/Strix)
|
||||
|
||||
[Strix](https://github.com/eduard256/Strix) auto-discovers working stream URLs for IP cameras and generates ready-to-use Frigate configs. It tests thousands of URL patterns against your camera and supports cameras without RTSP or ONVIF. 67K+ camera models from 3.6K+ brands.
|
||||
|
||||
@@ -83,6 +83,17 @@ const config: Config = {
|
||||
},
|
||||
},
|
||||
prism: {
|
||||
magicComments:[
|
||||
{
|
||||
className: 'theme-code-block-highlighted-line',
|
||||
line: 'highlight-next-line',
|
||||
block: {start: 'highlight-start', end: 'highlight-end'},
|
||||
},
|
||||
{
|
||||
className: 'code-block-error-line',
|
||||
line: 'highlight-error-line',
|
||||
},
|
||||
],
|
||||
additionalLanguages: ["bash", "json"],
|
||||
},
|
||||
languageTabs: [
|
||||
|
||||
@@ -234,3 +234,11 @@
|
||||
content: "schema";
|
||||
color: var(--ifm-color-secondary-contrast-foreground);
|
||||
}
|
||||
|
||||
.code-block-error-line {
|
||||
background-color: #ff000020;
|
||||
display: block;
|
||||
margin: 0 calc(-1 * var(--ifm-pre-padding));
|
||||
padding: 0 var(--ifm-pre-padding);
|
||||
border-left: 3px solid #ff000080;
|
||||
}
|
||||
+17
-3
@@ -218,7 +218,7 @@ def config_raw_paths(request: Request):
|
||||
return JSONResponse(content=raw_paths)
|
||||
|
||||
|
||||
@router.get("/config/raw", dependencies=[Depends(allow_any_authenticated())])
|
||||
@router.get("/config/raw", dependencies=[Depends(require_role(["admin"]))])
|
||||
def config_raw():
|
||||
config_file = find_config_file()
|
||||
|
||||
@@ -732,7 +732,12 @@ def get_recognized_license_plates(
|
||||
|
||||
|
||||
@router.get("/timeline", dependencies=[Depends(allow_any_authenticated())])
|
||||
def timeline(camera: str = "all", limit: int = 100, source_id: Optional[str] = None):
|
||||
def timeline(
|
||||
camera: str = "all",
|
||||
limit: int = 100,
|
||||
source_id: Optional[str] = None,
|
||||
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
|
||||
):
|
||||
clauses = []
|
||||
|
||||
selected_columns = [
|
||||
@@ -754,6 +759,9 @@ def timeline(camera: str = "all", limit: int = 100, source_id: Optional[str] = N
|
||||
else:
|
||||
clauses.append((Timeline.source_id.in_(source_ids)))
|
||||
|
||||
# Enforce per-camera access control
|
||||
clauses.append((Timeline.camera << allowed_cameras))
|
||||
|
||||
if len(clauses) == 0:
|
||||
clauses.append((True))
|
||||
|
||||
@@ -769,7 +777,10 @@ def timeline(camera: str = "all", limit: int = 100, source_id: Optional[str] = N
|
||||
|
||||
|
||||
@router.get("/timeline/hourly", dependencies=[Depends(allow_any_authenticated())])
|
||||
def hourly_timeline(params: AppTimelineHourlyQueryParameters = Depends()):
|
||||
def hourly_timeline(
|
||||
params: AppTimelineHourlyQueryParameters = Depends(),
|
||||
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
|
||||
):
|
||||
"""Get hourly summary for timeline."""
|
||||
cameras = params.cameras
|
||||
labels = params.labels
|
||||
@@ -787,6 +798,9 @@ def hourly_timeline(params: AppTimelineHourlyQueryParameters = Depends()):
|
||||
camera_list = cameras.split(",")
|
||||
clauses.append((Timeline.camera << camera_list))
|
||||
|
||||
# Enforce per-camera access control
|
||||
clauses.append((Timeline.camera << allowed_cameras))
|
||||
|
||||
if labels != "all":
|
||||
label_list = labels.split(",")
|
||||
clauses.append((Timeline.data["label"] << label_list))
|
||||
|
||||
+3
-1
@@ -67,7 +67,6 @@ def require_admin_by_default():
|
||||
"/stats",
|
||||
"/stats/history",
|
||||
"/config",
|
||||
"/config/raw",
|
||||
"/vainfo",
|
||||
"/nvinfo",
|
||||
"/labels",
|
||||
@@ -837,6 +836,7 @@ def create_user(
|
||||
User.notification_tokens: [],
|
||||
}
|
||||
).execute()
|
||||
request.app.config_publisher.publisher.publish("config/auth", None)
|
||||
return JSONResponse(content={"username": body.username})
|
||||
|
||||
|
||||
@@ -854,6 +854,7 @@ def delete_user(request: Request, username: str):
|
||||
)
|
||||
|
||||
User.delete_by_id(username)
|
||||
request.app.config_publisher.publisher.publish("config/auth", None)
|
||||
return JSONResponse(content={"success": True})
|
||||
|
||||
|
||||
@@ -973,6 +974,7 @@ async def update_role(
|
||||
)
|
||||
|
||||
User.set_by_id(username, {User.role: body.role})
|
||||
request.app.config_publisher.publisher.publish("config/auth", None)
|
||||
return JSONResponse(content={"success": True})
|
||||
|
||||
|
||||
|
||||
+58
-39
@@ -1142,7 +1142,6 @@ async def event_snapshot(
|
||||
|
||||
@router.get(
|
||||
"/events/{event_id}/thumbnail.{extension}",
|
||||
dependencies=[Depends(require_camera_access)],
|
||||
)
|
||||
async def event_thumbnail(
|
||||
request: Request,
|
||||
@@ -1186,11 +1185,12 @@ async def event_thumbnail(
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
img_as_np = np.frombuffer(thumbnail_bytes, dtype=np.uint8)
|
||||
img = cv2.imdecode(img_as_np, flags=1)
|
||||
|
||||
# android notifications prefer a 2:1 ratio
|
||||
if format == "android":
|
||||
img_as_np = np.frombuffer(thumbnail_bytes, dtype=np.uint8)
|
||||
img = cv2.imdecode(img_as_np, flags=1)
|
||||
thumbnail = cv2.copyMakeBorder(
|
||||
img = cv2.copyMakeBorder(
|
||||
img,
|
||||
0,
|
||||
0,
|
||||
@@ -1200,14 +1200,14 @@ async def event_thumbnail(
|
||||
(0, 0, 0),
|
||||
)
|
||||
|
||||
quality_params = None
|
||||
if extension in (Extension.jpg, Extension.jpeg):
|
||||
quality_params = [int(cv2.IMWRITE_JPEG_QUALITY), 70]
|
||||
elif extension == Extension.webp:
|
||||
quality_params = [int(cv2.IMWRITE_WEBP_QUALITY), 60]
|
||||
quality_params = None
|
||||
if extension in (Extension.jpg, Extension.jpeg):
|
||||
quality_params = [int(cv2.IMWRITE_JPEG_QUALITY), 70]
|
||||
elif extension == Extension.webp:
|
||||
quality_params = [int(cv2.IMWRITE_WEBP_QUALITY), 60]
|
||||
|
||||
_, img = cv2.imencode(f".{extension.value}", thumbnail, quality_params)
|
||||
thumbnail_bytes = img.tobytes()
|
||||
_, encoded = cv2.imencode(f".{extension.value}", img, quality_params)
|
||||
thumbnail_bytes = encoded.tobytes()
|
||||
|
||||
return Response(
|
||||
thumbnail_bytes,
|
||||
@@ -1343,12 +1343,12 @@ def grid_snapshot(
|
||||
|
||||
@router.get(
|
||||
"/events/{event_id}/snapshot-clean.webp",
|
||||
dependencies=[Depends(require_camera_access)],
|
||||
)
|
||||
def event_snapshot_clean(request: Request, event_id: str, download: bool = False):
|
||||
async def event_snapshot_clean(request: Request, event_id: str, download: bool = False):
|
||||
webp_bytes = None
|
||||
try:
|
||||
event = Event.get(Event.id == event_id)
|
||||
await require_camera_access(event.camera, request=request)
|
||||
snapshot_config = request.app.frigate_config.cameras[event.camera].snapshots
|
||||
if not (snapshot_config.enabled and event.has_snapshot):
|
||||
return JSONResponse(
|
||||
@@ -1469,7 +1469,7 @@ def event_snapshot_clean(request: Request, event_id: str, download: bool = False
|
||||
|
||||
|
||||
@router.get(
|
||||
"/events/{event_id}/clip.mp4", dependencies=[Depends(require_camera_access)]
|
||||
"/events/{event_id}/clip.mp4",
|
||||
)
|
||||
async def event_clip(
|
||||
request: Request,
|
||||
@@ -1483,6 +1483,8 @@ async def event_clip(
|
||||
content={"success": False, "message": "Event not found"}, status_code=404
|
||||
)
|
||||
|
||||
await require_camera_access(event.camera, request=request)
|
||||
|
||||
if not event.has_clip:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Clip not available"}, status_code=404
|
||||
@@ -1499,9 +1501,9 @@ async def event_clip(
|
||||
|
||||
|
||||
@router.get(
|
||||
"/events/{event_id}/preview.gif", dependencies=[Depends(require_camera_access)]
|
||||
"/events/{event_id}/preview.gif",
|
||||
)
|
||||
def event_preview(request: Request, event_id: str):
|
||||
async def event_preview(request: Request, event_id: str):
|
||||
try:
|
||||
event: Event = Event.get(Event.id == event_id)
|
||||
except DoesNotExist:
|
||||
@@ -1509,6 +1511,8 @@ def event_preview(request: Request, event_id: str):
|
||||
content={"success": False, "message": "Event not found"}, status_code=404
|
||||
)
|
||||
|
||||
await require_camera_access(event.camera, request=request)
|
||||
|
||||
start_ts = event.start_time
|
||||
end_ts = start_ts + (
|
||||
min(event.end_time - event.start_time, 20) if event.end_time else 20
|
||||
@@ -1531,25 +1535,25 @@ def preview_gif(
|
||||
):
|
||||
if datetime.fromtimestamp(start_ts) < datetime.now().replace(minute=0, second=0):
|
||||
# has preview mp4
|
||||
preview: Previews = (
|
||||
Previews.select(
|
||||
Previews.camera,
|
||||
Previews.path,
|
||||
Previews.duration,
|
||||
Previews.start_time,
|
||||
Previews.end_time,
|
||||
try:
|
||||
preview: Previews = (
|
||||
Previews.select(
|
||||
Previews.camera,
|
||||
Previews.path,
|
||||
Previews.duration,
|
||||
Previews.start_time,
|
||||
Previews.end_time,
|
||||
)
|
||||
.where(
|
||||
Previews.start_time.between(start_ts, end_ts)
|
||||
| Previews.end_time.between(start_ts, end_ts)
|
||||
| ((start_ts > Previews.start_time) & (end_ts < Previews.end_time))
|
||||
)
|
||||
.where(Previews.camera == camera_name)
|
||||
.limit(1)
|
||||
.get()
|
||||
)
|
||||
.where(
|
||||
Previews.start_time.between(start_ts, end_ts)
|
||||
| Previews.end_time.between(start_ts, end_ts)
|
||||
| ((start_ts > Previews.start_time) & (end_ts < Previews.end_time))
|
||||
)
|
||||
.where(Previews.camera == camera_name)
|
||||
.limit(1)
|
||||
.get()
|
||||
)
|
||||
|
||||
if not preview:
|
||||
except DoesNotExist:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Preview not found"},
|
||||
status_code=404,
|
||||
@@ -1853,8 +1857,8 @@ def preview_mp4(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/review/{event_id}/preview", dependencies=[Depends(require_camera_access)])
|
||||
def review_preview(
|
||||
@router.get("/review/{event_id}/preview")
|
||||
async def review_preview(
|
||||
request: Request,
|
||||
event_id: str,
|
||||
format: str = Query(default="gif", enum=["gif", "mp4"]),
|
||||
@@ -1867,6 +1871,8 @@ def review_preview(
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
await require_camera_access(review.camera, request=request)
|
||||
|
||||
padding = 8
|
||||
start_ts = review.start_time - padding
|
||||
end_ts = (
|
||||
@@ -1880,12 +1886,14 @@ def review_preview(
|
||||
|
||||
|
||||
@router.get(
|
||||
"/preview/{file_name}/thumbnail.jpg", dependencies=[Depends(require_camera_access)]
|
||||
"/preview/{file_name}/thumbnail.jpg",
|
||||
dependencies=[Depends(allow_any_authenticated())],
|
||||
)
|
||||
@router.get(
|
||||
"/preview/{file_name}/thumbnail.webp", dependencies=[Depends(require_camera_access)]
|
||||
"/preview/{file_name}/thumbnail.webp",
|
||||
dependencies=[Depends(allow_any_authenticated())],
|
||||
)
|
||||
def preview_thumbnail(file_name: str):
|
||||
async def preview_thumbnail(request: Request, file_name: str):
|
||||
"""Get a thumbnail from the cached preview frames."""
|
||||
if len(file_name) > 1000:
|
||||
return JSONResponse(
|
||||
@@ -1895,6 +1903,17 @@ def preview_thumbnail(file_name: str):
|
||||
status_code=403,
|
||||
)
|
||||
|
||||
# Extract camera name from preview filename (format: preview_{camera}-{timestamp}.ext)
|
||||
if not file_name.startswith("preview_"):
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Invalid preview filename"},
|
||||
status_code=400,
|
||||
)
|
||||
# Use rsplit to handle camera names containing dashes (e.g. front-door)
|
||||
name_part = file_name[len("preview_") :].rsplit(".", 1)[0] # strip extension
|
||||
camera_name = name_part.rsplit("-", 1)[0] # split off timestamp
|
||||
await require_camera_access(camera_name, request=request)
|
||||
|
||||
safe_file_name_current = sanitize_filename(file_name)
|
||||
preview_dir = os.path.join(CACHE_DIR, "preview_frames")
|
||||
|
||||
|
||||
+59
-11
@@ -17,6 +17,7 @@ from titlecase import titlecase
|
||||
from frigate.comms.base_communicator import Communicator
|
||||
from frigate.comms.config_updater import ConfigSubscriber
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.config.auth import AuthConfig
|
||||
from frigate.config.camera.updater import (
|
||||
CameraConfigUpdateEnum,
|
||||
CameraConfigUpdateSubscriber,
|
||||
@@ -58,6 +59,7 @@ class WebPushClient(Communicator):
|
||||
for c in self.config.cameras.values()
|
||||
}
|
||||
self.last_notification_time: float = 0
|
||||
self.user_cameras: dict[str, set[str]] = {}
|
||||
self.notification_queue: queue.Queue[PushNotification] = queue.Queue()
|
||||
self.notification_thread = threading.Thread(
|
||||
target=self._process_notifications, daemon=True
|
||||
@@ -78,13 +80,12 @@ class WebPushClient(Communicator):
|
||||
for sub in user["notification_tokens"]:
|
||||
self.web_pushers[user["username"]].append(WebPusher(sub))
|
||||
|
||||
# notification config updater
|
||||
self.global_config_subscriber = ConfigSubscriber(
|
||||
"config/notifications", exact=True
|
||||
)
|
||||
# notification and auth config updater
|
||||
self.global_config_subscriber = ConfigSubscriber("config/")
|
||||
self.config_subscriber = CameraConfigUpdateSubscriber(
|
||||
self.config, self.config.cameras, [CameraConfigUpdateEnum.notifications]
|
||||
)
|
||||
self._refresh_user_cameras()
|
||||
|
||||
def subscribe(self, receiver: Callable) -> None:
|
||||
"""Wrapper for allowing dispatcher to subscribe."""
|
||||
@@ -164,13 +165,19 @@ class WebPushClient(Communicator):
|
||||
|
||||
def publish(self, topic: str, payload: Any, retain: bool = False) -> None:
|
||||
"""Wrapper for publishing when client is in valid state."""
|
||||
# check for updated notification config
|
||||
_, updated_notification_config = (
|
||||
self.global_config_subscriber.check_for_update()
|
||||
)
|
||||
|
||||
if updated_notification_config:
|
||||
self.config.notifications = updated_notification_config
|
||||
# check for updated global config (notifications, auth)
|
||||
while True:
|
||||
config_topic, config_payload = (
|
||||
self.global_config_subscriber.check_for_update()
|
||||
)
|
||||
if config_topic is None:
|
||||
break
|
||||
if config_topic == "config/notifications" and config_payload:
|
||||
self.config.notifications = config_payload
|
||||
elif config_topic == "config/auth":
|
||||
if isinstance(config_payload, AuthConfig):
|
||||
self.config.auth = config_payload
|
||||
self._refresh_user_cameras()
|
||||
|
||||
updates = self.config_subscriber.check_for_updates()
|
||||
|
||||
@@ -291,6 +298,31 @@ class WebPushClient(Communicator):
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing notification: {str(e)}")
|
||||
|
||||
def _refresh_user_cameras(self) -> None:
|
||||
"""Rebuild the user-to-cameras access cache from the database."""
|
||||
all_camera_names = set(self.config.cameras.keys())
|
||||
roles_dict = self.config.auth.roles
|
||||
updated: dict[str, set[str]] = {}
|
||||
for user in User.select(User.username, User.role).dicts().iterator():
|
||||
allowed = User.get_allowed_cameras(
|
||||
user["role"], roles_dict, all_camera_names
|
||||
)
|
||||
updated[user["username"]] = set(allowed)
|
||||
logger.debug(
|
||||
"User %s has access to cameras: %s",
|
||||
user["username"],
|
||||
", ".join(allowed),
|
||||
)
|
||||
self.user_cameras = updated
|
||||
|
||||
def _user_has_camera_access(self, username: str, camera: str) -> bool:
|
||||
"""Check if a user has access to a specific camera based on cached roles."""
|
||||
allowed = self.user_cameras.get(username)
|
||||
if allowed is None:
|
||||
logger.debug(f"No camera access information found for user {username}")
|
||||
return False
|
||||
return camera in allowed
|
||||
|
||||
def _within_cooldown(self, camera: str) -> bool:
|
||||
now = datetime.datetime.now().timestamp()
|
||||
if now - self.last_notification_time < self.config.notifications.cooldown:
|
||||
@@ -418,6 +450,14 @@ class WebPushClient(Communicator):
|
||||
logger.debug(f"Sending push notification for {camera}, review ID {reviewId}")
|
||||
|
||||
for user in self.web_pushers:
|
||||
if not self._user_has_camera_access(user, camera):
|
||||
logger.debug(
|
||||
"Skipping notification for user %s - no access to camera %s",
|
||||
user,
|
||||
camera,
|
||||
)
|
||||
continue
|
||||
|
||||
self.send_push_notification(
|
||||
user=user,
|
||||
payload=payload,
|
||||
@@ -465,6 +505,14 @@ class WebPushClient(Communicator):
|
||||
)
|
||||
|
||||
for user in self.web_pushers:
|
||||
if not self._user_has_camera_access(user, camera):
|
||||
logger.debug(
|
||||
"Skipping notification for user %s - no access to camera %s",
|
||||
user,
|
||||
camera,
|
||||
)
|
||||
continue
|
||||
|
||||
self.send_push_notification(
|
||||
user=user,
|
||||
payload=payload,
|
||||
|
||||
@@ -72,7 +72,7 @@ class PtzAutotrackConfig(FrigateBaseModel):
|
||||
|
||||
|
||||
class OnvifConfig(FrigateBaseModel):
|
||||
host: str = Field(default="", title="Onvif Host")
|
||||
host: EnvString = Field(default="", title="Onvif Host")
|
||||
port: int = Field(default=8000, title="Onvif Port")
|
||||
user: Optional[EnvString] = Field(default=None, title="Onvif Username")
|
||||
password: Optional[EnvString] = Field(default=None, title="Onvif Password")
|
||||
|
||||
@@ -24,8 +24,10 @@ EnvString = Annotated[str, AfterValidator(validate_env_string)]
|
||||
|
||||
def validate_env_vars(v: dict[str, str], info: ValidationInfo) -> dict[str, str]:
|
||||
if isinstance(info.context, dict) and info.context.get("install", False):
|
||||
for k, v in v.items():
|
||||
os.environ[k] = v
|
||||
for k, val in v.items():
|
||||
os.environ[k] = val
|
||||
if k.startswith("FRIGATE_"):
|
||||
FRIGATE_ENV_VARS[k] = val
|
||||
|
||||
return v
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ __all__ = ["MqttConfig"]
|
||||
|
||||
class MqttConfig(FrigateBaseModel):
|
||||
enabled: bool = Field(default=True, title="Enable MQTT Communication.")
|
||||
host: str = Field(default="", title="MQTT Host")
|
||||
host: EnvString = Field(default="", title="MQTT Host")
|
||||
port: int = Field(default=1883, title="MQTT Port")
|
||||
topic_prefix: str = Field(default="frigate", title="MQTT Topic Prefix")
|
||||
client_id: str = Field(default="frigate", title="MQTT Client ID")
|
||||
|
||||
@@ -463,6 +463,13 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
||||
thumbs = []
|
||||
for idx, thumb_path in enumerate(frame_paths):
|
||||
thumb_data = cv2.imread(thumb_path)
|
||||
|
||||
if thumb_data is None:
|
||||
logger.warning(
|
||||
"Could not read preview frame at %s, skipping", thumb_path
|
||||
)
|
||||
continue
|
||||
|
||||
ret, jpg = cv2.imencode(
|
||||
".jpg", thumb_data, [int(cv2.IMWRITE_JPEG_QUALITY), 100]
|
||||
)
|
||||
|
||||
@@ -321,6 +321,9 @@ class AudioEventMaintainer(threading.Thread):
|
||||
self.start_or_restart_ffmpeg()
|
||||
|
||||
while not self.stop_event.is_set():
|
||||
# check if there is an updated config
|
||||
self.config_subscriber.check_for_updates()
|
||||
|
||||
enabled = self.camera_config.enabled
|
||||
if enabled != self.was_enabled:
|
||||
if enabled:
|
||||
@@ -347,9 +350,6 @@ class AudioEventMaintainer(threading.Thread):
|
||||
time.sleep(0.1)
|
||||
continue
|
||||
|
||||
# check if there is an updated config
|
||||
self.config_subscriber.check_for_updates()
|
||||
|
||||
self.read_audio()
|
||||
|
||||
if self.audio_listener:
|
||||
|
||||
@@ -324,6 +324,10 @@ class EventCleanup(threading.Thread):
|
||||
return events_to_update
|
||||
|
||||
def run(self) -> None:
|
||||
if self.config.safe_mode:
|
||||
logger.info("Safe mode enabled, skipping event cleanup")
|
||||
return
|
||||
|
||||
# only expire events every 5 minutes
|
||||
while not self.stop_event.wait(300):
|
||||
events_with_expired_clips = self.expire_clips()
|
||||
|
||||
@@ -350,6 +350,10 @@ class RecordingCleanup(threading.Thread):
|
||||
logger.debug("End expire recordings.")
|
||||
|
||||
def run(self) -> None:
|
||||
if self.config.safe_mode:
|
||||
logger.info("Safe mode enabled, skipping recording cleanup")
|
||||
return
|
||||
|
||||
# on startup sync recordings with disk if enabled
|
||||
if self.config.record.sync_recordings:
|
||||
sync_recordings(limited=False)
|
||||
|
||||
@@ -272,6 +272,10 @@ class StorageMaintainer(threading.Thread):
|
||||
|
||||
def run(self):
|
||||
"""Check every 5 minutes if storage needs to be cleaned up."""
|
||||
if self.config.safe_mode:
|
||||
logger.info("Safe mode enabled, skipping storage maintenance")
|
||||
return
|
||||
|
||||
self.calculate_camera_bandwidth()
|
||||
while not self.stop_event.wait(300):
|
||||
if not self.camera_storage_stats or True in [
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Tests for environment variable handling."""
|
||||
|
||||
import os
|
||||
import unittest
|
||||
|
||||
from frigate.config.env import (
|
||||
FRIGATE_ENV_VARS,
|
||||
validate_env_string,
|
||||
validate_env_vars,
|
||||
)
|
||||
|
||||
|
||||
class TestEnvString(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._original_env_vars = dict(FRIGATE_ENV_VARS)
|
||||
|
||||
def tearDown(self):
|
||||
FRIGATE_ENV_VARS.clear()
|
||||
FRIGATE_ENV_VARS.update(self._original_env_vars)
|
||||
|
||||
def test_substitution(self):
|
||||
"""EnvString substitutes FRIGATE_ env vars."""
|
||||
FRIGATE_ENV_VARS["FRIGATE_TEST_HOST"] = "192.168.1.100"
|
||||
result = validate_env_string("{FRIGATE_TEST_HOST}")
|
||||
self.assertEqual(result, "192.168.1.100")
|
||||
|
||||
def test_substitution_in_url(self):
|
||||
"""EnvString substitutes vars embedded in a URL."""
|
||||
FRIGATE_ENV_VARS["FRIGATE_CAM_USER"] = "admin"
|
||||
FRIGATE_ENV_VARS["FRIGATE_CAM_PASS"] = "secret"
|
||||
result = validate_env_string(
|
||||
"rtsp://{FRIGATE_CAM_USER}:{FRIGATE_CAM_PASS}@10.0.0.1/stream"
|
||||
)
|
||||
self.assertEqual(result, "rtsp://admin:secret@10.0.0.1/stream")
|
||||
|
||||
def test_no_placeholder(self):
|
||||
"""Plain strings pass through unchanged."""
|
||||
result = validate_env_string("192.168.1.1")
|
||||
self.assertEqual(result, "192.168.1.1")
|
||||
|
||||
def test_unknown_var_raises(self):
|
||||
"""Referencing an unknown var raises KeyError."""
|
||||
with self.assertRaises(KeyError):
|
||||
validate_env_string("{FRIGATE_NONEXISTENT_VAR}")
|
||||
|
||||
|
||||
class TestEnvVars(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._original_env_vars = dict(FRIGATE_ENV_VARS)
|
||||
self._original_environ = os.environ.copy()
|
||||
|
||||
def tearDown(self):
|
||||
FRIGATE_ENV_VARS.clear()
|
||||
FRIGATE_ENV_VARS.update(self._original_env_vars)
|
||||
# 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]
|
||||
|
||||
def _make_context(self, install: bool):
|
||||
"""Create a mock ValidationInfo with the given install flag."""
|
||||
|
||||
class MockContext:
|
||||
def __init__(self, ctx):
|
||||
self.context = ctx
|
||||
|
||||
mock = MockContext({"install": install})
|
||||
return mock
|
||||
|
||||
def test_install_sets_os_environ(self):
|
||||
"""validate_env_vars with install=True sets 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")
|
||||
|
||||
def test_install_updates_frigate_env_vars(self):
|
||||
"""validate_env_vars with install=True updates FRIGATE_ENV_VARS for FRIGATE_ keys."""
|
||||
ctx = self._make_context(install=True)
|
||||
validate_env_vars({"FRIGATE_MQTT_PASS": "secret"}, ctx)
|
||||
self.assertEqual(FRIGATE_ENV_VARS["FRIGATE_MQTT_PASS"], "secret")
|
||||
|
||||
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)
|
||||
|
||||
def test_no_install_does_not_set(self):
|
||||
"""validate_env_vars without install=True does not modify state."""
|
||||
ctx = self._make_context(install=False)
|
||||
validate_env_vars({"FRIGATE_SKIP": "nope"}, ctx)
|
||||
self.assertNotIn("FRIGATE_SKIP", FRIGATE_ENV_VARS)
|
||||
self.assertNotIn("FRIGATE_SKIP", os.environ)
|
||||
|
||||
def test_env_vars_available_for_env_string(self):
|
||||
"""Vars set via validate_env_vars are usable in validate_env_string."""
|
||||
ctx = self._make_context(install=True)
|
||||
validate_env_vars({"FRIGATE_BROKER": "mqtt.local"}, ctx)
|
||||
result = validate_env_string("{FRIGATE_BROKER}")
|
||||
self.assertEqual(result, "mqtt.local")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -55,6 +55,14 @@ DYNAMIC_OBJECT_THRESHOLDS = StationaryThresholds(
|
||||
motion_classifier_enabled=True,
|
||||
)
|
||||
|
||||
# Thresholds for objects that are not expected to be stationary
|
||||
NON_STATIONARY_OBJECT_THRESHOLDS = StationaryThresholds(
|
||||
objects=["license_plate"],
|
||||
known_active_iou=0.9,
|
||||
stationary_check_iou=0.9,
|
||||
max_stationary_history=4,
|
||||
)
|
||||
|
||||
|
||||
def get_stationary_threshold(label: str) -> StationaryThresholds:
|
||||
"""Get the stationary thresholds for a given object label."""
|
||||
@@ -65,6 +73,9 @@ def get_stationary_threshold(label: str) -> StationaryThresholds:
|
||||
if label in DYNAMIC_OBJECT_THRESHOLDS.objects:
|
||||
return DYNAMIC_OBJECT_THRESHOLDS
|
||||
|
||||
if label in NON_STATIONARY_OBJECT_THRESHOLDS.objects:
|
||||
return NON_STATIONARY_OBJECT_THRESHOLDS
|
||||
|
||||
return StationaryThresholds()
|
||||
|
||||
|
||||
|
||||
+2
-2
@@ -436,7 +436,7 @@ class CameraWatchdog(threading.Thread):
|
||||
|
||||
for role in p["roles"]:
|
||||
self.requestor.send_data(
|
||||
f"{self.config.name}/status/{role}", "offline"
|
||||
f"{self.config.name}/status/{role.value}", "offline"
|
||||
)
|
||||
|
||||
continue
|
||||
@@ -451,7 +451,7 @@ class CameraWatchdog(threading.Thread):
|
||||
|
||||
for role in p["roles"]:
|
||||
self.requestor.send_data(
|
||||
f"{self.config.name}/status/{role}", "offline"
|
||||
f"{self.config.name}/status/{role.value}", "offline"
|
||||
)
|
||||
|
||||
p["logpipe"].dump()
|
||||
|
||||
@@ -213,6 +213,7 @@ export function AnimatedEventCard({
|
||||
playsInline
|
||||
muted
|
||||
disableRemotePlayback
|
||||
disablePictureInPicture
|
||||
loop
|
||||
onTimeUpdate={() => {
|
||||
if (!isLoaded) {
|
||||
|
||||
@@ -126,19 +126,21 @@ export default function AccountSettings({ className }: AccountSettingsProps) {
|
||||
|
||||
<DropdownMenuSeparator className={isDesktop ? "my-2" : "my-2"} />
|
||||
|
||||
{profile?.username && profile.username !== "anonymous" && (
|
||||
<MenuItem
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2",
|
||||
isDesktop ? "cursor-pointer" : "p-2 text-sm",
|
||||
)}
|
||||
aria-label={t("menu.user.setPassword", { ns: "common" })}
|
||||
onClick={() => setPasswordDialogOpen(true)}
|
||||
>
|
||||
<LuSquarePen className="mr-2 size-4" />
|
||||
<span>{t("menu.user.setPassword", { ns: "common" })}</span>
|
||||
</MenuItem>
|
||||
)}
|
||||
{config?.auth?.enabled !== false &&
|
||||
profile?.username &&
|
||||
profile.username !== "anonymous" && (
|
||||
<MenuItem
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2",
|
||||
isDesktop ? "cursor-pointer" : "p-2 text-sm",
|
||||
)}
|
||||
aria-label={t("menu.user.setPassword", { ns: "common" })}
|
||||
onClick={() => setPasswordDialogOpen(true)}
|
||||
>
|
||||
<LuSquarePen className="mr-2 size-4" />
|
||||
<span>{t("menu.user.setPassword", { ns: "common" })}</span>
|
||||
</MenuItem>
|
||||
)}
|
||||
|
||||
<MenuItem
|
||||
className={cn(
|
||||
|
||||
@@ -225,20 +225,24 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
|
||||
<DropdownMenuSeparator
|
||||
className={isDesktop ? "mt-3" : "mt-1"}
|
||||
/>
|
||||
{profile?.username && profile.username !== "anonymous" && (
|
||||
<MenuItem
|
||||
className={
|
||||
isDesktop
|
||||
? "cursor-pointer"
|
||||
: "flex items-center p-2 text-sm"
|
||||
}
|
||||
aria-label={t("menu.user.setPassword", { ns: "common" })}
|
||||
onClick={() => setPasswordDialogOpen(true)}
|
||||
>
|
||||
<LuSquarePen className="mr-2 size-4" />
|
||||
<span>{t("menu.user.setPassword", { ns: "common" })}</span>
|
||||
</MenuItem>
|
||||
)}
|
||||
{config?.auth?.enabled !== false &&
|
||||
profile?.username &&
|
||||
profile.username !== "anonymous" && (
|
||||
<MenuItem
|
||||
className={
|
||||
isDesktop
|
||||
? "cursor-pointer"
|
||||
: "flex items-center p-2 text-sm"
|
||||
}
|
||||
aria-label={t("menu.user.setPassword", { ns: "common" })}
|
||||
onClick={() => setPasswordDialogOpen(true)}
|
||||
>
|
||||
<LuSquarePen className="mr-2 size-4" />
|
||||
<span>
|
||||
{t("menu.user.setPassword", { ns: "common" })}
|
||||
</span>
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem
|
||||
className={
|
||||
isDesktop
|
||||
|
||||
@@ -125,17 +125,23 @@ export default function ClassificationSelectionDialog({
|
||||
isMobile && "gap-2 pb-4",
|
||||
)}
|
||||
>
|
||||
{classes.sort().map((category) => (
|
||||
<SelectorItem
|
||||
key={category}
|
||||
className="flex cursor-pointer gap-2 smart-capitalize"
|
||||
onClick={() => onCategorizeImage(category)}
|
||||
>
|
||||
{category === "none"
|
||||
? t("details.none")
|
||||
: category.replaceAll("_", " ")}
|
||||
</SelectorItem>
|
||||
))}
|
||||
{classes
|
||||
.sort((a, b) => {
|
||||
if (a === "none") return 1;
|
||||
if (b === "none") return -1;
|
||||
return a.localeCompare(b);
|
||||
})
|
||||
.map((category) => (
|
||||
<SelectorItem
|
||||
key={category}
|
||||
className="flex cursor-pointer gap-2 smart-capitalize"
|
||||
onClick={() => onCategorizeImage(category)}
|
||||
>
|
||||
{category === "none"
|
||||
? t("details.none")
|
||||
: category.replaceAll("_", " ")}
|
||||
</SelectorItem>
|
||||
))}
|
||||
<Separator />
|
||||
<SelectorItem
|
||||
className="flex cursor-pointer gap-2 smart-capitalize"
|
||||
|
||||
@@ -495,6 +495,15 @@ export default function SearchDetailDialog({
|
||||
}
|
||||
}, [search]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDesktop || !onPrevious || !onNext) {
|
||||
setShowNavigationButtons(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setShowNavigationButtons(isOpen);
|
||||
}, [isOpen, onNext, onPrevious]);
|
||||
|
||||
// show/hide annotation settings is handled inside TabsWithActions
|
||||
|
||||
const searchTabs = useMemo(() => {
|
||||
|
||||
@@ -40,6 +40,7 @@ import ImageLoadingIndicator from "@/components/indicators/ImageLoadingIndicator
|
||||
import ObjectTrackOverlay from "../ObjectTrackOverlay";
|
||||
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||
import { VideoResolutionType } from "@/types/live";
|
||||
import { VodManifest } from "@/types/playback";
|
||||
|
||||
type TrackingDetailsProps = {
|
||||
className?: string;
|
||||
@@ -117,19 +118,64 @@ export function TrackingDetails({
|
||||
},
|
||||
);
|
||||
|
||||
// Fetch the VOD manifest JSON to get the actual clipFrom after keyframe
|
||||
// snapping. The backend may snap clipFrom backwards to a keyframe, making
|
||||
// the video start earlier than the requested time.
|
||||
const vodManifestUrl = useMemo(() => {
|
||||
if (!event.camera) return null;
|
||||
const startTime =
|
||||
event.start_time + annotationOffset / 1000 - REVIEW_PADDING;
|
||||
const endTime =
|
||||
(event.end_time ?? Date.now() / 1000) +
|
||||
annotationOffset / 1000 +
|
||||
REVIEW_PADDING;
|
||||
return `vod/clip/${event.camera}/start/${startTime}/end/${endTime}`;
|
||||
}, [event, annotationOffset]);
|
||||
|
||||
const { data: vodManifest } = useSWR<VodManifest>(vodManifestUrl, null, {
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
dedupingInterval: 30000,
|
||||
});
|
||||
|
||||
// Derive the actual video start time from the VOD manifest's first clip.
|
||||
// Without this correction the timeline-to-player-time mapping is off by
|
||||
// the keyframe preroll amount.
|
||||
const actualVideoStart = useMemo(() => {
|
||||
const videoStartTime = eventStartRecord - REVIEW_PADDING;
|
||||
|
||||
if (!vodManifest?.sequences?.[0]?.clips?.[0] || !recordings?.length) {
|
||||
return videoStartTime;
|
||||
}
|
||||
|
||||
const firstClip = vodManifest.sequences[0].clips[0];
|
||||
|
||||
// Guard: clipFrom is only expected when the first recording starts before
|
||||
// the requested start. If this doesn't hold, fall back.
|
||||
if (recordings[0].start_time >= videoStartTime) {
|
||||
return recordings[0].start_time;
|
||||
}
|
||||
|
||||
if (firstClip.clipFrom !== undefined) {
|
||||
// clipFrom is in milliseconds from the start of the first recording
|
||||
return recordings[0].start_time + firstClip.clipFrom / 1000;
|
||||
}
|
||||
|
||||
// clipFrom absent means the full recording is included (keyframe probe failed)
|
||||
return recordings[0].start_time;
|
||||
}, [vodManifest, recordings, eventStartRecord]);
|
||||
|
||||
// Convert a timeline timestamp to actual video player time, accounting for
|
||||
// motion-only recording gaps. Uses the same algorithm as DynamicVideoController.
|
||||
const timestampToVideoTime = useCallback(
|
||||
(timestamp: number): number => {
|
||||
if (!recordings || recordings.length === 0) {
|
||||
// Fallback to simple calculation if no recordings data
|
||||
return timestamp - (eventStartRecord - REVIEW_PADDING);
|
||||
return timestamp - actualVideoStart;
|
||||
}
|
||||
|
||||
const videoStartTime = eventStartRecord - REVIEW_PADDING;
|
||||
|
||||
// If timestamp is before video start, return 0
|
||||
if (timestamp < videoStartTime) return 0;
|
||||
// If timestamp is before actual video start, return 0
|
||||
if (timestamp < actualVideoStart) return 0;
|
||||
|
||||
// Check if timestamp is before the first recording or after the last
|
||||
if (
|
||||
@@ -143,10 +189,10 @@ export function TrackingDetails({
|
||||
// Calculate the inpoint offset - the HLS video may start partway through the first segment
|
||||
let inpointOffset = 0;
|
||||
if (
|
||||
videoStartTime > recordings[0].start_time &&
|
||||
videoStartTime < recordings[0].end_time
|
||||
actualVideoStart > recordings[0].start_time &&
|
||||
actualVideoStart < recordings[0].end_time
|
||||
) {
|
||||
inpointOffset = videoStartTime - recordings[0].start_time;
|
||||
inpointOffset = actualVideoStart - recordings[0].start_time;
|
||||
}
|
||||
|
||||
let seekSeconds = 0;
|
||||
@@ -164,7 +210,7 @@ export function TrackingDetails({
|
||||
if (segment === recordings[0]) {
|
||||
// For the first segment, account for the inpoint offset
|
||||
seekSeconds +=
|
||||
timestamp - Math.max(segment.start_time, videoStartTime);
|
||||
timestamp - Math.max(segment.start_time, actualVideoStart);
|
||||
} else {
|
||||
seekSeconds += timestamp - segment.start_time;
|
||||
}
|
||||
@@ -174,7 +220,7 @@ export function TrackingDetails({
|
||||
|
||||
return seekSeconds;
|
||||
},
|
||||
[recordings, eventStartRecord],
|
||||
[recordings, actualVideoStart],
|
||||
);
|
||||
|
||||
// Convert video player time back to timeline timestamp, accounting for
|
||||
@@ -183,19 +229,16 @@ export function TrackingDetails({
|
||||
(playerTime: number): number => {
|
||||
if (!recordings || recordings.length === 0) {
|
||||
// Fallback to simple calculation if no recordings data
|
||||
const videoStartTime = eventStartRecord - REVIEW_PADDING;
|
||||
return playerTime + videoStartTime;
|
||||
return playerTime + actualVideoStart;
|
||||
}
|
||||
|
||||
const videoStartTime = eventStartRecord - REVIEW_PADDING;
|
||||
|
||||
// Calculate the inpoint offset - the video may start partway through the first segment
|
||||
let inpointOffset = 0;
|
||||
if (
|
||||
videoStartTime > recordings[0].start_time &&
|
||||
videoStartTime < recordings[0].end_time
|
||||
actualVideoStart > recordings[0].start_time &&
|
||||
actualVideoStart < recordings[0].end_time
|
||||
) {
|
||||
inpointOffset = videoStartTime - recordings[0].start_time;
|
||||
inpointOffset = actualVideoStart - recordings[0].start_time;
|
||||
}
|
||||
|
||||
let timestamp = 0;
|
||||
@@ -212,7 +255,7 @@ export function TrackingDetails({
|
||||
if (segment === recordings[0]) {
|
||||
// For the first segment, add the inpoint offset
|
||||
timestamp =
|
||||
Math.max(segment.start_time, videoStartTime) +
|
||||
Math.max(segment.start_time, actualVideoStart) +
|
||||
(playerTime - totalTime);
|
||||
} else {
|
||||
timestamp = segment.start_time + (playerTime - totalTime);
|
||||
@@ -225,7 +268,7 @@ export function TrackingDetails({
|
||||
|
||||
return timestamp;
|
||||
},
|
||||
[recordings, eventStartRecord],
|
||||
[recordings, actualVideoStart],
|
||||
);
|
||||
|
||||
eventSequence?.map((event) => {
|
||||
|
||||
@@ -352,7 +352,7 @@ export default function HlsVideoPlayer({
|
||||
>
|
||||
{isDetailMode &&
|
||||
camera &&
|
||||
currentTime &&
|
||||
currentTime != null &&
|
||||
loadedMetadata &&
|
||||
videoDimensions.width > 0 &&
|
||||
videoDimensions.height > 0 && (
|
||||
|
||||
@@ -20,7 +20,10 @@ import type {
|
||||
CameraConfigData,
|
||||
ConfigSetBody,
|
||||
} from "@/types/cameraWizard";
|
||||
import { processCameraName } from "@/utils/cameraUtil";
|
||||
import {
|
||||
processCameraName,
|
||||
calculateDetectDimensions,
|
||||
} from "@/utils/cameraUtil";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type WizardState = {
|
||||
@@ -203,6 +206,25 @@ export default function CameraWizardDialog({
|
||||
},
|
||||
};
|
||||
|
||||
// Calculate detect dimensions from the detect stream's probed resolution
|
||||
const detectStream = wizardData.streams.find((stream) =>
|
||||
stream.roles.includes("detect"),
|
||||
);
|
||||
if (detectStream?.testResult?.resolution) {
|
||||
const [streamWidth, streamHeight] = detectStream.testResult.resolution
|
||||
.split("x")
|
||||
.map(Number);
|
||||
if (streamWidth > 0 && streamHeight > 0) {
|
||||
const detectDimensions = calculateDetectDimensions(
|
||||
streamWidth,
|
||||
streamHeight,
|
||||
);
|
||||
if (detectDimensions) {
|
||||
configData.cameras[finalCameraName].detect = detectDimensions;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add live.streams configuration for go2rtc streams
|
||||
if (wizardData.streams && wizardData.streams.length > 0) {
|
||||
configData.cameras[finalCameraName].live = {
|
||||
|
||||
@@ -598,18 +598,18 @@ function LibrarySelector({
|
||||
{Object.values(faces).map((face) => (
|
||||
<DropdownMenuItem
|
||||
key={face}
|
||||
className="group flex items-center justify-between"
|
||||
className="group flex items-center justify-between p-0"
|
||||
>
|
||||
<div
|
||||
className="flex-grow cursor-pointer"
|
||||
onClick={() => setPageToggle(face)}
|
||||
>
|
||||
{face}
|
||||
<span className="ml-2 text-muted-foreground">
|
||||
<span className="ml-2 px-2 py-1.5 text-muted-foreground">
|
||||
({faceData?.[face].length})
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-0.5">
|
||||
<div className="flex gap-0.5 px-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
|
||||
@@ -162,6 +162,10 @@ export type CameraConfigData = {
|
||||
input_args?: string;
|
||||
}[];
|
||||
};
|
||||
detect?: {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
live?: {
|
||||
streams: Record<string, string>;
|
||||
};
|
||||
|
||||
@@ -346,6 +346,7 @@ export interface FrigateConfig {
|
||||
};
|
||||
|
||||
auth: {
|
||||
enabled: boolean;
|
||||
roles: {
|
||||
[roleName: string]: string[];
|
||||
};
|
||||
|
||||
@@ -11,3 +11,7 @@ export type PreviewPlayback = {
|
||||
preview: Preview | undefined;
|
||||
timeRange: TimeRange;
|
||||
};
|
||||
|
||||
export type VodManifest = {
|
||||
sequences: { clips: { clipFrom?: number }[] }[];
|
||||
};
|
||||
|
||||
@@ -115,6 +115,51 @@ export type CameraAudioFeatures = {
|
||||
* @param requireSecureContext - If true, two-way audio requires secure context (default: true)
|
||||
* @returns CameraAudioFeatures object with detected capabilities
|
||||
*/
|
||||
/**
|
||||
* Calculates optimal detect dimensions from stream resolution.
|
||||
*
|
||||
* Scales dimensions to an efficient size for object detection while
|
||||
* preserving the stream's aspect ratio. Does not upscale.
|
||||
*
|
||||
* @param streamWidth - Native stream width in pixels
|
||||
* @param streamHeight - Native stream height in pixels
|
||||
* @returns Detect dimensions with even values, or null if inputs are invalid
|
||||
*/
|
||||
|
||||
// Target size for the smaller dimension (width or height) for detect streams
|
||||
export const DETECT_TARGET_PX = 720;
|
||||
|
||||
export function calculateDetectDimensions(
|
||||
streamWidth: number,
|
||||
streamHeight: number,
|
||||
): { width: number; height: number } | null {
|
||||
if (
|
||||
!Number.isFinite(streamWidth) ||
|
||||
!Number.isFinite(streamHeight) ||
|
||||
streamWidth <= 0 ||
|
||||
streamHeight <= 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const smallerDim = Math.min(streamWidth, streamHeight);
|
||||
const target = Math.min(DETECT_TARGET_PX, smallerDim);
|
||||
const scale = target / smallerDim;
|
||||
|
||||
let width = Math.round(streamWidth * scale);
|
||||
let height = Math.round(streamHeight * scale);
|
||||
|
||||
// Round down to even numbers (required for video processing)
|
||||
width = width - (width % 2);
|
||||
height = height - (height % 2);
|
||||
|
||||
if (width < 2 || height < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
export function detectCameraAudioFeatures(
|
||||
metadata: LiveStreamMetadata | null | undefined,
|
||||
requireSecureContext: boolean = true,
|
||||
|
||||
@@ -700,66 +700,72 @@ function LibrarySelector({
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{Object.keys(dataset).map((id) => (
|
||||
<DropdownMenuItem
|
||||
key={id}
|
||||
className="group flex items-center justify-between"
|
||||
>
|
||||
<div
|
||||
className="flex-grow cursor-pointer capitalize"
|
||||
onClick={() => setPageToggle(id)}
|
||||
{Object.keys(dataset)
|
||||
.sort((a, b) => {
|
||||
if (a === "none") return 1;
|
||||
if (b === "none") return -1;
|
||||
return a.localeCompare(b);
|
||||
})
|
||||
.map((id) => (
|
||||
<DropdownMenuItem
|
||||
key={id}
|
||||
className="group flex items-center justify-between p-0"
|
||||
>
|
||||
{id === "none" ? t("details.none") : id.replaceAll("_", " ")}
|
||||
<span className="ml-2 text-muted-foreground">
|
||||
({dataset?.[id].length})
|
||||
</span>
|
||||
</div>
|
||||
{id != "none" && (
|
||||
<div className="flex gap-0.5">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 lg:opacity-0 lg:transition-opacity lg:group-hover:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setRenameClass(id);
|
||||
}}
|
||||
>
|
||||
<LuPencil className="size-4 text-primary" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent>
|
||||
{t("button.renameCategory")}
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 lg:opacity-0 lg:transition-opacity lg:group-hover:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setConfirmDelete(id);
|
||||
}}
|
||||
>
|
||||
<LuTrash2 className="size-4 text-destructive" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent>
|
||||
{t("button.deleteCategory")}
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
<div
|
||||
className="flex-grow cursor-pointer px-2 py-1.5 capitalize"
|
||||
onClick={() => setPageToggle(id)}
|
||||
>
|
||||
{id === "none" ? t("details.none") : id.replaceAll("_", " ")}
|
||||
<span className="ml-2 text-muted-foreground">
|
||||
({dataset?.[id].length})
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{id != "none" && (
|
||||
<div className="flex gap-0.5 px-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 lg:opacity-0 lg:transition-opacity lg:group-hover:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setRenameClass(id);
|
||||
}}
|
||||
>
|
||||
<LuPencil className="size-4 text-primary" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent>
|
||||
{t("button.renameCategory")}
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 lg:opacity-0 lg:transition-opacity lg:group-hover:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setConfirmDelete(id);
|
||||
}}
|
||||
>
|
||||
<LuTrash2 className="size-4 text-destructive" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent>
|
||||
{t("button.deleteCategory")}
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user