mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-09 20:19:47 -04:00
Compare commits
92
Commits
v0.18.0-beta3
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77a66e75c6 | ||
|
|
4ac92dac72 | ||
|
|
6ceb370abb | ||
|
|
dfefc5a64b | ||
|
|
97dec9d046 | ||
|
|
ffcadb440e | ||
|
|
a381a88b29 | ||
|
|
9e74adf812 | ||
|
|
287fc42404 | ||
|
|
b4d5035b79 | ||
|
|
7497ef7506 | ||
|
|
0d5038c7a0 | ||
|
|
c71939acc2 | ||
|
|
e983825781 | ||
|
|
25681444d4 | ||
|
|
47d25254e9 | ||
|
|
3e6d60fcac | ||
|
|
77d6b0540d | ||
|
|
632af09f73 | ||
|
|
10eb677e4c | ||
|
|
bc3e4a0b35 | ||
|
|
08a13d955e | ||
|
|
ac16decf05 | ||
|
|
6e106854e4 | ||
|
|
c6688c8ce7 | ||
|
|
149362c77f | ||
|
|
2117b58aba | ||
|
|
a529656a90 | ||
|
|
c1b56b51b0 | ||
|
|
d37dff1b49 | ||
|
|
a745070b76 | ||
|
|
4ffe687c44 | ||
|
|
59269238ed | ||
|
|
02da563712 | ||
|
|
a670972a4f | ||
|
|
c4d46b7ed3 | ||
|
|
baa1ba718c | ||
|
|
359d44fc8f | ||
|
|
80efea2554 | ||
|
|
592775bd19 | ||
|
|
aee6aa1845 | ||
|
|
ca18b8dc13 | ||
|
|
5197881ef7 | ||
|
|
18c77faea5 | ||
|
|
41c8d6cc6b | ||
|
|
271051f15b | ||
|
|
65fe6b610a | ||
|
|
41bc24cce4 | ||
|
|
0254a11874 | ||
|
|
ad79e666eb | ||
|
|
fc79aeab5e | ||
|
|
b1cdf1f76b | ||
|
|
fc319f4223 | ||
|
|
bf35e90bc8 | ||
|
|
07abbd2c0a | ||
|
|
694d162071 | ||
|
|
b1d9676638 | ||
|
|
891a0df879 | ||
|
|
2d5845c770 | ||
|
|
612a7cb871 | ||
|
|
101e5d0e98 | ||
|
|
bcff29c35b | ||
|
|
467404b410 | ||
|
|
767597967e | ||
|
|
57b8206e86 | ||
|
|
86b828f52e | ||
|
|
4b39edf983 | ||
|
|
06f5229567 | ||
|
|
90a33f504c | ||
|
|
d0766aa3ee | ||
|
|
76a5e00bd5 | ||
|
|
b914f32cea | ||
|
|
48acba8dab | ||
|
|
c605295483 | ||
|
|
ad35bf49f7 | ||
|
|
000bf4a03b | ||
|
|
d2982bd144 | ||
|
|
2cd53ccdfe | ||
|
|
2ba33e227c | ||
|
|
036bae4ea9 | ||
|
|
8384a8c5b3 | ||
|
|
77fc2ce174 | ||
|
|
8425a76558 | ||
|
|
11f8786459 | ||
|
|
812e5308a3 | ||
|
|
fd98977506 | ||
|
|
6816050a46 | ||
|
|
c70a0802b8 | ||
|
|
aff9799451 | ||
|
|
c75611b4df | ||
|
|
0735a8ac75 | ||
|
|
2599795ab0 |
No files matched your search
+1
-1
@@ -24,7 +24,7 @@ yell
|
||||
sigh
|
||||
singing
|
||||
choir
|
||||
sodeling
|
||||
yodeling
|
||||
chant
|
||||
mantra
|
||||
child_singing
|
||||
|
||||
@@ -150,7 +150,9 @@ http {
|
||||
include auth_request.conf;
|
||||
types {
|
||||
video/mp4 mp4;
|
||||
image/jpeg jpg;
|
||||
image/jpeg jpg jpeg;
|
||||
image/png png;
|
||||
image/webp webp;
|
||||
}
|
||||
|
||||
expires 7d;
|
||||
|
||||
@@ -1100,7 +1100,7 @@ synaptics:
|
||||
- key: ssd
|
||||
label: SSD MobileNet
|
||||
recommended: true
|
||||
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).
|
||||
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).
|
||||
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:
|
||||
|
||||
|
||||
@@ -50,6 +50,31 @@ Connect each stream to get a live preview, an estimated bandwidth figure, and a
|
||||
|
||||
Other features, including [hardware acceleration](hardware_acceleration_video.md), [two way talk](/configuration/live#two-way-talk), and audio transcoding, is configured after the camera has been added. For camera model specific quirks, see the [camera specific](camera_specific.md) docs.
|
||||
|
||||
## Deleting a camera
|
||||
|
||||
Click **Delete Camera** in <NavPath path="Settings > Global configuration > Camera management" />, choose the camera, and confirm. Deleting a camera requires the `admin` role and cannot be undone.
|
||||
|
||||
:::warning
|
||||
|
||||
Deleting a camera permanently removes its recordings, tracked objects, and configuration. If you only want to stop processing a camera, set its state to **Off** or **Disabled** in <NavPath path="Settings > Global configuration > Camera management" /> instead. See [camera state](/configuration/live#camera-state).
|
||||
|
||||
:::
|
||||
|
||||
Deleting a camera removes:
|
||||
|
||||
- The camera's section of your config file, along with its entries in any [role](authentication.md#user-roles) camera list. A custom role left with no cameras is removed as well.
|
||||
- Every database record for the camera: tracked objects, review items, recordings, previews, timeline entries, the saved region grid, and [triggers](semantic_search.md#triggers).
|
||||
- Every media file for the camera: recordings, snapshots, thumbnails, and preview clips.
|
||||
|
||||
[Exports](/usage/exports) are kept by default, so saved footage survives the deletion of the camera it came from. Turn on **Also delete exports for this camera** in the confirmation step to remove those too.
|
||||
|
||||
The camera's processes are stopped and the change takes effect immediately, so no restart is required. If the resulting config cannot be parsed, Frigate restores the previous config and reports an error instead of leaving Frigate in a broken state.
|
||||
|
||||
Two things are not cleaned up for you:
|
||||
|
||||
- **go2rtc streams.** Frigate makes a best effort to stop a running [go2rtc](go2rtc.md) stream named after the camera, but stream entries in your config file remain and are recreated on the next restart. Remove them in <NavPath path="Settings > System > go2rtc streams" /> or in your config file.
|
||||
- **Camera groups.** A deleted camera stays listed in any [camera group](#setting-up-camera-groups) that referenced it. The group skips the missing camera, so this is harmless, but you can edit the group to drop the stale entry.
|
||||
|
||||
## Setting Up Camera Inputs
|
||||
|
||||
Several inputs can be configured for each camera and the role of each input can be mixed and matched based on your needs. This allows you to use a lower resolution stream for object detection, but create recordings from a higher resolution stream, or vice versa.
|
||||
|
||||
@@ -59,13 +59,17 @@ Running Generative AI models on CPU is not recommended, as high inference times
|
||||
|
||||
### Recommended Local Models
|
||||
|
||||
#### Vision models
|
||||
|
||||
You must use a vision-capable model with Frigate. The following models are recommended for local deployment of the `descriptions` and `chat` roles:
|
||||
|
||||
| Model | Notes |
|
||||
| ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `qwen3-vl` | Strong visual and situational understanding, enhanced ability to identify smaller objects and interactions with object. |
|
||||
| `qwen3.6` | Strong situational understanding, but missing DeepStack from qwen3-vl leading to worse performance for identifying objects in people's hand and other small details. |
|
||||
| `gemma4` | Strong situational understanding, sometimes resorts to more vague terms like 'interacts' instead of assigning a specific action. |
|
||||
| Model | Notes |
|
||||
| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `qwen3-vl` | Strong visual and situational understanding, enhanced ability to identify smaller objects and interactions with object. |
|
||||
| `qwen3.6`/`qwen3.8` | Strong situational understanding, but missing DeepStack from qwen3-vl leading to worse performance for identifying objects in people's hand and other small details. |
|
||||
| `gemma4` | Strong situational understanding, sometimes resorts to more vague terms like 'interacts' instead of assigning a specific action. |
|
||||
|
||||
#### Embedding models
|
||||
|
||||
The `embeddings` role needs a different kind of model. Text queries are matched against the stored image embeddings, so the model must be trained to place images and text into the same vector space. A chat or description model will still return vectors when asked, but those vectors are not trained for retrieval and text searches will return poor matches with no error to indicate why.
|
||||
|
||||
|
||||
@@ -498,7 +498,7 @@ cameras:
|
||||
|
||||
## Synaptics
|
||||
|
||||
Hardware accelerated video de-/encoding is supported on Synpatics SL-series SoC.
|
||||
Hardware accelerated video de-/encoding is supported on Synaptics 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` 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.
|
||||
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.
|
||||
|
||||
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 `car` or `motorcycle` tracked object.
|
||||
- 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/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 `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.
|
||||
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.
|
||||
|
||||
:::
|
||||
|
||||
@@ -86,7 +86,7 @@ cameras:
|
||||
</TabItem>
|
||||
</ConfigTabs>
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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 `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`.
|
||||
- **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`.
|
||||
- **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 `car` or `motorcycle` 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 vehicles but you don't want Frigate to run LPR for that camera, disable LPR at the camera level:
|
||||
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
@@ -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 `car` / `motorcycle` and the `license_plate` in the snapshots on the Frigate+ website, even if the car 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 vehicle and the `license_plate` in the snapshots on the Frigate+ website, even if the vehicle 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 <code>car</code> or <code>motorcycle</code> objects?</>}>
|
||||
<FaqItem id="can-i-run-lpr-without-detecting-car-or-motorcycle-objects" question={<>Can I run LPR without detecting vehicle objects?</>}>
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
</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 `car` or `motorcycle` 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 vehicle's 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 `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.
|
||||
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.
|
||||
|
||||
</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 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.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ title: Notifications
|
||||
import ConfigTabs from "@site/src/components/ConfigTabs";
|
||||
import TabItem from "@theme/TabItem";
|
||||
import NavPath from "@site/src/components/NavPath";
|
||||
import FaqItem from "@site/src/components/FaqItem";
|
||||
|
||||
# Notifications
|
||||
|
||||
@@ -21,7 +22,7 @@ Push notifications require internet access from the Frigate server to the browse
|
||||
|
||||
In order to use notifications the following requirements must be met:
|
||||
|
||||
- Frigate must be accessed via a secure `https` connection ([see the authorization docs](/configuration/authentication)).
|
||||
- Frigate must be accessed via a secure `https` connection while signed in as a Frigate user ([see the authorization docs](/configuration/authentication)).
|
||||
- A supported browser must be used. Currently Chrome, Firefox, and Safari are known to be supported.
|
||||
- In order for notifications to be usable externally, Frigate must be accessible externally.
|
||||
- For iOS devices, some users have also indicated that the Notifications switch needs to be enabled in iOS Settings --> Apps --> Safari --> Advanced --> Features.
|
||||
@@ -85,7 +86,13 @@ cameras:
|
||||
|
||||
### Registration
|
||||
|
||||
Once notifications are enabled, press the `Register for Notifications` button on all devices that you would like to receive notifications on. This will register the background worker. After this Frigate must be restarted and then notifications will begin to be sent.
|
||||
Once notifications are enabled, press the `Register This Device` button on all devices that you would like to receive notifications on. This will register the background worker. After this Frigate must be restarted and then notifications will begin to be sent.
|
||||
|
||||
:::warning
|
||||
|
||||
Each registration is attached to the Frigate user account you are signed in as, so you must register over a secure connection to the authenticated port (`8971`). Reverse proxies and tunnels should point at port `8971`.
|
||||
|
||||
:::
|
||||
|
||||
## Supported Notifications
|
||||
|
||||
@@ -104,3 +111,62 @@ Different platforms handle notifications differently, some settings changes may
|
||||
### Android
|
||||
|
||||
Most Android phones have battery optimization settings. To get reliable Notification delivery the browser (Chrome, Firefox) should have battery optimizations disabled. If Frigate is running as a PWA then the Frigate app should have battery optimizations disabled as well.
|
||||
|
||||
## Notifications FAQ
|
||||
|
||||
<FaqItem id="how-do-i-debug-notifications-issues" question="How do I debug notifications issues?">
|
||||
|
||||
Push notifications involve Frigate, your browser, and your browser vendor's push service, so it helps to work from the server outward.
|
||||
|
||||
1. Enable debug logs for the push client by adding `frigate.comms.webpush: debug` to your `logger` configuration. Restart Frigate after this change.
|
||||
|
||||
```yaml
|
||||
logger:
|
||||
default: info
|
||||
logs:
|
||||
# highlight-next-line
|
||||
frigate.comms.webpush: debug
|
||||
```
|
||||
|
||||
These logs show exactly where a notification stopped, including:
|
||||
- `Email must be provided for push notifications to be sent` means the global `email` field is empty and nothing will ever be sent.
|
||||
- `Sending test notification` and `Sending push notification for <camera>, review ID <id>` mean Frigate handed the message off to the push service.
|
||||
- `Skipping notification for <camera> - in global cooldown period` (or `camera-specific cooldown period`) means your [cooldown](#configuration) values suppressed it.
|
||||
- `Notifications for <camera> are currently suspended` means notifications were suspended from <NavPath path="Settings > Notifications" /> or MQTT.
|
||||
- `Notification endpoint expired for <user>, received 410` means that device's subscription is no longer valid and it must be re-registered.
|
||||
- `Failed to send notification to <user> :: <status>` means the push service rejected the message. A `401` or `403` usually points at a VAPID or `email` problem, and a `5xx` is a problem on the push service's end.
|
||||
- If you see no messages at all when an alert occurs, the notification was never queued. Confirm an actual **alert** was created (notifications are not sent for detections), and that notifications are enabled both globally and for that camera.
|
||||
|
||||
2. Verify the basics that most reports come down to:
|
||||
- Frigate must be reached over `https` with a certificate your device trusts. Browsers silently refuse to register a service worker otherwise, and a self-signed certificate that is not installed as trusted on the device will fail.
|
||||
- On iOS, notifications only work when Frigate has been installed to the Home Screen via **Share > Add to Home Screen** and opened from that icon. Safari and Chrome tabs cannot receive web push on iOS.
|
||||
- Each device must be registered individually, and Frigate must be restarted after registering before anything can be sent, including test notifications.
|
||||
- The Frigate server needs outbound internet access to the browser vendor's push service. See [Network Requirements](/frigate/network_requirements#push-notifications).
|
||||
|
||||
3. Test from the UI. Use the `Send a test notification` button in <NavPath path="Settings > Notifications" />. If the log shows `Sending test notification` but nothing arrives on the device, the problem is between the push service and your device rather than in Frigate.
|
||||
|
||||
4. Check the browser side on the device that is not receiving notifications:
|
||||
- Confirm the site's notification permission is set to **Allow** in your browser or OS settings, and that a focus/do not disturb mode is not hiding them.
|
||||
- In desktop browsers, open Developer Tools > Application > Service Workers and confirm `notifications-worker.js` is registered and activated. Unregistering it and registering the device again will rebuild a broken subscription.
|
||||
- Check the browser console and your reverse proxy logs for failures loading `/notifications-worker.js` or errors on `/api/notifications/register`.
|
||||
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem id="why-did-notifications-stop-arriving-after-working-for-a-while" question="Why did notifications stop arriving after working for a while?">
|
||||
|
||||
Push subscriptions are issued by the browser vendor and can be revoked, most often after a browser update, after clearing site data, or when a device has been offline for an extended period. When this happens the device still appears registered in Frigate, but the push service rejects the message. The debug logs will show `Notification endpoint expired` with a `404` or `410` status.
|
||||
|
||||
Unregister and re-register the affected device from <NavPath path="Settings > Notifications" />, then restart Frigate.
|
||||
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem id="why-am-i-not-getting-notifications-for-one-specific-camera" question="Why am I not getting notifications for one specific camera?">
|
||||
|
||||
Work through these in order:
|
||||
|
||||
- Notifications are only sent for **alerts**. If the camera is producing detections instead, adjust the camera's `review > alerts > labels` so the objects you care about are classified as alerts.
|
||||
- Confirm notifications are enabled for that camera in <NavPath path="Settings > Camera configuration > Notifications" />.
|
||||
- Check the camera's `cooldown` value, and remember that the global cooldown applies across all cameras. A busy camera can consume the global cooldown and suppress a quieter one.
|
||||
- If [authentication](/configuration/authentication) is enabled with roles, users only receive notifications for the cameras their role grants access to.
|
||||
|
||||
</FaqItem>
|
||||
@@ -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 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 pass a validation check and 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).
|
||||
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 following example exports a time-lapse at 60x speed with 25 FPS:
|
||||
|
||||
|
||||
@@ -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 without `#backchannel=0` for two-way talk functionality (can be used by Frigate's WebRTC viewer or other applications)
|
||||
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)
|
||||
|
||||
Configuration example:
|
||||
|
||||
@@ -215,6 +215,8 @@ 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.
|
||||
|
||||
@@ -121,6 +121,31 @@ cameras:
|
||||
</TabItem>
|
||||
</ConfigTabs>
|
||||
|
||||
## Categorizing manual events
|
||||
|
||||
Events created with the [create manual event API](../integrations/api/create-event-events-camera-name-label-create-post.api.mdx) are categorized with the same label lists, using the label from the request path:
|
||||
|
||||
1. If alerts are enabled and the label is listed in `review -> alerts -> labels`, the review item is an alert.
|
||||
2. Otherwise, if detections are enabled and the label is listed in `review -> detections -> labels`, the review item is a detection.
|
||||
3. If the label is in neither list, the review item is an alert, or no review item is created if alerts are disabled.
|
||||
|
||||
This means manual events are alerts unless you explicitly list their label as a detection label. For example, to have PIR sensors create detections instead of alerts, post to `/api/events/front_door/pir_sensor/create` with the following config:
|
||||
|
||||
```yaml {5-7}
|
||||
cameras:
|
||||
front_door:
|
||||
review:
|
||||
detections:
|
||||
labels:
|
||||
- pir_sensor
|
||||
```
|
||||
|
||||
:::note
|
||||
|
||||
Required zones do not apply to manual events, since they are created through the API rather than by the object tracker. Setting `review -> alerts -> labels` to an empty list also does not stop manual events from becoming alerts, as a label in neither list still falls back to an alert.
|
||||
|
||||
:::
|
||||
|
||||
## Restricting review items to specific zones
|
||||
|
||||
By default a review item will be created if any `review -> alerts -> labels` and `review -> detections -> labels` are detected anywhere in the camera frame. You will likely want to configure review items to only be created when the object enters an area of interest, [see the zone docs for more information](./zones.md#restricting-alerts-and-detections-to-specific-zones)
|
||||
|
||||
@@ -292,7 +292,9 @@ Topic with the currently active profile name. Published value is the profile nam
|
||||
|
||||
### `frigate/notifications/set`
|
||||
|
||||
Topic to turn notifications on and off. Expected values are `ON` and `OFF`.
|
||||
Topic to turn notifications on and off for all cameras. Expected values are `ON` and `OFF`.
|
||||
|
||||
Only available when notifications are enabled in the config. Not persisted across Frigate restarts.
|
||||
|
||||
### `frigate/notifications/state`
|
||||
|
||||
@@ -308,6 +310,8 @@ Publishes the current health status of each role that is enabled (`audio`, `dete
|
||||
- `offline`: Stream is offline and is being restarted
|
||||
- `disabled`: Camera is currently turned off (either at runtime via the `enabled/set` topic, or persistently via the configuration file). See [Camera state](/configuration/live#camera-state) for the distinction.
|
||||
|
||||
These reflect the state of Frigate's process for that role, not the camera's reachability, so an unreachable camera alternates between `offline` and `online` as the watchdog restarts ffmpeg. Wait for the status to hold steady (for example with Home Assistant's `for:`) rather than acting on a single message.
|
||||
|
||||
### `frigate/<camera_name>/<object_name>`
|
||||
|
||||
Publishes the count of objects for the camera for use as a sensor in Home Assistant.
|
||||
@@ -568,16 +572,20 @@ Topic with current state of the Birdseye mode for a camera. Published values are
|
||||
|
||||
### `frigate/<camera_name>/notifications/set`
|
||||
|
||||
Topic to turn notifications on and off. Expected values are `ON` and `OFF`.
|
||||
Topic to turn notifications for a camera on and off. Expected values are `ON` and `OFF`.
|
||||
|
||||
`ON` is ignored unless notifications are enabled in the config for the camera. This is not persisted across Frigate restarts. It is the same control the UI labels **Suspend until restart**.
|
||||
|
||||
### `frigate/<camera_name>/notifications/state`
|
||||
|
||||
Topic with current state of notifications. Published values are `ON` and `OFF`.
|
||||
Topic with current state of notifications. Published values are `ON` and `OFF`. This is the authoritative topic for whether a camera will notify.
|
||||
|
||||
### `frigate/<camera_name>/notifications/suspend`
|
||||
|
||||
Topic to suspend notifications for a certain number of minutes. Expected value is an integer.
|
||||
Topic to suspend notifications for a certain number of minutes. Expected value is an integer. Separate from `notifications/set`: it does not change `notifications/state`, and is ignored while notifications are off.
|
||||
|
||||
### `frigate/<camera_name>/notifications/suspended`
|
||||
|
||||
Topic with timestamp that notifications are suspended until. Published value is a UNIX timestamp, or 0 if notifications are not suspended.
|
||||
Topic with timestamp that notifications are suspended until. Published value is a UNIX timestamp, or 0 if there is no timed suspension.
|
||||
|
||||
`0` does not mean notifications are enabled: `notifications/set` `OFF` clears the timed suspension, so this publishes `0` while `notifications/state` is `OFF`.
|
||||
@@ -66,17 +66,19 @@ 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 for <camera> in the last 120s">
|
||||
<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">
|
||||
|
||||
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).
|
||||
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.
|
||||
|
||||
See [Recordings: the record stream isn't connecting](/troubleshooting/recordings#the-record-stream-isnt-connecting).
|
||||
See [Recordings: no new recording segments were created](/troubleshooting/recordings#no-new-recording-segments-were-created).
|
||||
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem id="invalid-or-missing-video-stream-in-segment" question="Invalid or missing video stream in segment. Discarding.">
|
||||
<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">
|
||||
|
||||
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.
|
||||
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).
|
||||
|
||||
</FaqItem>
|
||||
|
||||
|
||||
@@ -3,7 +3,31 @@ id: cpu
|
||||
title: High CPU Usage
|
||||
---
|
||||
|
||||
High CPU usage can impact Frigate's performance and responsiveness. This guide outlines the most effective configuration changes to help reduce CPU consumption and optimize resource usage.
|
||||
High CPU usage can impact Frigate's performance and responsiveness. This guide explains how to interpret the CPU values Frigate reports and outlines the most effective configuration changes to help reduce CPU consumption and optimize resource usage.
|
||||
|
||||
## Understanding Frigate's Reported CPU Usage
|
||||
|
||||
Frigate's CPU percentages often look much higher than what the host reports. Usually both numbers are correct and are simply measured against different denominators, so confirm you actually have a problem before tuning anything.
|
||||
|
||||
### Per-process values are relative to a single core
|
||||
|
||||
The values Frigate reports for FFmpeg, capture, detect, detector, and other processes follow the same convention as `top`: 100% means one CPU core is fully saturated, not that the whole system is saturated. A multithreaded process such as FFmpeg can legitimately report well over 100%.
|
||||
|
||||
Host and hypervisor tools instead report a percentage of the machine's total capacity across all cores. This includes `docker stats`, the `htop` summary, the Proxmox summary graph, the Unraid dashboard, Synology Resource Monitor, and Home Assistant's system monitor sensors. To reconcile the two:
|
||||
|
||||
```
|
||||
host percentage ≈ (sum of Frigate's process percentages) / (number of cores)
|
||||
```
|
||||
|
||||
On a 4 core system, an FFmpeg process reporting 100% is consuming one quarter of the machine, so the host will show roughly 25 to 30% once the remaining Frigate processes are included. That same 100% on a 16 core system is about 6%. Frigate's own warning thresholds use the per-core convention as well, so an FFmpeg process is flagged at 20% of a single core, not 20% of the system.
|
||||
|
||||
### Instantaneous samples and averages measure different things
|
||||
|
||||
Frigate collects stats every 15 seconds, and the `cpu` value covers only the interval since the previous collection. The `cpu_average` value in the stats API and MQTT payload is the average across the entire life of the process, and it is what the high CPU usage warnings are based on. Host dashboards generally plot data averaged over a longer window, so a single Frigate sample can show a peak that a host graph never displays. A process that has just started, such as FFmpeg after a camera reconnect, reports 0 until it has been sampled twice.
|
||||
|
||||
### The system-wide value depends on what the container can see
|
||||
|
||||
The system CPU value is read from `/proc/stat`. Under Docker that file belongs to the host, so the value covers the entire machine including workloads unrelated to Frigate, and it will not match `docker stats` for the Frigate container. Under an LXC container, lxcfs virtualizes `/proc/stat` and the value reflects only the cores assigned to the container. In a virtual machine, the guest sees only its assigned vCPUs while the hypervisor divides by every physical thread on the node, so guest and host percentages will not agree even when both are accurate.
|
||||
|
||||
## 1. Hardware Acceleration for Video Decoding
|
||||
|
||||
@@ -72,3 +96,19 @@ The model you use significantly impacts detector performance. Frigate provides d
|
||||
- Larger models (640x640): Slower inference, can sometimes have higher accuracy on very large objects that take up a majority of the frame.
|
||||
|
||||
For more detail on picking the right size, see [Choosing a model size](../configuration/object_detectors.md#choosing-a-model-size).
|
||||
|
||||
## 3. Reducing Detector CPU Usage
|
||||
|
||||
**Priority: High**
|
||||
|
||||
The **Detector CPU Usage** metric measures the CPU spent converting frames into the tensor format the model expects and post-processing the model's output. It does not include inference, so this value can be high even when you've configured a GPU, NPU, or Coral for object detection.
|
||||
|
||||
This metric scales with how many detections per second Frigate runs and how expensive each one is to prepare. Tuning [motion detection](../configuration/motion_detection) is usually the first recommendation to reduce the number of detections. Additionally, you can:
|
||||
|
||||
- **Lower `detect -> fps`.** 5 is the recommended value for nearly all cameras. Running at 10 doubles the frames eligible for detection and is one of the largest contributors to this metric.
|
||||
- **Use a 320x320 model.** A 640x640 model has 4 times as many pixels to transpose, convert, and copy on every inference.
|
||||
- **Prefer a model that takes integer input.** Models configured with `input_dtype: float` require each frame to be converted to float32 and normalized on the CPU first. Models taking `int` input, such as the tflite models used by the Edge TPU, skip that step.
|
||||
- **Do not match the detect resolution to the model resolution.** The detect stream should match your camera's aspect ratio, for example `1280x720`, not the model's input size. Frigate crops and scales regions of motion itself, so an oversized detect stream only adds work.
|
||||
- **Tune stationary object behavior.** Objects that never settle into a stationary state are re-detected continuously. Raising `detect -> stationary -> interval` reduces how often detection runs on objects that are already parked. See [stationary objects](../configuration/stationary_objects).
|
||||
|
||||
Adding [more detector instances](#multiple-detector-instances) spreads this work across more CPU cores, but does not reduce the total CPU used.
|
||||
@@ -39,6 +39,20 @@ 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.
|
||||
@@ -65,9 +79,17 @@ This is because Frigate does not run in host mode so localhost points to the Fri
|
||||
|
||||
### How do I know if my camera is offline
|
||||
|
||||
A camera being offline can be detected via MQTT or /api/stats, the camera_fps for any offline camera will be 0.
|
||||
Frigate publishes a per-role health status to [`frigate/<camera_name>/status/<role>`](/integrations/mqtt#frigatecamera_namestatusrole), where `<role>` is each enabled role on the camera (`detect`, `record`, and `audio`). The published value is one of:
|
||||
|
||||
Also, Home Assistant will mark any offline camera as being unavailable when the camera is offline.
|
||||
- `online`: Frigate's process for that role is running normally
|
||||
- `offline`: the process is down and Frigate is restarting it
|
||||
- `disabled`: the camera is turned off, either at runtime or in the configuration file
|
||||
|
||||
These reflect the state of Frigate's process for that role, not the camera's reachability, so an unreachable camera alternates between `offline` and `online` as the watchdog restarts ffmpeg. Wait for the status to hold steady (for example with Home Assistant's `for:`) rather than acting on a single message.
|
||||
|
||||
Because the status is per role, a camera whose substream is fine but whose recording stream has dropped will report `online` for `detect` and `offline` for `record`. The status is republished whenever it changes.
|
||||
|
||||
You can also detect an offline camera through `/api/stats`, where `camera_fps` will be 0.
|
||||
|
||||
### How can I view the Frigate log files without using the Web UI?
|
||||
|
||||
@@ -125,6 +147,12 @@ cameras:
|
||||
height: 720
|
||||
```
|
||||
|
||||
### What is the `version` key in my config file?
|
||||
|
||||
`version` records the config format that your config was last migrated to. On startup Frigate compares it against the format the running version expects, and if it is older it copies your config to `/config/backup_config.yaml`, rewrites it to the new format, and updates `version` as the final step. A config with no `version` key is assumed to predate 0.14 and is migrated from there.
|
||||
|
||||
Frigate manages this key for you, so do not set or edit it. Raising it makes Frigate skip migrations your config still needs, and lowering it re-runs migrations against config that has already been converted. Either can leave you with a config that no longer validates.
|
||||
|
||||
### Why does Frigate keep creating new tracked objects for my parked car?
|
||||
|
||||
Stationary tracking is designed to _prevent_ this: a parked car should remain a single tracked object rather than generating new ones. If you're repeatedly getting new tracked objects for the same car, it's likely that Frigate is losing the object and re-detecting it as a new one.
|
||||
|
||||
@@ -78,7 +78,9 @@ go2rtc:
|
||||
|
||||
:::warning
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
:::
|
||||
|
||||
@@ -153,7 +155,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, 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, carrying no `#` modifiers of any kind, as documented in [preventing go2rtc from blocking two-way audio](/configuration/restream#two-way-talk-restream).
|
||||
|
||||
## High CPU usage
|
||||
|
||||
|
||||
@@ -209,6 +209,50 @@ 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.
|
||||
|
||||
Vendored
+12
-9
@@ -1476,7 +1476,7 @@ paths:
|
||||
- Classification
|
||||
summary: Get custom classification attributes
|
||||
description: |-
|
||||
**Access:** Admin role required.
|
||||
**Access:** Authenticated user with access to all cameras.
|
||||
|
||||
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:
|
||||
- frigateAdminAuth: []
|
||||
x-required-role: admin
|
||||
- frigateUserAuth: []
|
||||
x-required-role: all_cameras
|
||||
/classification/{name}/train:
|
||||
get:
|
||||
tags:
|
||||
@@ -2308,15 +2308,15 @@ paths:
|
||||
$ref: '#/components/schemas/HTTPValidationError'
|
||||
security:
|
||||
- frigateUserAuth: []
|
||||
x-required-role: any
|
||||
description: '**Access:** Any authenticated user.'
|
||||
x-required-role: camera
|
||||
description: '**Access:** Authenticated user with access to the referenced camera.'
|
||||
/review/summarize/start/{start_ts}/end/{end_ts}:
|
||||
post:
|
||||
tags:
|
||||
- Review
|
||||
summary: Generate Review Summary
|
||||
description: |-
|
||||
**Access:** Admin role required.
|
||||
**Access:** Authenticated user with access to all cameras.
|
||||
|
||||
Use GenAI to summarize review items over a period of time.
|
||||
operationId:
|
||||
@@ -2347,8 +2347,8 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/HTTPValidationError'
|
||||
security:
|
||||
- frigateAdminAuth: []
|
||||
x-required-role: admin
|
||||
- frigateUserAuth: []
|
||||
x-required-role: all_cameras
|
||||
/:
|
||||
get:
|
||||
tags:
|
||||
@@ -5093,6 +5093,7 @@ paths:
|
||||
NOTES:
|
||||
- Creating a manual event does not trigger an update to /events MQTT topic.
|
||||
- If a duration is set to null, the event will need to be ended manually by calling /events/{event_id}/end.
|
||||
- The review item is an alert unless the label is listed in the camera's review -> detections -> labels config.
|
||||
operationId: create_event_events__camera_name___label__create_post
|
||||
parameters:
|
||||
- name: camera_name
|
||||
@@ -7108,7 +7109,9 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/DebugReplayStartResponse'
|
||||
'400':
|
||||
description: Invalid camera, time range, or no recordings
|
||||
description: Invalid camera or time range
|
||||
'404':
|
||||
description: No recordings in the requested time range
|
||||
'409':
|
||||
description: A replay session is already active
|
||||
'422':
|
||||
|
||||
+26
-3
@@ -85,6 +85,7 @@ def require_admin_by_default():
|
||||
"/sub_labels",
|
||||
"/plus/models",
|
||||
"/recognized_license_plates",
|
||||
"/classification/attributes",
|
||||
"/timeline",
|
||||
"/timeline/hourly",
|
||||
"/recordings/storage",
|
||||
@@ -971,6 +972,7 @@ 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,
|
||||
@@ -984,10 +986,11 @@ async def update_password(
|
||||
current_username = current_user.get("username")
|
||||
current_role = current_user.get("role")
|
||||
|
||||
# viewers can only change their own password
|
||||
if current_role == "viewer" and current_username != username:
|
||||
# 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:
|
||||
raise HTTPException(
|
||||
status_code=403, detail="Viewers can only update their own password"
|
||||
status_code=403, detail="Users can only update their own password"
|
||||
)
|
||||
|
||||
HASH_ITERATIONS = request.app.frigate_config.auth.hash_iterations
|
||||
@@ -1251,3 +1254,23 @@ 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",
|
||||
)
|
||||
+134
-64
@@ -11,11 +11,10 @@ from typing import Any
|
||||
import cv2
|
||||
from fastapi import APIRouter, Depends, Request, UploadFile
|
||||
from fastapi.responses import JSONResponse
|
||||
from pathvalidate import sanitize_filename
|
||||
from peewee import DoesNotExist
|
||||
from playhouse.shortcuts import model_to_dict
|
||||
|
||||
from frigate.api.auth import require_role
|
||||
from frigate.api.auth import require_full_camera_access, require_role
|
||||
from frigate.api.defs.request.classification_body import (
|
||||
AudioTranscriptionBody,
|
||||
DeleteFaceImagesBody,
|
||||
@@ -43,12 +42,21 @@ from frigate.util.classification import (
|
||||
write_training_metadata,
|
||||
)
|
||||
from frigate.util.file import get_event_snapshot
|
||||
from frigate.util.path import safe_join, sanitize_path_component
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=[Tags.classification])
|
||||
|
||||
|
||||
def invalid_name_response(value: str) -> JSONResponse:
|
||||
"""Response for a name that cannot be used as a path component."""
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": f"Invalid name: {value}"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/faces",
|
||||
response_model=FacesResponse,
|
||||
@@ -98,9 +106,7 @@ def reclassify_face(request: Request, body: dict = None):
|
||||
)
|
||||
|
||||
json: dict[str, Any] = body or {}
|
||||
training_file = os.path.join(
|
||||
FACE_DIR, f"train/{sanitize_filename(json.get('training_file', ''))}"
|
||||
)
|
||||
training_file = safe_join(FACE_DIR, "train", json.get("training_file", ""))
|
||||
|
||||
if not training_file or not os.path.isfile(training_file):
|
||||
return JSONResponse(
|
||||
@@ -150,8 +156,10 @@ def train_face(request: Request, name: str, body: dict = None):
|
||||
)
|
||||
|
||||
json: dict[str, Any] = body or {}
|
||||
training_file_name = sanitize_filename(json.get("training_file", ""))
|
||||
training_file = os.path.join(FACE_DIR, f"train/{training_file_name}")
|
||||
training_file_name = json.get("training_file", "")
|
||||
training_file = (
|
||||
safe_join(FACE_DIR, "train", training_file_name) if training_file_name else None
|
||||
)
|
||||
event_id = json.get("event_id")
|
||||
|
||||
if not training_file_name and not event_id:
|
||||
@@ -165,7 +173,9 @@ def train_face(request: Request, name: str, body: dict = None):
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
if training_file_name and not os.path.isfile(training_file):
|
||||
if training_file_name and (
|
||||
training_file is None or not os.path.isfile(training_file)
|
||||
):
|
||||
return JSONResponse(
|
||||
content=(
|
||||
{
|
||||
@@ -176,9 +186,13 @@ def train_face(request: Request, name: str, body: dict = None):
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
sanitized_name = sanitize_filename(name)
|
||||
sanitized_name = sanitize_path_component(name)
|
||||
new_file_folder = safe_join(FACE_DIR, name)
|
||||
|
||||
if sanitized_name is None or new_file_folder is None:
|
||||
return invalid_name_response(name)
|
||||
|
||||
new_name = f"{sanitized_name}-{datetime.datetime.now().timestamp()}.webp"
|
||||
new_file_folder = os.path.join(FACE_DIR, f"{sanitized_name}")
|
||||
|
||||
os.makedirs(new_file_folder, exist_ok=True)
|
||||
|
||||
@@ -261,9 +275,12 @@ async def create_face(request: Request, name: str):
|
||||
content={"message": "Face recognition is not enabled.", "success": False},
|
||||
)
|
||||
|
||||
os.makedirs(
|
||||
os.path.join(FACE_DIR, sanitize_filename(name.replace(" ", "_"))), exist_ok=True
|
||||
)
|
||||
face_folder = safe_join(FACE_DIR, name.replace(" ", "_"))
|
||||
|
||||
if face_folder is None:
|
||||
return invalid_name_response(name)
|
||||
|
||||
os.makedirs(face_folder, exist_ok=True)
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={"success": False, "message": "Successfully created face folder."},
|
||||
@@ -287,6 +304,9 @@ def register_face(request: Request, name: str, file: UploadFile):
|
||||
content={"message": "Face recognition is not enabled.", "success": False},
|
||||
)
|
||||
|
||||
if sanitize_path_component(name) is None:
|
||||
return invalid_name_response(name)
|
||||
|
||||
context: EmbeddingsContext = request.app.embeddings
|
||||
result = None if context is None else context.register_face(name, file.file.read())
|
||||
|
||||
@@ -356,8 +376,8 @@ def reclassify_face_image(request: Request, name: str, body: dict = None):
|
||||
)
|
||||
|
||||
json: dict[str, Any] = body or {}
|
||||
image_id = sanitize_filename(json.get("id", ""))
|
||||
new_name = sanitize_filename(json.get("new_name", ""))
|
||||
image_id = sanitize_path_component(json.get("id", ""))
|
||||
new_name = sanitize_path_component(json.get("new_name", ""))
|
||||
|
||||
if not image_id or not new_name:
|
||||
return JSONResponse(
|
||||
@@ -381,7 +401,12 @@ def reclassify_face_image(request: Request, name: str, body: dict = None):
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
source_folder = os.path.join(FACE_DIR, sanitize_filename(name))
|
||||
source_folder = safe_join(FACE_DIR, name)
|
||||
target_folder = safe_join(FACE_DIR, new_name)
|
||||
|
||||
if source_folder is None or target_folder is None:
|
||||
return invalid_name_response(name)
|
||||
|
||||
source_file = os.path.join(source_folder, image_id)
|
||||
|
||||
if not os.path.isfile(source_file):
|
||||
@@ -396,7 +421,6 @@ def reclassify_face_image(request: Request, name: str, body: dict = None):
|
||||
)
|
||||
|
||||
target_filename = f"{new_name}-{datetime.datetime.now().timestamp()}.webp"
|
||||
target_folder = os.path.join(FACE_DIR, new_name)
|
||||
|
||||
os.makedirs(target_folder, exist_ok=True)
|
||||
shutil.move(source_file, os.path.join(target_folder, target_filename))
|
||||
@@ -430,8 +454,19 @@ def deregister_faces(request: Request, name: str, body: DeleteFaceImagesBody):
|
||||
content={"message": "Face recognition is not enabled.", "success": False},
|
||||
)
|
||||
|
||||
sanitized_name = sanitize_path_component(name)
|
||||
|
||||
if sanitized_name is None:
|
||||
return invalid_name_response(name)
|
||||
|
||||
sanitized_ids = [
|
||||
component
|
||||
for component in map(sanitize_path_component, body.ids)
|
||||
if component is not None
|
||||
]
|
||||
|
||||
context: EmbeddingsContext = request.app.embeddings
|
||||
context.delete_face_ids(name, map(lambda file: sanitize_filename(file), body.ids))
|
||||
context.delete_face_ids(sanitized_name, sanitized_ids)
|
||||
return JSONResponse(
|
||||
content=({"success": True, "message": "Successfully deleted faces."}),
|
||||
status_code=200,
|
||||
@@ -642,7 +677,11 @@ def transcribe_audio(request: Request, body: AudioTranscriptionBody):
|
||||
def get_classification_dataset(name: str):
|
||||
dataset_dict: dict[str, list[str]] = {}
|
||||
|
||||
dataset_dir = os.path.join(CLIPS_DIR, sanitize_filename(name), "dataset")
|
||||
sanitized_name = sanitize_path_component(name)
|
||||
dataset_dir = safe_join(CLIPS_DIR, name, "dataset")
|
||||
|
||||
if sanitized_name is None or dataset_dir is None:
|
||||
return invalid_name_response(name)
|
||||
|
||||
if not os.path.exists(dataset_dir):
|
||||
return JSONResponse(
|
||||
@@ -664,8 +703,8 @@ def get_classification_dataset(name: str):
|
||||
dataset_dict[category_name].append(file)
|
||||
|
||||
# Get training metadata
|
||||
metadata = read_training_metadata(sanitize_filename(name))
|
||||
current_image_count = get_dataset_image_count(sanitize_filename(name))
|
||||
metadata = read_training_metadata(sanitized_name)
|
||||
current_image_count = get_dataset_image_count(sanitized_name)
|
||||
|
||||
if metadata is None:
|
||||
training_metadata = {
|
||||
@@ -702,6 +741,7 @@ 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'.
|
||||
@@ -729,8 +769,8 @@ def get_custom_attributes(
|
||||
if object_type is not None and object_type not in model_objects:
|
||||
continue
|
||||
|
||||
dataset_dir = os.path.join(CLIPS_DIR, sanitize_filename(model_key), "dataset")
|
||||
if not os.path.exists(dataset_dir):
|
||||
dataset_dir = safe_join(CLIPS_DIR, model_key, "dataset")
|
||||
if dataset_dir is None or not os.path.exists(dataset_dir):
|
||||
continue
|
||||
|
||||
attributes = []
|
||||
@@ -760,7 +800,10 @@ def get_custom_attributes(
|
||||
The name must exist in the classification models. Returns a success message or an error if the name is invalid.""",
|
||||
)
|
||||
def get_classification_images(name: str):
|
||||
train_dir = os.path.join(CLIPS_DIR, sanitize_filename(name), "train")
|
||||
train_dir = safe_join(CLIPS_DIR, name, "train")
|
||||
|
||||
if train_dir is None:
|
||||
return invalid_name_response(name)
|
||||
|
||||
if not os.path.exists(train_dir):
|
||||
return JSONResponse(status_code=200, content=[])
|
||||
@@ -831,15 +874,17 @@ def delete_classification_dataset_images(
|
||||
|
||||
json: dict[str, Any] = body or {}
|
||||
list_of_ids = json.get("ids", "")
|
||||
folder = os.path.join(
|
||||
CLIPS_DIR, sanitize_filename(name), "dataset", sanitize_filename(category)
|
||||
)
|
||||
sanitized_name = sanitize_path_component(name)
|
||||
folder = safe_join(CLIPS_DIR, name, "dataset", category)
|
||||
|
||||
if sanitized_name is None or folder is None:
|
||||
return invalid_name_response(name)
|
||||
|
||||
deleted_count = 0
|
||||
for id in list_of_ids:
|
||||
file_path = os.path.join(folder, sanitize_filename(id))
|
||||
file_path = safe_join(folder, id)
|
||||
|
||||
if os.path.isfile(file_path):
|
||||
if file_path and os.path.isfile(file_path):
|
||||
os.unlink(file_path)
|
||||
deleted_count += 1
|
||||
|
||||
@@ -850,7 +895,6 @@ def delete_classification_dataset_images(
|
||||
# This ensures the dataset is marked as changed after deletion
|
||||
# (even if the total count happens to be the same after adding and deleting)
|
||||
if deleted_count > 0:
|
||||
sanitized_name = sanitize_filename(name)
|
||||
metadata = read_training_metadata(sanitized_name)
|
||||
if metadata:
|
||||
last_count = metadata.get("last_training_image_count", 0)
|
||||
@@ -888,8 +932,8 @@ def reclassify_classification_image(
|
||||
)
|
||||
|
||||
json: dict[str, Any] = body or {}
|
||||
image_id = sanitize_filename(json.get("id", ""))
|
||||
new_category = sanitize_filename(json.get("new_category", ""))
|
||||
image_id = sanitize_path_component(json.get("id", ""))
|
||||
new_category = sanitize_path_component(json.get("new_category", ""))
|
||||
|
||||
if not image_id or not new_category:
|
||||
return JSONResponse(
|
||||
@@ -913,10 +957,13 @@ def reclassify_classification_image(
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
sanitized_name = sanitize_filename(name)
|
||||
source_folder = os.path.join(
|
||||
CLIPS_DIR, sanitized_name, "dataset", sanitize_filename(category)
|
||||
)
|
||||
sanitized_name = sanitize_path_component(name)
|
||||
source_folder = safe_join(CLIPS_DIR, name, "dataset", category)
|
||||
target_folder = safe_join(CLIPS_DIR, name, "dataset", new_category)
|
||||
|
||||
if sanitized_name is None or source_folder is None or target_folder is None:
|
||||
return invalid_name_response(name)
|
||||
|
||||
source_file = os.path.join(source_folder, image_id)
|
||||
|
||||
if not os.path.isfile(source_file):
|
||||
@@ -933,7 +980,6 @@ def reclassify_classification_image(
|
||||
random_id = "".join(random.choices(string.ascii_lowercase + string.digits, k=6))
|
||||
timestamp = datetime.datetime.now().timestamp()
|
||||
new_name = f"{new_category}-{timestamp}-{random_id}.png"
|
||||
target_folder = os.path.join(CLIPS_DIR, sanitized_name, "dataset", new_category)
|
||||
|
||||
os.makedirs(target_folder, exist_ok=True)
|
||||
|
||||
@@ -983,7 +1029,7 @@ def rename_classification_category(
|
||||
)
|
||||
|
||||
json: dict[str, Any] = body or {}
|
||||
new_category = sanitize_filename(json.get("new_category", ""))
|
||||
new_category = sanitize_path_component(json.get("new_category", ""))
|
||||
|
||||
if not new_category:
|
||||
return JSONResponse(
|
||||
@@ -996,12 +1042,12 @@ def rename_classification_category(
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
old_folder = os.path.join(
|
||||
CLIPS_DIR, sanitize_filename(name), "dataset", sanitize_filename(old_category)
|
||||
)
|
||||
new_folder = os.path.join(
|
||||
CLIPS_DIR, sanitize_filename(name), "dataset", new_category
|
||||
)
|
||||
sanitized_name = sanitize_path_component(name)
|
||||
old_folder = safe_join(CLIPS_DIR, name, "dataset", old_category)
|
||||
new_folder = safe_join(CLIPS_DIR, name, "dataset", new_category)
|
||||
|
||||
if sanitized_name is None or old_folder is None or new_folder is None:
|
||||
return invalid_name_response(name)
|
||||
|
||||
if not os.path.exists(old_folder):
|
||||
return JSONResponse(
|
||||
@@ -1030,7 +1076,6 @@ def rename_classification_category(
|
||||
|
||||
# Mark dataset as ready to train by resetting training metadata
|
||||
# This ensures the dataset is marked as changed after renaming
|
||||
sanitized_name = sanitize_filename(name)
|
||||
write_training_metadata(sanitized_name, 0)
|
||||
|
||||
return JSONResponse(
|
||||
@@ -1078,13 +1123,20 @@ def categorize_classification_image(request: Request, name: str, body: dict = No
|
||||
)
|
||||
|
||||
json: dict[str, Any] = body or {}
|
||||
category = sanitize_filename(json.get("category", ""))
|
||||
training_file_name = sanitize_filename(json.get("training_file", ""))
|
||||
training_file = os.path.join(
|
||||
CLIPS_DIR, sanitize_filename(name), "train", training_file_name
|
||||
category = sanitize_path_component(json.get("category", ""))
|
||||
training_file_name = json.get("training_file", "")
|
||||
training_file = (
|
||||
safe_join(CLIPS_DIR, name, "train", training_file_name)
|
||||
if training_file_name
|
||||
else None
|
||||
)
|
||||
|
||||
if training_file_name and not os.path.isfile(training_file):
|
||||
if category is None:
|
||||
return invalid_name_response(json.get("category", ""))
|
||||
|
||||
if training_file_name and (
|
||||
training_file is None or not os.path.isfile(training_file)
|
||||
):
|
||||
return JSONResponse(
|
||||
content=(
|
||||
{
|
||||
@@ -1098,9 +1150,10 @@ def categorize_classification_image(request: Request, name: str, body: dict = No
|
||||
random_id = "".join(random.choices(string.ascii_lowercase + string.digits, k=6))
|
||||
timestamp = datetime.datetime.now().timestamp()
|
||||
new_name = f"{category}-{timestamp}-{random_id}.png"
|
||||
new_file_folder = os.path.join(
|
||||
CLIPS_DIR, sanitize_filename(name), "dataset", category
|
||||
)
|
||||
new_file_folder = safe_join(CLIPS_DIR, name, "dataset", category)
|
||||
|
||||
if new_file_folder is None:
|
||||
return invalid_name_response(name)
|
||||
|
||||
os.makedirs(new_file_folder, exist_ok=True)
|
||||
|
||||
@@ -1138,9 +1191,10 @@ def create_classification_category(request: Request, name: str, category: str):
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
category_folder = os.path.join(
|
||||
CLIPS_DIR, sanitize_filename(name), "dataset", sanitize_filename(category)
|
||||
)
|
||||
category_folder = safe_join(CLIPS_DIR, name, "dataset", category)
|
||||
|
||||
if category_folder is None:
|
||||
return invalid_name_response(category)
|
||||
|
||||
os.makedirs(category_folder, exist_ok=True)
|
||||
|
||||
@@ -1179,12 +1233,15 @@ def delete_classification_train_images(request: Request, name: str, body: dict =
|
||||
|
||||
json: dict[str, Any] = body or {}
|
||||
list_of_ids = json.get("ids", "")
|
||||
folder = os.path.join(CLIPS_DIR, sanitize_filename(name), "train")
|
||||
folder = safe_join(CLIPS_DIR, name, "train")
|
||||
|
||||
if folder is None:
|
||||
return invalid_name_response(name)
|
||||
|
||||
for id in list_of_ids:
|
||||
file_path = os.path.join(folder, sanitize_filename(id))
|
||||
file_path = safe_join(folder, id)
|
||||
|
||||
if os.path.isfile(file_path):
|
||||
if file_path and os.path.isfile(file_path):
|
||||
os.unlink(file_path)
|
||||
|
||||
return JSONResponse(
|
||||
@@ -1201,7 +1258,11 @@ def delete_classification_train_images(request: Request, name: str, body: dict =
|
||||
)
|
||||
async def generate_state_examples(request: Request, body: GenerateStateExamplesBody):
|
||||
"""Generate examples for state classification."""
|
||||
model_name = sanitize_filename(body.model_name)
|
||||
model_name = sanitize_path_component(body.model_name)
|
||||
|
||||
if model_name is None:
|
||||
return invalid_name_response(body.model_name)
|
||||
|
||||
cameras_normalized = {
|
||||
camera_name: tuple(crop)
|
||||
for camera_name, crop in body.cameras.items()
|
||||
@@ -1224,7 +1285,11 @@ async def generate_state_examples(request: Request, body: GenerateStateExamplesB
|
||||
)
|
||||
async def generate_object_examples(request: Request, body: GenerateObjectExamplesBody):
|
||||
"""Generate examples for object classification."""
|
||||
model_name = sanitize_filename(body.model_name)
|
||||
model_name = sanitize_path_component(body.model_name)
|
||||
|
||||
if model_name is None:
|
||||
return invalid_name_response(body.model_name)
|
||||
|
||||
collect_object_classification_examples(model_name, body.label)
|
||||
|
||||
return JSONResponse(
|
||||
@@ -1243,10 +1308,16 @@ async def generate_object_examples(request: Request, body: GenerateObjectExample
|
||||
Returns a success message.""",
|
||||
)
|
||||
def delete_classification_model(request: Request, name: str):
|
||||
sanitized_name = sanitize_filename(name)
|
||||
# This endpoint intentionally accepts models that are not in the config, so
|
||||
# there is no allow list to fall back on. Both paths below are recursive
|
||||
# deletes, so an unusable name has to be rejected outright.
|
||||
data_dir = safe_join(CLIPS_DIR, name)
|
||||
model_dir = safe_join(MODEL_CACHE_DIR, name)
|
||||
|
||||
if data_dir is None or model_dir is None:
|
||||
return invalid_name_response(name)
|
||||
|
||||
# Delete the classification model's data directory in clips
|
||||
data_dir = os.path.join(CLIPS_DIR, sanitized_name)
|
||||
if os.path.exists(data_dir):
|
||||
try:
|
||||
shutil.rmtree(data_dir)
|
||||
@@ -1255,7 +1326,6 @@ def delete_classification_model(request: Request, name: str):
|
||||
logger.debug(f"Failed to delete data directory for {name}: {e}")
|
||||
|
||||
# Delete the classification model's files in model_cache
|
||||
model_dir = os.path.join(MODEL_CACHE_DIR, sanitized_name)
|
||||
if os.path.exists(model_dir):
|
||||
try:
|
||||
shutil.rmtree(model_dir)
|
||||
|
||||
@@ -13,6 +13,7 @@ from frigate.api.auth import require_role
|
||||
from frigate.api.defs.tags import Tags
|
||||
from frigate.jobs.debug_replay import (
|
||||
ExportDebugReplaySource,
|
||||
NoRecordingsError,
|
||||
RecordingDebugReplaySource,
|
||||
start_debug_replay_job,
|
||||
)
|
||||
@@ -74,7 +75,8 @@ class DebugReplayStopResponse(BaseModel):
|
||||
response_model=DebugReplayStartResponse,
|
||||
status_code=202,
|
||||
responses={
|
||||
400: {"description": "Invalid camera, time range, or no recordings"},
|
||||
400: {"description": "Invalid camera or time range"},
|
||||
404: {"description": "No recordings in the requested time range"},
|
||||
409: {"description": "A replay session is already active"},
|
||||
},
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
@@ -113,6 +115,14 @@ async def start_debug_replay(request: Request, body: DebugReplayStartBody):
|
||||
},
|
||||
status_code=409,
|
||||
)
|
||||
except NoRecordingsError:
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": "No recordings found in the selected time range",
|
||||
},
|
||||
status_code=404,
|
||||
)
|
||||
except ValueError:
|
||||
logger.exception("Rejected debug replay start request")
|
||||
return JSONResponse(
|
||||
|
||||
+42
-37
@@ -16,7 +16,6 @@ import numpy as np
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.params import Depends
|
||||
from fastapi.responses import JSONResponse
|
||||
from pathvalidate import sanitize_filename
|
||||
from peewee import JOIN, DoesNotExist, fn, operator
|
||||
from playhouse.shortcuts import model_to_dict
|
||||
|
||||
@@ -56,11 +55,12 @@ from frigate.api.defs.response.generic_response import GenericResponse
|
||||
from frigate.api.defs.tags import Tags
|
||||
from frigate.comms.event_metadata_updater import EventMetadataTypeEnum
|
||||
from frigate.config.classification import ObjectClassificationType
|
||||
from frigate.const import CLIPS_DIR, TRIGGER_DIR
|
||||
from frigate.const import CLIPS_DIR
|
||||
from frigate.embeddings import EmbeddingsContext
|
||||
from frigate.models import Event, ReviewSegment, Timeline, Trigger
|
||||
from frigate.track.object_processing import TrackedObject
|
||||
from frigate.util.file import get_event_thumbnail_bytes, load_event_snapshot_image
|
||||
from frigate.util.path import get_trigger_thumbnail_path, safe_join
|
||||
from frigate.util.time import get_dst_transitions, get_tz_modifiers
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -1452,10 +1452,10 @@ async def set_attributes(
|
||||
continue
|
||||
|
||||
# Get available labels from dataset directory
|
||||
dataset_dir = os.path.join(CLIPS_DIR, sanitize_filename(model_key), "dataset")
|
||||
dataset_dir = safe_join(CLIPS_DIR, model_key, "dataset")
|
||||
available_labels = set()
|
||||
|
||||
if os.path.exists(dataset_dir):
|
||||
if dataset_dir and os.path.exists(dataset_dir):
|
||||
for category_name in os.listdir(dataset_dir):
|
||||
category_dir = os.path.join(dataset_dir, category_name)
|
||||
if os.path.isdir(category_dir):
|
||||
@@ -1748,6 +1748,7 @@ async def delete_events(request: Request, body: EventsDeleteBody):
|
||||
NOTES:
|
||||
- Creating a manual event does not trigger an update to /events MQTT topic.
|
||||
- If a duration is set to null, the event will need to be ended manually by calling /events/{event_id}/end.
|
||||
- The review item is an alert unless the label is listed in the camera's review -> detections -> labels config.
|
||||
""",
|
||||
)
|
||||
def create_event(
|
||||
@@ -1958,18 +1959,13 @@ def create_trigger_embedding(
|
||||
if body.type == "thumbnail":
|
||||
# Save image to the triggers directory
|
||||
try:
|
||||
os.makedirs(
|
||||
os.path.join(TRIGGER_DIR, sanitize_filename(camera_name)),
|
||||
exist_ok=True,
|
||||
)
|
||||
with open(
|
||||
os.path.join(
|
||||
TRIGGER_DIR,
|
||||
sanitize_filename(camera_name),
|
||||
f"{sanitize_filename(body.data)}.webp",
|
||||
),
|
||||
"wb",
|
||||
) as f:
|
||||
webp_path = get_trigger_thumbnail_path(camera_name, body.data)
|
||||
|
||||
if webp_path is None:
|
||||
raise ValueError(f"Invalid trigger thumbnail path for {body.data}")
|
||||
|
||||
os.makedirs(os.path.dirname(webp_path), exist_ok=True)
|
||||
with open(webp_path, "wb") as f:
|
||||
f.write(thumbnail)
|
||||
logger.debug(
|
||||
f"Writing thumbnail for trigger with data {body.data} in {camera_name}."
|
||||
@@ -2041,10 +2037,16 @@ def update_trigger_embedding(
|
||||
if body.type == "description":
|
||||
embedding = context.generate_description_embedding(body.data)
|
||||
elif body.type == "thumbnail":
|
||||
webp_file = sanitize_filename(body.data) + ".webp"
|
||||
webp_path = os.path.join(
|
||||
TRIGGER_DIR, sanitize_filename(camera_name), webp_file
|
||||
)
|
||||
webp_path = get_trigger_thumbnail_path(camera_name, body.data)
|
||||
|
||||
if webp_path is None:
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": f"Invalid data for {body.type} trigger",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
try:
|
||||
event: Event = Event.get(Event.id == body.data)
|
||||
@@ -2101,13 +2103,14 @@ def update_trigger_embedding(
|
||||
# Update existing trigger
|
||||
if trigger.data != body.data: # Delete old thumbnail only if data changes
|
||||
try:
|
||||
os.remove(
|
||||
os.path.join(
|
||||
TRIGGER_DIR,
|
||||
sanitize_filename(camera_name),
|
||||
f"{trigger.data}.webp",
|
||||
old_path = get_trigger_thumbnail_path(camera_name, trigger.data)
|
||||
|
||||
if old_path is None:
|
||||
raise ValueError(
|
||||
f"Invalid trigger thumbnail path for {trigger.data}"
|
||||
)
|
||||
)
|
||||
|
||||
os.remove(old_path)
|
||||
logger.debug(
|
||||
f"Deleted thumbnail for trigger with data {trigger.data} in {camera_name}."
|
||||
)
|
||||
@@ -2141,12 +2144,13 @@ def update_trigger_embedding(
|
||||
if body.type == "thumbnail":
|
||||
# Save image to the triggers directory
|
||||
try:
|
||||
camera_path = os.path.join(TRIGGER_DIR, sanitize_filename(camera_name))
|
||||
os.makedirs(camera_path, exist_ok=True)
|
||||
with open(
|
||||
os.path.join(camera_path, f"{sanitize_filename(body.data)}.webp"),
|
||||
"wb",
|
||||
) as f:
|
||||
thumbnail_path = get_trigger_thumbnail_path(camera_name, body.data)
|
||||
|
||||
if thumbnail_path is None:
|
||||
raise ValueError(f"Invalid trigger thumbnail path for {body.data}")
|
||||
|
||||
os.makedirs(os.path.dirname(thumbnail_path), exist_ok=True)
|
||||
with open(thumbnail_path, "wb") as f:
|
||||
f.write(thumbnail)
|
||||
logger.debug(
|
||||
f"Writing thumbnail for trigger with data {body.data} in {camera_name}."
|
||||
@@ -2217,11 +2221,12 @@ def delete_trigger_embedding(
|
||||
)
|
||||
|
||||
try:
|
||||
os.remove(
|
||||
os.path.join(
|
||||
TRIGGER_DIR, sanitize_filename(camera_name), f"{trigger.data}.webp"
|
||||
)
|
||||
)
|
||||
thumbnail_path = get_trigger_thumbnail_path(camera_name, trigger.data)
|
||||
|
||||
if thumbnail_path is None:
|
||||
raise ValueError(f"Invalid trigger thumbnail path for {trigger.data}")
|
||||
|
||||
os.remove(thumbnail_path)
|
||||
logger.debug(
|
||||
f"Deleted thumbnail for trigger with data {trigger.data} in {camera_name}."
|
||||
)
|
||||
|
||||
+28
-13
@@ -9,11 +9,12 @@ 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
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from pathvalidate import sanitize_filename, sanitize_filepath
|
||||
from pathvalidate import sanitize_filename
|
||||
from peewee import DoesNotExist
|
||||
from playhouse.shortcuts import model_to_dict
|
||||
|
||||
@@ -68,10 +69,12 @@ 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,
|
||||
)
|
||||
from frigate.util.path import sanitize_contained_path
|
||||
from frigate.util.time import is_current_hour
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -129,18 +132,12 @@ def _validate_export_case(export_case_id: str | None) -> JSONResponse | None:
|
||||
def _sanitize_existing_image(
|
||||
image_path: str | None,
|
||||
) -> tuple[str | None, JSONResponse | None]:
|
||||
# sanitize_filepath normalizes "\" to "/" but leaves ".." intact, so a path
|
||||
# like "clips\..\..\etc/passwd" passes the CLIPS_DIR prefix check yet still
|
||||
# escapes the directory once resolved. A valid snapshot path never uses "..".
|
||||
if image_path and ".." in image_path:
|
||||
return None, JSONResponse(
|
||||
content={"success": False, "message": "Invalid image path"},
|
||||
status_code=400,
|
||||
)
|
||||
if not image_path:
|
||||
return None, None
|
||||
|
||||
existing_image = sanitize_filepath(image_path) if image_path else None
|
||||
existing_image = sanitize_contained_path(image_path, CLIPS_DIR)
|
||||
|
||||
if existing_image and not existing_image.startswith(CLIPS_DIR):
|
||||
if existing_image is None:
|
||||
return None, JSONResponse(
|
||||
content={"success": False, "message": "Invalid image path"},
|
||||
status_code=400,
|
||||
@@ -458,6 +455,22 @@ 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())],
|
||||
@@ -500,7 +513,9 @@ def download_export_case(
|
||||
_stream_case_archive(exports),
|
||||
media_type="application/zip",
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="{archive_base}.zip"',
|
||||
"Content-Disposition": _content_disposition(
|
||||
f"{archive_base}.zip", f"{case_id}.zip"
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -998,7 +1013,7 @@ def export_recording_custom(
|
||||
|
||||
# Set default values if not provided (timelapse defaults)
|
||||
if ffmpeg_input_args is None:
|
||||
ffmpeg_input_args = ""
|
||||
ffmpeg_input_args = DEFAULT_TIME_LAPSE_FFMPEG_INPUT_ARGS
|
||||
|
||||
if ffmpeg_output_args is None:
|
||||
ffmpeg_output_args = DEFAULT_TIME_LAPSE_FFMPEG_ARGS
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"""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
|
||||
@@ -19,6 +21,95 @@ 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",
|
||||
@@ -71,6 +162,17 @@ 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
|
||||
|
||||
@@ -17,6 +17,7 @@ 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 (
|
||||
@@ -709,6 +710,7 @@ async def get_review(request: Request, review_id: str):
|
||||
dependencies=[Depends(allow_any_authenticated())],
|
||||
)
|
||||
async def set_not_reviewed(
|
||||
request: Request,
|
||||
review_id: str,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
@@ -727,6 +729,8 @@ async def set_not_reviewed(
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
await require_camera_access(review.camera, request=request)
|
||||
|
||||
try:
|
||||
user_review = UserReviewStatus.get(
|
||||
UserReviewStatus.user_id == user_id,
|
||||
@@ -743,9 +747,12 @@ 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_role(["admin"]))],
|
||||
dependencies=[Depends(require_full_camera_access)],
|
||||
description="Use GenAI to summarize review items over a period of time.",
|
||||
)
|
||||
def generate_review_summary(request: Request, start_ts: float, end_ts: float):
|
||||
|
||||
+3
-15
@@ -103,21 +103,9 @@ class FrigateApp:
|
||||
self.detection_shms: list[mp.shared_memory.SharedMemory] = []
|
||||
self.log_queue: Queue = mp.Queue()
|
||||
self.camera_metrics: DictProxy = self.metrics_manager.dict()
|
||||
self.embeddings_metrics: DataProcessorMetrics | None = (
|
||||
DataProcessorMetrics(
|
||||
self.metrics_manager, list(config.classification.custom.keys())
|
||||
)
|
||||
if (
|
||||
config.semantic_search.enabled
|
||||
or any(
|
||||
c.objects.genai.enabled or c.review.genai.enabled
|
||||
for c in config.cameras.values()
|
||||
)
|
||||
or config.lpr.enabled
|
||||
or config.face_recognition.enabled
|
||||
or len(config.classification.custom) > 0
|
||||
)
|
||||
else None
|
||||
|
||||
self.embeddings_metrics = DataProcessorMetrics(
|
||||
self.metrics_manager, list(config.classification.custom.keys())
|
||||
)
|
||||
self.ptz_metrics: dict[str, PTZMetrics] = {}
|
||||
self.processes: dict[str, int] = {}
|
||||
|
||||
@@ -60,6 +60,11 @@ 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
|
||||
@@ -452,7 +457,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 ("car", "motorcycle")
|
||||
obj_label in self.lp_objects
|
||||
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
|
||||
@@ -548,7 +553,7 @@ class CameraState:
|
||||
current_best.thumbnail_data is not None
|
||||
and obj.thumbnail_data is not None
|
||||
and is_better_thumbnail(
|
||||
object_type,
|
||||
obj.thumbnail_attributes,
|
||||
current_best.thumbnail_data,
|
||||
obj.thumbnail_data,
|
||||
self.camera_config.frame_shape,
|
||||
|
||||
@@ -89,7 +89,9 @@ class WebPushClient(Communicator):
|
||||
# notification and auth config updater
|
||||
self.global_config_subscriber = ConfigSubscriber("config/")
|
||||
self.config_subscriber = CameraConfigUpdateSubscriber(
|
||||
self.config, self.config.cameras, [CameraConfigUpdateEnum.notifications]
|
||||
self.config,
|
||||
self.config.cameras,
|
||||
[CameraConfigUpdateEnum.add, CameraConfigUpdateEnum.notifications],
|
||||
)
|
||||
self._refresh_user_cameras()
|
||||
|
||||
@@ -213,6 +215,8 @@ class WebPushClient(Communicator):
|
||||
self.suspended_cameras[camera] = 0
|
||||
self.last_camera_notification_time[camera] = 0
|
||||
|
||||
self._refresh_user_cameras()
|
||||
|
||||
if topic == "reviews":
|
||||
decoded = json.loads(payload)
|
||||
camera = decoded["before"]["camera"]
|
||||
@@ -417,6 +421,7 @@ 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"])
|
||||
|
||||
@@ -44,7 +44,11 @@ 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",
|
||||
|
||||
@@ -1172,6 +1172,28 @@ class LicensePlateProcessingMixin:
|
||||
|
||||
return rep["plate"], rep["conf"], rep["char_confidences"], rep["area"]
|
||||
|
||||
def _passes_plate_filters(self, camera: str, plate: str) -> bool:
|
||||
"""Check a plate against the configured length and format filters."""
|
||||
if len(plate) < self.lpr_config.min_plate_length:
|
||||
logger.debug(
|
||||
f"{camera}: Filtered out plate '{plate}' due to length ({len(plate)} < {self.lpr_config.min_plate_length})"
|
||||
)
|
||||
return False
|
||||
|
||||
if self.lpr_config.format:
|
||||
try:
|
||||
if not re.fullmatch(self.lpr_config.format, plate):
|
||||
logger.debug(
|
||||
f"{camera}: Filtered out plate '{plate}' due to format mismatch"
|
||||
)
|
||||
return False
|
||||
except re.error:
|
||||
logger.error(
|
||||
f"{camera}: Invalid regex in LPR format configuration: {self.lpr_config.format}"
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
def _generate_plate_event(self, camera: str, plate: str, plate_score: float) -> str:
|
||||
"""Generate a unique ID for a plate event based on camera and text."""
|
||||
now = datetime.datetime.now().timestamp()
|
||||
@@ -1268,7 +1290,7 @@ class LicensePlateProcessingMixin:
|
||||
and obj_data.get("label") != "license_plate"
|
||||
):
|
||||
logger.debug(
|
||||
f"{camera}: Not a processing license plate for non car/motorcycle object."
|
||||
f"{camera}: Not a processing license plate for {obj_data.get('label', 'unknown')}."
|
||||
)
|
||||
return
|
||||
|
||||
@@ -1345,7 +1367,7 @@ class LicensePlateProcessingMixin:
|
||||
|
||||
if not license_plate:
|
||||
logger.debug(
|
||||
f"{camera}: Detected no license plates for car/motorcycle object."
|
||||
f"{camera}: Detected no license plates for {obj_data.get('label', 'unknown')} object."
|
||||
)
|
||||
return
|
||||
|
||||
@@ -1511,10 +1533,14 @@ class LicensePlateProcessingMixin:
|
||||
plate_id = None
|
||||
|
||||
for existing_id, data in self.detected_license_plates.items():
|
||||
# entries from the object pipeline on this camera have no
|
||||
# last_seen until they pass the filters below
|
||||
last_seen = data.get("last_seen")
|
||||
|
||||
if (
|
||||
data["camera"] == camera
|
||||
and data["last_seen"] is not None
|
||||
and current_time - data["last_seen"]
|
||||
and last_seen is not None
|
||||
and current_time - last_seen
|
||||
<= self.config.cameras[camera].lpr.expire_time
|
||||
):
|
||||
similarity = JaroWinkler.similarity(data["plate"], top_plate)
|
||||
@@ -1525,6 +1551,11 @@ class LicensePlateProcessingMixin:
|
||||
)
|
||||
break
|
||||
if plate_id is None:
|
||||
# the event id doubles as the cluster key, so a plate rejected
|
||||
# after this point would leave an entry that never expires
|
||||
if not self._passes_plate_filters(camera, top_plate):
|
||||
return
|
||||
|
||||
plate_id = self._generate_plate_event(camera, top_plate, avg_confidence)
|
||||
logger.debug(
|
||||
f"{camera}: New plate event for dedicated LPR camera {plate_id}: {top_plate}"
|
||||
@@ -1569,27 +1600,12 @@ class LicensePlateProcessingMixin:
|
||||
f"{camera}: Clustering changed top plate '{top_plate}' (conf: {avg_confidence:.3f}) to rep '{rep_plate}' (conf: {rep_conf:.3f})"
|
||||
)
|
||||
|
||||
# Apply length and format filters to the clustered representative
|
||||
# rather than individual OCR readings, so noisy variants still
|
||||
# contribute to clustering even when they don't pass on their own.
|
||||
if len(rep_plate) < self.lpr_config.min_plate_length:
|
||||
logger.debug(
|
||||
f"{camera}: Filtered out clustered plate '{rep_plate}' due to length ({len(rep_plate)} < {self.lpr_config.min_plate_length})"
|
||||
)
|
||||
# filter the clustered representative rather than individual OCR
|
||||
# readings, so noisy variants still contribute to clustering even
|
||||
# when they don't pass on their own
|
||||
if not self._passes_plate_filters(camera, rep_plate):
|
||||
return
|
||||
|
||||
if self.lpr_config.format:
|
||||
try:
|
||||
if not re.fullmatch(self.lpr_config.format, rep_plate):
|
||||
logger.debug(
|
||||
f"{camera}: Filtered out clustered plate '{rep_plate}' due to format mismatch"
|
||||
)
|
||||
return
|
||||
except re.error:
|
||||
logger.error(
|
||||
f"{camera}: Invalid regex in LPR format configuration: {self.lpr_config.format}"
|
||||
)
|
||||
|
||||
# Update stored rep
|
||||
self.detected_license_plates[id].update(
|
||||
{
|
||||
|
||||
@@ -63,8 +63,10 @@ class ObjectDescriptionProcessor(PostProcessorApi):
|
||||
"""Handle an update to a frame for an object."""
|
||||
camera_config = self.config.cameras[camera]
|
||||
|
||||
# no need to save our own thumbnails if genai is not enabled
|
||||
# or if the object has become stationary
|
||||
if not camera_config.objects.genai.enabled:
|
||||
return
|
||||
|
||||
# no need to save our own thumbnails if the object has become stationary
|
||||
if not data["stationary"]:
|
||||
if data["id"] not in self.tracked_events:
|
||||
self.tracked_events[data["id"]] = []
|
||||
|
||||
@@ -28,6 +28,7 @@ from frigate.data_processing.common.face.model import (
|
||||
from frigate.types import TrackedObjectUpdateTypesEnum
|
||||
from frigate.util.builtin import EventsPerSecond, InferenceSpeed
|
||||
from frigate.util.image import area
|
||||
from frigate.util.path import safe_join, sanitize_path_component
|
||||
|
||||
from ..types import DataProcessorMetrics
|
||||
from .api import RealTimeProcessorApi
|
||||
@@ -409,9 +410,17 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
|
||||
)
|
||||
|
||||
# write face to library
|
||||
folder = os.path.join(FACE_DIR, label)
|
||||
sanitized_label = sanitize_path_component(label)
|
||||
folder = safe_join(FACE_DIR, label)
|
||||
|
||||
if sanitized_label is None or folder is None:
|
||||
return {
|
||||
"message": f"Invalid face name: {label}",
|
||||
"success": False,
|
||||
}
|
||||
|
||||
file = os.path.join(
|
||||
folder, f"{label}_{datetime.datetime.now().timestamp()}.webp"
|
||||
folder, f"{sanitized_label}_{datetime.datetime.now().timestamp()}.webp"
|
||||
)
|
||||
os.makedirs(folder, exist_ok=True)
|
||||
|
||||
|
||||
@@ -25,25 +25,31 @@ def is_arm64_platform() -> bool:
|
||||
return machine in ("aarch64", "arm64", "armv8", "armv7l")
|
||||
|
||||
|
||||
def get_ort_session_options(
|
||||
is_complex_model: bool = False,
|
||||
) -> ort.SessionOptions | None:
|
||||
def get_ort_session_options(model_type: str | None = None) -> ort.SessionOptions | None:
|
||||
"""Get ONNX Runtime session options with appropriate settings.
|
||||
|
||||
Args:
|
||||
is_complex_model: Whether the model needs basic optimization to avoid graph fusion issues.
|
||||
model_type: Model being loaded, used to pin its graph optimization level.
|
||||
|
||||
Returns:
|
||||
SessionOptions with appropriate optimization level, or None for default settings.
|
||||
SessionOptions with a pinned optimization level, or None for default settings.
|
||||
"""
|
||||
if is_complex_model:
|
||||
sess_options = ort.SessionOptions()
|
||||
sess_options.graph_optimization_level = (
|
||||
ort.GraphOptimizationLevel.ORT_ENABLE_BASIC
|
||||
)
|
||||
return sess_options
|
||||
# Import here to avoid circular imports
|
||||
from frigate.embeddings.types import EnrichmentModelTypeEnum
|
||||
|
||||
return None
|
||||
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
|
||||
|
||||
|
||||
# Import OpenVINO only when needed to avoid circular dependencies
|
||||
@@ -115,21 +121,6 @@ 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
|
||||
@@ -208,15 +199,20 @@ 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._captured = False
|
||||
self._prepared = 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."""
|
||||
@@ -226,35 +222,41 @@ 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 = input[input_name]
|
||||
tensor_input = np.ascontiguousarray(tensor_input)
|
||||
tensor_input = np.ascontiguousarray(input[input_name])
|
||||
|
||||
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()]
|
||||
if not self._prepared:
|
||||
self._prepare(input_name, tensor_input)
|
||||
else:
|
||||
# Replay using updated input
|
||||
self._input_ortvalue.update_inplace(tensor_input)
|
||||
|
||||
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()
|
||||
@@ -323,6 +325,12 @@ 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"})
|
||||
@@ -626,9 +634,7 @@ def get_optimized_runner(
|
||||
return ONNXModelRunner(
|
||||
ort.InferenceSession(
|
||||
model_path,
|
||||
sess_options=get_ort_session_options(
|
||||
ONNXModelRunner.is_cpu_complex_model(model_type)
|
||||
),
|
||||
sess_options=get_ort_session_options(model_type),
|
||||
providers=providers,
|
||||
provider_options=options,
|
||||
),
|
||||
|
||||
@@ -21,6 +21,7 @@ from frigate.db.sqlitevecq import SqliteVecQueueDatabase
|
||||
from frigate.models import Event
|
||||
from frigate.util.builtin import serialize
|
||||
from frigate.util.classification import kickoff_model_training
|
||||
from frigate.util.path import safe_join
|
||||
from frigate.util.process import FrigateProcess
|
||||
|
||||
from .maintainer import EmbeddingMaintainer
|
||||
@@ -33,7 +34,7 @@ class EmbeddingProcess(FrigateProcess):
|
||||
def __init__(
|
||||
self,
|
||||
config: FrigateConfig,
|
||||
metrics: DataProcessorMetrics | None,
|
||||
metrics: DataProcessorMetrics,
|
||||
stop_event: MpEvent,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
@@ -234,11 +235,16 @@ class EmbeddingsContext:
|
||||
)
|
||||
|
||||
def delete_face_ids(self, face: str, ids: list[str]) -> None:
|
||||
folder = os.path.join(FACE_DIR, face)
|
||||
for id in ids:
|
||||
file_path = os.path.join(folder, id)
|
||||
folder = safe_join(FACE_DIR, face)
|
||||
|
||||
if os.path.isfile(file_path):
|
||||
if folder is None:
|
||||
logger.warning("Not deleting faces for invalid name %s", face)
|
||||
return
|
||||
|
||||
for id in ids:
|
||||
file_path = safe_join(folder, id)
|
||||
|
||||
if file_path and os.path.isfile(file_path):
|
||||
os.unlink(file_path)
|
||||
|
||||
if face != "train" and len(os.listdir(folder)) == 0:
|
||||
|
||||
@@ -78,6 +78,16 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
MAX_THUMBNAILS = 10
|
||||
|
||||
GENAI_UPDATE_TOPICS = frozenset(
|
||||
{
|
||||
CameraConfigUpdateEnum.add.name,
|
||||
CameraConfigUpdateEnum.objects.name,
|
||||
CameraConfigUpdateEnum.object_genai.name,
|
||||
CameraConfigUpdateEnum.review.name,
|
||||
CameraConfigUpdateEnum.review_genai.name,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class EmbeddingMaintainer(threading.Thread):
|
||||
"""Handle embedding queue and post event updates."""
|
||||
@@ -85,7 +95,7 @@ class EmbeddingMaintainer(threading.Thread):
|
||||
def __init__(
|
||||
self,
|
||||
config: FrigateConfig,
|
||||
metrics: DataProcessorMetrics | None,
|
||||
metrics: DataProcessorMetrics,
|
||||
stop_event: MpEvent,
|
||||
) -> None:
|
||||
super().__init__(name="embeddings_maintainer")
|
||||
@@ -220,16 +230,6 @@ class EmbeddingMaintainer(threading.Thread):
|
||||
# post processors
|
||||
self.post_processors: list[PostProcessorApi] = []
|
||||
|
||||
if any(c.review.genai.enabled_in_config for c in self.config.cameras.values()):
|
||||
self.post_processors.append(
|
||||
ReviewDescriptionProcessor(
|
||||
self.config,
|
||||
self.requestor,
|
||||
self.metrics,
|
||||
self.genai_manager,
|
||||
)
|
||||
)
|
||||
|
||||
if self.config.lpr.enabled:
|
||||
self.post_processors.append(
|
||||
LicensePlatePostProcessor(
|
||||
@@ -252,9 +252,9 @@ class EmbeddingMaintainer(threading.Thread):
|
||||
)
|
||||
)
|
||||
|
||||
semantic_trigger_processor: SemanticTriggerProcessor | None = None
|
||||
self.semantic_trigger_processor: SemanticTriggerProcessor | None = None
|
||||
if self.config.semantic_search.enabled:
|
||||
semantic_trigger_processor = SemanticTriggerProcessor(
|
||||
self.semantic_trigger_processor = SemanticTriggerProcessor(
|
||||
db,
|
||||
self.config,
|
||||
self.requestor,
|
||||
@@ -262,9 +262,49 @@ class EmbeddingMaintainer(threading.Thread):
|
||||
metrics,
|
||||
self.embeddings,
|
||||
)
|
||||
self.post_processors.append(semantic_trigger_processor)
|
||||
self.post_processors.append(self.semantic_trigger_processor)
|
||||
|
||||
if any(c.objects.genai.enabled_in_config for c in self.config.cameras.values()):
|
||||
self._sync_genai_processors()
|
||||
|
||||
self.stop_event = stop_event
|
||||
|
||||
# recordings data
|
||||
self.recordings_available_through: dict[str, float] = {}
|
||||
|
||||
def _sync_genai_processors(self) -> None:
|
||||
"""Create GenAI post processors for cameras that have GenAI enabled.
|
||||
|
||||
Called at startup and again after camera config updates so enabling
|
||||
GenAI on the first camera does not require a restart. Processors are
|
||||
never removed once created.
|
||||
|
||||
A profile can turn GenAI on without setting enabled_in_config, so both
|
||||
flags are checked.
|
||||
"""
|
||||
cameras = self.config.cameras.values()
|
||||
|
||||
if any(
|
||||
c.review.genai.enabled or c.review.genai.enabled_in_config for c in cameras
|
||||
) and not any(
|
||||
isinstance(p, ReviewDescriptionProcessor) for p in self.post_processors
|
||||
):
|
||||
logger.debug("Initializing review description processor")
|
||||
self.post_processors.append(
|
||||
ReviewDescriptionProcessor(
|
||||
self.config,
|
||||
self.requestor,
|
||||
self.metrics,
|
||||
self.genai_manager,
|
||||
)
|
||||
)
|
||||
|
||||
if any(
|
||||
c.objects.genai.enabled or c.objects.genai.enabled_in_config
|
||||
for c in cameras
|
||||
) and not any(
|
||||
isinstance(p, ObjectDescriptionProcessor) for p in self.post_processors
|
||||
):
|
||||
logger.debug("Initializing object description processor")
|
||||
self.post_processors.append(
|
||||
ObjectDescriptionProcessor(
|
||||
self.config,
|
||||
@@ -272,19 +312,21 @@ class EmbeddingMaintainer(threading.Thread):
|
||||
self.requestor,
|
||||
self.metrics,
|
||||
self.genai_manager,
|
||||
semantic_trigger_processor,
|
||||
self.semantic_trigger_processor,
|
||||
)
|
||||
)
|
||||
|
||||
self.stop_event = stop_event
|
||||
def _check_camera_config_updates(self) -> None:
|
||||
"""Apply camera config updates and register newly enabled processors."""
|
||||
updated_topics = self.config_updater.check_for_updates()
|
||||
|
||||
# recordings data
|
||||
self.recordings_available_through: dict[str, float] = {}
|
||||
if updated_topics.keys() & GENAI_UPDATE_TOPICS:
|
||||
self._sync_genai_processors()
|
||||
|
||||
def run(self) -> None:
|
||||
"""Maintain a SQLite-vec database for semantic search."""
|
||||
while not self.stop_event.is_set():
|
||||
self.config_updater.check_for_updates()
|
||||
self._check_camera_config_updates()
|
||||
self._check_enrichment_config_updates()
|
||||
self._process_requests()
|
||||
self._process_updates()
|
||||
|
||||
@@ -245,7 +245,7 @@ class GeminiClient(GenAIClient):
|
||||
)
|
||||
gemini_messages.append(
|
||||
types.Content(
|
||||
role="function",
|
||||
role="user",
|
||||
parts=[
|
||||
types.Part.from_function_response(
|
||||
name=msg.get("name")
|
||||
@@ -501,7 +501,7 @@ class GeminiClient(GenAIClient):
|
||||
)
|
||||
gemini_messages.append(
|
||||
types.Content(
|
||||
role="function",
|
||||
role="user",
|
||||
parts=[
|
||||
types.Part.from_function_response(
|
||||
name=msg.get("name")
|
||||
|
||||
@@ -115,6 +115,10 @@ def query_recordings(source_camera: str, start_ts: float, end_ts: float) -> Mode
|
||||
return cast(ModelSelect, query)
|
||||
|
||||
|
||||
class NoRecordingsError(ValueError):
|
||||
"""Raised when no recordings exist in the requested time range."""
|
||||
|
||||
|
||||
class DebugReplaySource(ABC):
|
||||
"""Abstract source for a debug replay session.
|
||||
|
||||
@@ -187,7 +191,7 @@ class RecordingDebugReplaySource(DebugReplaySource):
|
||||
raise ValueError("End time must be after start time")
|
||||
|
||||
if not query_recordings(self._camera, self._start_ts, self._end_ts).count():
|
||||
raise ValueError(
|
||||
raise NoRecordingsError(
|
||||
f"No recordings found for camera '{self._camera}' in the specified time range"
|
||||
)
|
||||
|
||||
|
||||
@@ -36,8 +36,9 @@ 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 = "-an -skip_frame nokey"
|
||||
TIMELAPSE_DATA_INPUT_ARGS = "-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.
|
||||
@@ -737,7 +738,7 @@ class RecordingExporter(threading.Thread):
|
||||
parse_preset_hardware_acceleration_encode(
|
||||
self.config.ffmpeg.ffmpeg_path,
|
||||
hwaccel_args,
|
||||
f"{self.ffmpeg_input_args} -an {ffmpeg_input}".strip(),
|
||||
f"{self.ffmpeg_input_args} {ffmpeg_input}".strip(),
|
||||
f"{self.ffmpeg_output_args} -movflags +faststart".strip(),
|
||||
EncodeTypeEnum.timelapse,
|
||||
)
|
||||
|
||||
@@ -392,6 +392,32 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
return self._publish_segment_end(segment, prev_data)
|
||||
return None
|
||||
|
||||
def get_manual_event_severity(self, camera: str, label: str) -> SeverityEnum | None:
|
||||
"""Determine the review severity for a manual event label.
|
||||
|
||||
Alert labels take precedence over detection labels, matching how
|
||||
tracked objects are categorized. Labels in neither list default to
|
||||
alerts so manual events keep their historical severity.
|
||||
"""
|
||||
review_config = self.config.cameras[camera].review
|
||||
# label contains 'label: sub_label', only the label is categorized
|
||||
label = label.split(": ")[0]
|
||||
|
||||
if review_config.alerts.enabled and label in review_config.alerts.labels:
|
||||
return SeverityEnum.alert
|
||||
|
||||
if (
|
||||
review_config.detections.enabled
|
||||
and review_config.detections.labels is not None
|
||||
and label in review_config.detections.labels
|
||||
):
|
||||
return SeverityEnum.detection
|
||||
|
||||
if review_config.alerts.enabled:
|
||||
return SeverityEnum.alert
|
||||
|
||||
return None
|
||||
|
||||
def update_existing_segment(
|
||||
self,
|
||||
segment: PendingReviewSegment,
|
||||
@@ -734,24 +760,19 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
manual_info["label"]
|
||||
)
|
||||
if topic == DetectionTypeEnum.api:
|
||||
# manual_info["label"] contains 'label: sub_label'
|
||||
# so split out the label without modifying manual_info
|
||||
det_labels = self.config.cameras[
|
||||
camera
|
||||
].review.detections.labels
|
||||
if (
|
||||
self.config.cameras[camera].review.detections.enabled
|
||||
and det_labels is not None
|
||||
and manual_info["label"].split(": ")[0] in det_labels
|
||||
):
|
||||
current_segment.last_detection_time = manual_info[
|
||||
"end_time"
|
||||
]
|
||||
elif self.config.cameras[camera].review.alerts.enabled:
|
||||
severity = self.get_manual_event_severity(
|
||||
camera, manual_info["label"]
|
||||
)
|
||||
|
||||
if severity == SeverityEnum.alert:
|
||||
current_segment.severity = SeverityEnum.alert
|
||||
current_segment.last_alert_time = manual_info[
|
||||
"end_time"
|
||||
]
|
||||
elif severity == SeverityEnum.detection:
|
||||
current_segment.last_detection_time = manual_info[
|
||||
"end_time"
|
||||
]
|
||||
elif (
|
||||
topic == DetectionTypeEnum.lpr
|
||||
and self.config.cameras[camera].review.detections.enabled
|
||||
@@ -765,21 +786,12 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
current_segment.detections[manual_info["event_id"]] = (
|
||||
manual_info["label"]
|
||||
)
|
||||
if (
|
||||
topic == DetectionTypeEnum.api
|
||||
and self.config.cameras[camera].review.alerts.enabled
|
||||
):
|
||||
# manual_info["label"] contains 'label: sub_label'
|
||||
# so split out the label without modifying manual_info
|
||||
det_labels = self.config.cameras[
|
||||
camera
|
||||
].review.detections.labels
|
||||
if topic == DetectionTypeEnum.api:
|
||||
if (
|
||||
not self.config.cameras[
|
||||
camera
|
||||
].review.detections.enabled
|
||||
or det_labels is None
|
||||
or manual_info["label"].split(": ")[0] not in det_labels
|
||||
self.get_manual_event_severity(
|
||||
camera, manual_info["label"]
|
||||
)
|
||||
== SeverityEnum.alert
|
||||
):
|
||||
current_segment.severity = SeverityEnum.alert
|
||||
elif (
|
||||
@@ -853,18 +865,9 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
detections,
|
||||
)
|
||||
elif topic == DetectionTypeEnum.api:
|
||||
severity = None
|
||||
# manual_info["label"] contains 'label: sub_label'
|
||||
# so split out the label without modifying manual_info
|
||||
det_labels = self.config.cameras[camera].review.detections.labels
|
||||
if (
|
||||
self.config.cameras[camera].review.detections.enabled
|
||||
and det_labels is not None
|
||||
and manual_info["label"].split(": ")[0] in det_labels
|
||||
):
|
||||
severity = SeverityEnum.detection
|
||||
elif self.config.cameras[camera].review.alerts.enabled:
|
||||
severity = SeverityEnum.alert
|
||||
severity = self.get_manual_event_severity(
|
||||
camera, manual_info["label"]
|
||||
)
|
||||
|
||||
if severity:
|
||||
api_segment = PendingReviewSegment(
|
||||
|
||||
@@ -62,7 +62,7 @@ def get_latest_version(config: FrigateConfig) -> str:
|
||||
def stats_init(
|
||||
config: FrigateConfig,
|
||||
camera_metrics: DictProxy,
|
||||
embeddings_metrics: DataProcessorMetrics | None,
|
||||
embeddings_metrics: DataProcessorMetrics,
|
||||
detectors: dict[str, ObjectDetectProcess],
|
||||
processes: dict[str, int],
|
||||
) -> StatsTrackingTypes:
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from frigate.jobs.debug_replay import NoRecordingsError
|
||||
from frigate.models import Event, Recordings, ReviewSegment
|
||||
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
|
||||
|
||||
@@ -66,6 +67,32 @@ class TestDebugReplayAPI(BaseTestHttp):
|
||||
# (CodeQL: information exposure through an exception).
|
||||
self.assertEqual(body["message"], "Invalid debug replay parameters")
|
||||
|
||||
def test_start_returns_404_when_no_recordings(self):
|
||||
with patch(
|
||||
"frigate.api.debug_replay.start_debug_replay_job",
|
||||
side_effect=NoRecordingsError(
|
||||
"No recordings found for camera 'front' in the specified time range"
|
||||
),
|
||||
):
|
||||
with AuthTestClient(self.app) as client:
|
||||
resp = client.post(
|
||||
"/debug_replay/start",
|
||||
json={
|
||||
"camera": "front",
|
||||
"start_time": 100,
|
||||
"end_time": 200,
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 404)
|
||||
body = resp.json()
|
||||
self.assertFalse(body["success"])
|
||||
# Message is hard-coded so we don't echo exception text back to clients
|
||||
# (CodeQL: information exposure through an exception).
|
||||
self.assertEqual(
|
||||
body["message"], "No recordings found in the selected time range"
|
||||
)
|
||||
|
||||
def test_start_returns_409_when_session_already_active(self):
|
||||
with patch(
|
||||
"frigate.api.debug_replay.start_debug_replay_job",
|
||||
|
||||
@@ -440,3 +440,68 @@ 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
|
||||
@@ -0,0 +1,73 @@
|
||||
"""End to end checks that classification endpoints cannot escape their base dir."""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from unittest.mock import patch
|
||||
|
||||
from frigate.models import Event
|
||||
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
|
||||
|
||||
# Percent encodings that survive nginx normalization. nginx collapses a bare
|
||||
# ".." segment, but "..:" and friends are not relative segments to nginx while
|
||||
# pathvalidate still reduces them to exactly "..".
|
||||
TRAVERSAL_NAMES = ["..%3A", "..%2A", "..%3C", "..%7C", "..%20", ".."]
|
||||
|
||||
|
||||
class TestHttpClassificationTraversal(BaseTestHttp):
|
||||
def setUp(self):
|
||||
super().setUp([Event])
|
||||
self.app = super().create_app()
|
||||
|
||||
self.root = tempfile.mkdtemp()
|
||||
self.clips = os.path.join(self.root, "clips")
|
||||
self.model_cache = os.path.join(self.root, "model_cache")
|
||||
os.makedirs(os.path.join(self.clips, "model1"))
|
||||
os.makedirs(os.path.join(self.model_cache, "model1"))
|
||||
os.makedirs(os.path.join(self.root, "recordings"))
|
||||
|
||||
# Sibling data that a "/.." escape from clips would reach.
|
||||
self.canary = os.path.join(self.root, "recordings", "seg.mp4")
|
||||
|
||||
with open(self.canary, "w") as f:
|
||||
f.write("recording")
|
||||
|
||||
clips_patch = patch("frigate.api.classification.CLIPS_DIR", self.clips)
|
||||
cache_patch = patch(
|
||||
"frigate.api.classification.MODEL_CACHE_DIR", self.model_cache
|
||||
)
|
||||
clips_patch.start()
|
||||
cache_patch.start()
|
||||
self.addCleanup(clips_patch.stop)
|
||||
self.addCleanup(cache_patch.stop)
|
||||
|
||||
def tearDown(self):
|
||||
shutil.rmtree(self.root, ignore_errors=True)
|
||||
self.app.dependency_overrides.clear()
|
||||
super().tearDown()
|
||||
|
||||
def test_delete_model_rejects_traversal_names(self):
|
||||
client = AuthTestClient(self.app)
|
||||
|
||||
for name in TRAVERSAL_NAMES:
|
||||
with self.subTest(name=name):
|
||||
response = client.delete(f"/classification/{name}")
|
||||
|
||||
# Either the router never matches it or the handler rejects it,
|
||||
# but the sibling directory must survive either way.
|
||||
self.assertNotEqual(response.status_code, 200)
|
||||
self.assertTrue(
|
||||
os.path.exists(self.canary),
|
||||
f"{name} deleted data outside the clips directory",
|
||||
)
|
||||
self.assertTrue(os.path.exists(os.path.join(self.root, "recordings")))
|
||||
|
||||
def test_delete_model_still_removes_its_own_directories(self):
|
||||
client = AuthTestClient(self.app)
|
||||
|
||||
response = client.delete("/classification/model1")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertFalse(os.path.exists(os.path.join(self.clips, "model1")))
|
||||
self.assertFalse(os.path.exists(os.path.join(self.model_cache, "model1")))
|
||||
self.assertTrue(os.path.exists(self.canary))
|
||||
@@ -1,5 +1,7 @@
|
||||
import io
|
||||
import os
|
||||
import tempfile
|
||||
import zipfile
|
||||
from unittest.mock import patch
|
||||
|
||||
from frigate.jobs.export import (
|
||||
@@ -1431,3 +1433,79 @@ 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"
|
||||
)
|
||||
@@ -0,0 +1,113 @@
|
||||
"""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
|
||||
@@ -9,6 +9,7 @@ from unittest.mock import MagicMock, patch
|
||||
from frigate.debug_replay import DebugReplayManager
|
||||
from frigate.jobs.debug_replay import (
|
||||
DebugReplayJob,
|
||||
NoRecordingsError,
|
||||
RecordingDebugReplaySource,
|
||||
cancel_debug_replay_job,
|
||||
get_active_runner,
|
||||
@@ -129,7 +130,7 @@ class TestStartDebugReplayJob(unittest.TestCase):
|
||||
empty_qs = MagicMock()
|
||||
empty_qs.count.return_value = 0
|
||||
with patch("frigate.jobs.debug_replay.query_recordings", return_value=empty_qs):
|
||||
with self.assertRaises(ValueError):
|
||||
with self.assertRaises(NoRecordingsError):
|
||||
start_debug_replay_job(
|
||||
source=RecordingDebugReplaySource(
|
||||
source_camera="front",
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,213 @@
|
||||
"""Tests for GenAI enablement gating in the embeddings maintainer.
|
||||
|
||||
Covers creating post processors when GenAI is enabled at runtime, and the
|
||||
per-camera gating those processors apply once they exist.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
# Mock TFLite before importing the maintainer
|
||||
_MOCK_MODULES = [
|
||||
"tflite_runtime",
|
||||
"tflite_runtime.interpreter",
|
||||
"ai_edge_litert",
|
||||
"ai_edge_litert.interpreter",
|
||||
]
|
||||
for mod in _MOCK_MODULES:
|
||||
if mod not in sys.modules:
|
||||
sys.modules[mod] = MagicMock()
|
||||
|
||||
# imported from the maintainer to avoid tripping the circular import between
|
||||
# the maintainer and the processor modules
|
||||
from frigate.embeddings.maintainer import ( # noqa: E402
|
||||
EmbeddingMaintainer,
|
||||
ObjectDescriptionProcessor,
|
||||
PostProcessDataEnum,
|
||||
ReviewDescriptionProcessor,
|
||||
)
|
||||
|
||||
|
||||
class TestGenAIProcessorSync(unittest.TestCase):
|
||||
"""Enabling GenAI on the first camera must not require a restart."""
|
||||
|
||||
def _make_maintainer(
|
||||
self,
|
||||
review: bool = False,
|
||||
objects: bool = False,
|
||||
review_in_config: bool | None = None,
|
||||
objects_in_config: bool | None = None,
|
||||
) -> EmbeddingMaintainer:
|
||||
# Bypass the heavy __init__; only the attributes touched by
|
||||
# _sync_genai_processors are needed for these tests.
|
||||
maintainer = EmbeddingMaintainer.__new__(EmbeddingMaintainer)
|
||||
maintainer.post_processors = []
|
||||
maintainer.config = MagicMock()
|
||||
maintainer.config.cameras = {
|
||||
"front": self._make_camera(
|
||||
review,
|
||||
objects,
|
||||
review if review_in_config is None else review_in_config,
|
||||
objects if objects_in_config is None else objects_in_config,
|
||||
)
|
||||
}
|
||||
maintainer.config_updater = MagicMock()
|
||||
maintainer.embeddings = None
|
||||
maintainer.requestor = MagicMock()
|
||||
maintainer.metrics = MagicMock()
|
||||
maintainer.genai_manager = MagicMock()
|
||||
maintainer.semantic_trigger_processor = None
|
||||
return maintainer
|
||||
|
||||
def _make_camera(
|
||||
self,
|
||||
review: bool,
|
||||
objects: bool,
|
||||
review_in_config: bool,
|
||||
objects_in_config: bool,
|
||||
) -> MagicMock:
|
||||
camera = MagicMock()
|
||||
camera.review.genai.enabled = review
|
||||
camera.review.genai.enabled_in_config = review_in_config
|
||||
camera.objects.genai.enabled = objects
|
||||
camera.objects.genai.enabled_in_config = objects_in_config
|
||||
return camera
|
||||
|
||||
def _processor_types(self, maintainer: EmbeddingMaintainer) -> list[type]:
|
||||
return [type(p) for p in maintainer.post_processors]
|
||||
|
||||
def test_no_processors_when_genai_disabled(self):
|
||||
"""A config with no GenAI cameras registers neither processor."""
|
||||
maintainer = self._make_maintainer()
|
||||
|
||||
maintainer._sync_genai_processors()
|
||||
|
||||
self.assertEqual(maintainer.post_processors, [])
|
||||
|
||||
def test_review_processor_added_when_enabled_after_startup(self):
|
||||
"""Enabling review GenAI on the first camera registers the processor."""
|
||||
maintainer = self._make_maintainer()
|
||||
maintainer._sync_genai_processors()
|
||||
|
||||
camera = maintainer.config.cameras["front"]
|
||||
camera.review.genai.enabled = True
|
||||
camera.review.genai.enabled_in_config = True
|
||||
maintainer._sync_genai_processors()
|
||||
|
||||
self.assertEqual(
|
||||
self._processor_types(maintainer), [ReviewDescriptionProcessor]
|
||||
)
|
||||
|
||||
def test_object_processor_added_when_enabled_after_startup(self):
|
||||
"""Enabling object GenAI on the first camera registers the processor."""
|
||||
maintainer = self._make_maintainer()
|
||||
maintainer._sync_genai_processors()
|
||||
|
||||
camera = maintainer.config.cameras["front"]
|
||||
camera.objects.genai.enabled = True
|
||||
camera.objects.genai.enabled_in_config = True
|
||||
maintainer._sync_genai_processors()
|
||||
|
||||
self.assertEqual(
|
||||
self._processor_types(maintainer), [ObjectDescriptionProcessor]
|
||||
)
|
||||
|
||||
def test_processor_added_when_only_enabled_by_profile(self):
|
||||
"""A profile enables GenAI without setting enabled_in_config."""
|
||||
maintainer = self._make_maintainer(
|
||||
review=True, objects=True, review_in_config=False, objects_in_config=False
|
||||
)
|
||||
|
||||
maintainer._sync_genai_processors()
|
||||
|
||||
self.assertEqual(
|
||||
self._processor_types(maintainer),
|
||||
[ReviewDescriptionProcessor, ObjectDescriptionProcessor],
|
||||
)
|
||||
|
||||
def test_processors_are_not_duplicated(self):
|
||||
"""Repeated config updates must not register a second processor."""
|
||||
maintainer = self._make_maintainer(review=True, objects=True)
|
||||
|
||||
maintainer._sync_genai_processors()
|
||||
maintainer._sync_genai_processors()
|
||||
|
||||
self.assertEqual(
|
||||
self._processor_types(maintainer),
|
||||
[ReviewDescriptionProcessor, ObjectDescriptionProcessor],
|
||||
)
|
||||
|
||||
def test_genai_topic_triggers_sync(self):
|
||||
"""A camera config update on a GenAI topic registers the processor."""
|
||||
maintainer = self._make_maintainer(review=True)
|
||||
maintainer.config_updater.check_for_updates.return_value = {"review": ["front"]}
|
||||
|
||||
maintainer._check_camera_config_updates()
|
||||
|
||||
self.assertEqual(
|
||||
self._processor_types(maintainer), [ReviewDescriptionProcessor]
|
||||
)
|
||||
|
||||
def test_unrelated_topic_does_not_sync(self):
|
||||
"""An unrelated camera config update must not register processors."""
|
||||
maintainer = self._make_maintainer(review=True)
|
||||
maintainer.config_updater.check_for_updates.return_value = {"motion": ["front"]}
|
||||
|
||||
maintainer._check_camera_config_updates()
|
||||
|
||||
self.assertEqual(maintainer.post_processors, [])
|
||||
|
||||
|
||||
class TestObjectDescriptionCameraGating(unittest.TestCase):
|
||||
"""One camera enabling object descriptions must not enlist the others."""
|
||||
|
||||
def _make_processor(self, enabled: bool) -> ObjectDescriptionProcessor:
|
||||
config = MagicMock()
|
||||
camera = MagicMock()
|
||||
camera.objects.genai.enabled = enabled
|
||||
camera.objects.genai.send_triggers.after_significant_updates = None
|
||||
config.cameras = {"front": camera}
|
||||
|
||||
genai_manager = MagicMock()
|
||||
genai_manager.description_client = MagicMock()
|
||||
|
||||
return ObjectDescriptionProcessor(
|
||||
config, None, MagicMock(), MagicMock(), genai_manager, None
|
||||
)
|
||||
|
||||
def _update(self, processor: ObjectDescriptionProcessor) -> None:
|
||||
processor.process_data(
|
||||
{
|
||||
"camera": "front",
|
||||
"data": {
|
||||
"id": "1234.5-abcdef",
|
||||
"box": (0, 0, 10, 10),
|
||||
"stationary": False,
|
||||
},
|
||||
"state": "update",
|
||||
"yuv_frame": MagicMock(),
|
||||
},
|
||||
PostProcessDataEnum.tracked_object,
|
||||
)
|
||||
|
||||
@patch("frigate.data_processing.post.object_descriptions.create_thumbnail")
|
||||
def test_disabled_camera_collects_no_thumbnails(self, mock_create_thumbnail):
|
||||
"""A camera with object descriptions off does no thumbnail work."""
|
||||
processor = self._make_processor(enabled=False)
|
||||
|
||||
self._update(processor)
|
||||
|
||||
mock_create_thumbnail.assert_not_called()
|
||||
self.assertEqual(processor.tracked_events, {})
|
||||
|
||||
@patch("frigate.data_processing.post.object_descriptions.create_thumbnail")
|
||||
def test_enabled_camera_collects_thumbnails(self, mock_create_thumbnail):
|
||||
"""A camera with object descriptions on still collects thumbnails."""
|
||||
mock_create_thumbnail.return_value = b"jpg"
|
||||
processor = self._make_processor(enabled=True)
|
||||
|
||||
self._update(processor)
|
||||
|
||||
mock_create_thumbnail.assert_called_once()
|
||||
self.assertEqual(len(processor.tracked_events["1234.5-abcdef"]), 1)
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Tests for manual event severity categorization.
|
||||
|
||||
Regression coverage for manual events created via the events API being
|
||||
categorized as detections when their label appears in both the alerts and
|
||||
detections label lists. Alert labels must win, matching how tracked objects
|
||||
are categorized, and labels in neither list must default to alerts so the
|
||||
historical behavior of the API is preserved.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.review.maintainer import ReviewSegmentMaintainer
|
||||
from frigate.review.types import SeverityEnum
|
||||
|
||||
BASE_CONFIG = """
|
||||
mqtt:
|
||||
enabled: False
|
||||
cameras:
|
||||
front_door:
|
||||
ffmpeg:
|
||||
inputs:
|
||||
- path: rtsp://10.0.0.1:554/video
|
||||
roles:
|
||||
- detect
|
||||
detect:
|
||||
width: 1920
|
||||
height: 1080
|
||||
fps: 5
|
||||
%s
|
||||
"""
|
||||
|
||||
|
||||
class TestManualEventSeverity(unittest.TestCase):
|
||||
def _make_maintainer(self, review_config: str = "") -> ReviewSegmentMaintainer:
|
||||
"""Build a maintainer without invoking __init__ (avoids needing ZMQ
|
||||
sockets, shared memory, and clip dirs). Only the config is read when
|
||||
categorizing a manual event label."""
|
||||
maintainer = ReviewSegmentMaintainer.__new__(ReviewSegmentMaintainer)
|
||||
maintainer.config = FrigateConfig.parse_yaml(BASE_CONFIG % review_config)
|
||||
return maintainer
|
||||
|
||||
def test_defaults_to_alert(self) -> None:
|
||||
maintainer = self._make_maintainer()
|
||||
|
||||
self.assertEqual(
|
||||
maintainer.get_manual_event_severity("front_door", "person"),
|
||||
SeverityEnum.alert,
|
||||
)
|
||||
|
||||
def test_unlisted_label_defaults_to_alert(self) -> None:
|
||||
maintainer = self._make_maintainer(
|
||||
"""
|
||||
review:
|
||||
detections:
|
||||
labels:
|
||||
- dog
|
||||
"""
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
maintainer.get_manual_event_severity("front_door", "pir_sensor"),
|
||||
SeverityEnum.alert,
|
||||
)
|
||||
|
||||
def test_detection_label_is_detection(self) -> None:
|
||||
maintainer = self._make_maintainer(
|
||||
"""
|
||||
review:
|
||||
alerts:
|
||||
labels:
|
||||
- person
|
||||
detections:
|
||||
labels:
|
||||
- pir_sensor
|
||||
"""
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
maintainer.get_manual_event_severity("front_door", "pir_sensor"),
|
||||
SeverityEnum.detection,
|
||||
)
|
||||
|
||||
def test_alert_label_wins_over_detection_label(self) -> None:
|
||||
maintainer = self._make_maintainer(
|
||||
"""
|
||||
review:
|
||||
alerts:
|
||||
labels:
|
||||
- person
|
||||
detections:
|
||||
labels:
|
||||
- person
|
||||
- dog
|
||||
"""
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
maintainer.get_manual_event_severity("front_door", "person"),
|
||||
SeverityEnum.alert,
|
||||
)
|
||||
|
||||
def test_sub_label_is_stripped_before_categorizing(self) -> None:
|
||||
maintainer = self._make_maintainer(
|
||||
"""
|
||||
review:
|
||||
alerts:
|
||||
labels:
|
||||
- person
|
||||
detections:
|
||||
labels:
|
||||
- person
|
||||
"""
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
maintainer.get_manual_event_severity("front_door", "person: Bob"),
|
||||
SeverityEnum.alert,
|
||||
)
|
||||
|
||||
def test_alert_label_is_detection_when_alerts_disabled(self) -> None:
|
||||
maintainer = self._make_maintainer(
|
||||
"""
|
||||
review:
|
||||
alerts:
|
||||
enabled: False
|
||||
labels:
|
||||
- person
|
||||
detections:
|
||||
labels:
|
||||
- person
|
||||
"""
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
maintainer.get_manual_event_severity("front_door", "person"),
|
||||
SeverityEnum.detection,
|
||||
)
|
||||
|
||||
def test_no_severity_when_alerts_disabled_and_label_not_a_detection(self) -> None:
|
||||
maintainer = self._make_maintainer(
|
||||
"""
|
||||
review:
|
||||
alerts:
|
||||
enabled: False
|
||||
detections:
|
||||
labels:
|
||||
- dog
|
||||
"""
|
||||
)
|
||||
|
||||
self.assertIsNone(
|
||||
maintainer.get_manual_event_severity("front_door", "pir_sensor")
|
||||
)
|
||||
@@ -0,0 +1,197 @@
|
||||
"""Tests for safe filesystem path construction."""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from frigate.const import TRIGGER_DIR
|
||||
from frigate.util.path import (
|
||||
get_trigger_thumbnail_path,
|
||||
is_contained_in,
|
||||
safe_join,
|
||||
sanitize_contained_path,
|
||||
sanitize_path_component,
|
||||
)
|
||||
|
||||
# Values that pathvalidate's sanitize_filename reduces to exactly "..", because
|
||||
# it strips reserved characters but leaves relative markers intact. nginx only
|
||||
# normalizes a bare ".." segment, so the decorated variants reach the app.
|
||||
DOT_DOT_VARIANTS = ["..", "..:", "..*", "..?", '.."', "..<", "..>", "..|", ".. ", " .."]
|
||||
|
||||
|
||||
class TestSanitizePathComponent(unittest.TestCase):
|
||||
def test_rejects_dot_dot_variants(self):
|
||||
for value in DOT_DOT_VARIANTS:
|
||||
with self.subTest(value=value):
|
||||
self.assertIsNone(sanitize_path_component(value))
|
||||
|
||||
def test_rejects_relative_markers_and_empty(self):
|
||||
for value in [".", "", None, " ", "/", "//", "\\"]:
|
||||
with self.subTest(value=value):
|
||||
self.assertIsNone(sanitize_path_component(value))
|
||||
|
||||
def test_strips_separators(self):
|
||||
component = sanitize_path_component("a/b/c")
|
||||
self.assertIsNotNone(component)
|
||||
self.assertNotIn("/", component)
|
||||
|
||||
def test_allows_ordinary_names(self):
|
||||
for value in ["model1", "front-door", "My Model", "café", "a.b_c-1"]:
|
||||
with self.subTest(value=value):
|
||||
self.assertEqual(sanitize_path_component(value), value)
|
||||
|
||||
|
||||
class TestSafeJoin(unittest.TestCase):
|
||||
base = "/media/frigate/clips"
|
||||
|
||||
def test_rejects_dot_dot_variants(self):
|
||||
for value in DOT_DOT_VARIANTS:
|
||||
with self.subTest(value=value):
|
||||
self.assertIsNone(safe_join(self.base, value))
|
||||
|
||||
def test_rejects_dot_dot_in_any_segment(self):
|
||||
self.assertIsNone(safe_join(self.base, "model", "dataset", ".."))
|
||||
self.assertIsNone(safe_join(self.base, "..", "dataset", ".."))
|
||||
|
||||
def test_result_stays_inside_base(self):
|
||||
for value in ["model1", "a/../..", "....//", "..\\..", "%2e%2e"]:
|
||||
with self.subTest(value=value):
|
||||
joined = safe_join(self.base, value)
|
||||
|
||||
if joined is not None:
|
||||
self.assertTrue(is_contained_in(joined, self.base))
|
||||
|
||||
def test_joins_multiple_segments(self):
|
||||
self.assertEqual(
|
||||
safe_join(self.base, "model1", "dataset", "none"),
|
||||
"/media/frigate/clips/model1/dataset/none",
|
||||
)
|
||||
|
||||
def test_rejects_empty_segment(self):
|
||||
self.assertIsNone(safe_join(self.base, "model1", "", "none"))
|
||||
|
||||
|
||||
class TestIsContainedIn(unittest.TestCase):
|
||||
def test_rejects_sibling_sharing_a_name_prefix(self):
|
||||
self.assertFalse(
|
||||
is_contained_in("/media/frigate/clips_evil/x.webp", "/media/frigate/clips")
|
||||
)
|
||||
|
||||
def test_accepts_base_itself_and_children(self):
|
||||
self.assertTrue(is_contained_in("/media/frigate/clips", "/media/frigate/clips"))
|
||||
self.assertTrue(
|
||||
is_contained_in("/media/frigate/clips/a/b.webp", "/media/frigate/clips")
|
||||
)
|
||||
|
||||
def test_rejects_parent(self):
|
||||
self.assertFalse(is_contained_in("/media/frigate", "/media/frigate/clips"))
|
||||
|
||||
def test_handles_a_root_base(self):
|
||||
# A prefix test would compare against "//" here and wrongly report that
|
||||
# the root directory contains nothing.
|
||||
self.assertTrue(is_contained_in("/child", "/"))
|
||||
self.assertEqual(safe_join("/", "child"), "/child")
|
||||
|
||||
def test_rejects_uncomparable_paths(self):
|
||||
self.assertFalse(is_contained_in("relative/x", "/media/frigate/clips"))
|
||||
|
||||
|
||||
class TestSanitizeContainedPath(unittest.TestCase):
|
||||
base = "/media/frigate/clips"
|
||||
|
||||
def test_rejects_dot_dot_anywhere(self):
|
||||
for value in [
|
||||
"/media/frigate/clips/../../etc/passwd",
|
||||
"clips\\..\\..\\etc/passwd",
|
||||
"/media/frigate/clips/a/../../../x",
|
||||
]:
|
||||
with self.subTest(value=value):
|
||||
self.assertIsNone(sanitize_contained_path(value, self.base))
|
||||
|
||||
def test_rejects_sibling_sharing_a_name_prefix(self):
|
||||
self.assertIsNone(
|
||||
sanitize_contained_path("/media/frigate/clips_evil/x.webp", self.base)
|
||||
)
|
||||
|
||||
def test_rejects_outside_base(self):
|
||||
self.assertIsNone(sanitize_contained_path("/etc/passwd", self.base))
|
||||
|
||||
def test_rejects_empty(self):
|
||||
self.assertIsNone(sanitize_contained_path("", self.base))
|
||||
self.assertIsNone(sanitize_contained_path(None, self.base))
|
||||
|
||||
def test_keeps_a_valid_nested_path(self):
|
||||
self.assertEqual(
|
||||
sanitize_contained_path("/media/frigate/clips/a/b.webp", self.base),
|
||||
"/media/frigate/clips/a/b.webp",
|
||||
)
|
||||
|
||||
|
||||
class TestTriggerThumbnailPath(unittest.TestCase):
|
||||
def test_stays_inside_the_trigger_dir(self):
|
||||
for camera, data in [
|
||||
("cam", "../../../../etc/passwd"),
|
||||
("cam", "../../../../config/config.yml"),
|
||||
("cam", "normal-event-id"),
|
||||
]:
|
||||
with self.subTest(camera=camera, data=data):
|
||||
path = get_trigger_thumbnail_path(camera, data)
|
||||
|
||||
self.assertIsNotNone(path)
|
||||
self.assertTrue(is_contained_in(path, TRIGGER_DIR))
|
||||
|
||||
def test_rejects_traversal_camera_names(self):
|
||||
for camera in DOT_DOT_VARIANTS:
|
||||
with self.subTest(camera=camera):
|
||||
self.assertIsNone(get_trigger_thumbnail_path(camera, "data"))
|
||||
|
||||
def test_builds_the_expected_path(self):
|
||||
self.assertEqual(
|
||||
get_trigger_thumbnail_path("front_door", "abc"),
|
||||
os.path.join(TRIGGER_DIR, "front_door", "abc.webp"),
|
||||
)
|
||||
|
||||
|
||||
class TestRmtreeContainment(unittest.TestCase):
|
||||
"""A recursive delete built through safe_join must not reach a parent.
|
||||
|
||||
shutil.rmtree on a path ending in ".." deletes the parent's contents before
|
||||
failing on the final rmdir, so the guard has to run before the call.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.root = tempfile.mkdtemp()
|
||||
self.clips = os.path.join(self.root, "clips")
|
||||
os.makedirs(os.path.join(self.clips, "model1"))
|
||||
os.makedirs(os.path.join(self.root, "recordings"))
|
||||
|
||||
with open(os.path.join(self.root, "recordings", "seg.mp4"), "w") as f:
|
||||
f.write("recording")
|
||||
|
||||
def tearDown(self):
|
||||
shutil.rmtree(self.root, ignore_errors=True)
|
||||
|
||||
def test_traversal_name_never_yields_a_path_to_delete(self):
|
||||
for value in DOT_DOT_VARIANTS:
|
||||
with self.subTest(value=value):
|
||||
self.assertIsNone(safe_join(self.clips, value))
|
||||
|
||||
self.assertTrue(
|
||||
os.path.exists(os.path.join(self.root, "recordings", "seg.mp4"))
|
||||
)
|
||||
|
||||
def test_ordinary_name_still_deletes_its_own_directory(self):
|
||||
target = safe_join(self.clips, "model1")
|
||||
self.assertIsNotNone(target)
|
||||
|
||||
shutil.rmtree(target)
|
||||
|
||||
self.assertFalse(os.path.exists(os.path.join(self.clips, "model1")))
|
||||
self.assertTrue(
|
||||
os.path.exists(os.path.join(self.root, "recordings", "seg.mp4"))
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
@@ -0,0 +1,150 @@
|
||||
"""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()
|
||||
@@ -54,6 +54,11 @@ 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
|
||||
@@ -149,7 +154,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.obj_data["label"],
|
||||
self.thumbnail_attributes,
|
||||
self.thumbnail_data,
|
||||
obj_data,
|
||||
self.camera_config.frame_shape,
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ from frigate.object_detection.base import ObjectDetectProcess
|
||||
|
||||
class StatsTrackingTypes(TypedDict):
|
||||
camera_metrics: dict[str, CameraMetrics]
|
||||
embeddings_metrics: DataProcessorMetrics | None
|
||||
embeddings_metrics: DataProcessorMetrics
|
||||
detectors: dict[str, ObjectDetectProcess]
|
||||
started: int
|
||||
latest_frigate_version: str
|
||||
|
||||
@@ -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"] = ["embeddings", "descriptions", "chat"]
|
||||
genai["roles"] = ["descriptions", "chat"]
|
||||
new_config["genai"] = {"default": genai}
|
||||
|
||||
# Remove deprecated sync_recordings from global record config
|
||||
|
||||
+5
-13
@@ -67,7 +67,7 @@ def has_better_attr(current_thumb, new_obj, attr_label) -> bool:
|
||||
|
||||
|
||||
def is_better_thumbnail(
|
||||
label: str,
|
||||
label_attributes: list[str],
|
||||
current_thumb: dict[str, Any],
|
||||
new_obj: dict[str, Any],
|
||||
frame_shape: tuple[int, int],
|
||||
@@ -76,20 +76,12 @@ def is_better_thumbnail(
|
||||
# cutoff images are less ideal, but they should also be smaller?
|
||||
# better scores are obviously better too
|
||||
|
||||
# check face on person
|
||||
if label == "person":
|
||||
if has_better_attr(current_thumb, new_obj, "face"):
|
||||
for attr_label in label_attributes:
|
||||
if has_better_attr(current_thumb, new_obj, attr_label):
|
||||
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
|
||||
|
||||
# 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"]]):
|
||||
# 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"]]):
|
||||
return False
|
||||
|
||||
# if the new_thumb is on an edge, and the current thumb is not
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Helpers for building filesystem paths out of user supplied values."""
|
||||
|
||||
import os
|
||||
|
||||
from pathvalidate import ValidationError, sanitize_filename, sanitize_filepath
|
||||
|
||||
from frigate.const import TRIGGER_DIR
|
||||
|
||||
# Components that name a directory relative to its parent instead of a child.
|
||||
# pathvalidate strips separators and reserved characters but leaves these
|
||||
# intact, and it collapses values like "..:" down to "..", so they have to be
|
||||
# rejected after sanitizing rather than before.
|
||||
RELATIVE_COMPONENTS = {"", ".", ".."}
|
||||
|
||||
|
||||
def sanitize_path_component(value: str | None) -> str | None:
|
||||
"""Reduce a user supplied value to a single path component.
|
||||
|
||||
Args:
|
||||
value: The untrusted value, such as a path parameter or body field
|
||||
|
||||
Returns:
|
||||
A component that is safe to join onto a base directory, or None when
|
||||
nothing usable remains so the caller can reject the request.
|
||||
"""
|
||||
if not value:
|
||||
return None
|
||||
|
||||
try:
|
||||
component = sanitize_filename(value)
|
||||
except (ValidationError, ValueError):
|
||||
return None
|
||||
|
||||
if component.strip() in RELATIVE_COMPONENTS:
|
||||
return None
|
||||
|
||||
if os.sep in component or (os.altsep and os.altsep in component):
|
||||
return None
|
||||
|
||||
return component
|
||||
|
||||
|
||||
def is_contained_in(path: str, base: str) -> bool:
|
||||
"""Check that a path sits inside a base directory.
|
||||
|
||||
Compares whole path components, so a sibling directory that merely shares a
|
||||
name prefix with base is not treated as contained.
|
||||
"""
|
||||
resolved = os.path.normpath(path)
|
||||
root = os.path.normpath(base)
|
||||
|
||||
try:
|
||||
# commonpath compares components, and unlike a prefix test it stays
|
||||
# correct for a base that already ends in a separator such as "/".
|
||||
return os.path.commonpath([resolved, root]) == root
|
||||
except ValueError:
|
||||
# Raised when the paths cannot be compared, such as one relative and
|
||||
# one absolute, or two different Windows drives.
|
||||
return False
|
||||
|
||||
|
||||
def safe_join(base: str, *parts: str | None) -> str | None:
|
||||
"""Join user supplied parts beneath a trusted base directory.
|
||||
|
||||
Args:
|
||||
base: Trusted base directory the result must stay inside of
|
||||
parts: Untrusted values, each becoming one path component
|
||||
|
||||
Returns:
|
||||
The joined path, or None if any part is unusable or the result would
|
||||
land outside base.
|
||||
"""
|
||||
components: list[str] = []
|
||||
|
||||
for part in parts:
|
||||
component = sanitize_path_component(part)
|
||||
|
||||
if component is None:
|
||||
return None
|
||||
|
||||
components.append(component)
|
||||
|
||||
resolved = os.path.normpath(os.path.join(base, *components))
|
||||
|
||||
# normpath rather than realpath so symlinked media roots keep working; the
|
||||
# per component checks above are what actually prevent traversal.
|
||||
if not is_contained_in(resolved, base):
|
||||
return None
|
||||
|
||||
return resolved
|
||||
|
||||
|
||||
def sanitize_contained_path(path: str | None, base: str) -> str | None:
|
||||
"""Validate a whole user supplied path that must already sit under base.
|
||||
|
||||
Unlike safe_join this keeps the directory structure the caller sent, so it
|
||||
suits values that name an existing file rather than one component.
|
||||
|
||||
Args:
|
||||
path: The untrusted path
|
||||
base: Directory the path has to stay inside of
|
||||
|
||||
Returns:
|
||||
The sanitized path, or None if it is unusable or escapes base.
|
||||
"""
|
||||
if not path:
|
||||
return None
|
||||
|
||||
# sanitize_filepath normalizes "\" to "/" but leaves ".." intact, so a path
|
||||
# like "clips\..\..\etc/passwd" would pass the containment check yet still
|
||||
# escape once resolved. A valid path here never uses "..".
|
||||
if ".." in path:
|
||||
return None
|
||||
|
||||
sanitized = sanitize_filepath(path)
|
||||
|
||||
if not is_contained_in(sanitized, base):
|
||||
return None
|
||||
|
||||
return sanitized
|
||||
|
||||
|
||||
def get_trigger_thumbnail_path(camera_name: str, data: str) -> str | None:
|
||||
"""Path of the thumbnail stored for a semantic search trigger.
|
||||
|
||||
Args:
|
||||
camera_name: Camera the trigger belongs to
|
||||
data: The trigger's data value, which is free-form text supplied by the
|
||||
client and persisted verbatim
|
||||
|
||||
Returns:
|
||||
The thumbnail path, or None if it cannot be built safely.
|
||||
"""
|
||||
return safe_join(TRIGGER_DIR, camera_name, f"{data}.webp")
|
||||
+4
-14
@@ -216,20 +216,10 @@ def process_frames(
|
||||
|
||||
# remove license_plate from attributes if this camera is a dedicated LPR cam
|
||||
if camera_config.type == CameraTypeEnum.lpr:
|
||||
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
|
||||
|
||||
attributes_map = {
|
||||
label: [attr for attr in attributes if attr != "license_plate"]
|
||||
for label, attributes in model_config.attributes_map.items()
|
||||
}
|
||||
all_attributes = [
|
||||
attr for attr in model_config.all_attributes if attr != "license_plate"
|
||||
]
|
||||
|
||||
@@ -94,6 +94,7 @@ SPEC_SERVERS = [
|
||||
PUBLIC = "public"
|
||||
AUTHENTICATED = "any"
|
||||
CAMERA = "camera"
|
||||
ALL_CAMERAS = "all_cameras"
|
||||
ADMIN = "admin"
|
||||
|
||||
ADMIN_SCHEME = "frigateAdminAuth"
|
||||
@@ -128,6 +129,7 @@ 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.",
|
||||
}
|
||||
|
||||
@@ -197,6 +199,8 @@ 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:
|
||||
@@ -254,6 +258,8 @@ 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:
|
||||
@@ -337,8 +343,8 @@ def security_for(level: str) -> list:
|
||||
return []
|
||||
if level == ADMIN:
|
||||
return [{ADMIN_SCHEME: []}]
|
||||
# AUTHENTICATED and CAMERA both require any authenticated session; the
|
||||
# camera-specific scoping is conveyed in the note and x-required-role.
|
||||
# AUTHENTICATED, CAMERA and ALL_CAMERAS all require any authenticated
|
||||
# session; the camera scoping is conveyed in the note and x-required-role.
|
||||
return [{USER_SCHEME: []}]
|
||||
|
||||
|
||||
|
||||
@@ -54,8 +54,11 @@ export class FrigateApp {
|
||||
});
|
||||
|
||||
await this.ws.install(this.page);
|
||||
await this.media.install();
|
||||
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();
|
||||
}
|
||||
|
||||
/** Navigate to a page. Always call installDefaults() first. */
|
||||
|
||||
@@ -52,6 +52,9 @@ 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: {
|
||||
|
||||
@@ -249,8 +249,9 @@ export class MediaMocker {
|
||||
}),
|
||||
);
|
||||
|
||||
// Event thumbnails
|
||||
await this.page.route("**/api/events/*/thumbnail.jpg**", (route) =>
|
||||
// Event thumbnails. The explore grid and detail dialog request .webp,
|
||||
// everything else requests .jpg.
|
||||
await this.page.route("**/api/events/*/thumbnail.{jpg,webp}**", (route) =>
|
||||
route.fulfill({
|
||||
contentType: "image/png",
|
||||
body: PLACEHOLDER_PNG,
|
||||
|
||||
@@ -263,3 +263,71 @@ 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();
|
||||
});
|
||||
});
|
||||
+133
-11
@@ -1,4 +1,4 @@
|
||||
import { test, expect } from "../fixtures/frigate-test";
|
||||
import { test, expect, type FrigateApp } from "../fixtures/frigate-test";
|
||||
import {
|
||||
expectBodyInteractive,
|
||||
waitForBodyInteractive,
|
||||
@@ -575,7 +575,7 @@ test.describe("Multi-Review Export @high", () => {
|
||||
await expect(dialog.getByText(/None/)).toBeVisible();
|
||||
});
|
||||
|
||||
test("starting an export posts the expected payload and navigates to the case", async ({
|
||||
test("starting an export posts the expected payload and stays on the review page", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
test.skip(frigateApp.isMobile, "Desktop multi-select flow");
|
||||
@@ -673,9 +673,15 @@ test.describe("Multi-Review Export @high", () => {
|
||||
"mex-review-002",
|
||||
]);
|
||||
|
||||
await expect(frigateApp.page).toHaveURL(/caseId=new-case-xyz/, {
|
||||
timeout: 5_000,
|
||||
});
|
||||
// Creating a case must not pull the user off the review they were
|
||||
// working through — the case is offered as a link on the toast instead.
|
||||
const viewCase = frigateApp.page.getByRole("link", { name: /view/i });
|
||||
await expect(viewCase).toBeVisible({ timeout: 5_000 });
|
||||
await expect(viewCase).toHaveAttribute(
|
||||
"href",
|
||||
/export\?caseId=new-case-xyz$/,
|
||||
);
|
||||
await expect(frigateApp.page).toHaveURL(/\/review(\?|$)/);
|
||||
});
|
||||
|
||||
test("mobile opens a drawer (not a dialog) for the multi-review export flow", async ({
|
||||
@@ -834,12 +840,128 @@ test.describe("Multi-Review Export @high", () => {
|
||||
expect(payload.new_case_description).toBeUndefined();
|
||||
expect(payload.items).toHaveLength(2);
|
||||
|
||||
// Navigate should hit /export. useSearchEffect consumes the caseId
|
||||
// query param and strips it once the case is found in the cases list,
|
||||
// so we assert on the path, not the query string.
|
||||
await expect(frigateApp.page).toHaveURL(/\/export(\?|$)/, {
|
||||
timeout: 5_000,
|
||||
});
|
||||
// Attaching to a case leaves the user on the review page; the case is
|
||||
// reachable from the toast action.
|
||||
const viewCase = frigateApp.page.getByRole("link", { name: /view/i });
|
||||
await expect(viewCase).toBeVisible({ timeout: 5_000 });
|
||||
await expect(viewCase).toHaveAttribute(
|
||||
"href",
|
||||
/export\?caseId=existing-case-abc$/,
|
||||
);
|
||||
await expect(frigateApp.page).toHaveURL(/\/review(\?|$)/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Multi-Camera Export from History @high", () => {
|
||||
// The recording view seeds the multi-camera range around the playback
|
||||
// position, so the deep link has to land close to the live edge for the
|
||||
// seeded end to run past the end of the timeline.
|
||||
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: [] }),
|
||||
);
|
||||
|
||||
await frigateApp.goto(`/review?timestamp=front_door_${playbackTime}`);
|
||||
}
|
||||
|
||||
// Desktop opens the export form in a dialog from the Actions menu; mobile
|
||||
// opens the same form inside the settings drawer.
|
||||
async function openMultiCameraTab(frigateApp: FrigateApp) {
|
||||
await openRecordingView(frigateApp);
|
||||
|
||||
if (frigateApp.isMobile) {
|
||||
await frigateApp.page
|
||||
.getByRole("button", { name: /filters/i })
|
||||
.first()
|
||||
.click({ timeout: 15_000 });
|
||||
await frigateApp.page.getByRole("button", { name: /^export$/i }).click();
|
||||
} else {
|
||||
await frigateApp.page
|
||||
.getByRole("button", { name: /actions/i })
|
||||
.click({ timeout: 15_000 });
|
||||
await frigateApp.page.getByRole("menuitem", { name: /export/i }).click();
|
||||
}
|
||||
|
||||
const form = frigateApp.page.getByRole("dialog");
|
||||
await expect(form).toBeVisible({ timeout: 5_000 });
|
||||
await form.getByRole("tab", { name: /multi-camera/i }).click();
|
||||
|
||||
return form;
|
||||
}
|
||||
|
||||
test("timeline selection renders both export handles on the timeline", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.installDefaults();
|
||||
const form = await openMultiCameraTab(frigateApp);
|
||||
|
||||
await form
|
||||
.getByRole("button", { name: "Select from Timeline" })
|
||||
.click({ timeout: 5_000 });
|
||||
await expect(form).toBeHidden({ timeout: 5_000 });
|
||||
|
||||
// A range seeded past the end of the timeline has no segment to anchor
|
||||
// to, which leaves the handle unpositioned at the top of the timeline
|
||||
// with an empty label until it is dragged.
|
||||
for (const handle of [".export-start", ".export-end"]) {
|
||||
const locator = frigateApp.page.locator(handle);
|
||||
await expect(locator).toHaveText(/\d{1,2}:\d{2}/, { timeout: 5_000 });
|
||||
await expect(locator).not.toHaveAttribute("style", /top:\s*0px/);
|
||||
}
|
||||
});
|
||||
|
||||
test("the time range picker opens without a configured timezone", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.installDefaults();
|
||||
const form = await openMultiCameraTab(frigateApp);
|
||||
|
||||
// ui.timezone is null until the user sets one, which used to take the
|
||||
// whole page down when the calendar worked out its disabled days
|
||||
await form
|
||||
.getByRole("button", { name: /^start time$/i })
|
||||
.click({ timeout: 5_000 });
|
||||
|
||||
await expect(
|
||||
frigateApp.page.getByRole("button", { name: /previous month/i }),
|
||||
).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
|
||||
test("canceling timeline selection reopens the form with the case intact", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.installDefaults();
|
||||
const form = await openMultiCameraTab(frigateApp);
|
||||
|
||||
await form
|
||||
.getByPlaceholder(/new case name/i)
|
||||
.fill("Incident 7", { timeout: 5_000 });
|
||||
await form
|
||||
.getByPlaceholder(/case description/i)
|
||||
.fill("Front gate follow-up");
|
||||
|
||||
await form.getByRole("button", { name: "Select from Timeline" }).click();
|
||||
await expect(form).toBeHidden({ timeout: 5_000 });
|
||||
|
||||
await frigateApp.page.getByRole("button", { name: /cancel/i }).click();
|
||||
|
||||
await expect(form).toBeVisible({ timeout: 5_000 });
|
||||
await expect(
|
||||
form.getByRole("tab", { name: /multi-camera/i }),
|
||||
).toHaveAttribute("aria-selected", "true");
|
||||
await expect(form.getByPlaceholder(/new case name/i)).toHaveValue(
|
||||
"Incident 7",
|
||||
);
|
||||
await expect(form.getByPlaceholder(/case description/i)).toHaveValue(
|
||||
"Front gate follow-up",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,7 @@
|
||||
<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>
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
<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>
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
"cough": "سُعَال",
|
||||
"throat_clearing": "تَنْحِيم",
|
||||
"sneeze": "عُطَاس",
|
||||
"sniff": "شَمَّ",
|
||||
"sniff": "كشف",
|
||||
"run": "رَكْض",
|
||||
"shuffle": "خَلْط",
|
||||
"footsteps": "خُطُوَات",
|
||||
@@ -161,5 +161,43 @@
|
||||
"rumble": "الحلبة",
|
||||
"skateboard": "لوح تزلج",
|
||||
"echo": "صدى الصوت",
|
||||
"noise": "ازعاج"
|
||||
"noise": "ازعاج",
|
||||
"duck": "بطة",
|
||||
"quack": "صوت البطة",
|
||||
"goose": "وزة",
|
||||
"honk": "نعيق الأوز",
|
||||
"wild_animals": "حيوانات برية",
|
||||
"roar": "زئير",
|
||||
"chirp": "زقزقة",
|
||||
"pigeon": "حمامة",
|
||||
"crow": "غراب",
|
||||
"roaring_cats": "قطط هائجة",
|
||||
"squawk": "نعيق",
|
||||
"coo": "هديل الحمام",
|
||||
"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": "شوكة رنانة"
|
||||
}
|
||||
@@ -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 @@
|
||||
{}
|
||||
@@ -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 @@
|
||||
{}
|
||||
@@ -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 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1,502 @@
|
||||
{
|
||||
"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": "Вібрацыя"
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
{
|
||||
"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": "Захавана - пакіньце пустым, каб не мяняць"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"form": {
|
||||
"user": "Імя карыстальніка",
|
||||
"password": "Пароль",
|
||||
"login": "Уваход",
|
||||
"firstTimeLogin": "Уваходзіце ўпершыню? Даныя карыстальніка друкуюцца ў журналах Frigate.",
|
||||
"errors": {
|
||||
"usernameRequired": "Патрабуецца імя карыстальніка",
|
||||
"passwordRequired": "Патрабуецца пароль",
|
||||
"rateLimit": "Перавышаны ліміт запытаў. Паспрабуйце пазней.",
|
||||
"loginFailed": "Не ўдалося ўвайсці",
|
||||
"unknownError": "Невядомая памылка. Праверце журналы.",
|
||||
"webUnknownError": "Невядомая памылка. Праверце журналы кансолі."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"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": "Шляхі"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
{
|
||||
"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": "Для гэтай камеры паменшаных выяў не знойдзена"
|
||||
}
|
||||
}
|
||||
Loaded 100 of 440 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user