mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-25 02:36:52 +03:00
Compare commits
45
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2aa0ee29a | ||
|
|
9c7e32d0d6 | ||
|
|
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 |
+1
-1
@@ -24,7 +24,7 @@ yell
|
||||
sigh
|
||||
singing
|
||||
choir
|
||||
sodeling
|
||||
yodeling
|
||||
chant
|
||||
mantra
|
||||
child_singing
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -65,9 +65,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 +133,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.
|
||||
|
||||
Vendored
+9
-6
@@ -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':
|
||||
|
||||
+25
-3
@@ -971,6 +971,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 +985,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 +1253,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",
|
||||
)
|
||||
|
||||
+132
-63
@@ -11,7 +11,6 @@ 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
|
||||
|
||||
@@ -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 = {
|
||||
@@ -729,8 +768,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 +799,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 +873,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 +894,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 +931,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 +956,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 +979,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 +1028,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 +1041,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 +1075,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 +1122,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 +1149,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 +1190,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 +1232,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 +1257,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 +1284,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 +1307,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 +1325,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}."
|
||||
)
|
||||
|
||||
+6
-11
@@ -13,7 +13,7 @@ from pathlib import Path
|
||||
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
|
||||
|
||||
@@ -72,6 +72,7 @@ from frigate.record.export import (
|
||||
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 +130,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,
|
||||
|
||||
@@ -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] = {}
|
||||
|
||||
@@ -396,7 +396,6 @@ class CameraState:
|
||||
"attributes": new_obj.obj_data["attributes"],
|
||||
"current_estimated_speed": 0,
|
||||
"velocity_angle": 0,
|
||||
"path_data": [],
|
||||
"recognized_license_plate": None,
|
||||
"recognized_license_plate_score": None,
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
|
||||
+63
-4
@@ -3,6 +3,8 @@
|
||||
import errno
|
||||
import json
|
||||
import logging
|
||||
import queue
|
||||
import socket
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
@@ -74,6 +76,9 @@ _WS_VIEWER_TOPICS = frozenset(
|
||||
# Camera-scoped command topics a camera-authorized (non-admin) user may send.
|
||||
_WS_CAMERA_COMMAND_TOPICS = frozenset({"ptz"})
|
||||
|
||||
# Max outbound messages waiting on a client's writer thread.
|
||||
WS_MAX_PENDING_MESSAGES = 256
|
||||
|
||||
|
||||
def _check_ws_authorization(
|
||||
topic: str,
|
||||
@@ -446,6 +451,63 @@ def _materialize_for_ws(
|
||||
|
||||
|
||||
class WebSocket(WebSocket_): # type: ignore[misc]
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self._send_queue: queue.Queue[tuple[Any, bool] | None] = queue.Queue(
|
||||
maxsize=WS_MAX_PENDING_MESSAGES
|
||||
)
|
||||
self._writer: threading.Thread | None = None
|
||||
self._aborted = False
|
||||
|
||||
def opened(self) -> None:
|
||||
# every client gets its own writer so a client that stops reading only
|
||||
# blocks itself, never the thread that called publish()
|
||||
self._writer = threading.Thread(
|
||||
target=self._drain_send_queue, name="ws_writer", daemon=True
|
||||
)
|
||||
self._writer.start()
|
||||
|
||||
def send(self, payload: Any, binary: bool = False) -> None:
|
||||
try:
|
||||
self._send_queue.put_nowait((payload, binary))
|
||||
except queue.Full:
|
||||
self._abort("Websocket client is not keeping up, disconnecting it")
|
||||
|
||||
def _drain_send_queue(self) -> None:
|
||||
while True:
|
||||
item = self._send_queue.get()
|
||||
if item is None or self.terminated or self.sock is None:
|
||||
return
|
||||
try:
|
||||
super().send(*item)
|
||||
except Exception:
|
||||
self._abort()
|
||||
return
|
||||
|
||||
def _abort(self, reason: str | None = None) -> None:
|
||||
# publish() keeps hitting a full queue until the manager thread removes
|
||||
# the connection, so only act (and log) the first time
|
||||
if self._aborted:
|
||||
return
|
||||
self._aborted = True
|
||||
if reason:
|
||||
logger.warning(reason)
|
||||
|
||||
# shutdown rather than close so the ws4py manager thread sees EOF and
|
||||
# runs its normal unregister/terminate; this also unblocks a stuck sendall
|
||||
sock = self.sock
|
||||
if sock is not None:
|
||||
try:
|
||||
sock.shutdown(socket.SHUT_RDWR)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def closed(self, code: int, reason: str | None = None) -> None:
|
||||
try:
|
||||
self._send_queue.put_nowait(None)
|
||||
except queue.Full:
|
||||
pass
|
||||
|
||||
def unhandled_error(self, error: Any) -> None:
|
||||
"""
|
||||
Handles the unfriendly socket closures on the server side
|
||||
@@ -580,10 +642,7 @@ class WebSocketClient(Communicator):
|
||||
)
|
||||
if message is None:
|
||||
continue
|
||||
try:
|
||||
ws.send(message)
|
||||
except (ConnectionResetError, BrokenPipeError, ValueError):
|
||||
pass
|
||||
ws.send(message)
|
||||
|
||||
def stop(self) -> None:
|
||||
if self.websocket_server is not None:
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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))
|
||||
@@ -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,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()
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Outbound websocket sends must never block the thread calling publish()."""
|
||||
|
||||
import socket
|
||||
import threading
|
||||
import unittest
|
||||
|
||||
from frigate.comms.ws import WS_MAX_PENDING_MESSAGES, WebSocket
|
||||
|
||||
|
||||
class _FakeSock:
|
||||
"""Socket stand-in; ``block`` makes sendall hang like a client that stopped reading."""
|
||||
|
||||
def __init__(self, block: bool = False) -> None:
|
||||
self.block = block
|
||||
self.released = threading.Event()
|
||||
self.shutdown_called = threading.Event()
|
||||
self.frames: list[bytes] = []
|
||||
|
||||
def sendall(self, data: bytes) -> None:
|
||||
if self.block and not self.released.is_set():
|
||||
self.released.wait(timeout=10)
|
||||
raise BrokenPipeError()
|
||||
self.frames.append(data)
|
||||
|
||||
def shutdown(self, how: int) -> None:
|
||||
assert how == socket.SHUT_RDWR
|
||||
self.shutdown_called.set()
|
||||
self.released.set()
|
||||
|
||||
def close(self) -> None:
|
||||
pass
|
||||
|
||||
def fileno(self) -> int:
|
||||
return 99
|
||||
|
||||
|
||||
def _wait_for(predicate, timeout: float = 2.0) -> bool:
|
||||
deadline = threading.Event()
|
||||
for _ in range(int(timeout / 0.01)):
|
||||
if predicate():
|
||||
return True
|
||||
deadline.wait(0.01)
|
||||
return predicate()
|
||||
|
||||
|
||||
class TestWebSocketSendQueue(unittest.TestCase):
|
||||
def _open(self, sock: _FakeSock) -> WebSocket:
|
||||
ws = WebSocket(sock)
|
||||
ws.opened()
|
||||
return ws
|
||||
|
||||
def test_stalled_client_does_not_block_publisher(self):
|
||||
sock = _FakeSock(block=True)
|
||||
ws = self._open(sock)
|
||||
|
||||
def publish_many():
|
||||
for i in range(WS_MAX_PENDING_MESSAGES + 5):
|
||||
ws.send(f"message {i}")
|
||||
|
||||
publisher = threading.Thread(target=publish_many, daemon=True)
|
||||
publisher.start()
|
||||
publisher.join(timeout=2.0)
|
||||
|
||||
self.assertFalse(publisher.is_alive(), "publish() blocked on a stalled client")
|
||||
self.assertTrue(
|
||||
sock.shutdown_called.wait(timeout=2.0),
|
||||
"a client that cannot keep up should be disconnected",
|
||||
)
|
||||
|
||||
def test_overflow_warns_and_shuts_down_once(self):
|
||||
sock = _FakeSock(block=True)
|
||||
ws = self._open(sock)
|
||||
shutdown_calls = []
|
||||
original_shutdown = sock.shutdown
|
||||
sock.shutdown = lambda how: (shutdown_calls.append(how), original_shutdown(how))
|
||||
|
||||
with self.assertLogs("frigate.comms.ws", level="WARNING") as logs:
|
||||
# keep publishing after overflow, as the dispatcher does until the
|
||||
# manager thread removes the connection
|
||||
for i in range(WS_MAX_PENDING_MESSAGES * 3):
|
||||
ws.send(f"message {i}")
|
||||
|
||||
self.assertEqual(len(logs.output), 1)
|
||||
self.assertEqual(len(shutdown_calls), 1)
|
||||
|
||||
def test_messages_delivered_in_order(self):
|
||||
sock = _FakeSock()
|
||||
ws = self._open(sock)
|
||||
for i in range(3):
|
||||
ws.send(f"message {i}")
|
||||
|
||||
self.assertTrue(_wait_for(lambda: len(sock.frames) == 3))
|
||||
for i, frame in enumerate(sock.frames):
|
||||
self.assertIn(f"message {i}".encode(), frame)
|
||||
self.assertFalse(sock.shutdown_called.is_set())
|
||||
|
||||
def test_closed_stops_writer_thread(self):
|
||||
sock = _FakeSock()
|
||||
ws = self._open(sock)
|
||||
writer = ws._writer
|
||||
ws.closed(1000, "bye")
|
||||
writer.join(timeout=2.0)
|
||||
self.assertFalse(writer.is_alive())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -164,7 +164,6 @@ class TrackedObject:
|
||||
"attributes": obj_data["attributes"],
|
||||
"current_estimated_speed": self.current_estimated_speed,
|
||||
"velocity_angle": self.velocity_angle,
|
||||
"path_data": self.path_data.copy(),
|
||||
"recognized_license_plate": obj_data.get(
|
||||
"recognized_license_plate"
|
||||
),
|
||||
|
||||
+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
|
||||
|
||||
@@ -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")
|
||||
@@ -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: []}]
|
||||
|
||||
|
||||
|
||||
+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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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": "شوكة رنانة"
|
||||
}
|
||||
|
||||
@@ -71,9 +71,9 @@
|
||||
"batchSuccess_other": "S'han iniciat {{count}} exportacions. Obrint el cas ara.",
|
||||
"batchPartial": "S'han iniciat {{successful}} de {{total}} exportacions. Càmeres fallides: {{failedCameras}}",
|
||||
"batchFailed": "No s'han pogut iniciar {{total}} exportacions. Càmeres fallides: {{failedCameras}}",
|
||||
"batchQueuedSuccess_one": "Exporta a la cua 1. Obrint el cas ara.",
|
||||
"batchQueuedSuccess_many": "{{count}} exportacions a la cua. Obrint el cas ara.",
|
||||
"batchQueuedSuccess_other": "{{count}} exportacions a la cua. Obrint el cas ara.",
|
||||
"batchQueuedSuccess_one": "Exporta a la cua 1.",
|
||||
"batchQueuedSuccess_many": "{{count}} exportacions a la cua.",
|
||||
"batchQueuedSuccess_other": "{{count}} exportacions a la cua.",
|
||||
"batchQueuedPartial": "{{successful}} de {{total}} exportacions a la cua. Càmeres fallides: {{failedCameras}}",
|
||||
"batchQueueFailed": "No s'han pogut posar a la cua {{total}} exportacions. Càmeres fallides: {{failedCameras}}"
|
||||
},
|
||||
@@ -132,9 +132,9 @@
|
||||
"exportButton_other": "Exporta {{count}} ressenyes",
|
||||
"exportingButton": "S'està exportant...",
|
||||
"toast": {
|
||||
"started_one": "S'ha iniciat l'exportació 1. Obrint el cas ara.",
|
||||
"started_many": "S'han iniciat {{count}} exportacions. Obrint el cas ara.",
|
||||
"started_other": "S'han iniciat {{count}} exportacions. Obrint el cas ara.",
|
||||
"started_one": "S'ha iniciat l'exportació 1.",
|
||||
"started_many": "S'han iniciat {{count}} exportacions.",
|
||||
"started_other": "S'han iniciat {{count}} exportacions.",
|
||||
"startedNoCase_one": "S'ha iniciat l'exportació 1.",
|
||||
"startedNoCase_many": "S'han iniciat {{count}} exportacions.",
|
||||
"startedNoCase_other": "S'han iniciat {{count}} exportacions.",
|
||||
|
||||
@@ -52,7 +52,8 @@
|
||||
"error": "No s'ha pogut iniciar la repetició de depuració: {{error}}",
|
||||
"alreadyActive": "Ja hi ha activada una sessió de reproducció",
|
||||
"stopError": "No s'ha pogut aturar la repetició de depuració: {{error}}",
|
||||
"goToReplay": "Ves a la repetició"
|
||||
"goToReplay": "Ves a la repetició",
|
||||
"noRecordings": "No s'ha trobat cap enregistrament a l'interval de temps seleccionat"
|
||||
}
|
||||
},
|
||||
"description": "Reprodueix els enregistraments de la càmera per a la depuració. La llista d'objectes mostra un resum retardat en el temps dels objectes detectats i la pestanya Missatges mostra un flux de missatges interns de frigate a partir del metratge de reproducció."
|
||||
|
||||
@@ -265,7 +265,7 @@
|
||||
"rhythm_and_blues": "Rhythm and Blues",
|
||||
"soul_music": "Soulmusik",
|
||||
"reggae": "Reggae",
|
||||
"country": "Country",
|
||||
"country": "Land",
|
||||
"disco": "Disko",
|
||||
"classical_music": "Klassisk musik",
|
||||
"electronic_music": "Elektronisk musik",
|
||||
@@ -341,5 +341,7 @@
|
||||
"propeller": "Probel",
|
||||
"fixed-wing_aircraft": "Fastvingefly",
|
||||
"engine": "Motor",
|
||||
"light_engine": "Lille motor"
|
||||
"light_engine": "Lille motor",
|
||||
"swing_music": "Swing musik",
|
||||
"bluegrass": "Bluegrass"
|
||||
}
|
||||
|
||||
@@ -1 +1,7 @@
|
||||
{}
|
||||
{
|
||||
"camera_ui": {
|
||||
"review": {
|
||||
"label": "Vis i gennemgang"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,7 +170,10 @@
|
||||
"title": "Rediger kategoriseringsmodel",
|
||||
"descriptionState": "Rediger kategorierne for denne model til genstandstilstande. Ændringer kræver, at modellen trænes igen.",
|
||||
"descriptionObject": "Rediger genstandstypen og kategoriseringstypen for denne genstandskategoriseringsmodel.",
|
||||
"stateClassesInfo": "Bemærk: Ændring af tilstandskategorier kræver, at modellen trænes igen med de opdaterede kategorier."
|
||||
"stateClassesInfo": "Bemærk: Ændring af tilstandskategorier kræver, at modellen trænes igen med de opdaterede kategorier.",
|
||||
"enabled": "Aktiveret",
|
||||
"enabledDesc": "Kør denne model. Ved deaktivering stopper den med at køre og klassificerer ikke længere",
|
||||
"saveAttempts": "Gem forsøg"
|
||||
},
|
||||
"deleteDatasetImages": {
|
||||
"title": "Slet billeder i datasættet",
|
||||
|
||||
@@ -28,5 +28,8 @@
|
||||
"detail": {
|
||||
"aria": "Skift til detaljevisning"
|
||||
},
|
||||
"timeline.aria": "Vælg tidslinje"
|
||||
"timeline.aria": "Vælg tidslinje",
|
||||
"motionPreviews": {
|
||||
"crop": "Beskær til filter"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,9 @@
|
||||
{}
|
||||
{
|
||||
"metrics": {
|
||||
"minutesSeconds": "{{minutes}}m {{seconds}}s",
|
||||
"scanSummary": "{{segments}} segmentee · {{time}}"
|
||||
},
|
||||
"polygonControls": {
|
||||
"moveMode": "Flyt"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,5 +16,31 @@
|
||||
"menu": {
|
||||
"ui": "Brugergrænseflade",
|
||||
"profiles": "Profiler"
|
||||
},
|
||||
"configMessages": {
|
||||
"model": {
|
||||
"optimizedFor320": "Frigate er optimeret til en 320x320 model, der er det bedste valg for de fleste konfigurationer. En 640x640 model er langsommere og hjælper kun i særlige scenarier"
|
||||
}
|
||||
},
|
||||
"configForm": {
|
||||
"cameraInputs": {
|
||||
"sourceMode": {
|
||||
"manual": "Manuel input sti"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cameraManagement": {
|
||||
"clone": {
|
||||
"footer": {
|
||||
"restartNeeded": "Genstart er nødvendig for visse ændringer.",
|
||||
"liveOnly": "Alle ændringer vil blive aktiveret umiddelbart uden genstart.",
|
||||
"submit": "Klone",
|
||||
"submitting": "Kloner…"
|
||||
},
|
||||
"toast": {
|
||||
"success": "Indstillinger kopieret til {{cameraName}}",
|
||||
"successWithRestart": "Indstillinger kopieret til {{cameraName}}. Genstart Frigate for at aktivere alle ændringer."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,8 +78,8 @@
|
||||
"batchSuccess_other": "{{count}} Exports gestartet. Öffne den Fall jetzt.",
|
||||
"batchPartial": "{{successful}} von {{total}} Exporten gestartet. Fehlgeschlagene Kameras: {{failedCameras}}",
|
||||
"batchFailed": "Fehler beim Starten der {{total}} Exporte. Fehlgeschlagene Kameras: {{failedCameras}}",
|
||||
"batchQueuedSuccess_one": "1 Export in die Warteschlange gestellt. Fall wird jetzt geöffnet.",
|
||||
"batchQueuedSuccess_other": "{{count}} Exporte in der Warteschlange. Fall wird jetzt geöffnet.",
|
||||
"batchQueuedSuccess_one": "1 Export in die Warteschlange gestellt.",
|
||||
"batchQueuedSuccess_other": "{{count}} Exporte in der Warteschlange.",
|
||||
"batchQueuedPartial": "{{successful}} von {{total}} Exporten in die Warteschlange gestellt. Fehlerhafte Kameras: {{failedCameras}}",
|
||||
"batchQueueFailed": "Fehler beim Einreihen von {{total}} Exporten in die Warteschlange. Fehlerhafte Kameras: {{failedCameras}}"
|
||||
},
|
||||
@@ -112,7 +112,7 @@
|
||||
"noCameras": "Keine Kameras verfügbar",
|
||||
"detectionCount_one": "1 verfolgtes Objekt",
|
||||
"detectionCount_other": "{{count}} verfolgte Objekte",
|
||||
"nameLabel": "Exportname",
|
||||
"nameLabel": "Name exportieren",
|
||||
"namePlaceholder": "Optionaler Basisname für diese Exporte",
|
||||
"queueingButton": "Exporte werden in die Warteschlange gestellt...",
|
||||
"exportButton_one": "Export 1 Kamera",
|
||||
@@ -135,8 +135,8 @@
|
||||
"exportButton_other": "{{count}} Bewertung exportieren",
|
||||
"exportingButton": "Exportieren...",
|
||||
"toast": {
|
||||
"started_one": "1 Export gestartet. Fall wird jetzt geöffnet.",
|
||||
"started_other": "{{count}} Exporte gestartet. Fall wird jetzt geöffnet.",
|
||||
"started_one": "1 Export gestartet.",
|
||||
"started_other": "{{count}} Exporte gestartet.",
|
||||
"startedNoCase_one": "1 Export gestartet.",
|
||||
"startedNoCase_other": "{{count}} Exports gestartet.",
|
||||
"partial": "{{successful}} von {{total}} Exporten gestartet. Fehlgeschlagen: {{failedItems}}",
|
||||
|
||||
@@ -547,7 +547,7 @@
|
||||
"description": "Maximale Anzahl der gleichzeitig zu verarbeitenden Exportaufträge."
|
||||
},
|
||||
"chapters": {
|
||||
"label": "Kapitel-Metadaten zur Einbettung in exportierte Aufzeichnungen"
|
||||
"label": "Kapitel-Metadaten, die in exportierte Aufzeichnungen eingebettet werden sollen"
|
||||
}
|
||||
},
|
||||
"preview": {
|
||||
@@ -772,7 +772,7 @@
|
||||
},
|
||||
"dashboard": {
|
||||
"label": "Im Live-Dashboard anzeigen",
|
||||
"description": "Legt fest, ob diese Kamera im Live-Dashboard ‚Alle Kameras‘ angezeigt wird. Die Kamera bleibt in allen anderen Bereichen der Benutzeroberfläche verfügbar, einschließlich Kamera-Gruppen und Einstellungen."
|
||||
"description": "Legt fest, ob diese Kamera im Standard-Live-Dashboard „Alle Kameras“ angezeigt wird. Die Kamera bleibt in allen anderen Bereichen der Benutzeroberfläche verfügbar, einschließlich Kameragruppen und Einstellungen."
|
||||
},
|
||||
"review": {
|
||||
"label": "In der Überprüfung anzeigen",
|
||||
|
||||
@@ -539,7 +539,7 @@
|
||||
},
|
||||
"network_bandwidth": {
|
||||
"label": "Netzwerk Bandbreite",
|
||||
"description": "Aktivieren Sie die prozessbezogene Überwachung der Netzwerkbandbreite für Kamera-FFmpeg-Prozesse und Detektoren (erfordert entsprechende Funktionen)."
|
||||
"description": "Prozessbezogene Überwachung der Netzwerkbandbreite für Kamera-FFmpeg-Prozesse und Detektoren aktivieren (erfordert entsprechende Funktionen)."
|
||||
},
|
||||
"intel_gpu_device": {
|
||||
"label": "Intel GPU",
|
||||
@@ -1402,7 +1402,7 @@
|
||||
"description": "Maximale Anzahl der gleichzeitig zu verarbeitenden Exportaufträge."
|
||||
},
|
||||
"chapters": {
|
||||
"label": "Kapitel-Metadaten zur Einbettung in exportierte Aufzeichnungen"
|
||||
"label": "Kapitel-Metadaten, die in exportierte Aufzeichnungen eingebettet werden sollen"
|
||||
}
|
||||
},
|
||||
"preview": {
|
||||
|
||||
@@ -126,6 +126,6 @@
|
||||
"baby_stroller": "Kinderwagen",
|
||||
"rickshaw": "Rikscha",
|
||||
"rodent": "Nagetier",
|
||||
"garbage_truck": "Müllfahrzeug",
|
||||
"possum": "Possum"
|
||||
"garbage_truck": "Müllwagen",
|
||||
"possum": "Opossum"
|
||||
}
|
||||
|
||||
@@ -56,9 +56,9 @@
|
||||
},
|
||||
"deleteModel": {
|
||||
"title": "Klassifizierungsmodell löschen",
|
||||
"single": "Möchten Sie {{name}} wirklich löschen? Dadurch werden alle zugehörigen Daten, einschließlich Bilder und Trainingsdaten, dauerhaft gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"desc_one": "Möchtest du {{count}} Modell wirklich löschen? Dadurch werden alle zugehörigen Daten, einschließlich Bilder und Trainingsdaten, dauerhaft gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"desc_other": "Möchtest du {{count}} Modelle wirklich löschen? Dadurch werden alle zugehörigen Daten, einschließlich Bilder und Trainingsdaten, dauerhaft gelöscht. Diese Aktion kann nicht rückgängig gemacht werden."
|
||||
"single": "{{name}} wirklich löschen? Dadurch werden alle zugehörigen Daten, einschließlich Bilder und Trainingsdaten, dauerhaft gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"desc_one": "{{count}} Modell wirklich löschen? Dadurch werden alle zugehörigen Daten, einschließlich Bilder und Trainingsdaten, dauerhaft gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"desc_other": "{{count}} Modelle wirklich löschen? Dadurch werden alle zugehörigen Daten, einschließlich Bilder und Trainingsdaten, dauerhaft gelöscht. Diese Aktion kann nicht rückgängig gemacht werden."
|
||||
},
|
||||
"edit": {
|
||||
"title": "Klassifikationsmodell bearbeiten",
|
||||
@@ -75,19 +75,19 @@
|
||||
"intervalDesc": "Sekundenabstand zwischen regelmäßigen Klassifizierungsläufen. Leer lassen, um die Klassifizierung nur bei Bewegung auszuführen.",
|
||||
"intervalPlaceholder": "Kein Intervall",
|
||||
"errors": {
|
||||
"saveAttemptsInvalid": "Sparversuche müssen eine ganze Zahl von 0 oder mehr sein",
|
||||
"intervalInvalid": "Intervall muss eine ganze Nummer sein größer als 0"
|
||||
"saveAttemptsInvalid": "Die Anzahl der Speicherversuche muss eine ganze Zahl sein, die größer oder gleich 0 ist",
|
||||
"intervalInvalid": "Intervall muss eine ganze Zahl sein, die größer als 0 ist"
|
||||
}
|
||||
},
|
||||
"deleteDatasetImages": {
|
||||
"title": "Datensatz Bilder löschen",
|
||||
"desc_one": "Bist du sicher, dass {{count}} Bild von {{dataset}} gelöscht werden sollen? Diese Aktion kann nicht rückgängig gemacht werden und erfordert ein erneutes Trainieren des Modells.",
|
||||
"desc_other": "Bist du sicher, dass {{count}} Bilder von {{dataset}} gelöscht werden sollen? Diese Aktion kann nicht rückgängig gemacht werden und erfordert ein erneutes Trainieren des Modells."
|
||||
"desc_one": "Wirklich {{count}} Bild von {{dataset}} löschen? Diese Aktion kann nicht rückgängig gemacht werden und erfordert ein erneutes Trainieren des Modells.",
|
||||
"desc_other": "Wirklich {{count}} Bilder von {{dataset}} löschen? Diese Aktion kann nicht rückgängig gemacht werden und erfordert ein erneutes Trainieren des Modells."
|
||||
},
|
||||
"deleteTrainImages": {
|
||||
"title": "Trainingsbilder löschen",
|
||||
"desc_one": "Bist du sicher, dass du {{count}} Bild löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"desc_other": "Bist du sicher, dass du {{count}} Bilder löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden."
|
||||
"desc_one": "Wirklich {{count}} Bild löschen? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"desc_other": "Wirklich {{count}} Bilder löschen? Diese Aktion kann nicht rückgängig gemacht werden."
|
||||
},
|
||||
"renameCategory": {
|
||||
"title": "Klasse umbenennen",
|
||||
@@ -98,7 +98,7 @@
|
||||
},
|
||||
"train": {
|
||||
"title": "Neue Klassifizierungen",
|
||||
"titleShort": "frisch",
|
||||
"titleShort": "Neu",
|
||||
"aria": "Neue Klassifizierungen auswählen"
|
||||
},
|
||||
"categories": "Klassen",
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
"menuItem": "Bewegungsvorschau anzeigen",
|
||||
"title": "Bewegungsvorschau: {{camera}}",
|
||||
"mobileSettingsTitle": "Einstellungen für die Bewegungsvorschau",
|
||||
"mobileSettingsDesc": "Passen Sie die Wiedergabegeschwindigkeit und die Dimmung an und wählen Sie ein Datum aus, um Clips mit nur Bewegungen anzusehen.",
|
||||
"mobileSettingsDesc": "Passen Sie die Wiedergabegeschwindigkeit, die Dimmung und den Zuschnitt an und wählen Sie ein Datum aus, um Clips mit ausschließlich erkannten Bewegungen zu überprüfen.",
|
||||
"dim": "düster",
|
||||
"dimAria": "Dimmintensität einstellen",
|
||||
"dimDesc": "Erhöhen Sie die Dimmung, um die Sichtbarkeit des Bewegungsbereichs zu verbessern.",
|
||||
@@ -89,6 +89,9 @@
|
||||
"seekAria": "{{camera}} Player suchen bis {{time}}",
|
||||
"filter": "Filter",
|
||||
"filterDesc": "Wählen Sie Bereiche aus, um nur Clips mit Bewegungen in diesen Regionen anzuzeigen.",
|
||||
"filterClear": "Säubern"
|
||||
"filterClear": "Säubern",
|
||||
"crop": "Auf Filterbereich zuschneiden",
|
||||
"cropAria": "Vorschauen auf die gefilterten Bereiche zuschneiden ein- oder ausschalten",
|
||||
"cropDesc": "Zeigt eine vergrößerte Vorschau der ausgewählten Filterbereiche an, statt das vollständige Bild zu zeigen."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
"title": "Neueste Erkennungen",
|
||||
"aria": "Wähle aktuelle Erkennungen",
|
||||
"empty": "Es gibt keine aktuellen Versuche zur Gesichtserkennung",
|
||||
"titleShort": "frisch",
|
||||
"titleShort": "Neu",
|
||||
"emptyNoLibrary": {
|
||||
"title": "Gesicht hinzufügen",
|
||||
"description": "Sie müssen mindestens ein Gesicht zur Bibliothek hinzufügen, damit die Gesichtserkennung funktioniert."
|
||||
|
||||
@@ -22,7 +22,8 @@
|
||||
"error": "Fehler beim Starten der Debug-Wiedergabe: {{error}}",
|
||||
"alreadyActive": "Eine Wiederholungssitzung ist bereits aktiv",
|
||||
"stopError": "Die Wiedergabe der Debug-Daten konnte nicht beendet werden: {{error}}",
|
||||
"goToReplay": "Zur Aufzeichnung"
|
||||
"goToReplay": "Zur Aufzeichnung",
|
||||
"noRecordings": "Keine Aufnahmen im ausgewählten Zeitraum gefunden"
|
||||
}
|
||||
},
|
||||
"page": {
|
||||
|
||||
@@ -1383,7 +1383,7 @@
|
||||
"webuiUrlHelp": "URL, um die Web-Benutzeroberfläche der Kamera direkt aus der Debug-Ansicht aufzurufen. Lassen Sie das Feld leer, um den Link zu deaktivieren.",
|
||||
"webuiUrlInvalid": "Es muss sich um eine gültige URL handeln (z. B. https://example.com).",
|
||||
"dashboardLabel": "Im Live-Dashboard anzeigen",
|
||||
"dashboardHelp": "Zeige diese Kamera auf dem Standard Alle Kameras live dashboard. Es bleibt überall verfügbar, einschließlich Kameragruppen.",
|
||||
"dashboardHelp": "Diese Kamera im Standard Dashboard \"Alle Live Kameras\" anzeigen. Sie bleibt überall sonst verfügbar einschließlich Kameragruppen.",
|
||||
"reviewLabel": "In der Überprüfung anzeigen",
|
||||
"reviewHelp": "Zeige diese Kamera in der Übersicht an, einschließlich des Kamerafilters, der Bewegungsübersicht und der Verlaufsansicht."
|
||||
},
|
||||
@@ -1608,8 +1608,8 @@
|
||||
"overriddenInCameras": {
|
||||
"label_one": "In {{count}} Kamera überschrieben",
|
||||
"label_other": "In {{count}} Kameras überschrieben",
|
||||
"tooltip_one": "Die Kamera mit der Nummer {{count}} überschreibt die Werte in diesem Abschnitt. Klicken Sie hier, um Details anzuzeigen.",
|
||||
"tooltip_other": "Die Kamera mit der Nummer {{count}} überschreibt die Werte in diesem Abschnitt. Klicken Sie hier, um Details anzuzeigen.",
|
||||
"tooltip_one": "Die Kamera mit der Nummer {{count}} überschreibt die Werte in diesem Abschnitt. Hier klicken, um Details anzuzeigen.",
|
||||
"tooltip_other": "{{count}} Kameras überschreiben die Werte in diesem Abschnitt. Hier klicken, um Details anzuzeigen.",
|
||||
"heading_one": "Dieser globale Abschnitt enthält Felder, die in {{count}} Kamera überschrieben werden.",
|
||||
"heading_other": "Dieser globale Abschnitt enthält Felder, die bei {{count}} Kameras überschrieben werden.",
|
||||
"othersField_one": "{{count}} andere",
|
||||
@@ -1721,13 +1721,13 @@
|
||||
"valueLabel": "Wert",
|
||||
"keyPlaceholder": "Neuer Schlüssel",
|
||||
"remove": "Entfernen",
|
||||
"providerNameLabel": "Name des Anbieters",
|
||||
"providerNamePlaceholder": "z.B. openai",
|
||||
"variableNameLabel": "Variabler Name",
|
||||
"providerNameLabel": "Anbietername",
|
||||
"providerNamePlaceholder": "z. B. openai",
|
||||
"variableNameLabel": "Variablenname",
|
||||
"variableNamePlaceholder": "z.B. MY_VARIABLE",
|
||||
"loggerNameLabel": "Name des Loggers",
|
||||
"loggerNamePlaceholder": "z.B. frigate.record",
|
||||
"keyPatternError": "Verwenden Sie nur Buchstaben, Zahlen, Bindestriche und Unterstriche (keine Leerzeichen)"
|
||||
"keyPatternError": "Verwende nur Buchstaben, Zahlen, Bindestriche und Unterstriche (keine Leerzeichen)"
|
||||
},
|
||||
"timezone": {
|
||||
"defaultOption": "Zeitzone des Browsers verwenden"
|
||||
@@ -2142,7 +2142,8 @@
|
||||
"autotrackingNoZones": "Für die automatische Verfolgung ist mindestens eine Zone erforderlich. Definieren Sie unter „Masken / Zonen“ eine Zone für diese Kamera und legen Sie diese anschließend unten als erforderliche Zone fest."
|
||||
},
|
||||
"ffmpeg": {
|
||||
"hwaccelManualNotRecommended": "Explizite Definitionen der Hardware-beschleunigungs Variablen sind nicht empfohlen. Wähle die Voreinstellung die zu deiner Hardware passt, außer wenn spezifische Anforderungen eine andere Konfiguration erfordern."
|
||||
"hwaccelManualNotRecommended": "Explizite Definitionen der Hardware-beschleunigungs Variablen sind nicht empfohlen. Wähle die Voreinstellung die zu deiner Hardware passt, außer wenn spezifische Anforderungen eine andere Konfiguration erfordern.",
|
||||
"inputsMissingGo2rtcStream": "Ein Eingabefeld unten verweist auf einen go2rtc-Restream, der nicht mehr existiert. Wählen Sie einen vorhandenen Restream aus oder geben Sie die URL der Kamera manuell ein. Andernfalls kann keine Verbindung zu dieser Kamera hergestellt werden."
|
||||
},
|
||||
"model": {
|
||||
"optimizedFor320": "Frigate ist für ein 320x320-Modell optimiert, was für die meisten Konfigurationen die beste Wahl ist. Ein 640x640-Modell ist langsamer und bietet nur in bestimmten Szenarien Vorteile.",
|
||||
|
||||
@@ -426,7 +426,6 @@
|
||||
"radio": "Radio",
|
||||
"field_recording": "Field Recording",
|
||||
"scream": "Scream",
|
||||
"sodeling": "Sodeling",
|
||||
"chird": "Chird",
|
||||
"change_ringing": "Change Ringing",
|
||||
"shofar": "Shofar",
|
||||
|
||||
@@ -100,10 +100,8 @@
|
||||
"exportButton_other": "Export {{count}} reviews",
|
||||
"exportingButton": "Exporting...",
|
||||
"toast": {
|
||||
"started_one": "Started 1 export. Opening the case now.",
|
||||
"started_other": "Started {{count}} exports. Opening the case now.",
|
||||
"startedNoCase_one": "Started 1 export.",
|
||||
"startedNoCase_other": "Started {{count}} exports.",
|
||||
"started_one": "Started 1 export.",
|
||||
"started_other": "Started {{count}} exports.",
|
||||
"partial": "Started {{successful}} of {{total}} exports. Failed: {{failedItems}}",
|
||||
"failed": "Failed to start {{total}} exports. Failed: {{failedItems}}"
|
||||
}
|
||||
@@ -116,8 +114,8 @@
|
||||
"batchSuccess_other": "Started {{count}} exports. Opening the case now.",
|
||||
"batchPartial": "Started {{successful}} of {{total}} exports. Failed cameras: {{failedCameras}}",
|
||||
"batchFailed": "Failed to start {{total}} exports. Failed cameras: {{failedCameras}}",
|
||||
"batchQueuedSuccess_one": "Queued 1 export. Opening the case now.",
|
||||
"batchQueuedSuccess_other": "Queued {{count}} exports. Opening the case now.",
|
||||
"batchQueuedSuccess_one": "Queued 1 export.",
|
||||
"batchQueuedSuccess_other": "Queued {{count}} exports.",
|
||||
"batchQueuedPartial": "Queued {{successful}} of {{total}} exports. Failed cameras: {{failedCameras}}",
|
||||
"batchQueueFailed": "Failed to queue {{total}} exports. Failed cameras: {{failedCameras}}",
|
||||
"error": {
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"toast": {
|
||||
"error": "Failed to start debug replay: {{error}}",
|
||||
"alreadyActive": "A replay session is already active",
|
||||
"noRecordings": "No recordings found in the selected time range",
|
||||
"stopError": "Failed to stop debug replay: {{error}}",
|
||||
"goToReplay": "Go to Replay"
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
"ask_full": "¿Es este objeto un <code>{{untranslatedLabel}}</code> ({{translatedLabel}})?"
|
||||
},
|
||||
"toast": {
|
||||
"error": "Error al enviarlo a Frigate+. Por favor, comprueba tu conexión a internet y prueba de nuevo."
|
||||
"error": "No se pudo enviar a Frigate+. Verifique su conexión de red e inténtelo de nuevo."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -583,6 +583,9 @@
|
||||
"max_concurrent": {
|
||||
"label": "Exportaciones simultáneas máximas",
|
||||
"description": "Número máximo de trabajos de exportación que se procesarán al mismo tiempo."
|
||||
},
|
||||
"chapters": {
|
||||
"label": "Metadatos de capítulo que se incluirán en las grabaciones exportadas"
|
||||
}
|
||||
},
|
||||
"preview": {
|
||||
@@ -600,8 +603,8 @@
|
||||
},
|
||||
"ui": {
|
||||
"dashboard": {
|
||||
"description": "Alterna si esta cámara es visible en toda la interfaz de usuario de Frigate. Desactivar esta opción requerirá editar manualmente la configuración para volver a visualizar esta cámara en la interfaz.",
|
||||
"label": "Mostrar en la interfaz"
|
||||
"description": "Alterna si esta cámara será visible en el panel de control en directo predeterminado Todas las cámaras. La cámara seguirá estando disponible en el resto de la interfaz, incluidos los grupos de cámaras y la configuración.",
|
||||
"label": "Mostrar en el panel de control en directo"
|
||||
},
|
||||
"label": "Interfaz de cámara",
|
||||
"description": "Orden de visualización y visibilidad de esta cámara en la interfaz. El orden afecta al panel predeterminado. Para un control más granular, usa grupos de cámaras.",
|
||||
|
||||
@@ -471,7 +471,7 @@
|
||||
},
|
||||
"model_type": {
|
||||
"label": "Tipo de modelo de detección de objetos",
|
||||
"description": "Tipo de arquitectura del modelo detector (ssd, yolox, yolonas) usado por algunos detectores para optimización."
|
||||
"description": "Tipo de arquitectura del modelo del detector (ssd, yolox, yolonas, yolo-generic, rfdetr, dfine) utilizado por algunos detectores para su optimización."
|
||||
}
|
||||
},
|
||||
"model_path": {
|
||||
@@ -984,6 +984,9 @@
|
||||
"max_concurrent": {
|
||||
"label": "Exportaciones simultáneas máximas",
|
||||
"description": "Número máximo de trabajos de exportación que se procesarán al mismo tiempo."
|
||||
},
|
||||
"chapters": {
|
||||
"label": "Metadatos de capítulo que se incluirán en las grabaciones exportadas"
|
||||
}
|
||||
},
|
||||
"preview": {
|
||||
@@ -1002,8 +1005,8 @@
|
||||
},
|
||||
"camera_ui": {
|
||||
"dashboard": {
|
||||
"description": "Alterna si esta cámara es visible en toda la interfaz de usuario de Frigate. Desactivar esta opción requerirá editar manualmente la configuración para volver a visualizar esta cámara en la interfaz.",
|
||||
"label": "Mostrar en la interfaz"
|
||||
"description": "Alterna si esta cámara será visible en el panel de control en directo predeterminado Todas las cámaras. La cámara seguirá estando disponible en el resto de la interfaz, incluidos los grupos de cámaras y la configuración.",
|
||||
"label": "Mostrar en el panel de control en directo"
|
||||
},
|
||||
"label": "Interfaz de cámara",
|
||||
"description": "Orden de visualización y visibilidad de esta cámara en la interfaz. El orden afecta al panel predeterminado. Para un control más granular, usa grupos de cámaras.",
|
||||
@@ -1549,7 +1552,7 @@
|
||||
},
|
||||
"model_type": {
|
||||
"label": "Tipo de modelo de detección de objetos",
|
||||
"description": "Tipo de arquitectura del modelo detector (ssd, yolox, yolonas) usado por algunos detectores para optimización."
|
||||
"description": "Tipo de arquitectura del modelo del detector (ssd, yolox, yolonas, yolo-generic, rfdetr, dfine) utilizada por algunos detectores para su optimización."
|
||||
}
|
||||
},
|
||||
"classification": {
|
||||
|
||||
@@ -126,5 +126,7 @@
|
||||
"baby_stroller": "Cochecito de bebé",
|
||||
"rickshaw": "Rickshaw",
|
||||
"Rodent": "Roedor",
|
||||
"rodent": "Roedor"
|
||||
"rodent": "Roedor",
|
||||
"possum": "Zarigüeya",
|
||||
"garbage_truck": "Camión de basura"
|
||||
}
|
||||
|
||||
@@ -57,7 +57,20 @@
|
||||
"title": "Editar modelo de clasificación",
|
||||
"descriptionState": "Edita las clases para este modelo de clasificación de estados. Los cambios requerirán un reentrenamiento de modelo.",
|
||||
"descriptionObject": "Edita el tipo de objeto y el tipo de clasificación para este modelo de clasificación de objetos.",
|
||||
"stateClassesInfo": "Nota: El cambio de las clases de estado requiere reentrenar el modelo con las clases actualizadas."
|
||||
"stateClassesInfo": "Modelo actualizado. Vuelva a entrenar el modelo para que los cambios de clase surtan efecto.",
|
||||
"enabled": "Activado",
|
||||
"enabledDesc": "Ejecuta este modelo. Cuando se desactiva, deja de funcionar y ya no clasifica.",
|
||||
"saveAttempts": "Guardar intentos",
|
||||
"saveAttemptsDesc": "Número de imágenes de intentos de clasificación que se conservarán para la interfaz de usuario de clasificaciones recientes.",
|
||||
"motion": "Ejecutar en movimiento",
|
||||
"motionDesc": "Ejecutar la clasificación cuando se detecte movimiento dentro del cultivo configurado.",
|
||||
"interval": "Intervalo",
|
||||
"intervalDesc": "Segundos entre ciclos de clasificación periódica. Dejar en blanco para que se ejecute solo con detección de movimiento.",
|
||||
"intervalPlaceholder": "Sin intervalo",
|
||||
"errors": {
|
||||
"saveAttemptsInvalid": "Los intentos de guardado deben ser un número entero de 0 o mayor",
|
||||
"intervalInvalid": "El intervalo debe ser un número entero mayor que 0"
|
||||
}
|
||||
},
|
||||
"tooltip": {
|
||||
"noChanges": "No se han realizado cambios en el conjunto de datos desde el último entrenamiento.",
|
||||
@@ -201,5 +214,6 @@
|
||||
}
|
||||
},
|
||||
"reclassifyImage": "Reclasificar imagen",
|
||||
"reclassifyImageAs": "Reclasificar imagen como:"
|
||||
"reclassifyImageAs": "Reclasificar imagen como:",
|
||||
"disabled": "Desactivado"
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
"menuItem": "Ver vistas previas de movimiento",
|
||||
"title": "Vistas previas de movimiento: {{camera}}",
|
||||
"mobileSettingsTitle": "Ajustes de vistas previas de movimiento",
|
||||
"mobileSettingsDesc": "Ajusta la velocidad de reproducción y el atenuado, y elige una fecha para revisar clips solo de movimiento.",
|
||||
"mobileSettingsDesc": "Ajusta la velocidad de reproducción, el brillo y el recorte, y elige una fecha para revisar los clips que solo contienen movimiento.",
|
||||
"dim": "Atenuar",
|
||||
"dimAria": "Ajustar intensidad de atenuado",
|
||||
"dimDesc": "Aumenta el atenuado para mejorar la visibilidad de las áreas con movimiento.",
|
||||
@@ -90,6 +90,9 @@
|
||||
"seekAria": "Mover el reproductor de {{camera}} a {{time}}",
|
||||
"filter": "Filtrar",
|
||||
"filterDesc": "Selecciona áreas para mostrar solo clips con movimiento en esas regiones.",
|
||||
"filterClear": "Limpiar"
|
||||
"filterClear": "Limpiar",
|
||||
"crop": "Recortar para filtrar",
|
||||
"cropAria": "Alternar las vistas previas de recorte a las áreas filtradas",
|
||||
"cropDesc": "La vista previa con zoom se centra en las áreas de filtro seleccionadas en lugar de mostrar el fotograma completo."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,7 +302,7 @@
|
||||
},
|
||||
"offset": {
|
||||
"label": "Desplazamiento de anotación",
|
||||
"desc": "Estos datos provienen de la señal de detección de la cámara, pero se superponen a las imágenes de la señal de grabación. Es poco probable que ambas transmisiones estén perfectamente sincronizadas. Por lo tanto, el cuadro delimitador y el metraje no se alinearán perfectamente. Puede usar esta configuración para desplazar las anotaciones hacia adelante o hacia atrás en el tiempo para que se alineen mejor con el metraje grabado.",
|
||||
"desc": "Estos datos provienen de la señal de detección de tu cámara, pero se superponen a las imágenes de la señal de grabación. Es poco probable que ambas señales estén perfectamente sincronizadas. Por lo tanto, el cuadro delimitador y la grabación no coincidirán a la perfección. Puedes usar esta configuración para desplazar las anotaciones hacia adelante o hacia atrás en el tiempo y así alinearlas mejor con la grabación.",
|
||||
"millisecondsToOffset": "Milisegundos para compensar la detección de anotaciones. <em>Predeterminado: 0</em>",
|
||||
"tips": "Disminuya el valor si la reproducción de vídeo se produce antes de los cuadros y los puntos de ruta, y auméntelo si se produce después de ellos. Este valor puede ser negativo.",
|
||||
"toast": {
|
||||
|
||||
@@ -691,7 +691,7 @@
|
||||
"multiDeviceAdmin": "También puede obligar a todos los usuarios a volver a autenticarse inmediatamente rotando su secreto JWT."
|
||||
},
|
||||
"createUser": {
|
||||
"desc": "Añadir una nueva cuenta de usuario y especificar un rol para el acceso a áreas de la interfaz de usuario de Frigate.",
|
||||
"desc": "Agregue una nueva cuenta de usuario y especifique un rol para el acceso a las áreas de la interfaz de usuario de Frigate.",
|
||||
"title": "Crear nuevo usuario",
|
||||
"usernameOnlyInclude": "El nombre de usuario solo puede incluir letras, números, . o _",
|
||||
"confirmPassword": "Por favor, confirma tu contraseña"
|
||||
@@ -1308,7 +1308,7 @@
|
||||
"webuiUrlHelp": "URL para acceder directamente a la interfaz web de la cámara desde la vista de depuración. Déjala en blanco para deshabilitar el enlace.",
|
||||
"webuiUrlInvalid": "Debe ser una URL válida (p. ej., https://ejemplo.com).",
|
||||
"dashboardLabel": "Mostrar en el panel En directo",
|
||||
"dashboardHelp": "Mostrar esta cámara en el panel en directo.",
|
||||
"dashboardHelp": "Esta cámara se muestra en el panel de control predeterminado \"Todas las cámaras\". Permanece disponible en todas partes, incluidos los grupos de cámaras.",
|
||||
"reviewLabel": "Mostrar en Revisión",
|
||||
"reviewHelp": "Mostrar esta cámara en Revisión, incluido el filtro de cámaras, la revisión de movimiento y la vista de historial."
|
||||
},
|
||||
@@ -1354,7 +1354,7 @@
|
||||
"deleteCameraDialog": {
|
||||
"description": "Eliminar una cámara borrará permanentemente todas las grabaciones, los objetos rastreados y la configuración de esa cámara. Es posible que sea necesario eliminar manualmente cualquier transmisión go2rtc asociada a esta cámara.",
|
||||
"title": "Eliminar cámara",
|
||||
"selectPlaceholder": "Elegir cámara...",
|
||||
"selectPlaceholder": "Elige una cámara…",
|
||||
"confirmTitle": "¿Estás seguro?",
|
||||
"confirmWarning": "Eliminar <strong>{{cameraName}}</strong> no se puede deshacer.",
|
||||
"deleteExports": "Eliminar también las exportaciones de esta cámara",
|
||||
@@ -1668,7 +1668,14 @@
|
||||
"keyLabel": "Clave",
|
||||
"valueLabel": "Valor",
|
||||
"keyPlaceholder": "Nueva clave",
|
||||
"remove": "Eliminar"
|
||||
"remove": "Eliminar",
|
||||
"providerNameLabel": "Nombre del proveedor",
|
||||
"providerNamePlaceholder": "p. ej., openai",
|
||||
"variableNameLabel": "Nombre de la variable",
|
||||
"variableNamePlaceholder": "p. ej., MI_VARIABLE",
|
||||
"loggerNameLabel": "Nombre del registrador",
|
||||
"keyPatternError": "Utilice únicamente letras, números, guiones y guiones bajos (sin espacios)",
|
||||
"loggerNamePlaceholder": "p. ej., frigate.record"
|
||||
},
|
||||
"knownPlates": {
|
||||
"namePlaceholder": "p. ej., Coche de mi mujer",
|
||||
@@ -2032,7 +2039,12 @@
|
||||
"autotrackingNoZones": "El seguimiento automático requiere al menos una zona. Define una zona para esta cámara en Máscaras / Zonas y, a continuación, establécela como zona obligatoria a continuación."
|
||||
},
|
||||
"ffmpeg": {
|
||||
"hwaccelManualNotRecommended": "No son remontados los argumentos de aceleración por hardware manual. A no ser que un requisito específico exista, seleccione la preselección que coincida con su hardware."
|
||||
"hwaccelManualNotRecommended": "No son remontados los argumentos de aceleración por hardware manual. A no ser que un requisito específico exista, seleccione la preselección que coincida con su hardware.",
|
||||
"inputsMissingGo2rtcStream": "El campo de entrada que aparece a continuación apunta a una retransmisión de go2rtc que ya no existe. Seleccione una retransmisión existente o introduzca la URL de la cámara manualmente; de lo contrario, la cámara no podrá conectarse."
|
||||
},
|
||||
"model": {
|
||||
"optimizedFor320": "Frigate está optimizado para un modelo de 320x320, que es la mejor opción para la mayoría de las configuraciones. Un modelo de 640x640 es más lento y solo resulta útil en situaciones específicas.",
|
||||
"inputDimensionsNotDetectResolution": "El ancho y alto de entrada del modelo son las dimensiones de entrada del modelo de detección de objetos, no la resolución de detección de la cámara. Deben coincidir con las dimensiones del modelo que esté utilizando, normalmente un tamaño cuadrado como 320x320 o 640x640."
|
||||
}
|
||||
},
|
||||
"resetToDefaultDescription": "Esto restablecerá todos los ajustes de esta sección a sus valores predeterminados. Esta acción no se puede deshacer.",
|
||||
|
||||
@@ -83,8 +83,8 @@
|
||||
"batchSuccess_other": "Alustasin {{count}} ekspordiga. Avan juhtumi kohe.",
|
||||
"batchPartial": "Alustasin {{successful}}/{{total}} ekspordiga. Ebaõnnestunud kaamerad: {{failedCameras}}",
|
||||
"batchFailed": "{{total}} eksporti ebaõnnestus algatada. Ebaõnnestunud kaamerad: {{failedCameras}}",
|
||||
"batchQueuedSuccess_one": "1 eksport järjekorda lisatud. Avan juhtumi kohe.",
|
||||
"batchQueuedSuccess_other": "{{count}} eksporti järjekorda lisatud. Avan juhtumi kohe.",
|
||||
"batchQueuedSuccess_one": "1 eksportimine on lisatud järjekorda.",
|
||||
"batchQueuedSuccess_other": "{{count}} eksportimist on lisatud järjekorda.",
|
||||
"batchQueuedPartial": "{{successful}}/{{total}} ekspordist on järjekorda lisatud. Ebaõnnestunud kaamerad: {{failedCameras}}",
|
||||
"batchQueueFailed": "{{total}} eksporti ebaõnnestus järjekorda lisada. Ebaõnnestunud kaamerad: {{failedCameras}}"
|
||||
},
|
||||
@@ -139,8 +139,8 @@
|
||||
"exportButton_other": "Ekspordi {{count}} ülevaadet",
|
||||
"exportingButton": "Ekspordin...",
|
||||
"toast": {
|
||||
"started_one": "Alustasin 1 eksporti. Avan juhtumi kohe.",
|
||||
"started_other": "Alustasin {{count}} ekspordiga. Avan juhtumi kohe.",
|
||||
"started_one": "Alustasin ühte eksportimist.",
|
||||
"started_other": "Alustasin {{count}} eksportimisega.",
|
||||
"startedNoCase_one": "Alustasin 1 ekspordiga.",
|
||||
"startedNoCase_other": "Alustasin {{count}} ekspordiga.",
|
||||
"partial": "Alustasin {{successful}}/{{total}} ekspordiga. Ebaõnnestusid: {{failedItems}}",
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
"error": "Silumise taasesitus ebaõnnestus: {{error}}",
|
||||
"alreadyActive": "Kordusseanss on juba aktiivne",
|
||||
"stopError": "Silumise taasesituse peatamine ebaõnnestus: {{error}}",
|
||||
"goToReplay": "Mine kordusesse"
|
||||
"goToReplay": "Mine kordusesse",
|
||||
"noRecordings": "Valitud ajavahemikust ei leidu ühtegi salvestust"
|
||||
}
|
||||
},
|
||||
"title": "Kordus veaotsinguks",
|
||||
|
||||
@@ -413,7 +413,7 @@
|
||||
"typewriter": "ماشین تحریر",
|
||||
"computer_keyboard": "صفحهکلید رایانه",
|
||||
"writing": "نوشتن",
|
||||
"alarm": "هشدار",
|
||||
"alarm": "زنگ هشدار",
|
||||
"telephone": "تلفن",
|
||||
"siren": "آژیر",
|
||||
"civil_defense_siren": "آژیر دفاع مدنی",
|
||||
|
||||
@@ -134,7 +134,7 @@
|
||||
"export": "خروجی گرفتن",
|
||||
"next": "بعدی",
|
||||
"reset": "بازنشانی",
|
||||
"enabled": "فعال",
|
||||
"enabled": "فعال شده",
|
||||
"disable": "غیرفعال کردن",
|
||||
"save": "ذخیره",
|
||||
"saving": "در حال ذخیرهسازی…",
|
||||
@@ -161,7 +161,8 @@
|
||||
"resetToDefault": "بازنشانی به پیشفرض",
|
||||
"saveAll": "ذخیره همه",
|
||||
"savingAll": "ذخیرهسازی همه…",
|
||||
"undoAll": "بازگردانی همه"
|
||||
"undoAll": "بازگردانی همه",
|
||||
"overridden": "نادیدهگرفتهشده"
|
||||
},
|
||||
"menu": {
|
||||
"systemMetrics": "شاخصهای سیستم",
|
||||
@@ -211,9 +212,10 @@
|
||||
"label": "برای زبان از تنظیمات سامانه استفاده کنید"
|
||||
},
|
||||
"zhHant": "چینی سنتی",
|
||||
"hr": "کرواسیایی"
|
||||
"hr": "کرواسیایی",
|
||||
"bs": "بوسنیایی"
|
||||
},
|
||||
"system": "سامانه",
|
||||
"system": "سیستم",
|
||||
"systemLogs": "لاگهای سامانه",
|
||||
"configurationEditor": "ویرایشگر پیکربندی",
|
||||
"languages": "زبانها",
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"restart": {
|
||||
"title": "آیا برای راه اندازی مجدد Frigate مطمئن هستید؟",
|
||||
"button": "ریاستارت",
|
||||
"button": "راهاندازی دوباره",
|
||||
"restarting": {
|
||||
"title": "فریگیت در حال ریاستارت شدن",
|
||||
"content": "صفحه تا {{countdown}} ثانیه دیگر مجددا بارگزاری خواهد شد.",
|
||||
"button": "بارگزاری مجدد هم اکنون اجرا شود"
|
||||
}
|
||||
},
|
||||
"description": "این کار بهطور موقت Frigate را هنگام راهاندازی مجدد متوقف میکند."
|
||||
},
|
||||
"explore": {
|
||||
"plus": {
|
||||
@@ -23,6 +24,9 @@
|
||||
},
|
||||
"state": {
|
||||
"submitted": "ارسال شد"
|
||||
},
|
||||
"toast": {
|
||||
"error": "ارسال به Frigate+ انجام نشد. لطفاً اتصال شبکه خود را بررسی کرده و دوباره تلاش کنید."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -49,21 +53,86 @@
|
||||
"error": {
|
||||
"endTimeMustAfterStartTime": "زمان پایان باید بعد از زمان شروع باشد",
|
||||
"noVaildTimeSelected": "بازهٔ زمانی معتبر انتخاب نشده است",
|
||||
"failed": "شروع خروجیگیری ناموفق بود: {{error}}"
|
||||
"failed": "شروع خروجیگیری ناموفق بود: {{error}}",
|
||||
"noValidTimeSelected": "بازهٔ زمانی معتبر انتخاب نشده است"
|
||||
},
|
||||
"success": "ساخت خروجی با موفقیت آغاز شد. فایل را در صفحه خروجیها مشاهده کنید.",
|
||||
"view": "مشاهده"
|
||||
"view": "مشاهده",
|
||||
"queued": "خروجی در صف قرار گرفت. پیشرفت را در صفحهٔ خروجیها مشاهده کنید.",
|
||||
"batchSuccess_one": "۱ خروجی شروع شد. در حال باز کردن مورد.",
|
||||
"batchSuccess_other": "{{count}} خروجی شروع شد. در حال باز کردن مورد.",
|
||||
"batchPartial": "{{successful}} از {{total}} خروجی شروع شد. دوربینهای ناموفق: {{failedCameras}}",
|
||||
"batchFailed": "شروع {{total}} خروجی ناموفق بود. دوربینهای ناموفق: {{failedCameras}}",
|
||||
"batchQueuedSuccess_one": "۱ خروجی در صف قرار گرفت.",
|
||||
"batchQueuedSuccess_other": "{{count}} خروجی در صف قرار گرفت.",
|
||||
"batchQueuedPartial": "{{successful}} از {{total}} خروجی در صف قرار گرفت. دوربینهای ناموفق: {{failedCameras}}",
|
||||
"batchQueueFailed": "صفبندی {{total}} خروجی ناموفق بود. دوربینهای ناموفق: {{failedCameras}}"
|
||||
},
|
||||
"fromTimeline": {
|
||||
"saveExport": "ذخیرهٔ خروجی",
|
||||
"previewExport": "پیشنمایش خروجی"
|
||||
"previewExport": "پیشنمایش خروجی",
|
||||
"queueingExport": "در حال صفبندی خروجی...",
|
||||
"useThisRange": "استفاده از این بازه"
|
||||
},
|
||||
"name": {
|
||||
"placeholder": "برای خروجی نام بگذارید"
|
||||
},
|
||||
"select": "انتخاب",
|
||||
"export": "خروجی",
|
||||
"selectOrExport": "انتخاب یا خروجی"
|
||||
"selectOrExport": "انتخاب یا خروجی",
|
||||
"multiCamera": {
|
||||
"selectGroup": "انتخاب گروه",
|
||||
"noMatchingCameras": "هیچ دوربینی با جستجوی شما مطابقت ندارد",
|
||||
"checkingActivity": "در حال بررسی فعالیت دوربین...",
|
||||
"noCameras": "هیچ دوربینی در دسترس نیست",
|
||||
"detectionCount_one": "1 شیء ردیابی شده",
|
||||
"detectionCount_other": "{{count}} شیء ردیابی شده",
|
||||
"nameLabel": "نام خروجی",
|
||||
"namePlaceholder": "نام پایه اختیاری برای این خروجیها",
|
||||
"queueingButton": "به صف کردن خروجیها...",
|
||||
"exportButton_one": "خروجی 1 دوربین",
|
||||
"exportButton_other": "خروجی {{count}} دوربین",
|
||||
"timeRange": "محدوده زمانی",
|
||||
"selectFromTimeline": "انتخاب از تایملاین",
|
||||
"cameraSelection": "دوربینها",
|
||||
"cameraSelectionHelp": "دوربینهایی که در این بازه زمانی دارای اشیاء ردیابیشده هستند، از قبل انتخاب شدهاند",
|
||||
"searchOrSelectGroup": "جستجو یا انتخاب گروه دوربین...",
|
||||
"selectAll": "انتخاب همه دوربینها",
|
||||
"clearSelection": "پاک کردن انتخابها",
|
||||
"selectWithActivity": "دوربینهای دارای اشیاء ردیابیشده",
|
||||
"selectedCount": "{{selected}} / {{total}} انتخاب شده"
|
||||
},
|
||||
"multi": {
|
||||
"title_one": "خروجی ۱ بازبینی",
|
||||
"title_other": "خروجی {{count}} بازبینی",
|
||||
"description": "از هر بازبینی انتخابشده خروجی بگیرید. تمام خروجیها تحت یک پرونده واحد گروهبندی خواهند شد.",
|
||||
"descriptionNoCase": "از هر بازبینی انتخابشده خروجی بگیرید.",
|
||||
"caseNamePlaceholder": "بازبینی خروجی - {{date}}",
|
||||
"exportButton_one": "خروجی 1 بازبینی",
|
||||
"exportButton_other": "خروجی {{count}} بازبینی",
|
||||
"exportingButton": "خروجی گرفتن...",
|
||||
"toast": {
|
||||
"started_one": "۱ خروجی شروع شد.",
|
||||
"started_other": "{{count}} خروجی شروع شد.",
|
||||
"startedNoCase_one": "۱ خروجی آغاز شد.",
|
||||
"startedNoCase_other": "{{count}} خروجی آغاز شد.",
|
||||
"partial": "{{successful}} از {{total}} خروجی آغاز شد. موارد ناموفق: {{failedItems}}",
|
||||
"failed": "شروع {{total}} خروجی ناموفق بود. ناموفق: {{failedItems}}"
|
||||
}
|
||||
},
|
||||
"case": {
|
||||
"newCaseOption": "ایجاد پرونده جدید",
|
||||
"newCaseNamePlaceholder": "نام پرونده جدید",
|
||||
"newCaseDescriptionPlaceholder": "توضیحات مورد",
|
||||
"label": "مورد",
|
||||
"nonAdminHelp": "یک مورد جدید برای این خروجیها ایجاد خواهد شد.",
|
||||
"placeholder": "انتخاب یک مورد"
|
||||
},
|
||||
"queueing": "در حال قرار دادن خروجی در صف...",
|
||||
"tabs": {
|
||||
"export": "تک دوربین",
|
||||
"multiCamera": "چند-دوربین"
|
||||
}
|
||||
},
|
||||
"streaming": {
|
||||
"label": "جریان",
|
||||
@@ -109,6 +178,14 @@
|
||||
"markAsReviewed": "علامتگذاری بهعنوان بازبینیشده",
|
||||
"markAsUnreviewed": "علامتگذاری بهعنوان بازبینینشده",
|
||||
"deleteNow": "حذف فوری"
|
||||
},
|
||||
"shareTimestamp": {
|
||||
"label": "اشتراکگذاری مهر زمانی",
|
||||
"title": "اشتراکگذاری مهر زمانی",
|
||||
"description": "یک نشانی اینترنتی با مهر زمانی از موقعیت فعلی پخشکننده به اشتراک بگذارید یا یک مهر زمانی سفارشی انتخاب کنید. توجه کنید که این یک لینک اشتراکگذاری عمومی نیست و فقط برای کاربرانی که به Frigate و این دوربین دسترسی دارند قابل دسترسی است.",
|
||||
"custom": "مهر زمانی سفارشی",
|
||||
"button": "اشتراکگذاری نشانی مهر زمانی",
|
||||
"shareTitle": "مهر زمانی بازبینی فریگیت: {{camera}}"
|
||||
}
|
||||
},
|
||||
"imagePicker": {
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"count_other": "{{count}} برچسبها"
|
||||
},
|
||||
"zones": {
|
||||
"label": "مناطق",
|
||||
"label": "نواحی",
|
||||
"all": {
|
||||
"title": "همهٔ ناحیهها",
|
||||
"short": "ناحیهها"
|
||||
|
||||
@@ -33,7 +33,11 @@
|
||||
},
|
||||
"filters": {
|
||||
"label": "فیلترهای صوتی",
|
||||
"description": "تنظیمات فیلتر بر اساس نوع صدا مانند آستانههای اطمینان که برای کاهش تشخیصهای مثبت کاذب استفاده میشوند."
|
||||
"description": "تنظیمات فیلتر بر اساس نوع صدا مانند آستانههای اطمینان که برای کاهش تشخیصهای مثبت کاذب استفاده میشوند.",
|
||||
"threshold": {
|
||||
"label": "حداقل اطمینان صوتی",
|
||||
"description": "حداقل آستانه اطمینان برای رویداد صوتی تا شمارش شود."
|
||||
}
|
||||
},
|
||||
"enabled_in_config": {
|
||||
"label": "وضعیت صوتی اصلی",
|
||||
@@ -471,7 +475,7 @@
|
||||
"description": "تنظیمات نگهداری برای ضبط رویدادهای تشخیص.",
|
||||
"days": {
|
||||
"label": "روزهای نگهداری",
|
||||
"description": "تعداد روزهایی که لازم است سوابق رویدادهای شناساییشده نگهداری EMSebi شوند ."
|
||||
"description": "تعداد روزهایی که ضبطهای رویدادهای تشخیص باید نگهداری شوند."
|
||||
},
|
||||
"mode": {
|
||||
"label": "حالت نگهداری",
|
||||
@@ -485,6 +489,13 @@
|
||||
"hwaccel_args": {
|
||||
"label": "خروجی گرفتن از آرگومانهای hwaccel",
|
||||
"description": "آرگومانهای شتاب سختافزاری برای استفاده در عملیات صادرات/تبدیل کد."
|
||||
},
|
||||
"max_concurrent": {
|
||||
"label": "حداکثر خروجیهای همزمان",
|
||||
"description": "حداکثر تعداد کارهای خروجی که همزمان پردازش میشوند."
|
||||
},
|
||||
"chapters": {
|
||||
"label": "فرادادهٔ فصل برای جاسازی در ضبطهای خروجیگرفتهشده"
|
||||
}
|
||||
},
|
||||
"preview": {
|
||||
@@ -672,8 +683,8 @@
|
||||
}
|
||||
},
|
||||
"quality": {
|
||||
"label": "کیفیت JPEG",
|
||||
"description": "کیفیت کدگذاری JPEG برای عکسهای ذخیره شده (0-100)."
|
||||
"label": "کیفیت عکس فوری",
|
||||
"description": "کیفیت کدگذاری برای عکسهای فوری ذخیرهشده (0-100)."
|
||||
}
|
||||
},
|
||||
"timestamp_style": {
|
||||
@@ -823,7 +834,7 @@
|
||||
},
|
||||
"timeout": {
|
||||
"label": "مهلت بازگشت",
|
||||
"description": "چند ثانیه صبر کن پس از از دست دادن ردیابی قبل از بازگرداندن دوربین به موقعیت از پیش تعیین شده ."
|
||||
"description": "پس از قطع ردیابی، این تعداد ثانیه صبر کنید قبل از بازگرداندن دوربین به موقعیت از پیش تعیینشده."
|
||||
},
|
||||
"movement_weights": {
|
||||
"label": "وزنههای حرکتی",
|
||||
@@ -838,7 +849,11 @@
|
||||
"label": "عدم تطابق زمانی را نادیده بگیرید",
|
||||
"description": "برای ارتباط ONVIF، از تفاوتهای همگامسازی زمانی بین دوربین و سرور Frigate صرف نظر کنید."
|
||||
},
|
||||
"label": "ONVIF پروتکل استاندارد انتقال تصویر ."
|
||||
"label": "ONVIF",
|
||||
"profile": {
|
||||
"label": "پروفایل ONVIF",
|
||||
"description": "پروفایل رسانهٔ ONVIF خاص برای کنترل PTZ، مطابقتشده با توکن یا نام. اگر تنظیم نشود، اولین پروفایل با پیکربندی PTZ معتبر بهطور خودکار انتخاب میشود."
|
||||
}
|
||||
},
|
||||
"type": {
|
||||
"label": "نوع دوربین",
|
||||
@@ -854,6 +869,10 @@
|
||||
"dashboard": {
|
||||
"label": "نمایش در رابط کاربری",
|
||||
"description": "مشخص کنید که آیا این دوربین در همه جای رابط کاربری Frigate قابل مشاهده باشد یا خیر. غیرفعال کردن این گزینه مستلزم ویرایش دستی پیکربندی برای مشاهده مجدد این دوربین در رابط کاربری است."
|
||||
},
|
||||
"review": {
|
||||
"label": "نمایش در بازبینی",
|
||||
"description": "مشخص کنید که آیا این دوربین در بازبینی قابل مشاهده باشد (صفحهٔ بازبینی و فیلتر دوربین آن، بازبینی حرکت، و نمای تاریخچه)."
|
||||
}
|
||||
},
|
||||
"webui_url": {
|
||||
@@ -861,7 +880,7 @@
|
||||
"description": "آدرس اینترنتی برای بازدید مستقیم از دوربین از صفحه سیستم"
|
||||
},
|
||||
"zones": {
|
||||
"label": "مناطق",
|
||||
"label": "نواحی",
|
||||
"description": "مناطق به شما امکان میدهند ناحیه خاصی از قاب را تعریف کنید تا بتوانید تعیین کنید که آیا یک شیء در یک ناحیه خاص قرار دارد یا خیر.",
|
||||
"friendly_name": {
|
||||
"label": "نام منطقه",
|
||||
@@ -937,5 +956,9 @@
|
||||
"enabled_in_config": {
|
||||
"label": "وضعیت دوربین اصلی",
|
||||
"description": "وضعیت اولیه دوربین را پیگیری کنید."
|
||||
},
|
||||
"profiles": {
|
||||
"label": "پروفایلها",
|
||||
"description": "پروفایلهای پیکربندی نامگذاریشده با لغوهای جزئی که در زمان اجرا قابل فعالسازی هستند."
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1 +1,73 @@
|
||||
{}
|
||||
{
|
||||
"audio": {
|
||||
"global": {
|
||||
"detection": "تشخیص سراسری",
|
||||
"sensitivity": "حساسیت سراسری"
|
||||
},
|
||||
"cameras": {
|
||||
"detection": "تشخیص",
|
||||
"sensitivity": "حساسیت"
|
||||
}
|
||||
},
|
||||
"timestamp_style": {
|
||||
"global": {
|
||||
"appearance": "ظاهر سراسری"
|
||||
},
|
||||
"cameras": {
|
||||
"appearance": "ظاهر"
|
||||
}
|
||||
},
|
||||
"motion": {
|
||||
"global": {
|
||||
"sensitivity": "حساسیت سراسری",
|
||||
"algorithm": "الگوریتم سراسری"
|
||||
},
|
||||
"cameras": {
|
||||
"sensitivity": "حساسیت",
|
||||
"algorithm": "الگوریتم"
|
||||
}
|
||||
},
|
||||
"snapshots": {
|
||||
"global": {
|
||||
"display": "نمایش سراسری"
|
||||
},
|
||||
"cameras": {
|
||||
"display": "نمایش"
|
||||
}
|
||||
},
|
||||
"detect": {
|
||||
"global": {
|
||||
"resolution": "وضوح سراسری",
|
||||
"tracking": "ردیابی سراسری"
|
||||
},
|
||||
"cameras": {
|
||||
"resolution": "وضوح",
|
||||
"tracking": "ردیابی"
|
||||
}
|
||||
},
|
||||
"objects": {
|
||||
"global": {
|
||||
"tracking": "ردیابی سراسری",
|
||||
"filtering": "پالایش سراسری"
|
||||
},
|
||||
"cameras": {
|
||||
"tracking": "ردیابی",
|
||||
"filtering": "پالایش"
|
||||
}
|
||||
},
|
||||
"record": {
|
||||
"global": {
|
||||
"retention": "نگهداری سراسری",
|
||||
"events": "رویدادهای سراسری"
|
||||
},
|
||||
"cameras": {
|
||||
"retention": "نگهداری",
|
||||
"events": "رویدادها"
|
||||
}
|
||||
},
|
||||
"ffmpeg": {
|
||||
"cameras": {
|
||||
"cameraFfmpeg": "آرگومانهای FFmpeg اختصاصی دوربین"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,35 @@
|
||||
{}
|
||||
{
|
||||
"minimum": "باید حداقل {{limit}} باشد",
|
||||
"maximum": "باید حداکثر {{limit}} باشد",
|
||||
"exclusiveMinimum": "باید بزرگتر از {{limit}} باشد",
|
||||
"exclusiveMaximum": "باید کمتر از {{limit}} باشد",
|
||||
"minLength": "باید حداقل {{limit}} نویسه باشد",
|
||||
"maxLength": "باید حداکثر {{limit}} نویسه باشد",
|
||||
"minItems": "باید حداقل {{limit}} مورد داشته باشد",
|
||||
"maxItems": "باید حداکثر {{limit}} مورد داشته باشد",
|
||||
"pattern": "قالب نامعتبر",
|
||||
"required": "این فیلد الزامی است",
|
||||
"type": "نوع مقدار نامعتبر",
|
||||
"enum": "باید یکی از مقادیر مجاز باشد",
|
||||
"const": "مقدار با ثابت مورد انتظار مطابقت ندارد",
|
||||
"uniqueItems": "همه موارد باید یکتا باشند",
|
||||
"format": "قالب نامعتبر",
|
||||
"additionalProperties": "ویژگی ناشناخته مجاز نیست",
|
||||
"oneOf": "باید دقیقاً با یکی از طرحوارههای مجاز مطابقت داشته باشد",
|
||||
"anyOf": "باید با حداقل یکی از طرحوارههای مجاز مطابقت داشته باشد",
|
||||
"proxy": {
|
||||
"header_map": {
|
||||
"roleHeaderRequired": "هنگامی که نگاشت نقشها پیکربندی شده است، هدر نقش الزامی است."
|
||||
}
|
||||
},
|
||||
"ffmpeg": {
|
||||
"inputs": {
|
||||
"rolesUnique": "هر نقش فقط میتواند به یک جریان ورودی اختصاص یابد.",
|
||||
"detectRequired": "حداقل یک جریان ورودی باید نقش «detect» داشته باشد.",
|
||||
"hwaccelDetectOnly": "فقط جریان ورودی با نقش detect میتواند آرگومانهای شتابدهی سختافزاری تعریف کند."
|
||||
}
|
||||
},
|
||||
"detect": {
|
||||
"dimensionMustBeEven": "باید یک عدد زوج باشد."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,5 +116,16 @@
|
||||
"nzpost": "اِنزد پُست",
|
||||
"gls": "جیاِلاِس",
|
||||
"dpd": "دیپیدی",
|
||||
"surfboard": "تخته موج سواری"
|
||||
"surfboard": "تخته موج سواری",
|
||||
"canada_post": "پست کانادا",
|
||||
"royal_mail": "پست سلطنتی",
|
||||
"school_bus": "اتوبوس مدرسه",
|
||||
"skunk": "اسکانک",
|
||||
"kangaroo": "کانگورو",
|
||||
"baby": "نوزاد",
|
||||
"baby_stroller": "کالسکه نوزاد",
|
||||
"rickshaw": "ریکشا",
|
||||
"rodent": "جونده",
|
||||
"possum": "صاریغ",
|
||||
"garbage_truck": "کامیون زباله"
|
||||
}
|
||||
|
||||
@@ -1 +1,72 @@
|
||||
{}
|
||||
{
|
||||
"documentTitle": "گفتگو - فریگیت",
|
||||
"title": "گفتگوی فریگیت",
|
||||
"subtitle": "دستیار هوش مصنوعی شما برای مدیریت دوربین و دریافت بینش",
|
||||
"placeholder": "هر چیزی بپرسید...",
|
||||
"error": "خطایی رخ داد. لطفاً دوباره تلاش کنید.",
|
||||
"processing": "در حال پردازش...",
|
||||
"toolsUsed": "استفادهشده: {{tools}}",
|
||||
"showTools": "نمایش ابزارها ({{count}})",
|
||||
"hideTools": "پنهان کردن ابزارها",
|
||||
"call": "فراخوانی",
|
||||
"result": "نتیجه",
|
||||
"arguments": "آرگومانها:",
|
||||
"response": "پاسخ:",
|
||||
"attachment_chip_label": "{{label}} در {{camera}}",
|
||||
"attachment_chip_remove": "حذف پیوست",
|
||||
"open_in_explore": "باز کردن در کاوش",
|
||||
"attach_event_aria": "پیوست رویداد {{eventId}}",
|
||||
"attachment_picker_paste_label": "یا شناسه رویداد را جایگذاری کنید",
|
||||
"attachment_picker_attach": "پیوست",
|
||||
"attachment_picker_placeholder": "پیوست یک رویداد",
|
||||
"quick_reply_find_similar": "یافتن مشاهدات مشابه",
|
||||
"quick_reply_tell_me_more": "بیشتر درباره این به من بگو",
|
||||
"quick_reply_when_else": "چه زمانهای دیگری دیده شده؟",
|
||||
"quick_reply_find_similar_text": "مشاهدات مشابه این را پیدا کن.",
|
||||
"quick_reply_tell_me_more_text": "بیشتر درباره این یکی به من بگو.",
|
||||
"quick_reply_when_else_text": "چه زمانهای دیگری این دیده شده است؟",
|
||||
"anchor": "ارجاع",
|
||||
"similarity_score": "شباهت",
|
||||
"no_similar_objects_found": "شیء مشابهی یافت نشد.",
|
||||
"semantic_search_required": "برای یافتن اشیای مشابه، جستوجوی معنایی باید فعال باشد.",
|
||||
"send": "ارسال",
|
||||
"suggested_requests": "این موارد را امتحان کنید:",
|
||||
"starting_requests": {
|
||||
"show_recent_events": "نمایش رویدادهای اخیر",
|
||||
"show_camera_status": "نمایش وضعیت دوربین",
|
||||
"recap": "وقتی من نبودم چه اتفاقی افتاد؟",
|
||||
"watch_camera": "تماشای دوربین برای فعالیت"
|
||||
},
|
||||
"starting_requests_prompts": {
|
||||
"show_recent_events": "رویدادهای اخیر در یک ساعت گذشته را نشان بده",
|
||||
"show_camera_status": "وضعیت فعلی دوربینهای من چیست؟",
|
||||
"recap": "وقتی من نبودم چه اتفاقی افتاد؟",
|
||||
"watch_camera": "درب جلویی را تماشا کن و اگر کسی ظاهر شد به من اطلاع بده"
|
||||
},
|
||||
"new_chat": "گفتگوی جدید",
|
||||
"settings": {
|
||||
"title": "تنظیمات گفتگو",
|
||||
"show_stats": {
|
||||
"title": "نمایش آمار",
|
||||
"desc": "نرخ تولید و اندازه زمینه برای پاسخهای گفتگو را نشان بده.",
|
||||
"while_generating": "هنگام تولید",
|
||||
"always": "همیشه"
|
||||
},
|
||||
"auto_scroll": {
|
||||
"title": "پیمایش خودکار",
|
||||
"desc": "پیامهای جدید را هنگام رسیدن دنبال کن."
|
||||
}
|
||||
},
|
||||
"stats": {
|
||||
"context": "{{tokens}} توکن",
|
||||
"tokens_per_second": "{{rate}} ت/ث"
|
||||
},
|
||||
"reasoning": {
|
||||
"active": "در حال استدلال…",
|
||||
"show": "نمایش استدلال",
|
||||
"hide": "پنهان کردن استدلال"
|
||||
},
|
||||
"thinking": {
|
||||
"toggle": "فعال/غیرفعال کردن تفکر"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,17 +11,18 @@
|
||||
},
|
||||
"toast": {
|
||||
"success": {
|
||||
"deletedCategory_one": "کلاس حذف شده",
|
||||
"deletedCategory_other": "",
|
||||
"deletedImage_one": "عکس های حذف شده",
|
||||
"deletedImage_other": "",
|
||||
"deletedCategory_one": "{{count}} کلاس حذف شد",
|
||||
"deletedCategory_other": "{{count}} کلاس حذف شدند",
|
||||
"deletedImage_one": "{{count}} تصویر حذف شد",
|
||||
"deletedImage_other": "{{count}} تصویر حذف شدند",
|
||||
"categorizedImage": "تصویر طبقه بندی شده",
|
||||
"trainedModel": "مدل آموزش دیده شده.",
|
||||
"trainingModel": "آموزش دادن مدل با موفقیت شروع شد.",
|
||||
"deletedModel_one": "{{count}} مدل با موفقیت حذف شد",
|
||||
"deletedModel_other": "{{count}} مدل با موفقیت حذف شدند",
|
||||
"updatedModel": "پیکربندی مدل با موفقیت بهروزرسانی شد",
|
||||
"renamedCategory": "نام کلاس با موفقیت به {{name}} تغییر یافت"
|
||||
"renamedCategory": "نام کلاس با موفقیت به {{name}} تغییر یافت",
|
||||
"reclassifiedImage": "طبقهبندی مجدد تصویر با موفقیت انجام شد"
|
||||
},
|
||||
"error": {
|
||||
"deleteImageFailed": "حذف نشد: {{errorMessage}}",
|
||||
@@ -31,7 +32,8 @@
|
||||
"trainingFailed": "آموزش مدل ناموفق بود. برای جزئیات، گزارشهای Frigate را بررسی کنید.",
|
||||
"trainingFailedToStart": "شروع آموزش مدل ناموفق بود: {{errorMessage}}",
|
||||
"updateModelFailed": "بهروزرسانی مدل ناموفق بود: {{errorMessage}}",
|
||||
"renameCategoryFailed": "تغییر نام کلاس ناموفق بود: {{errorMessage}}"
|
||||
"renameCategoryFailed": "تغییر نام کلاس ناموفق بود: {{errorMessage}}",
|
||||
"reclassifyFailed": "طبقهبندی مجدد تصویر ناموفق بود: {{errorMessage}}"
|
||||
}
|
||||
},
|
||||
"documentTitle": "دسته بندی مدل ها - فریگیت",
|
||||
@@ -158,14 +160,32 @@
|
||||
"noImages": "هیچ تصویر نمونهای تولید نشد",
|
||||
"modelCreated": "مدل با موفقیت ایجاد شد. از نمای «طبقهبندیهای اخیر» برای افزودن تصاویرِ وضعیتهایِ جاافتاده استفاده کنید، سپس مدل را آموزش دهید.",
|
||||
"generateSuccess": "تصاویر نمونه با موفقیت تولید شد",
|
||||
"selectImagesPrompt": "همهٔ تصاویر با {{className}} را انتخاب کنید"
|
||||
"selectImagesPrompt": "همهٔ تصاویر با {{className}} را انتخاب کنید",
|
||||
"refreshExamples": "تولید نمونههای جدید",
|
||||
"refreshConfirm": {
|
||||
"title": "تولید نمونههای جدید؟",
|
||||
"description": "این کار یک مجموعهٔ جدید از تصاویر تولید میکند و همهٔ انتخابها را پاک میکند، از جمله کلاسهای قبلی. باید نمونهها را برای همهٔ کلاسها دوباره انتخاب کنید."
|
||||
}
|
||||
}
|
||||
},
|
||||
"edit": {
|
||||
"title": "ویرایش مدل طبقهبندی",
|
||||
"descriptionState": "کلاسهای این مدل طبقهبندی حالت را ویرایش کنید. اعمال تغییرات نیاز به بازآموزی مدل دارد.",
|
||||
"descriptionObject": "نوع شیء و نوع طبقهبندی را برای این مدل طبقهبندی شیء ویرایش کنید.",
|
||||
"stateClassesInfo": "توجه: تغییر کلاسهای وضعیت نیازمند بازآموزی مدل با کلاسهای بهروزرسانیشده است."
|
||||
"stateClassesInfo": "توجه: تغییر کلاسهای وضعیت نیازمند بازآموزی مدل با کلاسهای بهروزرسانیشده است.",
|
||||
"enabled": "فعال شده",
|
||||
"enabledDesc": "اجرای این مدل. هنگام غیرفعال بودن، اجرا متوقف میشود و دیگر طبقهبندی نمیکند.",
|
||||
"saveAttempts": "تلاشهای ذخیره",
|
||||
"saveAttemptsDesc": "تعداد تصاویر تلاش طبقهبندی که برای رابط کاربری طبقهبندیهای اخیر نگهداری میشوند.",
|
||||
"motion": "اجرای هنگام حرکت",
|
||||
"motionDesc": "اجرای طبقهبندی هنگام تشخیص حرکت در ناحیهٔ برشخوردهٔ پیکربندیشده.",
|
||||
"interval": "بازه",
|
||||
"intervalDesc": "ثانیه بین اجراهای دورهای طبقهبندی. برای اجرای فقط هنگام حرکت، خالی بگذارید.",
|
||||
"intervalPlaceholder": "بدون بازه",
|
||||
"errors": {
|
||||
"saveAttemptsInvalid": "تلاشهای ذخیره باید یک عدد صحیح ۰ یا بزرگتر باشد",
|
||||
"intervalInvalid": "بازه باید یک عدد صحیح بزرگتر از ۰ باشد"
|
||||
}
|
||||
},
|
||||
"deleteDatasetImages": {
|
||||
"title": "حذف تصاویر مجموعهداده",
|
||||
@@ -185,5 +205,8 @@
|
||||
"createCategory": {
|
||||
"new": "ایجاد کلاس جدید"
|
||||
},
|
||||
"categorizeImageAs": "طبقهبندی تصویر بهعنوان:"
|
||||
"categorizeImageAs": "طبقهبندی تصویر بهعنوان:",
|
||||
"disabled": "غیرفعال",
|
||||
"reclassifyImageAs": "طبقهبندی مجدد تصویر بهعنوان:",
|
||||
"reclassifyImage": "طبقهبندی مجدد تصویر"
|
||||
}
|
||||
|
||||
@@ -15,7 +15,9 @@
|
||||
"description": "موارد بازبینی برای یک دوربین تنها درصورتی امکان ساخت دارند که ضبطها برای آن دورین فعال باشد."
|
||||
}
|
||||
},
|
||||
"timeline": "خط زمانی",
|
||||
"timeline": {
|
||||
"label": "خط زمانی"
|
||||
},
|
||||
"timeline.aria": "انتخاب خط زمانی",
|
||||
"zoomIn": "بزرگنمایی",
|
||||
"zoomOut": "کوچکنمایی",
|
||||
@@ -25,7 +27,9 @@
|
||||
"label": "رویدادها"
|
||||
},
|
||||
"recordings": {
|
||||
"documentTitle": "ضبطها - فریگیت"
|
||||
"documentTitle": "ضبطها - فریگیت",
|
||||
"invalidSharedLink": "باز کردن لینک ضبط با مهر زمانی بهدلیل خطای تجزیه ممکن نیست.",
|
||||
"invalidSharedCamera": "باز کردن لینک ضبط با مهر زمانی بهدلیل دوربین ناشناخته یا غیرمجاز ممکن نیست."
|
||||
},
|
||||
"calendarFilter": {
|
||||
"last24Hours": "۲۴ ساعت گذشته"
|
||||
@@ -55,11 +59,37 @@
|
||||
},
|
||||
"documentTitle": "بازبینی - Frigate",
|
||||
"selected_one": "{{count}} انتخاب شد",
|
||||
"selected_other": "{{count}} انتخاب شدند",
|
||||
"selected_other": "{{count}} انتخاب شد",
|
||||
"select_all": "همه",
|
||||
"camera": "دوربین",
|
||||
"detected": "گزینههاشناسایی شد",
|
||||
"normalActivity": "عادی",
|
||||
"needsReview": "نیاز به بازبینی",
|
||||
"securityConcern": "نگرانی امنیتی"
|
||||
"securityConcern": "نگرانی امنیتی",
|
||||
"motionSearch": {
|
||||
"menuItem": "جستوجوی حرکت",
|
||||
"openMenu": "گزینههای دوربین"
|
||||
},
|
||||
"motionPreviews": {
|
||||
"menuItem": "مشاهدهٔ پیشنمایشهای حرکت",
|
||||
"title": "پیشنمایشهای حرکت: {{camera}}",
|
||||
"mobileSettingsTitle": "تنظیمات پیشنمایش حرکت",
|
||||
"mobileSettingsDesc": "سرعت پخش، تاری و برش را تنظیم کنید، و تاری را برای بازبینی کلیپهای فقط حرکتی انتخاب کنید.",
|
||||
"dim": "تاری",
|
||||
"dimAria": "تنظیم شدت تاری",
|
||||
"dimDesc": "برای افزایش دیدمندی منطقهٔ حرکت، تاری را بیشتر کنید.",
|
||||
"speed": "سرعت",
|
||||
"speedAria": "انتخاب سرعت پخش پیشنمایش",
|
||||
"speedDesc": "انتخاب کنید کلیپهای پیشنمایش با چه سرعتی پخش شوند.",
|
||||
"back": "بازگشت",
|
||||
"empty": "هیچ پیشنمایشی در دسترس نیست",
|
||||
"noPreview": "پیشنمایش در دسترس نیست",
|
||||
"seekAria": "پیمایش پخشکننده {{camera}} به {{time}}",
|
||||
"filter": "فیلتر",
|
||||
"filterDesc": "مناطق را انتخاب کنید تا فقط کلیپهایی با حرکت در آن مناطق نشان داده شوند.",
|
||||
"filterClear": "پاک کردن",
|
||||
"crop": "برش به فیلتر",
|
||||
"cropAria": "فعال/غیرفعال کردن برش پیشنمایشها به مناطق فیلترشده",
|
||||
"cropDesc": "بزرگنمایی پیشنمایشها به مناطق فیلتر انتخابشده بهجای نمایش کامل فریم."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,7 +85,8 @@
|
||||
"regenerateFromThumbnails": "بازسازی از تصاویر بندانگشتی",
|
||||
"title": {
|
||||
"label": "عنوان"
|
||||
}
|
||||
},
|
||||
"scoreInfo": "اطلاعات امتیاز"
|
||||
},
|
||||
"exploreIsUnavailable": {
|
||||
"title": "کاوش کردن در دسترس نیست",
|
||||
@@ -136,7 +137,10 @@
|
||||
"zones": "ناحیهها",
|
||||
"ratio": "نسبت",
|
||||
"area": "مساحت",
|
||||
"score": "امتیاز"
|
||||
"score": "امتیاز",
|
||||
"computedScore": "امتیاز محاسبهشده",
|
||||
"topScore": "بالاترین امتیاز",
|
||||
"toggleAdvancedScores": "فعال/غیرفعالسازی امتیازهای پیشرفته"
|
||||
}
|
||||
},
|
||||
"title": "جزئیات ردیابی",
|
||||
@@ -216,6 +220,13 @@
|
||||
},
|
||||
"deleteTrackedObject": {
|
||||
"label": "حذف این شیء ردیابیشده"
|
||||
},
|
||||
"debugReplay": {
|
||||
"label": "بازپخش اشکالزدایی",
|
||||
"aria": "مشاهدهٔ این شیء ردیابیشده در نمای بازپخش اشکالزدایی"
|
||||
},
|
||||
"more": {
|
||||
"aria": "بیشتر"
|
||||
}
|
||||
},
|
||||
"noTrackedObjects": "هیچ شیء ردیابیشدهای پیدا نشد",
|
||||
@@ -226,6 +237,9 @@
|
||||
"confirmDelete": {
|
||||
"title": "تأیید حذف",
|
||||
"desc": "حذف این شیء ردیابیشده عکس فوری، هرگونه امبدینگ ذخیرهشده و هر ورودی مرتبط با جزئیات ردیابی را حذف میکند. فیلم ضبطشدهٔ این شیء ردیابیشده در نمای تاریخ <em>حذف نخواهد شد</em>.<br /><br />آیا مطمئنید میخواهید ادامه دهید؟"
|
||||
},
|
||||
"toast": {
|
||||
"error": "خطا در حذف این شیء ردیابیشده: {{errorMessage}}"
|
||||
}
|
||||
},
|
||||
"searchResult": {
|
||||
@@ -244,5 +258,8 @@
|
||||
},
|
||||
"concerns": {
|
||||
"label": "نگرانیها"
|
||||
},
|
||||
"objectLifecycle": {
|
||||
"noImageFound": "برای این شیء ردیابیشده هیچ تصویری یافت نشد."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
"search": "جستجو",
|
||||
"documentTitle": "گرفتن خروجی - فریگیت",
|
||||
"noExports": "هیچ خروجی یافت نشد",
|
||||
"deleteExport": "حذف خروجی",
|
||||
"deleteExport": {
|
||||
"label": "حذف خروجی"
|
||||
},
|
||||
"deleteExport.desc": "آیا مطمئن هستید که میخواهید {{exportName}} را حذف کنید؟",
|
||||
"editExport": {
|
||||
"title": "تغییر نام خروجی",
|
||||
@@ -14,15 +16,113 @@
|
||||
"downloadVideo": "دانلود ویدئو",
|
||||
"editName": "ویرایش نام",
|
||||
"deleteExport": "حذف خروجی",
|
||||
"assignToCase": "به مورد اضافه کنید"
|
||||
"assignToCase": "اضافه کردن به مورد",
|
||||
"removeFromCase": "حذف از مورد"
|
||||
},
|
||||
"toast": {
|
||||
"error": {
|
||||
"renameExportFailed": "تغییر نام خروجی ناموفق بود: {{errorMessage}}"
|
||||
"renameExportFailed": "تغییر نام خروجی ناموفق بود: {{errorMessage}}",
|
||||
"assignCaseFailed": "بهروزرسانی انتساب مورد ناموفق بود: {{errorMessage}}",
|
||||
"caseSaveFailed": "ذخیرهٔ مورد ناموفق بود: {{errorMessage}}",
|
||||
"caseDeleteFailed": "حذف مورد ناموفق بود: {{errorMessage}}"
|
||||
}
|
||||
},
|
||||
"headings": {
|
||||
"cases": "موارد",
|
||||
"uncategorizedExports": "خروجی دستهبندی نشده"
|
||||
"uncategorizedExports": "خروجی های دسته بندی نشده"
|
||||
},
|
||||
"toolbar": {
|
||||
"newCase": "مورد جدید",
|
||||
"addExport": "افزودن خروجی",
|
||||
"editCase": "ویرایش مورد",
|
||||
"deleteCase": "حذف مورد"
|
||||
},
|
||||
"deleteCase": {
|
||||
"label": "حذف مورد",
|
||||
"desc": "آیا مطمئن هستید که میخواهید {{caseName}} را حذف کنید؟",
|
||||
"descKeepExports": "خروجیها بهعنوان خروجیهای دستهبندینشده در دسترس باقی خواهند ماند.",
|
||||
"descDeleteExports": "همهٔ خروجیهای این مورد بهطور دائمی حذف خواهند شد.",
|
||||
"deleteExports": "حذف خروجیها نیز"
|
||||
},
|
||||
"caseDialog": {
|
||||
"title": "افزودن به مورد",
|
||||
"description": "یک مورد موجود انتخاب کنید یا مورد جدیدی ایجاد کنید.",
|
||||
"selectLabel": "مورد",
|
||||
"newCaseOption": "ایجاد مورد جدید",
|
||||
"nameLabel": "نام مورد",
|
||||
"descriptionLabel": "توضیحات"
|
||||
},
|
||||
"caseCard": {
|
||||
"emptyCase": "هنوز خروجیای وجود ندارد"
|
||||
},
|
||||
"jobCard": {
|
||||
"defaultName": "خروجی {{camera}}",
|
||||
"queued": "در صف",
|
||||
"running": "در حال اجرا",
|
||||
"preparing": "در حال آمادهسازی",
|
||||
"copying": "در حال کپی",
|
||||
"encoding": "در حال رمزگذاری",
|
||||
"encodingRetry": "رمزگذاری (تلاش مجدد)",
|
||||
"finalizing": "در حال نهاییسازی"
|
||||
},
|
||||
"caseView": {
|
||||
"noDescription": "بدون توضیحات",
|
||||
"createdAt": "ایجاد شده {{value}}",
|
||||
"exportCount_one": "۱ خروجی",
|
||||
"exportCount_other": "{{count}} خروجی",
|
||||
"cameraCount_one": "۱ دوربین",
|
||||
"cameraCount_other": "{{count}} دوربین",
|
||||
"showMore": "نمایش بیشتر",
|
||||
"showLess": "نمایش کمتر",
|
||||
"emptyTitle": "این مورد خالی است",
|
||||
"emptyDescription": "برای سازماندهی مورد، خروجیهای دستهبندینشده موجود را اضافه کنید.",
|
||||
"emptyDescriptionNoExports": "هنوز خروجی دستهبندینشدهای برای افزودن وجود ندارد."
|
||||
},
|
||||
"caseEditor": {
|
||||
"createTitle": "ایجاد مورد",
|
||||
"editTitle": "ویرایش مورد",
|
||||
"namePlaceholder": "نام مورد",
|
||||
"descriptionPlaceholder": "یادداشت یا زمینه برای این مورد اضافه کنید"
|
||||
},
|
||||
"addExportDialog": {
|
||||
"title": "افزودن خروجی به {{caseName}}",
|
||||
"searchPlaceholder": "جستوجوی خروجیهای دستهبندینشده",
|
||||
"empty": "هیچ خروجی دستهبندینشدهای با این جستوجو مطابقت ندارد.",
|
||||
"addButton_one": "افزودن ۱ خروجی",
|
||||
"addButton_other": "افزودن {{count}} خروجی",
|
||||
"adding": "در حال افزودن..."
|
||||
},
|
||||
"selected_one": "{{count}} انتخاب شد",
|
||||
"selected_other": "{{count}} انتخاب شد",
|
||||
"bulkActions": {
|
||||
"addToCase": "افزودن به مورد",
|
||||
"moveToCase": "انتقال به مورد",
|
||||
"removeFromCase": "حذف از مورد",
|
||||
"delete": "حذف",
|
||||
"deleteNow": "حذف فوری"
|
||||
},
|
||||
"bulkDelete": {
|
||||
"title": "حذف خروجیها",
|
||||
"desc_one": "آیا مطمئن هستید که میخواهید {{count}} خروجی را حذف کنید؟",
|
||||
"desc_other": "آیا مطمئن هستید که میخواهید {{count}} خروجی را حذف کنید؟"
|
||||
},
|
||||
"bulkRemoveFromCase": {
|
||||
"title": "حذف از مورد",
|
||||
"desc_one": "حذف {{count}} خروجی از این مورد؟",
|
||||
"desc_other": "حذف {{count}} خروجی از این مورد؟",
|
||||
"descKeepExports": "خروجیها به دستهبندینشده منتقل خواهند شد.",
|
||||
"descDeleteExports": "خروجیها بهطور دائمی حذف خواهند شد.",
|
||||
"deleteExports": "حذف خروجیها بهجای آن"
|
||||
},
|
||||
"bulkToast": {
|
||||
"success": {
|
||||
"delete": "خروجیها با موفقیت حذف شدند",
|
||||
"reassign": "انتساب مورد با موفقیت بهروزرسانی شد",
|
||||
"remove": "خروجیها با موفقیت از مورد حذف شدند"
|
||||
},
|
||||
"error": {
|
||||
"deleteFailed": "حذف خروجیها ناموفق بود: {{errorMessage}}",
|
||||
"reassignFailed": "بهروزرسانی انتساب مورد ناموفق بود: {{errorMessage}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
{
|
||||
"documentTitle": "زنده - فریگیت",
|
||||
"documentTitle": {
|
||||
"default": "زنده - فریگیت"
|
||||
},
|
||||
"documentTitle.withCamera": "{{camera}} - زنده - فریگیت",
|
||||
"lowBandwidthMode": "حالت کاهش مصرف پهنای باند",
|
||||
"twoWayTalk": {
|
||||
@@ -15,7 +17,8 @@
|
||||
"clickMove": {
|
||||
"label": "برای قرار دادن دوربین در مرکز، در کادر کلیک کنید",
|
||||
"enable": "فعالسازی کلیک برای جابهجایی",
|
||||
"disable": "غیرفعالسازی کلیک برای جابهجایی"
|
||||
"disable": "غیرفعالسازی کلیک برای جابهجایی",
|
||||
"enableWithZoom": "فعالسازی کلیک برای جابهجایی / کشیدن برای بزرگنمایی"
|
||||
},
|
||||
"left": {
|
||||
"label": "دوربین PTZ را به چپ حرکت دهید"
|
||||
@@ -55,7 +58,8 @@
|
||||
},
|
||||
"recording": {
|
||||
"disable": "غیرفعال کردن ضبط",
|
||||
"enable": "فعالسازی ضبط"
|
||||
"enable": "فعالسازی ضبط",
|
||||
"disabledInConfig": "ابتدا باید ضبط در تنظیمات برای این دوربین فعال باشد."
|
||||
},
|
||||
"snapshots": {
|
||||
"enable": "فعال کردن عکسهای فوری",
|
||||
@@ -69,7 +73,9 @@
|
||||
},
|
||||
"camera": {
|
||||
"enable": "فعال کردن دوربین",
|
||||
"disable": "غیرفعال کردن دوربین"
|
||||
"disable": "غیرفعال کردن دوربین",
|
||||
"turnOn": "روشن کردن دوربین",
|
||||
"turnOff": "خاموش کردن دوربین"
|
||||
},
|
||||
"muteCameras": {
|
||||
"enable": "بیصدا کردن همهٔ دوربینها",
|
||||
@@ -127,7 +133,8 @@
|
||||
"autotracking": "ردیابی خودکار",
|
||||
"cameraEnabled": "دوربین فعال",
|
||||
"recording": "ضبط",
|
||||
"transcription": "رونویسی صوتی"
|
||||
"transcription": "رونویسی صوتی",
|
||||
"camera": "دوربین"
|
||||
},
|
||||
"effectiveRetainMode": {
|
||||
"modes": {
|
||||
@@ -150,7 +157,17 @@
|
||||
"description": "شما اجازهٔ مشاهدهٔ هیچ دوربینی را در این گروه ندارید.",
|
||||
"title": "هیچ دوربینی در دسترس نیست"
|
||||
},
|
||||
"description": "برای شروع، یک دوربین را به Frigate متصل کنید."
|
||||
"description": "برای شروع، یک دوربین را به Frigate متصل کنید.",
|
||||
"default": {
|
||||
"title": "هیچ دوربینی پیکربندی نشده است",
|
||||
"description": "برای شروع، یک دوربین را به Frigate متصل کنید.",
|
||||
"buttonText": "افزودن دوربین"
|
||||
},
|
||||
"group": {
|
||||
"title": "هیچ دوربینی در گروه نیست",
|
||||
"description": "این گروه دوربین هیچ دوربین اختصاصیافته یا فعالی ندارد.",
|
||||
"buttonText": "مدیریت گروهها"
|
||||
}
|
||||
},
|
||||
"streamStats": {
|
||||
"enable": "نمایش آمار پخش",
|
||||
|
||||
@@ -1 +1,78 @@
|
||||
{}
|
||||
{
|
||||
"documentTitle": "جستوجوی حرکت - فریگیت",
|
||||
"title": "جستوجوی حرکت",
|
||||
"description": "یک چندضلعی رسم کنید تا منطقه مورد نظر را تعریف کنید، و یک بازه زمانی برای جستوجوی تغییرات حرکت در آن منطقه مشخص کنید.",
|
||||
"selectCamera": "جستوجوی حرکت در حال بارگذاری است",
|
||||
"startSearch": "شروع جستوجو",
|
||||
"searchStarted": "جستوجو شروع شد",
|
||||
"searchCancelled": "جستوجو لغو شد",
|
||||
"cancelSearch": "لغو",
|
||||
"searching": "جستوجو در حال انجام است.",
|
||||
"scanning": "در حال اسکن {{time}}",
|
||||
"searchComplete": "جستوجو کامل شد",
|
||||
"noResultsYet": "برای یافتن تغییرات حرکت در منطقه انتخابشده، یک جستوجو اجرا کنید",
|
||||
"noChangesFound": "هیچ تغییر پیکسلی در منطقه انتخابشده شناسایی نشد",
|
||||
"changesFound_one": "{{count}} تغییر حرکت یافت شد",
|
||||
"changesFound_other": "{{count}} تغییر حرکت یافت شدند",
|
||||
"framesProcessed": "{{count}} فریم پردازش شد",
|
||||
"jumpToTime": "پرش به این زمان",
|
||||
"results": "نتایج",
|
||||
"showSegmentHeatmap": "نقشه حرارتی",
|
||||
"newSearch": "جستوجوی جدید",
|
||||
"clearResults": "پاک کردن نتایج",
|
||||
"clearROI": "پاک کردن چندضلعی",
|
||||
"polygonControls": {
|
||||
"points_one": "{{count}} نقطه",
|
||||
"points_other": "{{count}} نقطهها",
|
||||
"undo": "برگرداندن آخرین نقطه",
|
||||
"reset": "بازنشانی چندضلعی",
|
||||
"drawMode": "رسم",
|
||||
"moveMode": "جابهجایی"
|
||||
},
|
||||
"motionHeatmapLabel": "نقشه حرارتی حرکت",
|
||||
"dialog": {
|
||||
"title": "جستوجوی حرکت",
|
||||
"cameraLabel": "دوربین",
|
||||
"previewAlt": "پیشنمایش دوربین برای {{camera}}"
|
||||
},
|
||||
"timeRange": {
|
||||
"title": "بازه جستوجو",
|
||||
"start": "زمان شروع",
|
||||
"end": "زمان پایان"
|
||||
},
|
||||
"settings": {
|
||||
"title": "تنظیمات جستوجو",
|
||||
"parallelMode": "حالت موازی",
|
||||
"parallelModeDesc": "اسکن همزمان چندین بازه ضبط (سریعتر؛ منابع رمزگشایی بیشتری مصرف میکند)",
|
||||
"threshold": "آستانه حساسیت",
|
||||
"thresholdDesc": "مقادیر پایینتر تغییرات کوچکتری را شناسایی میکنند (۱-۲۵۵)",
|
||||
"minArea": "حداقل مساحت تغییر",
|
||||
"minAreaDesc": "حداقل اندازه یک منطقه متحرک، به درصد از منطقه مورد نظر",
|
||||
"maxResults": "حداکثر نتایج",
|
||||
"maxResultsDesc": "پس از این تعداد مهر زمانی منطبق، متوقف شود"
|
||||
},
|
||||
"errors": {
|
||||
"noCamera": "لطفاً یک دوربین انتخاب کنید",
|
||||
"noROI": "لطفاً یک منطقه مورد نظر رسم کنید",
|
||||
"noTimeRange": "لطفاً یک بازه زمانی انتخاب کنید",
|
||||
"invalidTimeRange": "زمان پایان باید بعد از زمان شروع باشد",
|
||||
"searchFailed": "جستوجو ناموفق بود: {{message}}",
|
||||
"polygonTooSmall": "چندضلعی باید حداقل ۳ نقطه داشته باشد",
|
||||
"unknown": "خطای ناشناخته"
|
||||
},
|
||||
"changePercentage": "{{percentage}}٪ تغییر",
|
||||
"metrics": {
|
||||
"title": "معیارهای جستوجو",
|
||||
"segmentsScanned": "بخشهای اسکنشده",
|
||||
"segmentsProcessed": "پردازششده",
|
||||
"segmentsSkippedInactive": "رد شده (بدون فعالیت)",
|
||||
"segmentsSkippedHeatmap": "رد شده (بدون همپوشانی منطقه)",
|
||||
"fallbackFullRange": "اسکن کامل بازه (پشتیبان)",
|
||||
"framesDecoded": "فریمهای رمزگشاییشده",
|
||||
"wallTime": "زمان جستوجو",
|
||||
"segmentErrors": "خطاهای بخش",
|
||||
"seconds": "{{seconds}}ث",
|
||||
"minutesSeconds": "{{minutes}}د {{seconds}}ث",
|
||||
"scanSummary": "{{segments}} بخش · {{time}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,60 @@
|
||||
{}
|
||||
{
|
||||
"title": "بازپخش اشکالزدایی",
|
||||
"description": "ضبطهای دوربین را برای اشکالزدایی بازپخش کنید. فهرست اشیا خلاصهای با تأخیر زمانی از اشیای شناساییشده را نشان میدهد و تب پیامها جریانی از پیامهای داخلی فریگیت از تصویر بازپخش را نمایش میدهد.",
|
||||
"websocket_messages": "پیامها",
|
||||
"dialog": {
|
||||
"title": "شروع بازپخش اشکالزدایی",
|
||||
"description": "یک دوربین بازپخش موقت ایجاد کنید که تصاویر تاریخی را برای اشکالزدایی تشخیص شیء و ردیابی حلقه میکند. دوربین بازپخش همان پیکربندی تشخیص دوربین منبع را خواهد داشت. یک بازه زمانی برای شروع انتخاب کنید.",
|
||||
"camera": "دوربین منبع",
|
||||
"timeRange": "بازه زمانی",
|
||||
"preset": {
|
||||
"1m": "۱ دقیقه اخیر",
|
||||
"5m": "۵ دقیقه اخیر",
|
||||
"timeline": "از خط زمانی",
|
||||
"custom": "سفارشی"
|
||||
},
|
||||
"startButton": "شروع بازپخش",
|
||||
"selectFromTimeline": "انتخاب",
|
||||
"starting": "در حال شروع بازپخش...",
|
||||
"startLabel": "شروع",
|
||||
"endLabel": "پایان",
|
||||
"toast": {
|
||||
"error": "شروع بازپخش اشکالزدایی ناموفق بود: {{error}}",
|
||||
"alreadyActive": "یک نشست بازپخش از قبل فعال است",
|
||||
"stopError": "توقف بازپخش اشکالزدایی ناموفق بود: {{error}}",
|
||||
"goToReplay": "رفتن به بازپخش",
|
||||
"noRecordings": "هیچ فیلم ضبط شده ای در بازه زمانی انتخابشده یافت نشد"
|
||||
}
|
||||
},
|
||||
"page": {
|
||||
"noSession": "هیچ نشست بازپخش اشکالزدایی فعالی وجود ندارد",
|
||||
"noSessionDesc": "یک بازپخش اشکالزدایی را از نمای تاریخچه با کلیک روی دکمه اقدامات در نوار ابزار و انتخاب بازپخش اشکالزدایی شروع کنید.",
|
||||
"goToRecordings": "رفتن به تاریخچه",
|
||||
"preparingClip": "در حال آمادهسازی کلیپ…",
|
||||
"preparingClipDesc": "فریگیت در حال کنار هم گذاشتن ضبطها برای بازه زمانی انتخابشده است. این کار برای بازههای طولانیتر ممکن است چند دقیقه طول بکشد.",
|
||||
"startingCamera": "در حال شروع بازپخش اشکالزدایی…",
|
||||
"startError": {
|
||||
"title": "شروع بازپخش اشکالزدایی ناموفق بود",
|
||||
"back": "بازگشت به تاریخچه"
|
||||
},
|
||||
"sourceCamera": "دوربین منبع",
|
||||
"replayCamera": "دوربین بازپخش",
|
||||
"initializingReplay": "در حال راهاندازی بازپخش اشکالزدایی...",
|
||||
"stoppingReplay": "در حال توقف بازپخش اشکالزدایی...",
|
||||
"stopReplay": "توقف بازپخش",
|
||||
"confirmStop": {
|
||||
"title": "توقف بازپخش اشکالزدایی؟",
|
||||
"description": "این کار نشست را متوقف کرده و همه دادههای موقت را پاک میکند. مطمئن هستید؟",
|
||||
"confirm": "توقف بازپخش",
|
||||
"cancel": "لغو"
|
||||
},
|
||||
"activity": "فعالیت",
|
||||
"objects": "فهرست اشیا",
|
||||
"audioDetections": "تشخیصهای صوتی",
|
||||
"noActivity": "هیچ فعالیتی شناسایی نشد",
|
||||
"activeTracking": "ردیابی فعال",
|
||||
"noActiveTracking": "ردیابی فعالی وجود ندارد",
|
||||
"configuration": "پیکربندی",
|
||||
"configurationDesc": "تنظیمات تشخیص حرکت و ردیابی شیء را برای دوربین بازپخش اشکالزدایی دقیق تنظیم کنید. هیچ تغییری در فایل پیکربندی فریگیت شما ذخیره نمیشود."
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,8 @@
|
||||
"logs": {
|
||||
"frigate": "ثبت رخدادهای فریگیت - فریگیت",
|
||||
"go2rtc": "گزارشهای Go2RTC - فریگیت",
|
||||
"nginx": "گزارشهای Nginx - فریگیت"
|
||||
"nginx": "گزارشهای Nginx - فریگیت",
|
||||
"websocket": "گزارشهای پیامها - فریگیت"
|
||||
}
|
||||
},
|
||||
"title": "سیستم",
|
||||
@@ -33,6 +34,33 @@
|
||||
"fetchingLogsFailed": "خطا در دریافت گزارشها: {{errorMessage}}",
|
||||
"whileStreamingLogs": "خطا هنگام پخش زندهٔ گزارشها: {{errorMessage}}"
|
||||
}
|
||||
},
|
||||
"websocket": {
|
||||
"label": "پیامها",
|
||||
"pause": "توقف",
|
||||
"resume": "ادامه",
|
||||
"clear": "پاک کردن",
|
||||
"filter": {
|
||||
"all": "همهٔ موضوعات",
|
||||
"topics": "موضوعات",
|
||||
"events": "رویدادها",
|
||||
"reviews": "بازبینیها",
|
||||
"classification": "طبقهبندی",
|
||||
"face_recognition": "شناسایی چهره",
|
||||
"lpr": "پلاکخوان",
|
||||
"camera_activity": "فعالیت دوربین",
|
||||
"system": "سامانه",
|
||||
"camera": "دوربین",
|
||||
"all_cameras": "همهٔ دوربینها",
|
||||
"cameras_count_one": "{{count}} دوربین",
|
||||
"cameras_count_other": "{{count}} دوربین"
|
||||
},
|
||||
"empty": "هنوز پیامی ثبت نشده است",
|
||||
"count_one": "{{count}} پیام",
|
||||
"count_other": "{{count}} پیام",
|
||||
"expanded": {
|
||||
"payload": "بار"
|
||||
}
|
||||
}
|
||||
},
|
||||
"general": {
|
||||
@@ -72,7 +100,10 @@
|
||||
"title": "هشدار آمار GPU اینتل",
|
||||
"message": "آمار GPU در دسترس نیست",
|
||||
"description": "این یک باگ شناختهشده در ابزارهای گزارشدهی آمار GPU اینتل (intel_gpu_top) است که باعث میشود از کار بیفتد و حتی در مواردی که شتابدهی سختافزاری و تشخیص شیء بهدرستی روی (i)GPU اجرا میشوند، بهطور مکرر میزان استفادهٔ GPU را ۰٪ برگرداند. این مشکل مربوط به Frigate نیست. میتوانید میزبان را ریاستارت کنید تا موقتاً مشکل برطرف شود و تأیید کنید که GPU درست کار میکند. این موضوع روی عملکرد تأثیری ندارد."
|
||||
}
|
||||
},
|
||||
"gpuCompute": "محاسبات / رمزگذاری GPU",
|
||||
"gpuTemperature": "دمای GPU",
|
||||
"npuTemperature": "دمای NPU"
|
||||
},
|
||||
"title": "عمومی",
|
||||
"detector": {
|
||||
@@ -86,7 +117,14 @@
|
||||
"otherProcesses": {
|
||||
"title": "فرایندهای دیگر",
|
||||
"processCpuUsage": "میزان استفادهٔ CPU فرایند",
|
||||
"processMemoryUsage": "میزان استفادهٔ حافظهٔ فرایند"
|
||||
"processMemoryUsage": "میزان استفادهٔ حافظهٔ فرایند",
|
||||
"series": {
|
||||
"go2rtc": "go2rtc",
|
||||
"recording": "ضبط",
|
||||
"review_segment": "بخش بازبینی",
|
||||
"embeddings": "امبدینگها",
|
||||
"audio_detector": "آشکارساز صدا"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
@@ -97,7 +135,11 @@
|
||||
},
|
||||
"shm": {
|
||||
"warning": "اندازهٔ فعلی SHM برابر {{total}}MB خیلی کوچک است. آن را دستکم به {{min_shm}}MB افزایش دهید.",
|
||||
"title": "اختصاص SHM (حافظهٔ اشتراکی)"
|
||||
"title": "اختصاص SHM (حافظهٔ اشتراکی)",
|
||||
"frameLifetime": {
|
||||
"title": "طول عمر فریم",
|
||||
"description": "هر دوربین {{frames}} شیار فریم در حافظهٔ اشتراکی دارد. با نرخ فریم سریعترین دوربین، هر فریم تقریباً بهمدت {{lifetime}} ثانیه قبل از بازنویسی در دسترس است."
|
||||
}
|
||||
},
|
||||
"cameraStorage": {
|
||||
"title": "ذخیرهسازی دوربین",
|
||||
@@ -131,6 +173,21 @@
|
||||
"error": "خطا: {{error}}",
|
||||
"tips": {
|
||||
"title": "اطلاعات بررسی دوربین"
|
||||
},
|
||||
"keyframes": {
|
||||
"title": "تحلیل فریمکلیدی",
|
||||
"analyzing": "در حال تحلیل فریمهای کلیدی... {{seconds}} ثانیه باقی مانده است",
|
||||
"stillAnalyzing": "هنوز در حال تحلیل فریمهای کلیدی...",
|
||||
"recordStream": "جریان ضبط:",
|
||||
"keyframeCount": "فریمهای کلیدی مشاهدهشده:",
|
||||
"observedDuration": "مدت مشاهدهشده:",
|
||||
"gap": "فاصلهٔ فریم کلیدی (حداقل / میانگین / حداکثر):",
|
||||
"segmentLength": "طول بخش ضبط:",
|
||||
"ok": "فریمهای کلیدی هر ~{{seconds}} ثانیه، مناسب برای ضبط و پخش.",
|
||||
"warning": "فریمهای کلیدی پراکنده یا متغیر (طولانیترین فاصله ~{{seconds}} ثانیه)، احتمالاً کدک هوشمند (H.264+/H.265+) است، توصیه نمیشود.",
|
||||
"error": "فاصلهٔ فریم کلیدی (~{{seconds}} ثانیه) از طول بخش ضبط ({{segmentTime}} ثانیه) بیشتر است. ممکن است برخی بخشها فریم کلیدی نداشته باشند که پخش را خراب میکند. کدک هوشمند/+ را روی دوربین غیرفعال کنید یا فاصلهٔ فریم کلیدی را کوتاه کنید.",
|
||||
"unknown": "فاصلهبندی فریم کلیدی قابل تشخیص نیست.",
|
||||
"recordDisabled": "ضبط برای این دوربین غیرفعال است."
|
||||
}
|
||||
},
|
||||
"framesAndDetections": "فریمها / تشخیصها",
|
||||
@@ -148,7 +205,8 @@
|
||||
"cameraDetect": "تشخیص {{camName}}",
|
||||
"cameraFfmpeg": "{{camName}} FFmpeg",
|
||||
"cameraFramesPerSecond": "{{camName}} فریم بر ثانیه",
|
||||
"cameraSkippedDetectionsPerSecond": "{{camName}} تشخیصهای ردشده در ثانیه"
|
||||
"cameraSkippedDetectionsPerSecond": "{{camName}} تشخیصهای ردشده در ثانیه",
|
||||
"cameraGpu": "GPU {{camName}}"
|
||||
},
|
||||
"toast": {
|
||||
"error": {
|
||||
@@ -158,7 +216,21 @@
|
||||
"copyToClipboard": "دادههای بررسی در کلیپبورد کپی شد."
|
||||
}
|
||||
},
|
||||
"title": "دوربینها"
|
||||
"title": "دوربینها",
|
||||
"noCameras": {
|
||||
"title": "هیچ دوربینی یافت نشد"
|
||||
},
|
||||
"connectionQuality": {
|
||||
"title": "کیفیت اتصال",
|
||||
"excellent": "عالی",
|
||||
"fair": "قابل قبول",
|
||||
"poor": "ضعیف",
|
||||
"unusable": "غیرقابلاستفاده",
|
||||
"fps": "FPS",
|
||||
"expectedFps": "FPS مورد انتظار",
|
||||
"reconnectsLastHour": "اتصال مجدد (ساعت گذشته)",
|
||||
"stallsLastHour": "توقفها (ساعت گذشته)"
|
||||
}
|
||||
},
|
||||
"stats": {
|
||||
"ffmpegHighCpuUsage": "{{camera}} استفادهٔ CPU بالایی برای FFmpeg دارد ({{ffmpegAvg}}%)",
|
||||
@@ -168,7 +240,8 @@
|
||||
"detectIsVerySlow": "{{detect}} بسیار کند است ({{speed}} ms)",
|
||||
"shmTooLow": "اختصاص /dev/shm ({{total}} MB) باید دستکم تا {{min}} MB افزایش یابد.",
|
||||
"healthy": "سامانه سالم است",
|
||||
"detectIsSlow": "{{detect}} کند است ( {{speed}} میلیثانیه )"
|
||||
"detectIsSlow": "{{detect}} کند است ( {{speed}} میلیثانیه )",
|
||||
"debugReplayActive": "نشست بازپخش اشکالزدایی فعال است"
|
||||
},
|
||||
"enrichments": {
|
||||
"infPerSecond": "استنتاجها در ثانیه",
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
"selectOrExport": "Sélectionner ou exporter",
|
||||
"toast": {
|
||||
"error": {
|
||||
"failed": "Échec du démarrage de l'exportation : {{error}}",
|
||||
"failed": "Échec de la mis en attente de l'exportation : {{error}}",
|
||||
"endTimeMustAfterStartTime": "L'heure de fin doit être postérieure à l'heure de début.",
|
||||
"noVaildTimeSelected": "La plage horaire sélectionnée n'est pas valide.",
|
||||
"noValidTimeSelected": "Interval de temps invalide"
|
||||
@@ -78,9 +78,11 @@
|
||||
"batchSuccess_other": "{{count}} exports démarrés. Ouverture du dossier en cours.",
|
||||
"batchPartial": "{{successful}} exports ont démarré sur un total de : {{total}}. Échecs : {{failedCameras}}",
|
||||
"batchFailed": "Échec du démarrage de {{total}} exports. Échecs : {{failedCameras}}",
|
||||
"batchQueuedSuccess_one": "Un export rajouté au traitement. Ouverture du dossier en cours.",
|
||||
"batchQueuedSuccess_many": "{{count}} exports rajoutés au traitement. Ouverture du dossier en cours.",
|
||||
"batchQueuedSuccess_other": "{{count}} exports rajoutés au traitement. Ouverture du dossier en cours."
|
||||
"batchQueuedSuccess_one": "Un export ajouté au traitement. Ouverture du dossier en cours.",
|
||||
"batchQueuedSuccess_many": "{{count}} exports ajoutés au traitement. Ouverture du dossier en cours.",
|
||||
"batchQueuedSuccess_other": "{{count}} exports rajoutés au traitement. Ouverture du dossier en cours.",
|
||||
"batchQueuedPartial": "{{successful}} export(s) sur {{total}} mis en file d'attente. Caméra(s) en échec : {{failedCameras}}",
|
||||
"batchQueueFailed": "Échec de mis en file d'attente de {{total}} export(s). Caméras en échec : {{failedCameras}}"
|
||||
},
|
||||
"select": "Sélectionner",
|
||||
"name": {
|
||||
@@ -144,7 +146,7 @@
|
||||
"toast": {
|
||||
"started_one": "Un export a démarré. Ouverture du dossier en cours.",
|
||||
"started_many": "{{count}} exports ont démarré. Ouverture du dossier en cours.",
|
||||
"started_other": "{{count}} exports ont démarré. Ouverture du dossier en cours.",
|
||||
"started_other": "{{count}} exports ont démarré. Ouverture du dossier en cours",
|
||||
"startedNoCase_one": "Un export a démarré.",
|
||||
"startedNoCase_many": "{{count}} exports ont démarré.",
|
||||
"startedNoCase_other": "{{count}} exports ont démarré.",
|
||||
@@ -201,7 +203,11 @@
|
||||
},
|
||||
"shareTimestamp": {
|
||||
"label": "Partager cette date",
|
||||
"title": "Partager cette date"
|
||||
"title": "Partager cette date",
|
||||
"description": "Partager une URL horodatée correspondant à la position actuelle du lecteur, ou choisir un horodatage personnalisé. Notez qu’il ne s’agit pas d’un lien de partage public et qu’il est uniquement accessible aux utilisateurs ayant accès à Frigate et à cette caméra.",
|
||||
"custom": "Horodatage personnalisé",
|
||||
"button": "Partager le lien de l'horodatage",
|
||||
"shareTitle": "Horodatage de la revue Frigate : {{camera}}"
|
||||
}
|
||||
},
|
||||
"imagePicker": {
|
||||
|
||||
@@ -48,5 +48,6 @@
|
||||
"success": {
|
||||
"submittedFrigatePlus": "Image soumise avec succès à Frigate+"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cameraOff": "La camera est éteinte"
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"description": "Activé"
|
||||
},
|
||||
"audio": {
|
||||
"label": "Événements audio",
|
||||
"label": "Détection audio",
|
||||
"description": "Réglages pour la détection des événements audio de cette caméra.",
|
||||
"enabled": {
|
||||
"label": "Activer la détection audio",
|
||||
@@ -28,11 +28,15 @@
|
||||
},
|
||||
"listen": {
|
||||
"label": "Types d'écoute",
|
||||
"description": "Liste des types d'événements audio à détecter (p. ex. bark, fire_alarm, scream, speech, yell)."
|
||||
"description": "Liste des types d'événements audio à détecter (exemples : aboiements, alertes incendies, voix, cris)."
|
||||
},
|
||||
"filters": {
|
||||
"label": "Filtres audio",
|
||||
"description": "Réglages des filtres par type audio, tels que seuils de confiance utilisé afin de réduire les faux positifs."
|
||||
"description": "Réglages des filtres par type audio, tels que seuils de confiance utilisé afin de réduire les faux positifs.",
|
||||
"threshold": {
|
||||
"label": "Minimum de confiance audio",
|
||||
"description": "Seuil de confiance minimum pour que l'événement audio soit pris en compte."
|
||||
}
|
||||
},
|
||||
"enabled_in_config": {
|
||||
"label": "État audio original",
|
||||
@@ -67,7 +71,7 @@
|
||||
},
|
||||
"mode": {
|
||||
"label": "Mode de suivi",
|
||||
"description": "Mode pour l'inclusion des caméras dans la vue à vol d'oiseau: 'objects', 'motion', ou 'continuous'."
|
||||
"description": "Mode pour l'inclusion des caméras dans la vue à vol d'oiseau : 'objects', 'motion', ou 'continuous'."
|
||||
},
|
||||
"order": {
|
||||
"label": "Emplacement",
|
||||
@@ -147,11 +151,11 @@
|
||||
}
|
||||
},
|
||||
"ffmpeg": {
|
||||
"label": "FFmpeg",
|
||||
"description": "Réglages de FFmpeg incluant l'emplacement du fichier binaire, les arguments, les options pour hwaccel et les arguments de sortie par rôle.",
|
||||
"label": "Flots (FFmpeg)",
|
||||
"description": "Arguments d'entrée du flux de la caméra et réglages de FFmpeg incluant l'emplacement du fichier binaire, les arguments, hwaccel et les arguments de sortie par rôle.",
|
||||
"path": {
|
||||
"label": "Emplacement de FFmpeg",
|
||||
"description": "Emplacement du fichier binaire de FFmpeg à utiliser ou un alias de version (peut être «5.0» ou «7.0»)."
|
||||
"description": "Emplacement du fichier binaire de FFmpeg à utiliser ou un alias de version (peut être «7.0» ou «8.0»)."
|
||||
},
|
||||
"global_args": {
|
||||
"label": "Arguments globaux de FFmpeg",
|
||||
@@ -263,7 +267,7 @@
|
||||
},
|
||||
"lightning_threshold": {
|
||||
"label": "Seuil d'éclairage",
|
||||
"description": "Seuil permettant de détecter et d'ignorer les brusques pointes d'éclairage (plus la valeur est faible, plus la sensibilité est élevée, valeurs comprises entre 0.3 et 1.0)."
|
||||
"description": "Seuil permettant de détecter et d'ignorer les brusques pointes d'éclairage (plus la valeur est faible, plus la sensibilité est élevée, valeurs comprises entre 0.3 et 1.0). Cela ne permet pas d'éviter complètement la détection de mouvement mais simplement d'arrêter l'analyse d'autres images une fois que le seuil est dépassé. Les enregistrements basés sur le mouvement sont tout de même créés lors de ces événements."
|
||||
},
|
||||
"improve_contrast": {
|
||||
"label": "Améliorer le contraste",
|
||||
@@ -324,9 +328,201 @@
|
||||
"description": "Zone de boite englobante maximum (pixels ou pourcentage) autorisée pour ce type d'objet. Peut être en pixels (entier) ou pourcentage (décimale entre 0,000001 and 0,99)."
|
||||
},
|
||||
"min_ratio": {
|
||||
"label": "Rapport d'aspect minimal"
|
||||
"label": "Rapport d'aspect minimal",
|
||||
"description": "Rapport largeur/hauteur minimal requis pour que la boîte englobante soit prise en compte."
|
||||
},
|
||||
"max_ratio": {
|
||||
"label": "Rapport d’aspect maximal",
|
||||
"description": "Rapport largeur/hauteur maximal requis pour que la boîte englobante soit prise en compte."
|
||||
},
|
||||
"threshold": {
|
||||
"label": "Seuil de confiance",
|
||||
"description": "Seuil de confiance moyen requis pour que l'objet soit considéré comme un vrai positif."
|
||||
},
|
||||
"min_score": {
|
||||
"label": "Confiance minimum",
|
||||
"description": "Confiance minimum pour la détection basée sur une image pour que l'objet soit prise en compte."
|
||||
},
|
||||
"mask": {
|
||||
"label": "Masque de filtre",
|
||||
"description": "Coordonnées du polygone indiquant où le filtre s'applique dans l'image."
|
||||
},
|
||||
"raw_mask": {
|
||||
"label": "Masque brut"
|
||||
}
|
||||
},
|
||||
"mask": {
|
||||
"label": "Masque de l'objet",
|
||||
"description": "Masque du polygone à utiliser pour prévenir la détection d'objets dans des zones spécifiques."
|
||||
},
|
||||
"raw_mask": {
|
||||
"label": "Masque brut"
|
||||
},
|
||||
"genai": {
|
||||
"label": "Configuration d'objet GenAI",
|
||||
"description": "Réglages GenAI pour décrire les objets suivis et envoyer images pour la génération.",
|
||||
"enabled": {
|
||||
"label": "Activer GenAI",
|
||||
"description": "Activer la génération GenAI des descriptions pour les objets suivies par défaut."
|
||||
},
|
||||
"use_snapshot": {
|
||||
"label": "Utiliser les instantanées",
|
||||
"description": "Utiliser les objets instantanés à la place des miniatures pour la génération de description GenAI."
|
||||
},
|
||||
"prompt": {
|
||||
"label": "Invite de légende",
|
||||
"description": "Modèle d’invite par défaut utilisé lors de la génération de descriptions avec GenAI."
|
||||
},
|
||||
"object_prompts": {
|
||||
"label": "Invites d'objets",
|
||||
"description": "Invites par objets pour personnaliser les sorties GenAI pour des légendes spécifiques."
|
||||
},
|
||||
"objects": {
|
||||
"label": "Objets GenAI",
|
||||
"description": "Liste des légendes d'objets à envoyer à GenAI par défaut."
|
||||
},
|
||||
"required_zones": {
|
||||
"label": "Zones requises",
|
||||
"description": "Zones qui doivent être entrées pour que les objets soit pris en compte pour la génération de description GenAI."
|
||||
},
|
||||
"debug_save_thumbnails": {
|
||||
"label": "Sauvegarder miniatures",
|
||||
"description": "Sauvegarder les miniatures envoyés à GenAI pour le débogage et la revue."
|
||||
},
|
||||
"send_triggers": {
|
||||
"label": "Activations GenAI",
|
||||
"description": "Définition des images qui devraient être envoyés à GenAI (quand terminé, après màj, etc.).",
|
||||
"tracked_object_end": {
|
||||
"label": "Envoyé quand terminé",
|
||||
"description": "Envoyé une requête à GenAI quand le suivi de l'objet se termine."
|
||||
},
|
||||
"after_significant_updates": {
|
||||
"label": "Déclenchement anticipé de GenAI",
|
||||
"description": "Envoyé une requête à GenAI après une nombre donné de mise à jour significatives pour l'objet suivi."
|
||||
}
|
||||
},
|
||||
"enabled_in_config": {
|
||||
"label": "État original de GenAI",
|
||||
"description": "Indique si GenAI était activé dans la configuration statique original."
|
||||
}
|
||||
}
|
||||
},
|
||||
"label": "Configuration de la caméra"
|
||||
"label": "Configuration de la caméra",
|
||||
"record": {
|
||||
"label": "Enregistrement",
|
||||
"description": "Réglages des enregistrement et des options de rétentions pour cette caméra.",
|
||||
"enabled": {
|
||||
"label": "Activer enregistrement",
|
||||
"description": "Activer ou désactiver l'enregistrement pour cette caméra."
|
||||
},
|
||||
"expire_interval": {
|
||||
"label": "Intervalle de nettoyage des enregistrements",
|
||||
"description": "Nombre de minutes entre les opérations de nettoyage supprimant les segments d’enregistrement expirés."
|
||||
},
|
||||
"continuous": {
|
||||
"label": "Rétention continue",
|
||||
"description": "Nombre de jours de rétention des enregistrements sans prendre en compte les objets suivis ou les mouvements. Mettre à 0 si vous voulez seulement garder les enregistrements des alertes et des détections.",
|
||||
"days": {
|
||||
"label": "Nombre de jours de rétention",
|
||||
"description": "Nombre de jours de rétention des enregistrements."
|
||||
}
|
||||
},
|
||||
"motion": {
|
||||
"label": "Rétention des mouvements",
|
||||
"description": "Nombre de jours de rétention des enregistrements déclenchés par des mouvements sans prendre en compte les objets suivis. Mettre à 0 si vous voulez seulement garder les enregistrements des alertes et des détections.",
|
||||
"days": {
|
||||
"label": "Nombre de jours de rétention",
|
||||
"description": "Nombre de jours de rétention des enregistrements."
|
||||
}
|
||||
},
|
||||
"detections": {
|
||||
"label": "Rétention des détections",
|
||||
"description": "Réglages de la rétention des enregistrements pour les événements des détection incluant les durées de captures antérieur et postérieur.",
|
||||
"pre_capture": {
|
||||
"label": "Nombre de secondes antérieur à la capture",
|
||||
"description": "Nombre de secondes antérieur à l'événement de détection à inclure dans l'enregistrement."
|
||||
},
|
||||
"post_capture": {
|
||||
"label": "Nombre de secondes posterieur à l'événement",
|
||||
"description": "Nombre de secondes posterieur à l'événement de détection à inclure dans l'enregistrement."
|
||||
},
|
||||
"retain": {
|
||||
"label": "Rétention d'événement",
|
||||
"description": "Réglages de la rétention pour les enregistrements d'événements de détection.",
|
||||
"days": {
|
||||
"label": "Nombre de jours de rétention",
|
||||
"description": "Nombre de jours de rétention des enregistrements d'événements de détection."
|
||||
},
|
||||
"mode": {
|
||||
"label": "Mode de rétention",
|
||||
"description": "Mode de rétention : tout (sauvegarder tout les segments), mouvement (sauvegarder les segments avec mouvement), active_objects (sauvegarder les segments avec des objets actifs)."
|
||||
}
|
||||
}
|
||||
},
|
||||
"alerts": {
|
||||
"label": "Rétention d'alerte",
|
||||
"description": "Réglages de la rétention des enregistrements des événements d'alertes incluant les durées des captures antérieur et postérieur.",
|
||||
"pre_capture": {
|
||||
"label": "Nombre de secondes antérieur à la capture",
|
||||
"description": "Nombre de secondes antérieur à l'événement de détection à inclure dans l'enregistrement."
|
||||
},
|
||||
"post_capture": {
|
||||
"label": "Nombre de secondes postérieur à la capture",
|
||||
"description": "Nombre de secondes postérieur à l'événement de capture à inclure dans l'enregistrement."
|
||||
},
|
||||
"retain": {
|
||||
"label": "Rétention d'événement",
|
||||
"description": "Réglages de la rétention pour les enregistrement d'événements de détection.",
|
||||
"days": {
|
||||
"label": "Nombre de jours de rétention",
|
||||
"description": "Nombre de jours de rétention pour les enregistrements d'événements de détection."
|
||||
},
|
||||
"mode": {
|
||||
"label": "Mode de rétention",
|
||||
"description": "Mode de rétention : tout (sauvegarder tout les segments), mouvement (sauvegarder les segments avec mouvement), active_objects (sauvegarder les segments avec des objets actifs)."
|
||||
}
|
||||
}
|
||||
},
|
||||
"export": {
|
||||
"label": "Exporter la configuration",
|
||||
"description": "Réglages à utiliser lors de l'exportation des enregistrements tels que le timelapse et l'accélération matérielle.",
|
||||
"hwaccel_args": {
|
||||
"label": "Exporter les arguments hwaccel",
|
||||
"description": "Arguments d’accélération matérielle à utiliser pour les opérations d’exportation et de transcodage."
|
||||
},
|
||||
"max_concurrent": {
|
||||
"label": "Nombre maximum d'exports simultanés",
|
||||
"description": "Nombre maximum de tâches d'exportation à effectuer en même temps."
|
||||
}
|
||||
},
|
||||
"preview": {
|
||||
"label": "Prévisualiser la configuration",
|
||||
"quality": {
|
||||
"label": "Prévisualisation qualité",
|
||||
"description": "Niveau de qualité des prévisualisations (très_bas, bas, moyen, haut, très_haut)."
|
||||
}
|
||||
},
|
||||
"enabled_in_config": {
|
||||
"label": "État original de l'enregistrement",
|
||||
"description": "Indique si l'enregistrement était activé dans la configuration statique originale."
|
||||
}
|
||||
},
|
||||
"review": {
|
||||
"label": "Révision",
|
||||
"description": "Paramètres contrôlant les alertes, les détections, et les résumés de revue générés par IA utilisés dans l'interface utilisateur et le stockage pour cette caméra.",
|
||||
"alerts": {
|
||||
"label": "Configuration des alertes",
|
||||
"enabled": {
|
||||
"label": "Active les alertes",
|
||||
"description": "Active ou désactive la génération d'alertes pour cette caméra."
|
||||
},
|
||||
"labels": {
|
||||
"label": "Étiquettes d'alertes",
|
||||
"description": "Liste d'étiquettes d'objets considérés comme des alertes (par exemple : voiture, personne)."
|
||||
},
|
||||
"required_zones": {
|
||||
"label": "Zones requises"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +86,9 @@
|
||||
"birdseye": {
|
||||
"quality": {
|
||||
"label": "Qualité d'encodage"
|
||||
},
|
||||
"mode": {
|
||||
"description": "Mode pour l'inclusion des caméras dans la vue à vol d'oiseau : 'objects', 'motion', ou 'continuous'."
|
||||
}
|
||||
},
|
||||
"detect": {
|
||||
@@ -97,6 +100,12 @@
|
||||
"skip_motion_threshold": {
|
||||
"label": "Ignorer le seuil de détection de mouvement",
|
||||
"description": "Si une valeur entre 0,0 et 1,0 est définie, et que plus de cette fraction de l'image change en une seule trame, le détecteur ne retournera aucune zone de mouvement et se recalibrera immédiatement. Cela peut économiser du CPU et réduire les faux positifs lors d'éclairs, d'orages, etc., mais peut manquer des événements réels comme une caméra PTZ suivant automatiquement un objet. Le compromis est entre perdre quelques mégaoctets d'enregistrements ou visionner quelques courts clips. Laisser vide (None) pour désactiver cette fonctionnalité."
|
||||
},
|
||||
"lightning_threshold": {
|
||||
"description": "Seuil permettant de détecter et d'ignorer les brusques pointes d'éclairage (plus la valeur est faible, plus la sensibilité est élevée, valeurs comprises entre 0.3 et 1.0). Cela ne permet pas d'éviter complètement la détection de mouvement mais simplement d'arrêter l'analyse d'autres images une fois que le seuil est dépassé. Les enregistrements basés sur le mouvement sont tout de même créés lors de ces événements."
|
||||
},
|
||||
"improve_contrast": {
|
||||
"description": "Appliquer les amélioration du contraste aux images avant l'analyse de mouvement afin d'améliorer la détection."
|
||||
}
|
||||
},
|
||||
"objects": {
|
||||
@@ -109,11 +118,229 @@
|
||||
"description": "Zone de boite englobante maximum (pixels ou pourcentage) autorisée pour ce type d'objet. Peut être en pixels (entier) ou pourcentage (décimale entre 0,000001 and 0,99)."
|
||||
},
|
||||
"min_ratio": {
|
||||
"label": "Rapport d'aspect minimal"
|
||||
"label": "Rapport d'aspect minimal",
|
||||
"description": "Rapport largeur/hauteur minimal requis pour que la boîte englobante soit prise en compte."
|
||||
},
|
||||
"max_ratio": {
|
||||
"label": "Rapport d’aspect maximal",
|
||||
"description": "Rapport largeur/hauteur maximal requis pour que la boîte englobante soit prise en compte."
|
||||
},
|
||||
"threshold": {
|
||||
"label": "Seuil de confiance",
|
||||
"description": "Seuil de confiance moyen requis pour que l'objet soit considéré comme un vrai positif."
|
||||
},
|
||||
"min_score": {
|
||||
"label": "Confiance minimum",
|
||||
"description": "Confiance minimum pour la détection basée sur une image pour que l'objet soit prise en compte."
|
||||
},
|
||||
"mask": {
|
||||
"label": "Masque de filtre",
|
||||
"description": "Coordonnées du polygone indiquant où le filtre s'applique dans l'image."
|
||||
},
|
||||
"raw_mask": {
|
||||
"label": "Masque brut"
|
||||
},
|
||||
"label": "Filtres d'objets"
|
||||
},
|
||||
"mask": {
|
||||
"label": "Masque de l'objet",
|
||||
"description": "Masque du polygone à utiliser pour prévenir la détection d'objets dans des zones spécifiques."
|
||||
},
|
||||
"raw_mask": {
|
||||
"label": "Masque brut"
|
||||
},
|
||||
"genai": {
|
||||
"label": "Configuration d'objet GenAI",
|
||||
"description": "Réglages GenAI pour décrire les objets suivis et envoyer images pour la génération.",
|
||||
"enabled": {
|
||||
"label": "Activer GenAI",
|
||||
"description": "Activer la génération GenAI des descriptions pour les objets suivies par défaut."
|
||||
},
|
||||
"use_snapshot": {
|
||||
"label": "Utiliser les instantanées",
|
||||
"description": "Utiliser les objets instantanés à la place des miniatures pour la génération de description GenAI."
|
||||
},
|
||||
"prompt": {
|
||||
"label": "Invite de légende",
|
||||
"description": "Modèle d’invite par défaut utilisé lors de la génération de descriptions avec GenAI."
|
||||
},
|
||||
"object_prompts": {
|
||||
"label": "Invites d'objets",
|
||||
"description": "Invites par objets pour personnaliser les sorties GenAI pour des légendes spécifiques."
|
||||
},
|
||||
"objects": {
|
||||
"label": "Objets GenAI",
|
||||
"description": "Liste des légendes d'objets à envoyer à GenAI par défaut."
|
||||
},
|
||||
"required_zones": {
|
||||
"label": "Zones requises",
|
||||
"description": "Zones qui doivent être entrées pour que les objets soit pris en compte pour la génération de description GenAI."
|
||||
},
|
||||
"debug_save_thumbnails": {
|
||||
"label": "Sauvegarder miniatures",
|
||||
"description": "Sauvegarder les miniatures envoyés à GenAI pour le débogage et la revue."
|
||||
},
|
||||
"send_triggers": {
|
||||
"label": "Activations GenAI",
|
||||
"description": "Définition des images qui devraient être envoyés à GenAI (quand terminé, après màj, etc.).",
|
||||
"tracked_object_end": {
|
||||
"label": "Envoyé quand terminé",
|
||||
"description": "Envoyé une requête à GenAI quand le suivi de l'objet se termine."
|
||||
},
|
||||
"after_significant_updates": {
|
||||
"label": "Déclenchement anticipé de GenAI",
|
||||
"description": "Envoyé une requête à GenAI après une nombre donné de mise à jour significatives pour l'objet suivi."
|
||||
}
|
||||
},
|
||||
"enabled_in_config": {
|
||||
"label": "État original de GenAI",
|
||||
"description": "Indique si GenAI était activé dans la configuration statique original."
|
||||
}
|
||||
}
|
||||
},
|
||||
"audio_transcription": {
|
||||
"label": "Transcription audio"
|
||||
},
|
||||
"audio": {
|
||||
"label": "Détection audio",
|
||||
"listen": {
|
||||
"description": "Liste des types d'événements audio à détecter (exemples : aboiements, alertes incendies, voix, cris)."
|
||||
},
|
||||
"filters": {
|
||||
"threshold": {
|
||||
"label": "Minimum de confiance audio",
|
||||
"description": "Seuil de confiance minimum pour que l'événement audio soit pris en compte."
|
||||
}
|
||||
}
|
||||
},
|
||||
"ffmpeg": {
|
||||
"path": {
|
||||
"description": "Emplacement du fichier binaire de FFmpeg à utiliser ou un alias de version (peut être «7.0» ou «8.0»)."
|
||||
},
|
||||
"output_args": {
|
||||
"description": "Arguments de sortie par défaut utilisés pour les différents rôles FFmpeg, tels que detect et record.",
|
||||
"detect": {
|
||||
"description": "Arguments de sortie par défaut pour les flux du rôle detect."
|
||||
},
|
||||
"record": {
|
||||
"description": "Arguments de sortie par défaut pour les flux du rôle record."
|
||||
}
|
||||
},
|
||||
"apple_compatibility": {
|
||||
"description": "Activer l'étiquetage HEVC pour une meilleure compatibilité avec les lecteurs Apple lors de l'enregistrement H.265."
|
||||
}
|
||||
},
|
||||
"record": {
|
||||
"label": "Enregistrement",
|
||||
"enabled": {
|
||||
"label": "Activer enregistrement"
|
||||
},
|
||||
"expire_interval": {
|
||||
"label": "Intervalle de nettoyage des enregistrements",
|
||||
"description": "Nombre de minutes entre les opérations de nettoyage supprimant les segments d’enregistrement expirés."
|
||||
},
|
||||
"continuous": {
|
||||
"label": "Rétention continue",
|
||||
"description": "Nombre de jours de rétention des enregistrements sans prendre en compte les objets suivis ou les mouvements. Mettre à 0 si vous voulez seulement garder les enregistrements des alertes et des détections.",
|
||||
"days": {
|
||||
"label": "Nombre de jours de rétention",
|
||||
"description": "Nombre de jours de rétention des enregistrements."
|
||||
}
|
||||
},
|
||||
"motion": {
|
||||
"label": "Rétention des mouvements",
|
||||
"description": "Nombre de jours de rétention des enregistrements déclenchés par des mouvements sans prendre en compte les objets suivis. Mettre à 0 si vous voulez seulement garder les enregistrements des alertes et des détections.",
|
||||
"days": {
|
||||
"label": "Nombre de jours de rétention",
|
||||
"description": "Nombre de jours de rétention des enregistrements."
|
||||
}
|
||||
},
|
||||
"detections": {
|
||||
"label": "Rétention des détections",
|
||||
"description": "Réglages de la rétention des enregistrements pour les événements des détection incluant les durées de captures antérieur et postérieur.",
|
||||
"pre_capture": {
|
||||
"label": "Nombre de secondes antérieur à la capture",
|
||||
"description": "Nombre de secondes antérieur à l'événement de détection à inclure dans l'enregistrement."
|
||||
},
|
||||
"post_capture": {
|
||||
"label": "Nombre de secondes posterieur à l'événement",
|
||||
"description": "Nombre de secondes posterieur à l'événement de détection à inclure dans l'enregistrement."
|
||||
},
|
||||
"retain": {
|
||||
"label": "Rétention d'événement",
|
||||
"description": "Réglages de la rétention pour les enregistrements d'événements de détection.",
|
||||
"days": {
|
||||
"label": "Nombre de jours de rétention",
|
||||
"description": "Nombre de jours de rétention des enregistrements d'événements de détection."
|
||||
},
|
||||
"mode": {
|
||||
"label": "Mode de rétention",
|
||||
"description": "Mode de rétention : tout (sauvegarder tout les segments), mouvement (sauvegarder les segments avec mouvement), active_objects (sauvegarder les segments avec des objets actifs)."
|
||||
}
|
||||
}
|
||||
},
|
||||
"alerts": {
|
||||
"label": "Rétention d'alerte",
|
||||
"description": "Réglages de la rétention des enregistrements des événements d'alertes incluant les durées des captures antérieur et postérieur.",
|
||||
"pre_capture": {
|
||||
"label": "Nombre de secondes antérieur à la capture",
|
||||
"description": "Nombre de secondes antérieur à l'événement de détection à inclure dans l'enregistrement."
|
||||
},
|
||||
"post_capture": {
|
||||
"label": "Nombre de secondes postérieur à la capture",
|
||||
"description": "Nombre de secondes postérieur à l'événement de capture à inclure dans l'enregistrement."
|
||||
},
|
||||
"retain": {
|
||||
"label": "Rétention d'événement",
|
||||
"description": "Réglages de la rétention pour les enregistrement d'événements de détection.",
|
||||
"days": {
|
||||
"label": "Nombre de jours de rétention",
|
||||
"description": "Nombre de jours de rétention pour les enregistrements d'événements de détection."
|
||||
},
|
||||
"mode": {
|
||||
"label": "Mode de rétention",
|
||||
"description": "Mode de rétention : tout (sauvegarder tout les segments), mouvement (sauvegarder les segments avec mouvement), active_objects (sauvegarder les segments avec des objets actifs)."
|
||||
}
|
||||
}
|
||||
},
|
||||
"export": {
|
||||
"label": "Exporter la configuration",
|
||||
"description": "Réglages à utiliser lors de l'exportation des enregistrements tels que le timelapse et l'accélération matérielle.",
|
||||
"hwaccel_args": {
|
||||
"label": "Exporter les arguments hwaccel",
|
||||
"description": "Arguments d’accélération matérielle à utiliser pour les opérations d’exportation et de transcodage."
|
||||
},
|
||||
"max_concurrent": {
|
||||
"label": "Nombre maximum d'exports simultanés",
|
||||
"description": "Nombre maximum de tâches d'exportation à effectuer en même temps."
|
||||
}
|
||||
},
|
||||
"preview": {
|
||||
"label": "Prévisualiser la configuration",
|
||||
"quality": {
|
||||
"label": "Prévisualisation qualité",
|
||||
"description": "Niveau de qualité des prévisualisations (très_bas, bas, moyen, haut, très_haut)."
|
||||
}
|
||||
},
|
||||
"enabled_in_config": {
|
||||
"label": "État original de l'enregistrement",
|
||||
"description": "Indique si l'enregistrement était activé dans la configuration statique originale."
|
||||
}
|
||||
},
|
||||
"review": {
|
||||
"label": "Révision",
|
||||
"alerts": {
|
||||
"label": "Configuration des alertes",
|
||||
"enabled": {
|
||||
"label": "Active les alertes"
|
||||
},
|
||||
"labels": {
|
||||
"label": "Étiquettes d'alertes",
|
||||
"description": "Liste d'étiquettes d'objets considérés comme des alertes (par exemple : voiture, personne)."
|
||||
},
|
||||
"required_zones": {
|
||||
"label": "Zones requises"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -450,5 +450,10 @@
|
||||
"crushing": "Összenyom",
|
||||
"crumpling": "Gyűrődés",
|
||||
"beep": "Sípolás",
|
||||
"clang": "Csengés"
|
||||
"clang": "Csengés",
|
||||
"change_ringing": "Csengés módosítása",
|
||||
"basketball_bounce": "Kosárlabda pattogás",
|
||||
"sine_wave": "Szinusz hullám",
|
||||
"echo": "Visszhang",
|
||||
"cacophony": "Kakófónia"
|
||||
}
|
||||
|
||||
@@ -155,7 +155,9 @@
|
||||
"gl": "Galego (Galíciai)",
|
||||
"id": "Bahasa Indonesia (Indonéz)",
|
||||
"ur": "اردو (Urdu)",
|
||||
"hr": "Horvát"
|
||||
"hr": "Horvát",
|
||||
"zhHant": "Hagyományos kínai",
|
||||
"bs": "Bosnyák"
|
||||
},
|
||||
"uiPlayground": "UI játszótér",
|
||||
"faceLibrary": "Arc Könyvtár",
|
||||
@@ -179,7 +181,10 @@
|
||||
"systemLogs": "Rendszer naplók",
|
||||
"settings": "Beállítások",
|
||||
"classification": "Osztályozás",
|
||||
"profiles": "Profilok"
|
||||
"profiles": "Profilok",
|
||||
"actions": "Műveletek",
|
||||
"features": "Jellemzők",
|
||||
"chat": "Csevegés"
|
||||
},
|
||||
"role": {
|
||||
"viewer": "Néző",
|
||||
@@ -216,7 +221,8 @@
|
||||
"error": {
|
||||
"title": "Sikertelen konfiguráció mentés: {{errorMessage}}",
|
||||
"noMessage": "Konfigurációs változtatások mentése sikertelen"
|
||||
}
|
||||
},
|
||||
"success": "Kunfigurációs változtatások sikeresen mentve."
|
||||
}
|
||||
},
|
||||
"selectItem": "Kiválasztani {{item}}-et",
|
||||
@@ -275,7 +281,18 @@
|
||||
"deleteNow": "Törlés Most",
|
||||
"next": "Következő",
|
||||
"continue": "Tovább",
|
||||
"add": "Hozzáad"
|
||||
"add": "Hozzáad",
|
||||
"applying": "Alkalmazás…",
|
||||
"undo": "Vissza",
|
||||
"copiedToClipboard": "Vágólapra kimásolva",
|
||||
"modified": "Módosítva",
|
||||
"overridden": "Felülírva",
|
||||
"resetToDefault": "Visszatérés az alapbeállításhoz",
|
||||
"saveAll": "Minden mentésa",
|
||||
"savingAll": "Mentés…",
|
||||
"undoAll": "Mindent visszavonni",
|
||||
"retry": "Újra",
|
||||
"resetToGlobal": "Visszaállítás Globális helyzetbe"
|
||||
},
|
||||
"label": {
|
||||
"back": "Vissza",
|
||||
@@ -298,5 +315,10 @@
|
||||
"field": {
|
||||
"optional": "Opcionális",
|
||||
"internalID": "A belső ID, amelyet a Frigate használ a konfigurációban és az adatbázisban"
|
||||
},
|
||||
"no_items": "Nincsenek elemek",
|
||||
"validation_errors": "Érvényesítési Hibák",
|
||||
"credentialField": {
|
||||
"savedPlaceholder": "Mentve - hagyd üresen a jelenlegi megtartásáért"
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user