Compare commits

..
Author SHA1 Message Date
Nicolas Mowen abf8a1875c Update api spec 2026-08-22 09:13:08 -06:00
Nicolas Mowen 172b404a93 Require camera access to delete review for camera 2026-08-22 09:12:27 -06:00
267 changed files with 2423 additions and 21309 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
default_target: local
COMMIT_HASH := $(shell git log -1 --pretty=format:"%h"|tail -1)
VERSION = 0.18.1
VERSION = 0.18.0
IMAGE_REPO ?= ghcr.io/blakeblackshear/frigate
GITHUB_REF_NAME ?= $(shell git rev-parse --abbrev-ref HEAD)
BOARDS= #Initialized empty
@@ -150,9 +150,7 @@ http {
include auth_request.conf;
types {
video/mp4 mp4;
image/jpeg jpg jpeg;
image/png png;
image/webp webp;
image/jpeg jpg;
}
expires 7d;
+1 -1
View File
@@ -1100,7 +1100,7 @@ synaptics:
- key: ssd
label: SSD MobileNet
recommended: true
download: A synap model is provided in the container at `/synaptics/mobilenet.synap` and is used by this detector type by default. The model comes from the [Synap-release Github](https://github.com/synaptics-astra/synap-release/tree/v1.5.0/models/dolphin/object_detection/coco/model/mobilenet224_full80).
download: A synap model is provided in the container at `/mobilenet.synap` and is used by this detector type by default. The model comes from the [Synap-release Github](https://github.com/synaptics-astra/synap-release/tree/v1.5.0/models/dolphin/object_detection/coco/model/mobilenet224_full80).
ui: |-
Navigate to **Settings > System > Detectors and model** and select **Synaptics** from the detector type dropdown and click **Add**. Then on the same page, in the **Custom Model** tab, configure:
@@ -286,7 +286,7 @@ ffmpeg:
# Optional: output args for detect streams (default: shown below)
detect: -threads 2 -f rawvideo -pix_fmt yuv420p
# Optional: output args for record streams (default: shown below)
record: preset-record-generic-audio-aac
record: preset-record-generic
# Optional: Time in seconds to wait before ffmpeg retries connecting to the camera. (default: shown below)
# If set too low, frigate will retry a connection to the camera's stream too frequently, using up the limited streams some cameras can allow at once
# If set too high, then if a ffmpeg crash or camera stream timeout occurs, you could potentially lose up to a maximum of retry_interval second(s) of footage
@@ -498,7 +498,7 @@ cameras:
## Synaptics
Hardware accelerated video de-/encoding is supported on Synaptics SL-series SoC.
Hardware accelerated video de-/encoding is supported on Synpatics SL-series SoC.
### Prerequisites
@@ -8,7 +8,7 @@ import TabItem from "@theme/TabItem";
import NavPath from "@site/src/components/NavPath";
import FaqItem from "@site/src/components/FaqItem";
Frigate can recognize license plates on vehicles and automatically add the detected characters to the `recognized_license_plate` field or a [known](#matching) name as a `sub_label` to tracked objects of type `car`, `motorcycle`, `bus`, `truck`, `school_bus`, or `garbage_truck`, depending on which of those labels your model detects. A common use case may be to read the license plates of cars pulling into a driveway or cars passing by on a street.
Frigate can recognize license plates on vehicles and automatically add the detected characters to the `recognized_license_plate` field or a [known](#matching) name as a `sub_label` to tracked objects of type `car` or `motorcycle`. A common use case may be to read the license plates of cars pulling into a driveway or cars passing by on a street.
LPR works best when the license plate is clearly visible to the camera. For moving vehicles, Frigate continuously refines the recognition process, keeping the most confident result. When a vehicle becomes stationary, LPR continues to run for a short time after to attempt recognition.
@@ -24,7 +24,7 @@ When a plate is recognized, the details are:
- Viewable in the Details pane in Review/History.
- Viewable in the Tracked Object Details pane in Explore (sub labels and recognized license plates).
- Filterable through the More Filters menu in Explore.
- Published via the `frigate/events` MQTT topic as a `sub_label` ([known](#matching)) or `recognized_license_plate` (unknown) for the vehicle tracked object.
- Published via the `frigate/events` MQTT topic as a `sub_label` ([known](#matching)) or `recognized_license_plate` (unknown) for the `car` or `motorcycle` tracked object.
- Published via the `frigate/tracked_object_update` MQTT topic with `name` (if [known](#matching)) and `plate`.
## Model Requirements
@@ -35,7 +35,7 @@ Users without a model that detects license plates can still run LPR. Frigate use
:::note
In the default mode, Frigate's LPR needs to first detect a vehicle before it can recognize a license plate. If you're using a dedicated LPR camera and have a zoomed-in view where a vehicle will not be detected, you can still run LPR, but the configuration parameters will differ from the default mode. See the [Dedicated LPR Cameras](#dedicated-lpr-cameras) section below.
In the default mode, Frigate's LPR needs to first detect a `car` or `motorcycle` before it can recognize a license plate. If you're using a dedicated LPR camera and have a zoomed-in view where a `car` or `motorcycle` will not be detected, you can still run LPR, but the configuration parameters will differ from the default mode. See the [Dedicated LPR Cameras](#dedicated-lpr-cameras) section below.
:::
@@ -86,7 +86,7 @@ cameras:
</TabItem>
</ConfigTabs>
For non-dedicated LPR cameras, ensure that your camera is configured to detect vehicle objects, and that a vehicle is actually being detected by Frigate. Otherwise, LPR will not run. The object types that can carry a plate are defined by your model's `attributes_map`, so if your model detects other vehicle labels, you can add them there.
For non-dedicated LPR cameras, ensure that your camera is configured to detect objects of type `car` or `motorcycle`, and that a car or motorcycle is actually being detected by Frigate. Otherwise, LPR will not run.
Like the other real-time processors in Frigate, license plate recognition runs on the camera stream defined by the `detect` role in your config. To ensure optimal performance, select a suitable resolution for this stream in your camera's firmware that fits your specific scene and requirements.
@@ -158,7 +158,7 @@ lpr:
Navigate to <NavPath path="Settings > Enrichments > License plate recognition" />.
- **Known plates**: Assign custom `sub_label` values to vehicle objects when a recognized plate matches a known value. These labels appear in the UI, filters, and notifications. Unknown plates are still saved but are added to the `recognized_license_plate` field rather than the `sub_label`.
- **Known plates**: Assign custom `sub_label` values to `car` and `motorcycle` objects when a recognized plate matches a known value. These labels appear in the UI, filters, and notifications. Unknown plates are still saved but are added to the `recognized_license_plate` field rather than the `sub_label`.
- **Match distance**: Allows for minor variations (missing/incorrect characters) when matching a detected plate to a known plate. For example, setting to `1` allows a plate `ABCDE` to match `ABCBE` or `ABCD`. This parameter will _not_ operate on known plates that are defined as regular expressions.
</TabItem>
@@ -316,7 +316,7 @@ lpr:
:::note
If a camera is configured to detect vehicles but you don't want Frigate to run LPR for that camera, disable LPR at the camera level:
If a camera is configured to detect `car` or `motorcycle` but you don't want Frigate to run LPR for that camera, disable LPR at the camera level:
<ConfigTabs>
<TabItem value="ui">
@@ -378,10 +378,10 @@ Navigate to <NavPath path="Settings > Camera configuration > Object detection" /
Navigate to <NavPath path="Settings > Camera configuration > Objects" />.
| Field | Description |
| --------------------------------------------------------- | ------------------- |
| **Objects to track** | Add `license_plate` |
| **Object filters > License Plate > Confidence threshold** | Set to `0.7` |
| Field | Description |
| ---------------------------------------------- | ------------------- |
| **Objects to track** | Add `license_plate` |
| **Object filters > License Plate > Threshold** | Set to `0.7` |
Navigate to <NavPath path="Settings > Camera configuration > Motion detection" />.
@@ -456,7 +456,7 @@ With this setup:
- Snapshots will have license plate bounding boxes on them.
- The `frigate/events` MQTT topic will publish tracked object updates.
- Debug view will display `license_plate` bounding boxes.
- If you are using a Frigate+ model and want to submit images from your dedicated LPR camera for model training and fine-tuning, annotate both the vehicle and the `license_plate` in the snapshots on the Frigate+ website, even if the vehicle is barely visible.
- If you are using a Frigate+ model and want to submit images from your dedicated LPR camera for model training and fine-tuning, annotate both the `car` / `motorcycle` and the `license_plate` in the snapshots on the Frigate+ website, even if the car is barely visible.
### Using the Secondary LPR Pipeline (Without Frigate+)
@@ -611,9 +611,9 @@ If you are still having issues detecting plates, start with a basic configuratio
</FaqItem>
<FaqItem id="can-i-run-lpr-without-detecting-car-or-motorcycle-objects" question={<>Can I run LPR without detecting vehicle objects?</>}>
<FaqItem id="can-i-run-lpr-without-detecting-car-or-motorcycle-objects" question={<>Can I run LPR without detecting <code>car</code> or <code>motorcycle</code> objects?</>}>
In normal LPR mode, Frigate requires a vehicle to be detected first before recognizing a license plate. If you have a dedicated LPR camera, you can change the camera `type` to `"lpr"` to use the Dedicated LPR Camera algorithm. This comes with important caveats, though. See the [Dedicated LPR Cameras](#dedicated-lpr-cameras) section above.
In normal LPR mode, Frigate requires a `car` or `motorcycle` to be detected first before recognizing a license plate. If you have a dedicated LPR camera, you can change the camera `type` to `"lpr"` to use the Dedicated LPR Camera algorithm. This comes with important caveats, though. See the [Dedicated LPR Cameras](#dedicated-lpr-cameras) section above.
</FaqItem>
@@ -699,7 +699,7 @@ lpr:
4. Ensure the characters on detected plates are being _recognized_.
- Check the **Plate recognition** inference time in Enrichment metrics (<NavPath path="System metrics > Enrichments" />). High inference times (> 100ms) could lead to poor recognition results, especially for dedicated LPR cameras where the plate crosses the frame quickly.
- Enable `debug_save_plates` to save images of detected text on plates to the clips directory (`/media/frigate/clips/lpr`). Ensure these images are readable and the text is clear.
- Watch the debug view to see plates recognized in real-time. For non-dedicated LPR cameras, the vehicle's label will change to the recognized plate when LPR is enabled and working.
- Watch the debug view to see plates recognized in real-time. For non-dedicated LPR cameras, the `car` or `motorcycle` label will change to the recognized plate when LPR is enabled and working.
- Adjust `recognition_threshold` settings per the suggestions [above](#advanced-configuration).
</FaqItem>
@@ -714,13 +714,13 @@ LPR's performance impact depends on your hardware. Ensure you have at least 4GB
The YOLOv9 license plate detector model will run (and the metric will appear) if you've enabled LPR but haven't defined `license_plate` as an object to track, either at the global or camera level.
If you are detecting vehicles on cameras where you don't want to run LPR, make sure you disable LPR it at the camera level. And if you do want to run LPR on those cameras, make sure you define `license_plate` as an object to track.
If you are detecting `car` or `motorcycle` on cameras where you don't want to run LPR, make sure you disable LPR it at the camera level. And if you do want to run LPR on those cameras, make sure you define `license_plate` as an object to track.
</FaqItem>
<FaqItem id="it-looks-like-frigate-picked-up-my-cameras-timestamp-or-overlay-text-as-the-license-plate-how-can-i-prevent-this" question="It looks like Frigate picked up my camera's timestamp or overlay text as the license plate. How can I prevent this?">
This could happen if vehicles travel close to your camera's timestamp or overlay text. You could either move the text through your camera's firmware, or apply a mask to it in Frigate.
This could happen if cars or motorcycles travel close to your camera's timestamp or overlay text. You could either move the text through your camera's firmware, or apply a mask to it in Frigate.
If you are using a model that natively detects `license_plate`, add an _object mask_ of type `license_plate` and a _motion mask_ over your text.
+10 -10
View File
@@ -45,10 +45,10 @@ Any detection below `min_score` will be immediately thrown out and never tracked
Navigate to <NavPath path="Settings > Global configuration > Objects" /> to set score filters globally.
| Field | Description |
| -------------------------------------------------- | ---------------------------------------------------------------- |
| **Object filters > Person > Minimum confidence** | Minimum score for a single detection to initiate tracking |
| **Object filters > Person > Confidence threshold** | Minimum computed (median) score to be considered a true positive |
| Field | Description |
| --------------------------------------- | ---------------------------------------------------------------- |
| **Object filters > Person > Min Score** | Minimum score for a single detection to initiate tracking |
| **Object filters > Person > Threshold** | Minimum computed (median) score to be considered a true positive |
To override score filters for a specific camera, navigate to <NavPath path="Settings > Camera configuration > Objects" /> and select the camera.
@@ -103,12 +103,12 @@ Conceptually, a ratio of 1 is a square, 0.5 is a "tall skinny" box, and 2 is a "
Navigate to <NavPath path="Settings > Global configuration > Objects" /> to set shape filters globally.
| Field | Description |
| -------------------------------------------------- | ------------------------------------------------------------------------ |
| **Object filters > Person > Minimum object area** | Minimum bounding box area in pixels (or decimal for percentage of frame) |
| **Object filters > Person > Maximum object area** | Maximum bounding box area in pixels (or decimal for percentage of frame) |
| **Object filters > Person > Minimum aspect ratio** | Minimum width/height ratio of the bounding box |
| **Object filters > Person > Maximum aspect ratio** | Maximum width/height ratio of the bounding box |
| Field | Description |
| --------------------------------------- | ------------------------------------------------------------------------ |
| **Object filters > Person > Min Area** | Minimum bounding box area in pixels (or decimal for percentage of frame) |
| **Object filters > Person > Max Area** | Maximum bounding box area in pixels (or decimal for percentage of frame) |
| **Object filters > Person > Min Ratio** | Minimum width/height ratio of the bounding box |
| **Object filters > Person > Max Ratio** | Maximum width/height ratio of the bounding box |
To override shape filters for a specific camera, navigate to <NavPath path="Settings > Camera configuration > Objects" /> and select the camera.
+8 -8
View File
@@ -70,14 +70,14 @@ Object filters help reduce false positives by constraining the size, shape, and
Navigate to <NavPath path="Settings > Global configuration > Objects" />.
| Field | Description |
| -------------------------------------------------- | ------------------------------------------------------------------------ |
| **Object filters > Person > Minimum object area** | Minimum bounding box area in pixels (or decimal for percentage of frame) |
| **Object filters > Person > Maximum object area** | Maximum bounding box area in pixels (or decimal for percentage of frame) |
| **Object filters > Person > Minimum aspect ratio** | Minimum width/height ratio of the bounding box |
| **Object filters > Person > Maximum aspect ratio** | Maximum width/height ratio of the bounding box |
| **Object filters > Person > Minimum confidence** | Minimum score for the object to initiate tracking |
| **Object filters > Person > Confidence threshold** | Minimum computed score to be considered a true positive |
| Field | Description |
| --------------------------------------- | ------------------------------------------------------------------------ |
| **Object filters > Person > Min Area** | Minimum bounding box area in pixels (or decimal for percentage of frame) |
| **Object filters > Person > Max Area** | Maximum bounding box area in pixels (or decimal for percentage of frame) |
| **Object filters > Person > Min Ratio** | Minimum width/height ratio of the bounding box |
| **Object filters > Person > Max Ratio** | Maximum width/height ratio of the bounding box |
| **Object filters > Person > Min Score** | Minimum score for the object to initiate tracking |
| **Object filters > Person > Threshold** | Minimum computed score to be considered a true positive |
To override filters for a specific camera, navigate to <NavPath path="Settings > Camera configuration > Objects" />.
+3 -7
View File
@@ -191,12 +191,14 @@ cameras:
detect:
enabled: false
record:
enabled: true
enabled: false
profiles:
away:
enabled: true
detect:
enabled: true
record:
enabled: true
home:
enabled: false
```
@@ -249,12 +251,6 @@ Leaving the `objects` section empty (or omitting `track`) does not clear the lis
Fields that require a Frigate restart to take effect cannot be overridden by profiles, since profiles are applied at runtime without restarting. Those fields are hidden when editing a profile override and can only be changed on the base configuration.
### Why can't a profile enable recording when it's disabled in the base config?
Frigate only sets up a camera's recording stream at startup when recording is enabled in the base config, so enabling it later from a profile has no effect. The same applies to turning recording on from the UI or MQTT.
To keep recording off by default, leave `record.enabled: true` in the base config and create a profile that sets `record.enabled: false`. Activate that profile and it will be restored automatically when Frigate starts.
### Can I schedule profiles to be enabled or disabled at certain times?
Not within Frigate itself. Frigate is an NVR, not an automation platform, so it intentionally does not include a scheduler for activating profiles. Instead, activate profiles from an automation platform that already handles time- and event-based triggers well, such as [Home Assistant](https://www.home-assistant.io/) or [Node-RED](https://nodered.org/). These integrate with Frigate and give you far more robust and flexible scheduling than a built-in scheduler could.
+2 -2
View File
@@ -9,7 +9,7 @@ import NavPath from "@site/src/components/NavPath";
Recordings can be enabled and are stored at `/media/frigate/recordings`. The folder structure for the recordings is `YYYY-MM-DD/HH/<camera_name>/MM.SS.mp4` in **UTC time**. These recordings are written directly from your camera stream without re-encoding. Each camera supports a configurable retention policy. Frigate chooses the largest matching retention value between the recording retention and the tracked object retention when determining if a recording should be removed.
New recording segments are written from the camera stream to cache, they are only moved to disk if they pass a validation check and match the setup recording retention policy.
New recording segments are written from the camera stream to cache, they are only moved to disk if they match the setup recording retention policy.
:::tip
@@ -291,7 +291,7 @@ For advanced use cases, the [custom export HTTP API](../integrations/api/export-
POST /export/custom/{camera_name}/start/{start_time}/end/{end_time}
```
The request body accepts `ffmpeg_input_args` and `ffmpeg_output_args` to control encoding, frame rate, filters, and other FFmpeg options. If neither is provided, Frigate defaults to time-lapse output settings (25x speed, 30 FPS) with audio removed (`-an`). When providing your own `ffmpeg_input_args`, include `-an` if you want audio stripped from the export.
The request body accepts `ffmpeg_input_args` and `ffmpeg_output_args` to control encoding, frame rate, filters, and other FFmpeg options. If neither is provided, Frigate defaults to time-lapse output settings (25x speed, 30 FPS).
The following example exports a time-lapse at 60x speed with 25 FPS:
+1 -3
View File
@@ -197,7 +197,7 @@ For cameras that support two-way talk, go2rtc will automatically establish an au
To prevent this, you must configure two separate stream instances:
1. One stream instance with `#backchannel=0` for Frigate's viewing, recording, and detection (prevents go2rtc from establishing the blocking backchannel)
2. A second stream instance with no `#` parameters at all for two-way talk functionality (can be used by Frigate's WebRTC viewer or other applications)
2. A second stream instance without `#backchannel=0` for two-way talk functionality (can be used by Frigate's WebRTC viewer or other applications)
Configuration example:
@@ -215,8 +215,6 @@ In this configuration:
- `front_door` stream is used by Frigate for viewing, recording, and detection. The `#backchannel=0` parameter prevents go2rtc from establishing the audio output backchannel, so it won't block two-way talk access.
- `front_door_twoway` stream is used for two-way talk functionality. This stream can be used by Frigate's WebRTC viewer when two-way talk is enabled, or by other applications (like Home Assistant Advanced Camera Card) that need access to the camera's audio output channel.
Any `#` parameter on a bare `rtsp://` source disables the backchannel unless the URL explicitly contains `#backchannel=1`. A two-way talk stream with something like `#video=h264` on it silently loses two-way audio, and Frigate will report that two-way talk is unavailable for that stream.
## Security: Restricted Stream Sources
For security reasons, the `echo:`, `expr:`, and `exec:` stream sources are disabled by default in go2rtc. These sources allow arbitrary command execution and can pose security risks if misconfigured.
+2 -2
View File
@@ -245,8 +245,8 @@ Triggers are best configured through the Frigate UI.
1. Navigate to <NavPath path="Settings > Enrichments > Triggers" /> and select a camera from the dropdown menu.
2. Click **Add Trigger** to create a new trigger or use the pencil icon to edit an existing one.
3. In the **Create Trigger** wizard:
- Enter a **Name** for the trigger (e.g., "Red Car Alert"). Frigate derives the trigger's
internal **ID** from this name, which can be revealed and edited with the show/hide toggle.
- Enter a **Name** for the trigger (e.g., "Red Car Alert").
- Enter a descriptive **Friendly Name** for the trigger (e.g., "Red car on the driveway camera").
- Select the **Type** (`Thumbnail` or `Description`).
- For `Thumbnail`, select an image to trigger this action when a similar thumbnail image is detected, based on the threshold.
- For `Description`, enter text to trigger this action when a similar tracked object description is detected.
+4 -4
View File
@@ -28,7 +28,7 @@ During testing, enable the Zones option for the [Debug view](/usage/live#the-sin
1. Navigate to <NavPath path="Settings > Camera configuration > Masks / Zones" /> and select the desired camera.
2. Under the **Zones** section, click the plus icon to add a new zone.
3. Click on the camera's latest image to create the points for the zone boundary. Click the first point again to close the polygon.
4. Configure zone options such as **Name**, **Objects**, **Loitering Time**, and **Inertia** in the zone editor.
4. Configure zone options such as **Friendly name**, **Objects**, **Loitering time**, and **Inertia** in the zone editor.
5. Press **Save** when finished.
</TabItem>
@@ -200,7 +200,7 @@ When using loitering zones, a review item will behave in the following way:
1. Navigate to <NavPath path="Settings > Camera configuration > Masks / Zones" /> and select the desired camera.
2. Edit or create the zone (e.g., `sidewalk`).
- Set **Loitering Time** to the desired number of seconds (e.g., `4`)
- Set **Loitering time** to the desired number of seconds (e.g., `4`)
- Under **Objects**, add the relevant object types (e.g., `person`)
</TabItem>
@@ -291,7 +291,7 @@ Accurate real-world distance measurements are required to estimate speeds. These
1. Navigate to <NavPath path="Settings > Camera configuration > Masks / Zones" /> and select the desired camera.
2. Create or edit a zone with exactly 4 points aligned to the ground plane.
3. In the zone editor, enable **Speed Estimation** and enter the real-world **Line A distance**, **Line B distance**, **Line C distance**, and **Line D distance** between each pair of consecutive points.
3. In the zone editor, enter the real-world **Distances** between each pair of consecutive points.
- For example, if the distance between the first and second points is 10 meters, between the second and third is 12 meters, etc.
4. Distances are measured in meters (metric) or feet (imperial), depending on the **Unit system** setting.
@@ -358,7 +358,7 @@ Zones can be configured with a minimum speed requirement, meaning an object must
1. Navigate to <NavPath path="Settings > Camera configuration > Masks / Zones" /> and select the desired camera.
2. Edit or create the zone with distances configured.
- Set **Speed Threshold** to the desired minimum speed (e.g., `20`)
- Set **Speed threshold** to the desired minimum speed (e.g., `20`)
- The unit is kph or mph, depending on the **Unit system** setting
</TabItem>
+2 -2
View File
@@ -54,7 +54,7 @@ An object filter mask drops any [bounding box](#bounding-box) whose bottom cente
## Min Score
The lowest score a detected object can have to be kept during tracking. Anything scoring below the minimum is assumed to be a [false positive](#false-positive) and discarded. Set with `min_score` in the config, shown as **Minimum confidence** in the settings UI.
The lowest score a detected object can have to be kept during tracking. Anything scoring below the minimum is assumed to be a [false positive](#false-positive) and discarded.
## Model
@@ -86,7 +86,7 @@ A more specific identity assigned to a [tracked object](#tracked-object-event-in
## Threshold
The median score an object must reach to be considered a true positive. Set with `threshold` in the config, shown as **Confidence threshold** in the settings UI.
The median score an object must reach to be considered a true positive.
## Top Score
-6
View File
@@ -11,12 +11,6 @@ MQTT requires a network connection to your broker. This is typically local, but
:::
:::note
Wherever a topic below includes a camera, mask, or zone name, use its `ID` from the config, not its `friendly_name`. For example, a camera with `friendly_name: "Back Yard"` and ID `back_yard` publishes to `frigate/back_yard/...`, not `frigate/Back Yard/...`.
:::
## General Frigate Topics
### `frigate/available`
+1 -7
View File
@@ -21,13 +21,7 @@ Yes. Models and metadata are stored in the `model_cache` directory within the co
### Can I keep using my Frigate+ models even if I do not renew my subscription?
Yes. Subscriptions to Frigate+ provide access to the infrastructure used to train the models. Models you train during an active subscription remain licensed for your continued use even after your subscription ends — models already in your model cache will keep working indefinitely. An active subscription is required to train new models and download new versions.
### Can I use Frigate+ models commercially?
A standard subscription covers use on camera systems you own or operate, including for your business. A shop, restaurant, warehouse, or office running Frigate+ at its own locations (including multiple locations) is exactly the kind of use the subscription is for.
What the standard subscription does not cover is using Frigate+ models to provide a product or service to others. If you're deploying models at your customers' sites, bundling them with hardware you sell, or running them as part of a hosted or managed service, even if your customers never receive the model files themselves, you'll need a commercial license.
Note that professional installers are fine under standard subscriptions when each customer holds their own Frigate+ subscription. The commercial license is for cases where your license powers your customers' sites.
Yes. Subscriptions to Frigate+ provide access to the infrastructure used to train the models. Models trained with your subscription are yours to keep and use forever. However, do note that the terms and conditions prohibit you from sharing, reselling, or creating derivative products from the models.
### Why can't I submit images to Frigate+?
+13 -13
View File
@@ -64,20 +64,20 @@ Frigate+ models generally have much higher scores than the default model provide
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > Global configuration > Objects" />. Under **Object filters**, set **Minimum confidence** and **Confidence threshold** for each object type, then click **Save**.
Navigate to <NavPath path="Settings > Global configuration > Objects" />. Under **Object filters**, set **Min Score** and **Threshold** for each object type, then click **Save**.
| Object | Minimum confidence | Confidence threshold |
| ----------------- | ------------------ | -------------------- |
| **dog** | .7 | .9 |
| **cat** | .65 | .8 |
| **face** | .7 | |
| **package** | .65 | .9 |
| **license_plate** | .6 | |
| **amazon** | .75 | |
| **ups** | .75 | |
| **fedex** | .75 | |
| **person** | .65 | .85 |
| **car** | .65 | .85 |
| Object | Min Score | Threshold |
| ----------------- | --------- | --------- |
| **dog** | .7 | .9 |
| **cat** | .65 | .8 |
| **face** | .7 | |
| **package** | .65 | .9 |
| **license_plate** | .6 | |
| **amazon** | .75 | |
| **ups** | .75 | |
| **fedex** | .75 | |
| **person** | .65 | .85 |
| **car** | .65 | .85 |
</TabItem>
<TabItem value="yaml">
+6 -9
View File
@@ -65,11 +65,11 @@ Some users may find that Frigate+ models result in more false positives initiall
Frigate+ models support a more relevant set of objects for security cameras. The labels for annotation in Frigate+ are configurable by editing the camera in the Cameras section of Frigate+. Currently, the following objects are supported:
- **People**: `person`, `face`, `baby`
- **Vehicles**: `car`, `motorcycle`, `bicycle`, `boat`, `school_bus`, `garbage truck`, `license_plate`
- **People**: `person`, `face`
- **Vehicles**: `car`, `motorcycle`, `bicycle`, `boat`, `school_bus`, `license_plate`
- **Delivery Logos**: `amazon`, `usps`, `ups`, `fedex`, `dhl`, `an_post`, `purolator`, `postnl`, `nzpost`, `postnord`, `gls`, `dpd`, `canada_post`, `royal_mail`
- **Animals**: `dog`, `cat`, `deer`, `horse`, `bird`, `raccoon`, `fox`, `bear`, `cow`, `squirrel`, `goat`, `rabbit`, `skunk`, `kangaroo`, `possum`, `rodent`
- **Other**: `package`, `waste_bin`, `bbq_grill`, `robot_lawnmower`, `umbrella`, `baby_stroller`
- **Animals**: `dog`, `cat`, `deer`, `horse`, `bird`, `raccoon`, `fox`, `bear`, `cow`, `squirrel`, `goat`, `rabbit`, `skunk`, `kangaroo`
- **Other**: `package`, `waste_bin`, `bbq_grill`, `robot_lawnmower`, `umbrella`
Other object types available in the default Frigate model are not available. Additional object types will be added in future releases.
@@ -77,12 +77,9 @@ Other object types available in the default Frigate model are not available. Add
Candidate labels are also available for annotation. These labels don't have enough data to be included in the model yet, but using them will help add support sooner. You can enable these labels by editing the camera settings.
Where possible, these labels are mapped to existing labels during training. For example, any `duck` labels are mapped to `bird` until support for new labels is added.
Where possible, these labels are mapped to existing labels during training. For example, any `baby` labels are mapped to `person` until support for new labels is added.
- **Vehicles**: `tractor`, `golf_cart`, `bus`, `airplane`, `helicopter`, `rickshaw`, `scooter`
- **Delivery Logos**: `bpost`, `auspost`, `aramex`, `transoflex`, `parcelforce`, `hermes`, `cargus`, `fan_courier`, `sameday`, `la_poste`
- **Animals**: `badger`, `chicken`, `duck`, `turkey`, `groundhog`, `boar`, `hedgehog`, `wombat`, `bobcat`, `mustelid`, `mountain_lion`, `crocodile`, `monkey`, `coyote`, `porcupine`, `sheep`, `snake`, `lizard`, `heron`, `elk`, `moose`, `pig`, `donkey`, `civet`
- **Other**: `sports_ball`, `drone`, `lawnmower`
The candidate labels are: `baby`, `bpost`, `badger`, `possum`, `rodent`, `chicken`, `groundhog`, `boar`, `hedgehog`, `tractor`, `golf cart`, `garbage truck`, `bus`, `sports ball`, `la_poste`, `lawnmower`, `heron`, `rickshaw`, `wombat`, `auspost`, `aramex`, `bobcat`, `mustelid`, `transoflex`, `airplane`, `drone`, `mountain_lion`, `crocodile`, `turkey`, `baby_stroller`, `monkey`, `coyote`, `porcupine`, `parcelforce`, `sheep`, `snake`, `helicopter`, `lizard`, `duck`, `hermes`, `cargus`, `fan_courier`, `sameday`
Candidate labels are not available for automatic suggestions.
+5 -7
View File
@@ -66,19 +66,17 @@ An FFmpeg message meaning it probed the stream but never saw enough decodable vi
## Recording
<FaqItem id="no-new-recording-segments" question="No new recording segments were created (or: No new valid recording segments were created / No valid segments created since last invalid segment) for <camera> in the last 120s">
<FaqItem id="no-new-recording-segments" question="No new recording segments were created for <camera> in the last 120s">
Frigate's record watchdog is restarting the record FFmpeg process because the camera stopped producing usable recordings. The wording distinguishes the cases: `No new recording segments` means no new segment file reached the cache, so ffmpeg isn't getting video out of the record stream; the two `valid` variants mean recordings are arriving but keep failing validation. Either way the fault is on the camera or network side, and the restart is Frigate trying to recover.
Frigate's record watchdog is restarting the record FFmpeg process because no valid segment has reached the cache. This means the record stream is not connecting or the segments are being rejected (see the audio-codec entry below).
See [Recordings: no new recording segments were created](/troubleshooting/recordings#no-new-recording-segments-were-created).
See [Recordings: the record stream isn't connecting](/troubleshooting/recordings#the-record-stream-isnt-connecting).
</FaqItem>
<FaqItem id="invalid-or-missing-video-stream-in-segment" question="Invalid or missing video stream in segment. Discarding. / Discarding a corrupt recording segment / Failed to probe corrupt segment / Invalid recording segment detected">
<FaqItem id="invalid-or-missing-video-stream-in-segment" question="Invalid or missing video stream in segment. Discarding.">
A cached recording segment failed validation and was deleted, either because it had no readable video stream or because its length was impossible. This nearly always means the camera stopped sending usable video partway through the segment: a camera that rebooted, dropped the connection, or ran out of simultaneous connections, or an unreliable link such as WiFi or a failing switch port. Broken camera timestamps (a "Smart Codec" / H.264+ mode) cause the corrupt-segment variants. The same stream failure trips the record watchdog, so the restarts above usually appear alongside these messages.
See [Recordings: invalid or missing video stream in segment](/troubleshooting/recordings#invalid-or-missing-video-stream-in-segment).
A cached recording segment failed validation (no readable video stream) and was deleted. The most common cause is a segment that was truncated because the record FFmpeg process was killed mid-write, so this often appears alongside, and as a consequence of, the record-stream restarts above. A segment containing only audio triggers it too.
</FaqItem>
-14
View File
@@ -39,20 +39,6 @@ To do this efficiently the following setup is required:
When this is done correctly, the GPU will do the decoding and scaling which will result in a small increase in CPU usage but with better results.
### How can I rotate my camera's video feed?
Rotation is best done in the camera's firmware settings (usually called rotate, flip, or corridor mode) so the video arrives already rotated and no extra processing is needed. Check there first.
If your camera does not support rotation, go2rtc's ffmpeg module can rotate the stream with the `#rotate` parameter (`90`, `180`, `270`, or `-90`), but this is not recommended: rotation requires transcoding (re-encoding) the video, which significantly increases CPU usage, especially for high resolution streams.
```yaml
go2rtc:
streams:
my_camera: "ffmpeg:rtsp://user:password@192.168.1.10:554/stream#video=h264#hardware#rotate=90"
```
Point the camera's inputs at the restream as described in the [restream docs](/configuration/restream.md), and swap `detect -> width` and `detect -> height` to match the rotated resolution.
### My mjpeg stream or snapshots look green and crazy
This almost always means that the width/height defined for your camera are not correct. Double check the resolution with VLC or another player. Also make sure you don't have the width and height values backwards.
+2 -4
View File
@@ -78,9 +78,7 @@ go2rtc:
:::warning
The transcoding modifiers (`#video=`, `#audio=`, `#hardware`, …) **only take effect on a source that is prefixed with `ffmpeg:`**. Adding them to a bare `rtsp://…#audio=opus` source does nothing: go2rtc ignores them. Likewise, when a source references another stream by name (e.g. `ffmpeg:back#audio=aac`), the name must match the stream key **exactly** (it is case sensitive), or the transcode is silently never produced. This is the single most common configuration mistake. In the Frigate UI, the **Use compatibility mode (ffmpeg)** toggle adds the `ffmpeg:` prefix for you.
A bare `rtsp://` source reads a different set of modifiers: `#backchannel=`, `#media=`, `#timeout=`, and `#transport=`. These do nothing on an `ffmpeg:` source. Adding **any** modifier to a bare `rtsp://` source also disables the camera's backchannel unless the URL explicitly contains `#backchannel=1`, so a stream dedicated to two-way talk should carry no modifiers at all.
The `#`-modifiers (`#video=`, `#audio=`, `#hardware`, `#backchannel=0`, …) **only take effect on a source that is prefixed with `ffmpeg:`**. Adding them to a bare `rtsp://…#audio=opus` source does nothing: go2rtc ignores them. Likewise, when a source references another stream by name (e.g. `ffmpeg:back#audio=aac`), the name must match the stream key **exactly** (it is case sensitive), or the transcode is silently never produced. This is the single most common configuration mistake. In the Frigate UI, the **Use compatibility mode (ffmpeg)** toggle adds the `ffmpeg:` prefix for you.
:::
@@ -155,7 +153,7 @@ WebRTC is only attempted when MSE fails or when using a camera's two-way talk fe
- **Codec mismatch**: WebRTC cannot carry H.265 or AAC. The stream backing the WebRTC view must provide Opus (or PCMA/PCMU) audio and H.264 video. Add an `ffmpeg:back#audio=opus` source as shown above.
- **Port `8555` not reachable, or no candidates set**: WebRTC needs port `8555` (both TCP and UDP) open and a reachable candidate advertised. On Docker installs running on a custom/overlay network, go2rtc may advertise unreachable container IPs as ICE candidates; setting `webrtc.filters.candidates: []` and supplying only your host's LAN IP resolves this. See [WebRTC extra configuration](/configuration/live#webrtc-extra-configuration).
- **Two-way talk** additionally requires a secure context (HTTPS or the authenticated port `8971`, because browsers block microphone access on plain HTTP). The camera's RTSP backchannel must also be handled correctly: go2rtc seizes the backchannel by default, which blocks two-way audio for other consumers and can inject static. Disable it on the primary stream with `#backchannel=0` and use a separate dedicated stream for talk, carrying no `#` modifiers of any kind, as documented in [preventing go2rtc from blocking two-way audio](/configuration/restream#two-way-talk-restream).
- **Two-way talk** additionally requires a secure context (HTTPS or the authenticated port `8971`, because browsers block microphone access on plain HTTP). The camera's RTSP backchannel must also be handled correctly: go2rtc seizes the backchannel by default, which blocks two-way audio for other consumers and can inject static. Disable it on the primary stream with `#backchannel=0` and use a separate dedicated stream for talk, as documented in [preventing go2rtc from blocking two-way audio](/configuration/restream#two-way-talk-restream).
## High CPU usage
+10 -46
View File
@@ -209,50 +209,6 @@ If the record stream uses a "Smart Codec"/H.264+ mode or changes encoding parame
</FaqItem>
<FaqItem id="invalid-or-missing-video-stream-in-segment" question="I see the message: WARNING : Invalid or missing video stream in segment ... Discarding.">
Every recording segment is validated before it leaves the cache. Frigate probes each finished `.mp4` in `/tmp/cache` and requires a readable video stream and a valid duration before moving to storage. A segment that fails is deleted, so those ~10 seconds of footage are lost. Three messages come from this check:
- `Invalid or missing video stream in segment <path>. Discarding.` The segment holds no video, or could not be read at all.
- `Failed to probe corrupt segment <path>` followed by `Discarding a corrupt recording segment: <path>`. The segment was read, but its length could not be determined.
- `Discarding a corrupt recording segment: <path>` on its own. The segment's length is impossible (empty, or longer than ten minutes), which points at broken timestamps coming from the camera.
For each one, the camera watchdog also logs `Invalid recording segment detected for <camera> at <timestamp>`.
:::warning
This is almost always a **camera or network problem**, not a Frigate one. A segment is only complete once ffmpeg has finished writing it, so anything that interrupts the stream partway through leaves behind a file that cannot be saved. Frigate is reporting the interruption, not causing it.
:::
#### Start with the camera and the network
- **The camera dropped the connection.** Cameras reboot, reinitialize their stream when switching to night mode, and cut clients off when they are overloaded or out of simultaneous connections. Count everything pulling from the camera at once: Frigate's detect and record streams, go2rtc, a phone app, and any other NVR each use one. Routing all roles through a single [RTSP restream](/configuration/restream#reduce-connections-to-camera) so the camera only ever sees one connection often resolves this by itself.
- **The link to the camera is unreliable.** WiFi cameras, powerline adapters, a saturated uplink, a failing switch port, or a marginal cable all produce this pattern, and usually only on one camera at a time. WiFi cameras are [not recommended](https://ipcamtalk.com/threads/multiple-cameras-high-bandwidth.77100/#post-861110).
- **The camera cannot reliably send what it is being asked for.** A high bitrate 4K stream can be more than the camera's own hardware can encode and push out under load. Lower the bitrate, or record a lower-resolution profile.
- **The camera is using a "Smart Codec", H.264+, or H.265+ mode.** These change encoding parameters mid-stream and produce the broken timestamps behind the corrupt-segment variant. Turn the mode off and set the camera's keyframe interval equal to its frame rate. See [Segments are only ~1 second long](#segments-are-only-1-second-long).
Read the rest of the Frigate and/or go2rtc log around the **first** occurrence. When the camera or the network is at fault, other messages show up with it, such as `No frames received from <camera> in 20 seconds`, `Non-monotonic DTS`, `RTP: PT=xx: bad cseq`, `error while decoding MB`, or a connection timeout. Each of those is explained in [Common error messages](/troubleshooting/common_errors). To confirm the camera is the source, open its stream in the [go2rtc web interface](/troubleshooting/go2rtc) on port `1984` or play the same URL in VLC, and leave it running long enough for the failures to happen again.
#### If the camera and network check out
- **Audio the recording cannot store.** Some cameras send G.711 audio, which cannot be saved in an MP4 and stops segments from finalizing. See [Incompatible audio codec](#incompatible-audio-codec-recordings-silently-fail-to-save).
- **Frigate itself was stopped or restarted.** A single warning per camera around a restart is expected and needs no action.
- **The system ran out of room or memory.** A full `/tmp/cache`, or the host killing Frigate for using too much memory, cuts off the segment being written. Both leave other errors in the log alongside this one. See [No space left on device](#errno-28-no-space-left-on-device).
</FaqItem>
<FaqItem id="no-new-recording-segments-were-created" question="I see the message: ERROR : No new recording segments were created for <camera> in the last 120s. Restarting the ffmpeg record process...">
When a camera stops producing usable recordings for two minutes, Frigate restarts that camera's record process to try to recover. The wording tells you how far the recordings got:
- **`No new recording segments were created`**: no new segment file showed up in the cache at all, so ffmpeg isn't getting video out of the record stream. The camera is unreachable or refusing the connection, the stream URL, path, or credentials are wrong, or the camera accepted the connection and then sent nothing. See [The record stream isn't connecting](#the-record-stream-isnt-connecting).
- **`No new valid recording segments were created`** and **`No valid segments created since last invalid segment`**: recordings are arriving, but they keep failing validation, so the camera is sending video that cannot be saved. See [Invalid or missing video stream in segment](#invalid-or-missing-video-stream-in-segment) above.
The restart is Frigate recovering from a problem, not causing one. One of these after a camera reboot or a brief network drop is normal. Seeing them repeat every couple of minutes means the camera or the network is still failing, and the restarts can extend the damage, because each one cuts off the segment that was being written. Work from the earliest failure in that camera's log rather than from the restarts.
</FaqItem>
<FaqItem id="i-see-the-message-warning--unable-to-keep-up-with-recording-segments-in-cache-for-camera-keeping-the-5-most-recent-segments-out-of-6-and-discarding-the-rest" question="I see the message: WARNING : Unable to keep up with recording segments in cache for camera. Keeping the 5 most recent segments out of 6 and discarding the rest...">
This warning means the recording maintainer cannot move recording segments from the RAM cache to disk fast enough. When the cache fills up, Frigate discards the oldest segments to avoid running out of memory and crashing, so you lose recorded footage. This is almost always a storage throughput or system resource problem. Work through the steps below to identify which.
@@ -397,11 +353,19 @@ dmesg | grep -i -E "gpu|drm|reset|hang"
Messages like `trying reset from guc_exec_queue_timedout_job` or similar GPU reset/hang messages indicate a driver or hardware issue. Ensure your kernel and GPU drivers (especially Intel) are up to date.
#### Step 6: Verify go2rtc stream configuration
#### Step 6: Verify hardware acceleration configuration
An incorrect `hwaccel_args` preset can cause ffmpeg to fail silently or consume excessive CPU, starving the detector of resources.
- After upgrading Frigate, verify your preset matches your hardware (e.g., `preset-intel-qsv-h264` instead of the deprecated `preset-vaapi`).
- For h265 cameras, use the corresponding h265 preset (e.g., `preset-intel-qsv-h265`).
- Note that `hwaccel_args` are only relevant for the detect stream. Frigate does not decode the record stream.
#### Step 7: Verify go2rtc stream configuration
Ensure that the ffmpeg source names in your go2rtc configuration match the correct camera stream. A misconfigured stream name (e.g., copying a config from one camera to another without updating the stream reference) will cause the wrong stream to be used or the stream to fail entirely.
#### Step 7: Check system resources
#### Step 8: Check system resources
If none of the above apply, the issue may be a general resource constraint. Monitor the following on your host:
+22 -30
View File
@@ -3,9 +3,6 @@ import * as path from "node:path";
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 config: Config = {
title: "Frigate",
tagline: "NVR With Realtime Object Detection for IP Cameras",
@@ -26,17 +23,17 @@ const config: Config = {
mermaid: true,
},
i18n: {
defaultLocale: "en",
locales: ["en"],
defaultLocale: 'en',
locales: ['en'],
localeConfigs: {
en: {
label: "English",
},
label: 'English',
}
},
},
themeConfig: {
announcementBar: {
id: "frigate_plus",
id: 'frigate_plus',
content: `
<span style="margin-right: 8px; display: inline-block; animation: pulse 2s infinite;">🚀</span>
Get more relevant and accurate detections with Frigate+ models.
@@ -48,8 +45,8 @@ const config: Config = {
50% { transform: scale(1.1); }
}
</style>`,
backgroundColor: "#005f73",
textColor: "#e0fbfc",
backgroundColor: '#005f73',
textColor: '#e0fbfc',
isCloseable: false,
},
docs: {
@@ -86,15 +83,15 @@ const config: Config = {
},
},
prism: {
magicComments: [
magicComments:[
{
className: "theme-code-block-highlighted-line",
line: "highlight-next-line",
block: { start: "highlight-start", end: "highlight-end" },
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",
className: 'code-block-error-line',
line: 'highlight-error-line',
},
],
additionalLanguages: ["bash", "json"],
@@ -134,11 +131,6 @@ const config: Config = {
srcDark: "img/branding/logo-dark.svg",
},
items: [
{
href: "https://github.com/blakeblackshear/frigate/releases",
label: `${STABLE_VERSION}`,
position: "left",
},
{
to: "/",
activeBasePath: "docs",
@@ -156,19 +148,19 @@ const config: Config = {
position: "right",
},
{
type: "localeDropdown",
position: "right",
type: 'localeDropdown',
position: 'right',
dropdownItemsAfter: [
{
label: "简体中文(社区翻译)",
href: "https://docs.frigate-cn.video",
},
],
label: '简体中文(社区翻译)',
href: 'https://docs.frigate-cn.video',
}
]
},
{
href: "https://github.com/blakeblackshear/frigate",
label: "GitHub",
position: "right",
href: 'https://github.com/blakeblackshear/frigate',
label: 'GitHub',
position: 'right',
},
],
},
+6 -26
View File
@@ -1476,7 +1476,7 @@ paths:
- Classification
summary: Get custom classification attributes
description: |-
**Access:** Authenticated user with access to all cameras.
**Access:** Admin role required.
Returns custom classification attributes for a given object type.
Only includes models with classification_type set to 'attribute'.
@@ -1510,8 +1510,8 @@ paths:
schema:
$ref: '#/components/schemas/HTTPValidationError'
security:
- frigateUserAuth: []
x-required-role: all_cameras
- frigateAdminAuth: []
x-required-role: admin
/classification/{name}/train:
get:
tags:
@@ -2316,7 +2316,7 @@ paths:
- Review
summary: Generate Review Summary
description: |-
**Access:** Authenticated user with access to all cameras.
**Access:** Admin role required.
Use GenAI to summarize review items over a period of time.
operationId:
@@ -2347,8 +2347,8 @@ paths:
schema:
$ref: '#/components/schemas/HTTPValidationError'
security:
- frigateUserAuth: []
x-required-role: all_cameras
- frigateAdminAuth: []
x-required-role: admin
/:
get:
tags:
@@ -4073,16 +4073,6 @@ paths:
- type: 'null'
default: 100
title: Limit
- name: offset
in: query
required: false
schema:
anyOf:
- type: integer
minimum: 0
- type: 'null'
default: 0
title: Offset
- name: after
in: query
required: false
@@ -4388,16 +4378,6 @@ paths:
- type: 'null'
default: 50
title: Limit
- name: offset
in: query
required: false
schema:
anyOf:
- type: integer
minimum: 0
- type: 'null'
default: 0
title: Offset
- name: cameras
in: query
required: false
+3 -26
View File
@@ -85,7 +85,6 @@ def require_admin_by_default():
"/sub_labels",
"/plus/models",
"/recognized_license_plates",
"/classification/attributes",
"/timeline",
"/timeline/hourly",
"/recordings/storage",
@@ -972,7 +971,6 @@ def delete_user(request: Request, username: str):
summary="Update user password",
description="Updates a user's password. Users can only change their own password unless they have admin role. Requires the current password to verify identity for non-admin users. Password must be at least 12 characters long. If user changes their own password, a new JWT cookie is automatically issued.",
)
@limiter.limit(limit_value=rateLimiter.get_limit)
async def update_password(
request: Request,
username: str,
@@ -986,11 +984,10 @@ async def update_password(
current_username = current_user.get("username")
current_role = current_user.get("role")
# Only admins may target another account. This has to cover every non-admin
# role rather than just viewer, since custom roles are arbitrary names
if current_role != "admin" and current_username != username:
# viewers can only change their own password
if current_role == "viewer" and current_username != username:
raise HTTPException(
status_code=403, detail="Users can only update their own password"
status_code=403, detail="Viewers can only update their own password"
)
HASH_ITERATIONS = request.app.frigate_config.auth.hash_iterations
@@ -1254,23 +1251,3 @@ async def get_allowed_cameras_for_filter(request: Request):
all_camera_names = set(request.app.frigate_config.cameras.keys())
roles_dict = request.app.frigate_config.auth.roles
return User.get_allowed_cameras(role, roles_dict, all_camera_names)
async def require_full_camera_access(
request: Request,
allowed_cameras: list[str] = Depends(get_allowed_cameras_for_filter),
):
"""Dependency for endpoints returning data that spans every camera.
Some responses cannot be meaningfully scoped to a subset of cameras, so
rather than filter them the endpoint is limited to callers who can already
see every camera. Admin and viewer always qualify; a custom role qualifies
only when its camera list covers all configured cameras.
"""
all_camera_names = set(request.app.frigate_config.cameras.keys())
if not all_camera_names.issubset(allowed_cameras):
raise HTTPException(
status_code=403,
detail="Access to all cameras is required for this endpoint",
)
+1 -2
View File
@@ -14,7 +14,7 @@ from fastapi.responses import JSONResponse
from peewee import DoesNotExist
from playhouse.shortcuts import model_to_dict
from frigate.api.auth import require_full_camera_access, require_role
from frigate.api.auth import require_role
from frigate.api.defs.request.classification_body import (
AudioTranscriptionBody,
DeleteFaceImagesBody,
@@ -741,7 +741,6 @@ def get_classification_dataset(name: str):
@router.get(
"/classification/attributes",
dependencies=[Depends(require_full_camera_access)],
summary="Get custom classification attributes",
description="""Returns custom classification attributes for a given object type.
Only includes models with classification_type set to 'attribute'.
@@ -14,7 +14,6 @@ class EventsQueryParams(BaseModel):
zone: str | None = "all"
zones: str | None = "all"
limit: int | None = 100
offset: int | None = Field(0, ge=0)
after: float | None = None
before: float | None = None
time_range: str | None = DEFAULT_TIME_RANGE
@@ -56,7 +55,6 @@ class EventsSearchQueryParams(BaseModel):
deprecated=True,
)
limit: int | None = 50
offset: int | None = Field(0, ge=0)
cameras: str | None = "all"
labels: str | None = "all"
sub_labels: str | None = "all"
+2 -11
View File
@@ -129,7 +129,6 @@ def events(
zones = zone
limit = params.limit
offset = params.offset
after = params.after
before = params.before
time_range = params.time_range
@@ -362,15 +361,11 @@ def events(
else:
order_by = Event.start_time.desc()
# offset paging needs a stable order when scores or speeds tie
tiebreaker = [Event.id] if sort and sort.startswith(("score", "speed")) else []
events = (
Event.select(*selected_columns)
.where(reduce(operator.and_, clauses))
.order_by(order_by, *tiebreaker)
.order_by(order_by)
.limit(limit)
.offset(offset)
.dicts()
.iterator()
)
@@ -523,7 +518,6 @@ def events_search(
search_type = params.search_type
include_thumbnails = params.include_thumbnails
limit = params.limit
offset = params.offset
sort = params.sort
# Filters
@@ -830,9 +824,6 @@ def events_search(
if search_results:
events_query = events_query.where(Event.id << list(search_results.keys()))
# sorts below are stable, so this orders ties for offset paging
events_query = events_query.order_by(Event.id)
# Fetch events and process them in a single pass
processed_events = []
for event in events_query.dicts():
@@ -890,7 +881,7 @@ def events_search(
processed_events.sort(key=lambda x: x["start_time"], reverse=True)
# Limit the number of events returned
processed_events = processed_events[offset:][:limit]
processed_events = processed_events[:limit]
return JSONResponse(content=processed_events)
+2 -22
View File
@@ -9,7 +9,6 @@ import zipfile
from collections import deque
from collections.abc import Iterator
from pathlib import Path
from urllib.parse import quote
import psutil
from fastapi import APIRouter, Depends, Query, Request
@@ -69,7 +68,6 @@ from frigate.jobs.export import (
from frigate.models import Export, ExportCase, Previews, Recordings
from frigate.record.export import (
DEFAULT_TIME_LAPSE_FFMPEG_ARGS,
DEFAULT_TIME_LAPSE_FFMPEG_INPUT_ARGS,
ChaptersEnum,
PlaybackSourceEnum,
validate_ffmpeg_args,
@@ -455,22 +453,6 @@ def _stream_case_archive(exports: list[Export]) -> Iterator[bytes]:
yield from buffer.drain()
def _content_disposition(filename: str, ascii_fallback: str) -> str:
"""Build an attachment Content-Disposition that survives non-ASCII names.
Header values are encoded as latin-1, so a name outside that range cannot
go in filename at all. RFC 6266 handles this with a pair: a plain ASCII
filename for old clients, plus a percent-encoded UTF-8 filename* that
every current browser prefers.
"""
ascii_name = filename if filename.isascii() else ascii_fallback
return (
f'attachment; filename="{ascii_name}"; '
f"filename*=UTF-8''{quote(filename, safe='')}"
)
@router.get(
"/cases/{case_id}/download",
dependencies=[Depends(allow_any_authenticated())],
@@ -513,9 +495,7 @@ def download_export_case(
_stream_case_archive(exports),
media_type="application/zip",
headers={
"Content-Disposition": _content_disposition(
f"{archive_base}.zip", f"{case_id}.zip"
),
"Content-Disposition": f'attachment; filename="{archive_base}.zip"',
},
)
@@ -1013,7 +993,7 @@ def export_recording_custom(
# Set default values if not provided (timelapse defaults)
if ffmpeg_input_args is None:
ffmpeg_input_args = DEFAULT_TIME_LAPSE_FFMPEG_INPUT_ARGS
ffmpeg_input_args = ""
if ffmpeg_output_args is None:
ffmpeg_output_args = DEFAULT_TIME_LAPSE_FFMPEG_ARGS
-102
View File
@@ -1,10 +1,8 @@
"""Notification apis."""
import ipaddress
import logging
import os
from typing import Any
from urllib.parse import urlparse
from cryptography.hazmat.primitives import serialization
from fastapi import APIRouter, Depends, Request
@@ -21,95 +19,6 @@ logger = logging.getLogger(__name__)
router = APIRouter(tags=[Tags.notifications])
# Push endpoints are opaque URLs but stay well under this in practice
MAX_ENDPOINT_LENGTH = 2048
# Suffixes that only ever resolve on the local network
INTERNAL_HOST_SUFFIXES = (".local", ".localdomain", ".internal", ".home.arpa")
def _validate_push_endpoint(endpoint: Any) -> str | None:
"""Return a reason the endpoint is unusable, or None when it is valid.
Subscriptions are issued by the browser vendor's push service, so a valid
endpoint is always a public https URL. Anything else is either a broken
registration or an attempt to aim the notification sender somewhere it
should not reach.
"""
if not isinstance(endpoint, str) or not endpoint:
return "endpoint must be a url"
if len(endpoint) > MAX_ENDPOINT_LENGTH:
return "endpoint is too long"
try:
parsed = urlparse(endpoint)
port = parsed.port
except ValueError:
return "endpoint is not a valid url"
if parsed.scheme != "https":
return "endpoint must use https"
if parsed.username or parsed.password:
return "endpoint must not include credentials"
if port is not None and port != 443:
return "endpoint must use the default https port"
hostname = parsed.hostname
if not hostname:
return "endpoint must include a hostname"
try:
address = ipaddress.ip_address(hostname)
except ValueError:
address = None
if address is not None:
# A push service is never reachable at an address only this network can
# route, so anything non-global is a misconfiguration at best
if not address.is_global:
return "endpoint must not use a private address"
elif hostname == "localhost" or "." not in hostname:
return "endpoint must use a fully qualified hostname"
elif hostname.endswith(INTERNAL_HOST_SUFFIXES):
return "endpoint must not use an internal hostname"
# The subscription token lives in the path, and webpush.py assumes there is
# a separator after the host when it builds the VAPID audience
if len(parsed.path) <= 1:
return "endpoint must include a subscription path"
return None
def _validate_subscription(sub: Any) -> str | None:
"""Return a reason the subscription is unusable, or None when it is valid."""
if not isinstance(sub, dict):
return "subscription must be an object"
reason = _validate_push_endpoint(sub.get("endpoint"))
if reason:
return reason
keys = sub.get("keys")
if not isinstance(keys, dict):
return "subscription must include keys"
# WebPusher raises on a missing key, which would break every send for the
# user rather than just this registration
for name in ("p256dh", "auth"):
value = keys.get(name)
if not isinstance(value, str) or not value:
return f"subscription keys must include {name}"
return None
@router.get(
"/notifications/pubkey",
@@ -162,17 +71,6 @@ def register_notifications(request: Request, body: dict = None):
status_code=400,
)
reason = _validate_subscription(sub)
if reason:
logger.warning(
"Rejected notification registration for %s: %s", username, reason
)
return JSONResponse(
content={"success": False, "message": f"Invalid subscription: {reason}"},
status_code=400,
)
try:
User.update(notification_tokens=User.notification_tokens.append(sub)).where(
User.username == username
+12 -25
View File
@@ -17,7 +17,6 @@ from frigate.api.auth import (
get_allowed_cameras_for_filter,
get_current_user,
require_camera_access,
require_full_camera_access,
require_role,
)
from frigate.api.defs.query.review_query_parameters import (
@@ -43,22 +42,6 @@ logger = logging.getLogger(__name__)
router = APIRouter(tags=[Tags.review])
def get_label_clause(label: str, include_audio: bool = True):
"""Build a clause matching a label within a review segment's data.
Verified objects are stored with a `-verified` suffix (eg. `person-verified`)
so that variant is matched as well.
"""
clause = (ReviewSegment.data["objects"].cast("text") % f'*"{label}"*') | (
ReviewSegment.data["objects"].cast("text") % f'*"{label}-verified"*'
)
if include_audio:
clause |= ReviewSegment.data["audio"].cast("text") % f'*"{label}"*'
return clause
@router.get(
"/review",
response_model=list[ReviewSegmentResponse],
@@ -108,7 +91,10 @@ async def review(
filtered_labels = labels.split(",")
for label in filtered_labels:
label_clauses.append(get_label_clause(label))
label_clauses.append(
(ReviewSegment.data["objects"].cast("text") % f'*"{label}"*')
| (ReviewSegment.data["audio"].cast("text") % f'*"{label}"*')
)
clauses.append(reduce(operator.or_, label_clauses))
if zones != "all":
@@ -249,7 +235,10 @@ async def review_summary(
filtered_labels = labels.split(",")
for label in filtered_labels:
label_clauses.append(get_label_clause(label))
label_clauses.append(
(ReviewSegment.data["objects"].cast("text") % f'*"{label}"*')
| (ReviewSegment.data["audio"].cast("text") % f'*"{label}"*')
)
clauses.append(reduce(operator.or_, label_clauses))
if zones != "all":
# use matching so segments with multiple zones
@@ -347,8 +336,9 @@ async def review_summary(
filtered_labels = labels.split(",")
for label in filtered_labels:
label_clauses.append(get_label_clause(label, include_audio=False))
label_clauses.append(
ReviewSegment.data["objects"].cast("text") % f'*"{label}"*'
)
clauses.append(reduce(operator.or_, label_clauses))
# Find the time range of available data
@@ -756,12 +746,9 @@ async def set_not_reviewed(
)
# Intentionally not camera scoped, as the summary correlates each flagged event
# with overlapping activity on other cameras. Restricted to callers who can
# already see every camera, so the unscoped query discloses nothing.
@router.post(
"/review/summarize/start/{start_ts}/end/{end_ts}",
dependencies=[Depends(require_full_camera_access)],
dependencies=[Depends(require_role(["admin"]))],
description="Use GenAI to summarize review items over a period of time.",
)
def generate_review_summary(request: Request, start_ts: float, end_ts: float):
+4 -6
View File
@@ -103,13 +103,12 @@ class CameraActivityManager:
all_objects: list[dict[str, Any]] = []
for camera in new_activity.keys():
camera_config = self.config.cameras.get(camera)
if camera_config is None:
if camera not in self.config.cameras:
continue
# handle cameras that were added dynamically
if camera not in self.camera_all_object_counts:
self.__init_camera(camera_config)
self.__init_camera(self.config.cameras[camera])
new_objects = new_activity[camera].get("objects", [])
all_objects.extend(new_objects)
@@ -234,13 +233,12 @@ class AudioActivityManager:
now = datetime.datetime.now().timestamp()
for camera in new_activity.keys():
camera_config = self.config.cameras.get(camera)
if camera_config is None:
if camera not in self.config.cameras:
continue
# handle cameras that were added dynamically
if camera not in self.current_audio_detections:
self.__init_camera(camera_config)
self.__init_camera(self.config.cameras[camera])
new_detections = new_activity[camera].get("detections", [])
if self.compare_audio_activity(camera, new_detections, now):
+2 -7
View File
@@ -60,11 +60,6 @@ class CameraState:
# face/LPR pipelines when using a model without built-in detection.
self.face_recognition_min_obj_area: int = 0
self.lpr_min_obj_area: int = 0
self.lp_objects = {
label
for label, attributes in config.model.attributes_map.items()
if "license_plate" in attributes
}
if (
self.camera_config.face_recognition.enabled
@@ -457,7 +452,7 @@ class CameraState:
and obj_area >= self.face_recognition_min_obj_area
and updated_obj.obj_data.get("sub_label") is None
) or (
obj_label in self.lp_objects
obj_label in ("car", "motorcycle")
and self.lpr_min_obj_area > 0
and obj_area >= self.lpr_min_obj_area
and updated_obj.obj_data.get("sub_label") is None
@@ -553,7 +548,7 @@ class CameraState:
current_best.thumbnail_data is not None
and obj.thumbnail_data is not None
and is_better_thumbnail(
obj.thumbnail_attributes,
object_type,
current_best.thumbnail_data,
obj.thumbnail_data,
self.camera_config.frame_shape,
-1
View File
@@ -421,7 +421,6 @@ class WebPushClient(Communicator):
# Don't notify if message is an update and important fields don't have an update
if (
state == "update"
and payload["before"]["severity"] == payload["after"]["severity"]
and len(payload["before"]["data"]["objects"])
== len(payload["after"]["data"]["objects"])
and len(payload["before"]["data"]["zones"])
-4
View File
@@ -44,11 +44,7 @@ DEFAULT_ATTRIBUTE_LABEL_MAP = {
"ups",
"usps",
],
"truck": ["license_plate"],
"garbage_truck": ["license_plate"],
"motorcycle": ["license_plate"],
"bus": ["license_plate"],
"school_bus": ["license_plate"],
}
ATTRIBUTE_LABEL_DISPLAY_MAP = {
"amazon": "Amazon",
@@ -1290,7 +1290,7 @@ class LicensePlateProcessingMixin:
and obj_data.get("label") != "license_plate"
):
logger.debug(
f"{camera}: Not a processing license plate for {obj_data.get('label', 'unknown')}."
f"{camera}: Not a processing license plate for non car/motorcycle object."
)
return
@@ -1367,7 +1367,7 @@ class LicensePlateProcessingMixin:
if not license_plate:
logger.debug(
f"{camera}: Detected no license plates for {obj_data.get('label', 'unknown')} object."
f"{camera}: Detected no license plates for car/motorcycle object."
)
return
+15 -76
View File
@@ -1,10 +1,8 @@
import logging
import sqlite3
import threading
from typing import Any
import regex
from peewee import DatabaseError
from playhouse.sqliteq import SqliteQueueDatabase
logger = logging.getLogger(__name__)
@@ -19,7 +17,6 @@ class SqliteVecQueueDatabase(SqliteQueueDatabase):
self.load_vec_extension: bool = load_vec_extension
# no extension necessary, sqlite will load correctly for each platform
self.sqlite_vec_path = "/usr/local/lib/vec0"
self.upsert_lock = threading.Lock()
super().__init__(*args, **kwargs)
def _connect(self, *args: Any, **kwargs: Any) -> sqlite3.Connection:
@@ -56,22 +53,6 @@ class SqliteVecQueueDatabase(SqliteQueueDatabase):
conn.create_function("REGEXP", 2, regexp)
def execute_write(self, sql: str, params: Any = None) -> None:
"""Run a write and wait for it, so that failures are raised here.
SqliteQueueDatabase hands non-SELECT statements to a writer thread and
stores any exception on the cursor it returns, so callers that ignore
that cursor never learn the write failed.
"""
self.execute_sql(sql, params).fetchall()
def _table_exists(self, table: str) -> bool:
cursor = self.execute_sql(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?",
(table,),
)
return cursor.fetchone() is not None
def _delete_embeddings(self, table: str, event_ids: list[str]) -> None:
"""Delete embeddings for the given events, if the table exists.
@@ -82,17 +63,17 @@ class SqliteVecQueueDatabase(SqliteQueueDatabase):
return
# the embeddings tables are only created once semantic search has run
if not self._table_exists(table):
cursor = self.execute_sql(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?",
(table,),
)
if cursor.fetchone() is None:
logger.debug("Skipping %s cleanup, table does not exist", table)
return
ids = ",".join(["?" for _ in event_ids])
# callers treat cleanup as best effort, so log rather than propagate
try:
self.execute_write(f"DELETE FROM {table} WHERE id IN ({ids})", event_ids)
except DatabaseError:
logger.exception("Failed to delete embeddings from %s", table)
self.execute_sql(f"DELETE FROM {table} WHERE id IN ({ids})", event_ids)
def delete_embeddings_thumbnail(self, event_ids: list[str]) -> None:
self._delete_embeddings("vec_thumbnails", event_ids)
@@ -100,67 +81,25 @@ class SqliteVecQueueDatabase(SqliteQueueDatabase):
def delete_embeddings_description(self, event_ids: list[str]) -> None:
self._delete_embeddings("vec_descriptions", event_ids)
def _restore_vec_info_table(self, table: str) -> None:
"""Recreate the _info shadow table a legacy vec0 table is missing.
sqlite-vec added _info in 0.1.6 and drops it unconditionally when a
table is destroyed, so tables written by Frigate 0.17 and earlier fail
to drop. An empty stub is enough, and leaving it unseeded keeps the
table reading as pre-0.1.10 if the drop does not follow.
"""
if not self._table_exists(table) or self._table_exists(f"{table}_info"):
return
logger.debug("Restoring the %s_info shadow table before dropping", table)
self.execute_write(
f'CREATE TABLE "{table}_info" (key TEXT PRIMARY KEY, value ANY)'
)
def drop_embeddings_tables(self) -> None:
for table in ("vec_descriptions", "vec_thumbnails"):
self._restore_vec_info_table(table)
self.execute_write(f"DROP TABLE IF EXISTS {table}")
self.execute_sql("""
DROP TABLE vec_descriptions;
""")
self.execute_sql("""
DROP TABLE vec_thumbnails;
""")
def create_embeddings_tables(self) -> None:
"""Create vec0 virtual table for embeddings"""
self.execute_write("""
self.execute_sql("""
CREATE VIRTUAL TABLE IF NOT EXISTS vec_thumbnails USING vec0(
id TEXT PRIMARY KEY,
thumbnail_embedding FLOAT[768] distance_metric=cosine
);
""")
self.execute_write("""
self.execute_sql("""
CREATE VIRTUAL TABLE IF NOT EXISTS vec_descriptions USING vec0(
id TEXT PRIMARY KEY,
description_embedding FLOAT[768] distance_metric=cosine
);
""")
def upsert_embeddings(
self, table: str, column: str, embeddings: dict[str, bytes]
) -> None:
"""Write embeddings for the given event ids, replacing any that exist.
vec0 implements neither REPLACE nor UPSERT, so rows that are already
there have to be deleted first.
"""
if not embeddings:
return
event_ids = list(embeddings.keys())
ids = ",".join(["?" for _ in event_ids])
params: list[Any] = []
for event_id in event_ids:
params.extend((event_id, embeddings[event_id]))
values = ", ".join(["(?, ?)"] * len(event_ids))
# reindexing and live embedding run on separate threads, and each write
# is queued separately, so the delete and the insert have to be held
# together or an interleaved pair fails on the vec0 primary key
with self.upsert_lock:
self.execute_write(f"DELETE FROM {table} WHERE id IN ({ids})", event_ids)
self.execute_write(
f"INSERT INTO {table}(id, {column}) VALUES {values}", params
)
+56 -62
View File
@@ -25,31 +25,25 @@ def is_arm64_platform() -> bool:
return machine in ("aarch64", "arm64", "armv8", "armv7l")
def get_ort_session_options(model_type: str | None = None) -> ort.SessionOptions | None:
def get_ort_session_options(
is_complex_model: bool = False,
) -> ort.SessionOptions | None:
"""Get ONNX Runtime session options with appropriate settings.
Args:
model_type: Model being loaded, used to pin its graph optimization level.
is_complex_model: Whether the model needs basic optimization to avoid graph fusion issues.
Returns:
SessionOptions with a pinned optimization level, or None for default settings.
SessionOptions with appropriate optimization level, or None for default settings.
"""
# Import here to avoid circular imports
from frigate.embeddings.types import EnrichmentModelTypeEnum
if is_complex_model:
sess_options = ort.SessionOptions()
sess_options.graph_optimization_level = (
ort.GraphOptimizationLevel.ORT_ENABLE_BASIC
)
return sess_options
if model_type == EnrichmentModelTypeEnum.jina_v2.value:
# below EXTENDED the CUDA EP returns an identical vector for every image,
# and ORT_ENABLE_ALL fails to build on CPU with a SimplifiedLayerNormFusion error
level = ort.GraphOptimizationLevel.ORT_ENABLE_EXTENDED
elif model_type == EnrichmentModelTypeEnum.jina_v1.value:
# aggressive optimizations create or expect nodes that don't exist
level = ort.GraphOptimizationLevel.ORT_ENABLE_BASIC
else:
return None
sess_options = ort.SessionOptions()
sess_options.graph_optimization_level = level
return sess_options
return None
# Import OpenVINO only when needed to avoid circular dependencies
@@ -121,6 +115,21 @@ class BaseModelRunner(ABC):
class ONNXModelRunner(BaseModelRunner):
"""Run ONNX models using ONNX Runtime."""
@staticmethod
def is_cpu_complex_model(model_type: str) -> bool:
"""Check if model needs basic optimization level to avoid graph fusion issues.
Some models (like Jina-CLIP) have issues with aggressive optimizations like
SimplifiedLayerNormFusion that create or expect nodes that don't exist.
"""
# Import here to avoid circular imports
from frigate.embeddings.types import EnrichmentModelTypeEnum
return model_type in [
EnrichmentModelTypeEnum.jina_v1.value,
EnrichmentModelTypeEnum.jina_v2.value,
]
@staticmethod
def is_migraphx_complex_model(model_type: str) -> bool:
# Import here to avoid circular imports
@@ -199,20 +208,15 @@ class CudaGraphRunner(BaseModelRunner):
EnrichmentModelTypeEnum.yolov9_license_plate.value,
]
# ORT performs two regular runs before it starts capturing, but on some
# driver / cuDNN combinations the arena still has to extend on the run that
# captures, and cudaMalloc is not allowed during capture. Running with
# capture disabled first keeps those allocations outside of the capture.
GRAPH_FREE_WARMUP_RUNS = 2
def __init__(self, session: ort.InferenceSession, cuda_device_id: int):
self._session = session
self._cuda_device_id = cuda_device_id
self._prepared = False
self._captured = False
self._io_binding: ort.IOBinding | None = None
self._input_name: str | None = None
self._output_names: list[str] | None = None
self._input_ortvalue: ort.OrtValue | None = None
self._output_ortvalues: ort.OrtValue | None = None
def get_input_names(self) -> list[str]:
"""Get input names for the model."""
@@ -222,41 +226,35 @@ class CudaGraphRunner(BaseModelRunner):
"""Get the input width of the model."""
return self._session.get_inputs()[0].shape[3]
def _prepare(self, input_name: str, tensor_input: np.ndarray) -> None:
"""Bind CUDA buffers and warm the session up with capture disabled."""
self._io_binding = self._session.io_binding()
self._input_name = input_name
self._output_names = [o.name for o in self._session.get_outputs()]
self._input_ortvalue = ort.OrtValue.ortvalue_from_numpy(
tensor_input, "cuda", self._cuda_device_id
)
self._io_binding.bind_ortvalue_input(self._input_name, self._input_ortvalue)
for name in self._output_names:
# Bind outputs to CUDA and allow ORT to allocate appropriately
self._io_binding.bind_output(name, "cuda", self._cuda_device_id)
# gpu_graph_id -1 disables capture and replay for the run
warmup_options = ort.RunOptions()
warmup_options.add_run_config_entry("gpu_graph_id", "-1")
for _ in range(self.GRAPH_FREE_WARMUP_RUNS):
self._session.run_with_iobinding(self._io_binding, warmup_options)
self._prepared = True
def run(self, input: dict[str, Any]):
# Extract the single tensor input (assuming one input)
input_name = list(input.keys())[0]
tensor_input = np.ascontiguousarray(input[input_name])
tensor_input = input[input_name]
tensor_input = np.ascontiguousarray(tensor_input)
if not self._prepared:
self._prepare(input_name, tensor_input)
else:
# Replay using updated input
self._input_ortvalue.update_inplace(tensor_input)
if not self._captured:
# Prepare IOBinding with CUDA buffers and let ORT allocate outputs on device
self._io_binding = self._session.io_binding()
self._input_name = input_name
self._output_names = [o.name for o in self._session.get_outputs()]
self._input_ortvalue = ort.OrtValue.ortvalue_from_numpy(
tensor_input, "cuda", self._cuda_device_id
)
self._io_binding.bind_ortvalue_input(self._input_name, self._input_ortvalue)
for name in self._output_names:
# Bind outputs to CUDA and allow ORT to allocate appropriately
self._io_binding.bind_output(name, "cuda", self._cuda_device_id)
# First IOBinding run to allocate, execute, and capture CUDA Graph
ro = ort.RunOptions()
self._session.run_with_iobinding(self._io_binding, ro)
self._captured = True
return self._io_binding.copy_outputs_to_cpu()
# Replay using updated input, copy results to CPU
self._input_ortvalue.update_inplace(tensor_input)
ro = ort.RunOptions()
self._session.run_with_iobinding(self._io_binding, ro)
return self._io_binding.copy_outputs_to_cpu()
@@ -325,12 +323,6 @@ class OpenVINOModelRunner(BaseModelRunner):
if device in ["GPU", "AUTO", "NPU"]:
self.ov_core.set_property(device, {"PERFORMANCE_HINT": "LATENCY"})
if device in ["GPU", "AUTO"]:
try:
self.ov_core.set_property("GPU", {"GPU_QUEUE_THROTTLE": "LOW"})
except Exception as e:
logger.debug(f"GPU_QUEUE_THROTTLE not supported: {e}")
if device == "NPU" and OpenVINOModelRunner.is_detection_model(model_type):
try:
self.ov_core.set_property(device, {"NPU_TURBO": "YES"})
@@ -634,7 +626,9 @@ def get_optimized_runner(
return ONNXModelRunner(
ort.InferenceSession(
model_path,
sess_options=get_ort_session_options(model_type),
sess_options=get_ort_session_options(
ONNXModelRunner.is_cpu_complex_model(model_type)
),
providers=providers,
provider_options=options,
),
+40 -40
View File
@@ -6,10 +6,9 @@ import logging
import os
import threading
import time
from typing import Any
import numpy as np
from peewee import DatabaseError, DoesNotExist, IntegrityError
from peewee import DoesNotExist, IntegrityError
from PIL import Image
from playhouse.shortcuts import model_to_dict
@@ -208,10 +207,12 @@ class Embeddings:
embedding = self.vision_embedding([thumbnail])[0]
if upsert:
self.db.upsert_embeddings(
"vec_thumbnails",
"thumbnail_embedding",
{event_id: serialize(embedding)},
self.db.execute_sql(
"""
INSERT OR REPLACE INTO vec_thumbnails(id, thumbnail_embedding)
VALUES(?, ?)
""",
(event_id, serialize(embedding)),
)
self.image_inference_speed.update(datetime.datetime.now().timestamp() - start)
@@ -250,12 +251,19 @@ class Embeddings:
embeddings = self.vision_embedding(valid_thumbs)
if upsert:
items = {}
items = []
for i in range(len(valid_ids)):
items[valid_ids[i]] = serialize(embeddings[i])
items.append(valid_ids[i])
items.append(serialize(embeddings[i]))
self.image_eps.update()
self.db.upsert_embeddings("vec_thumbnails", "thumbnail_embedding", items)
self.db.execute_sql(
"""
INSERT OR REPLACE INTO vec_thumbnails(id, thumbnail_embedding)
VALUES {}
""".format(", ".join(["(?, ?)"] * len(valid_ids))),
items,
)
duration = datetime.datetime.now().timestamp() - start
self.image_inference_speed.update(duration / len(valid_ids))
@@ -269,10 +277,12 @@ class Embeddings:
embedding = self.text_embedding([description])[0]
if upsert:
self.db.upsert_embeddings(
"vec_descriptions",
"description_embedding",
{event_id: serialize(embedding)},
self.db.execute_sql(
"""
INSERT OR REPLACE INTO vec_descriptions(id, description_embedding)
VALUES(?, ?)
""",
(event_id, serialize(embedding)),
)
self.text_inference_speed.update(datetime.datetime.now().timestamp() - start)
@@ -292,14 +302,19 @@ class Embeddings:
if upsert:
ids = list(event_descriptions.keys())
items = {}
items = []
for i in range(len(ids)):
items[ids[i]] = serialize(embeddings[i])
items.append(ids[i])
items.append(serialize(embeddings[i]))
self.text_eps.update()
self.db.upsert_embeddings(
"vec_descriptions", "description_embedding", items
self.db.execute_sql(
"""
INSERT OR REPLACE INTO vec_descriptions(id, description_embedding)
VALUES {}
""".format(", ".join(["(?, ?)"] * len(ids))),
items,
)
self.text_inference_speed.update(datetime.datetime.now().timestamp() - start)
@@ -307,17 +322,6 @@ class Embeddings:
return embeddings
def reindex(self) -> None:
"""Rebuild every tracked object embedding from scratch."""
totals: dict[str, Any] = {"status": "indexing"}
try:
self._reindex(totals)
except DatabaseError:
logger.exception("Unable to reindex tracked object embeddings")
totals["status"] = "failed"
self.requestor.send_data(UPDATE_EMBEDDINGS_REINDEX_PROGRESS, totals)
def _reindex(self, totals: dict[str, Any]) -> None:
logger.info("Indexing tracked object embeddings...")
self.db.drop_embeddings_tables()
@@ -342,18 +346,14 @@ class Embeddings:
batch_size = 32
current_page = 1
totals.update(
{
"thumbnails": 0,
"descriptions": 0,
"processed_objects": total_events - 1
if total_events < batch_size
else 0,
"total_objects": total_events,
"time_remaining": 0 if total_events < batch_size else -1,
"status": "indexing",
}
)
totals = {
"thumbnails": 0,
"descriptions": 0,
"processed_objects": total_events - 1 if total_events < batch_size else 0,
"total_objects": total_events,
"time_remaining": 0 if total_events < batch_size else -1,
"status": "indexing",
}
self.requestor.send_data(UPDATE_EMBEDDINGS_REINDEX_PROGRESS, totals)
+2 -2
View File
@@ -121,8 +121,8 @@ PRESETS_HW_ACCEL_SCALE = {
"preset-rpi-64-h264": "-r {0} -vf fps={0},scale={1}:{2}",
"preset-rpi-64-h265": "-r {0} -vf fps={0},scale={1}:{2}",
FFMPEG_HWACCEL_VAAPI: "-r {0} -vf fps={0},scale_vaapi=w={1}:h={2},hwdownload,format=nv12",
"preset-intel-qsv-h264": "-r {0} -vf fps={0},vpp_qsv=w={1}:h={2}:format=nv12,hwdownload,format=nv12,format=yuv420p",
"preset-intel-qsv-h265": "-r {0} -vf fps={0},vpp_qsv=w={1}:h={2}:format=nv12,hwdownload,format=nv12,format=yuv420p",
"preset-intel-qsv-h264": "-r {0} -vf vpp_qsv=w={1}:h={2}:format=nv12,hwdownload,format=nv12,fps={0},format=yuv420p",
"preset-intel-qsv-h265": "-r {0} -vf vpp_qsv=w={1}:h={2}:format=nv12,hwdownload,format=nv12,fps={0},format=yuv420p",
FFMPEG_HWACCEL_NVIDIA: "-r {0} -vf fps={0},scale_cuda=w={1}:h={2},hwdownload,format=nv12",
"preset-jetson-h264": "-r {0}", # scaled in decoder
"preset-jetson-h265": "-r {0}", # scaled in decoder
+2 -3
View File
@@ -36,9 +36,8 @@ from frigate.util.time import is_current_hour
logger = logging.getLogger(__name__)
DEFAULT_TIME_LAPSE_FFMPEG_INPUT_ARGS = "-an"
DEFAULT_TIME_LAPSE_FFMPEG_ARGS = "-vf setpts=0.04*PTS -r 30"
TIMELAPSE_DATA_INPUT_ARGS = "-skip_frame nokey"
TIMELAPSE_DATA_INPUT_ARGS = "-an -skip_frame nokey"
# Matches the setpts factor used in timelapse exports (e.g. setpts=0.04*PTS).
# Captures the floating-point factor so we can scale expected duration.
@@ -738,7 +737,7 @@ class RecordingExporter(threading.Thread):
parse_preset_hardware_acceleration_encode(
self.config.ffmpeg.ffmpeg_path,
hwaccel_args,
f"{self.ffmpeg_input_args} {ffmpeg_input}".strip(),
f"{self.ffmpeg_input_args} -an {ffmpeg_input}".strip(),
f"{self.ffmpeg_output_args} -movflags +faststart".strip(),
EncodeTypeEnum.timelapse,
)
+4 -5
View File
@@ -352,9 +352,8 @@ def stats_snapshot(
total_camera_fps = total_process_fps = total_skipped_fps = total_detection_fps = 0
stats["cameras"] = {}
for name, camera_stats in list(camera_metrics.items()):
camera_config = config.cameras.get(name)
if camera_config is None:
for name, camera_stats in camera_metrics.items():
if name not in config.cameras:
continue
total_camera_fps += camera_stats.camera_fps.value
@@ -371,7 +370,7 @@ def stats_snapshot(
# Calculate connection quality based on current state
# This is computed at stats-collection time so offline cameras
# correctly show as unusable rather than excellent
expected_fps = camera_config.detect.fps
expected_fps = config.cameras[name].detect.fps
current_fps = camera_stats.camera_fps.value
reconnects = camera_stats.reconnects_last_hour.value
stalls = camera_stats.stalls_last_hour.value
@@ -399,7 +398,7 @@ def stats_snapshot(
"process_fps": round(camera_stats.process_fps.value, 2),
"skipped_fps": round(camera_stats.skipped_fps.value, 2),
"detection_fps": round(camera_stats.detection_fps.value, 2),
"detection_enabled": camera_config.detect.enabled,
"detection_enabled": config.cameras[name].detect.enabled,
"pid": pid,
"capture_pid": capture_pid,
"ffmpeg_pid": ffmpeg_pid,
@@ -166,6 +166,29 @@ class TestCameraAccessEventReview(BaseTestHttp):
resp = client.get("/review/rev1")
assert resp.status_code == 403
def test_review_not_reviewed_access(self):
super().insert_mock_review_segment("rev1", camera="front_door")
# Allowed
async def mock_require_allowed(camera: str, request: Request = None):
if camera == "front_door":
return
raise HTTPException(status_code=403, detail="Access denied")
with patch("frigate.api.review.require_camera_access", mock_require_allowed):
with AuthTestClient(self.app) as client:
resp = client.delete("/review/rev1/viewed")
assert resp.status_code == 200
# Disallowed
async def mock_require_disallowed(camera: str, request: Request = None):
raise HTTPException(status_code=403, detail="Access denied")
with patch("frigate.api.review.require_camera_access", mock_require_disallowed):
with AuthTestClient(self.app) as client:
resp = client.delete("/review/rev1/viewed")
assert resp.status_code == 403
def test_event_search_access(self):
super().insert_mock_event("event1", camera="front_door")
super().insert_mock_event("event2", camera="back_door")
@@ -440,68 +463,3 @@ class TestGo2rtcStreamAccess(BaseTestHttp):
f"limited_user should be denied on alias back_door_main; "
f"got {resp.status_code}"
)
class TestReviewSummaryAccess(BaseTestHttp):
"""Tests for POST /review/summarize/start/{start_ts}/end/{end_ts}.
The summary correlates each flagged event with overlapping activity on
other cameras, so it is gated on full camera access rather than scoped to
the caller's cameras. These tests pin that decision so the dependency is
not loosened without first scoping the query.
GenAI is not configured in unit tests, so an authorized request returns 400
while an unauthorized one is rejected with 403 before the handler runs.
"""
def setUp(self):
super().setUp([Event, ReviewSegment, Recordings])
self.minimal_config = _MULTI_CAMERA_CONFIG
self.app = super().create_app()
def tearDown(self):
self.app.dependency_overrides.clear()
super().tearDown()
def _summarize(self, allowed_cameras: list[str]):
async def mock_cameras(request: Request):
return allowed_cameras
self.app.dependency_overrides[get_allowed_cameras_for_filter] = mock_cameras
with AuthTestClient(self.app) as client:
return client.post("/review/summarize/start/0/end/9999999999")
def _assert_allowed(self, resp):
assert resp.status_code not in (401, 403), (
f"Caller should not be blocked; got {resp.status_code}"
)
def test_partial_camera_access_blocked(self):
assert self._summarize(["front_door"]).status_code == 403
def test_no_camera_access_blocked(self):
assert self._summarize([]).status_code == 403
def test_full_camera_access_allowed(self):
# Covers admin and viewer, which always resolve to every camera, and a
# custom role whose list happens to name them all.
self._assert_allowed(self._summarize(["front_door", "back_door"]))
def _summarize_as_role(self, role: str):
"""Summarize using the real role to allowed-cameras resolution."""
self.app.dependency_overrides.pop(get_allowed_cameras_for_filter, None)
with AuthTestClient(self.app) as client:
return client.post(
"/review/summarize/start/0/end/9999999999",
headers={"remote-user": "test", "remote-role": role},
)
def test_viewer_role_allowed(self):
# viewer is never camera restricted, so it resolves to every camera.
self._assert_allowed(self._summarize_as_role("viewer"))
def test_admin_role_allowed(self):
self._assert_allowed(self._summarize_as_role("admin"))
def test_restricted_role_blocked(self):
assert self._summarize_as_role("limited_user").status_code == 403
-102
View File
@@ -168,29 +168,6 @@ class TestHttpApp(BaseTestHttp):
assert events[0]["id"] == id
assert events[1]["id"] == id2
def test_get_event_list_offset_pages_score_sort(self):
now = datetime.now().timestamp()
scores = [0.6, 0.9, 0.7, 0.95, 0.8]
with AuthTestClient(self.app) as client:
for i, score in enumerate(scores):
super().insert_mock_event(
f"event-{i}", start_time=now + i, data={"score": score}
)
params = {"sort": "score_desc"}
full = [e["id"] for e in client.get("/events", params=params).json()]
paged = [
e["id"]
for offset in (0, 2, 4)
for e in client.get(
"/events", params={**params, "limit": 2, "offset": offset}
).json()
]
assert full == ["event-3", "event-1", "event-4", "event-2", "event-0"]
assert paged == full
def test_get_event_list_match_multilingual_attribute(self):
event_id = "123456.zh"
attribute = "中文标签"
@@ -242,85 +219,6 @@ class TestHttpApp(BaseTestHttp):
assert len(events) == 1
assert events[0]["id"] == event_id
def test_events_search_offset_pages_score_sort(self):
now = datetime.now().timestamp()
scores = [0.6, 0.9, 0.7, 0.95, 0.8]
ids = [f"event-{i}" for i in range(len(scores))]
mock_embeddings = Mock()
mock_embeddings.search_thumbnail.return_value = [
(event_id, 0.1 * i) for i, event_id in enumerate(ids)
]
self.app.frigate_config.semantic_search.enabled = True
self.app.embeddings = mock_embeddings
with AuthTestClient(self.app) as client:
for i, score in enumerate(scores):
super().insert_mock_event(
ids[i], start_time=now + i, data={"score": score}
)
params = {
"search_type": "similarity",
"event_id": ids[0],
"sort": "score_desc",
}
paged = [
e["id"]
for offset in (0, 2, 4)
for e in client.get(
"/events/search",
params={**params, "limit": 2, "offset": offset},
).json()
]
assert paged == ["event-3", "event-1", "event-4", "event-2", "event-0"]
def test_events_search_offset_pages_orders_ties_by_id(self):
now = datetime.now().timestamp()
ids = ["event-c", "event-a", "event-b"]
mock_embeddings = Mock()
mock_embeddings.search_thumbnail.return_value = [
(event_id, 0.1) for event_id in ids
]
self.app.frigate_config.semantic_search.enabled = True
self.app.embeddings = mock_embeddings
with AuthTestClient(self.app) as client:
for i, event_id in enumerate(ids):
super().insert_mock_event(
event_id, start_time=now + i, data={"score": 0.8}
)
for sort in ("score_desc", "relevance"):
params = {
"search_type": "similarity",
"event_id": ids[0],
"sort": sort,
}
paged = [
e["id"]
for offset in (0, 1, 2)
for e in client.get(
"/events/search",
params={**params, "limit": 1, "offset": offset},
).json()
]
assert paged == ["event-a", "event-b", "event-c"]
def test_event_list_rejects_negative_offset(self):
with AuthTestClient(self.app) as client:
response = client.get("/events", params={"offset": -5})
assert response.status_code == 422
response = client.get(
"/events/search",
params={"query": "car", "offset": -5},
)
assert response.status_code == 422
def test_similarity_search_hides_unauthorized_anchor_event(self):
mock_embeddings = Mock()
self.app.frigate_config.semantic_search.enabled = True
-78
View File
@@ -1,7 +1,5 @@
import io
import os
import tempfile
import zipfile
from unittest.mock import patch
from frigate.jobs.export import (
@@ -1433,79 +1431,3 @@ class TestHttpExport(BaseTestHttp):
)
assert response.status_code == 403
def test_download_export_case_with_multibyte_name(self):
"""A case name outside latin-1 must not break the response headers."""
case = ExportCase.create(
id="case_multibyte",
name="テスト事案",
description="",
created_at=10,
updated_at=10,
)
with tempfile.TemporaryDirectory() as tmpdir:
video_path = os.path.join(tmpdir, "multibyte_export.mp4")
with open(video_path, "wb") as handle:
handle.write(b"video")
Export.create(
id="export_multibyte",
camera="front_door",
name="現場カメラ",
date=100,
video_path=video_path,
thumb_path=os.path.join(tmpdir, "multibyte_export.webp"),
in_progress=False,
export_case=case,
)
with AuthTestClient(self.app) as client:
response = client.get(f"/cases/{case.id}/download")
assert response.status_code == 200
# RFC 5987/6266: the UTF-8 name rides in filename*, and a latin-1 safe
# fallback stays in filename for old clients.
assert response.headers["content-disposition"] == (
'attachment; filename="case_multibyte.zip"; '
"filename*=UTF-8''%E3%83%86%E3%82%B9%E3%83%88%E4%BA%8B%E6%A1%88.zip"
)
archive = zipfile.ZipFile(io.BytesIO(response.content))
assert archive.namelist() == ["現場カメラ.mp4"]
def test_download_export_case_with_ascii_name(self):
"""An ASCII case name still gets a plain, readable filename."""
case = ExportCase.create(
id="case_ascii",
name="Burglary 2026-08",
description="",
created_at=10,
updated_at=10,
)
with tempfile.TemporaryDirectory() as tmpdir:
video_path = os.path.join(tmpdir, "ascii_export.mp4")
with open(video_path, "wb") as handle:
handle.write(b"video")
Export.create(
id="export_ascii",
camera="front_door",
name="Front door",
date=100,
video_path=video_path,
thumb_path=os.path.join(tmpdir, "ascii_export.webp"),
in_progress=False,
export_case=case,
)
with AuthTestClient(self.app) as client:
response = client.get(f"/cases/{case.id}/download")
assert response.status_code == 200
assert (
response.headers["content-disposition"]
== 'attachment; filename="Burglary 2026-08.zip"; '
"filename*=UTF-8''Burglary%202026-08.zip"
)
-113
View File
@@ -1,113 +0,0 @@
"""Tests for password change authorization."""
from fastapi import Request
from frigate.api.auth import get_current_user, hash_password, verify_password
from frigate.models import Event, Recordings, ReviewSegment, User
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
# Config carrying a custom role, which is the class of user the literal
# "viewer" check used to let through.
_CUSTOM_ROLE_CONFIG = {
"mqtt": {"host": "mqtt"},
"auth": {"roles": {"neighbor": ["front_door"]}, "hash_iterations": 10},
"cameras": {
"front_door": {
"ffmpeg": {
"inputs": [{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}]
},
"detect": {"height": 1080, "width": 1920, "fps": 5},
},
},
}
ADMIN_PASSWORD = "admin-real-password"
NEW_PASSWORD = "AttackerChosenPassword123!"
class TestUpdatePasswordAccess(BaseTestHttp):
def setUp(self):
super().setUp([Event, ReviewSegment, Recordings, User])
self.minimal_config = _CUSTOM_ROLE_CONFIG
self.app = super().create_app()
User.insert(
username="admin",
password_hash=hash_password(ADMIN_PASSWORD, iterations=10),
role="admin",
notification_tokens=[],
).execute()
async def mock_get_current_user(request: Request):
return {
"username": request.headers.get("remote-user"),
"role": request.headers.get("remote-role"),
}
self.app.dependency_overrides[get_current_user] = mock_get_current_user
def tearDown(self):
self.app.dependency_overrides.clear()
super().tearDown()
def _change_password(self, actor: str, role: str, target: str, old_password: str):
with AuthTestClient(self.app) as client:
return client.put(
f"/users/{target}/password",
json={"password": NEW_PASSWORD, "old_password": old_password},
headers={"remote-user": actor, "remote-role": role},
)
def _admin_password_unchanged(self) -> bool:
return verify_password(ADMIN_PASSWORD, User.get_by_id("admin").password_hash)
def test_custom_role_cannot_target_another_account(self):
resp = self._change_password("neighbor", "neighbor", "admin", "wrong-guess")
assert resp.status_code == 403
assert self._admin_password_unchanged()
def test_custom_role_cannot_target_another_account_with_correct_password(self):
# The 403 must land before old_password is checked, so knowing the
# target's password is not a way through
resp = self._change_password("neighbor", "neighbor", "admin", ADMIN_PASSWORD)
assert resp.status_code == 403
assert self._admin_password_unchanged()
def test_viewer_cannot_target_another_account(self):
resp = self._change_password("viewer_user", "viewer", "admin", ADMIN_PASSWORD)
assert resp.status_code == 403
assert self._admin_password_unchanged()
def test_admin_can_target_another_account(self):
User.insert(
username="neighbor",
password_hash=hash_password("neighbor-password", iterations=10),
role="neighbor",
notification_tokens=[],
).execute()
resp = self._change_password("admin", "admin", "neighbor", "")
assert resp.status_code == 200
def test_non_admin_can_change_own_password(self):
User.insert(
username="neighbor",
password_hash=hash_password("neighbor-password", iterations=10),
role="neighbor",
notification_tokens=[],
).execute()
resp = self._change_password(
"neighbor", "neighbor", "neighbor", "neighbor-password"
)
assert resp.status_code == 200
def test_non_admin_own_password_still_requires_old_password(self):
User.insert(
username="neighbor",
password_hash=hash_password("neighbor-password", iterations=10),
role="neighbor",
notification_tokens=[],
).execute()
resp = self._change_password("neighbor", "neighbor", "neighbor", "wrong-guess")
assert resp.status_code == 401
-92
View File
@@ -240,101 +240,9 @@ class TestHttpReview(BaseTestHttp):
assert len(response_json) == 1
assert response_json[0]["id"] == id_reviewed
def test_get_review_with_label_filter_matches_verified(self):
"""Test that a label filter also matches the `-verified` variant."""
now = datetime.now().timestamp()
with AuthTestClient(self.app) as client:
super().insert_mock_review_segment(
"123456.person", now, now + 2, data={"objects": ["person"]}
)
super().insert_mock_review_segment(
"123456.verified", now, now + 2, data={"objects": ["person-verified"]}
)
super().insert_mock_review_segment(
"123456.car", now, now + 2, data={"objects": ["car"]}
)
params = {
"labels": "person",
"after": now - 1,
"before": now + 3,
}
response = client.get("/review", params=params)
assert response.status_code == 200
response_json = response.json()
assert {r["id"] for r in response_json} == {
"123456.person",
"123456.verified",
}
def test_get_review_with_label_filter_does_not_match_prefix(self):
"""Test that a label filter does not match labels that only share a prefix."""
now = datetime.now().timestamp()
with AuthTestClient(self.app) as client:
super().insert_mock_review_segment(
"123456.carrot", now, now + 2, data={"objects": ["carrot"]}
)
params = {
"labels": "car",
"after": now - 1,
"before": now + 3,
}
response = client.get("/review", params=params)
assert response.status_code == 200
assert len(response.json()) == 0
def test_get_review_with_audio_label_filter(self):
"""Test that a label filter still matches audio labels."""
now = datetime.now().timestamp()
with AuthTestClient(self.app) as client:
super().insert_mock_review_segment(
"123456.audio", now, now + 2, data={"audio": ["speech"]}
)
params = {
"labels": "speech",
"after": now - 1,
"before": now + 3,
}
response = client.get("/review", params=params)
assert response.status_code == 200
response_json = response.json()
assert len(response_json) == 1
assert response_json[0]["id"] == "123456.audio"
####################################################################################################################
################################### GET /review/summary Endpoint #################################################
####################################################################################################################
def test_get_review_summary_label_filter_matches_verified(self):
"""Test that the summary label filter also matches the `-verified` variant."""
with AuthTestClient(self.app) as client:
super().insert_mock_review_segment(
"123456.verified", data={"objects": ["person-verified"]}
)
super().insert_mock_review_segment(
"123456.car", data={"objects": ["car"]}, severity=SeverityEnum.detection
)
params = {
"cameras": "front_door",
"labels": "person",
"zones": "all",
"timezone": "utc",
}
response = client.get("/review/summary", params=params)
assert response.status_code == 200
response_json = response.json()
assert response_json["last24Hours"]["total_alert"] == 1
assert response_json["last24Hours"]["total_detection"] == 0
today_formatted = datetime.today().strftime("%Y-%m-%d")
assert response_json[today_formatted]["total_alert"] == 1
assert response_json[today_formatted]["total_detection"] == 0
def test_get_review_summary_all_filters(self):
with AuthTestClient(self.app) as client:
super().insert_mock_review_segment("123456.random")
-88
View File
@@ -1,88 +0,0 @@
"""Tests for ONNX Runtime session option selection."""
import unittest
from unittest.mock import MagicMock, patch
import numpy as np
import onnxruntime as ort
from frigate.detectors.detection_runners import (
CudaGraphRunner,
get_ort_session_options,
)
from frigate.detectors.detector_config import ModelTypeEnum
from frigate.embeddings.types import EnrichmentModelTypeEnum
class TestGetOrtSessionOptions(unittest.TestCase):
def test_jina_v2_uses_extended(self):
"""jina-clip-v2 returns an identical vector for every image on the CUDA
execution provider at anything below EXTENDED."""
options = get_ort_session_options(EnrichmentModelTypeEnum.jina_v2.value)
self.assertIsNotNone(options)
self.assertEqual(
options.graph_optimization_level,
ort.GraphOptimizationLevel.ORT_ENABLE_EXTENDED,
)
def test_jina_v1_uses_basic(self):
options = get_ort_session_options(EnrichmentModelTypeEnum.jina_v1.value)
self.assertIsNotNone(options)
self.assertEqual(
options.graph_optimization_level,
ort.GraphOptimizationLevel.ORT_ENABLE_BASIC,
)
def test_other_models_use_defaults(self):
for model_type in [
None,
EnrichmentModelTypeEnum.paddleocr.value,
EnrichmentModelTypeEnum.arcface.value,
ModelTypeEnum.rfdetr.value,
]:
with self.subTest(model_type=model_type):
self.assertIsNone(get_ort_session_options(model_type))
class TestCudaGraphRunner(unittest.TestCase):
"""CUDA graph capture fails if the arena has to allocate during capture, so
the session is warmed up with capture disabled before the first real run."""
def setUp(self):
self.session = MagicMock()
self.session.get_outputs.return_value = [MagicMock(name="output")]
self.io_binding = self.session.io_binding.return_value
self.input = {"images": np.zeros((1, 3, 320, 320), np.float32)}
def _annotations(self) -> list[str | None]:
"""Graph annotation id passed with each run, None when unset."""
annotations = []
for call in self.session.run_with_iobinding.call_args_list:
try:
annotations.append(call.args[1].get_run_config_entry("gpu_graph_id"))
except RuntimeError:
annotations.append(None)
return annotations
def test_first_run_warms_up_with_capture_disabled(self):
with patch.object(ort.OrtValue, "ortvalue_from_numpy"):
CudaGraphRunner(self.session, 0).run(self.input)
self.assertEqual(
self._annotations(),
["-1"] * CudaGraphRunner.GRAPH_FREE_WARMUP_RUNS + [None],
)
def test_later_runs_allow_capture(self):
with patch.object(ort.OrtValue, "ortvalue_from_numpy"):
runner = CudaGraphRunner(self.session, 0)
runner.run(self.input)
self.session.run_with_iobinding.reset_mock()
runner.run(self.input)
self.assertEqual(self._annotations(), [None])
runner._input_ortvalue.update_inplace.assert_called_once()
+1 -125
View File
@@ -1,24 +1,16 @@
"""Tests for embedding storage and cleanup on the main Frigate database.
"""Tests for embedding cleanup on the main Frigate database.
Embeddings are deleted whether or not semantic search is currently enabled, so
the delete path has to tolerate databases where the vec0 tables were never
created and installs where the sqlite-vec extension is unavailable.
The write paths need the real extension, since the behavior under test belongs
to vec0 itself, so those tests are skipped when it is not installed.
"""
import os
import struct
import tempfile
import unittest
from peewee import OperationalError
from frigate.db.sqlitevecq import SqliteVecQueueDatabase
VEC_EXTENSION_PATH = "/usr/local/lib/vec0.so"
class TestDeleteEmbeddings(unittest.TestCase):
def setUp(self) -> None:
@@ -60,21 +52,6 @@ class TestDeleteEmbeddings(unittest.TestCase):
self.assertEqual(self._thumbnail_ids(), ["b"])
def test_delete_failure_is_logged_not_raised(self) -> None:
self._create_thumbnails_table()
self.db.execute_sql(
"""
CREATE TRIGGER vec_thumbnails_no_delete BEFORE DELETE ON vec_thumbnails
BEGIN SELECT RAISE(ABORT, 'delete blocked'); END
"""
).fetchall()
with self.assertLogs("frigate.db.sqlitevecq", level="ERROR") as logs:
self.db.delete_embeddings_thumbnail(event_ids=["a"])
self.assertIn("Failed to delete embeddings", logs.output[0])
self.assertEqual(self._thumbnail_ids(), ["a", "b"])
def test_delete_skipped_without_extension(self) -> None:
self._create_thumbnails_table()
self.db.load_vec_extension = False
@@ -84,104 +61,3 @@ class TestDeleteEmbeddings(unittest.TestCase):
# the vec0 tables cannot be written without the extension
self.assertEqual(self._thumbnail_ids(), ["a", "b"])
def _vector(value: float) -> bytes:
return struct.pack("768f", *([value] * 768))
@unittest.skipUnless(
os.path.exists(VEC_EXTENSION_PATH), "sqlite-vec extension is not installed"
)
class TestEmbeddingsTableWrites(unittest.TestCase):
"""Covers the vec0 writes behind semantic search reindexing."""
def setUp(self) -> None:
self.tmp_dir = tempfile.TemporaryDirectory()
self.db = SqliteVecQueueDatabase(
os.path.join(self.tmp_dir.name, "test.db"), load_vec_extension=True
)
self.db.start()
self.db.create_embeddings_tables()
def tearDown(self) -> None:
self.db.stop()
self.db.close()
self.tmp_dir.cleanup()
def _vec_tables(self) -> list[str]:
return [
row[0]
for row in self.db.execute_sql(
"SELECT name FROM sqlite_master WHERE name LIKE 'vec_%' ORDER BY name"
)
]
def _make_legacy(self, table: str) -> None:
# sqlite-vec added the _info shadow table in 0.1.6, so tables written by
# Frigate 0.17 and earlier do not have one
self.db.execute_sql(f"DROP TABLE {table}_info").fetchall()
def _stored(self, table: str, column: str, event_id: str) -> str | None:
row = self.db.execute_sql(
f"SELECT vec_to_json({column}) FROM {table} WHERE id = ?", (event_id,)
).fetchone()
return row[0] if row else None
def test_write_error_is_raised(self) -> None:
# queued writes hide their exception in the returned cursor
with self.assertRaises(OperationalError):
self.db.execute_write("INSERT INTO vec_missing(id) VALUES ('a')")
def test_drop_tables_removes_legacy_tables(self) -> None:
self._make_legacy("vec_thumbnails")
self._make_legacy("vec_descriptions")
self.db.drop_embeddings_tables()
self.assertEqual(self._vec_tables(), [])
def test_drop_tables_without_any_tables_does_not_raise(self) -> None:
self.db.drop_embeddings_tables()
self.db.drop_embeddings_tables()
def test_upsert_replaces_existing_embedding(self) -> None:
self.db.upsert_embeddings(
"vec_thumbnails", "thumbnail_embedding", {"evt1": _vector(0.01)}
)
self.db.upsert_embeddings(
"vec_thumbnails", "thumbnail_embedding", {"evt1": _vector(0.99)}
)
stored = self._stored("vec_thumbnails", "thumbnail_embedding", "evt1")
self.assertTrue(stored.startswith("[0.990000"), stored)
def test_upsert_keeps_one_row_per_event(self) -> None:
for _ in range(3):
self.db.upsert_embeddings(
"vec_descriptions", "description_embedding", {"evt1": _vector(0.5)}
)
count = self.db.execute_sql(
"SELECT count(*) FROM vec_descriptions WHERE id = 'evt1'"
).fetchone()[0]
self.assertEqual(count, 1)
def test_reindex_cycle_rewrites_legacy_tables(self) -> None:
"""The 0.18 upgrade path: old vectors in, new vectors out."""
self.db.upsert_embeddings(
"vec_thumbnails", "thumbnail_embedding", {"evt1": _vector(0.01)}
)
self._make_legacy("vec_thumbnails")
self._make_legacy("vec_descriptions")
self.db.drop_embeddings_tables()
self.db.create_embeddings_tables()
self.db.upsert_embeddings(
"vec_thumbnails", "thumbnail_embedding", {"evt1": _vector(0.99)}
)
stored = self._stored("vec_thumbnails", "thumbnail_embedding", "evt1")
self.assertTrue(stored.startswith("[0.990000"), stored)
-150
View File
@@ -1,150 +0,0 @@
"""Tests for push notification subscription validation."""
import unittest
from frigate.api.notification import _validate_push_endpoint, _validate_subscription
VALID_ENDPOINTS = [
"https://fcm.googleapis.com/fcm/send/dGhpcy1pcy1hLXRva2Vu",
"https://updates.push.services.mozilla.com/wpush/v2/dGhpcy1pcy1hLXRva2Vu",
"https://web.push.apple.com/dGhpcy1pcy1hLXRva2Vu",
"https://wns2-by3p.notify.windows.com/w/?token=dGhpcy1pcy1hLXRva2Vu",
"https://fcm.googleapis.com:443/fcm/send/dGhpcy1pcy1hLXRva2Vu",
]
def _subscription(endpoint: str) -> dict:
return {
"endpoint": endpoint,
"keys": {"p256dh": "cHVibGljLWtleQ", "auth": "YXV0aC1zZWNyZXQ"},
}
class TestValidatePushEndpoint(unittest.TestCase):
def test_accepts_real_push_service_endpoints(self):
for endpoint in VALID_ENDPOINTS:
with self.subTest(endpoint=endpoint):
self.assertIsNone(_validate_push_endpoint(endpoint))
def test_rejects_http(self):
self.assertIsNotNone(
_validate_push_endpoint("http://fcm.googleapis.com/fcm/send/token")
)
def test_rejects_non_http_schemes(self):
for endpoint in (
"file:///etc/passwd",
"ftp://example.com/token",
"//example.com/token",
):
with self.subTest(endpoint=endpoint):
self.assertIsNotNone(_validate_push_endpoint(endpoint))
def test_rejects_localhost(self):
for endpoint in (
"https://localhost/token",
"https://localhost:443/token",
"https://127.0.0.1/token",
"https://[::1]/token",
):
with self.subTest(endpoint=endpoint):
self.assertIsNotNone(_validate_push_endpoint(endpoint))
def test_rejects_private_addresses(self):
for endpoint in (
"https://192.168.1.10/token",
"https://10.0.0.5/token",
"https://172.16.0.1/token",
"https://169.254.169.254/token",
"https://0.0.0.0/token",
):
with self.subTest(endpoint=endpoint):
self.assertIsNotNone(_validate_push_endpoint(endpoint))
def test_rejects_internal_hostnames(self):
for endpoint in (
"https://frigate/token",
"https://nas.local/token",
"https://push.internal/token",
"https://host.home.arpa/token",
):
with self.subTest(endpoint=endpoint):
self.assertIsNotNone(_validate_push_endpoint(endpoint))
def test_rejects_non_default_port(self):
self.assertIsNotNone(
_validate_push_endpoint("https://fcm.googleapis.com:8080/fcm/send/token")
)
def test_rejects_embedded_credentials(self):
self.assertIsNotNone(
_validate_push_endpoint(
"https://user:pass@fcm.googleapis.com/fcm/send/token"
)
)
def test_rejects_endpoint_without_path(self):
for endpoint in ("https://fcm.googleapis.com", "https://fcm.googleapis.com/"):
with self.subTest(endpoint=endpoint):
self.assertIsNotNone(_validate_push_endpoint(endpoint))
def test_rejects_endpoint_that_breaks_audience_parsing(self):
# webpush.py locates the host by searching for a separator after index
# 10, which raises ValueError when the url has no path at all
endpoint = "https://fcm.googleapis.com"
with self.assertRaises(ValueError):
endpoint.index("/", 10)
self.assertIsNotNone(_validate_push_endpoint(endpoint))
def test_rejects_missing_or_non_string_endpoint(self):
for endpoint in (None, "", 5, {"url": "https://example.com/token"}):
with self.subTest(endpoint=endpoint):
self.assertIsNotNone(_validate_push_endpoint(endpoint))
def test_rejects_overlong_endpoint(self):
self.assertIsNotNone(
_validate_push_endpoint(f"https://fcm.googleapis.com/{'a' * 4096}")
)
class TestValidateSubscription(unittest.TestCase):
def test_accepts_valid_subscription(self):
self.assertIsNone(_validate_subscription(_subscription(VALID_ENDPOINTS[0])))
def test_accepts_extra_fields_sent_by_the_browser(self):
sub = _subscription(VALID_ENDPOINTS[0])
sub["expirationTime"] = None
self.assertIsNone(_validate_subscription(sub))
def test_rejects_non_object(self):
for sub in ("https://fcm.googleapis.com/fcm/send/token", ["endpoint"], 5):
with self.subTest(sub=sub):
self.assertIsNotNone(_validate_subscription(sub))
def test_rejects_bad_endpoint(self):
self.assertIsNotNone(
_validate_subscription(_subscription("https://localhost/t"))
)
def test_rejects_missing_keys(self):
sub = _subscription(VALID_ENDPOINTS[0])
del sub["keys"]
self.assertIsNotNone(_validate_subscription(sub))
def test_rejects_incomplete_keys(self):
for keys in (
{"p256dh": "cHVibGljLWtleQ"},
{"auth": "YXV0aC1zZWNyZXQ"},
{"p256dh": "cHVibGljLWtleQ", "auth": ""},
{"p256dh": None, "auth": "YXV0aC1zZWNyZXQ"},
):
with self.subTest(keys=keys):
sub = _subscription(VALID_ENDPOINTS[0])
sub["keys"] = keys
self.assertIsNotNone(_validate_subscription(sub))
if __name__ == "__main__":
unittest.main()
+15 -30
View File
@@ -24,7 +24,6 @@ from frigate.comms.event_metadata_updater import (
from frigate.comms.events_updater import EventEndSubscriber, EventUpdatePublisher
from frigate.comms.inter_process import InterProcessRequestor
from frigate.config import (
CameraConfig,
CameraMqttConfig,
FrigateConfig,
RecordConfig,
@@ -129,10 +128,8 @@ class TrackedObjectProcessor(threading.Thread):
)
def update(camera: str, obj: TrackedObject, frame_name: str) -> None:
obj.has_snapshot = self.should_save_snapshot(
camera_state.camera_config, obj
)
obj.has_clip = self.should_retain_recording(camera_state.camera_config, obj)
obj.has_snapshot = self.should_save_snapshot(camera, obj)
obj.has_clip = self.should_retain_recording(camera, obj)
after = obj.to_dict()
message = {
"before": obj.previous,
@@ -156,10 +153,8 @@ class TrackedObjectProcessor(threading.Thread):
def end(camera: str, obj: TrackedObject, frame_name: str) -> None:
# populate has_snapshot
obj.has_snapshot = self.should_save_snapshot(
camera_state.camera_config, obj
)
obj.has_clip = self.should_retain_recording(camera_state.camera_config, obj)
obj.has_snapshot = self.should_save_snapshot(camera, obj)
obj.has_clip = self.should_retain_recording(camera, obj)
# write thumbnail to disk if it will be saved as an event
if obj.has_snapshot or obj.has_clip:
@@ -189,8 +184,8 @@ class TrackedObjectProcessor(threading.Thread):
)
def snapshot(camera: str, obj: TrackedObject) -> bool:
mqtt_config: CameraMqttConfig = camera_state.camera_config.mqtt
if mqtt_config.enabled and self.should_mqtt_snapshot(mqtt_config, obj):
mqtt_config: CameraMqttConfig = self.config.cameras[camera].mqtt
if mqtt_config.enabled and self.should_mqtt_snapshot(camera, obj):
jpg_bytes, _ = obj.get_img_bytes(
ext="jpg",
timestamp=mqtt_config.timestamp,
@@ -243,13 +238,11 @@ class TrackedObjectProcessor(threading.Thread):
camera_state.on("camera_activity", camera_activity)
self.camera_states[camera] = camera_state
def should_save_snapshot(
self, camera_config: CameraConfig, obj: TrackedObject
) -> bool:
def should_save_snapshot(self, camera: str, obj: TrackedObject) -> bool:
if obj.false_positive:
return False
snapshot_config: SnapshotsConfig = camera_config.snapshots
snapshot_config: SnapshotsConfig = self.config.cameras[camera].snapshots
if not snapshot_config.enabled:
return False
@@ -268,13 +261,11 @@ class TrackedObjectProcessor(threading.Thread):
return True
def should_retain_recording(
self, camera_config: CameraConfig, obj: TrackedObject
) -> bool:
def should_retain_recording(self, camera: str, obj: TrackedObject) -> bool:
if obj.false_positive:
return False
record_config: RecordConfig = camera_config.record
record_config: RecordConfig = self.config.cameras[camera].record
# Recording is disabled
if not record_config.enabled:
@@ -290,15 +281,13 @@ class TrackedObjectProcessor(threading.Thread):
return True
def should_mqtt_snapshot(
self, mqtt_config: CameraMqttConfig, obj: TrackedObject
) -> bool:
def should_mqtt_snapshot(self, camera: str, obj: TrackedObject) -> bool:
# object never changed position
if obj.is_stationary():
return False
# if there are required zones and there is no overlap
required_zones = mqtt_config.required_zones
required_zones = self.config.cameras[camera].mqtt.required_zones
if len(required_zones) > 0 and not set(obj.entered_zones) & set(required_zones):
logger.debug(
f"Not sending mqtt for {obj.obj_data['id']} because it did not enter required zones"
@@ -308,11 +297,7 @@ class TrackedObjectProcessor(threading.Thread):
return True
def update_mqtt_motion(
self,
camera: str,
camera_config: CameraConfig,
frame_time: float,
motion_boxes: list,
self, camera: str, frame_time: float, motion_boxes: list
) -> None:
# publish if motion is currently being detected
if motion_boxes:
@@ -327,7 +312,7 @@ class TrackedObjectProcessor(threading.Thread):
# always updated latest motion
self.last_motion_detected[camera] = frame_time
elif self.last_motion_detected.get(camera, 0) > 0:
mqtt_delay = camera_config.motion.mqtt_off_delay
mqtt_delay = self.config.cameras[camera].motion.mqtt_off_delay
# If no motion, make sure the off_delay has passed
if frame_time - self.last_motion_detected.get(camera, 0) >= mqtt_delay:
@@ -798,7 +783,7 @@ class TrackedObjectProcessor(threading.Thread):
frame_name, frame_time, current_tracked_objects, motion_boxes, regions
)
self.update_mqtt_motion(camera, camera_config, frame_time, motion_boxes)
self.update_mqtt_motion(camera, frame_time, motion_boxes)
tracked_objects = [
o.to_dict() for o in camera_state.tracked_objects.values()
+1 -6
View File
@@ -54,11 +54,6 @@ class TrackedObject:
self.obj_data = obj_data
self.colormap = model_config.colormap
self.logos = model_config.all_attribute_logos
self.thumbnail_attributes = [
attr
for attr in model_config.attributes_map.get(obj_data["label"], [])
if attr in model_config.non_logo_attributes
]
self.camera_config = camera_config
self.ui_config = ui_config
self.frame_cache = frame_cache
@@ -154,7 +149,7 @@ class TrackedObject:
if not self.false_positive and has_valid_frame:
# determine if this frame is a better thumbnail
if self.thumbnail_data is None or is_better_thumbnail(
self.thumbnail_attributes,
self.obj_data["label"],
self.thumbnail_data,
obj_data,
self.camera_config.frame_shape,
+1 -1
View File
@@ -533,7 +533,7 @@ def migrate_018_0(config: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]
genai = new_config.get("genai")
if genai and genai.get("provider"):
genai["roles"] = ["descriptions", "chat"]
genai["roles"] = ["embeddings", "descriptions", "chat"]
new_config["genai"] = {"default": genai}
# Remove deprecated sync_recordings from global record config
+13 -5
View File
@@ -67,7 +67,7 @@ def has_better_attr(current_thumb, new_obj, attr_label) -> bool:
def is_better_thumbnail(
label_attributes: list[str],
label: str,
current_thumb: dict[str, Any],
new_obj: dict[str, Any],
frame_shape: tuple[int, int],
@@ -76,12 +76,20 @@ def is_better_thumbnail(
# cutoff images are less ideal, but they should also be smaller?
# better scores are obviously better too
for attr_label in label_attributes:
if has_better_attr(current_thumb, new_obj, attr_label):
# check face on person
if label == "person":
if has_better_attr(current_thumb, new_obj, "face"):
return True
# if the current thumb has a face attr, dont update unless it gets better
if any([a["label"] == "face" for a in current_thumb["attributes"]]):
return False
# if the current thumb has the attr, dont update unless it gets better
if any([a["label"] == attr_label for a in current_thumb["attributes"]]):
# check license_plate on car
if label in ["car", "motorcycle"]:
if has_better_attr(current_thumb, new_obj, "license_plate"):
return True
# if the current thumb has a license_plate attr, dont update unless it gets better
if any([a["label"] == "license_plate" for a in current_thumb["attributes"]]):
return False
# if the new_thumb is on an edge, and the current thumb is not
+14 -4
View File
@@ -216,10 +216,20 @@ def process_frames(
# remove license_plate from attributes if this camera is a dedicated LPR cam
if camera_config.type == CameraTypeEnum.lpr:
attributes_map = {
label: [attr for attr in attributes if attr != "license_plate"]
for label, attributes in model_config.attributes_map.items()
}
modified_attributes_map = model_config.attributes_map.copy()
if (
"car" in modified_attributes_map
and "license_plate" in modified_attributes_map["car"]
):
modified_attributes_map["car"] = [
attr
for attr in modified_attributes_map["car"]
if attr != "license_plate"
]
attributes_map = modified_attributes_map
all_attributes = [
attr for attr in model_config.all_attributes if attr != "license_plate"
]
+7 -24
View File
@@ -34,8 +34,6 @@ from frigate.util.process import FrigateProcess
logger = logging.getLogger(__name__)
RECORD_GRACE_SECONDS = 90
def capture_frames(
ffmpeg_process: sp.Popen[Any],
@@ -166,7 +164,6 @@ class CameraWatchdog(threading.Thread):
self.latest_invalid_segment_time: float = 0
self.latest_cache_segment_time: float = 0
self.record_enable_time: datetime | None = None
self.record_grace_until: datetime | None = None
# `valid` segments are published with the segment's start time, so the
# gap between consecutive publishes can reach 2 * segment_time. Pad the
@@ -283,7 +280,6 @@ class CameraWatchdog(threading.Thread):
self.latest_valid_segment_time = 0
self.latest_invalid_segment_time = 0
self.latest_cache_segment_time = 0
self.record_grace_until = None
self.record_enable_time = datetime.now().astimezone(UTC)
last_restart_time = datetime.now().timestamp()
continue
@@ -298,7 +294,6 @@ class CameraWatchdog(threading.Thread):
self.latest_valid_segment_time = 0
self.latest_invalid_segment_time = 0
self.latest_cache_segment_time = 0
self.record_grace_until = None
self.record_enable_time = datetime.now().astimezone(UTC)
else:
self.logger.debug(f"Disabling camera {self.config.name}")
@@ -323,7 +318,6 @@ class CameraWatchdog(threading.Thread):
self.latest_valid_segment_time = 0
self.latest_invalid_segment_time = 0
self.latest_cache_segment_time = 0
self.record_grace_until = None
self.record_enable_time = datetime.now().astimezone(UTC)
last_restart_time = datetime.now().timestamp()
self.was_record_enabled_in_config = record_enabled_in_config
@@ -410,16 +404,11 @@ class CameraWatchdog(threading.Thread):
if self.config.record.enabled and "record" in p["roles"]:
now_utc = datetime.now().astimezone(UTC)
# ffmpeg needs time to create a first segment after
# recording is enabled and after a restart
in_grace_period = (
self.record_enable_time is not None
and (now_utc - self.record_enable_time)
< timedelta(seconds=RECORD_GRACE_SECONDS)
) or (
self.record_grace_until is not None
and now_utc < self.record_grace_until
)
# Check if we're within the grace period after enabling recording
# Grace period: 90 seconds allows time for ffmpeg to start and create first segment
in_grace_period = self.record_enable_time is not None and (
now_utc - self.record_enable_time
) < timedelta(seconds=90)
latest_cache_dt = (
datetime.fromtimestamp(self.latest_cache_segment_time, tz=UTC)
@@ -456,9 +445,8 @@ class CameraWatchdog(threading.Thread):
<= self.latest_invalid_segment_time
)
invalid_stale = invalid_stale_condition
stale = cache_stale or valid_stale or invalid_stale
if stale and can_restart:
if cache_stale or valid_stale or invalid_stale:
if cache_stale:
reason = "No new recording segments were created"
elif valid_stale:
@@ -483,13 +471,8 @@ class CameraWatchdog(threading.Thread):
f"{self.config.name}/status/{role.value}", "offline"
)
self.record_grace_until = now_utc + timedelta(
seconds=RECORD_GRACE_SECONDS
)
last_restart_time = now
continue
elif not stale:
else:
self._send_record_status("online", now)
p["latest_segment_time"] = self.latest_cache_segment_time
+2 -8
View File
@@ -94,7 +94,6 @@ SPEC_SERVERS = [
PUBLIC = "public"
AUTHENTICATED = "any"
CAMERA = "camera"
ALL_CAMERAS = "all_cameras"
ADMIN = "admin"
ADMIN_SCHEME = "frigateAdminAuth"
@@ -129,7 +128,6 @@ ACCESS_NOTES = {
PUBLIC: "**Access:** Public — no authentication required.",
AUTHENTICATED: "**Access:** Any authenticated user.",
CAMERA: "**Access:** Authenticated user with access to the referenced camera.",
ALL_CAMERAS: "**Access:** Authenticated user with access to all cameras.",
ADMIN: "**Access:** Admin role required.",
}
@@ -199,8 +197,6 @@ def _route_markers(route: APIRoute) -> tuple[set[str], list[str] | None]:
pass
elif name in ("require_camera_access", "require_go2rtc_stream_access"):
markers.add(CAMERA)
elif name == "require_full_camera_access":
markers.add(ALL_CAMERAS)
elif "auth_checker" in qualname:
markers.add(AUTHENTICATED)
elif "public_checker" in qualname:
@@ -258,8 +254,6 @@ def _classify_base(
# Explicit route-level markers win, in order of specificity.
if ADMIN in markers:
return ADMIN, admin_roles or ["admin"], None
if ALL_CAMERAS in markers:
return ALL_CAMERAS, None, None
if CAMERA in markers:
return CAMERA, None, None
if AUTHENTICATED in markers:
@@ -343,8 +337,8 @@ def security_for(level: str) -> list:
return []
if level == ADMIN:
return [{ADMIN_SCHEME: []}]
# AUTHENTICATED, CAMERA and ALL_CAMERAS all require any authenticated
# session; the camera scoping is conveyed in the note and x-required-role.
# AUTHENTICATED and CAMERA both require any authenticated session; the
# camera-specific scoping is conveyed in the note and x-required-role.
return [{USER_SCHEME: []}]
+1 -4
View File
@@ -54,11 +54,8 @@ export class FrigateApp {
});
await this.ws.install(this.page);
await this.api.install(overrides);
// media goes last so its per-event routes win over the broader
// `**/api/events**` list route, which otherwise answers thumbnail and
// snapshot requests with the events JSON
await this.media.install();
await this.api.install(overrides);
}
/** Navigate to a page. Always call installDefaults() first. */
-3
View File
@@ -52,9 +52,6 @@ function deepMerge<T extends Record<string, unknown>>(
export const BASE_CONFIG = {
...configSnapshot,
version: "0.15.0-test",
// injected by the /config endpoint rather than the Pydantic model, so it
// is absent from the snapshot
plus: { enabled: false },
cameras: {
...configSnapshot.cameras,
front_door: {
+2 -3
View File
@@ -249,9 +249,8 @@ export class MediaMocker {
}),
);
// Event thumbnails. The explore grid and detail dialog request .webp,
// everything else requests .jpg.
await this.page.route("**/api/events/*/thumbnail.{jpg,webp}**", (route) =>
// Event thumbnails
await this.page.route("**/api/events/*/thumbnail.jpg**", (route) =>
route.fulfill({
contentType: "image/png",
body: PLACEHOLDER_PNG,
-68
View File
@@ -263,71 +263,3 @@ test.describe("Explore — mobile @high @mobile", () => {
await expect(searchInput).toBeFocused();
});
});
// ---------------------------------------------------------------------------
// Frigate+ submission — desktop only
// The detail dialog's previous/next arrows only render on desktop.
// ---------------------------------------------------------------------------
test.describe("Explore — Frigate+ submission (desktop) @high", () => {
test.skip(
({ frigateApp }) => frigateApp.isMobile,
"Detail dialog navigation arrows are desktop-only",
);
test("in-flight submission does not mark the next tracked object as submitted", async ({
frigateApp,
}) => {
await frigateApp.installDefaults({ config: { plus: { enabled: true } } });
const page = frigateApp.page;
// Hold the submission open so it is still in flight while the user moves
// on to the next tracked object.
let releaseSubmission: () => void = () => {};
const submissionHeld = new Promise<void>((resolve) => {
releaseSubmission = resolve;
});
let submissions = 0;
await page.route("**/api/events/*/plus", async (route) => {
submissions += 1;
await submissionHeld;
await route.fulfill({ json: { success: true } });
});
await frigateApp.goto("/explore?labels=person");
const firstResult = page.locator("[data-start]").first();
await expect(firstResult).toBeVisible({ timeout: 10_000 });
await firstResult.click();
// The label being confirmed is rendered in a <code> tag inside the
// "Is this object a <label>?" question.
const dialog = page.getByRole("dialog");
await expect(dialog.locator("code")).toHaveText("person");
await dialog.getByRole("button", { name: "Yes", exact: true }).click();
await expect.poll(() => submissions, { timeout: 5_000 }).toBe(1);
await page.getByRole("button", { name: "Next tracked object" }).click();
await expect(dialog.locator("code")).toHaveText("car");
const submissionLanded = page.waitForResponse(/\/api\/events\/.*\/plus/);
releaseSubmission();
await submissionLanded;
// two frames is enough for React to flush the response handler's state
// updates, so the assertions below can't pass by racing ahead of them
await page.evaluate(
() =>
new Promise((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(resolve)),
),
);
// The car was never submitted, so its question must be untouched.
expect(submissions).toBe(1);
await expect(dialog.getByText("Submitted")).toHaveCount(0);
await expect(
dialog.getByRole("button", { name: "Yes", exact: true }),
).toBeVisible();
});
});
-207
View File
@@ -1,207 +0,0 @@
/**
* Debug Replay range selection from History -- HIGH tier.
*
* Covers the "Select from Timeline" flow that Debug Replay shares with
* Export. The other half of the same report, a loading spinner latched
* after Cancel, needs real media: the vod mock serves an empty playlist.
*/
import { test, expect, type FrigateApp } from "../fixtures/frigate-test";
// the selection is seeded around the playback position, so land near the
// live edge
const playbackTime = Math.floor(Date.now() / 1000) - 300;
async function openRecordingView(frigateApp: FrigateApp) {
// The recording view pulls these while the timeline renders; the preview
// server 500s on them, which the error collector would flag.
await frigateApp.page.route("**/api/*/recordings**", (route) =>
route.fulfill({ json: [] }),
);
await frigateApp.page.route("**/api/recordings/unavailable**", (route) =>
route.fulfill({ json: [] }),
);
// inert here; the 0.19 recording view fetches coverage and needs an
// object, so this has to follow the broad recordings route to win
await frigateApp.page.route("**/api/*/recordings/coverage**", (route) =>
route.fulfill({
json: {
spans: [
{
start_time: playbackTime - 3600,
end_time: playbackTime + 600,
streams: ["main"],
},
],
codecs_compatible: true,
streams: {
main: {
video_codec: "h264",
audio_rate: null,
audio_codec: null,
has_audio: false,
bitrate: 2_000_000,
},
},
},
}),
);
await frigateApp.goto(`/review?timestamp=front_door_${playbackTime}`);
}
// desktop reaches Debug Replay through the Actions menu, mobile through
// the settings drawer; both render the same form
async function openDebugReplayForm(frigateApp: FrigateApp) {
if (frigateApp.isMobile) {
await frigateApp.page
.getByRole("button", { name: /filters/i })
.first()
.click({ timeout: 15_000 });
await frigateApp.page
.getByRole("button", { name: /^debug replay$/i })
.click();
} else {
await frigateApp.page
.getByRole("button", { name: /actions/i })
.click({ timeout: 15_000 });
await frigateApp.page
.getByRole("menuitem", { name: /debug replay/i })
.click();
}
const form = frigateApp.page.getByRole("dialog");
await expect(form).toBeVisible({ timeout: 5_000 });
return form;
}
async function selectRangeFromTimeline(frigateApp: FrigateApp) {
const form = await openDebugReplayForm(frigateApp);
await form.getByText("From Timeline").click();
await form.getByRole("button", { name: "Select", exact: true }).click();
await expect(form).toBeHidden({ timeout: 5_000 });
await expect(frigateApp.page.locator(".export-start")).toHaveText(
/\d{1,2}:\d{2}/,
{ timeout: 5_000 },
);
}
// moving the playhead between the two selections is what makes the second
// range differ from the first
async function reselectRange(frigateApp: FrigateApp) {
await selectRangeFromTimeline(frigateApp);
await frigateApp.page
.getByRole("button", { name: /^cancel$/i })
.click({ timeout: 5_000 });
await expect(frigateApp.page.locator(".export-start")).toHaveCount(0);
const segments = frigateApp.page.locator(".segment[data-segment-id]");
const count = await segments.count();
await segments.nth(Math.min(20, count - 1)).click({ force: true });
await selectRangeFromTimeline(frigateApp);
}
// the loop flipped this label between the two ranges ~25 times a second
async function countHandleLabelChanges(frigateApp: FrigateApp) {
return frigateApp.page.evaluate(async () => {
const handle = document.querySelector(".export-start");
if (!handle) {
return -1;
}
let changes = 0;
let last = handle.textContent;
const observer = new MutationObserver(() => {
if (handle.textContent !== last) {
changes += 1;
last = handle.textContent;
}
});
observer.observe(handle, {
subtree: true,
childList: true,
characterData: true,
});
await new Promise((resolve) => setTimeout(resolve, 1500));
observer.disconnect();
return changes;
});
}
test.describe("Debug Replay from History @high", () => {
test("a reselected range lands once and stays put", async ({
frigateApp,
}) => {
if (frigateApp.isMobile) {
test.skip();
return;
}
const pageErrors: string[] = [];
frigateApp.page.on("pageerror", (err) => pageErrors.push(err.message));
await frigateApp.installDefaults();
await openRecordingView(frigateApp);
await reselectRange(frigateApp);
expect(await countHandleLabelChanges(frigateApp)).toBe(0);
expect(
pageErrors.filter((message) => /Maximum update depth/i.test(message)),
).toHaveLength(0);
});
test("dragging a handle after a reselect moves it", async ({
frigateApp,
}) => {
if (frigateApp.isMobile) {
test.skip();
return;
}
await frigateApp.installDefaults();
await openRecordingView(frigateApp);
await reselectRange(frigateApp);
const start = frigateApp.page.locator(".export-start");
const before = (await start.textContent()) ?? "";
const box = await start.boundingBox();
if (!box) {
throw new Error("export start handle has no bounding box");
}
const x = box.x + box.width / 2;
const y = box.y + box.height / 2;
await frigateApp.page.mouse.move(x, y);
await frigateApp.page.mouse.down();
await frigateApp.page.mouse.move(x, y - 90, { steps: 12 });
await frigateApp.page.mouse.up();
await expect(start).not.toHaveText(before, { timeout: 5_000 });
});
});
test.describe("Debug Replay from History — mobile @high @mobile", () => {
test("a reselected range lands once and stays put", async ({
frigateApp,
}) => {
if (!frigateApp.isMobile) {
test.skip();
return;
}
const pageErrors: string[] = [];
frigateApp.page.on("pageerror", (err) => pageErrors.push(err.message));
await frigateApp.installDefaults();
await openRecordingView(frigateApp);
await reselectRange(frigateApp);
expect(await countHandleLabelChanges(frigateApp)).toBe(0);
expect(
pageErrors.filter((message) => /Maximum update depth/i.test(message)),
).toHaveLength(0);
});
});
-1
View File
@@ -27,7 +27,6 @@
<link rel="mask-icon" href="/images/branding/favicon.svg" color="#3b82f7" />
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#000000" media="(prefers-color-scheme: dark)" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
</head>
<body>
<div id="root"></div>
-1
View File
@@ -27,7 +27,6 @@
<link rel="mask-icon" href="/images/branding/favicon.svg" color="#3b82f7" />
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#000000" media="(prefers-color-scheme: dark)" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
</head>
<body>
<div id="root"></div>
-1
View File
@@ -1 +0,0 @@
{}
-1
View File
@@ -1 +0,0 @@
{}
@@ -1 +0,0 @@
{}
@@ -1 +0,0 @@
{}
@@ -1 +0,0 @@
{}
@@ -1 +0,0 @@
{}
@@ -1 +0,0 @@
{}
@@ -1 +0,0 @@
{}
@@ -1 +0,0 @@
{}
@@ -1 +0,0 @@
{}
-1
View File
@@ -1 +0,0 @@
{}
-1
View File
@@ -1 +0,0 @@
{}
@@ -1 +0,0 @@
{}
-1
View File
@@ -1 +0,0 @@
{}
-1
View File
@@ -1 +0,0 @@
{}
@@ -1 +0,0 @@
{}
@@ -1 +0,0 @@
{}
-1
View File
@@ -1 +0,0 @@
{}
-1
View File
@@ -1 +0,0 @@
{}
-1
View File
@@ -1 +0,0 @@
{}
@@ -1 +0,0 @@
{}
-1
View File
@@ -1 +0,0 @@
{}
@@ -1 +0,0 @@
{}
@@ -1 +0,0 @@
{}
-1
View File
@@ -1 +0,0 @@
{}
-1
View File
@@ -1 +0,0 @@
{}
@@ -1 +0,0 @@
{}
-1
View File
@@ -1 +0,0 @@
{}
-502
View File
@@ -1,502 +0,0 @@
{
"speech": "Маўленне",
"babbling": "Мармытанне",
"yell": "Крык",
"bellow": "Рык",
"whoop": "Вокліч",
"whispering": "Шэпт",
"laughter": "Смех",
"snicker": "Пасмейванне",
"crying": "Плач",
"sigh": "Уздых",
"singing": "Спевы",
"choir": "Хор",
"yodeling": "Ёдль",
"chant": "Спеў",
"mantra": "Мантра",
"child_singing": "Спевы Дзіцяці",
"synthetic_singing": "Сінтэзаваныя Спевы",
"rapping": "Рэп",
"humming": "Мармытанне",
"groan": "Стогн",
"grunt": "Рохканне",
"whistling": "Свіст",
"breathing": "Дыханне",
"wheeze": "Хрыпы",
"snoring": "Хропанне",
"gasp": "Уздох",
"pant": "Задышка",
"snort": "Фырканне",
"cough": "Кашаль",
"throat_clearing": "Пакашліванне",
"sneeze": "Чханне",
"sniff": "Сопанне",
"run": "Бег",
"shuffle": "Шорганне",
"footsteps": "Крокі",
"chewing": "Жаванне",
"biting": "Кусанне",
"gargling": "Паласканне Горла",
"stomach_rumble": "Бурчанне ў Жываце",
"burping": "Адрыжка",
"hiccup": "Ікаўка",
"fart": "Пярдзёж",
"hands": "Рукі",
"finger_snapping": "Пстрычка Пальцамі",
"clapping": "Пляск",
"heartbeat": "Сэрцабіцце",
"heart_murmur": "Шум у Сэрцы",
"cheering": "Воклічы Радасці",
"applause": "Апладысменты",
"chatter": "Балбатня",
"crowd": "Натоўп",
"children_playing": "Дзеці Гуляюць",
"animal": "Жывёла",
"pets": "Хатнія Жывёлы",
"dog": "Сабака",
"bark": "Брэх",
"yip": "Вяўканне",
"howl": "Выццё",
"bow_wow": "Гаў-Гаў",
"growling": "Рык",
"whimper_dog": "Сабачае Скуголенне",
"cat": "Кот",
"purr": "Мурлыканне",
"meow": "Мяўканне",
"hiss": "Шыпенне",
"caterwaul": "Кацінае Выццё",
"livestock": "Свойская Жывёла",
"horse": "Конь",
"clip_clop": "Цокат Капытоў",
"neigh": "Іржанне",
"cattle": "Буйная Рагатая Жывёла",
"moo": "Мыканне",
"cowbell": "Каровін Звон",
"pig": "Свіння",
"oink": "Рох-Рох",
"goat": "Каза",
"bleat": "Бляянне",
"sheep": "Авечка",
"fowl": "Свойская Птушка",
"chicken": "Курыца",
"cluck": "Кудахтанне",
"cock_a_doodle_doo": "Кукарэку",
"turkey": "Індык",
"gobble": "Клакатанне Індыка",
"duck": "Качка",
"quack": "Кваканне Качкі",
"goose": "Гусь",
"honk": "Гудок",
"wild_animals": "Дзікія Жывёлы",
"roaring_cats": "Рык Вялікіх Кошак",
"roar": "Роў",
"bird": "Птушка",
"chirp": "Чырыканне",
"squawk": "Рэзкі Крык Птушкі",
"pigeon": "Голуб",
"coo": "Туркатанне",
"crow": "Варона",
"caw": "Крык Вароны",
"owl": "Сава",
"hoot": "Крык Савы",
"flapping_wings": "Лопанне Крылаў",
"dogs": "Сабакі",
"rats": "Пацукі",
"mouse": "Мыш",
"patter": "Тупат",
"insect": "Насякомае",
"cricket": "Цвыркун",
"mosquito": "Камар",
"fly": "Муха",
"buzz": "Гудзенне",
"frog": "Жаба",
"croak": "Кваканне",
"snake": "Змяя",
"rattle": "Бразгат",
"whale_vocalization": "Спевы Кітоў",
"music": "Музыка",
"musical_instrument": "Музычны Інструмент",
"plucked_string_instrument": "Шчыпковы Струнны Інструмент",
"guitar": "Гітара",
"electric_guitar": "Электрагітара",
"bass_guitar": "Бас-Гітара",
"acoustic_guitar": "Акустычная Гітара",
"steel_guitar": "Стыл-Гітара",
"tapping": "Пастукванне",
"strum": "Перабор Струн",
"banjo": "Банджа",
"sitar": "Сітар",
"mandolin": "Мандаліна",
"zither": "Цытра",
"ukulele": "Укулеле",
"keyboard": "Клавіятура",
"piano": "Піяніна",
"electric_piano": "Электрапіяніна",
"organ": "Арган",
"electronic_organ": "Электронны Арган",
"hammond_organ": "Арган Hammond",
"synthesizer": "Сінтэзатар",
"sampler": "Сэмплер",
"harpsichord": "Клавесін",
"percussion": "Перкусія",
"drum_kit": "Барабанная Ўстаноўка",
"drum_machine": "Драм-Машына",
"drum": "Барабан",
"snare_drum": "Малы Барабан",
"rimshot": "Рымшот",
"drum_roll": "Барабанны Дроб",
"bass_drum": "Бас-Барабан",
"timpani": "Тымпаны",
"tabla": "Табла",
"cymbal": "Тарэлка",
"hi_hat": "Хай-Хэт",
"wood_block": "Драўляны Блок",
"tambourine": "Тамбурын",
"maraca": "Маракас",
"gong": "Гонг",
"tubular_bells": "Трубчастыя Званы",
"mallet_percussion": "Перкусія з Малаточкамі",
"marimba": "Марымба",
"glockenspiel": "Глакеншпіль",
"vibraphone": "Вібрафон",
"steelpan": "Стылпан",
"orchestra": "Аркестр",
"brass_instrument": "Духавы Медны Інструмент",
"french_horn": "Валторна",
"trumpet": "Труба",
"trombone": "Трамбон",
"bowed_string_instrument": "Смыковы Струнны Інструмент",
"string_section": "Струнная Група",
"violin": "Скрыпка",
"pizzicato": "Пічыката",
"cello": "Віяланчэль",
"double_bass": "Кантрабас",
"wind_instrument": "Духавы Інструмент",
"flute": "Флейта",
"saxophone": "Саксафон",
"clarinet": "Кларнет",
"harp": "Арфа",
"bell": "Звон",
"church_bell": "Царкоўны Звон",
"jingle_bell": "Бразготка",
"bicycle_bell": "Веласіпедны Званок",
"tuning_fork": "Камертон",
"chime": "Перазвон",
"wind_chime": "Музыка Ветру",
"harmonica": "Губны Гармонік",
"accordion": "Акардэон",
"bagpipes": "Дуда",
"didgeridoo": "Дыджэрыду",
"theremin": "Тэрменвокс",
"singing_bowl": "Спеўная Чаша",
"scratching": "Драпанне",
"pop_music": "Поп-Музыка",
"hip_hop_music": "Хіп-Хоп",
"beatboxing": "Бітбоксінг",
"rock_music": "Рок-Музыка",
"heavy_metal": "Хэві-Метал",
"punk_rock": "Панк-Рок",
"grunge": "Грандж",
"progressive_rock": "Прагрэсіўны Рок",
"rock_and_roll": "Рок-Н-Рол",
"psychedelic_rock": "Псіхадэлічны Рок",
"rhythm_and_blues": "Рытм-Энд-Блюз",
"soul_music": "Соўл",
"reggae": "Рэгі",
"country": "Кантры",
"swing_music": "Свінг",
"bluegrass": "Блюграс",
"funk": "Фанк",
"folk_music": "Народная Музыка",
"middle_eastern_music": "Музыка Блізкага Усходу",
"jazz": "Джаз",
"disco": "Дыска",
"classical_music": "Класічная Музыка",
"opera": "Опера",
"electronic_music": "Электронная Музыка",
"house_music": "Хаус",
"techno": "Тэхна",
"dubstep": "Дабстэп",
"drum_and_bass": "Драм-Энд-Бэйс",
"electronica": "Электроніка",
"electronic_dance_music": "Электронная Танцавальная Музыка",
"ambient_music": "Эмбіент",
"trance_music": "Транс",
"music_of_latin_america": "Музыка Лацінскай Амерыкі",
"salsa_music": "Сальса",
"flamenco": "Фламенка",
"blues": "Блюз",
"music_for_children": "Музыка для Дзяцей",
"new-age_music": "Музыка Нью-Эйдж",
"vocal_music": "Вакальная Музыка",
"a_capella": "А Капэла",
"music_of_africa": "Музыка Афрыкі",
"afrobeat": "Афрабіт",
"christian_music": "Хрысціянская Музыка",
"gospel_music": "Госпел",
"music_of_asia": "Музыка Азіі",
"carnatic_music": "Карнатычная Музыка",
"music_of_bollywood": "Музыка Балівуда",
"ska": "Ска",
"traditional_music": "Традыцыйная Музыка",
"independent_music": "Незалежная Музыка",
"song": "Песня",
"background_music": "Фонавая Музыка",
"theme_music": "Тэматычная Музыка",
"jingle": "Джынгл",
"soundtrack_music": "Музыка Саўндтрэка",
"lullaby": "Калыханка",
"video_game_music": "Музыка Відэагульняў",
"christmas_music": "Калядная Музыка",
"dance_music": "Танцавальная Музыка",
"wedding_music": "Вясельная Музыка",
"happy_music": "Вясёлая Музыка",
"sad_music": "Сумная Музыка",
"tender_music": "Лагодная Музыка",
"exciting_music": "Энергічная Музыка",
"angry_music": "Агрэсіўная Музыка",
"scary_music": "Жудасная Музыка",
"wind": "Вецер",
"rustling_leaves": "Шамаценне Лісця",
"wind_noise": "Шум Ветру",
"thunderstorm": "Навальніца",
"thunder": "Грымоты",
"water": "Вада",
"rain": "Дождж",
"raindrop": "Кропля Дажджу",
"rain_on_surface": "Дождж па Паверхні",
"stream": "Ручай",
"waterfall": "Вадаспад",
"ocean": "Акіян",
"waves": "Хвалі",
"steam": "Пара",
"gurgling": "Бурчанне",
"fire": "Агонь",
"crackle": "Патрэскванне",
"vehicle": "Транспартны Сродак",
"boat": "Лодка",
"sailboat": "Ветразнік",
"rowboat": "Лодка на Вёслах",
"motorboat": "Маторная Лодка",
"ship": "Карабель",
"motor_vehicle": "Аўтатранспарт",
"car": "Аўтамабіль",
"toot": "Гудок",
"car_alarm": "Аўтасігналізацыя",
"power_windows": "Электрычныя Шклапад'ёмнікі",
"skidding": "Прабуксоўка",
"tire_squeal": "Віск Шын",
"car_passing_by": "Аўтамабіль Праязджае",
"race_car": "Гоначны Аўтамабіль",
"truck": "Грузавік",
"air_brake": "Пнеўматычны Тормаз",
"air_horn": "Пнеўматычны Сігнал",
"reversing_beeps": "Сігналы Задняга Ходу",
"ice_cream_truck": "Фургон з Марожаным",
"bus": "Аўтобус",
"emergency_vehicle": "Аварыйная Машына",
"police_car": "Паліцэйскі Аўтамабіль",
"ambulance": "Хуткая Дапамога",
"fire_engine": "Пажарная Машына",
"motorcycle": "Матацыкл",
"traffic_noise": "Шум Дарожнага Руху",
"rail_transport": "Чыгуначны Транспарт",
"train": "Цягнік",
"train_whistle": "Свісток Цягніка",
"train_horn": "Гудок Цягніка",
"railroad_car": "Чыгуначны Вагон",
"train_wheels_squealing": "Віск Колаў Цягніка",
"subway": "Метро",
"aircraft": "Паветранае Судна",
"aircraft_engine": "Авіяцыйны Рухавік",
"jet_engine": "Рэактыўны Рухавік",
"propeller": "Прапелер",
"helicopter": "Верталёт",
"fixed-wing_aircraft": "Самалёт",
"bicycle": "Веласіпед",
"skateboard": "Скейтборд",
"engine": "Рухавік",
"light_engine": "Лёгкі Рухавік",
"dental_drill's_drill": "Зубная Бармашына",
"lawn_mower": "Газонакасілка",
"chainsaw": "Бензапіла",
"medium_engine": "Сярэдні Рухавік",
"heavy_engine": "Цяжкі Рухавік",
"engine_knocking": "Стук Рухавіка",
"engine_starting": "Запуск Рухавіка",
"idling": "Халасты Ход",
"accelerating": "Паскарэнне",
"door": "Дзверы",
"doorbell": "Дзвярны Званок",
"ding-dong": "Дзін-Дон",
"sliding_door": "Рассоўныя Дзверы",
"slam": "Гучнае Зачыненне",
"knock": "Стук",
"tap": "Пастукванне",
"squeak": "Піск",
"cupboard_open_or_close": "Шафа Адчыняецца або Зачыняецца",
"drawer_open_or_close": "Шуфляда Адчыняецца або Зачыняецца",
"dishes": "Посуд",
"cutlery": "Сталовыя Прыборы",
"chopping": "Сяканне",
"frying": "Смажанне",
"microwave_oven": "Мікрахвалевая Печ",
"blender": "Блендэр",
"water_tap": "Водаправодны Кран",
"sink": "Ракавіна",
"bathtub": "Ванна",
"hair_dryer": "Фен",
"toilet_flush": "Спуск Вады ў Туалеце",
"toothbrush": "Зубная Шчотка",
"electric_toothbrush": "Электрычная Зубная Шчотка",
"vacuum_cleaner": "Пыласос",
"zipper": "Маланка",
"keys_jangling": "Бразгат Ключоў",
"coin": "Манета",
"scissors": "Нажніцы",
"electric_shaver": "Электрабрытва",
"shuffling_cards": "Тасаванне Карт",
"typing": "Набор Тэксту",
"typewriter": "Друкарская Машынка",
"computer_keyboard": "Камп'ютарная Клавіятура",
"writing": "Пісанне",
"alarm": "Сігнал Трывогі",
"telephone": "Тэлефон",
"telephone_bell_ringing": "Тэлефонны Званок",
"ringtone": "Рынгтон",
"telephone_dialing": "Набор Нумара",
"dial_tone": "Гудок Лініі",
"busy_signal": "Сігнал «Занята»",
"alarm_clock": "Будзільнік",
"siren": "Сірэна",
"civil_defense_siren": "Сірэна Грамадзянскай Абароны",
"buzzer": "Зумер",
"smoke_detector": "Датчык Дыму",
"fire_alarm": "Пажарная Сігналізацыя",
"foghorn": "Туманны Гудок",
"whistle": "Свісток",
"steam_whistle": "Паравы Свісток",
"mechanisms": "Механізмы",
"ratchet": "Трашчотка",
"clock": "Гадзіннік",
"tick": "Цік",
"tick-tock": "Цік-Так",
"gears": "Шасцярні",
"pulleys": "Шківы",
"sewing_machine": "Швейная Машына",
"mechanical_fan": "Вентылятар",
"air_conditioning": "Кандыцыянаванне Паветра",
"cash_register": "Касавы Апарат",
"printer": "Прынтар",
"camera": "Камера",
"single-lens_reflex_camera": "Люстраны Фотаапарат",
"tools": "Інструменты",
"hammer": "Молат",
"jackhammer": "Адбойны Молат",
"sawing": "Пілаванне",
"filing": "Апілоўванне",
"sanding": "Шліфаванне",
"power_tool": "Электраінструмент",
"drill": "Дрыль",
"explosion": "Выбух",
"gunshot": "Стрэл",
"machine_gun": "Кулямёт",
"fusillade": "Залп",
"artillery_fire": "Артылерыйскі Агонь",
"cap_gun": "Пугач",
"fireworks": "Феерверк",
"firecracker": "Петарда",
"burst": "Лопанне",
"eruption": "Вывяржэнне",
"boom": "Грукат",
"wood": "Дрэва",
"chop": "Удар",
"splinter": "Трэска",
"crack": "Трэск",
"glass": "Шкло",
"chink": "Дзынканне",
"shatter": "Разбіванне",
"silence": "Цішыня",
"sound_effect": "Гукавы Эфект",
"environmental_noise": "Навакольны Шум",
"static": "Статычныя Перашкоды",
"white_noise": "Белы Шум",
"pink_noise": "Ружовы Шум",
"television": "Тэлевізар",
"radio": "Радыё",
"field_recording": "Запіс на Прыродзе",
"scream": "Віск",
"chird": "Дзіця",
"change_ringing": "Перазвон",
"shofar": "Шафар",
"liquid": "Вадкасць",
"splash": "Усплёск",
"slosh": "Плюханне",
"squish": "Чвяканне",
"drip": "Капанне",
"pour": "Пераліванне",
"trickle": "Цурчанне",
"gush": "Струя",
"fill": "Запаўненне",
"spray": "Распыленне",
"pump": "Помпа",
"stir": "Размешванне",
"boiling": "Кіпенне",
"sonar": "Санар",
"arrow": "Страла",
"whoosh": "Свіст",
"thump": "Глухі Ўдар",
"thunk": "Глухі Стук",
"electronic_tuner": "Электронны Цюнер",
"effects_unit": "Блок Эфектаў",
"chorus_effect": "Эфект Хору",
"basketball_bounce": "Адскок Баскетбольнага Мяча",
"bang": "Бразгат",
"slap": "Шлёпанне",
"whack": "Хлёсткі Ўдар",
"smash": "Грукат",
"breaking": "Разбіванне",
"bouncing": "Адскокванне",
"whip": "Бізун",
"flap": "Лопанне",
"scratch": "Драпанне",
"scrape": "Скрэбанне",
"rub": "Трэнне",
"roll": "Перакат",
"crushing": "Драбленне",
"crumpling": "Шамаценне",
"tearing": "Рванне",
"beep": "Гукавы Сігнал",
"ping": "Пінг",
"ding": "Дзынь",
"clang": "Бразгат Металу",
"squeal": "Віск",
"creak": "Скрып",
"rustle": "Шамаценне",
"whir": "Жужжанне",
"clatter": "Грукатанне",
"sizzle": "Шыпенне на Патэльні",
"clicking": "Клацанне",
"clickety_clack": "Перастук Колаў",
"rumble": "Гул",
"plop": "Плюх",
"hum": "Гудзенне",
"zing": "Звон",
"boing": "Пружынны Звон",
"crunch": "Хруст",
"sine_wave": "Сінусоіда",
"harmonic": "Гармоніка",
"chirp_tone": "Чырпаваны Сігнал",
"pulse": "Пульс",
"inside": "У Памяшканні",
"outside": "На Вуліцы",
"reverberation": "Рэверберацыя",
"echo": "Рэха",
"noise": "Шум",
"mains_hum": "Гудзенне Сеткі",
"distortion": "Скажэнне",
"sidetone": "Мясцовы Эфект",
"cacophony": "Какафонія",
"throbbing": "Пульсацыя",
"vibration": "Вібрацыя"
}
-332
View File
@@ -1,332 +0,0 @@
{
"time": {
"untilForTime": "Да {{time}}",
"untilForRestart": "Да перазапуску Frigate.",
"untilRestart": "Да перазапуску",
"never": "Ніколі",
"ago": "{{timeAgo}} таму",
"justNow": "Толькі што",
"today": "Сёння",
"yesterday": "Учора",
"last7": "Апошнія 7 дзён",
"last14": "Апошнія 14 дзён",
"last30": "Апошнія 30 дзён",
"thisWeek": "Гэты тыдзень",
"lastWeek": "Мінулы тыдзень",
"thisMonth": "Гэты месяц",
"lastMonth": "Мінулы месяц",
"5minutes": "5 хвілін",
"10minutes": "10 хвілін",
"30minutes": "30 хвілін",
"1hour": "1 гадзіна",
"12hours": "12 гадзін",
"24hours": "24 гадзіны",
"pm": "вечара",
"am": "раніцы",
"yr": "{{time}} г",
"year_one": "{{time}} год",
"year_few": "{{time}} гады",
"year_many": "{{time}} гадоў",
"mo": "{{time}} мес",
"month_one": "{{time}} месяц",
"month_few": "{{time}} месяцы",
"month_many": "{{time}} месяцаў",
"d": "{{time}} д",
"day_one": "{{time}} дзень",
"day_few": "{{time}} дні",
"day_many": "{{time}} дзён",
"h": "{{time}} гадз",
"hour_one": "{{time}} гадзіна",
"hour_few": "{{time}} гадзіны",
"hour_many": "{{time}} гадзін",
"m": "{{time}} хв",
"minute_one": "{{time}} хвіліна",
"minute_few": "{{time}} хвіліны",
"minute_many": "{{time}} хвілін",
"s": "{{time}} с",
"second_one": "{{time}} секунда",
"second_few": "{{time}} секунды",
"second_many": "{{time}} секунд",
"formattedTimestamp": {
"12hour": "d MMM, h:mm:ss aaa",
"24hour": "d MMM, HH:mm:ss"
},
"formattedTimestamp2": {
"12hour": "dd.MM h:mm:ssa",
"24hour": "d MMM HH:mm:ss"
},
"formattedTimestampHourMinute": {
"12hour": "h:mm aaa",
"24hour": "HH:mm"
},
"formattedTimestampHourMinuteSecond": {
"12hour": "h:mm:ss aaa",
"24hour": "HH:mm:ss"
},
"formattedTimestampMonthDayHourMinute": {
"12hour": "d MMM, h:mm aaa",
"24hour": "d MMM, HH:mm"
},
"formattedTimestampMonthDayYear": {
"12hour": "d MMM yyyy",
"24hour": "d MMM yyyy"
},
"formattedTimestampMonthDayYearHourMinute": {
"12hour": "d MMM yyyy, h:mm aaa",
"24hour": "d MMM yyyy, HH:mm"
},
"formattedTimestampMonthDay": "d MMM",
"formattedTimestampFilename": {
"12hour": "dd-MM-yy-h-mm-ss-a",
"24hour": "dd-MM-yy-HH-mm-ss"
},
"inProgress": "Выконваецца",
"invalidStartTime": "Памылковы час пачатку",
"invalidEndTime": "Памылковы час заканчэння"
},
"unit": {
"speed": {
"mph": "міль/г",
"kph": "км/г"
},
"length": {
"feet": "футы",
"meters": "метры"
},
"data": {
"kbps": "кБ/с",
"mbps": "МБ/с",
"gbps": "ГБ/с",
"kbph": "кБ/гадз",
"mbph": "МБ/гадз",
"gbph": "ГБ/гадз"
}
},
"label": {
"back": "Назад",
"hide": "Схаваць {{item}}",
"show": "Паказаць {{item}}",
"ID": "ID",
"none": "Няма",
"all": "Усе",
"other": "Іншае"
},
"list": {
"two": "{{0}} і {{1}}",
"many": "{{items}} і {{last}}",
"separatorWithSpace": ", "
},
"field": {
"optional": "Неабавязкова",
"internalID": "Унутраны ID, які Frigate выкарыстоўвае ў канфігурацыі і базе даных"
},
"button": {
"add": "Дадаць",
"apply": "Прымяніць",
"applying": "Прымяненне…",
"reset": "Скінуць",
"undo": "Адрабіць",
"done": "Гатова",
"enabled": "Уключана",
"enable": "Уключыць",
"disabled": "Адключана",
"disable": "Адключыць",
"save": "Захаваць",
"saving": "Захаванне…",
"cancel": "Скасаваць",
"close": "Закрыць",
"copy": "Капіяваць",
"copiedToClipboard": "Скапіявана ў буфер абмену",
"back": "Назад",
"history": "Гісторыя",
"fullscreen": "На ўвесь экран",
"exitFullscreen": "Выйсці з поўнаэкраннага рэжыму",
"pictureInPicture": "Picture in Picture",
"twoWayTalk": "Двухбаковая размова",
"cameraAudio": "Гук камеры",
"on": "Укл.",
"off": "Выкл.",
"edit": "Рэдагаваць",
"copyCoordinates": "Скапіяваць каардынаты",
"delete": "Выдаліць",
"yes": "Так",
"no": "Не",
"download": "Спампоўка",
"info": "Звесткі",
"suspended": "Прыпынена",
"unsuspended": "Аднавіць",
"play": "Прайграць",
"unselect": "Зняць выбар",
"export": "Экспарт",
"deleteNow": "Выдаліць зараз",
"next": "Далей",
"continue": "Працягнуць",
"modified": "Зменена",
"overridden": "Перавызначана",
"resetToGlobal": "Скінуць да глабальнага",
"resetToDefault": "Скінуць да прадвызначанага",
"saveAll": "Захаваць усё",
"savingAll": "Захаванне ўсяго…",
"undoAll": "Адрабіць усё",
"retry": "Паўтарыць"
},
"menu": {
"system": "Сістэма",
"systemMetrics": "Сістэмныя метрыкі",
"configuration": "Канфігурацыя",
"systemLogs": "Журналы сістэмы",
"profiles": "Профілі",
"settings": "Налады",
"configurationEditor": "Рэдактар канфігурацыі",
"languages": "Мовы",
"language": {
"en": "English (англійская)",
"es": "Español (іспанская)",
"zhCN": "简体中文 (кітайская спрошчаная)",
"zhHant": "繁體中文 (кітайская традыцыйная)",
"hi": "हिन्दी (хіндзі)",
"fr": "Français (французская)",
"ar": "العربية (арабская)",
"pt": "Português (партугальская)",
"ptBR": "Português brasileiro (бразільская партугальская)",
"ru": "Русский (руская)",
"de": "Deutsch (нямецкая)",
"ja": "日本語 (японская)",
"tr": "Türkçe (турэцкая)",
"it": "Italiano (італьянская)",
"nl": "Nederlands (нідэрландская)",
"sv": "Svenska (шведская)",
"cs": "Čeština (чэшская)",
"nb": "Norsk Bokmål (нарвежская букмол)",
"ko": "한국어 (карэйская)",
"vi": "Tiếng Việt (в'етнамская)",
"fa": "فارسی (персідская)",
"pl": "Polski (польская)",
"uk": "Українська (украінская)",
"he": "עברית (іўрыт)",
"el": "Ελληνικά (грэчаская)",
"ro": "Română (румынская)",
"hu": "Magyar (венгерская)",
"fi": "Suomi (фінская)",
"da": "Dansk (дацкая)",
"sk": "Slovenčina (славацкая)",
"yue": "粵語 (кантонская)",
"th": "ไทย (тайская)",
"ca": "Català (каталонская)",
"hr": "Hrvatski (харвацкая)",
"bs": "Bosanski (баснійская)",
"sr": "Српски (сербская)",
"sl": "Slovenščina (славенская)",
"lt": "Lietuvių (літоўская)",
"bg": "Български (балгарская)",
"gl": "Galego (галісійская)",
"id": "Bahasa Indonesia (інданезійская)",
"ur": "اردو (урду)",
"withSystem": {
"label": "Выкарыстоўваць мову з налад сістэмы"
},
"be": "Беларуская"
},
"appearance": "Выгляд",
"darkMode": {
"label": "Цёмны рэжым",
"light": "Светлы",
"dark": "Цёмны",
"withSystem": {
"label": "Выкарыстоўваць светлы або цёмны рэжым з налад сістэмы"
}
},
"withSystem": "Сістэма",
"theme": {
"label": "Тэма",
"blue": "Сіняя",
"green": "Зялёная",
"nord": "Nord",
"red": "Чырвоная",
"highcontrast": "Высокая кантраснасць",
"default": "Прадвызначаны"
},
"help": "Даведка",
"documentation": {
"title": "Дакументацыя",
"label": "Дакументацыя Frigate"
},
"restart": "Перазапусціць Frigate",
"live": {
"title": "Эфір",
"allCameras": "Усе камеры",
"cameras": {
"title": "Камеры",
"count_one": "{{count}} камера",
"count_few": "{{count}} камеры",
"count_many": "{{count}} камер"
}
},
"review": "Разгляд",
"explore": "Агляд",
"export": "Экспарт",
"actions": "Дзеянні",
"uiPlayground": "Пясочніца інтэрфейсу",
"features": "Магчымасці",
"faceLibrary": "Бібліятэка асоб",
"classification": "Класіфікацыя",
"chat": "Чат",
"user": {
"title": "Карыстальнік",
"account": "Акаўнт",
"current": "Бягучы карыстальнік: {{user}}",
"anonymous": "ананімны",
"logout": "Выхад",
"setPassword": "Задаць пароль"
}
},
"toast": {
"copyUrlToClipboard": "URL скапіяваны ў буфер абмену.",
"save": {
"title": "Захаваць",
"error": {
"title": "Не ўдалося захаваць змены канфігурацыі: {{errorMessage}}",
"noMessage": "Не ўдалося захаваць змены канфігурацыі"
},
"success": "Змены канфігурацыі захаваны."
}
},
"role": {
"title": "Роля",
"admin": "Адміністратар",
"viewer": "Назіральнік",
"desc": "Адміністратары маюць поўны доступ да ўсіх магчымасцей інтэрфейсу Frigate. Назіральнікі могуць толькі праглядаць камеры, элементы разгляду і архіўныя відэа."
},
"pagination": {
"label": "нумарацыя старонак",
"previous": {
"title": "Папярэдні",
"label": "Перайсці на папярэднюю старонку"
},
"next": {
"title": "Далей",
"label": "Перайсці на наступную старонку"
},
"more": "Больш старонак"
},
"accessDenied": {
"documentTitle": "Доступ забаронены - Frigate",
"title": "Доступ забаронены",
"desc": "У вас няма дазволу на прагляд гэтай старонкі."
},
"notFound": {
"documentTitle": "Не знойдзена - Frigate",
"title": "404",
"desc": "Старонка не знойдзена"
},
"selectItem": "Выбраць {{item}}",
"readTheDocumentation": "Чытаць дакументацыю",
"information": {
"pixels": "{{area}} пікс"
},
"no_items": "Няма элементаў",
"validation_errors": "Памылкі праверкі",
"credentialField": {
"savedPlaceholder": "Захавана - пакіньце пустым, каб не мяняць"
}
}
@@ -1,16 +0,0 @@
{
"form": {
"user": "Імя карыстальніка",
"password": "Пароль",
"login": "Уваход",
"firstTimeLogin": "Уваходзіце ўпершыню? Даныя карыстальніка друкуюцца ў журналах Frigate.",
"errors": {
"usernameRequired": "Патрабуецца імя карыстальніка",
"passwordRequired": "Патрабуецца пароль",
"rateLimit": "Перавышаны ліміт запытаў. Паспрабуйце пазней.",
"loginFailed": "Не ўдалося ўвайсці",
"unknownError": "Невядомая памылка. Праверце журналы.",
"webUnknownError": "Невядомая памылка. Праверце журналы кансолі."
}
}
}
@@ -1,90 +0,0 @@
{
"group": {
"label": "Групы камер",
"add": "Дадаць групу камер",
"showAll": "Паказаць усе групы камер",
"showLess": "Паказаць меней",
"edit": "Рэдагаваць групу камер",
"editGroups": "Рэдагаваць групы камер",
"delete": {
"label": "Выдаліць групу камер",
"confirm": {
"title": "Пацвердзіце выдаленне",
"desc": "Сапраўды выдаліць групу камер <em>{{name}}</em>?"
}
},
"name": {
"label": "Назва",
"placeholder": "Увядзіце назву…",
"errorMessage": {
"mustLeastCharacters": "Назва групы камер мусіць мець не меней за 2 сімвалы.",
"exists": "Такая назва групы камер ужо існуе.",
"nameMustNotPeriod": "Назва групы камер не мусіць утрымліваць кропку.",
"invalid": "Памылковая назва групы камер."
}
},
"cameras": {
"label": "Камеры",
"desc": "Выберыце камеры для гэтай групы."
},
"icon": "Значок",
"success": "Група камер ({{name}}) захавана.",
"camera": {
"birdseye": "Birdseye",
"setting": {
"label": "Налады трансляцыі камеры",
"title": "Налады трансляцыі: {{cameraName}}",
"desc": "Змяніце параметры жывой трансляцыі для панэлі гэтай групы камер. <em>Гэтыя налады датычацца канкрэтнай прылады або браўзера.</em>",
"audioIsAvailable": "Для гэтай плыні даступны гук",
"audioIsUnavailable": "Для гэтай плыні гук недаступны",
"audio": {
"tips": {
"title": "Гук мусіць выводзіцца з камеры і быць наладжаны ў go2rtc для гэтай плыні."
}
},
"stream": "Плынь",
"placeholder": "Выберыце плынь",
"streamMethod": {
"label": "Спосаб трансляцыі",
"placeholder": "Выберыце спосаб трансляцыі",
"method": {
"noStreaming": {
"label": "Без трансляцыі",
"desc": "Відарыс камеры будзе абнаўляцца раз на хвіліну, жывой трансляцыі не будзе."
},
"smartStreaming": {
"label": "Разумная трансляцыя (рэкамендуецца)",
"desc": "Разумная трансляцыя абнаўляе відарыс камеры раз на хвіліну, калі нічога не адбываецца, каб зберагчы паласу прапускання і рэсурсы. Пры выяўленні актыўнасці відарыс плаўна пераходзіць у жывая плынь."
},
"continuousStreaming": {
"label": "Бесперапынная трансляцыя",
"desc": {
"title": "Відарыс камеры заўсёды будзе жывой плынню, пакуль ён бачны на панэлі, нават калі актыўнасці няма.",
"warning": "Бесперапынная трансляцыя можа моцна нагружаць паласу прапускання і зніжаць прадукцыйнасць. Карыстайцеся асцярожна."
}
}
}
},
"compatibilityMode": {
"label": "Рэжым сумяшчальнасці",
"desc": "Уключайце гэты параметр толькі калі ў жывой плыні камеры з'яўляюцца скажэнні колеру і дыяганальная лінія справа."
}
}
}
},
"debug": {
"options": {
"label": "Налады",
"title": "Параметры",
"showOptions": "Паказаць параметры",
"hideOptions": "Схаваць параметры"
},
"boundingBox": "Абмяжавальны прамавугольнік",
"timestamp": "Метка часу",
"zones": "Зоны",
"mask": "Маска",
"motion": "Рух",
"regions": "Рэгіёны",
"paths": "Шляхі"
}
}
@@ -1,204 +0,0 @@
{
"restart": {
"title": "Сапраўды перазапусціць Frigate?",
"description": "Frigate ненадоўга спыніцца на час перазапуску.",
"button": "Перазапусціць",
"restarting": {
"title": "Frigate перазапускаецца",
"content": "Гэта старонка перазагрузіцца праз {{countdown}} с.",
"button": "Прымусова перазагрузіць зараз"
}
},
"explore": {
"plus": {
"submitToPlus": {
"label": "Адправіць у Frigate+",
"desc": "Аб'екты ў месцах, якія вы хочаце ігнараваць, не з'яўляюцца памылковымі спрацоўваннямі. Адпраўка іх як памылковых заблытае мадэль."
},
"review": {
"question": {
"label": "Пацвердзіце гэтую метку для Frigate Plus",
"ask_a": "Ці з'яўляецца гэты аб'ект <code>{{label}}</code>?",
"ask_an": "Ці з'яўляецца гэты аб'ект <code>{{label}}</code>?",
"ask_full": "Ці з'яўляецца гэты аб'ект <code>{{untranslatedLabel}}</code> ({{translatedLabel}})?"
},
"state": {
"submitted": "Адпраўлена"
},
"toast": {
"error": "Не ўдалося адправіць у Frigate+. Праверце сеткавае злучэнне і паспрабуйце зноў."
}
}
},
"video": {
"viewInHistory": "Паказаць у гісторыі"
}
},
"export": {
"time": {
"fromTimeline": "Выбраць са шкалы часу",
"lastHour_one": "Апошняя {{count}} гадзіна",
"lastHour_few": "Апошнія {{count}} гадзіны",
"lastHour_many": "Апошнія {{count}} гадзін",
"custom": "Уласны",
"start": {
"title": "Час пачатку",
"label": "Выберыце час пачатку"
},
"end": {
"title": "Час заканчэння",
"label": "Выберыце час заканчэння"
}
},
"name": {
"placeholder": "Назавіце экспарт"
},
"case": {
"newCaseOption": "Стварыць новую справу",
"newCaseNamePlaceholder": "Назва новай справы",
"newCaseDescriptionPlaceholder": "Апісанне справы",
"label": "Справа",
"nonAdminHelp": "Для гэтых экспартаў будзе створана новая справа.",
"placeholder": "Выберыце справу"
},
"select": "Выбраць",
"export": "Экспарт",
"queueing": "Экспарт ставіцца ў чаргу…",
"selectOrExport": "Выбраць або экспартаваць",
"tabs": {
"export": "Адна камера",
"multiCamera": "Некалькі камер"
},
"multiCamera": {
"timeRange": "Прамежак часу",
"selectFromTimeline": "Выбраць са шкалы часу",
"cameraSelection": "Камеры",
"cameraSelectionHelp": "Камеры з аб'ектамі пад адсочваннем у гэтым прамежку выбраны загадзя",
"searchOrSelectGroup": "Шукайце або выберыце групу камер…",
"selectAll": "Выбраць усе камеры",
"clearSelection": "Зняць выбар",
"selectWithActivity": "Камеры з аб'ектамі пад адсочваннем",
"selectGroup": "Выбраць групу",
"noMatchingCameras": "Няма камер, якія адпавядаюць запыту",
"selectedCount": "Выбрана {{selected}} з {{total}}",
"checkingActivity": "Праверка актыўнасці камер…",
"noCameras": "Няма даступных камер",
"detectionCount_one": "{{count}} аб'ект пад адсочваннем",
"detectionCount_few": "{{count}} аб'екты пад адсочваннем",
"detectionCount_many": "{{count}} аб'ектаў пад адсочваннем",
"nameLabel": "Назва экспарту",
"namePlaceholder": "Неабавязковая базавая назва для гэтых экспартаў",
"queueingButton": "Экспарты ставяцца ў чаргу…",
"exportButton_one": "Экспартаваць {{count}} камеру",
"exportButton_few": "Экспартаваць {{count}} камеры",
"exportButton_many": "Экспартаваць {{count}} камер"
},
"multi": {
"title_one": "Экспартаваць {{count}} разгляд",
"title_few": "Экспартаваць {{count}} разгляды",
"title_many": "Экспартаваць {{count}} разглядаў",
"description": "Экспартаваць кожны выбраны разгляд. Усе экспарты будуць аб'яднаны ў адну справу.",
"descriptionNoCase": "Экспартаваць кожны выбраны разгляд.",
"caseNamePlaceholder": "Экспарт разгляду - {{date}}",
"exportButton_one": "Экспартаваць {{count}} разгляд",
"exportButton_few": "Экспартаваць {{count}} разгляды",
"exportButton_many": "Экспартаваць {{count}} разглядаў",
"exportingButton": "Экспартаванне…",
"toast": {
"started_one": "Пачаты {{count}} экспарт.",
"started_few": "Пачата {{count}} экспарты.",
"started_many": "Пачата {{count}} экспартаў.",
"partial": "Пачата {{successful}} з {{total}} экспартаў. Не ўдалося: {{failedItems}}",
"failed": "Не ўдалося пачаць {{total}} экспартаў. Не ўдалося: {{failedItems}}"
}
},
"toast": {
"success": "Экспарт пачаты. Файл даступны на старонцы экспартаў.",
"queued": "Экспарт пастаўлены ў чаргу. Ход выканання на старонцы экспартаў.",
"view": "Від",
"batchSuccess_one": "Пачаты {{count}} экспарт. Адкрываем справу.",
"batchSuccess_few": "Пачата {{count}} экспарты. Адкрываем справу.",
"batchSuccess_many": "Пачата {{count}} экспартаў. Адкрываем справу.",
"batchPartial": "Пачата {{successful}} з {{total}} экспартаў. Няўдалыя камеры: {{failedCameras}}",
"batchFailed": "Не ўдалося пачаць {{total}} экспартаў. Няўдалыя камеры: {{failedCameras}}",
"batchQueuedSuccess_one": "У чаргу пастаўлены {{count}} экспарт.",
"batchQueuedSuccess_few": "У чаргу пастаўлена {{count}} экспарты.",
"batchQueuedSuccess_many": "У чаргу пастаўлена {{count}} экспартаў.",
"batchQueuedPartial": "У чаргу пастаўлена {{successful}} з {{total}} экспартаў. Няўдалыя камеры: {{failedCameras}}",
"batchQueueFailed": "Не ўдалося паставіць у чаргу {{total}} экспартаў. Няўдалыя камеры: {{failedCameras}}",
"error": {
"failed": "Не ўдалося паставіць экспарт у чаргу: {{error}}",
"endTimeMustAfterStartTime": "Час заканчэння мусіць быць пазней за час пачатку",
"noValidTimeSelected": "Не выбраны сапраўдны прамежак часу"
}
},
"fromTimeline": {
"saveExport": "Захаваць экспарт",
"queueingExport": "Экспарт ставіцца ў чаргу…",
"previewExport": "Перадпрагляд экспарту",
"useThisRange": "Ужыць гэты прамежак"
}
},
"streaming": {
"label": "Плынь",
"restreaming": {
"disabled": "Для гэтай камеры рэтрансляцыя не ўключана.",
"desc": {
"title": "Наладзьце go2rtc, каб атрымаць дадатковыя параметры жывога прагляду і гук для гэтай камеры."
}
},
"showStats": {
"label": "Паказваць статыстыку плыні",
"desc": "Уключыце гэты параметр, каб паказваць статыстыку плыні паверх відарыса камеры."
},
"debugView": "Адладачны выгляд"
},
"search": {
"saveSearch": {
"label": "Захаваць пошук",
"desc": "Задайце назву для гэтага захаванага пошуку.",
"placeholder": "Увядзіце назву пошуку",
"overwrite": "{{searchName}} ужо існуе. Захаванне перазапіша наяўнае значэнне.",
"success": "Пошук ({{searchName}}) захаваны.",
"button": {
"save": {
"label": "Захаваць гэты пошук"
}
}
}
},
"recording": {
"shareTimestamp": {
"label": "Падзяліцца меткай часу",
"title": "Падзяліцца меткай часу",
"description": "Падзяліцеся URL з меткай часу бягучай пазіцыі плэера або выберыце сваю метку часу. Гэта не публічная спасылка: яна даступна толькі карыстальнікам, якія маюць доступ да Frigate і да гэтай камеры.",
"custom": "Свая метка часу",
"button": "URL з меткай часу",
"shareTitle": "Метка часу разгляду Frigate: {{camera}}"
},
"confirmDelete": {
"title": "Пацвердзіце выдаленне",
"desc": {
"selected": "Сапраўды выдаліць усё запісанае відэа, звязанае з гэтым элементам разгляду?<br /><br />Утрымлівайце клавішу <em>Shift</em>, каб надалей прапускаць гэтае акно."
},
"toast": {
"success": "Відэа, звязанае з выбранымі элементамі разгляду, выдалена.",
"error": "Не ўдалося выдаліць: {{error}}"
}
},
"button": {
"export": "Экспарт",
"markAsReviewed": "Пазначыць як разгледжанае",
"markAsUnreviewed": "Пазначыць як неразгледжанае",
"deleteNow": "Выдаліць зараз"
}
},
"imagePicker": {
"selectImage": "Выберыце паменшаную выяву аб'екта пад адсочваннем",
"unknownLabel": "Захаваны відарыс трыгера",
"search": {
"placeholder": "Пошук па метцы або падметцы…"
},
"noImages": "Для гэтай камеры паменшаных выяў не знойдзена"
}
}
@@ -1,140 +0,0 @@
{
"filter": "Фільтр",
"classes": {
"label": "Класы",
"all": {
"title": "Усе класы"
},
"count_one": "{{count}} клас",
"count_other": "{{count}} класа"
},
"labels": {
"label": "Меткі",
"all": {
"title": "Усе меткі",
"short": "Меткі"
},
"count_one": "{{count}} метка",
"count_other": "{{count}} меткі"
},
"zones": {
"label": "Зоны",
"all": {
"title": "Усе зоны",
"short": "Зоны"
}
},
"dates": {
"selectPreset": "Выберыце набор…",
"all": {
"title": "Усе даты",
"short": "Даты"
}
},
"more": "Больш фільтраў",
"reset": {
"label": "Скінуць фільтры да прадвызначаных значэнняў"
},
"timeRange": "Прамежак часу",
"subLabels": {
"label": "Падметкі",
"all": "Усе падметкі"
},
"attributes": {
"label": "Атрыбуты класіфікацыі",
"all": "Усе атрыбуты"
},
"score": "Бал",
"estimatedSpeed": "Ацэненая хуткасць ({{unit}})",
"features": {
"label": "Магчымасці",
"hasSnapshot": "Мае здымак",
"hasVideoClip": "Мае відэакліп",
"submittedToFrigatePlus": {
"label": "Адпраўлена ў Frigate+",
"tips": "Спачатку трэба адфільтраваць аб'екты пад адсочваннем, якія маюць здымак.<br /><br />Аб'екты без здымка нельга адправіць у Frigate+."
}
},
"sort": {
"label": "Сартаваць",
"dateAsc": "Дата (па ўзрастанні)",
"dateDesc": "Дата (па спаданні)",
"scoreAsc": "Бал аб'екта (па ўзрастанні)",
"scoreDesc": "Бал аб'екта (па спаданні)",
"speedAsc": "Ацэненая хуткасць (па ўзрастанні)",
"speedDesc": "Ацэненая хуткасць (па спаданні)",
"relevance": "Рэлевантнасць"
},
"cameras": {
"label": "Фільтр камер",
"all": {
"title": "Усе камеры",
"short": "Камеры"
}
},
"review": {
"showReviewed": "Паказваць разгледжаныя"
},
"motion": {
"showMotionOnly": "Паказваць толькі рух"
},
"explore": {
"settings": {
"title": "Налады",
"defaultView": {
"title": "Прадвызн. від",
"desc": "Калі фільтры не выбраны, паказваць зводку апошніх аб'ектаў пад адсочваннем па кожнай метцы або нефільтраваную сетку.",
"summary": "Зводка",
"unfilteredGrid": "Нефільтраваная сетка"
},
"gridColumns": {
"title": "Слупкі сеткі",
"desc": "Выберыце колькасць слупкоў у выглядзе сеткі."
},
"searchSource": {
"label": "Крыніца пошуку",
"desc": "Выберыце, дзе шукаць: у паменшаных выявах ці ў апісаннях аб'ектаў пад адсочваннем.",
"options": {
"thumbnailImage": "Паменшаная выява",
"description": "Апісанне"
}
}
},
"date": {
"selectDateBy": {
"label": "Выберыце дату для фільтравання"
}
}
},
"logSettings": {
"label": "Фільтр па ўзроўні журнала",
"filterBySeverity": "Фільтраваць журналы па ўзроўні важнасці",
"loading": {
"title": "Загрузка",
"desc": "Калі панэль журнала пракручана ўніз, новыя запісы з'яўляюцца аўтаматычна па меры паступлення."
},
"disableLogStreaming": "Адключыць плынь журналаў",
"allLogs": "Усе журналы"
},
"trackedObjectDelete": {
"title": "Пацвердзіце выдаленне",
"desc": "Выдаленне гэтых аб'ектаў пад адсочваннем ({{objectLength}}) прыбярэ здымак, усе захаваныя ўбудаванні і ўсе звязаныя запісы жыццёвага цыкла аб'екта. Запісанае відэа гэтых аб'ектаў у выглядзе «Гісторыя» <em>НЕ</em> будзе выдалена.<br /><br />Сапраўды працягнуць?<br /><br />Утрымлівайце клавішу <em>Shift</em>, каб надалей прапускаць гэтае акно.",
"toast": {
"success": "Аб'екты пад адсочваннем выдалены.",
"error": "Не ўдалося выдаліць аб'екты пад адсочваннем: {{errorMessage}}"
}
},
"zoneMask": {
"filterBy": "Фільтраваць па масцы зоны"
},
"recognizedLicensePlates": {
"title": "Распазнаныя нумарныя знакі",
"loadFailed": "Не ўдалося загрузіць распазнаныя нумарныя знакі.",
"loading": "Загрузка распазнаных нумарных знакаў…",
"placeholder": "Набярыце, каб шукаць нумарныя знакі…",
"noLicensePlatesFound": "Нумарных знакаў не знойдзена.",
"selectPlatesFromList": "Выберыце адзін або некалькі знакаў са спіса.",
"selectAll": "Выбраць усе",
"clearAll": "Ачысціць усё"
}
}
@@ -1,8 +0,0 @@
{
"iconPicker": {
"selectIcon": "Выберыце значок",
"search": {
"placeholder": "Пошук значка…"
}
}
}
@@ -1,10 +0,0 @@
{
"button": {
"downloadVideo": {
"label": "Спампаваць відэа",
"toast": {
"success": "Відэа элемента разгляду пачало спампоўвацца."
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More