mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-06-26 22:31:54 +03:00
Compare commits
44 Commits
c0fce2752c
...
ebfd2a1332
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ebfd2a1332 | ||
|
|
03f4f76b72 | ||
|
|
6ffb9f2c9e | ||
|
|
b470258d95 | ||
|
|
fac11286f5 | ||
|
|
50f7f11f0b | ||
|
|
f96127c264 | ||
|
|
2ae415be6b | ||
|
|
59faa4e088 | ||
|
|
3df7c22f4d | ||
|
|
f4cbbe806d | ||
|
|
cfb1420660 | ||
|
|
161f56b5d4 | ||
|
|
5ddf8bc1b0 | ||
|
|
dc2c48f6d7 | ||
|
|
6e5d55ff64 | ||
|
|
d439b09f90 | ||
|
|
3f7768a48f | ||
|
|
7881bea60f | ||
|
|
b0b00fe1d0 | ||
|
|
b1de5e2290 | ||
|
|
4fdc107987 | ||
|
|
a83809de54 | ||
|
|
43d97acd21 | ||
|
|
d968f00500 | ||
|
|
620923c27e | ||
|
|
32daf6f494 | ||
|
|
7413ce08d4 | ||
|
|
b712e1fbd9 | ||
|
|
c6eadfebb8 | ||
|
|
d9c1ea908d | ||
|
|
78fc472026 | ||
|
|
c8cfb9400a | ||
|
|
ca75f06456 | ||
|
|
bd1fc1cc72 | ||
|
|
e20fc521b1 | ||
|
|
19ec6fa245 | ||
|
|
f1e2240945 | ||
|
|
4e90d254ed | ||
|
|
c67170aa20 | ||
|
|
e9432d55e8 | ||
|
|
1f154a0205 | ||
|
|
fb68e95725 | ||
|
|
6902de1d64 |
40
.github/copilot-instructions.md
vendored
40
.github/copilot-instructions.md
vendored
@ -162,7 +162,6 @@ When reviewing code, do NOT comment on:
|
||||
- **Linting**: ESLint (see `web/.eslintrc.cjs`)
|
||||
- **Formatting**: Prettier with Tailwind CSS plugin
|
||||
- **Type Safety**: TypeScript strict mode enabled
|
||||
- **Testing**: Vitest for unit tests
|
||||
|
||||
### Component Patterns
|
||||
|
||||
@ -233,6 +232,9 @@ ruff format frigate/
|
||||
|
||||
# Run linter
|
||||
ruff check frigate/
|
||||
|
||||
# Type check
|
||||
python3 -u -m mypy --config-file frigate/mypy.ini frigate
|
||||
```
|
||||
|
||||
### Frontend (from web/ directory)
|
||||
@ -252,6 +254,38 @@ npm run lint:fix
|
||||
|
||||
# Format code
|
||||
npm run prettier:write
|
||||
|
||||
# E2E: first-time setup
|
||||
npm install
|
||||
npx playwright install chromium
|
||||
|
||||
# E2E: build the app and run all tests
|
||||
npm run e2e:build && npm run e2e
|
||||
|
||||
# E2E: interactive UI for debugging
|
||||
npm run e2e:ui
|
||||
|
||||
# E2E: run a specific spec
|
||||
npx playwright test --config e2e/playwright.config.ts e2e/specs/live.spec.ts
|
||||
|
||||
# E2E: filter by name, or run only desktop/mobile
|
||||
npx playwright test --config e2e/playwright.config.ts --grep="severity tab"
|
||||
npx playwright test --config e2e/playwright.config.ts --project=desktop
|
||||
|
||||
# E2E: regenerate mock data after backend model changes (from repo root)
|
||||
PYTHONPATH=. python3 web/e2e/fixtures/mock-data/generate-mock-data.py
|
||||
|
||||
# Regenerate config translations from Pydantic models — outputs to
|
||||
# web/public/locales/en/config/{global,cameras}.json. NEVER edit those
|
||||
# JSON files by hand; change the Pydantic field title/description and
|
||||
# re-run this script. (from repo root)
|
||||
python3 generate_config_translations.py
|
||||
|
||||
# Extract i18n keys from source into the locale files after adding
|
||||
# new t() calls. Use the :ci variant to verify the locale files are
|
||||
# in sync with source (fails if extraction would change anything).
|
||||
npm run i18n:extract
|
||||
npm run i18n:extract:ci
|
||||
```
|
||||
|
||||
### Docker Development
|
||||
@ -371,6 +405,10 @@ except ValueError:
|
||||
)
|
||||
```
|
||||
|
||||
## WebSocket Broadcasts
|
||||
|
||||
Outbound WebSocket broadcasts go through a per-recipient classifier in `frigate/comms/ws.py` that enforces camera-level access. **The classifier is fail-closed: any topic it doesn't recognize is dropped for every client.** New outbound topics must be classified there or they'll silently disappear.
|
||||
|
||||
## Project-Specific Conventions
|
||||
|
||||
### Configuration Files
|
||||
|
||||
@ -3,7 +3,6 @@
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ruamel.yaml import YAML
|
||||
@ -18,37 +17,12 @@ from frigate.const import (
|
||||
)
|
||||
from frigate.ffmpeg_presets import parse_preset_hardware_acceleration_encode
|
||||
from frigate.util.config import find_config_file
|
||||
from frigate.util.services import is_restricted_go2rtc_source
|
||||
|
||||
sys.path.remove("/opt/frigate")
|
||||
|
||||
yaml = YAML()
|
||||
|
||||
# Check if arbitrary exec sources are allowed (defaults to False for security)
|
||||
allow_arbitrary_exec = None
|
||||
if "GO2RTC_ALLOW_ARBITRARY_EXEC" in os.environ:
|
||||
allow_arbitrary_exec = os.environ.get("GO2RTC_ALLOW_ARBITRARY_EXEC")
|
||||
elif (
|
||||
os.path.isdir("/run/secrets")
|
||||
and os.access("/run/secrets", os.R_OK)
|
||||
and "GO2RTC_ALLOW_ARBITRARY_EXEC" in os.listdir("/run/secrets")
|
||||
):
|
||||
allow_arbitrary_exec = (
|
||||
Path(os.path.join("/run/secrets", "GO2RTC_ALLOW_ARBITRARY_EXEC"))
|
||||
.read_text()
|
||||
.strip()
|
||||
)
|
||||
# check for the add-on options file
|
||||
elif os.path.isfile("/data/options.json"):
|
||||
with open("/data/options.json") as f:
|
||||
raw_options = f.read()
|
||||
options = json.loads(raw_options)
|
||||
allow_arbitrary_exec = options.get("go2rtc_allow_arbitrary_exec")
|
||||
|
||||
ALLOW_ARBITRARY_EXEC = allow_arbitrary_exec is not None and str(
|
||||
allow_arbitrary_exec
|
||||
).lower() in ("true", "1", "yes")
|
||||
|
||||
|
||||
config_file = find_config_file()
|
||||
|
||||
try:
|
||||
@ -128,18 +102,13 @@ if LIBAVFORMAT_VERSION_MAJOR < 59:
|
||||
go2rtc_config["ffmpeg"]["rtsp"] = rtsp_args
|
||||
|
||||
|
||||
def is_restricted_source(stream_source: str) -> bool:
|
||||
"""Check if a stream source is restricted (echo, expr, or exec)."""
|
||||
return stream_source.strip().startswith(("echo:", "expr:", "exec:"))
|
||||
|
||||
|
||||
for name in list(go2rtc_config.get("streams", {})):
|
||||
stream = go2rtc_config["streams"][name]
|
||||
|
||||
if isinstance(stream, str):
|
||||
try:
|
||||
formatted_stream = substitute_frigate_vars(stream)
|
||||
if not ALLOW_ARBITRARY_EXEC and is_restricted_source(formatted_stream):
|
||||
if is_restricted_go2rtc_source(formatted_stream):
|
||||
print(
|
||||
f"[ERROR] Stream '{name}' uses a restricted source (echo/expr/exec) which is disabled by default for security. "
|
||||
f"Set GO2RTC_ALLOW_ARBITRARY_EXEC=true to enable arbitrary exec sources."
|
||||
@ -158,7 +127,7 @@ for name in list(go2rtc_config.get("streams", {})):
|
||||
for i, stream_item in enumerate(stream):
|
||||
try:
|
||||
formatted_stream = substitute_frigate_vars(stream_item)
|
||||
if not ALLOW_ARBITRARY_EXEC and is_restricted_source(formatted_stream):
|
||||
if is_restricted_go2rtc_source(formatted_stream):
|
||||
print(
|
||||
f"[ERROR] Stream '{name}' item {i + 1} uses a restricted source (echo/expr/exec) which is disabled by default for security. "
|
||||
f"Set GO2RTC_ALLOW_ARBITRARY_EXEC=true to enable arbitrary exec sources."
|
||||
|
||||
@ -172,7 +172,7 @@ Custom models may also require different input tensor formats. The colorspace co
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detection model" /> to configure the model path, dimensions, and input format.
|
||||
Navigate to <NavPath path="Settings > System > Detectors and model" /> and open the **Custom Model** tab to configure the model path, dimensions, and input format.
|
||||
|
||||
| Field | Description |
|
||||
| --------------------------------------------- | ------------------------------------ |
|
||||
|
||||
@ -110,7 +110,7 @@ Here are some common starter configuration examples. These can be configured thr
|
||||
|
||||
1. Navigate to <NavPath path="Settings > System > MQTT" /> and configure the MQTT connection to your Home Assistant Mosquitto broker
|
||||
2. Navigate to <NavPath path="Settings > Global configuration > FFmpeg" /> and set **Hardware acceleration arguments** to `Raspberry Pi (H.264)`
|
||||
3. Navigate to <NavPath path="Settings > System > Detector hardware" /> and add a detector with **Type** `EdgeTPU` and **Device** `usb`
|
||||
3. Navigate to <NavPath path="Settings > System > Detectors and model" /> and add a detector with **Type** `EdgeTPU` and **Device** `usb`
|
||||
4. Navigate to <NavPath path="Settings > Global configuration > Recording" /> and set **Enable recording** to on, **Motion retention > Retention days** to `7`, **Alert retention > Event retention > Retention days** to `30`, **Alert retention > Event retention > Retention mode** to `motion`, **Detection retention > Event retention > Retention days** to `30`, **Detection retention > Event retention > Retention mode** to `motion`
|
||||
5. Navigate to <NavPath path="Settings > Global configuration > Snapshots" /> and set **Enable snapshots** to on, **Snapshot retention > Default retention** to `30`
|
||||
6. Navigate to <NavPath path="Settings > Camera configuration > Management" /> and add your camera with the appropriate RTSP stream URL
|
||||
@ -189,7 +189,7 @@ cameras:
|
||||
|
||||
1. Navigate to <NavPath path="Settings > System > MQTT" /> and set **Enable MQTT** to off
|
||||
2. Navigate to <NavPath path="Settings > Global configuration > FFmpeg" /> and set **Hardware acceleration arguments** to `VAAPI (Intel/AMD GPU)`
|
||||
3. Navigate to <NavPath path="Settings > System > Detector hardware" /> and add a detector with **Type** `EdgeTPU` and **Device** `usb`
|
||||
3. Navigate to <NavPath path="Settings > System > Detectors and model" /> and add a detector with **Type** `EdgeTPU` and **Device** `usb`
|
||||
4. Navigate to <NavPath path="Settings > Global configuration > Recording" /> and set **Enable recording** to on, **Motion retention > Retention days** to `7`, **Alert retention > Event retention > Retention days** to `30`, **Alert retention > Event retention > Retention mode** to `motion`, **Detection retention > Event retention > Retention days** to `30`, **Detection retention > Event retention > Retention mode** to `motion`
|
||||
5. Navigate to <NavPath path="Settings > Global configuration > Snapshots" /> and set **Enable snapshots** to on, **Snapshot retention > Default retention** to `30`
|
||||
6. Navigate to <NavPath path="Settings > Camera configuration > Management" /> and add your camera with the appropriate RTSP stream URL
|
||||
@ -266,8 +266,8 @@ cameras:
|
||||
|
||||
1. Navigate to <NavPath path="Settings > System > MQTT" /> and configure the connection to your MQTT broker
|
||||
2. Navigate to <NavPath path="Settings > Global configuration > FFmpeg" /> and set **Hardware acceleration arguments** to `VAAPI (Intel/AMD GPU)`
|
||||
3. Navigate to <NavPath path="Settings > System > Detector hardware" /> and add a detector with **Type** `openvino` and **Device** `AUTO`
|
||||
4. Navigate to <NavPath path="Settings > System > Detection model" /> and configure the OpenVINO model path and settings
|
||||
3. Navigate to <NavPath path="Settings > System > Detectors and model" /> and add a detector with **Type** `openvino` and **Device** `AUTO`
|
||||
4. On the same page, in the **Custom Model** tab, configure the OpenVINO model path and settings
|
||||
5. Navigate to <NavPath path="Settings > Global configuration > Recording" /> and set **Enable recording** to on, **Motion retention > Retention days** to `7`, **Alert retention > Event retention > Retention days** to `30`, **Alert retention > Event retention > Retention mode** to `motion`, **Detection retention > Event retention > Retention days** to `30`, **Detection retention > Event retention > Retention mode** to `motion`
|
||||
6. Navigate to <NavPath path="Settings > Global configuration > Snapshots" /> and set **Enable snapshots** to on, **Snapshot retention > Default retention** to `30`
|
||||
7. Navigate to <NavPath path="Settings > Camera configuration > Management" /> and add your camera with the appropriate RTSP stream URL
|
||||
|
||||
@ -72,7 +72,7 @@ This does not affect using hardware for accelerating other tasks such as [semant
|
||||
|
||||
# Officially Supported Detectors
|
||||
|
||||
Frigate provides a number of builtin detector types. By default, Frigate will use a single OpenVINO detector running on the CPU. Other detectors may require additional configuration as described below. When using multiple detectors they will run in dedicated processes, but pull from a common queue of detection requests from across all cameras.
|
||||
Frigate provides a number of builtin detector types. By default, Frigate will use a single CPU detector. Other detectors may require additional configuration as described below. When using multiple detectors they will run in dedicated processes, but pull from a common queue of detection requests from across all cameras.
|
||||
|
||||
## Edge TPU Detector
|
||||
|
||||
@ -91,7 +91,7 @@ See [common Edge TPU troubleshooting steps](/troubleshooting/edgetpu) if the Edg
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **EdgeTPU** from the detector type dropdown and click **Add**, then set device to `usb`.
|
||||
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **EdgeTPU** from the detector type dropdown and click **Add**, then set device to `usb`.
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@ -111,7 +111,7 @@ detectors:
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **EdgeTPU** from the detector type dropdown and click **Add** to add multiple detectors, specifying `usb:0` and `usb:1` as the device for each.
|
||||
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **EdgeTPU** from the detector type dropdown and click **Add** to add multiple detectors, specifying `usb:0` and `usb:1` as the device for each.
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@ -136,7 +136,7 @@ _warning: may have [compatibility issues](https://github.com/blakeblackshear/fri
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **EdgeTPU** from the detector type dropdown and click **Add**, then leave the device field empty.
|
||||
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **EdgeTPU** from the detector type dropdown and click **Add**, then leave the device field empty.
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@ -156,7 +156,7 @@ detectors:
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **EdgeTPU** from the detector type dropdown and click **Add**, then set device to `pci`.
|
||||
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **EdgeTPU** from the detector type dropdown and click **Add**, then set device to `pci`.
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@ -176,7 +176,7 @@ detectors:
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **EdgeTPU** from the detector type dropdown and click **Add** to add multiple detectors, specifying `pci:0` and `pci:1` as the device for each.
|
||||
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **EdgeTPU** from the detector type dropdown and click **Add** to add multiple detectors, specifying `pci:0` and `pci:1` as the device for each.
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@ -199,7 +199,7 @@ detectors:
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **EdgeTPU** from the detector type dropdown and click **Add** to add multiple detectors with different device types (e.g., `usb` and `pci`).
|
||||
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **EdgeTPU** from the detector type dropdown and click **Add** to add multiple detectors with different device types (e.g., `usb` and `pci`).
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@ -246,7 +246,7 @@ After placing the downloaded files for the tflite model and labels in your confi
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **EdgeTPU** from the detector type dropdown and click **Add**, then set device to `usb`. Then navigate to <NavPath path="Settings > System > Detection model" /> and configure the model settings:
|
||||
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **EdgeTPU** from the detector type dropdown and click **Add**, then set device to `usb`. Then on the same page, in the **Custom Model** tab, configure the model settings:
|
||||
|
||||
| Field | Value |
|
||||
| ---------------------------------------- | ----------------------------------------------------------------- |
|
||||
@ -309,7 +309,7 @@ Use this configuration for YOLO-based models. When no custom model path or URL i
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **Hailo-8/Hailo-8L** from the detector type dropdown and click **Add**, then set device to `PCIe`. Then navigate to <NavPath path="Settings > System > Detection model" /> and configure the model settings:
|
||||
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **Hailo-8/Hailo-8L** from the detector type dropdown and click **Add**, then set device to `PCIe`. Then on the same page, in the **Custom Model** tab, configure the model settings:
|
||||
|
||||
| Field | Value |
|
||||
| ---------------------------------------- | ----------------------- |
|
||||
@ -365,7 +365,7 @@ For SSD-based models, provide either a model path or URL to your compiled SSD mo
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **Hailo-8/Hailo-8L** from the detector type dropdown and click **Add**, then set device to `PCIe`. Then navigate to <NavPath path="Settings > System > Detection model" /> and configure the model settings:
|
||||
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **Hailo-8/Hailo-8L** from the detector type dropdown and click **Add**, then set device to `PCIe`. Then on the same page, in the **Custom Model** tab, configure the model settings:
|
||||
|
||||
| Field | Value |
|
||||
| --------------------------------------- | ------ |
|
||||
@ -410,7 +410,7 @@ The Hailo detector supports all YOLO models compiled for Hailo hardware that inc
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **Hailo-8/Hailo-8L** from the detector type dropdown and click **Add**, then set device to `PCIe`. Then navigate to <NavPath path="Settings > System > Detection model" /> and configure the model settings to match your custom model dimensions and format.
|
||||
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **Hailo-8/Hailo-8L** from the detector type dropdown and click **Add**, then set device to `PCIe`. Then on the same page, in the **Custom Model** tab, configure the model settings to match your custom model dimensions and format.
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@ -465,7 +465,7 @@ When using many cameras one detector may not be enough to keep up. Multiple dete
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **OpenVINO** from the detector type dropdown and click **Add** to add multiple detectors, each targeting `GPU` or `NPU`.
|
||||
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **OpenVINO** from the detector type dropdown and click **Add** to add multiple detectors, each targeting `GPU` or `NPU`.
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@ -508,7 +508,7 @@ Use the model configuration shown below when using the OpenVINO detector with th
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **OpenVINO** from the detector type dropdown and click **Add**, then set device to `GPU` (or `NPU`). Then navigate to <NavPath path="Settings > System > Detection model" /> and configure:
|
||||
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **OpenVINO** from the detector type dropdown and click **Add**, then set device to `GPU` (or `NPU`). Then on the same page, in the **Custom Model** tab, configure:
|
||||
|
||||
| Field | Value |
|
||||
| ---------------------------------------- | ------------------------------------------ |
|
||||
@ -558,7 +558,7 @@ After placing the downloaded onnx model in your config folder, use the following
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **OpenVINO** from the detector type dropdown and click **Add**, then set device to `GPU`. Then navigate to <NavPath path="Settings > System > Detection model" /> and configure:
|
||||
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **OpenVINO** from the detector type dropdown and click **Add**, then set device to `GPU`. Then on the same page, in the **Custom Model** tab, configure:
|
||||
|
||||
| Field | Value |
|
||||
| ---------------------------------------- | ------------------------------------------------- |
|
||||
@ -620,7 +620,7 @@ After placing the downloaded onnx model in your config folder, use the following
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **OpenVINO** from the detector type dropdown and click **Add**, then set device to `GPU` (or `NPU`). Then navigate to <NavPath path="Settings > System > Detection model" /> and configure:
|
||||
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **OpenVINO** from the detector type dropdown and click **Add**, then set device to `GPU` (or `NPU`). Then on the same page, in the **Custom Model** tab, configure:
|
||||
|
||||
| Field | Value |
|
||||
| ---------------------------------------- | -------------------------------------------------------- |
|
||||
@ -676,7 +676,7 @@ After placing the downloaded onnx model in your `config/model_cache` folder, use
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **OpenVINO** from the detector type dropdown and click **Add**, then set device to `GPU`. Then navigate to <NavPath path="Settings > System > Detection model" /> and configure:
|
||||
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **OpenVINO** from the detector type dropdown and click **Add**, then set device to `GPU`. Then on the same page, in the **Custom Model** tab, configure:
|
||||
|
||||
| Field | Value |
|
||||
| --------------------------------------- | --------------------------------- |
|
||||
@ -728,7 +728,7 @@ After placing the downloaded onnx model in your config/model_cache folder, use t
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **OpenVINO** from the detector type dropdown and click **Add**, then set device to `CPU`. Then navigate to <NavPath path="Settings > System > Detection model" /> and configure:
|
||||
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **OpenVINO** from the detector type dropdown and click **Add**, then set device to `CPU`. Then on the same page, in the **Custom Model** tab, configure:
|
||||
|
||||
| Field | Value |
|
||||
| ---------------------------------------- | ---------------------------------- |
|
||||
@ -807,7 +807,7 @@ Using the detector config below will connect to the client:
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **ZMQ IPC** from the detector type dropdown and click **Add**, then set the endpoint to `tcp://host.docker.internal:5555`.
|
||||
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **ZMQ IPC** from the detector type dropdown and click **Add**, then set the endpoint to `tcp://host.docker.internal:5555`.
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@ -841,7 +841,7 @@ When Frigate is started with the following config it will connect to the detecto
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **ZMQ IPC** from the detector type dropdown and click **Add**, then set the endpoint to `tcp://host.docker.internal:5555`. Then navigate to <NavPath path="Settings > System > Detection model" /> and configure:
|
||||
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **ZMQ IPC** from the detector type dropdown and click **Add**, then set the endpoint to `tcp://host.docker.internal:5555`. Then on the same page, in the **Custom Model** tab, configure:
|
||||
|
||||
| Field | Value |
|
||||
| ---------------------------------------- | -------------------------------------------------------- |
|
||||
@ -1002,7 +1002,7 @@ When using many cameras one detector may not be enough to keep up. Multiple dete
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **ONNX** from the detector type dropdown and click **Add** to add multiple detectors.
|
||||
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **ONNX** from the detector type dropdown and click **Add** to add multiple detectors.
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@ -1050,7 +1050,7 @@ After placing the downloaded onnx model in your config folder, use the following
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **ONNX** from the detector type dropdown and click **Add**. Then navigate to <NavPath path="Settings > System > Detection model" /> and configure:
|
||||
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **ONNX** from the detector type dropdown and click **Add**. Then on the same page, in the **Custom Model** tab, configure:
|
||||
|
||||
| Field | Value |
|
||||
| ---------------------------------------- | ------------------------------------------------- |
|
||||
@ -1109,7 +1109,7 @@ After placing the downloaded onnx model in your config folder, use the following
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **ONNX** from the detector type dropdown and click **Add**. Then navigate to <NavPath path="Settings > System > Detection model" /> and configure:
|
||||
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **ONNX** from the detector type dropdown and click **Add**. Then on the same page, in the **Custom Model** tab, configure:
|
||||
|
||||
| Field | Value |
|
||||
| ---------------------------------------- | -------------------------------------------------------- |
|
||||
@ -1158,7 +1158,7 @@ After placing the downloaded onnx model in your config folder, use the following
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **ONNX** from the detector type dropdown and click **Add**. Then navigate to <NavPath path="Settings > System > Detection model" /> and configure:
|
||||
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **ONNX** from the detector type dropdown and click **Add**. Then on the same page, in the **Custom Model** tab, configure:
|
||||
|
||||
| Field | Value |
|
||||
| ---------------------------------------- | -------------------------------------------------------- |
|
||||
@ -1207,7 +1207,7 @@ After placing the downloaded onnx model in your `config/model_cache` folder, use
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **ONNX** from the detector type dropdown and click **Add**. Then navigate to <NavPath path="Settings > System > Detection model" /> and configure:
|
||||
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **ONNX** from the detector type dropdown and click **Add**. Then on the same page, in the **Custom Model** tab, configure:
|
||||
|
||||
| Field | Value |
|
||||
| --------------------------------------- | --------------------------------- |
|
||||
@ -1252,7 +1252,7 @@ After placing the downloaded onnx model in your `config/model_cache` folder, use
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **ONNX** from the detector type dropdown and click **Add**. Then navigate to <NavPath path="Settings > System > Detection model" /> and configure:
|
||||
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **ONNX** from the detector type dropdown and click **Add**. Then on the same page, in the **Custom Model** tab, configure:
|
||||
|
||||
| Field | Value |
|
||||
| ---------------------------------------- | ------------------------------------------- |
|
||||
@ -1328,7 +1328,7 @@ A TensorFlow Lite model is provided in the container at `/cpu_model.tflite` and
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **CPU** from the detector type dropdown and click **Add**. Configure the number of threads and click **Add** again to add additional CPU detectors as needed (one per camera is recommended).
|
||||
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **CPU** from the detector type dropdown and click **Add**. Configure the number of threads and click **Add** again to add additional CPU detectors as needed (one per camera is recommended).
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@ -1364,7 +1364,7 @@ To integrate CodeProject.AI into Frigate, configure the detector as follows:
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **DeepStack** from the detector type dropdown and click **Add**. Set the API URL to point to your CodeProject.AI server (e.g., `http://<your_codeproject_ai_server_ip>:<port>/v1/vision/detection`).
|
||||
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **DeepStack** from the detector type dropdown and click **Add**. Set the API URL to point to your CodeProject.AI server (e.g., `http://<your_codeproject_ai_server_ip>:<port>/v1/vision/detection`).
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@ -1403,7 +1403,7 @@ To configure the MemryX detector, use the following example configuration:
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **MemryX** from the detector type dropdown and click **Add**, then set device to `PCIe:0`.
|
||||
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **MemryX** from the detector type dropdown and click **Add**, then set device to `PCIe:0`.
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@ -1423,7 +1423,7 @@ detectors:
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **MemryX** from the detector type dropdown and click **Add** to add multiple detectors, specifying `PCIe:0`, `PCIe:1`, `PCIe:2`, etc. as the device for each.
|
||||
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **MemryX** from the detector type dropdown and click **Add** to add multiple detectors, specifying `PCIe:0`, `PCIe:1`, `PCIe:2`, etc. as the device for each.
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@ -1467,7 +1467,7 @@ Below is the recommended configuration for using the **YOLO-NAS** (small) model
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **MemryX** from the detector type dropdown and click **Add**, then set device to `PCIe:0`. Then navigate to <NavPath path="Settings > System > Detection model" /> and configure:
|
||||
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **MemryX** from the detector type dropdown and click **Add**, then set device to `PCIe:0`. Then on the same page, in the **Custom Model** tab, configure:
|
||||
|
||||
| Field | Value |
|
||||
| ---------------------------------------- | ------------------------------------------------- |
|
||||
@ -1515,7 +1515,7 @@ Below is the recommended configuration for using the **YOLOv9** (small) model wi
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **MemryX** from the detector type dropdown and click **Add**, then set device to `PCIe:0`. Then navigate to <NavPath path="Settings > System > Detection model" /> and configure:
|
||||
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **MemryX** from the detector type dropdown and click **Add**, then set device to `PCIe:0`. Then on the same page, in the **Custom Model** tab, configure:
|
||||
|
||||
| Field | Value |
|
||||
| ---------------------------------------- | ------------------------------------------------- |
|
||||
@ -1562,7 +1562,7 @@ Below is the recommended configuration for using the **YOLOX** (small) model wit
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **MemryX** from the detector type dropdown and click **Add**, then set device to `PCIe:0`. Then navigate to <NavPath path="Settings > System > Detection model" /> and configure:
|
||||
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **MemryX** from the detector type dropdown and click **Add**, then set device to `PCIe:0`. Then on the same page, in the **Custom Model** tab, configure:
|
||||
|
||||
| Field | Value |
|
||||
| ---------------------------------------- | ----------------------- |
|
||||
@ -1609,7 +1609,7 @@ Below is the recommended configuration for using the **SSDLite MobileNet v2** mo
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **MemryX** from the detector type dropdown and click **Add**, then set device to `PCIe:0`. Then navigate to <NavPath path="Settings > System > Detection model" /> and configure:
|
||||
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **MemryX** from the detector type dropdown and click **Add**, then set device to `PCIe:0`. Then on the same page, in the **Custom Model** tab, configure:
|
||||
|
||||
| Field | Value |
|
||||
| ---------------------------------------- | ----------------------- |
|
||||
@ -1768,7 +1768,7 @@ Use the config below to work with generated TRT models:
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **TensorRT** from the detector type dropdown and click **Add**, then set the device to `0` (the default GPU index). Then navigate to <NavPath path="Settings > System > Detection model" /> and configure:
|
||||
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **TensorRT** from the detector type dropdown and click **Add**, then set the device to `0` (the default GPU index). Then on the same page, in the **Custom Model** tab, configure:
|
||||
|
||||
| Field | Value |
|
||||
| ---------------------------------------- | ------------------------------------------------------------ |
|
||||
@ -1825,7 +1825,7 @@ Use the model configuration shown below when using the synaptics detector with t
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **Synaptics** from the detector type dropdown and click **Add**. Then navigate to <NavPath path="Settings > System > Detection model" /> and configure:
|
||||
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **Synaptics** from the detector type dropdown and click **Add**. Then on the same page, in the **Custom Model** tab, configure:
|
||||
|
||||
| Field | Value |
|
||||
| ---------------------------------------- | ---------------------------- |
|
||||
@ -1879,7 +1879,7 @@ When using many cameras one detector may not be enough to keep up. Multiple dete
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **RKNN** from the detector type dropdown and click **Add** to add multiple detectors, each with `num_cores` set to `0` for automatic selection.
|
||||
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **RKNN** from the detector type dropdown and click **Add** to add multiple detectors, each with `num_cores` set to `0` for automatic selection.
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@ -1921,7 +1921,7 @@ This `config.yml` shows all relevant options to configure the detector and expla
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **RKNN** from the detector type dropdown and click **Add**. Set `num_cores` to `0` for automatic selection (increase for better performance on multicore NPUs, e.g., set to `3` on rk3588).
|
||||
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **RKNN** from the detector type dropdown and click **Add**. Set `num_cores` to `0` for automatic selection (increase for better performance on multicore NPUs, e.g., set to `3` on rk3588).
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@ -1958,7 +1958,7 @@ The inference time was determined on a rk3588 with 3 NPU cores.
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detection model" /> and configure:
|
||||
Navigate to <NavPath path="Settings > System > Detectors and model" /> and, in the **Custom Model** tab, configure:
|
||||
|
||||
| Field | Value |
|
||||
| ---------------------------------------- | ----------------------------------------------------------------------- |
|
||||
@ -2004,7 +2004,7 @@ The pre-trained YOLO-NAS weights from DeciAI are subject to their license and ca
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detection model" /> and configure:
|
||||
Navigate to <NavPath path="Settings > System > Detectors and model" /> and, in the **Custom Model** tab, configure:
|
||||
|
||||
| Field | Value |
|
||||
| ---------------------------------------- | -------------------------------------------------- |
|
||||
@ -2044,7 +2044,7 @@ model: # required
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detection model" /> and configure:
|
||||
Navigate to <NavPath path="Settings > System > Detectors and model" /> and, in the **Custom Model** tab, configure:
|
||||
|
||||
| Field | Value |
|
||||
| ---------------------------------------- | ---------------------------------------------- |
|
||||
@ -2138,7 +2138,7 @@ Once completed, configure the detector as follows:
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **DeGirum** from the detector type dropdown and click **Add**. Set the location to your AI server (e.g., service name, container name, or `host:port`), the zoo to `degirum/public`, and provide your authentication token if needed.
|
||||
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **DeGirum** from the detector type dropdown and click **Add**. Set the location to your AI server (e.g., service name, container name, or `host:port`), the zoo to `degirum/public`, and provide your authentication token if needed.
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@ -2181,7 +2181,7 @@ It is also possible to eliminate the need for an AI server and run the hardware
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **DeGirum** from the detector type dropdown and click **Add**. Set the location to `@local`, the zoo to `degirum/public`, and provide your authentication token.
|
||||
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **DeGirum** from the detector type dropdown and click **Add**. Set the location to `@local`, the zoo to `degirum/public`, and provide your authentication token.
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@ -2218,7 +2218,7 @@ If you do not possess whatever hardware you want to run, there's also the option
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **DeGirum** from the detector type dropdown and click **Add**. Set the location to `@cloud`, the zoo to `degirum/public`, and provide your authentication token.
|
||||
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **DeGirum** from the detector type dropdown and click **Add**. Set the location to `@cloud`, the zoo to `degirum/public`, and provide your authentication token.
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@ -2274,7 +2274,7 @@ Use the model configuration shown below when using the axengine detector with th
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and select **AXEngine NPU** from the detector type dropdown and click **Add**. Then navigate to <NavPath path="Settings > System > Detection model" /> and configure:
|
||||
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **AXEngine NPU** from the detector type dropdown and click **Add**. Then on the same page, in the **Custom Model** tab, configure:
|
||||
|
||||
| Field | Value |
|
||||
| ---------------------------------------- | ----------------------- |
|
||||
|
||||
@ -204,8 +204,8 @@ You need to refer to **Configure hardware acceleration** above to enable the con
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
1. Navigate to <NavPath path="Settings > System > Detector hardware" /> and add a detector with **Type** `OpenVINO` and **Device** `GPU`
|
||||
2. Navigate to <NavPath path="Settings > System > Detection model" /> and configure the model settings for OpenVINO:
|
||||
1. Navigate to <NavPath path="Settings > System > Detectors and model" /> and add a detector with **Type** `OpenVINO` and **Device** `GPU`
|
||||
2. On the same page, in the **Custom Model** tab, configure the model settings for OpenVINO:
|
||||
|
||||
| Field | Value |
|
||||
| ---------------------------------------- | ------------------------------------------ |
|
||||
@ -273,7 +273,7 @@ services:
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detector hardware" /> and add a detector with **Type** `EdgeTPU` and **Device** `usb`.
|
||||
Navigate to <NavPath path="Settings > System > Detectors and model" /> and add a detector with **Type** `EdgeTPU` and **Device** `usb`.
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
|
||||
@ -3,6 +3,8 @@ id: plus
|
||||
title: Frigate+
|
||||
---
|
||||
|
||||
import NavPath from "@site/src/components/NavPath";
|
||||
|
||||
For more information about how to use Frigate+ to improve your model, see the [Frigate+ docs](/plus/).
|
||||
|
||||
:::info
|
||||
@ -57,7 +59,7 @@ You can view all of your submitted images at [https://plus.frigate.video](https:
|
||||
|
||||
Once you have [requested your first model](../plus/first_model.md) and gotten your own model ID, it can be used with a special model path. No other information needs to be configured for Frigate+ models because it fetches the remaining config from Frigate+ automatically.
|
||||
|
||||
You can either choose the new model from the Frigate+ pane in the Settings page of the Frigate UI, or manually set the model at the root level in your config:
|
||||
You can either choose the new model from the <NavPath path="Settings > System > Detectors and model" /> pane in the Frigate UI (the **Frigate+ Model** tab), or manually set the model at the root level in your config:
|
||||
|
||||
```yaml
|
||||
detectors: ...
|
||||
|
||||
@ -37,6 +37,8 @@ The per-clip variation is typically quite low and is mostly an artifact of keyfr
|
||||
|
||||
Debug Replay lets you re-run Frigate's detection pipeline against a section of recorded video without manually configuring a dummy camera. It automatically extracts the recording, creates a temporary camera with the same detection settings as the original, and loops the clip through the pipeline so you can observe detections in real time.
|
||||
|
||||
Debug Replay isn't intended to be a one-stop pane for all Frigate diagnostics or a comprehensive debugging environment for every Frigate feature. It merely makes it easier to spin up a "dummy camera" and perform some common adjustments in real-time. You'll still need to use the normal tools (logs, an MQTT client, etc) to debug your feature.
|
||||
|
||||
### When to use
|
||||
|
||||
- Reproducing a detection or tracking issue from a specific time range
|
||||
|
||||
6
docs/package-lock.json
generated
6
docs/package-lock.json
generated
@ -10971,9 +10971,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
|
||||
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
|
||||
"integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
|
||||
@ -63,8 +63,8 @@ SYSTEM_NAV: dict[str, tuple[str, str]] = {
|
||||
"environment_vars": ("System", "Environment variables"),
|
||||
"telemetry": ("System", "Telemetry"),
|
||||
"birdseye": ("System", "Birdseye"),
|
||||
"detectors": ("System", "Detector hardware"),
|
||||
"model": ("System", "Detection model"),
|
||||
"detectors": ("System", "Detectors and model"),
|
||||
"model": ("System", "Detectors and model"),
|
||||
}
|
||||
|
||||
# All known top-level config section keys
|
||||
|
||||
@ -96,11 +96,46 @@ def version():
|
||||
|
||||
|
||||
@router.get("/stats", dependencies=[Depends(allow_any_authenticated())])
|
||||
def stats(request: Request):
|
||||
return JSONResponse(content=request.app.stats_emitter.get_latest_stats())
|
||||
def stats(
|
||||
request: Request,
|
||||
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
|
||||
):
|
||||
stats_data = request.app.stats_emitter.get_latest_stats()
|
||||
|
||||
# Admins see the full snapshot
|
||||
if request.headers.get("remote-role") == "admin":
|
||||
return JSONResponse(content=stats_data)
|
||||
|
||||
allowed_set = set(allowed_cameras)
|
||||
|
||||
# Shallow-copy so we don't mutate the cached stats history entry.
|
||||
filtered = {**stats_data}
|
||||
|
||||
cameras = stats_data.get("cameras")
|
||||
if cameras is not None:
|
||||
filtered["cameras"] = {
|
||||
name: data for name, data in cameras.items() if name in allowed_set
|
||||
}
|
||||
|
||||
bandwidth = stats_data.get("bandwidth_usages")
|
||||
if bandwidth is not None:
|
||||
filtered["bandwidth_usages"] = {
|
||||
name: data for name, data in bandwidth.items() if name in allowed_set
|
||||
}
|
||||
|
||||
# cmdline can leak camera URLs/paths; strip but keep cpu/mem so
|
||||
# client-side problem heuristics still work.
|
||||
cpu_usages = stats_data.get("cpu_usages")
|
||||
if cpu_usages is not None:
|
||||
filtered["cpu_usages"] = {
|
||||
pid: {k: v for k, v in usage.items() if k != "cmdline"}
|
||||
for pid, usage in cpu_usages.items()
|
||||
}
|
||||
|
||||
return JSONResponse(content=filtered)
|
||||
|
||||
|
||||
@router.get("/stats/history", dependencies=[Depends(allow_any_authenticated())])
|
||||
@router.get("/stats/history", dependencies=[Depends(require_role(["admin"]))])
|
||||
def stats_history(request: Request, keys: str = None):
|
||||
if keys:
|
||||
keys = keys.split(",")
|
||||
@ -739,6 +774,8 @@ def config_set(request: Request, body: AppConfigSetBody):
|
||||
|
||||
if request.app.dispatcher is not None:
|
||||
request.app.dispatcher.config = config
|
||||
for comm in request.app.dispatcher.comms:
|
||||
comm.config = config
|
||||
|
||||
if body.update_topic:
|
||||
if body.update_topic.startswith("config/cameras/"):
|
||||
@ -835,7 +872,7 @@ def nvinfo():
|
||||
@router.get(
|
||||
"/logs/{service}",
|
||||
tags=[Tags.logs],
|
||||
dependencies=[Depends(allow_any_authenticated())],
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
)
|
||||
async def logs(
|
||||
service: str = Path(enum=["frigate", "nginx", "go2rtc"]),
|
||||
@ -1040,12 +1077,27 @@ def get_media_sync_status(job_id: str):
|
||||
|
||||
|
||||
@router.get("/labels", dependencies=[Depends(allow_any_authenticated())])
|
||||
def get_labels(camera: str = ""):
|
||||
def get_labels(
|
||||
camera: str = "",
|
||||
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
|
||||
):
|
||||
try:
|
||||
if camera:
|
||||
if camera not in allowed_cameras:
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": f"Access denied to camera '{camera}'",
|
||||
},
|
||||
status_code=403,
|
||||
)
|
||||
events = Event.select(Event.label).where(Event.camera == camera).distinct()
|
||||
else:
|
||||
events = Event.select(Event.label).distinct()
|
||||
events = (
|
||||
Event.select(Event.label)
|
||||
.where(Event.camera << allowed_cameras)
|
||||
.distinct()
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
return JSONResponse(
|
||||
@ -1058,9 +1110,16 @@ def get_labels(camera: str = ""):
|
||||
|
||||
|
||||
@router.get("/sub_labels", dependencies=[Depends(allow_any_authenticated())])
|
||||
def get_sub_labels(split_joined: Optional[int] = None):
|
||||
def get_sub_labels(
|
||||
split_joined: Optional[int] = None,
|
||||
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
|
||||
):
|
||||
try:
|
||||
events = Event.select(Event.sub_label).distinct()
|
||||
events = (
|
||||
Event.select(Event.sub_label)
|
||||
.where(Event.camera << allowed_cameras)
|
||||
.distinct()
|
||||
)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
content=({"success": False, "message": "Failed to get sub_labels"}),
|
||||
|
||||
@ -26,6 +26,7 @@ from frigate.api.defs.request.app_body import (
|
||||
AppPutRoleBody,
|
||||
)
|
||||
from frigate.api.defs.tags import Tags
|
||||
from frigate.api.media_auth import check_camera_access, deny_response_for_media_uri
|
||||
from frigate.config import AuthConfig, NetworkingConfig, ProxyConfig
|
||||
from frigate.const import CONFIG_DIR, JWT_SECRET_ENV_VAR, PASSWORD_HASH_ALGORITHM
|
||||
from frigate.models import User
|
||||
@ -633,6 +634,9 @@ def auth(request: Request):
|
||||
logger.debug("X-Proxy-Secret header does not match configured secret value")
|
||||
return fail_response
|
||||
|
||||
original_url = request.headers.get("x-original-url")
|
||||
frigate_config = request.app.frigate_config
|
||||
|
||||
# if auth is disabled, just apply the proxy header map and return success
|
||||
if not auth_config.enabled:
|
||||
# pass the user header value from the upstream proxy if a mapping is specified
|
||||
@ -649,6 +653,11 @@ def auth(request: Request):
|
||||
role = resolve_role(request.headers, proxy_config, config_roles_set)
|
||||
|
||||
success_response.headers["remote-role"] = role
|
||||
|
||||
deny_status = deny_response_for_media_uri(original_url, role, frigate_config)
|
||||
if deny_status is not None:
|
||||
return Response("", status_code=deny_status)
|
||||
|
||||
return success_response
|
||||
|
||||
# now apply authentication
|
||||
@ -743,6 +752,11 @@ def auth(request: Request):
|
||||
|
||||
success_response.headers["remote-user"] = user
|
||||
success_response.headers["remote-role"] = role
|
||||
|
||||
deny_status = deny_response_for_media_uri(original_url, role, frigate_config)
|
||||
if deny_status is not None:
|
||||
return Response("", status_code=deny_status)
|
||||
|
||||
return success_response
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing jwt: {e}")
|
||||
@ -1069,19 +1083,19 @@ async def require_camera_access(
|
||||
raise HTTPException(status_code=current_user.status_code, detail=detail)
|
||||
|
||||
role = current_user["role"]
|
||||
all_camera_names = set(request.app.frigate_config.cameras.keys())
|
||||
roles_dict = request.app.frigate_config.auth.roles
|
||||
allowed_cameras = User.get_allowed_cameras(role, roles_dict, all_camera_names)
|
||||
frigate_config = request.app.frigate_config
|
||||
|
||||
# Admin or full access bypasses
|
||||
if role == "admin" or not roles_dict.get(role):
|
||||
if check_camera_access(role, camera_name, frigate_config):
|
||||
return
|
||||
|
||||
if camera_name not in allowed_cameras:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"Access denied to camera '{camera_name}'. Allowed: {allowed_cameras}",
|
||||
)
|
||||
all_camera_names = set(frigate_config.cameras.keys())
|
||||
allowed_cameras = User.get_allowed_cameras(
|
||||
role, frigate_config.auth.roles, all_camera_names
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"Access denied to camera '{camera_name}'. Allowed: {allowed_cameras}",
|
||||
)
|
||||
|
||||
|
||||
def _get_stream_owner_cameras(request: Request, stream_name: str) -> set[str]:
|
||||
|
||||
@ -19,7 +19,9 @@ from zeep.exceptions import Fault, TransportError
|
||||
from zeep.transports import AsyncTransport
|
||||
|
||||
from frigate.api.auth import (
|
||||
_get_stream_owner_cameras,
|
||||
allow_any_authenticated,
|
||||
get_current_user,
|
||||
require_go2rtc_stream_access,
|
||||
require_role,
|
||||
)
|
||||
@ -31,11 +33,12 @@ from frigate.config.camera.updater import (
|
||||
CameraConfigUpdateTopic,
|
||||
)
|
||||
from frigate.config.env import substitute_frigate_vars
|
||||
from frigate.models import User
|
||||
from frigate.util.builtin import clean_camera_user_pass
|
||||
from frigate.util.camera_cleanup import cleanup_camera_db, cleanup_camera_files
|
||||
from frigate.util.config import find_config_file
|
||||
from frigate.util.image import run_ffmpeg_snapshot
|
||||
from frigate.util.services import ffprobe_stream
|
||||
from frigate.util.services import ffprobe_stream, is_restricted_go2rtc_source
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -66,7 +69,7 @@ def _is_valid_host(host: str) -> bool:
|
||||
|
||||
|
||||
@router.get("/go2rtc/streams", dependencies=[Depends(allow_any_authenticated())])
|
||||
def go2rtc_streams():
|
||||
async def go2rtc_streams(request: Request):
|
||||
r = requests.get("http://127.0.0.1:1984/api/streams")
|
||||
if not r.ok:
|
||||
logger.error("Failed to fetch streams from go2rtc")
|
||||
@ -75,6 +78,24 @@ def go2rtc_streams():
|
||||
status_code=500,
|
||||
)
|
||||
stream_data = r.json()
|
||||
|
||||
# Roles with an explicit camera list see only streams owned by an allowed
|
||||
# camera. Admin and full-access roles (no list / empty list) see all streams.
|
||||
current_user = await get_current_user(request)
|
||||
if not isinstance(current_user, JSONResponse):
|
||||
role = current_user["role"]
|
||||
roles_dict = request.app.frigate_config.auth.roles
|
||||
if role != "admin" and roles_dict.get(role):
|
||||
all_camera_names = set(request.app.frigate_config.cameras.keys())
|
||||
allowed_cameras = set(
|
||||
User.get_allowed_cameras(role, roles_dict, all_camera_names)
|
||||
)
|
||||
stream_data = {
|
||||
name: data
|
||||
for name, data in stream_data.items()
|
||||
if _get_stream_owner_cameras(request, name) & allowed_cameras
|
||||
}
|
||||
|
||||
for data in stream_data.values():
|
||||
for producer in data.get("producers") or []:
|
||||
producer["url"] = clean_camera_user_pass(producer.get("url", ""))
|
||||
@ -126,9 +147,24 @@ def go2rtc_add_stream(request: Request, stream_name: str, src: str = ""):
|
||||
params = {"name": stream_name}
|
||||
if src:
|
||||
try:
|
||||
params["src"] = substitute_frigate_vars(src)
|
||||
resolved_src = substitute_frigate_vars(src)
|
||||
except KeyError:
|
||||
params["src"] = src
|
||||
resolved_src = src
|
||||
|
||||
if is_restricted_go2rtc_source(resolved_src):
|
||||
logger.warning(
|
||||
"Rejected go2rtc stream '%s' with restricted source type (echo/expr/exec)",
|
||||
stream_name,
|
||||
)
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": "Restricted stream source type",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
params["src"] = resolved_src
|
||||
|
||||
r = requests.put(
|
||||
"http://127.0.0.1:1984/api/streams",
|
||||
@ -966,7 +1002,6 @@ async def onvif_probe(
|
||||
probe = ffprobe_stream(
|
||||
request.app.frigate_config.ffmpeg, test_uri, detailed=False
|
||||
)
|
||||
print(probe)
|
||||
ok = probe is not None and getattr(probe, "returncode", 1) == 0
|
||||
tested_candidates.append(
|
||||
{
|
||||
|
||||
@ -10,7 +10,7 @@ from functools import reduce
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import cv2
|
||||
from fastapi import APIRouter, Body, Depends, Request
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
@ -35,9 +35,13 @@ from frigate.api.defs.response.chat_response import (
|
||||
ToolCall,
|
||||
)
|
||||
from frigate.api.defs.tags import Tags
|
||||
from frigate.api.event import events
|
||||
from frigate.api.event import _build_attribute_filter_clause, events
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.config.ui import UnitSystemEnum
|
||||
from frigate.genai.prompts import (
|
||||
build_chat_system_prompt,
|
||||
get_attribute_classifications,
|
||||
get_tool_definitions,
|
||||
)
|
||||
from frigate.genai.utils import build_assistant_message_for_conversation
|
||||
from frigate.jobs.vlm_watch import (
|
||||
get_vlm_watch_job,
|
||||
@ -68,338 +72,21 @@ class VLMMonitorRequest(BaseModel):
|
||||
zones: List[str] = []
|
||||
|
||||
|
||||
def get_tool_definitions() -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get OpenAI-compatible tool definitions for Frigate.
|
||||
|
||||
Returns a list of tool definitions that can be used with OpenAI-compatible
|
||||
function calling APIs.
|
||||
"""
|
||||
return [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "search_objects",
|
||||
"description": (
|
||||
"Search the historical record of detected objects in Frigate. "
|
||||
"Use this ONLY for questions about the PAST — e.g. 'did anyone come by today?', "
|
||||
"'when was the last car?', 'show me detections from yesterday'. "
|
||||
"Do NOT use this for monitoring or alerting requests about future events — "
|
||||
"use start_camera_watch instead for those. "
|
||||
"An 'object' in Frigate represents a tracked detection (e.g., a person, package, car). "
|
||||
"When the user asks about a specific name (person, delivery company, animal, etc.), "
|
||||
"filter by sub_label only and do not set label."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"camera": {
|
||||
"type": "string",
|
||||
"description": "Camera name to filter by (optional).",
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"description": "Object label to filter by (e.g., 'person', 'package', 'car').",
|
||||
},
|
||||
"sub_label": {
|
||||
"type": "string",
|
||||
"description": "Name of a person, delivery company, animal, etc. When filtering by a specific name, use only sub_label; do not set label.",
|
||||
},
|
||||
"after": {
|
||||
"type": "string",
|
||||
"description": "Start time in ISO 8601 format (e.g., '2024-01-01T00:00:00Z').",
|
||||
},
|
||||
"before": {
|
||||
"type": "string",
|
||||
"description": "End time in ISO 8601 format (e.g., '2024-01-01T23:59:59Z').",
|
||||
},
|
||||
"zones": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "List of zone names to filter by.",
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of objects to return (default: 25).",
|
||||
"default": 25,
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "find_similar_objects",
|
||||
"description": (
|
||||
"Find tracked objects that are visually and semantically similar "
|
||||
"to a specific past event. Use this when the user references a "
|
||||
"particular object they have seen and wants to find other "
|
||||
"sightings of the same or similar one ('that green car', 'the "
|
||||
"person in the red jacket', 'the package that was delivered'). "
|
||||
"Prefer this over search_objects whenever the user's intent is "
|
||||
"'find more like this specific one.' Use search_objects first "
|
||||
"only if you need to locate the anchor event. Requires semantic "
|
||||
"search to be enabled."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"event_id": {
|
||||
"type": "string",
|
||||
"description": "The id of the anchor event to find similar objects to.",
|
||||
},
|
||||
"after": {
|
||||
"type": "string",
|
||||
"description": "Start time in ISO 8601 format (e.g., '2024-01-01T00:00:00Z').",
|
||||
},
|
||||
"before": {
|
||||
"type": "string",
|
||||
"description": "End time in ISO 8601 format (e.g., '2024-01-01T23:59:59Z').",
|
||||
},
|
||||
"cameras": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Optional list of cameras to restrict to. Defaults to all.",
|
||||
},
|
||||
"labels": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Optional list of labels to restrict to. Defaults to the anchor event's label.",
|
||||
},
|
||||
"sub_labels": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Optional list of sub_labels (names) to restrict to.",
|
||||
},
|
||||
"zones": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Optional list of zones. An event matches if any of its zones overlap.",
|
||||
},
|
||||
"similarity_mode": {
|
||||
"type": "string",
|
||||
"enum": ["visual", "semantic", "fused"],
|
||||
"description": "Which similarity signal(s) to use. 'fused' (default) combines visual and semantic.",
|
||||
"default": "fused",
|
||||
},
|
||||
"min_score": {
|
||||
"type": "number",
|
||||
"description": "Drop matches with a similarity score below this threshold (0.0-1.0).",
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of matches to return (default: 10).",
|
||||
"default": 10,
|
||||
},
|
||||
},
|
||||
"required": ["event_id"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "set_camera_state",
|
||||
"description": (
|
||||
"Change a camera's feature state (e.g., turn detection on/off, enable/disable recordings). "
|
||||
"Use camera='*' to apply to all cameras at once. "
|
||||
"Only call this tool when the user explicitly asks to change a camera setting. "
|
||||
"Requires admin privileges."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"camera": {
|
||||
"type": "string",
|
||||
"description": "Camera name to target, or '*' to target all cameras.",
|
||||
},
|
||||
"feature": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"detect",
|
||||
"record",
|
||||
"snapshots",
|
||||
"audio",
|
||||
"motion",
|
||||
"enabled",
|
||||
"birdseye",
|
||||
"birdseye_mode",
|
||||
"improve_contrast",
|
||||
"ptz_autotracker",
|
||||
"motion_contour_area",
|
||||
"motion_threshold",
|
||||
"notifications",
|
||||
"audio_transcription",
|
||||
"review_alerts",
|
||||
"review_detections",
|
||||
"object_descriptions",
|
||||
"review_descriptions",
|
||||
"profile",
|
||||
],
|
||||
"description": (
|
||||
"The feature to change. Most features accept ON or OFF. "
|
||||
"birdseye_mode accepts CONTINUOUS, MOTION, or OBJECTS. "
|
||||
"motion_contour_area and motion_threshold accept a number. "
|
||||
"profile accepts a profile name or 'none' to deactivate (requires camera='*')."
|
||||
),
|
||||
},
|
||||
"value": {
|
||||
"type": "string",
|
||||
"description": "The value to set. ON or OFF for toggles, a number for thresholds, a profile name or 'none' for profile.",
|
||||
},
|
||||
},
|
||||
"required": ["camera", "feature", "value"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_live_context",
|
||||
"description": (
|
||||
"Get the current live image and detection information for a camera: objects being tracked, "
|
||||
"zones, timestamps. Use this to understand what is visible in the live view. "
|
||||
"Call this when answering questions about what is happening right now on a specific camera."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"camera": {
|
||||
"type": "string",
|
||||
"description": "Camera name to get live context for.",
|
||||
},
|
||||
},
|
||||
"required": ["camera"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "start_camera_watch",
|
||||
"description": (
|
||||
"Start a continuous VLM watch job that monitors a camera and sends a notification "
|
||||
"when a specified condition is met. Use this when the user wants to be alerted about "
|
||||
"a future event, e.g. 'tell me when guests arrive' or 'notify me when the package is picked up'. "
|
||||
"Only one watch job can run at a time. Returns a job ID."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"camera": {
|
||||
"type": "string",
|
||||
"description": "Camera ID to monitor.",
|
||||
},
|
||||
"condition": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Natural-language description of the condition to watch for, "
|
||||
"e.g. 'a person arrives at the front door'."
|
||||
),
|
||||
},
|
||||
"max_duration_minutes": {
|
||||
"type": "integer",
|
||||
"description": "Maximum time to watch before giving up (minutes, default 60).",
|
||||
"default": 60,
|
||||
},
|
||||
"labels": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Object labels that should trigger a VLM check (e.g. ['person', 'car']). If omitted, any detection on the camera triggers a check.",
|
||||
},
|
||||
"zones": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Zone names to filter by. If specified, only detections in these zones trigger a VLM check.",
|
||||
},
|
||||
},
|
||||
"required": ["camera", "condition"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "stop_camera_watch",
|
||||
"description": (
|
||||
"Cancel the currently running VLM watch job. Use this when the user wants to "
|
||||
"stop a previously started watch, e.g. 'stop watching the front door'."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_profile_status",
|
||||
"description": (
|
||||
"Get the current profile status including the active profile and "
|
||||
"timestamps of when each profile was last activated. Use this to "
|
||||
"determine time periods for recap requests — e.g. when the user asks "
|
||||
"'what happened while I was away?', call this first to find the relevant "
|
||||
"time window based on profile activation history."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_recap",
|
||||
"description": (
|
||||
"Get a recap of all activity (alerts and detections) for a given time period. "
|
||||
"Use this after calling get_profile_status to retrieve what happened during "
|
||||
"a specific window — e.g. 'what happened while I was away?'. Returns a "
|
||||
"chronological list of activity with camera, objects, zones, and GenAI-generated "
|
||||
"descriptions when available. Summarize the results for the user."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"after": {
|
||||
"type": "string",
|
||||
"description": "Start of the time period in ISO 8601 format (e.g. '2025-03-15T08:00:00').",
|
||||
},
|
||||
"before": {
|
||||
"type": "string",
|
||||
"description": "End of the time period in ISO 8601 format (e.g. '2025-03-15T17:00:00').",
|
||||
},
|
||||
"cameras": {
|
||||
"type": "string",
|
||||
"description": "Comma-separated camera IDs to include, or 'all' for all cameras. Default is 'all'.",
|
||||
},
|
||||
"severity": {
|
||||
"type": "string",
|
||||
"enum": ["alert", "detection"],
|
||||
"description": "Filter by severity level. Omit to include both alerts and detections.",
|
||||
},
|
||||
},
|
||||
"required": ["after", "before"],
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/chat/tools",
|
||||
dependencies=[Depends(allow_any_authenticated())],
|
||||
summary="Get available tools",
|
||||
description="Returns OpenAI-compatible tool definitions for function calling.",
|
||||
)
|
||||
def get_tools() -> JSONResponse:
|
||||
def get_tools(request: Request) -> JSONResponse:
|
||||
"""Get list of available tools for LLM function calling."""
|
||||
tools = get_tool_definitions()
|
||||
config = request.app.frigate_config
|
||||
semantic_search_enabled = bool(getattr(config.semantic_search, "enabled", False))
|
||||
attribute_classifications = get_attribute_classifications(config)
|
||||
tools = get_tool_definitions(
|
||||
semantic_search_enabled=semantic_search_enabled,
|
||||
attribute_classifications=attribute_classifications,
|
||||
)
|
||||
return JSONResponse(content={"tools": tools})
|
||||
|
||||
|
||||
@ -432,16 +119,29 @@ def _resolve_zones(
|
||||
|
||||
|
||||
async def _execute_search_objects(
|
||||
request: Request,
|
||||
arguments: Dict[str, Any],
|
||||
allowed_cameras: List[str],
|
||||
config: FrigateConfig,
|
||||
) -> JSONResponse:
|
||||
"""
|
||||
Execute the search_objects tool.
|
||||
|
||||
This searches for detected objects (events) in Frigate using the same
|
||||
logic as the events API endpoint.
|
||||
Routes to the semantic path when the LLM supplied a `semantic_query`
|
||||
and semantic search is enabled; otherwise delegates to the standard
|
||||
events API logic.
|
||||
"""
|
||||
config = request.app.frigate_config
|
||||
semantic_query = arguments.get("semantic_query")
|
||||
if isinstance(semantic_query, str):
|
||||
semantic_query = semantic_query.strip() or None
|
||||
else:
|
||||
semantic_query = None
|
||||
|
||||
if semantic_query and getattr(config.semantic_search, "enabled", False):
|
||||
return await _execute_search_objects_semantic(
|
||||
request, arguments, allowed_cameras, semantic_query
|
||||
)
|
||||
|
||||
# Parse after/before as server local time; convert to Unix timestamp
|
||||
after = arguments.get("after")
|
||||
before = arguments.get("before")
|
||||
@ -477,11 +177,14 @@ async def _execute_search_objects(
|
||||
elif zones is None:
|
||||
zones = "all"
|
||||
|
||||
attribute = arguments.get("attribute")
|
||||
|
||||
# Build query parameters compatible with EventsQueryParams
|
||||
query_params = EventsQueryParams(
|
||||
cameras=arguments.get("camera", "all"),
|
||||
labels=arguments.get("label", "all"),
|
||||
sub_labels=arguments.get("sub_label", "all"), # case-insensitive on the backend
|
||||
attributes=attribute if attribute else "all",
|
||||
zones=zones,
|
||||
zone=zones,
|
||||
after=after,
|
||||
@ -508,6 +211,124 @@ async def _execute_search_objects(
|
||||
)
|
||||
|
||||
|
||||
async def _execute_search_objects_semantic(
|
||||
request: Request,
|
||||
arguments: Dict[str, Any],
|
||||
allowed_cameras: List[str],
|
||||
semantic_query: str,
|
||||
) -> JSONResponse:
|
||||
"""Search objects via fused thumbnail + description embeddings.
|
||||
|
||||
Runs both visual and description vec searches against `semantic_query`,
|
||||
intersects the candidates with the structured filters (camera, label,
|
||||
sub_label, zones, time window) the LLM supplied, and ranks the survivors
|
||||
by fused similarity. Mirrors the candidate-then-filter pattern used by
|
||||
find_similar_objects since sqlite-vec's IN filter is unreliable.
|
||||
"""
|
||||
from peewee import fn
|
||||
|
||||
config = request.app.frigate_config
|
||||
context = request.app.embeddings
|
||||
if context is None:
|
||||
logger.warning(
|
||||
"semantic_query supplied but embeddings context is unavailable; "
|
||||
"returning empty results."
|
||||
)
|
||||
return JSONResponse(content=[])
|
||||
|
||||
after = parse_iso_to_timestamp(arguments.get("after"))
|
||||
before = parse_iso_to_timestamp(arguments.get("before"))
|
||||
|
||||
camera_arg = arguments.get("camera")
|
||||
if camera_arg and camera_arg != "all":
|
||||
if camera_arg not in allowed_cameras:
|
||||
return JSONResponse(content=[])
|
||||
cameras = [camera_arg]
|
||||
else:
|
||||
cameras = list(allowed_cameras) if allowed_cameras else []
|
||||
|
||||
if not cameras:
|
||||
return JSONResponse(content=[])
|
||||
|
||||
label = arguments.get("label")
|
||||
sub_label = arguments.get("sub_label")
|
||||
attribute = arguments.get("attribute")
|
||||
|
||||
zones = arguments.get("zones")
|
||||
if isinstance(zones, list) and zones:
|
||||
zones = _resolve_zones(zones, config, cameras)
|
||||
else:
|
||||
zones = None
|
||||
|
||||
limit = int(arguments.get("limit", 25))
|
||||
limit = max(1, min(limit, 100))
|
||||
|
||||
visual_distances: Dict[str, float] = {}
|
||||
description_distances: Dict[str, float] = {}
|
||||
try:
|
||||
rows = context.search_thumbnail(semantic_query)
|
||||
visual_distances = {row[0]: row[1] for row in rows}
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"search_thumbnail failed for semantic_query: %s", semantic_query
|
||||
)
|
||||
|
||||
try:
|
||||
rows = context.search_description(semantic_query)
|
||||
description_distances = {row[0]: row[1] for row in rows}
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"search_description failed for semantic_query: %s", semantic_query
|
||||
)
|
||||
|
||||
vec_ids = set(visual_distances) | set(description_distances)
|
||||
if not vec_ids:
|
||||
return JSONResponse(content=[])
|
||||
|
||||
clauses = [Event.id.in_(list(vec_ids)), Event.camera.in_(cameras)]
|
||||
if after is not None:
|
||||
clauses.append(Event.start_time >= after)
|
||||
if before is not None:
|
||||
clauses.append(Event.start_time <= before)
|
||||
if label:
|
||||
clauses.append(Event.label == label)
|
||||
if sub_label:
|
||||
# case-insensitive match to mirror events() behavior
|
||||
clauses.append(fn.LOWER(Event.sub_label.cast("text")) == sub_label.lower())
|
||||
if attribute:
|
||||
attribute_clause = _build_attribute_filter_clause(attribute)
|
||||
if attribute_clause is not None:
|
||||
clauses.append(attribute_clause)
|
||||
if zones:
|
||||
zone_clauses = [Event.zones.cast("text") % f'*"{zone}"*' for zone in zones]
|
||||
clauses.append(reduce(operator.or_, zone_clauses))
|
||||
|
||||
eligible = {e.id: e for e in Event.select().where(reduce(operator.and_, clauses))}
|
||||
|
||||
scored: List[tuple[str, float]] = []
|
||||
for eid in eligible:
|
||||
v_score = (
|
||||
distance_to_score(visual_distances[eid], context.thumb_stats)
|
||||
if eid in visual_distances
|
||||
else None
|
||||
)
|
||||
d_score = (
|
||||
distance_to_score(description_distances[eid], context.desc_stats)
|
||||
if eid in description_distances
|
||||
else None
|
||||
)
|
||||
fused = fuse_scores(v_score, d_score)
|
||||
if fused is None:
|
||||
continue
|
||||
scored.append((eid, fused))
|
||||
|
||||
scored.sort(key=lambda pair: pair[1], reverse=True)
|
||||
scored = scored[:limit]
|
||||
|
||||
results = [hydrate_event(eligible[eid], score=score) for eid, score in scored]
|
||||
return JSONResponse(content=results)
|
||||
|
||||
|
||||
async def _execute_find_similar_objects(
|
||||
request: Request,
|
||||
arguments: Dict[str, Any],
|
||||
@ -696,9 +517,7 @@ async def execute_tool(
|
||||
logger.debug(f"Executing tool: {tool_name} with arguments: {arguments}")
|
||||
|
||||
if tool_name == "search_objects":
|
||||
return await _execute_search_objects(
|
||||
arguments, allowed_cameras, request.app.frigate_config
|
||||
)
|
||||
return await _execute_search_objects(request, arguments, allowed_cameras)
|
||||
|
||||
if tool_name == "find_similar_objects":
|
||||
result = await _execute_find_similar_objects(
|
||||
@ -878,9 +697,7 @@ async def _execute_tool_internal(
|
||||
This is used by the chat completion endpoint to execute tools.
|
||||
"""
|
||||
if tool_name == "search_objects":
|
||||
response = await _execute_search_objects(
|
||||
arguments, allowed_cameras, request.app.frigate_config
|
||||
)
|
||||
response = await _execute_search_objects(request, arguments, allowed_cameras)
|
||||
try:
|
||||
if hasattr(response, "body"):
|
||||
body_str = response.body.decode("utf-8")
|
||||
@ -1293,64 +1110,21 @@ async def chat_completion(
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
tools = get_tool_definitions()
|
||||
config = request.app.frigate_config
|
||||
semantic_search_enabled = bool(getattr(config.semantic_search, "enabled", False))
|
||||
attribute_classifications = get_attribute_classifications(config)
|
||||
tools = get_tool_definitions(
|
||||
semantic_search_enabled=semantic_search_enabled,
|
||||
attribute_classifications=attribute_classifications,
|
||||
)
|
||||
conversation = []
|
||||
|
||||
current_datetime = datetime.now()
|
||||
current_date_str = current_datetime.strftime("%Y-%m-%d")
|
||||
current_time_str = current_datetime.strftime("%I:%M:%S %p")
|
||||
|
||||
cameras_info = []
|
||||
config = request.app.frigate_config
|
||||
has_speed_zone = False
|
||||
for camera_id in allowed_cameras:
|
||||
if camera_id not in config.cameras:
|
||||
continue
|
||||
camera_config = config.cameras[camera_id]
|
||||
friendly_name = (
|
||||
camera_config.friendly_name
|
||||
if camera_config.friendly_name
|
||||
else camera_id.replace("_", " ").title()
|
||||
)
|
||||
zone_names = list(camera_config.zones.keys())
|
||||
if not has_speed_zone:
|
||||
has_speed_zone = any(
|
||||
zone.distances for zone in camera_config.zones.values()
|
||||
)
|
||||
if zone_names:
|
||||
cameras_info.append(
|
||||
f" - {friendly_name} (ID: {camera_id}, zones: {', '.join(zone_names)})"
|
||||
)
|
||||
else:
|
||||
cameras_info.append(f" - {friendly_name} (ID: {camera_id})")
|
||||
|
||||
cameras_section = ""
|
||||
if cameras_info:
|
||||
cameras_section = (
|
||||
"\n\nAvailable cameras:\n"
|
||||
+ "\n".join(cameras_info)
|
||||
+ "\n\nWhen users refer to cameras by their friendly name (e.g., 'Back Deck Camera'), use the corresponding camera ID (e.g., 'back_deck_cam') in tool calls."
|
||||
)
|
||||
|
||||
speed_units_section = ""
|
||||
if has_speed_zone:
|
||||
speed_unit = (
|
||||
"mph" if config.ui.unit_system == UnitSystemEnum.imperial else "km/h"
|
||||
)
|
||||
speed_units_section = f"\n\nReport object speeds to the user in {speed_unit}."
|
||||
|
||||
system_prompt = f"""You are a helpful assistant for Frigate, a security camera NVR system. You help users answer questions about their cameras, detected objects, and events.
|
||||
|
||||
Current server local date and time: {current_date_str} at {current_time_str}
|
||||
|
||||
Do not start your response with phrases like "I will check...", "Let me see...", or "Let me look...". Answer directly.
|
||||
|
||||
Always present times to the user in the server's local timezone. When tool results include start_time_local and end_time_local, use those exact strings when listing or describing detection times—do not convert or invent timestamps. Do not use UTC or ISO format with Z for the user-facing answer unless the tool result only provides Unix timestamps without local time fields.
|
||||
When users ask about "today", "yesterday", "this week", etc., use the current date above as reference.
|
||||
When searching for objects or events, use ISO 8601 format for dates (e.g., {current_date_str}T00:00:00Z for the start of today).
|
||||
Always be accurate with time calculations based on the current date provided.
|
||||
|
||||
When a user refers to a specific object they have seen or describe with identifying details ("that green car", "the person in the red jacket", "a package left today"), prefer the find_similar_objects tool over search_objects. Use search_objects first only to locate the anchor event, then pass its id to find_similar_objects. For generic queries like "show me all cars today", keep using search_objects. If a user message begins with [attached_event:<id>], treat that event id as the anchor for any similarity or "tell me more" request in the same message and call find_similar_objects with that id.{cameras_section}{speed_units_section}"""
|
||||
system_prompt = build_chat_system_prompt(
|
||||
config=config,
|
||||
allowed_cameras=allowed_cameras,
|
||||
semantic_search_enabled=semantic_search_enabled,
|
||||
attribute_classifications=attribute_classifications,
|
||||
)
|
||||
|
||||
conversation.append(
|
||||
{
|
||||
@ -1411,6 +1185,18 @@ When a user refers to a specific object they have seen or describe with identify
|
||||
)
|
||||
+ b"\n"
|
||||
)
|
||||
elif kind == "reasoning_delta":
|
||||
yield (
|
||||
json.dumps({"type": "reasoning", "delta": value}).encode(
|
||||
"utf-8"
|
||||
)
|
||||
+ b"\n"
|
||||
)
|
||||
elif kind == "stats":
|
||||
yield (
|
||||
json.dumps({"type": "stats", **value}).encode("utf-8")
|
||||
+ b"\n"
|
||||
)
|
||||
elif kind == "message":
|
||||
msg = value
|
||||
if msg.get("finish_reason") == "error":
|
||||
@ -1506,6 +1292,7 @@ When a user refers to a specific object they have seen or describe with identify
|
||||
final_content = response.get("content") or ""
|
||||
|
||||
if body.stream:
|
||||
final_reasoning = response.get("reasoning")
|
||||
|
||||
async def stream_body() -> Any:
|
||||
if tool_calls:
|
||||
@ -1520,6 +1307,15 @@ When a user refers to a specific object they have seen or describe with identify
|
||||
).encode("utf-8")
|
||||
+ b"\n"
|
||||
)
|
||||
# Emit the full reasoning trace up front when the
|
||||
# underlying client did not stream it
|
||||
if final_reasoning:
|
||||
yield (
|
||||
json.dumps(
|
||||
{"type": "reasoning", "delta": final_reasoning}
|
||||
).encode("utf-8")
|
||||
+ b"\n"
|
||||
)
|
||||
# Stream content in word-sized chunks for smooth UX
|
||||
for part in chunk_content(final_content):
|
||||
yield (
|
||||
@ -1540,6 +1336,7 @@ When a user refers to a specific object they have seen or describe with identify
|
||||
message=ChatMessageResponse(
|
||||
role="assistant",
|
||||
content=final_content,
|
||||
reasoning=response.get("reasoning"),
|
||||
tool_calls=None,
|
||||
),
|
||||
finish_reason=response.get("finish_reason", "stop"),
|
||||
@ -1641,6 +1438,7 @@ async def start_vlm_monitor(
|
||||
dispatcher=request.app.dispatcher,
|
||||
labels=body.labels,
|
||||
zones=body.zones,
|
||||
username=request.headers.get("remote-user", ""),
|
||||
)
|
||||
except RuntimeError as e:
|
||||
logger.error("Failed to start VLM watch job: %s", e, exc_info=True)
|
||||
@ -1661,10 +1459,22 @@ async def start_vlm_monitor(
|
||||
summary="Get current VLM watch job",
|
||||
description="Returns the current (or most recently completed) VLM watch job.",
|
||||
)
|
||||
async def get_vlm_monitor() -> JSONResponse:
|
||||
async def get_vlm_monitor(request: Request) -> JSONResponse:
|
||||
job = get_vlm_watch_job()
|
||||
if job is None:
|
||||
return JSONResponse(content={"active": False}, status_code=200)
|
||||
|
||||
role = request.headers.get("remote-role", "viewer")
|
||||
username = request.headers.get("remote-user", "")
|
||||
|
||||
# Admin and the job's creator always see the job. Other users only see it
|
||||
# if they have access to the camera being watched; otherwise hide it.
|
||||
if role != "admin" and username != job.username:
|
||||
try:
|
||||
await require_camera_access(job.camera, request=request)
|
||||
except HTTPException:
|
||||
return JSONResponse(content={"active": False}, status_code=200)
|
||||
|
||||
return JSONResponse(content={"active": True, **job.to_dict()}, status_code=200)
|
||||
|
||||
|
||||
@ -1674,7 +1484,27 @@ async def get_vlm_monitor() -> JSONResponse:
|
||||
summary="Cancel the current VLM watch job",
|
||||
description="Cancels the running watch job if one exists.",
|
||||
)
|
||||
async def cancel_vlm_monitor() -> JSONResponse:
|
||||
async def cancel_vlm_monitor(request: Request) -> JSONResponse:
|
||||
job = get_vlm_watch_job()
|
||||
if job is None:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "No active watch job to cancel."},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
role = request.headers.get("remote-role", "viewer")
|
||||
username = request.headers.get("remote-user", "")
|
||||
|
||||
# Admin can cancel any job; other users can only cancel jobs they started.
|
||||
if role != "admin" and username != job.username:
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": "Not authorized to cancel this watch job.",
|
||||
},
|
||||
status_code=403,
|
||||
)
|
||||
|
||||
cancelled = stop_vlm_watch_job()
|
||||
if not cancelled:
|
||||
return JSONResponse(
|
||||
|
||||
@ -6,11 +6,18 @@ from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from peewee import DoesNotExist
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from frigate.api.auth import require_role
|
||||
from frigate.api.defs.tags import Tags
|
||||
from frigate.jobs.debug_replay import start_debug_replay_job
|
||||
from frigate.jobs.debug_replay import (
|
||||
ExportDebugReplaySource,
|
||||
RecordingDebugReplaySource,
|
||||
start_debug_replay_job,
|
||||
)
|
||||
from frigate.models import Export
|
||||
from frigate.util.services import get_video_properties
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -25,6 +32,12 @@ class DebugReplayStartBody(BaseModel):
|
||||
end_time: float = Field(title="End timestamp")
|
||||
|
||||
|
||||
class DebugReplayStartFromExportBody(BaseModel):
|
||||
"""Request body for starting a debug replay session from an export."""
|
||||
|
||||
export_id: str = Field(title="Export id")
|
||||
|
||||
|
||||
class DebugReplayStartResponse(BaseModel):
|
||||
"""Response for starting a debug replay session."""
|
||||
|
||||
@ -73,13 +86,95 @@ class DebugReplayStopResponse(BaseModel):
|
||||
async def start_debug_replay(request: Request, body: DebugReplayStartBody):
|
||||
"""Start a debug replay session asynchronously."""
|
||||
replay_manager = request.app.replay_manager
|
||||
source = RecordingDebugReplaySource(
|
||||
source_camera=body.camera,
|
||||
start_ts=body.start_time,
|
||||
end_ts=body.end_time,
|
||||
)
|
||||
|
||||
try:
|
||||
job_id = await asyncio.to_thread(
|
||||
start_debug_replay_job,
|
||||
source_camera=body.camera,
|
||||
start_ts=body.start_time,
|
||||
end_ts=body.end_time,
|
||||
source=source,
|
||||
frigate_config=request.app.frigate_config,
|
||||
config_publisher=request.app.config_publisher,
|
||||
replay_manager=replay_manager,
|
||||
)
|
||||
except RuntimeError:
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": "A replay session is already active",
|
||||
},
|
||||
status_code=409,
|
||||
)
|
||||
except ValueError:
|
||||
logger.exception("Rejected debug replay start request")
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": "Invalid debug replay parameters",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": True,
|
||||
"replay_camera": replay_manager.replay_camera_name,
|
||||
"job_id": job_id,
|
||||
},
|
||||
status_code=202,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/debug_replay/start_from_export",
|
||||
response_model=DebugReplayStartResponse,
|
||||
status_code=202,
|
||||
responses={
|
||||
400: {"description": "Invalid export, time range, or no recordings"},
|
||||
404: {"description": "Export not found"},
|
||||
409: {"description": "A replay session is already active"},
|
||||
},
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
summary="Start debug replay from an export",
|
||||
description="Start a debug replay session covering an existing export's "
|
||||
"time range. The end time is derived from the export's video duration.",
|
||||
)
|
||||
async def start_debug_replay_from_export(
|
||||
request: Request, body: DebugReplayStartFromExportBody
|
||||
):
|
||||
"""Start a debug replay session from an existing export."""
|
||||
try:
|
||||
export: Export = Export.get(Export.id == body.export_id)
|
||||
except DoesNotExist:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Export not found"},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
properties = await get_video_properties(
|
||||
request.app.frigate_config.ffmpeg, export.video_path, get_duration=True
|
||||
)
|
||||
duration = properties.get("duration", -1)
|
||||
|
||||
if duration is None or duration <= 0:
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": "Could not determine export duration",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
replay_manager = request.app.replay_manager
|
||||
source = ExportDebugReplaySource(export=export, duration=float(duration))
|
||||
|
||||
try:
|
||||
job_id = await asyncio.to_thread(
|
||||
start_debug_replay_job,
|
||||
source=source,
|
||||
frigate_config=request.app.frigate_config,
|
||||
config_publisher=request.app.config_publisher,
|
||||
replay_manager=replay_manager,
|
||||
|
||||
@ -20,6 +20,10 @@ class ChatMessageResponse(BaseModel):
|
||||
content: Optional[str] = Field(
|
||||
default=None, description="Message content (None if tool calls present)"
|
||||
)
|
||||
reasoning: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Separated reasoning/thinking trace if the model emitted one",
|
||||
)
|
||||
tool_calls: Optional[list[ToolCallInvocation]] = Field(
|
||||
default=None, description="Tool calls if LLM wants to call tools"
|
||||
)
|
||||
|
||||
@ -398,7 +398,7 @@ class _StreamingZipBuffer:
|
||||
def _unique_archive_name(export: Export, used: set[str]) -> str:
|
||||
base = sanitize_filename(export.name) if export.name else None
|
||||
if not base:
|
||||
base = f"{export.camera}_{int(datetime.datetime.timestamp(export.date))}"
|
||||
base = f"{export.camera}_{int(export.date)}"
|
||||
|
||||
candidate = f"{base}.mp4"
|
||||
counter = 1
|
||||
|
||||
291
frigate/api/media_auth.py
Normal file
291
frigate/api/media_auth.py
Normal file
@ -0,0 +1,291 @@
|
||||
"""URI-aware authorization for nginx-served static media.
|
||||
|
||||
The `/auth` endpoint (used as nginx `auth_request` target) calls into this
|
||||
module to classify the requested URI from the `X-Original-URL` header and, for
|
||||
camera-scoped resources, decide whether the current role may access them.
|
||||
|
||||
Without this, `auth_request` only verifies the JWT — every authenticated user
|
||||
could read clips, recordings, and exports for *any* camera, bypassing the
|
||||
per-camera authorization the regular API enforces via `require_camera_access`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
from urllib.parse import unquote, urlparse
|
||||
|
||||
from peewee import DoesNotExist
|
||||
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.const import EXPORT_DIR
|
||||
from frigate.models import Export, User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MediaAuthResolution(str, Enum):
|
||||
"""Classification of an `X-Original-URL` path for media-auth purposes."""
|
||||
|
||||
CAMERA = "camera"
|
||||
ADMIN_ONLY = "admin_only"
|
||||
LISTING_MULTI_CAMERA = "listing_multi_camera"
|
||||
LISTING_NEUTRAL = "listing_neutral"
|
||||
# Under a recognized media root (/clips, /recordings, /exports) but
|
||||
# unclassifiable (unknown subtree, no matching DB row, DB error).
|
||||
# Restricted users are denied; admins/full-access roles are allowed
|
||||
# (nginx will likely return 404 if the file genuinely doesn't exist).
|
||||
UNRESOLVED_MEDIA = "unresolved_media"
|
||||
# Not a media URI at all (e.g. /api/events, /login).
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
def extract_path(original_url: Optional[str]) -> Optional[str]:
|
||||
"""Return the decoded path component of nginx's `X-Original-URL` header.
|
||||
|
||||
nginx forwards the *raw* request URI (with `..` segments intact) via
|
||||
`$request_uri`. nginx normalizes the path before serving the file, so a
|
||||
request like `/recordings/.../allowed_cam/../forbidden_cam/file.mp4`
|
||||
would (1) parse as the allowed camera in our auth check, (2) be served
|
||||
as the forbidden camera by nginx. To close the bypass we reject any URI
|
||||
whose path contains `.` or `..` segments outright.
|
||||
"""
|
||||
if not original_url:
|
||||
return None
|
||||
|
||||
parsed = urlparse(original_url)
|
||||
raw_path = parsed.path or original_url
|
||||
decoded = unquote(raw_path)
|
||||
if not decoded:
|
||||
return None
|
||||
|
||||
if not decoded.startswith("/"):
|
||||
decoded = "/" + decoded
|
||||
|
||||
segments = decoded.split("/")
|
||||
if ".." in segments or "." in segments:
|
||||
return None
|
||||
|
||||
return decoded
|
||||
|
||||
|
||||
def resolve_media_uri(
|
||||
uri: str, frigate_config: Optional[FrigateConfig] = None
|
||||
) -> tuple[MediaAuthResolution, Optional[str]]:
|
||||
"""Classify a URI and return the owning camera if applicable.
|
||||
|
||||
`frigate_config` is used to disambiguate clip/review filenames whose
|
||||
camera name contains hyphens by matching against the longest configured
|
||||
camera-name prefix.
|
||||
"""
|
||||
if not uri:
|
||||
return MediaAuthResolution.UNKNOWN, None
|
||||
|
||||
parts = [p for p in uri.split("/") if p]
|
||||
if not parts:
|
||||
return MediaAuthResolution.UNKNOWN, None
|
||||
|
||||
root = parts[0]
|
||||
if root == "recordings":
|
||||
return _resolve_recording(parts)
|
||||
if root == "clips":
|
||||
return _resolve_clip(parts, frigate_config)
|
||||
if root == "exports":
|
||||
return _resolve_export(parts)
|
||||
|
||||
return MediaAuthResolution.UNKNOWN, None
|
||||
|
||||
|
||||
def _resolve_recording(
|
||||
parts: list[str],
|
||||
) -> tuple[MediaAuthResolution, Optional[str]]:
|
||||
# /recordings → neutral
|
||||
# /recordings/{date} → neutral
|
||||
# /recordings/{date}/{hour} → multi-camera listing
|
||||
# /recordings/{date}/{hour}/{cam}/... → camera
|
||||
if len(parts) <= 2:
|
||||
return MediaAuthResolution.LISTING_NEUTRAL, None
|
||||
if len(parts) == 3:
|
||||
return MediaAuthResolution.LISTING_MULTI_CAMERA, None
|
||||
return MediaAuthResolution.CAMERA, parts[3]
|
||||
|
||||
|
||||
def _resolve_clip(
|
||||
parts: list[str], frigate_config: Optional[FrigateConfig]
|
||||
) -> tuple[MediaAuthResolution, Optional[str]]:
|
||||
# /clips → multi-camera listing
|
||||
# /clips/thumbs/{cam}/... → camera
|
||||
# /clips/previews/{cam}/... → camera
|
||||
# /clips/review/thumb-{cam}-{review_id}.webp → camera (parsed)
|
||||
# /clips/faces/... → admin-only
|
||||
# /clips/genai-requests/... → admin-only
|
||||
# /clips/preview_restart_cache/... → admin-only
|
||||
# /clips/{model}/train|dataset/... → admin-only
|
||||
# /clips/{cam}-{event_id}[-clean].{ext} → camera (parsed)
|
||||
# other /clips/{subdir}/... → unresolved (deny restricted)
|
||||
if len(parts) == 1:
|
||||
return MediaAuthResolution.LISTING_MULTI_CAMERA, None
|
||||
|
||||
second = parts[1]
|
||||
|
||||
if second in ("thumbs", "previews"):
|
||||
if len(parts) == 2:
|
||||
return MediaAuthResolution.LISTING_MULTI_CAMERA, None
|
||||
return MediaAuthResolution.CAMERA, parts[2]
|
||||
|
||||
if second == "review":
|
||||
if len(parts) == 2:
|
||||
return MediaAuthResolution.LISTING_MULTI_CAMERA, None
|
||||
camera = _camera_from_thumb_filename(parts[2], frigate_config)
|
||||
if camera:
|
||||
return MediaAuthResolution.CAMERA, camera
|
||||
return MediaAuthResolution.UNRESOLVED_MEDIA, None
|
||||
|
||||
if second in ("faces", "genai-requests", "preview_restart_cache"):
|
||||
return MediaAuthResolution.ADMIN_ONLY, None
|
||||
|
||||
if len(parts) >= 3 and parts[2] in ("train", "dataset"):
|
||||
return MediaAuthResolution.ADMIN_ONLY, None
|
||||
|
||||
if len(parts) == 2:
|
||||
camera = _camera_from_clip_filename(second, frigate_config)
|
||||
if camera:
|
||||
return MediaAuthResolution.CAMERA, camera
|
||||
return MediaAuthResolution.UNRESOLVED_MEDIA, None
|
||||
|
||||
return MediaAuthResolution.UNRESOLVED_MEDIA, None
|
||||
|
||||
|
||||
def _longest_prefix_camera(
|
||||
stem: str, frigate_config: Optional[FrigateConfig]
|
||||
) -> Optional[str]:
|
||||
if frigate_config is None:
|
||||
return None
|
||||
for cam in sorted(frigate_config.cameras.keys(), key=len, reverse=True):
|
||||
if stem.startswith(cam + "-"):
|
||||
return cam
|
||||
return None
|
||||
|
||||
|
||||
def _camera_from_clip_filename(
|
||||
filename: str, frigate_config: Optional[FrigateConfig]
|
||||
) -> Optional[str]:
|
||||
"""Match a flat clip filename `{camera}-{event_id}[-clean].{ext}` against
|
||||
configured camera names. Longest-prefix wins so camera names containing
|
||||
hyphens (e.g. `front-door`) resolve correctly.
|
||||
"""
|
||||
dot = filename.rfind(".")
|
||||
stem = filename[:dot] if dot > 0 else filename
|
||||
return _longest_prefix_camera(stem, frigate_config)
|
||||
|
||||
|
||||
def _camera_from_thumb_filename(
|
||||
filename: str, frigate_config: Optional[FrigateConfig]
|
||||
) -> Optional[str]:
|
||||
"""Match a review thumbnail filename `thumb-{camera}-{review_id}.webp`."""
|
||||
if not filename.startswith("thumb-"):
|
||||
return None
|
||||
dot = filename.rfind(".")
|
||||
stem = filename[len("thumb-") : dot] if dot > 0 else filename[len("thumb-") :]
|
||||
return _longest_prefix_camera(stem, frigate_config)
|
||||
|
||||
|
||||
def _resolve_export(
|
||||
parts: list[str],
|
||||
) -> tuple[MediaAuthResolution, Optional[str]]:
|
||||
# /exports → multi-camera listing
|
||||
# /exports/{filename}.mp4 → camera (DB lookup by exact path)
|
||||
if len(parts) == 1:
|
||||
return MediaAuthResolution.LISTING_MULTI_CAMERA, None
|
||||
if len(parts) != 2:
|
||||
return MediaAuthResolution.UNRESOLVED_MEDIA, None
|
||||
|
||||
filename = parts[1]
|
||||
full_path = os.path.join(EXPORT_DIR, filename)
|
||||
try:
|
||||
export = Export.get(Export.video_path == full_path)
|
||||
return MediaAuthResolution.CAMERA, export.camera
|
||||
except DoesNotExist:
|
||||
return MediaAuthResolution.UNRESOLVED_MEDIA, None
|
||||
except Exception as e:
|
||||
logger.warning("Export DB lookup failed for %s: %s", filename, e)
|
||||
return MediaAuthResolution.UNRESOLVED_MEDIA, None
|
||||
|
||||
|
||||
def check_camera_access(role: str, camera: str, frigate_config: FrigateConfig) -> bool:
|
||||
"""Return True iff `role` may access `camera`.
|
||||
|
||||
Mirrors the gating logic in `require_camera_access`: admin and any role
|
||||
without a non-empty allow-list bypass the check.
|
||||
"""
|
||||
if role == "admin":
|
||||
return True
|
||||
|
||||
roles_dict = frigate_config.auth.roles
|
||||
if not roles_dict.get(role):
|
||||
return True
|
||||
|
||||
all_camera_names = set(frigate_config.cameras.keys())
|
||||
allowed = User.get_allowed_cameras(role, roles_dict, all_camera_names)
|
||||
return camera in allowed
|
||||
|
||||
|
||||
def is_role_restricted(role: str, frigate_config: FrigateConfig) -> bool:
|
||||
"""True if `role` has a non-empty allow-list (i.e. not full-access)."""
|
||||
if role == "admin":
|
||||
return False
|
||||
return bool(frigate_config.auth.roles.get(role))
|
||||
|
||||
|
||||
def deny_response_for_media_uri(
|
||||
original_url: Optional[str], role: Optional[str], frigate_config: FrigateConfig
|
||||
) -> Optional[int]:
|
||||
"""Decide whether the current role should be blocked from `original_url`.
|
||||
|
||||
Returns an HTTP status code (403) when access should be denied, or `None`
|
||||
when the request is allowed.
|
||||
"""
|
||||
if not original_url:
|
||||
return None
|
||||
|
||||
path = extract_path(original_url)
|
||||
|
||||
# `extract_path` returns None for URIs containing `.` or `..` segments.
|
||||
# For media-root URIs that's a traversal attempt — deny outright. For
|
||||
# non-media URIs, pass through (nginx / the backend handle them).
|
||||
if path is None:
|
||||
raw = urlparse(original_url).path or original_url
|
||||
decoded = unquote(raw)
|
||||
first = decoded.lstrip("/").split("/", 1)[0] if decoded else ""
|
||||
if first in ("clips", "recordings", "exports"):
|
||||
return 403
|
||||
return None
|
||||
|
||||
resolution, camera = resolve_media_uri(path, frigate_config)
|
||||
if resolution == MediaAuthResolution.UNKNOWN:
|
||||
return None
|
||||
|
||||
if not role or role == "admin":
|
||||
return None
|
||||
|
||||
if not is_role_restricted(role, frigate_config):
|
||||
return None
|
||||
|
||||
if resolution == MediaAuthResolution.LISTING_NEUTRAL:
|
||||
return None
|
||||
|
||||
if resolution in (
|
||||
MediaAuthResolution.LISTING_MULTI_CAMERA,
|
||||
MediaAuthResolution.ADMIN_ONLY,
|
||||
MediaAuthResolution.UNRESOLVED_MEDIA,
|
||||
):
|
||||
return 403
|
||||
|
||||
if resolution == MediaAuthResolution.CAMERA:
|
||||
if camera and check_camera_access(role, camera, frigate_config):
|
||||
return None
|
||||
return 403
|
||||
|
||||
return 403
|
||||
@ -144,7 +144,7 @@ class FrigateApp:
|
||||
for d in dirs:
|
||||
if not os.path.exists(d) and not os.path.islink(d):
|
||||
logger.info(f"Creating directory: {d}")
|
||||
os.makedirs(d)
|
||||
os.makedirs(d, exist_ok=True)
|
||||
else:
|
||||
logger.debug(f"Skipping directory: {d}")
|
||||
|
||||
@ -428,18 +428,11 @@ class FrigateApp:
|
||||
self.camera_maintainer.start()
|
||||
|
||||
def start_audio_processor(self) -> None:
|
||||
audio_cameras = [
|
||||
c
|
||||
for c in self.config.cameras.values()
|
||||
if c.enabled and c.audio.enabled_in_config
|
||||
]
|
||||
|
||||
if audio_cameras:
|
||||
self.audio_process = AudioProcessor(
|
||||
self.config, audio_cameras, self.camera_metrics, self.stop_event
|
||||
)
|
||||
self.audio_process.start()
|
||||
self.processes["audio_detector"] = self.audio_process.pid or 0
|
||||
self.audio_process = AudioProcessor(
|
||||
self.config, self.camera_metrics, self.stop_event
|
||||
)
|
||||
self.audio_process.start()
|
||||
self.processes["audio_detector"] = self.audio_process.pid or 0
|
||||
|
||||
def start_timeline_processor(self) -> None:
|
||||
self.timeline_processor = TimelineProcessor(
|
||||
|
||||
@ -34,6 +34,8 @@ from frigate.const import (
|
||||
UPDATE_REVIEW_DESCRIPTION,
|
||||
UPSERT_REVIEW_SEGMENT,
|
||||
)
|
||||
from frigate.models import User
|
||||
from frigate.output.ws_auth import ws_has_camera_access
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -66,6 +68,7 @@ _WS_VIEWER_TOPICS = frozenset(
|
||||
"audioTranscriptionState",
|
||||
"birdseyeLayout",
|
||||
"embeddingsReindexProgress",
|
||||
"jobState",
|
||||
}
|
||||
)
|
||||
|
||||
@ -102,6 +105,321 @@ def _check_ws_authorization(
|
||||
return topic in _WS_VIEWER_TOPICS
|
||||
|
||||
|
||||
# ---- Outbound filtering ---------------------------------------------------
|
||||
#
|
||||
# Every WebSocket broadcast is classified into one of a small set of scopes,
|
||||
# then materialized per recipient. Connections with restricted roles only see
|
||||
# data for cameras they are authorized to access; admin and full-access roles
|
||||
# behave as today.
|
||||
|
||||
# Topics that are safe to broadcast to every authenticated client.
|
||||
_WS_GLOBAL_OUTBOUND_TOPICS = frozenset(
|
||||
{
|
||||
"model_state",
|
||||
"embeddings_reindex_progress",
|
||||
"audio_transcription_state",
|
||||
"profile/state",
|
||||
"notifications/state",
|
||||
"notification_test",
|
||||
}
|
||||
)
|
||||
|
||||
# Topics that restricted roles must never receive. Birdseye composites span
|
||||
# all cameras, so the existing JSMPEG policy already restricts birdseye access
|
||||
# to unrestricted roles; the layout broadcast follows the same rule.
|
||||
_WS_UNRESTRICTED_ONLY_TOPICS = frozenset(
|
||||
{
|
||||
"birdseye_layout",
|
||||
}
|
||||
)
|
||||
|
||||
# Topics whose payload (parsed as JSON) names a single owning camera at the
|
||||
# given key path. Used to scope events, reviews, triggers, etc.
|
||||
_WS_PAYLOAD_CAMERA_TOPICS: dict[str, tuple[str, ...]] = {
|
||||
"events": ("after", "camera"),
|
||||
"reviews": ("after", "camera"),
|
||||
"tracked_object_update": ("camera",),
|
||||
"triggers": ("camera",),
|
||||
"camera_monitoring": ("camera",),
|
||||
}
|
||||
|
||||
# Topics whose payload is a dict keyed by camera name; filter keys per
|
||||
# recipient.
|
||||
_WS_RESHAPE_BY_CAMERA_KEY_TOPICS = frozenset(
|
||||
{
|
||||
"camera_activity",
|
||||
"audio_detections",
|
||||
}
|
||||
)
|
||||
|
||||
# Topics whose payload is a dict keyed by job_type, where each entry may
|
||||
# contain a "camera" or "source_camera" field, or a nested ``results.jobs``
|
||||
# list of per-camera sub-jobs (export broadcasts).
|
||||
_WS_RESHAPE_JOB_STATE_TOPICS = frozenset(
|
||||
{
|
||||
"job_state",
|
||||
}
|
||||
)
|
||||
|
||||
# Topics whose payload mixes global aggregates with a ``cameras`` sub-dict
|
||||
# keyed by camera name. Aggregates and detector data stay; per-camera entries
|
||||
# are filtered.
|
||||
_WS_RESHAPE_STATS_TOPICS = frozenset(
|
||||
{
|
||||
"stats",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _collect_zone_names(config: FrigateConfig) -> set[str]:
|
||||
"""Return the set of all zone names defined across cameras."""
|
||||
names: set[str] = set()
|
||||
for camera in config.cameras.values():
|
||||
zones = getattr(camera, "zones", None) or {}
|
||||
names.update(zones.keys())
|
||||
return names
|
||||
|
||||
|
||||
def _parse_json_payload(payload: Any) -> Any:
|
||||
"""Return payload parsed as JSON if it is a string, else as-is."""
|
||||
if isinstance(payload, str):
|
||||
try:
|
||||
return json.loads(payload)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
return payload
|
||||
|
||||
|
||||
def _scope_job_entry_to_allowed(entry: Any, allowed: set[str]) -> dict[str, Any] | None:
|
||||
"""Filter a single job_state entry to the recipient's allowed cameras.
|
||||
|
||||
Returns the (possibly reshaped) entry, or None to drop it. Four shapes
|
||||
are handled:
|
||||
|
||||
* Top-level ``camera`` or ``source_camera`` (motion_search, vlm_watch,
|
||||
export sub-job dicts): drop the entry if not allowed.
|
||||
* Nested ``results.jobs`` list of per-camera sub-jobs (the aggregated
|
||||
export broadcast): filter the list; drop the entry if nothing remains.
|
||||
* Nested ``results.camera`` or ``results.source_camera`` (debug_replay,
|
||||
which puts replay-specific fields inside ``results``): drop the entry
|
||||
if not allowed.
|
||||
* No camera anywhere (e.g. ``media_sync``): treat as global and keep.
|
||||
"""
|
||||
if not isinstance(entry, dict):
|
||||
return None
|
||||
|
||||
cam = entry.get("camera") or entry.get("source_camera")
|
||||
|
||||
if cam is None:
|
||||
results = entry.get("results")
|
||||
if isinstance(results, dict):
|
||||
sub_jobs = results.get("jobs")
|
||||
if isinstance(sub_jobs, list):
|
||||
filtered_jobs = [
|
||||
j
|
||||
for j in sub_jobs
|
||||
if isinstance(j, dict)
|
||||
and (j.get("camera") or j.get("source_camera")) in allowed
|
||||
]
|
||||
if not filtered_jobs:
|
||||
return None
|
||||
reshaped = dict(entry)
|
||||
reshaped["results"] = dict(results)
|
||||
reshaped["results"]["jobs"] = filtered_jobs
|
||||
return reshaped
|
||||
|
||||
cam = results.get("camera") or results.get("source_camera")
|
||||
|
||||
if cam is not None:
|
||||
return entry if cam in allowed else None
|
||||
|
||||
return entry
|
||||
|
||||
|
||||
def _extract_payload_camera(payload: Any, path: tuple[str, ...]) -> str | None:
|
||||
"""Walk the dotted path through a (possibly JSON-encoded) payload."""
|
||||
cur = _parse_json_payload(payload)
|
||||
for key in path:
|
||||
if not isinstance(cur, dict):
|
||||
return None
|
||||
cur = cur.get(key)
|
||||
return cur if isinstance(cur, str) else None
|
||||
|
||||
|
||||
def _classify_outbound(
|
||||
topic: str, all_cameras: set[str], all_zones: set[str]
|
||||
) -> tuple[str, Any]:
|
||||
"""Classify an outbound topic into (kind, extra).
|
||||
|
||||
kind values:
|
||||
- "global" : send to every authenticated client
|
||||
- "drop" : send to nobody (fail-closed for unknowns)
|
||||
- "unrestricted_only" : send only to admin/full-access roles
|
||||
- "camera" : extra is the owning camera name
|
||||
- "payload_camera" : extra is the JSON key path to the camera name
|
||||
- "reshape_by_camera_key"
|
||||
- "reshape_job_state"
|
||||
- "reshape_stats"
|
||||
"""
|
||||
if topic in _WS_GLOBAL_OUTBOUND_TOPICS:
|
||||
return ("global", None)
|
||||
if topic in _WS_UNRESTRICTED_ONLY_TOPICS:
|
||||
return ("unrestricted_only", None)
|
||||
if topic in _WS_RESHAPE_BY_CAMERA_KEY_TOPICS:
|
||||
return ("reshape_by_camera_key", None)
|
||||
if topic in _WS_RESHAPE_JOB_STATE_TOPICS:
|
||||
return ("reshape_job_state", None)
|
||||
if topic in _WS_RESHAPE_STATS_TOPICS:
|
||||
return ("reshape_stats", None)
|
||||
if topic in _WS_PAYLOAD_CAMERA_TOPICS:
|
||||
return ("payload_camera", _WS_PAYLOAD_CAMERA_TOPICS[topic])
|
||||
|
||||
# Topic-prefix based: first segment names the owning camera or zone.
|
||||
first = topic.split("/", 1)[0]
|
||||
if first in all_cameras:
|
||||
return ("camera", first)
|
||||
if first in all_zones:
|
||||
# Zone aggregates span cameras; restricted users see nothing here.
|
||||
return ("unrestricted_only", None)
|
||||
|
||||
return ("drop", None)
|
||||
|
||||
|
||||
def _ws_role_header(ws: Any) -> str | None:
|
||||
"""Return the HTTP_REMOTE_ROLE header value, if any."""
|
||||
environ = getattr(ws, "environ", None)
|
||||
if not environ:
|
||||
return None
|
||||
value = environ.get("HTTP_REMOTE_ROLE")
|
||||
return value if isinstance(value, str) else None
|
||||
|
||||
|
||||
def _ws_valid_roles(ws: Any, config: FrigateConfig) -> list[str]:
|
||||
"""Return the list of recognized roles for this connection."""
|
||||
header = _ws_role_header(ws)
|
||||
if not header:
|
||||
return []
|
||||
roles = [r.strip() for r in header.split(config.proxy.separator) if r.strip()]
|
||||
return [r for r in roles if r in config.auth.roles]
|
||||
|
||||
|
||||
def _ws_is_unrestricted(ws: Any, config: FrigateConfig) -> bool:
|
||||
"""True when the connection has unrestricted camera access.
|
||||
|
||||
Mirrors the policy in ``frigate.output.ws_auth``: admin or any role with
|
||||
an empty allow-list grants full access.
|
||||
"""
|
||||
roles = _ws_valid_roles(ws, config)
|
||||
if not roles:
|
||||
return False
|
||||
roles_dict = config.auth.roles
|
||||
return any(r == "admin" or not roles_dict.get(r) for r in roles)
|
||||
|
||||
|
||||
def _ws_allowed_cameras(ws: Any, config: FrigateConfig) -> set[str]:
|
||||
"""Return the union of cameras this connection may access across its roles."""
|
||||
roles = _ws_valid_roles(ws, config)
|
||||
if not roles:
|
||||
return set()
|
||||
all_cameras = set(config.cameras.keys())
|
||||
allowed: set[str] = set()
|
||||
for role in roles:
|
||||
if role == "admin" or not config.auth.roles.get(role):
|
||||
return all_cameras
|
||||
allowed.update(User.get_allowed_cameras(role, config.auth.roles, all_cameras))
|
||||
return allowed
|
||||
|
||||
|
||||
def _wrap_envelope(topic: str, inner_payload: Any) -> str:
|
||||
"""Re-serialize a (topic, payload) message after payload reshaping.
|
||||
|
||||
Frigate's wire format keeps payloads as JSON-encoded strings inside the
|
||||
outer envelope, mirroring what producers send today.
|
||||
"""
|
||||
return json.dumps({"topic": topic, "payload": json.dumps(inner_payload)})
|
||||
|
||||
|
||||
def _materialize_for_ws(
|
||||
ws: Any,
|
||||
topic: str,
|
||||
full_message: str,
|
||||
scope: tuple[str, Any],
|
||||
parsed_payload: Any,
|
||||
config: FrigateConfig,
|
||||
) -> str | None:
|
||||
"""Return the JSON string to deliver to ``ws``, or None to skip it."""
|
||||
kind, extra = scope
|
||||
has_role = _ws_role_header(ws) is not None
|
||||
|
||||
if kind == "drop":
|
||||
return None
|
||||
|
||||
if kind == "global":
|
||||
# Globals still require an authenticated connection. Missing role
|
||||
# falls back to viewer semantics (matching the inbound rule).
|
||||
return full_message
|
||||
|
||||
# Beyond globals, an authenticated role header is required (fail-closed).
|
||||
if not has_role:
|
||||
return None
|
||||
|
||||
if kind == "unrestricted_only":
|
||||
return full_message if _ws_is_unrestricted(ws, config) else None
|
||||
|
||||
if kind == "camera":
|
||||
return full_message if ws_has_camera_access(ws, extra, config) else None
|
||||
|
||||
if kind == "payload_camera":
|
||||
camera = _extract_payload_camera(parsed_payload, extra)
|
||||
if camera is None:
|
||||
return None
|
||||
return full_message if ws_has_camera_access(ws, camera, config) else None
|
||||
|
||||
if kind == "reshape_by_camera_key":
|
||||
if _ws_is_unrestricted(ws, config):
|
||||
return full_message
|
||||
if not isinstance(parsed_payload, dict):
|
||||
return None
|
||||
allowed = _ws_allowed_cameras(ws, config)
|
||||
filtered = {cam: data for cam, data in parsed_payload.items() if cam in allowed}
|
||||
if not filtered:
|
||||
return None
|
||||
return _wrap_envelope(topic, filtered)
|
||||
|
||||
if kind == "reshape_job_state":
|
||||
if _ws_is_unrestricted(ws, config):
|
||||
return full_message
|
||||
if not isinstance(parsed_payload, dict):
|
||||
return None
|
||||
allowed = _ws_allowed_cameras(ws, config)
|
||||
filtered_jobs: dict[str, Any] = {}
|
||||
for job_type, job_payload in parsed_payload.items():
|
||||
scoped = _scope_job_entry_to_allowed(job_payload, allowed)
|
||||
if scoped is not None:
|
||||
filtered_jobs[job_type] = scoped
|
||||
if not filtered_jobs:
|
||||
return None
|
||||
return _wrap_envelope(topic, filtered_jobs)
|
||||
|
||||
if kind == "reshape_stats":
|
||||
if _ws_is_unrestricted(ws, config):
|
||||
return full_message
|
||||
if not isinstance(parsed_payload, dict):
|
||||
return None
|
||||
allowed = _ws_allowed_cameras(ws, config)
|
||||
cameras_block = parsed_payload.get("cameras")
|
||||
if isinstance(cameras_block, dict):
|
||||
filtered_cameras = {
|
||||
name: data for name, data in cameras_block.items() if name in allowed
|
||||
}
|
||||
reshaped = dict(parsed_payload)
|
||||
reshaped["cameras"] = filtered_cameras
|
||||
return _wrap_envelope(topic, reshaped)
|
||||
return full_message
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class WebSocket(WebSocket_): # type: ignore[misc]
|
||||
def unhandled_error(self, error: Any) -> None:
|
||||
"""
|
||||
@ -183,6 +501,10 @@ class WebSocketClient(Communicator):
|
||||
self.websocket_thread.start()
|
||||
|
||||
def publish(self, topic: str, payload: Any, _: bool = False) -> None:
|
||||
if self.websocket_server is None:
|
||||
logger.debug("Skipping message, websocket not connected yet")
|
||||
return
|
||||
|
||||
try:
|
||||
ws_message = json.dumps(
|
||||
{
|
||||
@ -195,14 +517,42 @@ class WebSocketClient(Communicator):
|
||||
logger.debug(f"payload for {topic} wasn't text. Skipping...")
|
||||
return
|
||||
|
||||
if self.websocket_server is None:
|
||||
logger.debug("Skipping message, websocket not connected yet")
|
||||
all_cameras = set(self.config.cameras.keys())
|
||||
all_zones = _collect_zone_names(self.config)
|
||||
scope = _classify_outbound(topic, all_cameras, all_zones)
|
||||
|
||||
if scope[0] == "drop":
|
||||
return
|
||||
|
||||
try:
|
||||
self.websocket_server.manager.broadcast(ws_message)
|
||||
except ConnectionResetError:
|
||||
pass
|
||||
# Pre-parse payload once for topics that need to read its contents.
|
||||
parsed_payload: Any = None
|
||||
if scope[0] in (
|
||||
"payload_camera",
|
||||
"reshape_by_camera_key",
|
||||
"reshape_job_state",
|
||||
"reshape_stats",
|
||||
):
|
||||
parsed_payload = _parse_json_payload(payload)
|
||||
if parsed_payload is None:
|
||||
# malformed payload — fail closed
|
||||
return
|
||||
|
||||
manager = self.websocket_server.manager
|
||||
with manager.lock:
|
||||
websockets = list(manager.websockets.values())
|
||||
|
||||
for ws in websockets:
|
||||
if getattr(ws, "terminated", False):
|
||||
continue
|
||||
message = _materialize_for_ws(
|
||||
ws, topic, ws_message, scope, parsed_payload, self.config
|
||||
)
|
||||
if message is None:
|
||||
continue
|
||||
try:
|
||||
ws.send(message)
|
||||
except (ConnectionResetError, BrokenPipeError, ValueError):
|
||||
pass
|
||||
|
||||
def stop(self) -> None:
|
||||
if self.websocket_server is not None:
|
||||
|
||||
@ -26,7 +26,6 @@ from frigate.plus import PlusApi
|
||||
from frigate.util.builtin import (
|
||||
deep_merge,
|
||||
get_ffmpeg_arg_list,
|
||||
load_labels,
|
||||
)
|
||||
from frigate.util.config import (
|
||||
CURRENT_CONFIG_VERSION,
|
||||
@ -81,12 +80,12 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
yaml = YAML()
|
||||
|
||||
DEFAULT_DETECTORS = {
|
||||
"ov": {
|
||||
"type": "openvino",
|
||||
"device": "CPU",
|
||||
}
|
||||
}
|
||||
# Pydantic field default applied when an existing config omits `detectors:`.
|
||||
# Kept as cpu tflite for backwards compatibility with 0.17 configs.
|
||||
DEFAULT_DETECTORS = {"cpu": {"type": "cpu"}}
|
||||
|
||||
# Used by the openvino branch below and rendered into the new-config YAML
|
||||
# template so first-time setups default to openvino on CPU.
|
||||
DEFAULT_MODEL = {
|
||||
"width": 300,
|
||||
"height": 300,
|
||||
@ -95,6 +94,7 @@ DEFAULT_MODEL = {
|
||||
"path": "/openvino-model/ssdlite_mobilenet_v2.xml",
|
||||
"labelmap_path": "/openvino-model/coco_91cl_bkgr.txt",
|
||||
}
|
||||
NEW_CONFIG_DETECTORS = {"ov": {"type": "openvino", "device": "CPU"}}
|
||||
DEFAULT_DETECT_DIMENSIONS = {"width": 1280, "height": 720}
|
||||
|
||||
|
||||
@ -110,7 +110,7 @@ DEFAULT_CONFIG = f"""
|
||||
mqtt:
|
||||
enabled: False
|
||||
|
||||
{_render_default_yaml({"detectors": DEFAULT_DETECTORS, "model": DEFAULT_MODEL})}
|
||||
{_render_default_yaml({"detectors": NEW_CONFIG_DETECTORS, "model": DEFAULT_MODEL})}
|
||||
cameras: {{}} # No cameras defined, UI wizard should be used
|
||||
version: {CURRENT_CONFIG_VERSION}
|
||||
"""
|
||||
@ -629,26 +629,22 @@ class FrigateConfig(FrigateBaseModel):
|
||||
|
||||
# set default min_score for object attributes
|
||||
for attribute in self.model.all_attributes:
|
||||
if not self.objects.filters.get(attribute):
|
||||
existing = self.objects.filters.get(attribute)
|
||||
if existing is None:
|
||||
self.objects.filters[attribute] = FilterConfig(min_score=0.7)
|
||||
elif self.objects.filters[attribute].min_score == 0.5:
|
||||
self.objects.filters[attribute].min_score = 0.7
|
||||
elif "min_score" not in existing.model_fields_set:
|
||||
existing.min_score = 0.7
|
||||
|
||||
# auto detect hwaccel args
|
||||
if self.ffmpeg.hwaccel_args == "auto":
|
||||
self.ffmpeg.hwaccel_args = auto_detect_hwaccel()
|
||||
|
||||
# Populate global audio filters for all audio labels
|
||||
all_audio_labels = {
|
||||
label
|
||||
for label in load_labels("/audio-labelmap.txt", prefill=521).values()
|
||||
if label
|
||||
}
|
||||
|
||||
# Populate global audio filters from listen. Existing user-defined
|
||||
# entries for labels not in listen are preserved but unused at runtime.
|
||||
if self.audio.filters is None:
|
||||
self.audio.filters = {}
|
||||
|
||||
for key in sorted(all_audio_labels - self.audio.filters.keys()):
|
||||
for key in sorted(set(self.audio.listen) - self.audio.filters.keys()):
|
||||
self.audio.filters[key] = AudioFilterConfig()
|
||||
|
||||
self.audio.filters = dict(sorted(self.audio.filters.items()))
|
||||
@ -840,7 +836,9 @@ class FrigateConfig(FrigateBaseModel):
|
||||
if camera_config.audio.filters is None:
|
||||
camera_config.audio.filters = {}
|
||||
|
||||
for key in sorted(all_audio_labels - camera_config.audio.filters.keys()):
|
||||
for key in sorted(
|
||||
set(camera_config.audio.listen) - camera_config.audio.filters.keys()
|
||||
):
|
||||
camera_config.audio.filters[key] = AudioFilterConfig()
|
||||
|
||||
camera_config.audio.filters = dict(
|
||||
@ -862,7 +860,9 @@ class FrigateConfig(FrigateBaseModel):
|
||||
if mask_config:
|
||||
coords = mask_config.coordinates
|
||||
relative_coords = get_relative_coordinates(
|
||||
coords, camera_config.frame_shape
|
||||
coords,
|
||||
camera_config.frame_shape,
|
||||
camera_name=camera_config.name,
|
||||
)
|
||||
# Create a new ObjectMaskConfig with raw_coordinates set
|
||||
processed_global_masks[mask_id] = ObjectMaskConfig(
|
||||
|
||||
@ -269,7 +269,9 @@ class ObjectDescriptionProcessor(PostProcessorApi):
|
||||
|
||||
if event.has_snapshot and camera_config.objects.genai.use_snapshot:
|
||||
snapshot_image = self._read_and_crop_snapshot(event)
|
||||
|
||||
if not snapshot_image:
|
||||
self.cleanup_event(event_id)
|
||||
return
|
||||
|
||||
num_thumbnails = len(self.tracked_events.get(event_id, []))
|
||||
|
||||
@ -9,6 +9,7 @@ import logging
|
||||
import os
|
||||
import shutil
|
||||
import threading
|
||||
import time
|
||||
|
||||
from ruamel.yaml import YAML
|
||||
|
||||
@ -25,7 +26,15 @@ from frigate.const import (
|
||||
REPLAY_DIR,
|
||||
THUMB_DIR,
|
||||
)
|
||||
from frigate.jobs.debug_replay import cancel_debug_replay_job, wait_for_runner
|
||||
from frigate.jobs.debug_replay import (
|
||||
JOB_TYPE as DEBUG_REPLAY_JOB_TYPE,
|
||||
)
|
||||
from frigate.jobs.debug_replay import (
|
||||
cancel_debug_replay_job,
|
||||
wait_for_runner,
|
||||
)
|
||||
from frigate.jobs.export import JobStatePublisher
|
||||
from frigate.types import JobStatusTypesEnum
|
||||
from frigate.util.camera_cleanup import cleanup_camera_db, cleanup_camera_files
|
||||
from frigate.util.config import find_config_file
|
||||
|
||||
@ -49,6 +58,7 @@ class DebugReplayManager:
|
||||
self.clip_path: str | None = None
|
||||
self.start_ts: float | None = None
|
||||
self.end_ts: float | None = None
|
||||
self._job_state_publisher = JobStatePublisher()
|
||||
|
||||
@property
|
||||
def active(self) -> bool:
|
||||
@ -150,6 +160,7 @@ class DebugReplayManager:
|
||||
return
|
||||
|
||||
replay_name = self.replay_camera_name
|
||||
source_camera = self.source_camera
|
||||
|
||||
# Only publish remove if the camera was actually added to the live
|
||||
# config (i.e. the runner reached the starting_camera phase).
|
||||
@ -163,6 +174,21 @@ class DebugReplayManager:
|
||||
self._cleanup_db(replay_name)
|
||||
self._cleanup_files(replay_name)
|
||||
|
||||
self._job_state_publisher.publish(
|
||||
{
|
||||
"id": "stopped",
|
||||
"job_type": DEBUG_REPLAY_JOB_TYPE,
|
||||
"status": JobStatusTypesEnum.cancelled,
|
||||
"start_time": None,
|
||||
"end_time": time.time(),
|
||||
"error_message": None,
|
||||
"results": {
|
||||
"source_camera": source_camera,
|
||||
"replay_camera_name": replay_name,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
self._clear_locked()
|
||||
|
||||
logger.info("Debug replay stopped and cleaned up: %s", replay_name)
|
||||
|
||||
@ -79,7 +79,11 @@ def is_openvino_gpu_npu_available() -> bool:
|
||||
available_devices = get_openvino_available_devices()
|
||||
# Check for GPU, NPU, or other acceleration devices (excluding CPU)
|
||||
acceleration_devices = ["GPU", "MYRIAD", "NPU", "GNA", "HDDL"]
|
||||
return any(device in available_devices for device in acceleration_devices)
|
||||
return any(
|
||||
avail_dev == accel_dev or avail_dev.startswith(accel_dev + ".")
|
||||
for avail_dev in available_devices
|
||||
for accel_dev in acceleration_devices
|
||||
)
|
||||
|
||||
|
||||
class BaseModelRunner(ABC):
|
||||
@ -278,6 +282,13 @@ class OpenVINOModelRunner(BaseModelRunner):
|
||||
EnrichmentModelTypeEnum.arcface.value,
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def is_detection_model(model_type: str) -> bool:
|
||||
# Import here to avoid circular imports
|
||||
from frigate.detectors.detector_config import ModelTypeEnum
|
||||
|
||||
return model_type in [m.value for m in ModelTypeEnum]
|
||||
|
||||
def __init__(self, model_path: str, device: str, model_type: str, **kwargs):
|
||||
self.model_path = model_path
|
||||
self.device = device
|
||||
@ -306,9 +317,15 @@ class OpenVINOModelRunner(BaseModelRunner):
|
||||
# Apply performance optimization
|
||||
self.ov_core.set_property(device, {"PERF_COUNT": "NO"})
|
||||
|
||||
if device in ["GPU", "AUTO"]:
|
||||
if device in ["GPU", "AUTO", "NPU"]:
|
||||
self.ov_core.set_property(device, {"PERFORMANCE_HINT": "LATENCY"})
|
||||
|
||||
if device == "NPU" and OpenVINOModelRunner.is_detection_model(model_type):
|
||||
try:
|
||||
self.ov_core.set_property(device, {"NPU_TURBO": "YES"})
|
||||
except Exception as e:
|
||||
logger.debug(f"NPU_TURBO not supported by driver: {e}")
|
||||
|
||||
# Compile model
|
||||
self.compiled_model = self.ov_core.compile_model(
|
||||
model=model_path, device_name=device
|
||||
|
||||
@ -60,7 +60,11 @@ from frigate.data_processing.real_time.license_plate import (
|
||||
)
|
||||
from frigate.data_processing.types import DataProcessorMetrics, PostProcessDataEnum
|
||||
from frigate.db.sqlitevecq import SqliteVecQueueDatabase
|
||||
from frigate.events.types import EventTypeEnum, RegenerateDescriptionEnum
|
||||
from frigate.events.types import (
|
||||
EventStateEnum,
|
||||
EventTypeEnum,
|
||||
RegenerateDescriptionEnum,
|
||||
)
|
||||
from frigate.genai import GenAIClientManager
|
||||
from frigate.models import Event, Recordings, ReviewSegment, Trigger
|
||||
from frigate.types import TrackedObjectUpdateTypesEnum
|
||||
@ -228,7 +232,7 @@ class EmbeddingMaintainer(threading.Thread):
|
||||
)
|
||||
)
|
||||
|
||||
if self.config.audio_transcription.enabled and any(
|
||||
if any(
|
||||
c.enabled_in_config and c.audio_transcription.enabled
|
||||
for c in self.config.cameras.values()
|
||||
):
|
||||
@ -435,7 +439,7 @@ class EmbeddingMaintainer(threading.Thread):
|
||||
if update is None:
|
||||
return
|
||||
|
||||
source_type, _, camera, frame_name, data = update
|
||||
source_type, event_type, camera, frame_name, data = update
|
||||
|
||||
logger.debug(
|
||||
f"Received update - source_type: {source_type}, camera: {camera}, data label: {data.get('label') if data else 'None'}"
|
||||
@ -485,6 +489,12 @@ class EmbeddingMaintainer(threading.Thread):
|
||||
|
||||
for processor in self.post_processors:
|
||||
if isinstance(processor, ObjectDescriptionProcessor):
|
||||
# skip end events — _process_finalized handles them via event_end_subscriber.
|
||||
# processing them here can re-create tracked_events entries after cleanup
|
||||
# when the event_subscriber queue is backlogged behind event_end_subscriber.
|
||||
if event_type == EventStateEnum.end:
|
||||
continue
|
||||
|
||||
processor.process_data(
|
||||
{
|
||||
"camera": camera,
|
||||
|
||||
@ -84,7 +84,6 @@ class AudioProcessor(FrigateProcess):
|
||||
def __init__(
|
||||
self,
|
||||
config: FrigateConfig,
|
||||
cameras: list[CameraConfig],
|
||||
camera_metrics: DictProxy,
|
||||
stop_event: MpEvent,
|
||||
):
|
||||
@ -93,16 +92,18 @@ class AudioProcessor(FrigateProcess):
|
||||
)
|
||||
|
||||
self.camera_metrics = camera_metrics
|
||||
self.cameras = cameras
|
||||
self.config = config
|
||||
|
||||
def run(self) -> None:
|
||||
self.pre_run_setup(self.config.logger)
|
||||
audio_threads: list[AudioEventMaintainer] = []
|
||||
audio_threads: dict[str, AudioEventMaintainer] = {}
|
||||
|
||||
threading.current_thread().name = "process:audio_manager"
|
||||
|
||||
if self.config.audio_transcription.enabled:
|
||||
if any(
|
||||
c.enabled_in_config and c.audio_transcription.enabled
|
||||
for c in self.config.cameras.values()
|
||||
):
|
||||
self.transcription_model_runner: AudioTranscriptionModelRunner | None = (
|
||||
AudioTranscriptionModelRunner(
|
||||
self.config.audio_transcription.device or "AUTO",
|
||||
@ -112,32 +113,56 @@ class AudioProcessor(FrigateProcess):
|
||||
else:
|
||||
self.transcription_model_runner = None
|
||||
|
||||
if len(self.cameras) == 0:
|
||||
return
|
||||
config_subscriber = CameraConfigUpdateSubscriber(
|
||||
self.config,
|
||||
self.config.cameras,
|
||||
[
|
||||
CameraConfigUpdateEnum.add,
|
||||
CameraConfigUpdateEnum.audio,
|
||||
CameraConfigUpdateEnum.ffmpeg,
|
||||
],
|
||||
)
|
||||
|
||||
for camera in self.cameras:
|
||||
audio_thread = AudioEventMaintainer(
|
||||
def spawn_if_needed(camera: CameraConfig) -> None:
|
||||
name = camera.name
|
||||
if name is None or name in audio_threads:
|
||||
return
|
||||
if not camera.enabled or not camera.audio.enabled:
|
||||
return
|
||||
# ffmpeg update may not have arrived yet; wait for next poll
|
||||
if not any("audio" in i.roles for i in camera.ffmpeg.inputs):
|
||||
return
|
||||
thread = AudioEventMaintainer(
|
||||
camera,
|
||||
self.config,
|
||||
self.camera_metrics,
|
||||
self.transcription_model_runner,
|
||||
self.stop_event, # type: ignore[arg-type]
|
||||
)
|
||||
audio_threads.append(audio_thread)
|
||||
audio_thread.start()
|
||||
audio_threads[name] = thread
|
||||
thread.start()
|
||||
self.logger.info(f"Audio maintainer started for {name}")
|
||||
|
||||
for camera in self.config.cameras.values():
|
||||
spawn_if_needed(camera)
|
||||
|
||||
self.logger.info(f"Audio processor started (pid: {self.pid})")
|
||||
|
||||
while not self.stop_event.wait():
|
||||
pass
|
||||
# poll for newly added cameras or cameras flipped to audio.enabled at runtime
|
||||
while not self.stop_event.wait(timeout=1.0):
|
||||
config_subscriber.check_for_updates()
|
||||
for camera in self.config.cameras.values():
|
||||
spawn_if_needed(camera)
|
||||
|
||||
for thread in audio_threads:
|
||||
config_subscriber.stop()
|
||||
|
||||
for thread in audio_threads.values():
|
||||
thread.join(1)
|
||||
if thread.is_alive():
|
||||
self.logger.info(f"Waiting for thread {thread.name:s} to exit")
|
||||
thread.join(10)
|
||||
|
||||
for thread in audio_threads:
|
||||
for thread in audio_threads.values():
|
||||
if thread.is_alive():
|
||||
self.logger.warning(f"Thread {thread.name} is still alive")
|
||||
|
||||
@ -184,7 +209,7 @@ class AudioEventMaintainer(threading.Thread):
|
||||
self.detection_publisher = DetectionPublisher(DetectionTypeEnum.audio.value)
|
||||
|
||||
if (
|
||||
self.config.audio_transcription.enabled
|
||||
self.camera_config.audio_transcription.enabled
|
||||
and self.audio_transcription_model_runner is not None
|
||||
):
|
||||
# init the transcription processor for this camera
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
"""Generative AI module for Frigate."""
|
||||
|
||||
import datetime
|
||||
import importlib
|
||||
import json
|
||||
import logging
|
||||
@ -9,13 +8,18 @@ import re
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
import numpy as np
|
||||
from playhouse.shortcuts import model_to_dict
|
||||
from pydantic import ValidationError
|
||||
|
||||
from frigate.config import CameraConfig, GenAIConfig, GenAIProviderEnum
|
||||
from frigate.const import CLIPS_DIR
|
||||
from frigate.data_processing.post.types import ReviewMetadata
|
||||
from frigate.genai.manager import GenAIClientManager
|
||||
from frigate.genai.prompts import (
|
||||
build_object_description_prompt,
|
||||
build_review_description_prompt,
|
||||
build_review_description_response_format,
|
||||
build_review_summary_prompt,
|
||||
)
|
||||
from frigate.models import Event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -61,75 +65,14 @@ class GenAIClient:
|
||||
activity_context_prompt: str,
|
||||
) -> ReviewMetadata | None:
|
||||
"""Generate a description for the review item activity."""
|
||||
context_prompt = build_review_description_prompt(
|
||||
review_data,
|
||||
thumbnails,
|
||||
concerns,
|
||||
preferred_language,
|
||||
activity_context_prompt,
|
||||
)
|
||||
|
||||
def get_concern_prompt() -> str:
|
||||
if concerns:
|
||||
concern_list = "\n - ".join(concerns)
|
||||
return f"""- `other_concerns` (list of strings): Include a list of any of the following concerns that are occurring:
|
||||
- {concern_list}"""
|
||||
else:
|
||||
return ""
|
||||
|
||||
def get_language_prompt() -> str:
|
||||
if preferred_language:
|
||||
return f"Provide your answer in {preferred_language}"
|
||||
else:
|
||||
return ""
|
||||
|
||||
def get_objects_list() -> str:
|
||||
if review_data["unified_objects"]:
|
||||
return "\n- " + "\n- ".join(review_data["unified_objects"])
|
||||
else:
|
||||
return "\n- (No objects detected)"
|
||||
|
||||
context_prompt = f"""
|
||||
Your task is to analyze a sequence of images taken in chronological order from a security camera.
|
||||
|
||||
## Normal Activity Patterns for This Property
|
||||
|
||||
{activity_context_prompt}
|
||||
|
||||
## Task Instructions
|
||||
|
||||
Describe the scene based on observable actions and movements, evaluate the activity against the Activity Indicators above, and assign a potential_threat_level (0, 1, or 2) by applying the threat level indicators consistently.
|
||||
|
||||
## Analysis Guidelines
|
||||
|
||||
When forming your description:
|
||||
- **CRITICAL: Only describe objects explicitly listed in "Objects in Scene" below.** Do not infer or mention additional people, vehicles, or objects not present in this list, even if visual patterns suggest them. If only a car is listed, do not describe a person interacting with it unless "person" is also in the objects list.
|
||||
- **Only describe actions actually visible in the frames.** Do not assume or infer actions that you don't observe happening. If someone walks toward furniture but you never see them sit, do not say they sat. Stick to what you can see across the sequence.
|
||||
- Describe what you observe: actions, movements, interactions with objects and the environment. Include any observable environmental changes (e.g., lighting changes triggered by activity).
|
||||
- Note visible details such as clothing, items being carried or placed, tools or equipment present, and how they interact with the property or objects.
|
||||
- Consider the full sequence chronologically: what happens from start to finish, how duration and actions relate to the location and objects involved.
|
||||
- **Use the actual timestamp provided in "Activity started at"** below for time of day context—do not infer time from image brightness or darkness. Unusual hours (late night/early morning) should increase suspicion when the observable behavior itself appears questionable. However, recognize that some legitimate activities can occur at any hour.
|
||||
- **Consider duration as a primary factor**: Apply the duration thresholds defined in the activity patterns above. Brief sequences during normal hours with apparent purpose typically indicate normal activity unless explicit suspicious actions are visible.
|
||||
- **Weigh all evidence holistically**: Match the activity against the normal and suspicious patterns defined above, then evaluate based on the complete context (zone, objects, time, actions, duration). Apply the threat level indicators consistently. Use your judgment for edge cases.
|
||||
|
||||
## Response Field Guidelines
|
||||
|
||||
Respond with a JSON object matching the provided schema. Field-specific guidance:
|
||||
- `observations`: Include the very start of the activity — for example, a vehicle entering the frame or pulling into the driveway — even if it lasts only a few frames and the rest of the clip is dominated by a longer activity. Include each arrival, departure, object handled, and notable change in position or state. Each item is a single concrete fact written as a complete sentence.
|
||||
- `scene`: Describe how the sequence begins, then the progression of events — all significant movements and actions in order. For example, if a vehicle arrives and then a person exits, describe both sequentially. For named subjects (those with a `←` separator in "Objects in Scene"), always use their name — do not replace them with generic terms. For unnamed objects (e.g., "person", "car"), refer to them naturally with articles (e.g., "a person", "the car"). Your description should align with and support the threat level you assign.
|
||||
- `title`: Name the primary activity across the observations, together with the location. An activity is what is being done with objects, tools, or surfaces; locomotion through the scene qualifies as the activity only when no other interaction is observed. For named subjects, always use their name. For unnamed objects, refer to them naturally with articles.
|
||||
- `shortSummary`: Briefly summarize the primary activity across the observations.
|
||||
- `potential_threat_level`: Must be consistent with your scene description and the activity patterns above.
|
||||
|
||||
## Sequence Details
|
||||
|
||||
- Camera: {review_data["camera"]}
|
||||
- Total frames: {len(thumbnails)} (Frame 1 = earliest, Frame {len(thumbnails)} = latest)
|
||||
- Activity started at {review_data["start"]} and lasted {review_data["duration"]} seconds
|
||||
- Zones involved: {", ".join(review_data["zones"]) if review_data["zones"] else "None"}
|
||||
|
||||
## Objects in Scene
|
||||
|
||||
Each line represents a detection state, not necessarily unique individuals. The `←` symbol separates a recognized subject's name from their object type — use only the name (before the `←`) in your response, not the type after it. The same subject may appear across multiple lines if detected multiple times.
|
||||
|
||||
**Note: Unidentified objects (without names) are NOT indicators of suspicious activity—they simply mean the system hasn't identified that object.**
|
||||
{get_objects_list()}
|
||||
|
||||
{get_language_prompt()}
|
||||
"""
|
||||
logger.debug(
|
||||
f"Sending {len(thumbnails)} images to create review description on {review_data['camera']}"
|
||||
)
|
||||
@ -143,25 +86,7 @@ Each line represents a detection state, not necessarily unique individuals. The
|
||||
) as f:
|
||||
f.write(context_prompt)
|
||||
|
||||
# Build JSON schema for structured output from ReviewMetadata model
|
||||
schema = ReviewMetadata.model_json_schema()
|
||||
schema.get("properties", {}).pop("time", None)
|
||||
|
||||
if "time" in schema.get("required", []):
|
||||
schema["required"].remove("time")
|
||||
if not concerns:
|
||||
schema.get("properties", {}).pop("other_concerns", None)
|
||||
if "other_concerns" in schema.get("required", []):
|
||||
schema["required"].remove("other_concerns")
|
||||
|
||||
response_format = {
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": "review_metadata",
|
||||
"strict": True,
|
||||
"schema": schema,
|
||||
},
|
||||
}
|
||||
response_format = build_review_description_response_format(concerns)
|
||||
|
||||
response = self._send(context_prompt, thumbnails, response_format)
|
||||
|
||||
@ -240,61 +165,9 @@ Each line represents a detection state, not necessarily unique individuals. The
|
||||
debug_save: bool,
|
||||
) -> str | None:
|
||||
"""Generate a summary of review item descriptions over a period of time."""
|
||||
time_range = f"{datetime.datetime.fromtimestamp(start_ts).strftime('%B %d, %Y at %I:%M %p')} to {datetime.datetime.fromtimestamp(end_ts).strftime('%B %d, %Y at %I:%M %p')}"
|
||||
timeline_summary_prompt = f"""
|
||||
You are a security officer writing a concise security report.
|
||||
|
||||
Time range: {time_range}
|
||||
|
||||
Input format: Each event is a JSON object with:
|
||||
- "title", "scene", "confidence", "potential_threat_level" (0-2), "other_concerns", "camera", "time", "start_time", "end_time"
|
||||
- "context": array of related events from other cameras that occurred during overlapping time periods
|
||||
|
||||
**Note: Use the "scene" field for event descriptions in the report. Ignore any "shortSummary" field if present.**
|
||||
|
||||
Report Structure - Use this EXACT format:
|
||||
|
||||
# Security Summary - {time_range}
|
||||
|
||||
## Overview
|
||||
[Write 1-2 sentences summarizing the overall activity pattern during this period.]
|
||||
|
||||
---
|
||||
|
||||
## Timeline
|
||||
|
||||
[Group events by time periods (e.g., "Morning (6:00 AM - 12:00 PM)", "Afternoon (12:00 PM - 5:00 PM)", "Evening (5:00 PM - 9:00 PM)", "Night (9:00 PM - 6:00 AM)"). Use appropriate time blocks based on when events occurred.]
|
||||
|
||||
### [Time Block Name]
|
||||
|
||||
**HH:MM AM/PM** | [Camera Name] | [Threat Level Indicator]
|
||||
- [Event title]: [Clear description incorporating contextual information from the "context" array]
|
||||
- Context: [If context array has items, mention them here, e.g., "Delivery truck present on Front Driveway Cam (HH:MM AM/PM)"]
|
||||
- Assessment: [Brief assessment incorporating context - if context explains the event, note it here]
|
||||
|
||||
[Repeat for each event in chronological order within the time block]
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
[One sentence summarizing the period. If all events are normal/explained: "Routine activity observed." If review needed: "Some activity requires review but no security concerns." If security concerns: "Security concerns requiring immediate attention."]
|
||||
|
||||
Guidelines:
|
||||
- List ALL events in chronological order, grouped by time blocks
|
||||
- Threat level indicators: ✓ Normal, ⚠️ Needs review, 🔴 Security concern
|
||||
- Integrate contextual information naturally - use the "context" array to enrich each event's description
|
||||
- If context explains the event (e.g., delivery truck explains person at door), describe it accordingly (e.g., "delivery person" not "unidentified person")
|
||||
- Be concise but informative - focus on what happened and what it means
|
||||
- If contextual information makes an event clearly normal, reflect that in your assessment
|
||||
- Only create time blocks that have events - don't create empty sections
|
||||
"""
|
||||
|
||||
timeline_summary_prompt += "\n\nEvents:\n"
|
||||
for event in events:
|
||||
timeline_summary_prompt += f"\n{event}\n"
|
||||
|
||||
if preferred_language:
|
||||
timeline_summary_prompt += f"\nProvide your answer in {preferred_language}"
|
||||
timeline_summary_prompt = build_review_summary_prompt(
|
||||
start_ts, end_ts, events, preferred_language
|
||||
)
|
||||
|
||||
if debug_save:
|
||||
with open(
|
||||
@ -326,10 +199,7 @@ Guidelines:
|
||||
) -> Optional[str]:
|
||||
"""Generate a description for the frame."""
|
||||
try:
|
||||
prompt = camera_config.objects.genai.object_prompts.get(
|
||||
str(event.label),
|
||||
camera_config.objects.genai.prompt,
|
||||
).format(**model_to_dict(event))
|
||||
prompt = build_object_description_prompt(camera_config, event)
|
||||
except KeyError as e:
|
||||
logger.error(f"Invalid key in GenAI prompt: {e}")
|
||||
return None
|
||||
@ -430,6 +300,10 @@ Guidelines:
|
||||
Returns:
|
||||
Dictionary with:
|
||||
- 'content': Optional[str] - The text response from the LLM, None if tool calls
|
||||
- 'reasoning': Optional[str] - The separated reasoning/thinking trace
|
||||
if the model emitted one (e.g. via OpenAI-compatible
|
||||
`reasoning_content`). None when the model does not surface a
|
||||
trace or the provider does not parse it.
|
||||
- 'tool_calls': Optional[List[Dict]] - List of tool calls if LLM wants to call tools.
|
||||
Each tool call dict has:
|
||||
- 'id': str - Unique identifier for this tool call
|
||||
@ -441,6 +315,14 @@ Guidelines:
|
||||
- 'length': Hit token limit
|
||||
- 'error': An error occurred
|
||||
|
||||
Streaming counterpart `chat_with_tools_stream` yields
|
||||
``(kind, value)`` tuples where ``kind`` is one of:
|
||||
- 'content_delta': value is a string fragment of the answer
|
||||
- 'reasoning_delta': value is a string fragment of the reasoning
|
||||
trace (emitted before content for thinking models)
|
||||
- 'stats': value is a usage stats dict
|
||||
- 'message': value is the final dict shape described above
|
||||
|
||||
Raises:
|
||||
NotImplementedError: If the provider doesn't implement this method.
|
||||
"""
|
||||
@ -451,14 +333,15 @@ Guidelines:
|
||||
)
|
||||
return {
|
||||
"content": None,
|
||||
"reasoning": None,
|
||||
"tool_calls": None,
|
||||
"finish_reason": "error",
|
||||
}
|
||||
|
||||
|
||||
def load_providers() -> None:
|
||||
package_dir = os.path.dirname(__file__)
|
||||
for filename in os.listdir(package_dir):
|
||||
plugins_dir = os.path.join(os.path.dirname(__file__), "plugins")
|
||||
for filename in os.listdir(plugins_dir):
|
||||
if filename.endswith(".py") and filename != "__init__.py":
|
||||
module_name = f"frigate.genai.{filename[:-3]}"
|
||||
module_name = f"frigate.genai.plugins.{filename[:-3]}"
|
||||
importlib.import_module(module_name)
|
||||
|
||||
@ -1,305 +0,0 @@
|
||||
"""Azure OpenAI Provider for Frigate AI."""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, AsyncGenerator, Optional
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from openai import AzureOpenAI
|
||||
|
||||
from frigate.config import GenAIProviderEnum
|
||||
from frigate.genai import GenAIClient, register_genai_provider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@register_genai_provider(GenAIProviderEnum.azure_openai)
|
||||
class OpenAIClient(GenAIClient):
|
||||
"""Generative AI client for Frigate using Azure OpenAI."""
|
||||
|
||||
provider: AzureOpenAI
|
||||
|
||||
def _init_provider(self) -> AzureOpenAI | None:
|
||||
"""Initialize the client."""
|
||||
try:
|
||||
parsed_url = urlparse(self.genai_config.base_url or "")
|
||||
query_params = parse_qs(parsed_url.query)
|
||||
api_version = query_params.get("api-version", [None])[0]
|
||||
azure_endpoint = f"{parsed_url.scheme}://{parsed_url.netloc}/"
|
||||
|
||||
if not api_version:
|
||||
logger.warning("Azure OpenAI url is missing API version.")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("Error parsing Azure OpenAI url: %s", str(e))
|
||||
return None
|
||||
|
||||
return AzureOpenAI(
|
||||
api_key=self.genai_config.api_key,
|
||||
api_version=api_version,
|
||||
azure_endpoint=azure_endpoint,
|
||||
)
|
||||
|
||||
def _send(
|
||||
self,
|
||||
prompt: str,
|
||||
images: list[bytes],
|
||||
response_format: Optional[dict] = None,
|
||||
) -> Optional[str]:
|
||||
"""Submit a request to Azure OpenAI."""
|
||||
encoded_images = [base64.b64encode(image).decode("utf-8") for image in images]
|
||||
try:
|
||||
request_params = {
|
||||
"model": self.genai_config.model,
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "text": prompt}]
|
||||
+ [
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": f"data:image/jpeg;base64,{image}",
|
||||
"detail": "low",
|
||||
},
|
||||
}
|
||||
for image in encoded_images
|
||||
],
|
||||
},
|
||||
],
|
||||
"timeout": self.timeout,
|
||||
**self.genai_config.runtime_options,
|
||||
}
|
||||
if response_format:
|
||||
request_params["response_format"] = response_format
|
||||
result = self.provider.chat.completions.create(**request_params)
|
||||
except Exception as e:
|
||||
logger.warning("Azure OpenAI returned an error: %s", str(e))
|
||||
return None
|
||||
if len(result.choices) > 0:
|
||||
return str(result.choices[0].message.content.strip())
|
||||
return None
|
||||
|
||||
def list_models(self) -> list[str]:
|
||||
"""Return available model IDs from Azure OpenAI."""
|
||||
try:
|
||||
return sorted(m.id for m in self.provider.models.list().data)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to list Azure OpenAI models: %s", e)
|
||||
return []
|
||||
|
||||
def get_context_size(self) -> int:
|
||||
"""Get the context window size for Azure OpenAI."""
|
||||
return 128000
|
||||
|
||||
def chat_with_tools(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
tools: Optional[list[dict[str, Any]]] = None,
|
||||
tool_choice: Optional[str] = "auto",
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
openai_tool_choice = None
|
||||
if tool_choice:
|
||||
if tool_choice == "none":
|
||||
openai_tool_choice = "none"
|
||||
elif tool_choice == "auto":
|
||||
openai_tool_choice = "auto"
|
||||
elif tool_choice == "required":
|
||||
openai_tool_choice = "required"
|
||||
|
||||
request_params = {
|
||||
"model": self.genai_config.model,
|
||||
"messages": messages,
|
||||
"timeout": self.timeout,
|
||||
}
|
||||
|
||||
if tools:
|
||||
request_params["tools"] = tools
|
||||
if openai_tool_choice is not None:
|
||||
request_params["tool_choice"] = openai_tool_choice
|
||||
|
||||
result = self.provider.chat.completions.create(**request_params) # type: ignore[call-overload]
|
||||
|
||||
if (
|
||||
result is None
|
||||
or not hasattr(result, "choices")
|
||||
or len(result.choices) == 0
|
||||
):
|
||||
return {
|
||||
"content": None,
|
||||
"tool_calls": None,
|
||||
"finish_reason": "error",
|
||||
}
|
||||
|
||||
choice = result.choices[0]
|
||||
message = choice.message
|
||||
|
||||
content = message.content.strip() if message.content else None
|
||||
|
||||
tool_calls = None
|
||||
if message.tool_calls:
|
||||
tool_calls = []
|
||||
for tool_call in message.tool_calls:
|
||||
try:
|
||||
arguments = json.loads(tool_call.function.arguments)
|
||||
except (json.JSONDecodeError, AttributeError) as e:
|
||||
logger.warning(
|
||||
f"Failed to parse tool call arguments: {e}, "
|
||||
f"tool: {tool_call.function.name if hasattr(tool_call.function, 'name') else 'unknown'}"
|
||||
)
|
||||
arguments = {}
|
||||
|
||||
tool_calls.append(
|
||||
{
|
||||
"id": tool_call.id if hasattr(tool_call, "id") else "",
|
||||
"name": tool_call.function.name
|
||||
if hasattr(tool_call.function, "name")
|
||||
else "",
|
||||
"arguments": arguments,
|
||||
}
|
||||
)
|
||||
|
||||
finish_reason = "error"
|
||||
if hasattr(choice, "finish_reason") and choice.finish_reason:
|
||||
finish_reason = choice.finish_reason
|
||||
elif tool_calls:
|
||||
finish_reason = "tool_calls"
|
||||
elif content:
|
||||
finish_reason = "stop"
|
||||
|
||||
return {
|
||||
"content": content,
|
||||
"tool_calls": tool_calls,
|
||||
"finish_reason": finish_reason,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("Azure OpenAI returned an error: %s", str(e))
|
||||
return {
|
||||
"content": None,
|
||||
"tool_calls": None,
|
||||
"finish_reason": "error",
|
||||
}
|
||||
|
||||
async def chat_with_tools_stream(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
tools: Optional[list[dict[str, Any]]] = None,
|
||||
tool_choice: Optional[str] = "auto",
|
||||
) -> AsyncGenerator[tuple[str, Any], None]:
|
||||
"""
|
||||
Stream chat with tools; yields content deltas then final message.
|
||||
|
||||
Implements streaming function calling/tool usage for Azure OpenAI models.
|
||||
"""
|
||||
try:
|
||||
openai_tool_choice = None
|
||||
if tool_choice:
|
||||
if tool_choice == "none":
|
||||
openai_tool_choice = "none"
|
||||
elif tool_choice == "auto":
|
||||
openai_tool_choice = "auto"
|
||||
elif tool_choice == "required":
|
||||
openai_tool_choice = "required"
|
||||
|
||||
request_params = {
|
||||
"model": self.genai_config.model,
|
||||
"messages": messages,
|
||||
"timeout": self.timeout,
|
||||
"stream": True,
|
||||
}
|
||||
|
||||
if tools:
|
||||
request_params["tools"] = tools
|
||||
if openai_tool_choice is not None:
|
||||
request_params["tool_choice"] = openai_tool_choice
|
||||
|
||||
# Use streaming API
|
||||
content_parts: list[str] = []
|
||||
tool_calls_by_index: dict[int, dict[str, Any]] = {}
|
||||
finish_reason = "stop"
|
||||
|
||||
stream = self.provider.chat.completions.create(**request_params) # type: ignore[call-overload]
|
||||
|
||||
for chunk in stream:
|
||||
if not chunk or not chunk.choices:
|
||||
continue
|
||||
|
||||
choice = chunk.choices[0]
|
||||
delta = choice.delta
|
||||
|
||||
# Check for finish reason
|
||||
if choice.finish_reason:
|
||||
finish_reason = choice.finish_reason
|
||||
|
||||
# Extract content deltas
|
||||
if delta.content:
|
||||
content_parts.append(delta.content)
|
||||
yield ("content_delta", delta.content)
|
||||
|
||||
# Extract tool calls
|
||||
if delta.tool_calls:
|
||||
for tc in delta.tool_calls:
|
||||
idx = tc.index
|
||||
fn = tc.function
|
||||
|
||||
if idx not in tool_calls_by_index:
|
||||
tool_calls_by_index[idx] = {
|
||||
"id": tc.id or "",
|
||||
"name": fn.name if fn and fn.name else "",
|
||||
"arguments": "",
|
||||
}
|
||||
|
||||
t = tool_calls_by_index[idx]
|
||||
if tc.id:
|
||||
t["id"] = tc.id
|
||||
if fn and fn.name:
|
||||
t["name"] = fn.name
|
||||
if fn and fn.arguments:
|
||||
t["arguments"] += fn.arguments
|
||||
|
||||
# Build final message
|
||||
full_content = "".join(content_parts).strip() or None
|
||||
|
||||
# Convert tool calls to list format
|
||||
tool_calls_list = None
|
||||
if tool_calls_by_index:
|
||||
tool_calls_list = []
|
||||
for tc in tool_calls_by_index.values():
|
||||
try:
|
||||
# Parse accumulated arguments as JSON
|
||||
parsed_args = json.loads(tc["arguments"])
|
||||
except (json.JSONDecodeError, Exception):
|
||||
parsed_args = tc["arguments"]
|
||||
|
||||
tool_calls_list.append(
|
||||
{
|
||||
"id": tc["id"],
|
||||
"name": tc["name"],
|
||||
"arguments": parsed_args,
|
||||
}
|
||||
)
|
||||
finish_reason = "tool_calls"
|
||||
|
||||
yield (
|
||||
"message",
|
||||
{
|
||||
"content": full_content,
|
||||
"tool_calls": tool_calls_list,
|
||||
"finish_reason": finish_reason,
|
||||
},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("Azure OpenAI streaming returned an error: %s", str(e))
|
||||
yield (
|
||||
"message",
|
||||
{
|
||||
"content": None,
|
||||
"tool_calls": None,
|
||||
"finish_reason": "error",
|
||||
},
|
||||
)
|
||||
1
frigate/genai/plugins/__init__.py
Normal file
1
frigate/genai/plugins/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""GenAI provider plugins."""
|
||||
53
frigate/genai/plugins/azure-openai.py
Normal file
53
frigate/genai/plugins/azure-openai.py
Normal file
@ -0,0 +1,53 @@
|
||||
"""Azure OpenAI Provider for Frigate AI.
|
||||
|
||||
Azure OpenAI exposes the same chat completions API as OpenAI once the
|
||||
client is constructed, so this provider inherits all transport, streaming,
|
||||
reasoning, and tool-calling logic from :class:`OpenAIClient` and only
|
||||
overrides what is genuinely Azure-specific:
|
||||
|
||||
- Client construction: parses ``api-version`` out of the configured
|
||||
``base_url`` query string and instantiates :class:`openai.AzureOpenAI`
|
||||
with ``azure_endpoint`` instead of ``base_url``. Raises if the URL is
|
||||
malformed; :class:`GenAIClientManager` catches the exception and
|
||||
disables the provider.
|
||||
- Context size: Azure does not expose a per-model ``max_model_len`` field
|
||||
reliably, so we keep the historical 128K default rather than the
|
||||
model-name heuristic used by OpenAI.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from openai import AzureOpenAI
|
||||
|
||||
from frigate.config import GenAIProviderEnum
|
||||
from frigate.genai import register_genai_provider
|
||||
from frigate.genai.plugins.openai import OpenAIClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@register_genai_provider(GenAIProviderEnum.azure_openai)
|
||||
class AzureOpenAIClient(OpenAIClient):
|
||||
"""Generative AI client for Frigate using Azure OpenAI."""
|
||||
|
||||
def _init_provider(self) -> AzureOpenAI:
|
||||
"""Initialize the AzureOpenAI client from the configured base_url."""
|
||||
parsed_url = urlparse(self.genai_config.base_url or "")
|
||||
query_params = parse_qs(parsed_url.query)
|
||||
api_version = query_params.get("api-version", [None])[0]
|
||||
|
||||
if not api_version:
|
||||
raise ValueError("Azure OpenAI base_url is missing api-version.")
|
||||
|
||||
azure_endpoint = f"{parsed_url.scheme}://{parsed_url.netloc}/"
|
||||
|
||||
return AzureOpenAI(
|
||||
api_key=self.genai_config.api_key,
|
||||
api_version=api_version,
|
||||
azure_endpoint=azure_endpoint,
|
||||
)
|
||||
|
||||
def get_context_size(self) -> int:
|
||||
"""Azure does not reliably surface per-model context size; use 128K."""
|
||||
return 128000
|
||||
@ -14,6 +14,20 @@ from frigate.genai import GenAIClient, register_genai_provider
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _stats_from_gemini_usage(usage: Any) -> Optional[dict[str, Any]]:
|
||||
"""Build a stats dict from a Gemini usage_metadata object."""
|
||||
prompt_tokens = getattr(usage, "prompt_token_count", None)
|
||||
completion_tokens = getattr(usage, "candidates_token_count", None)
|
||||
if prompt_tokens is None and completion_tokens is None:
|
||||
return None
|
||||
stats: dict[str, Any] = {}
|
||||
if isinstance(prompt_tokens, int):
|
||||
stats["prompt_tokens"] = prompt_tokens
|
||||
if isinstance(completion_tokens, int):
|
||||
stats["completion_tokens"] = completion_tokens
|
||||
return stats or None
|
||||
|
||||
|
||||
@register_genai_provider(GenAIProviderEnum.gemini)
|
||||
class GeminiClient(GenAIClient):
|
||||
"""Generative AI client for Frigate using Gemini."""
|
||||
@ -234,6 +248,13 @@ class GeminiClient(GenAIClient):
|
||||
if tool_config:
|
||||
config_params["tool_config"] = tool_config
|
||||
|
||||
# Ask thinking-capable models (Gemini 2.5+) to include their
|
||||
# reasoning trace as separate `thought` parts so we can surface
|
||||
# it on the reasoning channel. Older models ignore this field.
|
||||
config_params["thinking_config"] = types.ThinkingConfig(
|
||||
include_thoughts=True
|
||||
)
|
||||
|
||||
# Merge runtime_options
|
||||
if isinstance(self.genai_config.runtime_options, dict):
|
||||
config_params.update(self.genai_config.runtime_options)
|
||||
@ -248,19 +269,24 @@ class GeminiClient(GenAIClient):
|
||||
if not response or not response.candidates:
|
||||
return {
|
||||
"content": None,
|
||||
"reasoning": None,
|
||||
"tool_calls": None,
|
||||
"finish_reason": "error",
|
||||
}
|
||||
|
||||
candidate = response.candidates[0]
|
||||
content = None
|
||||
reasoning_parts: list[str] = []
|
||||
tool_calls = None
|
||||
|
||||
# Extract content and tool calls from response
|
||||
# Extract content, reasoning, and tool calls from response
|
||||
if candidate.content and candidate.content.parts:
|
||||
for part in candidate.content.parts:
|
||||
if part.text:
|
||||
content = part.text.strip()
|
||||
if getattr(part, "thought", False):
|
||||
reasoning_parts.append(part.text)
|
||||
else:
|
||||
content = part.text.strip()
|
||||
elif part.function_call:
|
||||
# Handle function call
|
||||
if tool_calls is None:
|
||||
@ -283,6 +309,8 @@ class GeminiClient(GenAIClient):
|
||||
}
|
||||
)
|
||||
|
||||
reasoning = "".join(reasoning_parts).strip() or None
|
||||
|
||||
# Determine finish reason
|
||||
finish_reason = "error"
|
||||
if hasattr(candidate, "finish_reason") and candidate.finish_reason:
|
||||
@ -308,6 +336,7 @@ class GeminiClient(GenAIClient):
|
||||
|
||||
return {
|
||||
"content": content,
|
||||
"reasoning": reasoning,
|
||||
"tool_calls": tool_calls,
|
||||
"finish_reason": finish_reason,
|
||||
}
|
||||
@ -316,6 +345,7 @@ class GeminiClient(GenAIClient):
|
||||
logger.warning("Gemini API error during chat_with_tools: %s", str(e))
|
||||
return {
|
||||
"content": None,
|
||||
"reasoning": None,
|
||||
"tool_calls": None,
|
||||
"finish_reason": "error",
|
||||
}
|
||||
@ -325,6 +355,7 @@ class GeminiClient(GenAIClient):
|
||||
)
|
||||
return {
|
||||
"content": None,
|
||||
"reasoning": None,
|
||||
"tool_calls": None,
|
||||
"finish_reason": "error",
|
||||
}
|
||||
@ -463,14 +494,22 @@ class GeminiClient(GenAIClient):
|
||||
if tool_config:
|
||||
config_params["tool_config"] = tool_config
|
||||
|
||||
# Ask thinking-capable models to include their reasoning trace
|
||||
# as separate `thought` parts (Gemini 2.5+; ignored elsewhere).
|
||||
config_params["thinking_config"] = types.ThinkingConfig(
|
||||
include_thoughts=True
|
||||
)
|
||||
|
||||
# Merge runtime_options
|
||||
if isinstance(self.genai_config.runtime_options, dict):
|
||||
config_params.update(self.genai_config.runtime_options)
|
||||
|
||||
# Use streaming API
|
||||
content_parts: list[str] = []
|
||||
reasoning_parts: list[str] = []
|
||||
tool_calls_by_index: dict[int, dict[str, Any]] = {}
|
||||
finish_reason = "stop"
|
||||
usage_stats: Optional[dict[str, Any]] = None
|
||||
|
||||
stream = await self.provider.aio.models.generate_content_stream(
|
||||
model=self.genai_config.model,
|
||||
@ -479,6 +518,12 @@ class GeminiClient(GenAIClient):
|
||||
)
|
||||
|
||||
async for chunk in stream:
|
||||
chunk_usage = getattr(chunk, "usage_metadata", None)
|
||||
if chunk_usage is not None:
|
||||
maybe_stats = _stats_from_gemini_usage(chunk_usage)
|
||||
if maybe_stats is not None:
|
||||
usage_stats = maybe_stats
|
||||
|
||||
if not chunk or not chunk.candidates:
|
||||
continue
|
||||
|
||||
@ -498,12 +543,16 @@ class GeminiClient(GenAIClient):
|
||||
]:
|
||||
finish_reason = "error"
|
||||
|
||||
# Extract content and tool calls from chunk
|
||||
# Extract content, reasoning, and tool calls from chunk
|
||||
if candidate.content and candidate.content.parts:
|
||||
for part in candidate.content.parts:
|
||||
if part.text:
|
||||
content_parts.append(part.text)
|
||||
yield ("content_delta", part.text)
|
||||
if getattr(part, "thought", False):
|
||||
reasoning_parts.append(part.text)
|
||||
yield ("reasoning_delta", part.text)
|
||||
else:
|
||||
content_parts.append(part.text)
|
||||
yield ("content_delta", part.text)
|
||||
elif part.function_call:
|
||||
# Handle function call
|
||||
try:
|
||||
@ -544,6 +593,7 @@ class GeminiClient(GenAIClient):
|
||||
|
||||
# Build final message
|
||||
full_content = "".join(content_parts).strip() or None
|
||||
full_reasoning = "".join(reasoning_parts).strip() or None
|
||||
|
||||
# Convert tool calls to list format
|
||||
tool_calls_list = None
|
||||
@ -565,10 +615,14 @@ class GeminiClient(GenAIClient):
|
||||
)
|
||||
finish_reason = "tool_calls"
|
||||
|
||||
if usage_stats is not None:
|
||||
yield ("stats", usage_stats)
|
||||
|
||||
yield (
|
||||
"message",
|
||||
{
|
||||
"content": full_content,
|
||||
"reasoning": full_reasoning,
|
||||
"tool_calls": tool_calls_list,
|
||||
"finish_reason": finish_reason,
|
||||
},
|
||||
@ -580,6 +634,7 @@ class GeminiClient(GenAIClient):
|
||||
"message",
|
||||
{
|
||||
"content": None,
|
||||
"reasoning": None,
|
||||
"tool_calls": None,
|
||||
"finish_reason": "error",
|
||||
},
|
||||
@ -592,6 +647,7 @@ class GeminiClient(GenAIClient):
|
||||
"message",
|
||||
{
|
||||
"content": None,
|
||||
"reasoning": None,
|
||||
"tool_calls": None,
|
||||
"finish_reason": "error",
|
||||
},
|
||||
@ -4,7 +4,7 @@ import base64
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, AsyncGenerator, Optional
|
||||
from typing import Any, AsyncGenerator, Optional, cast
|
||||
|
||||
import httpx
|
||||
import numpy as np
|
||||
@ -18,6 +18,86 @@ from frigate.genai.utils import parse_tool_calls_from_message
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _stats_from_llama_cpp_chunk(data: dict[str, Any]) -> Optional[dict[str, Any]]:
|
||||
"""Build a stats dict from a llama.cpp streaming chunk.
|
||||
|
||||
Final-chunk `usage` carries authoritative token counts. Per-chunk
|
||||
`timings` (enabled via timings_per_token) carries the running token
|
||||
counts (prompt_n, predicted_n) and generation rate, so live updates
|
||||
work mid-stream.
|
||||
"""
|
||||
usage = data.get("usage") or {}
|
||||
timings = data.get("timings") or {}
|
||||
prompt_tokens = usage.get("prompt_tokens")
|
||||
completion_tokens = usage.get("completion_tokens")
|
||||
predicted_ms = timings.get("predicted_ms")
|
||||
tps = timings.get("predicted_per_second")
|
||||
stats: dict[str, Any] = {}
|
||||
|
||||
if not isinstance(prompt_tokens, int):
|
||||
prompt_n = timings.get("prompt_n")
|
||||
|
||||
if isinstance(prompt_n, int):
|
||||
prompt_tokens = prompt_n
|
||||
|
||||
if not isinstance(completion_tokens, int):
|
||||
predicted_n = timings.get("predicted_n")
|
||||
|
||||
if isinstance(predicted_n, int):
|
||||
completion_tokens = predicted_n
|
||||
|
||||
if not isinstance(prompt_tokens, int) and not isinstance(completion_tokens, int):
|
||||
return None
|
||||
|
||||
if isinstance(prompt_tokens, int):
|
||||
stats["prompt_tokens"] = prompt_tokens
|
||||
|
||||
if isinstance(completion_tokens, int):
|
||||
stats["completion_tokens"] = completion_tokens
|
||||
|
||||
if isinstance(predicted_ms, (int, float)) and predicted_ms > 0:
|
||||
stats["completion_duration_ms"] = float(predicted_ms)
|
||||
|
||||
if isinstance(tps, (int, float)) and tps > 0:
|
||||
stats["tokens_per_second"] = float(tps)
|
||||
|
||||
return stats or None
|
||||
|
||||
|
||||
def _parse_launch_arg(args: list[str], flag: str) -> str | None:
|
||||
"""Return the value following `flag` in a positional argv list, or None."""
|
||||
try:
|
||||
idx = args.index(flag)
|
||||
except ValueError:
|
||||
return None
|
||||
if idx + 1 >= len(args):
|
||||
return None
|
||||
return args[idx + 1]
|
||||
|
||||
|
||||
def _fetch_llama_props(base_url: str, model: str) -> dict[str, Any]:
|
||||
"""Fetch /props from a llama.cpp server, with llama-swap fallback.
|
||||
|
||||
Raises the underlying RequestException if both endpoints fail; callers
|
||||
decide how to surface the failure.
|
||||
"""
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{base_url}/props",
|
||||
params={"model": model},
|
||||
timeout=10,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return cast(dict[str, Any], response.json())
|
||||
except Exception:
|
||||
response = requests.get(
|
||||
f"{base_url}/upstream/{model}/props",
|
||||
timeout=10,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return cast(dict[str, Any], response.json())
|
||||
|
||||
|
||||
def _to_jpeg(img_bytes: bytes) -> bytes | None:
|
||||
"""Convert image bytes to JPEG. llama.cpp/STB does not support WebP."""
|
||||
try:
|
||||
@ -71,26 +151,69 @@ class LlamaCppClient(GenAIClient):
|
||||
base_url = base_url.replace("/v1", "") # Strip /v1 if included in base_url
|
||||
|
||||
configured_model = self.genai_config.model
|
||||
info = self._get_model_info(base_url, configured_model)
|
||||
|
||||
# Query /v1/models to validate the configured model exists
|
||||
if info is None:
|
||||
return None
|
||||
|
||||
self._context_size = info["context_size"]
|
||||
self._supports_vision = info["supports_vision"]
|
||||
self._supports_audio = info["supports_audio"]
|
||||
self._supports_tools = info["supports_tools"]
|
||||
self._media_marker = info["media_marker"]
|
||||
|
||||
logger.info(
|
||||
"llama.cpp model '%s' initialized — context: %s, vision: %s, audio: %s, tools: %s",
|
||||
configured_model,
|
||||
self._context_size or "unknown",
|
||||
self._supports_vision,
|
||||
self._supports_audio,
|
||||
self._supports_tools,
|
||||
)
|
||||
|
||||
return base_url
|
||||
|
||||
def _get_model_info(
|
||||
self, base_url: str, configured_model: str
|
||||
) -> dict[str, Any] | None:
|
||||
"""Resolve model metadata from /v1/models with /props fallback.
|
||||
|
||||
Returns a dict of capability fields, or None if the server's model
|
||||
registry was reachable and reported the configured model as missing.
|
||||
A reachable-but-unparseable /v1/models is treated as soft-pass and
|
||||
falls through to /props, matching prior behavior.
|
||||
|
||||
After ggml-org/llama.cpp#22952, /v1/models exposes per-model
|
||||
`architecture.input_modalities` (text/image/audio) — the primary
|
||||
source. When proxied through llama-swap, the same entry carries
|
||||
`status.args` (server launch argv) and, for the loaded model,
|
||||
`meta.n_ctx`. /props remains the only source for `media_marker`,
|
||||
which the server randomizes per startup unless LLAMA_MEDIA_MARKER
|
||||
is set.
|
||||
"""
|
||||
info: dict[str, Any] = {
|
||||
"context_size": None,
|
||||
"supports_vision": False,
|
||||
"supports_audio": False,
|
||||
"supports_tools": False,
|
||||
"media_marker": "<__media__>",
|
||||
}
|
||||
|
||||
model_entry: dict[str, Any] | None = None
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{base_url}/v1/models",
|
||||
timeout=10,
|
||||
)
|
||||
response = requests.get(f"{base_url}/v1/models", timeout=10)
|
||||
response.raise_for_status()
|
||||
models_data = response.json()
|
||||
|
||||
model_found = False
|
||||
for model in models_data.get("data", []):
|
||||
model_ids = {model.get("id")}
|
||||
for alias in model.get("aliases", []):
|
||||
model_ids.add(alias)
|
||||
if configured_model in model_ids:
|
||||
model_found = True
|
||||
model_entry = model
|
||||
break
|
||||
|
||||
if not model_found:
|
||||
if model_entry is None:
|
||||
available = []
|
||||
for m in models_data.get("data", []):
|
||||
available.append(m.get("id", "unknown"))
|
||||
@ -109,65 +232,64 @@ class LlamaCppClient(GenAIClient):
|
||||
e,
|
||||
)
|
||||
|
||||
# Query /props for context size, modalities, and tool support.
|
||||
# The standard /props?model=<name> endpoint works with llama-server.
|
||||
# If it fails, try the llama-swap per-model passthrough endpoint which
|
||||
# returns props for a specific model without requiring it to be loaded.
|
||||
try:
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{base_url}/props",
|
||||
params={"model": configured_model},
|
||||
timeout=10,
|
||||
)
|
||||
response.raise_for_status()
|
||||
props = response.json()
|
||||
except Exception:
|
||||
response = requests.get(
|
||||
f"{base_url}/upstream/{configured_model}/props",
|
||||
timeout=10,
|
||||
)
|
||||
response.raise_for_status()
|
||||
props = response.json()
|
||||
if model_entry is not None:
|
||||
architecture = model_entry.get("architecture") or {}
|
||||
input_modalities = architecture.get("input_modalities") or []
|
||||
|
||||
if isinstance(input_modalities, list):
|
||||
info["supports_vision"] = "image" in input_modalities
|
||||
info["supports_audio"] = "audio" in input_modalities
|
||||
|
||||
status = model_entry.get("status") or {}
|
||||
launch_args = status.get("args") if isinstance(status, dict) else None
|
||||
if not isinstance(launch_args, list):
|
||||
launch_args = []
|
||||
|
||||
meta = model_entry.get("meta") if isinstance(model_entry, dict) else None
|
||||
n_ctx = meta.get("n_ctx") if isinstance(meta, dict) else None
|
||||
|
||||
if not n_ctx:
|
||||
n_ctx = _parse_launch_arg(launch_args, "--ctx-size")
|
||||
|
||||
# Context size from server runtime config
|
||||
default_settings = props.get("default_generation_settings", {})
|
||||
n_ctx = default_settings.get("n_ctx")
|
||||
if n_ctx:
|
||||
self._context_size = int(n_ctx)
|
||||
try:
|
||||
info["context_size"] = int(n_ctx)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
# Modalities (vision, audio)
|
||||
modalities = props.get("modalities", {})
|
||||
self._supports_vision = modalities.get("vision", False)
|
||||
self._supports_audio = modalities.get("audio", False)
|
||||
# Tool calling on llama-server requires --jinja.
|
||||
if "--jinja" in launch_args:
|
||||
info["supports_tools"] = True
|
||||
|
||||
# Tool support from chat template capabilities
|
||||
chat_caps = props.get("chat_template_caps", {})
|
||||
self._supports_tools = chat_caps.get("supports_tools", False)
|
||||
try:
|
||||
props = _fetch_llama_props(base_url, configured_model)
|
||||
|
||||
if info["context_size"] is None:
|
||||
default_settings = props.get("default_generation_settings", {})
|
||||
n_ctx = default_settings.get("n_ctx")
|
||||
if n_ctx:
|
||||
info["context_size"] = int(n_ctx)
|
||||
|
||||
if not (info["supports_vision"] or info["supports_audio"]):
|
||||
modalities = props.get("modalities", {})
|
||||
info["supports_vision"] = bool(modalities.get("vision", False))
|
||||
info["supports_audio"] = bool(modalities.get("audio", False))
|
||||
|
||||
if not info["supports_tools"]:
|
||||
chat_caps = props.get("chat_template_caps", {})
|
||||
info["supports_tools"] = bool(chat_caps.get("supports_tools", False))
|
||||
|
||||
# Media marker for multimodal embeddings; the server randomizes this
|
||||
# per startup unless LLAMA_MEDIA_MARKER is set, so we must read it
|
||||
# from /props rather than hardcoding "<__media__>".
|
||||
media_marker = props.get("media_marker")
|
||||
if isinstance(media_marker, str) and media_marker:
|
||||
self._media_marker = media_marker
|
||||
|
||||
logger.info(
|
||||
"llama.cpp model '%s' initialized — context: %s, vision: %s, audio: %s, tools: %s",
|
||||
configured_model,
|
||||
self._context_size or "unknown",
|
||||
self._supports_vision,
|
||||
self._supports_audio,
|
||||
self._supports_tools,
|
||||
)
|
||||
info["media_marker"] = media_marker
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to query llama.cpp /props endpoint: %s. "
|
||||
"Using defaults for context size and capabilities.",
|
||||
"Image embeddings may fail if the server randomized its media marker.",
|
||||
e,
|
||||
)
|
||||
|
||||
return base_url
|
||||
return info
|
||||
|
||||
def _send(
|
||||
self,
|
||||
@ -395,6 +517,8 @@ class LlamaCppClient(GenAIClient):
|
||||
}
|
||||
if stream:
|
||||
payload["stream"] = True
|
||||
payload["stream_options"] = {"include_usage": True}
|
||||
payload["timings_per_token"] = True
|
||||
if tools:
|
||||
payload["tools"] = tools
|
||||
if openai_tool_choice is not None:
|
||||
@ -403,19 +527,28 @@ class LlamaCppClient(GenAIClient):
|
||||
k: v for k, v in self.provider_options.items() if k != "context_size"
|
||||
}
|
||||
payload.update(provider_opts)
|
||||
payload.update(self.genai_config.runtime_options)
|
||||
return payload
|
||||
|
||||
def _message_from_choice(self, choice: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Parse OpenAI-style choice into {content, tool_calls, finish_reason}."""
|
||||
"""Parse OpenAI-style choice into {content, reasoning, tool_calls, finish_reason}.
|
||||
|
||||
llama.cpp's `--reasoning-format` puts the trace in
|
||||
`message.reasoning_content` (preferred) or `message.thinking`; both
|
||||
keys are accepted so different builds work without configuration.
|
||||
"""
|
||||
message = choice.get("message", {})
|
||||
content = message.get("content")
|
||||
content = content.strip() if content else None
|
||||
reasoning = message.get("reasoning_content") or message.get("thinking")
|
||||
reasoning = reasoning.strip() if reasoning else None
|
||||
tool_calls = parse_tool_calls_from_message(message)
|
||||
finish_reason = choice.get("finish_reason") or (
|
||||
"tool_calls" if tool_calls else "stop" if content else "error"
|
||||
)
|
||||
return {
|
||||
"content": content,
|
||||
"reasoning": reasoning,
|
||||
"tool_calls": tool_calls,
|
||||
"finish_reason": finish_reason,
|
||||
}
|
||||
@ -444,6 +577,31 @@ class LlamaCppClient(GenAIClient):
|
||||
)
|
||||
return result if result else None
|
||||
|
||||
def _refresh_media_marker(self) -> bool:
|
||||
"""Re-fetch /props and update the cached media marker if it changed.
|
||||
|
||||
The server randomizes the marker per startup (unless LLAMA_MEDIA_MARKER
|
||||
is set), so a stale marker indicates a restart. Returns True iff the
|
||||
marker was updated to a new value — used to gate a one-shot retry of
|
||||
a failed embeddings request.
|
||||
"""
|
||||
if self.provider is None:
|
||||
return False
|
||||
try:
|
||||
props = _fetch_llama_props(self.provider, self.genai_config.model)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to refresh llama.cpp media marker: %s", e)
|
||||
return False
|
||||
|
||||
marker = props.get("media_marker")
|
||||
|
||||
if not isinstance(marker, str) or not marker or marker == self._media_marker:
|
||||
return False
|
||||
|
||||
logger.info("llama.cpp media marker changed (server restart); refreshed")
|
||||
self._media_marker = marker
|
||||
return True
|
||||
|
||||
def embed(
|
||||
self,
|
||||
texts: list[str] | None = None,
|
||||
@ -468,30 +626,46 @@ class LlamaCppClient(GenAIClient):
|
||||
|
||||
EMBEDDING_DIM = 768
|
||||
|
||||
content = []
|
||||
for text in texts:
|
||||
content.append({"prompt_string": text})
|
||||
encoded_images: list[str] = []
|
||||
for img in images:
|
||||
# llama.cpp uses STB which does not support WebP; convert to JPEG
|
||||
jpeg_bytes = _to_jpeg(img)
|
||||
to_encode = jpeg_bytes if jpeg_bytes is not None else img
|
||||
encoded = base64.b64encode(to_encode).decode("utf-8")
|
||||
# prompt_string must contain the server's media marker placeholder.
|
||||
# The marker is randomized per server startup (read from /props).
|
||||
content.append(
|
||||
{
|
||||
"prompt_string": f"{self._media_marker}\n",
|
||||
"multimodal_data": [encoded], # type: ignore[dict-item]
|
||||
}
|
||||
encoded_images.append(base64.b64encode(to_encode).decode("utf-8"))
|
||||
|
||||
def build_content() -> list[dict[str, Any]]:
|
||||
# prompt_string must contain the server's media marker placeholder
|
||||
# for each image. The marker is randomized per server startup.
|
||||
content: list[dict[str, Any]] = []
|
||||
for text in texts:
|
||||
content.append({"prompt_string": text})
|
||||
for encoded in encoded_images:
|
||||
content.append(
|
||||
{
|
||||
"prompt_string": f"{self._media_marker}\n",
|
||||
"multimodal_data": [encoded],
|
||||
}
|
||||
)
|
||||
return content
|
||||
|
||||
def post_embeddings() -> requests.Response:
|
||||
return requests.post(
|
||||
f"{self.provider}/embeddings",
|
||||
json={"model": self.genai_config.model, "content": build_content()},
|
||||
timeout=self.timeout,
|
||||
)
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{self.provider}/embeddings",
|
||||
json={"model": self.genai_config.model, "content": content},
|
||||
timeout=self.timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
try:
|
||||
response = post_embeddings()
|
||||
response.raise_for_status()
|
||||
except requests.exceptions.RequestException:
|
||||
# The server may have restarted with a new media marker.
|
||||
# Refresh from /props; only retry if the marker actually changed.
|
||||
if not encoded_images or not self._refresh_media_marker():
|
||||
raise
|
||||
response = post_embeddings()
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
|
||||
items = result.get("data", result) if isinstance(result, dict) else result
|
||||
@ -637,6 +811,7 @@ class LlamaCppClient(GenAIClient):
|
||||
try:
|
||||
payload = self._build_payload(messages, tools, tool_choice, stream=True)
|
||||
content_parts: list[str] = []
|
||||
reasoning_parts: list[str] = []
|
||||
tool_calls_by_index: dict[int, dict[str, Any]] = {}
|
||||
finish_reason = "stop"
|
||||
|
||||
@ -657,12 +832,24 @@ class LlamaCppClient(GenAIClient):
|
||||
data = json.loads(data_str)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
maybe_stats = _stats_from_llama_cpp_chunk(data)
|
||||
if maybe_stats is not None:
|
||||
yield ("stats", maybe_stats)
|
||||
choices = data.get("choices") or []
|
||||
if not choices:
|
||||
continue
|
||||
delta = choices[0].get("delta", {})
|
||||
if choices[0].get("finish_reason"):
|
||||
finish_reason = choices[0]["finish_reason"]
|
||||
# llama.cpp emits separated thinking under
|
||||
# reasoning_content (preferred) or thinking before any
|
||||
# content tokens arrive
|
||||
reasoning_delta = delta.get("reasoning_content") or delta.get(
|
||||
"thinking"
|
||||
)
|
||||
if reasoning_delta:
|
||||
reasoning_parts.append(reasoning_delta)
|
||||
yield ("reasoning_delta", reasoning_delta)
|
||||
if delta.get("content"):
|
||||
content_parts.append(delta["content"])
|
||||
yield ("content_delta", delta["content"])
|
||||
@ -688,6 +875,7 @@ class LlamaCppClient(GenAIClient):
|
||||
)
|
||||
|
||||
full_content = "".join(content_parts).strip() or None
|
||||
full_reasoning = "".join(reasoning_parts).strip() or None
|
||||
tool_calls_list = self._streamed_tool_calls_to_list(tool_calls_by_index)
|
||||
if tool_calls_list:
|
||||
finish_reason = "tool_calls"
|
||||
@ -695,6 +883,7 @@ class LlamaCppClient(GenAIClient):
|
||||
"message",
|
||||
{
|
||||
"content": full_content,
|
||||
"reasoning": full_reasoning,
|
||||
"tool_calls": tool_calls_list,
|
||||
"finish_reason": finish_reason,
|
||||
},
|
||||
@ -18,6 +18,37 @@ from frigate.genai.utils import parse_tool_calls_from_message
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _extract_ollama_stats(response: Any) -> Optional[dict[str, Any]]:
|
||||
"""Build a stats dict from Ollama's response metadata.
|
||||
|
||||
Ollama reports eval_count/eval_duration (generation) and
|
||||
prompt_eval_count (context size). Durations are nanoseconds.
|
||||
"""
|
||||
if not response:
|
||||
return None
|
||||
if hasattr(response, "get"):
|
||||
getter = response.get
|
||||
else:
|
||||
getter = lambda key: getattr(response, key, None) # noqa: E731
|
||||
|
||||
eval_count = getter("eval_count")
|
||||
eval_duration_ns = getter("eval_duration")
|
||||
prompt_eval_count = getter("prompt_eval_count")
|
||||
if eval_count is None and prompt_eval_count is None:
|
||||
return None
|
||||
|
||||
stats: dict[str, Any] = {}
|
||||
if isinstance(prompt_eval_count, int):
|
||||
stats["prompt_tokens"] = prompt_eval_count
|
||||
if isinstance(eval_count, int):
|
||||
stats["completion_tokens"] = eval_count
|
||||
if isinstance(eval_duration_ns, int) and eval_duration_ns > 0:
|
||||
stats["completion_duration_ms"] = eval_duration_ns / 1_000_000
|
||||
if isinstance(eval_count, int) and eval_count > 0:
|
||||
stats["tokens_per_second"] = eval_count / (eval_duration_ns / 1_000_000_000)
|
||||
return stats or None
|
||||
|
||||
|
||||
def _normalize_multimodal_content(
|
||||
content: Any,
|
||||
) -> tuple[Optional[str], Optional[list[bytes]]]:
|
||||
@ -278,6 +309,7 @@ class OllamaClient(GenAIClient):
|
||||
"model": self.genai_config.model,
|
||||
"messages": request_messages,
|
||||
**self.provider_options,
|
||||
**self.genai_config.runtime_options,
|
||||
}
|
||||
if stream:
|
||||
request_params["stream"] = True
|
||||
@ -305,6 +337,9 @@ class OllamaClient(GenAIClient):
|
||||
response.get("done"),
|
||||
)
|
||||
content = message.get("content", "").strip() if message.get("content") else None
|
||||
reasoning = (
|
||||
message.get("thinking", "").strip() if message.get("thinking") else None
|
||||
)
|
||||
tool_calls = parse_tool_calls_from_message(message)
|
||||
finish_reason = "error"
|
||||
if response.get("done"):
|
||||
@ -317,6 +352,7 @@ class OllamaClient(GenAIClient):
|
||||
finish_reason = "stop"
|
||||
return {
|
||||
"content": content,
|
||||
"reasoning": reasoning,
|
||||
"tool_calls": tool_calls,
|
||||
"finish_reason": finish_reason,
|
||||
}
|
||||
@ -400,9 +436,15 @@ class OllamaClient(GenAIClient):
|
||||
)
|
||||
response = await async_client.chat(**request_params)
|
||||
result = self._message_from_response(response)
|
||||
reasoning = result.get("reasoning")
|
||||
if reasoning:
|
||||
yield ("reasoning_delta", reasoning)
|
||||
content = result.get("content")
|
||||
if content:
|
||||
yield ("content_delta", content)
|
||||
stats = _extract_ollama_stats(response)
|
||||
if stats is not None:
|
||||
yield ("stats", stats)
|
||||
yield ("message", result)
|
||||
return
|
||||
|
||||
@ -415,25 +457,38 @@ class OllamaClient(GenAIClient):
|
||||
headers=self._auth_headers(),
|
||||
)
|
||||
content_parts: list[str] = []
|
||||
reasoning_parts: list[str] = []
|
||||
final_message: dict[str, Any] | None = None
|
||||
final_chunk: Any = None
|
||||
stream = await async_client.chat(**request_params)
|
||||
async for chunk in stream:
|
||||
if not chunk or "message" not in chunk:
|
||||
continue
|
||||
msg = chunk.get("message", {})
|
||||
reasoning_delta = msg.get("thinking") or ""
|
||||
if reasoning_delta:
|
||||
reasoning_parts.append(reasoning_delta)
|
||||
yield ("reasoning_delta", reasoning_delta)
|
||||
delta = msg.get("content") or ""
|
||||
if delta:
|
||||
content_parts.append(delta)
|
||||
yield ("content_delta", delta)
|
||||
if chunk.get("done"):
|
||||
final_chunk = chunk
|
||||
full_content = "".join(content_parts).strip() or None
|
||||
full_reasoning = "".join(reasoning_parts).strip() or None
|
||||
final_message = {
|
||||
"content": full_content,
|
||||
"reasoning": full_reasoning,
|
||||
"tool_calls": None,
|
||||
"finish_reason": "stop",
|
||||
}
|
||||
break
|
||||
|
||||
stats = _extract_ollama_stats(final_chunk)
|
||||
if stats is not None:
|
||||
yield ("stats", stats)
|
||||
|
||||
if final_message is not None:
|
||||
yield ("message", final_message)
|
||||
else:
|
||||
@ -441,6 +496,7 @@ class OllamaClient(GenAIClient):
|
||||
"message",
|
||||
{
|
||||
"content": "".join(content_parts).strip() or None,
|
||||
"reasoning": "".join(reasoning_parts).strip() or None,
|
||||
"tool_calls": None,
|
||||
"finish_reason": "stop",
|
||||
},
|
||||
@ -14,6 +14,22 @@ from frigate.genai import GenAIClient, register_genai_provider
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _stats_from_openai_usage(usage: Any) -> Optional[dict[str, Any]]:
|
||||
"""Build a stats dict from an OpenAI-compatible usage object."""
|
||||
if usage is None:
|
||||
return None
|
||||
prompt_tokens = getattr(usage, "prompt_tokens", None)
|
||||
completion_tokens = getattr(usage, "completion_tokens", None)
|
||||
if prompt_tokens is None and completion_tokens is None:
|
||||
return None
|
||||
stats: dict[str, Any] = {}
|
||||
if isinstance(prompt_tokens, int):
|
||||
stats["prompt_tokens"] = prompt_tokens
|
||||
if isinstance(completion_tokens, int):
|
||||
stats["completion_tokens"] = completion_tokens
|
||||
return stats or None
|
||||
|
||||
|
||||
@register_genai_provider(GenAIProviderEnum.openai)
|
||||
class OpenAIClient(GenAIClient):
|
||||
"""Generative AI client for Frigate using OpenAI."""
|
||||
@ -22,7 +38,11 @@ class OpenAIClient(GenAIClient):
|
||||
context_size: Optional[int] = None
|
||||
|
||||
def _init_provider(self) -> OpenAI:
|
||||
"""Initialize the client."""
|
||||
"""Initialize the client.
|
||||
|
||||
Subclasses (e.g. Azure) should raise on configuration errors; the
|
||||
manager catches construction failures and disables the provider.
|
||||
"""
|
||||
# Extract context_size from provider_options as it's not a valid OpenAI client parameter
|
||||
# It will be used in get_context_size() instead
|
||||
provider_opts = {
|
||||
@ -187,6 +207,7 @@ class OpenAIClient(GenAIClient):
|
||||
"model": self.genai_config.model,
|
||||
"messages": messages,
|
||||
"timeout": self.timeout,
|
||||
**self.genai_config.runtime_options,
|
||||
}
|
||||
|
||||
if tools:
|
||||
@ -203,7 +224,7 @@ class OpenAIClient(GenAIClient):
|
||||
}
|
||||
request_params.update(provider_opts)
|
||||
|
||||
result = self.provider.chat.completions.create(**request_params) # type: ignore[call-overload]
|
||||
result = self.provider.chat.completions.create(**request_params)
|
||||
|
||||
if (
|
||||
result is None
|
||||
@ -219,6 +240,10 @@ class OpenAIClient(GenAIClient):
|
||||
choice = result.choices[0]
|
||||
message = choice.message
|
||||
content = message.content.strip() if message.content else None
|
||||
raw_reasoning = getattr(message, "reasoning_content", None) or getattr(
|
||||
message, "reasoning", None
|
||||
)
|
||||
reasoning = raw_reasoning.strip() if raw_reasoning else None
|
||||
|
||||
tool_calls = None
|
||||
if message.tool_calls:
|
||||
@ -253,6 +278,7 @@ class OpenAIClient(GenAIClient):
|
||||
|
||||
return {
|
||||
"content": content,
|
||||
"reasoning": reasoning,
|
||||
"tool_calls": tool_calls,
|
||||
"finish_reason": finish_reason,
|
||||
}
|
||||
@ -261,6 +287,7 @@ class OpenAIClient(GenAIClient):
|
||||
logger.warning("OpenAI request timed out: %s", str(e))
|
||||
return {
|
||||
"content": None,
|
||||
"reasoning": None,
|
||||
"tool_calls": None,
|
||||
"finish_reason": "error",
|
||||
}
|
||||
@ -268,6 +295,7 @@ class OpenAIClient(GenAIClient):
|
||||
logger.warning("OpenAI returned an error: %s", str(e))
|
||||
return {
|
||||
"content": None,
|
||||
"reasoning": None,
|
||||
"tool_calls": None,
|
||||
"finish_reason": "error",
|
||||
}
|
||||
@ -298,6 +326,8 @@ class OpenAIClient(GenAIClient):
|
||||
"messages": messages,
|
||||
"timeout": self.timeout,
|
||||
"stream": True,
|
||||
"stream_options": {"include_usage": True},
|
||||
**self.genai_config.runtime_options,
|
||||
}
|
||||
|
||||
if tools:
|
||||
@ -316,12 +346,18 @@ class OpenAIClient(GenAIClient):
|
||||
|
||||
# Use streaming API
|
||||
content_parts: list[str] = []
|
||||
reasoning_parts: list[str] = []
|
||||
tool_calls_by_index: dict[int, dict[str, Any]] = {}
|
||||
finish_reason = "stop"
|
||||
usage_stats: Optional[dict[str, Any]] = None
|
||||
|
||||
stream = self.provider.chat.completions.create(**request_params) # type: ignore[call-overload]
|
||||
stream = self.provider.chat.completions.create(**request_params)
|
||||
|
||||
for chunk in stream:
|
||||
chunk_usage = getattr(chunk, "usage", None)
|
||||
if chunk_usage is not None:
|
||||
usage_stats = _stats_from_openai_usage(chunk_usage)
|
||||
|
||||
if not chunk or not chunk.choices:
|
||||
continue
|
||||
|
||||
@ -332,6 +368,15 @@ class OpenAIClient(GenAIClient):
|
||||
if choice.finish_reason:
|
||||
finish_reason = choice.finish_reason
|
||||
|
||||
# Extract reasoning deltas (reasoning_content or reasoning,
|
||||
# depending on the server)
|
||||
reasoning_delta = getattr(delta, "reasoning_content", None) or getattr(
|
||||
delta, "reasoning", None
|
||||
)
|
||||
if reasoning_delta:
|
||||
reasoning_parts.append(reasoning_delta)
|
||||
yield ("reasoning_delta", reasoning_delta)
|
||||
|
||||
# Extract content deltas
|
||||
if delta.content:
|
||||
content_parts.append(delta.content)
|
||||
@ -360,6 +405,7 @@ class OpenAIClient(GenAIClient):
|
||||
|
||||
# Build final message
|
||||
full_content = "".join(content_parts).strip() or None
|
||||
full_reasoning = "".join(reasoning_parts).strip() or None
|
||||
|
||||
# Convert tool calls to list format
|
||||
tool_calls_list = None
|
||||
@ -381,10 +427,14 @@ class OpenAIClient(GenAIClient):
|
||||
)
|
||||
finish_reason = "tool_calls"
|
||||
|
||||
if usage_stats is not None:
|
||||
yield ("stats", usage_stats)
|
||||
|
||||
yield (
|
||||
"message",
|
||||
{
|
||||
"content": full_content,
|
||||
"reasoning": full_reasoning,
|
||||
"tool_calls": tool_calls_list,
|
||||
"finish_reason": finish_reason,
|
||||
},
|
||||
@ -396,6 +446,7 @@ class OpenAIClient(GenAIClient):
|
||||
"message",
|
||||
{
|
||||
"content": None,
|
||||
"reasoning": None,
|
||||
"tool_calls": None,
|
||||
"finish_reason": "error",
|
||||
},
|
||||
@ -406,6 +457,7 @@ class OpenAIClient(GenAIClient):
|
||||
"message",
|
||||
{
|
||||
"content": None,
|
||||
"reasoning": None,
|
||||
"tool_calls": None,
|
||||
"finish_reason": "error",
|
||||
},
|
||||
739
frigate/genai/prompts.py
Normal file
739
frigate/genai/prompts.py
Normal file
@ -0,0 +1,739 @@
|
||||
"""Prompt and response-format builders for GenAI features.
|
||||
|
||||
Centralizes the per-feature prompt framing and structured-output schema
|
||||
shaping so provider clients in :mod:`frigate.genai.plugins` only handle
|
||||
transport.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from playhouse.shortcuts import model_to_dict
|
||||
|
||||
from frigate.config import CameraConfig, FrigateConfig
|
||||
from frigate.config.classification import ObjectClassificationType
|
||||
from frigate.config.ui import UnitSystemEnum
|
||||
from frigate.data_processing.post.types import ReviewMetadata
|
||||
from frigate.models import Event
|
||||
|
||||
|
||||
def build_review_description_prompt(
|
||||
review_data: dict[str, Any],
|
||||
thumbnails: list[bytes],
|
||||
concerns: list[str],
|
||||
preferred_language: str | None,
|
||||
activity_context_prompt: str,
|
||||
) -> str:
|
||||
"""Build the prompt for review activity description generation."""
|
||||
|
||||
def get_concern_prompt() -> str:
|
||||
if concerns:
|
||||
concern_list = "\n - ".join(concerns)
|
||||
return (
|
||||
"\n- `other_concerns` (list of strings): Include a list of any of "
|
||||
"the following concerns that are occurring:\n"
|
||||
f" - {concern_list}"
|
||||
)
|
||||
else:
|
||||
return ""
|
||||
|
||||
def get_language_prompt() -> str:
|
||||
if preferred_language:
|
||||
return f"Provide your answer in {preferred_language}"
|
||||
else:
|
||||
return ""
|
||||
|
||||
def get_objects_list() -> str:
|
||||
if review_data["unified_objects"]:
|
||||
return "\n- " + "\n- ".join(review_data["unified_objects"])
|
||||
else:
|
||||
return "\n- (No objects detected)"
|
||||
|
||||
return f"""
|
||||
Your task is to analyze a sequence of images taken in chronological order from a security camera.
|
||||
|
||||
## Normal Activity Patterns for This Property
|
||||
|
||||
{activity_context_prompt}
|
||||
|
||||
## Task Instructions
|
||||
|
||||
Describe the scene based on observable actions and movements, evaluate the activity against the Activity Indicators above, and assign a potential_threat_level (0, 1, or 2) by applying the threat level indicators consistently.
|
||||
|
||||
## Analysis Guidelines
|
||||
|
||||
When forming your description:
|
||||
- **CRITICAL: Only describe objects explicitly listed in "Objects in Scene" below.** Do not infer or mention additional people, vehicles, or objects not present in this list, even if visual patterns suggest them. If only a car is listed, do not describe a person interacting with it unless "person" is also in the objects list.
|
||||
- **Only describe actions actually visible in the frames.** Do not assume or infer actions that you don't observe happening. If someone walks toward furniture but you never see them sit, do not say they sat. Stick to what you can see across the sequence.
|
||||
- Describe what you observe: actions, movements, interactions with objects and the environment. Include any observable environmental changes (e.g., lighting changes triggered by activity).
|
||||
- Note visible details such as clothing, items being carried or placed, tools or equipment present, and how they interact with the property or objects.
|
||||
- Consider the full sequence chronologically: what happens from start to finish, how duration and actions relate to the location and objects involved.
|
||||
- **Use the actual timestamp provided in "Activity started at"** below for time of day context—do not infer time from image brightness or darkness. Unusual hours (late night/early morning) should increase suspicion when the observable behavior itself appears questionable. However, recognize that some legitimate activities can occur at any hour.
|
||||
- **Consider duration as a primary factor**: Apply the duration thresholds defined in the activity patterns above. Brief sequences during normal hours with apparent purpose typically indicate normal activity unless explicit suspicious actions are visible.
|
||||
- **Weigh all evidence holistically**: Match the activity against the normal and suspicious patterns defined above, then evaluate based on the complete context (zone, objects, time, actions, duration). Apply the threat level indicators consistently. Use your judgment for edge cases.
|
||||
|
||||
## Response Field Guidelines
|
||||
|
||||
Respond with a JSON object matching the provided schema. Field-specific guidance:
|
||||
- `observations`: Include the very start of the activity — for example, a vehicle entering the frame or pulling into the driveway — even if it lasts only a few frames and the rest of the clip is dominated by a longer activity. Include each arrival, departure, object handled, and notable change in position or state. Each item is a single concrete fact written as a complete sentence.
|
||||
- `scene`: Describe how the sequence begins, then the progression of events — all significant movements and actions in order. For example, if a vehicle arrives and then a person exits, describe both sequentially. For named subjects (those with a `←` separator in "Objects in Scene"), always use their name — do not replace them with generic terms. For unnamed objects (e.g., "person", "car"), refer to them naturally with articles (e.g., "a person", "the car"). Your description should align with and support the threat level you assign.
|
||||
- `title`: Name the primary activity across the observations, together with the location. An activity is what is being done with objects, tools, or surfaces; locomotion through the scene qualifies as the activity only when no other interaction is observed. For named subjects, always use their name. For unnamed objects, refer to them naturally with articles.
|
||||
- `shortSummary`: Briefly summarize the primary activity across the observations.
|
||||
- `potential_threat_level`: Must be consistent with your scene description and the activity patterns above.
|
||||
{get_concern_prompt()}
|
||||
|
||||
## Sequence Details
|
||||
|
||||
- Camera: {review_data["camera"]}
|
||||
- Total frames: {len(thumbnails)} (Frame 1 = earliest, Frame {len(thumbnails)} = latest)
|
||||
- Activity started at {review_data["start"]} and lasted {review_data["duration"]} seconds
|
||||
- Zones involved: {", ".join(review_data["zones"]) if review_data["zones"] else "None"}
|
||||
|
||||
## Objects in Scene
|
||||
|
||||
Each line represents a detection state, not necessarily unique individuals. The `←` symbol separates a recognized subject's name from their object type — use only the name (before the `←`) in your response, not the type after it. The same subject may appear across multiple lines if detected multiple times.
|
||||
|
||||
**Note: Unidentified objects (without names) are NOT indicators of suspicious activity—they simply mean the system hasn't identified that object.**
|
||||
{get_objects_list()}
|
||||
|
||||
{get_language_prompt()}
|
||||
"""
|
||||
|
||||
|
||||
def build_review_description_response_format(concerns: list[str]) -> dict[str, Any]:
|
||||
"""Build the structured-output JSON schema for review descriptions.
|
||||
|
||||
Strips the `time` field (populated server-side) and drops
|
||||
`other_concerns` when no concerns are configured.
|
||||
"""
|
||||
schema = ReviewMetadata.model_json_schema()
|
||||
schema.get("properties", {}).pop("time", None)
|
||||
|
||||
if "time" in schema.get("required", []):
|
||||
schema["required"].remove("time")
|
||||
if not concerns:
|
||||
schema.get("properties", {}).pop("other_concerns", None)
|
||||
if "other_concerns" in schema.get("required", []):
|
||||
schema["required"].remove("other_concerns")
|
||||
|
||||
return {
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": "review_metadata",
|
||||
"strict": True,
|
||||
"schema": schema,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def build_review_summary_prompt(
|
||||
start_ts: float,
|
||||
end_ts: float,
|
||||
events: list[dict[str, Any]],
|
||||
preferred_language: str | None,
|
||||
) -> str:
|
||||
"""Build the prompt for a multi-event review summary."""
|
||||
time_range = (
|
||||
f"{datetime.datetime.fromtimestamp(start_ts).strftime('%B %d, %Y at %I:%M %p')}"
|
||||
f" to "
|
||||
f"{datetime.datetime.fromtimestamp(end_ts).strftime('%B %d, %Y at %I:%M %p')}"
|
||||
)
|
||||
prompt = f"""
|
||||
You are a security officer writing a concise security report.
|
||||
|
||||
Time range: {time_range}
|
||||
|
||||
Input format: Each event is a JSON object with:
|
||||
- "title", "scene", "confidence", "potential_threat_level" (0-2), "other_concerns", "camera", "time", "start_time", "end_time"
|
||||
- "context": array of related events from other cameras that occurred during overlapping time periods
|
||||
|
||||
**Note: Use the "scene" field for event descriptions in the report. Ignore any "shortSummary" field if present.**
|
||||
|
||||
Report Structure - Use this EXACT format:
|
||||
|
||||
# Security Summary - {time_range}
|
||||
|
||||
## Overview
|
||||
[Write 1-2 sentences summarizing the overall activity pattern during this period.]
|
||||
|
||||
---
|
||||
|
||||
## Timeline
|
||||
|
||||
[Group events by time periods (e.g., "Morning (6:00 AM - 12:00 PM)", "Afternoon (12:00 PM - 5:00 PM)", "Evening (5:00 PM - 9:00 PM)", "Night (9:00 PM - 6:00 AM)"). Use appropriate time blocks based on when events occurred.]
|
||||
|
||||
### [Time Block Name]
|
||||
|
||||
**HH:MM AM/PM** | [Camera Name] | [Threat Level Indicator]
|
||||
- [Event title]: [Clear description incorporating contextual information from the "context" array]
|
||||
- Context: [If context array has items, mention them here, e.g., "Delivery truck present on Front Driveway Cam (HH:MM AM/PM)"]
|
||||
- Assessment: [Brief assessment incorporating context - if context explains the event, note it here]
|
||||
|
||||
[Repeat for each event in chronological order within the time block]
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
[One sentence summarizing the period. If all events are normal/explained: "Routine activity observed." If review needed: "Some activity requires review but no security concerns." If security concerns: "Security concerns requiring immediate attention."]
|
||||
|
||||
Guidelines:
|
||||
- List ALL events in chronological order, grouped by time blocks
|
||||
- Threat level indicators: ✓ Normal, ⚠️ Needs review, 🔴 Security concern
|
||||
- Integrate contextual information naturally - use the "context" array to enrich each event's description
|
||||
- If context explains the event (e.g., delivery truck explains person at door), describe it accordingly (e.g., "delivery person" not "unidentified person")
|
||||
- Be concise but informative - focus on what happened and what it means
|
||||
- If contextual information makes an event clearly normal, reflect that in your assessment
|
||||
- Only create time blocks that have events - don't create empty sections
|
||||
"""
|
||||
|
||||
prompt += "\n\nEvents:\n"
|
||||
for event in events:
|
||||
prompt += f"\n{event}\n"
|
||||
|
||||
if preferred_language:
|
||||
prompt += f"\nProvide your answer in {preferred_language}"
|
||||
|
||||
return prompt
|
||||
|
||||
|
||||
def build_object_description_prompt(
|
||||
camera_config: CameraConfig,
|
||||
event: Event,
|
||||
) -> str:
|
||||
"""Build the prompt for a per-object description.
|
||||
|
||||
Pulls the per-label override from `objects.genai.object_prompts`, falling
|
||||
back to the camera default, and interpolates event fields.
|
||||
|
||||
Raises:
|
||||
KeyError: if the user-defined prompt template references an unknown
|
||||
event field.
|
||||
"""
|
||||
template = camera_config.objects.genai.object_prompts.get(
|
||||
str(event.label),
|
||||
camera_config.objects.genai.prompt,
|
||||
)
|
||||
return template.format(**model_to_dict(event))
|
||||
|
||||
|
||||
def get_attribute_classifications(config: FrigateConfig) -> List[Dict[str, Any]]:
|
||||
"""Return enabled custom classification models of `attribute` type.
|
||||
|
||||
Each entry: {"name": <model name>, "objects": [<object label>, ...]}.
|
||||
These models attach attribute metadata to events on the listed object
|
||||
types, which can later be filtered via the search_objects `attribute`
|
||||
field.
|
||||
"""
|
||||
result: List[Dict[str, Any]] = []
|
||||
|
||||
for model_key, model_config in config.classification.custom.items():
|
||||
if not model_config.enabled or model_config.object_config is None:
|
||||
continue
|
||||
|
||||
if (
|
||||
model_config.object_config.classification_type
|
||||
!= ObjectClassificationType.attribute
|
||||
):
|
||||
continue
|
||||
|
||||
result.append(
|
||||
{
|
||||
"name": model_config.name or model_key,
|
||||
"objects": list(model_config.object_config.objects or []),
|
||||
}
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_tool_definitions(
|
||||
semantic_search_enabled: bool = False,
|
||||
attribute_classifications: Optional[List[Dict[str, Any]]] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get OpenAI-compatible tool definitions for Frigate.
|
||||
|
||||
Returns a list of tool definitions that can be used with OpenAI-compatible
|
||||
function calling APIs. When semantic search is enabled, the search_objects
|
||||
tool exposes an additional `semantic_query` parameter for descriptive
|
||||
queries (e.g. "person riding a lawn mower") and find_similar_objects is
|
||||
included. When attribute classification models are configured, an
|
||||
`attribute` parameter is exposed for filtering by their labels.
|
||||
"""
|
||||
search_objects_properties: Dict[str, Any] = {
|
||||
"camera": {
|
||||
"type": "string",
|
||||
"description": "Camera name to filter by (optional).",
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Generic object class to filter by — one of the tracked detector "
|
||||
"labels such as 'person', 'package', 'car', 'dog', 'bird'. Use "
|
||||
"this for broad queries like 'show me all cars today'. Combine "
|
||||
"with semantic_query when the user also describes appearance or "
|
||||
"behavior (e.g. label='person', semantic_query='riding a lawn "
|
||||
"mower')."
|
||||
),
|
||||
},
|
||||
"sub_label": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Filter by a DISCRETE NAMED entity recognized in the detection. "
|
||||
"Use this for: a known person's name ('John'), a delivery "
|
||||
"company ('Amazon', 'UPS'), a recognized animal species or "
|
||||
"breed ('blue jay', 'cardinal', 'golden retriever'), or a "
|
||||
"license plate string. When filtering by a specific name, set "
|
||||
"only sub_label and leave label unset. Do NOT use sub_label "
|
||||
"for descriptions of appearance, clothing, or actions — those "
|
||||
"belong in semantic_query."
|
||||
),
|
||||
},
|
||||
"after": {
|
||||
"type": "string",
|
||||
"description": "Start time in ISO 8601 format (e.g., '2024-01-01T00:00:00Z').",
|
||||
},
|
||||
"before": {
|
||||
"type": "string",
|
||||
"description": "End time in ISO 8601 format (e.g., '2024-01-01T23:59:59Z').",
|
||||
},
|
||||
"zones": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "List of zone names to filter by.",
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of objects to return (default: 25).",
|
||||
"default": 25,
|
||||
},
|
||||
}
|
||||
|
||||
if attribute_classifications:
|
||||
model_outline = "; ".join(
|
||||
f"{m['name']} (applies to {', '.join(m['objects']) or 'any object'})"
|
||||
for m in attribute_classifications
|
||||
)
|
||||
search_objects_properties["attribute"] = {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Filter by a classification attribute label produced by a "
|
||||
"configured attribute classification model. Use this INSTEAD "
|
||||
"of semantic_query when the user's request matches one of "
|
||||
"these classifications. Configured models: "
|
||||
f"{model_outline}. "
|
||||
"Set the value to the attribute label that matches the user's "
|
||||
"phrasing (case-sensitive)."
|
||||
),
|
||||
}
|
||||
|
||||
if semantic_search_enabled:
|
||||
search_objects_properties["semantic_query"] = {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Optional natural-language description of a PHYSICAL "
|
||||
"CHARACTERISTIC, APPEARANCE, or ACTIVITY the user mentioned, "
|
||||
"used to semantically narrow results. Only set this when the "
|
||||
"user describes something beyond what label and sub_label can "
|
||||
"express on their own.\n"
|
||||
"USE for descriptive phrases like: 'riding a lawn mower', "
|
||||
"'wearing a red jacket', 'carrying a package', 'walking a "
|
||||
"dog', 'on a bicycle', 'holding an umbrella'.\n"
|
||||
"DO NOT USE for:\n"
|
||||
"- specific named people, pets, or delivery companies → use sub_label\n"
|
||||
"- animal species or breed names like 'blue jay', 'cardinal', "
|
||||
"'golden retriever' → use sub_label\n"
|
||||
"- license plate strings → use sub_label\n"
|
||||
"- generic object queries like 'all cars today' or 'every "
|
||||
"person' → use label alone with no semantic_query\n"
|
||||
"When set, combine with label/time/camera/zone filters as "
|
||||
"usual (e.g. label='person', semantic_query='riding a lawn "
|
||||
"mower', after='2024-05-01T00:00:00Z')."
|
||||
),
|
||||
}
|
||||
|
||||
search_objects_description = (
|
||||
"Search the historical record of detected objects in Frigate. "
|
||||
"Use this ONLY for questions about the PAST — e.g. 'did anyone come by today?', "
|
||||
"'when was the last car?', 'show me detections from yesterday'. "
|
||||
"Do NOT use this for monitoring or alerting requests about future events — "
|
||||
"use start_camera_watch instead for those. "
|
||||
"An 'object' in Frigate represents a tracked detection (e.g., a person, package, car).\n\n"
|
||||
"Choose filters based on what the user is asking for:\n"
|
||||
"- Generic class query ('show me all cars today'): set `label` only.\n"
|
||||
"- Specific NAMED entity (known person, delivery company, animal "
|
||||
"species/breed like 'blue jay' or 'golden retriever', license "
|
||||
"plate): set `sub_label` only and leave `label` unset.\n"
|
||||
)
|
||||
if semantic_search_enabled:
|
||||
search_objects_description += (
|
||||
"- Physical CHARACTERISTIC, APPEARANCE, or ACTIVITY that is not a "
|
||||
"discrete name ('person riding a lawn mower', 'someone in a red "
|
||||
"jacket', 'person carrying a package'): set `semantic_query` with "
|
||||
"the descriptive phrase, optionally alongside `label` for the "
|
||||
"object class. Do NOT put descriptive phrases in sub_label."
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "search_objects",
|
||||
"description": search_objects_description,
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": search_objects_properties,
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "find_similar_objects",
|
||||
"description": (
|
||||
"Find tracked objects that are visually and semantically similar "
|
||||
"to a specific past event. Use this when the user references a "
|
||||
"particular object they have seen and wants to find other "
|
||||
"sightings of the same or similar one ('that green car', 'the "
|
||||
"person in the red jacket', 'the package that was delivered'). "
|
||||
"Prefer this over search_objects whenever the user's intent is "
|
||||
"'find more like this specific one.' Use search_objects first "
|
||||
"only if you need to locate the anchor event. Requires semantic "
|
||||
"search to be enabled."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"event_id": {
|
||||
"type": "string",
|
||||
"description": "The id of the anchor event to find similar objects to.",
|
||||
},
|
||||
"after": {
|
||||
"type": "string",
|
||||
"description": "Start time in ISO 8601 format (e.g., '2024-01-01T00:00:00Z').",
|
||||
},
|
||||
"before": {
|
||||
"type": "string",
|
||||
"description": "End time in ISO 8601 format (e.g., '2024-01-01T23:59:59Z').",
|
||||
},
|
||||
"cameras": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Optional list of cameras to restrict to. Defaults to all.",
|
||||
},
|
||||
"labels": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Optional list of labels to restrict to. Defaults to the anchor event's label.",
|
||||
},
|
||||
"sub_labels": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Optional list of sub_labels (names) to restrict to.",
|
||||
},
|
||||
"zones": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Optional list of zones. An event matches if any of its zones overlap.",
|
||||
},
|
||||
"similarity_mode": {
|
||||
"type": "string",
|
||||
"enum": ["visual", "semantic", "fused"],
|
||||
"description": "Which similarity signal(s) to use. 'fused' (default) combines visual and semantic.",
|
||||
"default": "fused",
|
||||
},
|
||||
"min_score": {
|
||||
"type": "number",
|
||||
"description": "Drop matches with a similarity score below this threshold (0.0-1.0).",
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of matches to return (default: 10).",
|
||||
"default": 10,
|
||||
},
|
||||
},
|
||||
"required": ["event_id"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "set_camera_state",
|
||||
"description": (
|
||||
"Change a camera's feature state (e.g., turn detection on/off, enable/disable recordings). "
|
||||
"Use camera='*' to apply to all cameras at once. "
|
||||
"Only call this tool when the user explicitly asks to change a camera setting. "
|
||||
"Requires admin privileges."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"camera": {
|
||||
"type": "string",
|
||||
"description": "Camera name to target, or '*' to target all cameras.",
|
||||
},
|
||||
"feature": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"detect",
|
||||
"record",
|
||||
"snapshots",
|
||||
"audio",
|
||||
"motion",
|
||||
"enabled",
|
||||
"birdseye",
|
||||
"birdseye_mode",
|
||||
"improve_contrast",
|
||||
"ptz_autotracker",
|
||||
"motion_contour_area",
|
||||
"motion_threshold",
|
||||
"notifications",
|
||||
"audio_transcription",
|
||||
"review_alerts",
|
||||
"review_detections",
|
||||
"object_descriptions",
|
||||
"review_descriptions",
|
||||
"profile",
|
||||
],
|
||||
"description": (
|
||||
"The feature to change. Most features accept ON or OFF. "
|
||||
"birdseye_mode accepts CONTINUOUS, MOTION, or OBJECTS. "
|
||||
"motion_contour_area and motion_threshold accept a number. "
|
||||
"profile accepts a profile name or 'none' to deactivate (requires camera='*')."
|
||||
),
|
||||
},
|
||||
"value": {
|
||||
"type": "string",
|
||||
"description": "The value to set. ON or OFF for toggles, a number for thresholds, a profile name or 'none' for profile.",
|
||||
},
|
||||
},
|
||||
"required": ["camera", "feature", "value"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_live_context",
|
||||
"description": (
|
||||
"Get the current live image and detection information for a camera: objects being tracked, "
|
||||
"zones, timestamps. Use this to understand what is visible in the live view. "
|
||||
"Call this when answering questions about what is happening right now on a specific camera."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"camera": {
|
||||
"type": "string",
|
||||
"description": "Camera name to get live context for.",
|
||||
},
|
||||
},
|
||||
"required": ["camera"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "start_camera_watch",
|
||||
"description": (
|
||||
"Start a continuous VLM watch job that monitors a camera and sends a notification "
|
||||
"when a specified condition is met. Use this when the user wants to be alerted about "
|
||||
"a future event, e.g. 'tell me when guests arrive' or 'notify me when the package is picked up'. "
|
||||
"Only one watch job can run at a time. Returns a job ID."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"camera": {
|
||||
"type": "string",
|
||||
"description": "Camera ID to monitor.",
|
||||
},
|
||||
"condition": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Natural-language description of the condition to watch for, "
|
||||
"e.g. 'a person arrives at the front door'."
|
||||
),
|
||||
},
|
||||
"max_duration_minutes": {
|
||||
"type": "integer",
|
||||
"description": "Maximum time to watch before giving up (minutes, default 60).",
|
||||
"default": 60,
|
||||
},
|
||||
"labels": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Object labels that should trigger a VLM check (e.g. ['person', 'car']). If omitted, any detection on the camera triggers a check.",
|
||||
},
|
||||
"zones": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Zone names to filter by. If specified, only detections in these zones trigger a VLM check.",
|
||||
},
|
||||
},
|
||||
"required": ["camera", "condition"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "stop_camera_watch",
|
||||
"description": (
|
||||
"Cancel the currently running VLM watch job. Use this when the user wants to "
|
||||
"stop a previously started watch, e.g. 'stop watching the front door'."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_profile_status",
|
||||
"description": (
|
||||
"Get the current profile status including the active profile and "
|
||||
"timestamps of when each profile was last activated. Use this to "
|
||||
"determine time periods for recap requests — e.g. when the user asks "
|
||||
"'what happened while I was away?', call this first to find the relevant "
|
||||
"time window based on profile activation history."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_recap",
|
||||
"description": (
|
||||
"Get a recap of all activity (alerts and detections) for a given time period. "
|
||||
"Use this after calling get_profile_status to retrieve what happened during "
|
||||
"a specific window — e.g. 'what happened while I was away?'. Returns a "
|
||||
"chronological list of activity with camera, objects, zones, and GenAI-generated "
|
||||
"descriptions when available. Summarize the results for the user."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"after": {
|
||||
"type": "string",
|
||||
"description": "Start of the time period in ISO 8601 format (e.g. '2025-03-15T08:00:00').",
|
||||
},
|
||||
"before": {
|
||||
"type": "string",
|
||||
"description": "End of the time period in ISO 8601 format (e.g. '2025-03-15T17:00:00').",
|
||||
},
|
||||
"cameras": {
|
||||
"type": "string",
|
||||
"description": "Comma-separated camera IDs to include, or 'all' for all cameras. Default is 'all'.",
|
||||
},
|
||||
"severity": {
|
||||
"type": "string",
|
||||
"enum": ["alert", "detection"],
|
||||
"description": "Filter by severity level. Omit to include both alerts and detections.",
|
||||
},
|
||||
},
|
||||
"required": ["after", "before"],
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def build_chat_system_prompt(
|
||||
config: FrigateConfig,
|
||||
allowed_cameras: List[str],
|
||||
semantic_search_enabled: bool,
|
||||
attribute_classifications: List[Dict[str, Any]],
|
||||
) -> str:
|
||||
"""Build the system prompt for the chat completion endpoint.
|
||||
|
||||
Composes the static framing with conditional sections describing the
|
||||
available cameras, speed units, semantic-search routing guidance, and
|
||||
configured attribute classifications.
|
||||
"""
|
||||
current_datetime = datetime.datetime.now()
|
||||
current_date_str = current_datetime.strftime("%Y-%m-%d")
|
||||
current_time_str = current_datetime.strftime("%I:%M:%S %p")
|
||||
|
||||
cameras_info: List[str] = []
|
||||
has_speed_zone = False
|
||||
for camera_id in allowed_cameras:
|
||||
if camera_id not in config.cameras:
|
||||
continue
|
||||
camera_config = config.cameras[camera_id]
|
||||
friendly_name = (
|
||||
camera_config.friendly_name
|
||||
if camera_config.friendly_name
|
||||
else camera_id.replace("_", " ").title()
|
||||
)
|
||||
zone_names = list(camera_config.zones.keys())
|
||||
if not has_speed_zone:
|
||||
has_speed_zone = any(
|
||||
zone.distances for zone in camera_config.zones.values()
|
||||
)
|
||||
if zone_names:
|
||||
cameras_info.append(
|
||||
f" - {friendly_name} (ID: {camera_id}, zones: {', '.join(zone_names)})"
|
||||
)
|
||||
else:
|
||||
cameras_info.append(f" - {friendly_name} (ID: {camera_id})")
|
||||
|
||||
cameras_section = ""
|
||||
if cameras_info:
|
||||
cameras_section = (
|
||||
"\n\nAvailable cameras:\n"
|
||||
+ "\n".join(cameras_info)
|
||||
+ "\n\nWhen users refer to cameras by their friendly name (e.g., 'Back Deck Camera'), use the corresponding camera ID (e.g., 'back_deck_cam') in tool calls."
|
||||
)
|
||||
|
||||
speed_units_section = ""
|
||||
if has_speed_zone:
|
||||
speed_unit = (
|
||||
"mph" if config.ui.unit_system == UnitSystemEnum.imperial else "km/h"
|
||||
)
|
||||
speed_units_section = f"\n\nReport object speeds to the user in {speed_unit}."
|
||||
|
||||
semantic_search_section = ""
|
||||
if semantic_search_enabled:
|
||||
semantic_search_section = (
|
||||
"\n\nWhen routing a search_objects call, pick filters by the shape of the user's request:\n"
|
||||
"- Generic class ('show me all cars today'): set `label` only.\n"
|
||||
"- Specific named entity — a known person ('John'), delivery company ('Amazon'), animal species/breed ('blue jay', 'cardinal', 'golden retriever'), or license plate: set `sub_label` only and leave `label` unset.\n"
|
||||
"- Physical characteristic, appearance, or activity that is NOT a discrete name ('find me people riding a lawn mower', 'someone in a red jacket', 'a person carrying a package'): set `semantic_query` with the descriptive phrase, optionally combined with `label` for the object class. Never put descriptive phrases in `sub_label`."
|
||||
)
|
||||
|
||||
attribute_classification_section = ""
|
||||
if attribute_classifications:
|
||||
model_lines = "\n".join(
|
||||
f"- {m['name']}: applies to {', '.join(m['objects']) or 'any object'}"
|
||||
for m in attribute_classifications
|
||||
)
|
||||
attribute_classification_section = (
|
||||
"\n\nAttribute classification models are configured for the following object types:\n"
|
||||
f"{model_lines}\n"
|
||||
"When the user's request matches one of these classifications, set the search_objects `attribute` field to the matching label rather than using `semantic_query`. Reserve `semantic_query` for descriptive phrases that fall outside the configured attribute labels."
|
||||
)
|
||||
|
||||
return f"""You are a helpful assistant for Frigate, a security camera NVR system. You help users answer questions about their cameras, detected objects, and events.
|
||||
|
||||
Current server local date and time: {current_date_str} at {current_time_str}
|
||||
|
||||
Do not start your response with phrases like "I will check...", "Let me see...", or "Let me look...". Answer directly.
|
||||
|
||||
Always present times to the user in the server's local timezone. When tool results include start_time_local and end_time_local, use those exact strings when listing or describing detection times—do not convert or invent timestamps. Do not use UTC or ISO format with Z for the user-facing answer unless the tool result only provides Unix timestamps without local time fields.
|
||||
When users ask about "today", "yesterday", "this week", etc., use the current date above as reference.
|
||||
When searching for objects or events, use ISO 8601 format for dates (e.g., {current_date_str}T00:00:00Z for the start of today).
|
||||
Always be accurate with time calculations based on the current date provided.
|
||||
|
||||
When a user refers to a specific object they have seen or describe with identifying details ("that green car", "the person in the red jacket", "a package left today"), prefer the find_similar_objects tool over search_objects. Use search_objects first only to locate the anchor event, then pass its id to find_similar_objects. For generic queries like "show me all cars today", keep using search_objects. If a user message begins with [attached_event:<id>], treat that event id as the anchor for any similarity or "tell me more" request in the same message and call find_similar_objects with that id.{semantic_search_section}{attribute_classification_section}{cameras_section}{speed_units_section}"""
|
||||
@ -12,6 +12,7 @@ import os
|
||||
import subprocess as sp
|
||||
import threading
|
||||
import time
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any, Optional, cast
|
||||
|
||||
@ -23,7 +24,7 @@ from frigate.const import REPLAY_CAMERA_PREFIX, REPLAY_DIR
|
||||
from frigate.jobs.export import JobStatePublisher
|
||||
from frigate.jobs.job import Job
|
||||
from frigate.jobs.manager import job_is_running, set_current_job
|
||||
from frigate.models import Recordings
|
||||
from frigate.models import Export, Recordings
|
||||
from frigate.types import JobStatusTypesEnum
|
||||
from frigate.util.ffmpeg import run_ffmpeg_with_progress
|
||||
|
||||
@ -114,6 +115,125 @@ def query_recordings(source_camera: str, start_ts: float, end_ts: float) -> Mode
|
||||
return cast(ModelSelect, query)
|
||||
|
||||
|
||||
class DebugReplaySource(ABC):
|
||||
"""Abstract source for a debug replay session.
|
||||
|
||||
Provides the camera identity and time range the replay represents,
|
||||
validates that usable content exists, and supplies the ffmpeg input
|
||||
args used to build the replay clip.
|
||||
"""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def source_camera(self) -> str:
|
||||
"""Camera name the replay is derived from."""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def start_ts(self) -> float:
|
||||
"""Unix timestamp marking the start of the replay range."""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def end_ts(self) -> float:
|
||||
"""Unix timestamp marking the end of the replay range."""
|
||||
|
||||
@abstractmethod
|
||||
def validate(self) -> None:
|
||||
"""Raise ValueError if the source has no usable content."""
|
||||
|
||||
@abstractmethod
|
||||
def ffmpeg_input_args(self, working_dir: str) -> list[str]:
|
||||
"""Return ffmpeg input args (including -i). May write temp files in working_dir."""
|
||||
|
||||
def cleanup(self, working_dir: str) -> None:
|
||||
"""Remove any temp files the source created in working_dir. Default no-op."""
|
||||
|
||||
|
||||
class RecordingDebugReplaySource(DebugReplaySource):
|
||||
"""Replay source backed by the Recordings table.
|
||||
|
||||
Builds a concat playlist of recording files covering the time range
|
||||
and feeds it to ffmpeg's concat demuxer.
|
||||
"""
|
||||
|
||||
def __init__(self, source_camera: str, start_ts: float, end_ts: float) -> None:
|
||||
self._camera = source_camera
|
||||
self._start_ts = start_ts
|
||||
self._end_ts = end_ts
|
||||
self._concat_file: Optional[str] = None
|
||||
|
||||
@property
|
||||
def source_camera(self) -> str:
|
||||
return self._camera
|
||||
|
||||
@property
|
||||
def start_ts(self) -> float:
|
||||
return self._start_ts
|
||||
|
||||
@property
|
||||
def end_ts(self) -> float:
|
||||
return self._end_ts
|
||||
|
||||
def validate(self) -> None:
|
||||
if self._end_ts <= self._start_ts:
|
||||
raise ValueError("End time must be after start time")
|
||||
|
||||
if not query_recordings(self._camera, self._start_ts, self._end_ts).count():
|
||||
raise ValueError(
|
||||
f"No recordings found for camera '{self._camera}' in the specified time range"
|
||||
)
|
||||
|
||||
def ffmpeg_input_args(self, working_dir: str) -> list[str]:
|
||||
replay_name = f"{REPLAY_CAMERA_PREFIX}{self._camera}"
|
||||
concat_file = os.path.join(working_dir, f"{replay_name}_concat.txt")
|
||||
recordings = query_recordings(self._camera, self._start_ts, self._end_ts)
|
||||
with open(concat_file, "w") as f:
|
||||
for recording in recordings:
|
||||
f.write(f"file '{recording.path}'\n")
|
||||
self._concat_file = concat_file
|
||||
return ["-f", "concat", "-safe", "0", "-i", concat_file]
|
||||
|
||||
def cleanup(self, working_dir: str) -> None:
|
||||
if self._concat_file:
|
||||
_remove_silent(self._concat_file)
|
||||
|
||||
|
||||
class ExportDebugReplaySource(DebugReplaySource):
|
||||
"""Replay source backed by an existing Export.
|
||||
|
||||
Uses the export's video file directly as the ffmpeg input — does not
|
||||
require recordings to still exist for the time range.
|
||||
"""
|
||||
|
||||
def __init__(self, export: Export, duration: float) -> None:
|
||||
self._camera = cast(str, export.camera)
|
||||
# Export.date is declared DateTimeField but Frigate writes raw unix
|
||||
# timestamps to the column.
|
||||
self._start_ts = float(cast(Any, export.date))
|
||||
self._video_path = cast(str, export.video_path)
|
||||
self._duration = duration
|
||||
|
||||
@property
|
||||
def source_camera(self) -> str:
|
||||
return self._camera
|
||||
|
||||
@property
|
||||
def start_ts(self) -> float:
|
||||
return self._start_ts
|
||||
|
||||
@property
|
||||
def end_ts(self) -> float:
|
||||
return self._start_ts + self._duration
|
||||
|
||||
def validate(self) -> None:
|
||||
if not os.path.exists(self._video_path):
|
||||
raise ValueError(f"Export video file not found: {self._video_path}")
|
||||
|
||||
def ffmpeg_input_args(self, working_dir: str) -> list[str]:
|
||||
return ["-i", self._video_path]
|
||||
|
||||
|
||||
class DebugReplayJobRunner(threading.Thread):
|
||||
"""Worker thread that drives the startup job to completion.
|
||||
|
||||
@ -126,6 +246,7 @@ class DebugReplayJobRunner(threading.Thread):
|
||||
def __init__(
|
||||
self,
|
||||
job: DebugReplayJob,
|
||||
source: DebugReplaySource,
|
||||
frigate_config: FrigateConfig,
|
||||
config_publisher: CameraConfigUpdatePublisher,
|
||||
replay_manager: "DebugReplayManager",
|
||||
@ -133,6 +254,7 @@ class DebugReplayJobRunner(threading.Thread):
|
||||
) -> None:
|
||||
super().__init__(daemon=True, name=f"debug_replay_{job.id}")
|
||||
self.job = job
|
||||
self.source = source
|
||||
self.frigate_config = frigate_config
|
||||
self.config_publisher = config_publisher
|
||||
self.replay_manager = replay_manager
|
||||
@ -183,7 +305,6 @@ class DebugReplayJobRunner(threading.Thread):
|
||||
def run(self) -> None:
|
||||
replay_name = self.job.replay_camera_name
|
||||
os.makedirs(REPLAY_DIR, exist_ok=True)
|
||||
concat_file = os.path.join(REPLAY_DIR, f"{replay_name}_concat.txt")
|
||||
clip_path = os.path.join(REPLAY_DIR, f"{replay_name}.mp4")
|
||||
|
||||
self.job.status = JobStatusTypesEnum.running
|
||||
@ -192,23 +313,13 @@ class DebugReplayJobRunner(threading.Thread):
|
||||
self._broadcast(force=True)
|
||||
|
||||
try:
|
||||
recordings = query_recordings(
|
||||
self.job.source_camera, self.job.start_ts, self.job.end_ts
|
||||
)
|
||||
with open(concat_file, "w") as f:
|
||||
for recording in recordings:
|
||||
f.write(f"file '{recording.path}'\n")
|
||||
input_args = self.source.ffmpeg_input_args(REPLAY_DIR)
|
||||
|
||||
ffmpeg_cmd = [
|
||||
self.frigate_config.ffmpeg.ffmpeg_path,
|
||||
"-hide_banner",
|
||||
"-y",
|
||||
"-f",
|
||||
"concat",
|
||||
"-safe",
|
||||
"0",
|
||||
"-i",
|
||||
concat_file,
|
||||
*input_args,
|
||||
"-c",
|
||||
"copy",
|
||||
"-movflags",
|
||||
@ -285,7 +396,7 @@ class DebugReplayJobRunner(threading.Thread):
|
||||
self.replay_manager.clear_session()
|
||||
_remove_silent(clip_path)
|
||||
finally:
|
||||
_remove_silent(concat_file)
|
||||
self.source.cleanup(REPLAY_DIR)
|
||||
_set_active_runner(None)
|
||||
|
||||
def _finalize_cancelled(self, clip_path: str) -> None:
|
||||
@ -309,52 +420,43 @@ def _remove_silent(path: str) -> None:
|
||||
|
||||
def start_debug_replay_job(
|
||||
*,
|
||||
source_camera: str,
|
||||
start_ts: float,
|
||||
end_ts: float,
|
||||
source: DebugReplaySource,
|
||||
frigate_config: FrigateConfig,
|
||||
config_publisher: CameraConfigUpdatePublisher,
|
||||
replay_manager: "DebugReplayManager",
|
||||
) -> str:
|
||||
"""Validate, create job, start runner. Returns the job id.
|
||||
|
||||
Raises ValueError for bad params (camera missing, time range
|
||||
invalid, no recordings) and RuntimeError if a session is already
|
||||
active.
|
||||
Raises ValueError for an invalid source (camera missing, source has
|
||||
no usable content) and RuntimeError if a session is already active.
|
||||
"""
|
||||
if job_is_running(JOB_TYPE) or replay_manager.active:
|
||||
raise RuntimeError("A replay session is already active")
|
||||
|
||||
if source_camera not in frigate_config.cameras:
|
||||
raise ValueError(f"Camera '{source_camera}' not found")
|
||||
if source.source_camera not in frigate_config.cameras:
|
||||
raise ValueError(f"Camera '{source.source_camera}' not found")
|
||||
|
||||
if end_ts <= start_ts:
|
||||
raise ValueError("End time must be after start time")
|
||||
source.validate()
|
||||
|
||||
recordings = query_recordings(source_camera, start_ts, end_ts)
|
||||
if not recordings.count():
|
||||
raise ValueError(
|
||||
f"No recordings found for camera '{source_camera}' in the specified time range"
|
||||
)
|
||||
|
||||
replay_name = f"{REPLAY_CAMERA_PREFIX}{source_camera}"
|
||||
replay_name = f"{REPLAY_CAMERA_PREFIX}{source.source_camera}"
|
||||
replay_manager.mark_starting(
|
||||
source_camera=source_camera,
|
||||
source_camera=source.source_camera,
|
||||
replay_camera_name=replay_name,
|
||||
start_ts=start_ts,
|
||||
end_ts=end_ts,
|
||||
start_ts=source.start_ts,
|
||||
end_ts=source.end_ts,
|
||||
)
|
||||
|
||||
job = DebugReplayJob(
|
||||
source_camera=source_camera,
|
||||
source_camera=source.source_camera,
|
||||
replay_camera_name=replay_name,
|
||||
start_ts=start_ts,
|
||||
end_ts=end_ts,
|
||||
start_ts=source.start_ts,
|
||||
end_ts=source.end_ts,
|
||||
)
|
||||
set_current_job(job)
|
||||
|
||||
runner = DebugReplayJobRunner(
|
||||
job=job,
|
||||
source=source,
|
||||
frigate_config=frigate_config,
|
||||
config_publisher=config_publisher,
|
||||
replay_manager=replay_manager,
|
||||
|
||||
@ -45,6 +45,7 @@ class VLMWatchJob(Job):
|
||||
last_reasoning: str = ""
|
||||
notification_message: str = ""
|
||||
iteration_count: int = 0
|
||||
username: str = ""
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
@ -374,6 +375,7 @@ def start_vlm_watch_job(
|
||||
dispatcher: Any,
|
||||
labels: list[str] | None = None,
|
||||
zones: list[str] | None = None,
|
||||
username: str = "",
|
||||
) -> str:
|
||||
"""Start a new VLM watch job. Returns the job ID.
|
||||
|
||||
@ -397,6 +399,7 @@ def start_vlm_watch_job(
|
||||
max_duration_minutes=max_duration_minutes,
|
||||
labels=labels or [],
|
||||
zones=zones or [],
|
||||
username=username,
|
||||
)
|
||||
cancel_ev = threading.Event()
|
||||
_current_job = job
|
||||
|
||||
@ -62,8 +62,10 @@ def get_canvas_shape(width: int, height: int) -> tuple[int, int]:
|
||||
if round(a_w / a_h, 2) != round(width / height, 2):
|
||||
canvas_width = int(width // 4 * 4)
|
||||
canvas_height = int((canvas_width / a_w * a_h) // 4 * 4)
|
||||
logger.warning(
|
||||
f"The birdseye resolution is a non-standard aspect ratio, forcing birdseye resolution to {canvas_width} x {canvas_height}"
|
||||
logger.error(
|
||||
f"Birdseye resolution {width}x{height} is not a supported aspect ratio "
|
||||
f"and may cause visual distortion; falling back to {canvas_width}x{canvas_height}. "
|
||||
f"Set width and height to a supported aspect ratio (16:9, 20:10, 16:6, 32:9, 12:9, 22:15, 9:16, 9:12, 16:3, or 1:1)"
|
||||
)
|
||||
|
||||
return (canvas_width, canvas_height)
|
||||
@ -796,15 +798,18 @@ class Birdseye:
|
||||
websocket_server: Any,
|
||||
) -> None:
|
||||
self.config = config
|
||||
canvas_width, canvas_height = get_canvas_shape(
|
||||
config.birdseye.width, config.birdseye.height
|
||||
)
|
||||
self.input: queue.Queue[bytes] = queue.Queue(maxsize=10)
|
||||
self.converter = FFMpegConverter(
|
||||
config.ffmpeg,
|
||||
self.input,
|
||||
stop_event,
|
||||
config.birdseye.width,
|
||||
config.birdseye.height,
|
||||
config.birdseye.width,
|
||||
config.birdseye.height,
|
||||
canvas_width,
|
||||
canvas_height,
|
||||
canvas_width,
|
||||
canvas_height,
|
||||
config.birdseye.quality,
|
||||
config.birdseye.restream,
|
||||
)
|
||||
|
||||
@ -610,8 +610,7 @@ class RecordingMaintainer(threading.Thread):
|
||||
camera,
|
||||
)
|
||||
|
||||
if not os.path.exists(directory):
|
||||
os.makedirs(directory)
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
|
||||
# file will be in utc due to start_time being in utc
|
||||
file_name = f"{start_time.strftime('%M.%S.mp4')}"
|
||||
|
||||
109
frigate/stats/intel_gpu_info.py
Normal file
109
frigate/stats/intel_gpu_info.py
Normal file
@ -0,0 +1,109 @@
|
||||
"""Resolve human-readable names for Intel GPUs via OpenVINO."""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class IntelGpuNameResolver:
|
||||
"""Build a pdev -> normalized device name map by enumerating OpenVINO GPUs.
|
||||
|
||||
The lookup is performed once on first access and cached for the process
|
||||
lifetime. OpenVINO exposes DEVICE_PCI_INFO (domain/bus/device/function) and
|
||||
FULL_DEVICE_NAME for each GPU it can see, which is enough to associate the
|
||||
name with the pdev string used by DRM fdinfo.
|
||||
"""
|
||||
|
||||
_names: Optional[dict[str, str]] = None
|
||||
|
||||
def get_names(self) -> dict[str, str]:
|
||||
if self._names is not None:
|
||||
return self._names
|
||||
|
||||
names: dict[str, str] = {}
|
||||
|
||||
try:
|
||||
from openvino import Core
|
||||
except ImportError:
|
||||
logger.debug("OpenVINO unavailable; cannot resolve Intel GPU names")
|
||||
self._names = names
|
||||
return names
|
||||
|
||||
try:
|
||||
core = Core()
|
||||
devices = core.available_devices
|
||||
except Exception as exc:
|
||||
logger.debug(f"OpenVINO Core initialization failed: {exc}")
|
||||
self._names = names
|
||||
return names
|
||||
|
||||
cpu_name: Optional[str] = None
|
||||
if "CPU" in devices:
|
||||
try:
|
||||
cpu_name = self._strip_trademarks(
|
||||
core.get_property("CPU", "FULL_DEVICE_NAME")
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug(f"Failed to read CPU FULL_DEVICE_NAME: {exc}")
|
||||
|
||||
for device in devices:
|
||||
if not device.startswith("GPU"):
|
||||
continue
|
||||
|
||||
try:
|
||||
pci = core.get_property(device, "DEVICE_PCI_INFO")
|
||||
raw_name = core.get_property(device, "FULL_DEVICE_NAME")
|
||||
device_type = core.get_property(device, "DEVICE_TYPE")
|
||||
except Exception as exc:
|
||||
logger.debug(f"Failed to read properties for {device}: {exc}")
|
||||
continue
|
||||
|
||||
pdev = self._format_pdev(pci)
|
||||
if not pdev:
|
||||
continue
|
||||
|
||||
names[pdev] = self._resolve_name(raw_name, device_type, cpu_name)
|
||||
|
||||
self._names = names
|
||||
return names
|
||||
|
||||
@staticmethod
|
||||
def _format_pdev(pci) -> Optional[str]:
|
||||
try:
|
||||
return f"{pci.domain:04x}:{pci.bus:02x}:{pci.device:02x}.{pci.function:x}"
|
||||
except AttributeError:
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _resolve_name(cls, raw_name: str, device_type, cpu_name: Optional[str]) -> str:
|
||||
"""Build a display name for a GPU.
|
||||
|
||||
Modern integrated Intel GPUs are reported by OpenVINO with a generic
|
||||
FULL_DEVICE_NAME like "Intel(R) Graphics (iGPU)" that gives no model
|
||||
information. Since the iGPU is part of the CPU on these platforms, fall
|
||||
back to the CPU name (which OpenVINO does report specifically) and
|
||||
suffix it with "iGPU" so it's clear what the entry is.
|
||||
"""
|
||||
is_integrated = "INTEGRATED" in str(device_type).upper()
|
||||
|
||||
if is_integrated and cpu_name:
|
||||
short_cpu = re.sub(r"^Intel\s+", "", cpu_name)
|
||||
return f"{short_cpu} iGPU"
|
||||
|
||||
return cls._normalize_name(raw_name)
|
||||
|
||||
@classmethod
|
||||
def _normalize_name(cls, name: str) -> str:
|
||||
cleaned = cls._strip_trademarks(name)
|
||||
cleaned = re.sub(r"\s*\((?:i|d)GPU\)\s*$", "", cleaned, flags=re.IGNORECASE)
|
||||
return " ".join(cleaned.split())
|
||||
|
||||
@staticmethod
|
||||
def _strip_trademarks(name: str) -> str:
|
||||
cleaned = re.sub(r"\(R\)|\(TM\)", "", name)
|
||||
return " ".join(cleaned.split())
|
||||
|
||||
|
||||
intel_gpu_name_resolver = IntelGpuNameResolver()
|
||||
@ -230,6 +230,7 @@ async def set_gpu_stats(
|
||||
hwaccel_args.append(args)
|
||||
|
||||
stats: dict[str, dict] = {}
|
||||
intel_gpu_collected = False
|
||||
|
||||
for args in hwaccel_args:
|
||||
if args in hwaccel_errors:
|
||||
@ -242,6 +243,7 @@ async def set_gpu_stats(
|
||||
if nvidia_usage:
|
||||
for i in range(len(nvidia_usage)):
|
||||
stats[nvidia_usage[i]["name"]] = {
|
||||
"vendor": "nvidia",
|
||||
"gpu": str(round(float(nvidia_usage[i]["gpu"]), 2)) + "%",
|
||||
"mem": str(round(float(nvidia_usage[i]["mem"]), 2)) + "%",
|
||||
"enc": str(round(float(nvidia_usage[i]["enc"]), 2)) + "%",
|
||||
@ -250,31 +252,34 @@ async def set_gpu_stats(
|
||||
}
|
||||
|
||||
else:
|
||||
stats["nvidia-gpu"] = {"gpu": "", "mem": ""}
|
||||
stats["nvidia-gpu"] = {"vendor": "nvidia", "gpu": "", "mem": ""}
|
||||
hwaccel_errors.append(args)
|
||||
elif "nvmpi" in args or "jetson" in args:
|
||||
# nvidia Jetson
|
||||
jetson_usage = get_jetson_stats()
|
||||
|
||||
if jetson_usage:
|
||||
stats["jetson-gpu"] = jetson_usage
|
||||
stats["jetson-gpu"] = {"vendor": "nvidia", **jetson_usage}
|
||||
else:
|
||||
stats["jetson-gpu"] = {"gpu": "", "mem": ""}
|
||||
stats["jetson-gpu"] = {"vendor": "nvidia", "gpu": "", "mem": ""}
|
||||
hwaccel_errors.append(args)
|
||||
elif "qsv" in args or ("vaapi" in args and not is_vaapi_amd_driver()):
|
||||
if not config.telemetry.stats.intel_gpu_stats:
|
||||
continue
|
||||
|
||||
if "intel-gpu" not in stats:
|
||||
if not intel_gpu_collected:
|
||||
# intel GPU (QSV or VAAPI both use the same physical GPU)
|
||||
intel_gpu_collected = True
|
||||
intel_usage = get_intel_gpu_stats(
|
||||
config.telemetry.stats.intel_gpu_device
|
||||
)
|
||||
|
||||
if intel_usage is not None:
|
||||
stats["intel-gpu"] = intel_usage or {"gpu": "", "mem": ""}
|
||||
if intel_usage:
|
||||
for entry in intel_usage.values():
|
||||
name = entry.pop("name")
|
||||
stats[name] = entry
|
||||
else:
|
||||
stats["intel-gpu"] = {"gpu": "", "mem": ""}
|
||||
stats["intel-gpu"] = {"vendor": "intel", "gpu": "", "mem": ""}
|
||||
hwaccel_errors.append(args)
|
||||
elif "vaapi" in args:
|
||||
if not config.telemetry.stats.amd_gpu_stats:
|
||||
@ -284,18 +289,18 @@ async def set_gpu_stats(
|
||||
amd_usage = get_amd_gpu_stats()
|
||||
|
||||
if amd_usage:
|
||||
stats["amd-vaapi"] = amd_usage
|
||||
stats["amd-vaapi"] = {"vendor": "amd", **amd_usage}
|
||||
else:
|
||||
stats["amd-vaapi"] = {"gpu": "", "mem": ""}
|
||||
stats["amd-vaapi"] = {"vendor": "amd", "gpu": "", "mem": ""}
|
||||
hwaccel_errors.append(args)
|
||||
elif "preset-rk" in args:
|
||||
rga_usage = get_rockchip_gpu_stats()
|
||||
|
||||
if rga_usage:
|
||||
stats["rockchip"] = rga_usage
|
||||
stats["rockchip"] = {"vendor": "rockchip", **rga_usage}
|
||||
elif "v4l2m2m" in args or "rpi" in args:
|
||||
# RPi v4l2m2m is currently not able to get usage stats
|
||||
stats["rpi-v4l2m2m"] = {"gpu": "", "mem": ""}
|
||||
stats["rpi-v4l2m2m"] = {"vendor": "rpi", "gpu": "", "mem": ""}
|
||||
|
||||
if stats:
|
||||
all_stats["gpu_usages"] = stats
|
||||
|
||||
@ -15,11 +15,12 @@ class TestDebugReplayAPI(BaseTestHttp):
|
||||
# Stub the factory to skip validation/threading and just record the
|
||||
# name on the manager the way the real factory's mark_starting would.
|
||||
def fake_start(**kwargs):
|
||||
source = kwargs["source"]
|
||||
kwargs["replay_manager"].mark_starting(
|
||||
source_camera=kwargs["source_camera"],
|
||||
source_camera=source.source_camera,
|
||||
replay_camera_name="_replay_front",
|
||||
start_ts=kwargs["start_ts"],
|
||||
end_ts=kwargs["end_ts"],
|
||||
start_ts=source.start_ts,
|
||||
end_ts=source.end_ts,
|
||||
)
|
||||
return "job-1234"
|
||||
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
@ -357,6 +358,51 @@ class TestGo2rtcStreamAccess(BaseTestHttp):
|
||||
f"got {resp.status_code}"
|
||||
)
|
||||
|
||||
def test_add_stream_rejects_restricted_source(self):
|
||||
"""PUT /go2rtc/streams must reject exec:/echo:/expr: sources even for
|
||||
admins"""
|
||||
app = self._make_app(_MULTI_CAMERA_CONFIG)
|
||||
with AuthTestClient(app) as client:
|
||||
for src in (
|
||||
"exec:/tmp/rev.sh",
|
||||
"echo:foo",
|
||||
"expr:bar",
|
||||
" exec:/tmp/rev.sh",
|
||||
):
|
||||
resp = client.put(f"/go2rtc/streams/revshell?src={src}")
|
||||
assert resp.status_code == 400, (
|
||||
f"Expected 400 for restricted src {src!r}; got {resp.status_code}"
|
||||
)
|
||||
assert resp.json().get("success") is False
|
||||
|
||||
def test_add_stream_allows_non_restricted_source(self):
|
||||
"""A normal stream URL should pass the restricted-source check and reach
|
||||
the (unavailable in tests) go2rtc proxy — so we expect 500, not 400."""
|
||||
app = self._make_app(_MULTI_CAMERA_CONFIG)
|
||||
with AuthTestClient(app) as client:
|
||||
resp = client.put("/go2rtc/streams/legit?src=rtsp://10.0.0.1:554/video")
|
||||
assert resp.status_code != 400, (
|
||||
f"Non-restricted source should not be rejected with 400; got {resp.status_code}"
|
||||
)
|
||||
|
||||
def test_add_stream_allows_restricted_source_when_override_set(self):
|
||||
"""When GO2RTC_ALLOW_ARBITRARY_EXEC is set, the API must defer to operator
|
||||
intent and forward the request to go2rtc instead of short-circuiting with 400."""
|
||||
app = self._make_app(_MULTI_CAMERA_CONFIG)
|
||||
mock_response = type("R", (), {"ok": True, "status_code": 200, "text": "ok"})()
|
||||
with patch.dict(os.environ, {"GO2RTC_ALLOW_ARBITRARY_EXEC": "true"}):
|
||||
with patch(
|
||||
"frigate.api.camera.requests.put", return_value=mock_response
|
||||
) as mock_put:
|
||||
with AuthTestClient(app) as client:
|
||||
resp = client.put("/go2rtc/streams/legit?src=exec:/tmp/something")
|
||||
assert resp.status_code == 200, (
|
||||
f"Restricted src should be forwarded when override set; got {resp.status_code}"
|
||||
)
|
||||
mock_put.assert_called_once()
|
||||
forwarded_src = mock_put.call_args.kwargs["params"]["src"]
|
||||
assert forwarded_src == "exec:/tmp/something"
|
||||
|
||||
def test_stream_alias_blocked_when_owning_camera_disallowed(self):
|
||||
"""limited_user cannot access a stream alias that belongs to a camera they
|
||||
are not allowed to see."""
|
||||
|
||||
@ -10,7 +10,7 @@ from ruamel.yaml.constructor import DuplicateKeyError
|
||||
from frigate.config import BirdseyeModeEnum, FrigateConfig
|
||||
from frigate.const import MODEL_CACHE_DIR
|
||||
from frigate.detectors import DetectorTypeEnum
|
||||
from frigate.util.builtin import deep_merge, load_labels
|
||||
from frigate.util.builtin import deep_merge
|
||||
|
||||
|
||||
class TestConfig(unittest.TestCase):
|
||||
@ -64,9 +64,9 @@ class TestConfig(unittest.TestCase):
|
||||
|
||||
def test_config_class(self):
|
||||
frigate_config = FrigateConfig(**self.minimal)
|
||||
assert "ov" in frigate_config.detectors.keys()
|
||||
assert frigate_config.detectors["ov"].type == DetectorTypeEnum.openvino
|
||||
assert frigate_config.detectors["ov"].model.width == 300
|
||||
assert "cpu" in frigate_config.detectors.keys()
|
||||
assert frigate_config.detectors["cpu"].type == DetectorTypeEnum.cpu
|
||||
assert frigate_config.detectors["cpu"].model.width == 320
|
||||
|
||||
@patch("frigate.detectors.detector_config.load_labels")
|
||||
def test_detector_custom_model_path(self, mock_labels):
|
||||
@ -309,16 +309,11 @@ class TestConfig(unittest.TestCase):
|
||||
}
|
||||
|
||||
frigate_config = FrigateConfig(**config)
|
||||
all_audio_labels = {
|
||||
label
|
||||
for label in load_labels("/audio-labelmap.txt", prefill=521).values()
|
||||
if label
|
||||
assert set(frigate_config.cameras["back"].audio.filters.keys()) == {
|
||||
"speech",
|
||||
"yell",
|
||||
}
|
||||
|
||||
assert all_audio_labels.issubset(
|
||||
set(frigate_config.cameras["back"].audio.filters.keys())
|
||||
)
|
||||
|
||||
def test_override_audio_filters(self):
|
||||
config = {
|
||||
"mqtt": {"host": "mqtt"},
|
||||
@ -345,7 +340,8 @@ class TestConfig(unittest.TestCase):
|
||||
frigate_config = FrigateConfig(**config)
|
||||
assert "speech" in frigate_config.cameras["back"].audio.filters
|
||||
assert frigate_config.cameras["back"].audio.filters["speech"].threshold == 0.9
|
||||
assert "babbling" in frigate_config.cameras["back"].audio.filters
|
||||
assert "yell" in frigate_config.cameras["back"].audio.filters
|
||||
assert "babbling" not in frigate_config.cameras["back"].audio.filters
|
||||
|
||||
def test_inherit_object_filters(self):
|
||||
config = {
|
||||
@ -1677,5 +1673,60 @@ class TestConfig(unittest.TestCase):
|
||||
self.assertRaises(ValueError, lambda: FrigateConfig(**config))
|
||||
|
||||
|
||||
class TestAttributeFilterDefaults(unittest.TestCase):
|
||||
"""Verify attribute filter min_score handling at config load."""
|
||||
|
||||
def setUp(self):
|
||||
self.minimal = {
|
||||
"mqtt": {"host": "mqtt"},
|
||||
"cameras": {
|
||||
"back": {
|
||||
"ffmpeg": {
|
||||
"inputs": [
|
||||
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
|
||||
]
|
||||
},
|
||||
"detect": {
|
||||
"height": 1080,
|
||||
"width": 1920,
|
||||
"fps": 5,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
def _build_config(self, object_filters: dict | None = None) -> FrigateConfig:
|
||||
config = deep_merge({}, self.minimal)
|
||||
if object_filters is not None:
|
||||
config.setdefault("objects", {})["filters"] = object_filters
|
||||
return FrigateConfig(**config)
|
||||
|
||||
def test_attribute_with_no_filter_gets_default_min_score(self):
|
||||
"""Attribute with no user-provided filter gets created with min_score=0.7."""
|
||||
config = self._build_config()
|
||||
face_filter = config.objects.filters.get("face")
|
||||
self.assertIsNotNone(face_filter)
|
||||
self.assertEqual(face_filter.min_score, 0.7)
|
||||
|
||||
def test_attribute_filter_without_min_score_gets_bumped(self):
|
||||
"""If user sets some FilterConfig field but not min_score, min_score is bumped to 0.7."""
|
||||
config = self._build_config({"face": {"min_area": 500}})
|
||||
face_filter = config.objects.filters["face"]
|
||||
self.assertEqual(face_filter.min_area, 500)
|
||||
self.assertEqual(face_filter.min_score, 0.7)
|
||||
|
||||
def test_attribute_filter_explicit_min_score_half_is_preserved(self):
|
||||
"""User-provided min_score=0.5 must NOT be silently rewritten to 0.7."""
|
||||
config = self._build_config({"face": {"min_score": 0.5}})
|
||||
face_filter = config.objects.filters["face"]
|
||||
self.assertEqual(face_filter.min_score, 0.5)
|
||||
|
||||
def test_attribute_filter_explicit_min_score_other_value_is_preserved(self):
|
||||
"""Sanity: explicit non-0.5 values pass through unchanged."""
|
||||
config = self._build_config({"face": {"min_score": 0.3}})
|
||||
face_filter = config.objects.filters["face"]
|
||||
self.assertEqual(face_filter.min_score, 0.3)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
|
||||
@ -71,6 +71,14 @@ class TestDebugReplayManagerSession(unittest.TestCase):
|
||||
|
||||
|
||||
class TestDebugReplayManagerStop(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
# stop() publishes a terminal job_state via a real JobStatePublisher,
|
||||
# which opens a ZMQ REQ socket and blocks on REP. No dispatcher runs
|
||||
# in unit tests, so substitute a no-op publisher.
|
||||
patcher = patch("frigate.debug_replay.JobStatePublisher")
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
|
||||
def test_stop_when_inactive_is_a_noop(self) -> None:
|
||||
from frigate.debug_replay import DebugReplayManager
|
||||
|
||||
|
||||
@ -9,6 +9,7 @@ from unittest.mock import MagicMock, patch
|
||||
from frigate.debug_replay import DebugReplayManager
|
||||
from frigate.jobs.debug_replay import (
|
||||
DebugReplayJob,
|
||||
RecordingDebugReplaySource,
|
||||
cancel_debug_replay_job,
|
||||
get_active_runner,
|
||||
start_debug_replay_job,
|
||||
@ -99,9 +100,9 @@ class TestStartDebugReplayJob(unittest.TestCase):
|
||||
def test_rejects_unknown_camera(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
start_debug_replay_job(
|
||||
source_camera="missing",
|
||||
start_ts=100.0,
|
||||
end_ts=200.0,
|
||||
source=RecordingDebugReplaySource(
|
||||
source_camera="missing", start_ts=100.0, end_ts=200.0
|
||||
),
|
||||
frigate_config=self.frigate_config,
|
||||
config_publisher=self.publisher,
|
||||
replay_manager=self.manager,
|
||||
@ -110,9 +111,9 @@ class TestStartDebugReplayJob(unittest.TestCase):
|
||||
def test_rejects_invalid_time_range(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
start_debug_replay_job(
|
||||
source_camera="front",
|
||||
start_ts=200.0,
|
||||
end_ts=100.0,
|
||||
source=RecordingDebugReplaySource(
|
||||
source_camera="front", start_ts=200.0, end_ts=100.0
|
||||
),
|
||||
frigate_config=self.frigate_config,
|
||||
config_publisher=self.publisher,
|
||||
replay_manager=self.manager,
|
||||
@ -124,9 +125,9 @@ class TestStartDebugReplayJob(unittest.TestCase):
|
||||
with patch("frigate.jobs.debug_replay.query_recordings", return_value=empty_qs):
|
||||
with self.assertRaises(ValueError):
|
||||
start_debug_replay_job(
|
||||
source_camera="front",
|
||||
start_ts=100.0,
|
||||
end_ts=200.0,
|
||||
source=RecordingDebugReplaySource(
|
||||
source_camera="front", start_ts=100.0, end_ts=200.0
|
||||
),
|
||||
frigate_config=self.frigate_config,
|
||||
config_publisher=self.publisher,
|
||||
replay_manager=self.manager,
|
||||
@ -154,9 +155,9 @@ class TestStartDebugReplayJob(unittest.TestCase):
|
||||
patch("builtins.open", unittest.mock.mock_open()),
|
||||
):
|
||||
job_id = start_debug_replay_job(
|
||||
source_camera="front",
|
||||
start_ts=100.0,
|
||||
end_ts=200.0,
|
||||
source=RecordingDebugReplaySource(
|
||||
source_camera="front", start_ts=100.0, end_ts=200.0
|
||||
),
|
||||
frigate_config=self.frigate_config,
|
||||
config_publisher=self.publisher,
|
||||
replay_manager=self.manager,
|
||||
@ -191,9 +192,9 @@ class TestStartDebugReplayJob(unittest.TestCase):
|
||||
patch("builtins.open", unittest.mock.mock_open()),
|
||||
):
|
||||
start_debug_replay_job(
|
||||
source_camera="front",
|
||||
start_ts=100.0,
|
||||
end_ts=200.0,
|
||||
source=RecordingDebugReplaySource(
|
||||
source_camera="front", start_ts=100.0, end_ts=200.0
|
||||
),
|
||||
frigate_config=self.frigate_config,
|
||||
config_publisher=self.publisher,
|
||||
replay_manager=self.manager,
|
||||
@ -201,9 +202,9 @@ class TestStartDebugReplayJob(unittest.TestCase):
|
||||
|
||||
with self.assertRaises(RuntimeError):
|
||||
start_debug_replay_job(
|
||||
source_camera="front",
|
||||
start_ts=100.0,
|
||||
end_ts=200.0,
|
||||
source=RecordingDebugReplaySource(
|
||||
source_camera="front", start_ts=100.0, end_ts=200.0
|
||||
),
|
||||
frigate_config=self.frigate_config,
|
||||
config_publisher=self.publisher,
|
||||
replay_manager=self.manager,
|
||||
@ -269,9 +270,9 @@ class TestRunnerHappyPath(unittest.TestCase):
|
||||
patch("builtins.open", unittest.mock.mock_open()),
|
||||
):
|
||||
start_debug_replay_job(
|
||||
source_camera="front",
|
||||
start_ts=100.0,
|
||||
end_ts=200.0,
|
||||
source=RecordingDebugReplaySource(
|
||||
source_camera="front", start_ts=100.0, end_ts=200.0
|
||||
),
|
||||
frigate_config=self.frigate_config,
|
||||
config_publisher=self.publisher,
|
||||
replay_manager=self.manager,
|
||||
@ -340,9 +341,9 @@ class TestRunnerFailurePath(unittest.TestCase):
|
||||
patch("builtins.open", unittest.mock.mock_open()),
|
||||
):
|
||||
start_debug_replay_job(
|
||||
source_camera="front",
|
||||
start_ts=100.0,
|
||||
end_ts=200.0,
|
||||
source=RecordingDebugReplaySource(
|
||||
source_camera="front", start_ts=100.0, end_ts=200.0
|
||||
),
|
||||
frigate_config=self.frigate_config,
|
||||
config_publisher=self.publisher,
|
||||
replay_manager=self.manager,
|
||||
@ -418,9 +419,9 @@ class TestRunnerCancellation(unittest.TestCase):
|
||||
patch("builtins.open", unittest.mock.mock_open()),
|
||||
):
|
||||
start_debug_replay_job(
|
||||
source_camera="front",
|
||||
start_ts=100.0,
|
||||
end_ts=200.0,
|
||||
source=RecordingDebugReplaySource(
|
||||
source_camera="front", start_ts=100.0, end_ts=200.0
|
||||
),
|
||||
frigate_config=self.frigate_config,
|
||||
config_publisher=self.publisher,
|
||||
replay_manager=self.manager,
|
||||
|
||||
@ -17,12 +17,14 @@ class TestGpuStats(unittest.TestCase):
|
||||
amd_stats = get_amd_gpu_stats()
|
||||
assert amd_stats == {"gpu": "4.17%", "mem": "60.37%"}
|
||||
|
||||
@patch("frigate.stats.intel_gpu_info.intel_gpu_name_resolver.get_names")
|
||||
@patch("frigate.util.services.time.sleep")
|
||||
@patch("frigate.util.services.time.monotonic")
|
||||
@patch("frigate.util.services._read_intel_drm_fdinfo")
|
||||
def test_intel_gpu_stats_fdinfo(self, read_fdinfo, monotonic, sleep):
|
||||
def test_intel_gpu_stats_fdinfo(self, read_fdinfo, monotonic, sleep, get_names):
|
||||
# 1 second of wall clock between snapshots
|
||||
monotonic.side_effect = [0.0, 1.0]
|
||||
get_names.return_value = {"0000:00:02.0": "Intel Graphics"}
|
||||
|
||||
# Two i915 clients on the same iGPU. Engine values are cumulative ns.
|
||||
# Deltas over the 1s window:
|
||||
@ -79,11 +81,15 @@ class TestGpuStats(unittest.TestCase):
|
||||
|
||||
sleep.assert_called_once()
|
||||
assert intel_stats == {
|
||||
"gpu": "90.0%",
|
||||
"mem": "-%",
|
||||
"compute": "30.0%",
|
||||
"dec": "60.0%",
|
||||
"clients": {"100": "80.0%", "200": "10.0%"},
|
||||
"0000:00:02.0": {
|
||||
"name": "Intel Graphics",
|
||||
"vendor": "intel",
|
||||
"gpu": "90.0%",
|
||||
"mem": "-%",
|
||||
"compute": "30.0%",
|
||||
"dec": "60.0%",
|
||||
"clients": {"100": "80.0%", "200": "10.0%"},
|
||||
},
|
||||
}
|
||||
|
||||
@patch("frigate.util.services._read_intel_drm_fdinfo")
|
||||
|
||||
381
frigate/test/test_media_auth.py
Normal file
381
frigate/test/test_media_auth.py
Normal file
@ -0,0 +1,381 @@
|
||||
"""Unit tests for `frigate.api.media_auth`.
|
||||
|
||||
Covers URI classification, the role-vs-camera decision matrix, and the export
|
||||
DB-lookup path. These are pure functions/DB lookups — no HTTP stack involved.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
import os
|
||||
import unittest
|
||||
|
||||
from peewee_migrate import Router
|
||||
from playhouse.sqlite_ext import SqliteExtDatabase
|
||||
from playhouse.sqliteq import SqliteQueueDatabase
|
||||
|
||||
from frigate.api.media_auth import (
|
||||
MediaAuthResolution,
|
||||
deny_response_for_media_uri,
|
||||
extract_path,
|
||||
resolve_media_uri,
|
||||
)
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.models import Event, Export, Recordings, ReviewSegment
|
||||
from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS
|
||||
|
||||
_CONFIG = {
|
||||
"mqtt": {"host": "mqtt"},
|
||||
"auth": {"roles": {"limited_user": ["front_door"]}},
|
||||
"cameras": {
|
||||
"front_door": {
|
||||
"ffmpeg": {
|
||||
"inputs": [{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}]
|
||||
},
|
||||
"detect": {"height": 1080, "width": 1920, "fps": 5},
|
||||
},
|
||||
"back_door": {
|
||||
"ffmpeg": {
|
||||
"inputs": [{"path": "rtsp://10.0.0.2:554/video", "roles": ["detect"]}]
|
||||
},
|
||||
"detect": {"height": 1080, "width": 1920, "fps": 5},
|
||||
},
|
||||
# Camera name with a hyphen — exercises longest-prefix match.
|
||||
"back-yard": {
|
||||
"ffmpeg": {
|
||||
"inputs": [{"path": "rtsp://10.0.0.3:554/video", "roles": ["detect"]}]
|
||||
},
|
||||
"detect": {"height": 1080, "width": 1920, "fps": 5},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class TestExtractPath(unittest.TestCase):
|
||||
def test_full_url(self):
|
||||
self.assertEqual(
|
||||
extract_path("http://host:8971/clips/front_door-1.jpg"),
|
||||
"/clips/front_door-1.jpg",
|
||||
)
|
||||
|
||||
def test_strips_query_string(self):
|
||||
self.assertEqual(
|
||||
extract_path("http://h/recordings/2026-05-11/14/front_door/00.00.mp4?t=1"),
|
||||
"/recordings/2026-05-11/14/front_door/00.00.mp4",
|
||||
)
|
||||
|
||||
def test_path_only(self):
|
||||
self.assertEqual(extract_path("/exports/x.mp4"), "/exports/x.mp4")
|
||||
|
||||
def test_percent_decoded(self):
|
||||
self.assertEqual(
|
||||
extract_path("http://h/clips/front%20door-1.jpg"),
|
||||
"/clips/front door-1.jpg",
|
||||
)
|
||||
|
||||
def test_empty(self):
|
||||
self.assertIsNone(extract_path(None))
|
||||
self.assertIsNone(extract_path(""))
|
||||
|
||||
|
||||
class TestResolveMediaUri(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.config = FrigateConfig(**_CONFIG)
|
||||
|
||||
def _assert(self, uri, resolution, camera=None):
|
||||
got_resolution, got_camera = resolve_media_uri(uri, self.config)
|
||||
self.assertEqual(got_resolution, resolution, uri)
|
||||
self.assertEqual(got_camera, camera, uri)
|
||||
|
||||
def test_unknown_paths(self):
|
||||
self._assert("/api/events", MediaAuthResolution.UNKNOWN)
|
||||
self._assert("/", MediaAuthResolution.UNKNOWN)
|
||||
self._assert("", MediaAuthResolution.UNKNOWN)
|
||||
|
||||
def test_recordings(self):
|
||||
self._assert("/recordings/", MediaAuthResolution.LISTING_NEUTRAL)
|
||||
self._assert("/recordings/2026-05-11/", MediaAuthResolution.LISTING_NEUTRAL)
|
||||
self._assert(
|
||||
"/recordings/2026-05-11/14/", MediaAuthResolution.LISTING_MULTI_CAMERA
|
||||
)
|
||||
self._assert(
|
||||
"/recordings/2026-05-11/14/front_door/",
|
||||
MediaAuthResolution.CAMERA,
|
||||
camera="front_door",
|
||||
)
|
||||
self._assert(
|
||||
"/recordings/2026-05-11/14/back_door/00.00.mp4",
|
||||
MediaAuthResolution.CAMERA,
|
||||
camera="back_door",
|
||||
)
|
||||
|
||||
def test_clip_flat_filename_resolves_camera(self):
|
||||
self._assert(
|
||||
"/clips/front_door-1234.jpg",
|
||||
MediaAuthResolution.CAMERA,
|
||||
camera="front_door",
|
||||
)
|
||||
self._assert(
|
||||
"/clips/back_door-1234-clean.webp",
|
||||
MediaAuthResolution.CAMERA,
|
||||
camera="back_door",
|
||||
)
|
||||
|
||||
def test_clip_filename_with_hyphenated_camera_name(self):
|
||||
# Camera name "back-yard" itself contains a hyphen; longest-prefix
|
||||
# match must pick `back-yard`, not the bogus `back` prefix.
|
||||
self._assert(
|
||||
"/clips/back-yard-1234.jpg",
|
||||
MediaAuthResolution.CAMERA,
|
||||
camera="back-yard",
|
||||
)
|
||||
|
||||
def test_clip_filename_no_matching_camera(self):
|
||||
# Looks like a media path but couldn't classify — fail closed for
|
||||
# restricted users (UNRESOLVED_MEDIA), not pass-through.
|
||||
self._assert(
|
||||
"/clips/nonexistent-1234.jpg", MediaAuthResolution.UNRESOLVED_MEDIA
|
||||
)
|
||||
|
||||
def test_clip_thumbs(self):
|
||||
self._assert("/clips/thumbs/", MediaAuthResolution.LISTING_MULTI_CAMERA)
|
||||
self._assert(
|
||||
"/clips/thumbs/front_door/",
|
||||
MediaAuthResolution.CAMERA,
|
||||
camera="front_door",
|
||||
)
|
||||
self._assert(
|
||||
"/clips/thumbs/back_door/abc.webp",
|
||||
MediaAuthResolution.CAMERA,
|
||||
camera="back_door",
|
||||
)
|
||||
|
||||
def test_clip_previews(self):
|
||||
self._assert("/clips/previews/", MediaAuthResolution.LISTING_MULTI_CAMERA)
|
||||
self._assert(
|
||||
"/clips/previews/front_door/",
|
||||
MediaAuthResolution.CAMERA,
|
||||
camera="front_door",
|
||||
)
|
||||
self._assert(
|
||||
"/clips/previews/back_door/segment.mp4",
|
||||
MediaAuthResolution.CAMERA,
|
||||
camera="back_door",
|
||||
)
|
||||
|
||||
def test_clip_review_thumbs(self):
|
||||
# Format: /clips/review/thumb-{camera}-{review_id}.webp (frigate/review/maintainer.py).
|
||||
self._assert(
|
||||
"/clips/review/thumb-front_door-abc123.webp",
|
||||
MediaAuthResolution.CAMERA,
|
||||
camera="front_door",
|
||||
)
|
||||
# Hyphenated camera name — longest-prefix match.
|
||||
self._assert(
|
||||
"/clips/review/thumb-back-yard-abc123.webp",
|
||||
MediaAuthResolution.CAMERA,
|
||||
camera="back-yard",
|
||||
)
|
||||
# Unknown camera prefix → unresolved, not allowed for restricted users.
|
||||
self._assert(
|
||||
"/clips/review/thumb-unknown-cam-abc123.webp",
|
||||
MediaAuthResolution.UNRESOLVED_MEDIA,
|
||||
)
|
||||
|
||||
def test_clip_admin_only_subtrees(self):
|
||||
self._assert("/clips/faces/train/foo.webp", MediaAuthResolution.ADMIN_ONLY)
|
||||
self._assert("/clips/faces/", MediaAuthResolution.ADMIN_ONLY)
|
||||
self._assert("/clips/genai-requests/x/0.webp", MediaAuthResolution.ADMIN_ONLY)
|
||||
self._assert(
|
||||
"/clips/preview_restart_cache/x.mp4", MediaAuthResolution.ADMIN_ONLY
|
||||
)
|
||||
self._assert("/clips/some_model/train/x.jpg", MediaAuthResolution.ADMIN_ONLY)
|
||||
self._assert("/clips/some_model/dataset/x.jpg", MediaAuthResolution.ADMIN_ONLY)
|
||||
|
||||
def test_clip_unknown_subtree_is_unresolved(self):
|
||||
# Unknown /clips/{x}/{y}/... subtree falls through as unresolved (not
|
||||
# admin-only) so restricted users get 403 without admins being denied
|
||||
# access to legitimate but unrecognized resources.
|
||||
self._assert("/clips/random_dir/foo.jpg", MediaAuthResolution.UNRESOLVED_MEDIA)
|
||||
|
||||
def test_clip_top_level_listing(self):
|
||||
self._assert("/clips/", MediaAuthResolution.LISTING_MULTI_CAMERA)
|
||||
|
||||
def test_exports_listing(self):
|
||||
self._assert("/exports/", MediaAuthResolution.LISTING_MULTI_CAMERA)
|
||||
|
||||
|
||||
class TestExportResolution(unittest.TestCase):
|
||||
"""Export resolution requires a DB lookup."""
|
||||
|
||||
def setUp(self):
|
||||
migrate_db = SqliteExtDatabase("test.db")
|
||||
del logging.getLogger("peewee_migrate").handlers[:]
|
||||
Router(migrate_db).run()
|
||||
migrate_db.close()
|
||||
self.db = SqliteQueueDatabase(TEST_DB)
|
||||
self.db.bind([Event, ReviewSegment, Recordings, Export])
|
||||
self.config = FrigateConfig(**_CONFIG)
|
||||
|
||||
def tearDown(self):
|
||||
if not self.db.is_closed():
|
||||
self.db.close()
|
||||
for f in TEST_DB_CLEANUPS:
|
||||
try:
|
||||
os.remove(f)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _insert_export(self, export_id, camera, filename):
|
||||
Export.insert(
|
||||
id=export_id,
|
||||
camera=camera,
|
||||
name=f"export-{export_id}",
|
||||
date=int(datetime.datetime.now().timestamp()),
|
||||
video_path=f"/media/frigate/exports/{filename}",
|
||||
thumb_path=f"/media/frigate/exports/{filename}.jpg",
|
||||
in_progress=False,
|
||||
).execute()
|
||||
|
||||
def test_export_resolves_camera(self):
|
||||
self._insert_export(
|
||||
"exp1", "back_door", "back_door_20260511_140000-20260511_150000_abc123.mp4"
|
||||
)
|
||||
resolution, camera = resolve_media_uri(
|
||||
"/exports/back_door_20260511_140000-20260511_150000_abc123.mp4",
|
||||
self.config,
|
||||
)
|
||||
self.assertEqual(resolution, MediaAuthResolution.CAMERA)
|
||||
self.assertEqual(camera, "back_door")
|
||||
|
||||
def test_unknown_export_is_unresolved(self):
|
||||
# No matching row → UNRESOLVED_MEDIA (fail closed for restricted users),
|
||||
# not UNKNOWN (which would pass-through).
|
||||
resolution, camera = resolve_media_uri(
|
||||
"/exports/does_not_exist.mp4", self.config
|
||||
)
|
||||
self.assertEqual(resolution, MediaAuthResolution.UNRESOLVED_MEDIA)
|
||||
self.assertIsNone(camera)
|
||||
|
||||
def test_export_anchored_match_not_endswith(self):
|
||||
# Anchored exact-path equality must NOT match by filename suffix.
|
||||
# A request like /exports/clip.mp4 must not authorize against a row at
|
||||
# /media/frigate/exports/back_door_clip.mp4 just because the suffix matches.
|
||||
self._insert_export("exp_bd", "back_door", "back_door_clip.mp4")
|
||||
self._insert_export("exp_fd", "front_door", "front_door_clip.mp4")
|
||||
resolution, _ = resolve_media_uri("/exports/clip.mp4", self.config)
|
||||
self.assertEqual(resolution, MediaAuthResolution.UNRESOLVED_MEDIA)
|
||||
|
||||
|
||||
class TestDenyResponseForMediaUri(unittest.TestCase):
|
||||
"""End-to-end decision check used by /auth."""
|
||||
|
||||
def setUp(self):
|
||||
self.config = FrigateConfig(**_CONFIG)
|
||||
|
||||
def _deny(self, url, role):
|
||||
return deny_response_for_media_uri(url, role, self.config)
|
||||
|
||||
def test_admin_always_allowed(self):
|
||||
self.assertIsNone(self._deny("/clips/back_door-1.jpg", "admin"))
|
||||
self.assertIsNone(self._deny("/clips/", "admin"))
|
||||
self.assertIsNone(self._deny("/clips/faces/x.webp", "admin"))
|
||||
self.assertIsNone(
|
||||
self._deny("/recordings/2026-05-11/14/back_door/00.00.mp4", "admin")
|
||||
)
|
||||
|
||||
def test_unrestricted_role_allowed(self):
|
||||
# "viewer" role has no entry in roles_dict → full access (matches the
|
||||
# behavior of require_camera_access).
|
||||
self.assertIsNone(self._deny("/clips/back_door-1.jpg", "viewer"))
|
||||
self.assertIsNone(self._deny("/clips/", "viewer"))
|
||||
|
||||
def test_restricted_role_allowed_camera(self):
|
||||
self.assertIsNone(self._deny("/clips/front_door-1.jpg", "limited_user"))
|
||||
self.assertIsNone(
|
||||
self._deny("/recordings/2026-05-11/14/front_door/00.00.mp4", "limited_user")
|
||||
)
|
||||
self.assertIsNone(
|
||||
self._deny("/clips/thumbs/front_door/abc.webp", "limited_user")
|
||||
)
|
||||
|
||||
def test_restricted_role_blocked_other_camera(self):
|
||||
self.assertEqual(self._deny("/clips/back_door-1.jpg", "limited_user"), 403)
|
||||
self.assertEqual(
|
||||
self._deny("/recordings/2026-05-11/14/back_door/00.00.mp4", "limited_user"),
|
||||
403,
|
||||
)
|
||||
self.assertEqual(
|
||||
self._deny("/clips/thumbs/back_door/abc.webp", "limited_user"), 403
|
||||
)
|
||||
|
||||
def test_restricted_role_blocked_admin_only(self):
|
||||
self.assertEqual(self._deny("/clips/faces/train/foo.webp", "limited_user"), 403)
|
||||
|
||||
def test_restricted_role_blocked_multi_camera_listing(self):
|
||||
self.assertEqual(self._deny("/clips/", "limited_user"), 403)
|
||||
self.assertEqual(self._deny("/exports/", "limited_user"), 403)
|
||||
self.assertEqual(self._deny("/recordings/2026-05-11/14/", "limited_user"), 403)
|
||||
|
||||
def test_restricted_role_allowed_neutral_listing(self):
|
||||
self.assertIsNone(self._deny("/recordings/", "limited_user"))
|
||||
self.assertIsNone(self._deny("/recordings/2026-05-11/", "limited_user"))
|
||||
|
||||
def test_non_media_uri_passes_through(self):
|
||||
self.assertIsNone(self._deny("/api/events", "limited_user"))
|
||||
self.assertIsNone(self._deny("http://h/login", "limited_user"))
|
||||
|
||||
def test_missing_header(self):
|
||||
self.assertIsNone(self._deny(None, "limited_user"))
|
||||
self.assertIsNone(self._deny("", "limited_user"))
|
||||
|
||||
def test_traversal_in_media_uri_denied_for_all_roles(self):
|
||||
# Bypass attempt: parts[3] looks like an allowed camera, but the
|
||||
# normalized path nginx would serve points at a forbidden camera.
|
||||
# Both restricted and admin should be denied — the URI is malformed
|
||||
# and we refuse to make an auth decision against it.
|
||||
traversal_uris = [
|
||||
"/recordings/2026-05-11/14/front_door/../back_door/00.00.mp4",
|
||||
"/clips/front_door-1.jpg/../back_door-1.jpg",
|
||||
"/exports/../recordings/2026-05-11/14/back_door/00.00.mp4",
|
||||
"/clips/./back_door-1.jpg",
|
||||
]
|
||||
for uri in traversal_uris:
|
||||
self.assertEqual(self._deny(uri, "limited_user"), 403, uri)
|
||||
self.assertEqual(self._deny(uri, "admin"), 403, uri)
|
||||
self.assertEqual(self._deny(uri, "viewer"), 403, uri)
|
||||
|
||||
def test_traversal_outside_media_passes_through(self):
|
||||
# `..` in non-media URIs is not our problem; the backend handles it.
|
||||
self.assertIsNone(self._deny("/api/foo/../bar", "limited_user"))
|
||||
|
||||
def test_percent_encoded_traversal_denied(self):
|
||||
# nginx may decode percent-encoded `%2E%2E` to `..` before serving;
|
||||
# we must apply the same denial after percent-decoding.
|
||||
self.assertEqual(
|
||||
self._deny(
|
||||
"/recordings/2026-05-11/14/front_door/%2E%2E/back_door/00.mp4",
|
||||
"limited_user",
|
||||
),
|
||||
403,
|
||||
)
|
||||
|
||||
def test_unresolved_media_fails_closed_for_restricted(self):
|
||||
# Restricted user requesting a media URI we can't classify (no DB row,
|
||||
# unknown clip prefix, unknown clip subtree) must be denied.
|
||||
self.assertEqual(self._deny("/clips/nonexistent-1.jpg", "limited_user"), 403)
|
||||
self.assertEqual(self._deny("/clips/random_dir/foo.jpg", "limited_user"), 403)
|
||||
self.assertEqual(
|
||||
self._deny("/clips/review/thumb-unknown_cam-1.webp", "limited_user"),
|
||||
403,
|
||||
)
|
||||
|
||||
def test_unresolved_media_allowed_for_admin(self):
|
||||
# Admin and full-access roles are *not* denied on UNRESOLVED_MEDIA —
|
||||
# nginx returns 404 if the file doesn't exist on disk anyway, and we
|
||||
# don't want a stale DB to lock out admins.
|
||||
self.assertIsNone(self._deny("/clips/nonexistent-1.jpg", "admin"))
|
||||
self.assertIsNone(self._deny("/clips/nonexistent-1.jpg", "viewer"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
806
frigate/test/test_ws_outbound_filter.py
Normal file
806
frigate/test/test_ws_outbound_filter.py
Normal file
@ -0,0 +1,806 @@
|
||||
"""Tests for outbound WebSocket broadcast filtering."""
|
||||
|
||||
import json
|
||||
import threading
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
from frigate.comms.ws import (
|
||||
WebSocketClient,
|
||||
_classify_outbound,
|
||||
_collect_zone_names,
|
||||
_extract_payload_camera,
|
||||
_materialize_for_ws,
|
||||
_ws_allowed_cameras,
|
||||
_ws_is_unrestricted,
|
||||
)
|
||||
from frigate.config import FrigateConfig
|
||||
|
||||
|
||||
def _build_config(
|
||||
*,
|
||||
extra_roles: dict[str, list[str]] | None = None,
|
||||
extra_cameras: dict[str, dict[str, Any]] | None = None,
|
||||
extra_zones: dict[str, dict[str, dict[str, Any]]] | None = None,
|
||||
) -> FrigateConfig:
|
||||
"""Construct a FrigateConfig used by the outbound filter tests.
|
||||
|
||||
The default fixture has three cameras: front_door, back_door, garage.
|
||||
Restricted role "house_only" sees front_door + back_door but not garage.
|
||||
"""
|
||||
cameras: dict[str, dict[str, Any]] = {
|
||||
"front_door": {
|
||||
"ffmpeg": {
|
||||
"inputs": [{"path": "rtsp://10.0.0.1:554/v", "roles": ["detect"]}],
|
||||
},
|
||||
"detect": {"height": 1080, "width": 1920, "fps": 5},
|
||||
},
|
||||
"back_door": {
|
||||
"ffmpeg": {
|
||||
"inputs": [{"path": "rtsp://10.0.0.2:554/v", "roles": ["detect"]}],
|
||||
},
|
||||
"detect": {"height": 1080, "width": 1920, "fps": 5},
|
||||
},
|
||||
"garage": {
|
||||
"ffmpeg": {
|
||||
"inputs": [{"path": "rtsp://10.0.0.3:554/v", "roles": ["detect"]}],
|
||||
},
|
||||
"detect": {"height": 1080, "width": 1920, "fps": 5},
|
||||
},
|
||||
}
|
||||
if extra_cameras:
|
||||
cameras.update(extra_cameras)
|
||||
if extra_zones:
|
||||
for cam_name, zones in extra_zones.items():
|
||||
cameras[cam_name]["zones"] = zones
|
||||
|
||||
roles = {"house_only": ["front_door", "back_door"]}
|
||||
if extra_roles:
|
||||
roles.update(extra_roles)
|
||||
|
||||
return FrigateConfig(
|
||||
mqtt={"host": "mqtt"},
|
||||
auth={"roles": roles},
|
||||
cameras=cameras,
|
||||
)
|
||||
|
||||
|
||||
def _ws(role: str | None) -> Any:
|
||||
"""Build a fake ws4py-style websocket exposing ``environ``."""
|
||||
environ = {} if role is None else {"HTTP_REMOTE_ROLE": role}
|
||||
return SimpleNamespace(environ=environ, terminated=False, sent=[])
|
||||
|
||||
|
||||
class TestClassifyOutbound(unittest.TestCase):
|
||||
"""The pure classifier — bucket every topic into a scope."""
|
||||
|
||||
def setUp(self):
|
||||
self.config = _build_config(
|
||||
extra_zones={"front_door": {"driveway": {"coordinates": "0,0,1,0,1,1,0,1"}}}
|
||||
)
|
||||
self.all_cameras = set(self.config.cameras.keys())
|
||||
self.all_zones = _collect_zone_names(self.config)
|
||||
|
||||
def _classify(self, topic: str) -> tuple[str, Any]:
|
||||
return _classify_outbound(topic, self.all_cameras, self.all_zones)
|
||||
|
||||
# --- Global allowlist ---
|
||||
|
||||
def test_model_state_is_global(self):
|
||||
self.assertEqual(self._classify("model_state"), ("global", None))
|
||||
|
||||
def test_profile_state_is_global(self):
|
||||
self.assertEqual(self._classify("profile/state"), ("global", None))
|
||||
|
||||
def test_bare_notifications_state_is_global(self):
|
||||
"""The 2-segment ``notifications/state`` is global; the 3-segment
|
||||
``<camera>/notifications/state`` is camera-scoped (see below)."""
|
||||
self.assertEqual(self._classify("notifications/state"), ("global", None))
|
||||
|
||||
def test_notification_test_is_global(self):
|
||||
self.assertEqual(self._classify("notification_test"), ("global", None))
|
||||
|
||||
# --- Unrestricted-only ---
|
||||
|
||||
def test_birdseye_layout_is_unrestricted_only(self):
|
||||
self.assertEqual(self._classify("birdseye_layout"), ("unrestricted_only", None))
|
||||
|
||||
# --- Camera-prefixed ---
|
||||
|
||||
def test_camera_state_topic_resolves_to_camera(self):
|
||||
self.assertEqual(
|
||||
self._classify("front_door/detect/state"), ("camera", "front_door")
|
||||
)
|
||||
|
||||
def test_camera_motion_topic_resolves_to_camera(self):
|
||||
self.assertEqual(self._classify("back_door/motion"), ("camera", "back_door"))
|
||||
|
||||
def test_camera_per_notification_topic_resolves_to_camera(self):
|
||||
self.assertEqual(
|
||||
self._classify("front_door/notifications/state"),
|
||||
("camera", "front_door"),
|
||||
)
|
||||
|
||||
def test_camera_label_counter_resolves_to_camera(self):
|
||||
self.assertEqual(self._classify("front_door/person"), ("camera", "front_door"))
|
||||
|
||||
def test_camera_object_mask_state_resolves_to_camera(self):
|
||||
self.assertEqual(
|
||||
self._classify("front_door/object_mask/zone_1/state"),
|
||||
("camera", "front_door"),
|
||||
)
|
||||
|
||||
# --- Zone-prefixed ---
|
||||
|
||||
def test_zone_aggregate_topic_is_unrestricted_only(self):
|
||||
self.assertEqual(self._classify("driveway/person"), ("unrestricted_only", None))
|
||||
|
||||
def test_zone_all_topic_is_unrestricted_only(self):
|
||||
self.assertEqual(self._classify("driveway/all"), ("unrestricted_only", None))
|
||||
|
||||
# --- Payload-camera ---
|
||||
|
||||
def test_events_topic_marks_payload_camera_path(self):
|
||||
self.assertEqual(
|
||||
self._classify("events"), ("payload_camera", ("after", "camera"))
|
||||
)
|
||||
|
||||
def test_reviews_topic_marks_payload_camera_path(self):
|
||||
self.assertEqual(
|
||||
self._classify("reviews"), ("payload_camera", ("after", "camera"))
|
||||
)
|
||||
|
||||
def test_triggers_topic_marks_payload_camera_path(self):
|
||||
self.assertEqual(self._classify("triggers"), ("payload_camera", ("camera",)))
|
||||
|
||||
def test_tracked_object_update_marks_payload_camera_path(self):
|
||||
self.assertEqual(
|
||||
self._classify("tracked_object_update"), ("payload_camera", ("camera",))
|
||||
)
|
||||
|
||||
# --- Reshape ---
|
||||
|
||||
def test_camera_activity_is_reshape_by_camera_key(self):
|
||||
self.assertEqual(
|
||||
self._classify("camera_activity"), ("reshape_by_camera_key", None)
|
||||
)
|
||||
|
||||
def test_audio_detections_is_reshape_by_camera_key(self):
|
||||
self.assertEqual(
|
||||
self._classify("audio_detections"), ("reshape_by_camera_key", None)
|
||||
)
|
||||
|
||||
def test_job_state_is_reshape_job_state(self):
|
||||
self.assertEqual(self._classify("job_state"), ("reshape_job_state", None))
|
||||
|
||||
def test_stats_is_reshape_stats(self):
|
||||
self.assertEqual(self._classify("stats"), ("reshape_stats", None))
|
||||
|
||||
# --- Fail-closed ---
|
||||
|
||||
def test_unknown_topic_is_dropped(self):
|
||||
self.assertEqual(self._classify("some_random_topic"), ("drop", None))
|
||||
|
||||
def test_unknown_camera_prefix_is_dropped(self):
|
||||
self.assertEqual(self._classify("ghost_camera/detect/state"), ("drop", None))
|
||||
|
||||
|
||||
class TestCollectZoneNames(unittest.TestCase):
|
||||
def test_zones_from_all_cameras(self):
|
||||
config = _build_config(
|
||||
extra_zones={
|
||||
"front_door": {"driveway": {"coordinates": "0,0,1,0,1,1,0,1"}},
|
||||
"back_door": {"yard": {"coordinates": "0,0,1,0,1,1,0,1"}},
|
||||
}
|
||||
)
|
||||
self.assertEqual(_collect_zone_names(config), {"driveway", "yard"})
|
||||
|
||||
def test_no_zones_returns_empty(self):
|
||||
self.assertEqual(_collect_zone_names(_build_config()), set())
|
||||
|
||||
|
||||
class TestExtractPayloadCamera(unittest.TestCase):
|
||||
def test_extract_from_dict_path(self):
|
||||
payload = {"after": {"camera": "front_door"}}
|
||||
self.assertEqual(
|
||||
_extract_payload_camera(payload, ("after", "camera")), "front_door"
|
||||
)
|
||||
|
||||
def test_extract_from_json_string(self):
|
||||
payload = json.dumps({"after": {"camera": "front_door"}})
|
||||
self.assertEqual(
|
||||
_extract_payload_camera(payload, ("after", "camera")), "front_door"
|
||||
)
|
||||
|
||||
def test_extract_single_segment_path(self):
|
||||
self.assertEqual(
|
||||
_extract_payload_camera({"camera": "garage"}, ("camera",)), "garage"
|
||||
)
|
||||
|
||||
def test_missing_key_returns_none(self):
|
||||
self.assertIsNone(_extract_payload_camera({}, ("after", "camera")))
|
||||
|
||||
def test_malformed_json_returns_none(self):
|
||||
self.assertIsNone(_extract_payload_camera("not-json", ("camera",)))
|
||||
|
||||
def test_non_string_camera_returns_none(self):
|
||||
self.assertIsNone(_extract_payload_camera({"camera": 42}, ("camera",)))
|
||||
|
||||
|
||||
class TestWsRoleHelpers(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.config = _build_config()
|
||||
|
||||
def test_admin_is_unrestricted(self):
|
||||
self.assertTrue(_ws_is_unrestricted(_ws("admin"), self.config))
|
||||
|
||||
def test_viewer_is_unrestricted(self):
|
||||
self.assertTrue(_ws_is_unrestricted(_ws("viewer"), self.config))
|
||||
|
||||
def test_restricted_role_is_not_unrestricted(self):
|
||||
self.assertFalse(_ws_is_unrestricted(_ws("house_only"), self.config))
|
||||
|
||||
def test_missing_role_is_not_unrestricted(self):
|
||||
self.assertFalse(_ws_is_unrestricted(_ws(None), self.config))
|
||||
|
||||
def test_unknown_role_is_not_unrestricted(self):
|
||||
self.assertFalse(_ws_is_unrestricted(_ws("ghost"), self.config))
|
||||
|
||||
def test_admin_allowed_cameras_is_all(self):
|
||||
self.assertEqual(
|
||||
_ws_allowed_cameras(_ws("admin"), self.config),
|
||||
{"front_door", "back_door", "garage"},
|
||||
)
|
||||
|
||||
def test_restricted_role_allowed_cameras_is_subset(self):
|
||||
self.assertEqual(
|
||||
_ws_allowed_cameras(_ws("house_only"), self.config),
|
||||
{"front_door", "back_door"},
|
||||
)
|
||||
|
||||
def test_missing_role_allowed_cameras_is_empty(self):
|
||||
self.assertEqual(_ws_allowed_cameras(_ws(None), self.config), set())
|
||||
|
||||
def test_multi_role_union_grants_widest(self):
|
||||
self.assertEqual(
|
||||
_ws_allowed_cameras(_ws("house_only,admin"), self.config),
|
||||
{"front_door", "back_door", "garage"},
|
||||
)
|
||||
|
||||
|
||||
class TestMaterializeForWs(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.config = _build_config(
|
||||
extra_zones={"front_door": {"driveway": {"coordinates": "0,0,1,0,1,1,0,1"}}}
|
||||
)
|
||||
self.all_cameras = set(self.config.cameras.keys())
|
||||
self.all_zones = _collect_zone_names(self.config)
|
||||
|
||||
def _materialize(self, ws: Any, topic: str, payload: Any) -> str | None:
|
||||
scope = _classify_outbound(topic, self.all_cameras, self.all_zones)
|
||||
from frigate.comms.ws import _parse_json_payload
|
||||
|
||||
parsed = (
|
||||
_parse_json_payload(payload)
|
||||
if scope[0]
|
||||
in (
|
||||
"payload_camera",
|
||||
"reshape_by_camera_key",
|
||||
"reshape_job_state",
|
||||
"reshape_stats",
|
||||
)
|
||||
else None
|
||||
)
|
||||
full = json.dumps({"topic": topic, "payload": payload})
|
||||
return _materialize_for_ws(ws, topic, full, scope, parsed, self.config)
|
||||
|
||||
# --- Globals: every authenticated client sees them ---
|
||||
|
||||
def test_globals_reach_admin(self):
|
||||
self.assertIsNotNone(self._materialize(_ws("admin"), "model_state", "{}"))
|
||||
|
||||
def test_globals_reach_restricted(self):
|
||||
self.assertIsNotNone(self._materialize(_ws("house_only"), "model_state", "{}"))
|
||||
|
||||
def test_globals_reach_no_role(self):
|
||||
"""A missing role header still gets globals (matches viewer-default
|
||||
for inbound)."""
|
||||
self.assertIsNotNone(self._materialize(_ws(None), "model_state", "{}"))
|
||||
|
||||
# --- Unknown topic dropped for everyone ---
|
||||
|
||||
def test_unknown_topic_dropped_for_admin(self):
|
||||
self.assertIsNone(self._materialize(_ws("admin"), "rogue_topic", "{}"))
|
||||
|
||||
# --- Non-global topics require a role (fail-closed) ---
|
||||
|
||||
def test_no_role_blocked_from_camera_topic(self):
|
||||
self.assertIsNone(self._materialize(_ws(None), "front_door/detect/state", "ON"))
|
||||
|
||||
def test_no_role_blocked_from_events(self):
|
||||
payload = json.dumps({"after": {"camera": "front_door"}})
|
||||
self.assertIsNone(self._materialize(_ws(None), "events", payload))
|
||||
|
||||
# --- Camera-prefixed ---
|
||||
|
||||
def test_restricted_role_sees_allowed_camera(self):
|
||||
self.assertIsNotNone(
|
||||
self._materialize(_ws("house_only"), "front_door/detect/state", "ON")
|
||||
)
|
||||
|
||||
def test_restricted_role_blocked_from_unallowed_camera(self):
|
||||
self.assertIsNone(
|
||||
self._materialize(_ws("house_only"), "garage/detect/state", "ON")
|
||||
)
|
||||
|
||||
def test_admin_sees_all_camera_topics(self):
|
||||
self.assertIsNotNone(
|
||||
self._materialize(_ws("admin"), "garage/detect/state", "ON")
|
||||
)
|
||||
|
||||
# --- Unrestricted-only (zones, birdseye_layout) ---
|
||||
|
||||
def test_zone_aggregate_blocked_for_restricted(self):
|
||||
self.assertIsNone(self._materialize(_ws("house_only"), "driveway/person", 3))
|
||||
|
||||
def test_zone_aggregate_visible_to_admin(self):
|
||||
self.assertIsNotNone(self._materialize(_ws("admin"), "driveway/person", 3))
|
||||
|
||||
def test_birdseye_layout_blocked_for_restricted(self):
|
||||
payload = json.dumps(
|
||||
{"front_door": {"x": 0, "y": 0, "width": 100, "height": 100}}
|
||||
)
|
||||
self.assertIsNone(
|
||||
self._materialize(_ws("house_only"), "birdseye_layout", payload)
|
||||
)
|
||||
|
||||
def test_birdseye_layout_visible_to_admin(self):
|
||||
payload = json.dumps(
|
||||
{"front_door": {"x": 0, "y": 0, "width": 100, "height": 100}}
|
||||
)
|
||||
self.assertIsNotNone(
|
||||
self._materialize(_ws("admin"), "birdseye_layout", payload)
|
||||
)
|
||||
|
||||
# --- Payload-camera ---
|
||||
|
||||
def test_events_filtered_by_payload_camera(self):
|
||||
payload = json.dumps({"after": {"camera": "garage"}})
|
||||
self.assertIsNone(self._materialize(_ws("house_only"), "events", payload))
|
||||
|
||||
payload = json.dumps({"after": {"camera": "front_door"}})
|
||||
self.assertIsNotNone(self._materialize(_ws("house_only"), "events", payload))
|
||||
|
||||
def test_events_with_missing_camera_dropped(self):
|
||||
payload = json.dumps({"after": {}})
|
||||
self.assertIsNone(self._materialize(_ws("house_only"), "events", payload))
|
||||
|
||||
def test_triggers_filtered_by_payload_camera(self):
|
||||
payload = json.dumps({"name": "t1", "camera": "garage"})
|
||||
self.assertIsNone(self._materialize(_ws("house_only"), "triggers", payload))
|
||||
|
||||
# --- Reshape: dict keyed by camera ---
|
||||
|
||||
def test_camera_activity_filtered_to_allowed_keys(self):
|
||||
payload = json.dumps(
|
||||
{
|
||||
"front_door": {"objects": 1},
|
||||
"back_door": {"objects": 0},
|
||||
"garage": {"objects": 2},
|
||||
}
|
||||
)
|
||||
message = self._materialize(_ws("house_only"), "camera_activity", payload)
|
||||
self.assertIsNotNone(message)
|
||||
envelope = json.loads(message) # type: ignore[arg-type]
|
||||
inner = json.loads(envelope["payload"])
|
||||
self.assertEqual(set(inner.keys()), {"front_door", "back_door"})
|
||||
self.assertNotIn("garage", inner)
|
||||
|
||||
def test_camera_activity_unchanged_for_admin(self):
|
||||
payload = json.dumps({"front_door": {}, "back_door": {}, "garage": {}})
|
||||
message = self._materialize(_ws("admin"), "camera_activity", payload)
|
||||
envelope = json.loads(message) # type: ignore[arg-type]
|
||||
self.assertEqual(envelope["payload"], payload)
|
||||
|
||||
def test_camera_activity_with_no_allowed_returns_none(self):
|
||||
payload = json.dumps({"garage": {"objects": 2}})
|
||||
self.assertIsNone(
|
||||
self._materialize(_ws("house_only"), "camera_activity", payload)
|
||||
)
|
||||
|
||||
def test_audio_detections_filtered_to_allowed_keys(self):
|
||||
payload = json.dumps({"front_door": {"bark": {}}, "garage": {"speech": {}}})
|
||||
message = self._materialize(_ws("house_only"), "audio_detections", payload)
|
||||
envelope = json.loads(message) # type: ignore[arg-type]
|
||||
inner = json.loads(envelope["payload"])
|
||||
self.assertEqual(set(inner.keys()), {"front_door"})
|
||||
|
||||
# --- Reshape: job_state ---
|
||||
|
||||
def test_job_state_admin_sees_full_payload(self):
|
||||
payload = json.dumps(
|
||||
{
|
||||
"motion_search": {"job_type": "motion_search", "camera": "garage"},
|
||||
"media_sync": {"job_type": "media_sync"},
|
||||
}
|
||||
)
|
||||
message = self._materialize(_ws("admin"), "job_state", payload)
|
||||
envelope = json.loads(message) # type: ignore[arg-type]
|
||||
self.assertEqual(envelope["payload"], payload)
|
||||
|
||||
def test_job_state_restricted_keeps_allowed_camera_jobs(self):
|
||||
"""Top-level camera field on a job entry: drop if not allowed."""
|
||||
payload = json.dumps(
|
||||
{
|
||||
"motion_search": {"job_type": "motion_search", "camera": "front_door"},
|
||||
"vlm_watch": {"job_type": "vlm_watch", "camera": "garage"},
|
||||
}
|
||||
)
|
||||
message = self._materialize(_ws("house_only"), "job_state", payload)
|
||||
envelope = json.loads(message) # type: ignore[arg-type]
|
||||
inner = json.loads(envelope["payload"])
|
||||
self.assertIn("motion_search", inner)
|
||||
self.assertNotIn("vlm_watch", inner)
|
||||
|
||||
def test_job_state_export_results_jobs_filtered_per_recipient(self):
|
||||
"""The aggregated export broadcast nests per-camera sub-jobs under
|
||||
``results.jobs``. Restricted users must only see allowed entries."""
|
||||
payload = json.dumps(
|
||||
{
|
||||
"export": {
|
||||
"job_type": "export",
|
||||
"status": "running",
|
||||
"results": {
|
||||
"jobs": [
|
||||
{"job_type": "export", "camera": "front_door", "id": "a"},
|
||||
{"job_type": "export", "camera": "garage", "id": "b"},
|
||||
{"job_type": "export", "camera": "back_door", "id": "c"},
|
||||
]
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
message = self._materialize(_ws("house_only"), "job_state", payload)
|
||||
envelope = json.loads(message) # type: ignore[arg-type]
|
||||
inner = json.loads(envelope["payload"])
|
||||
self.assertIn("export", inner)
|
||||
kept_cameras = [j["camera"] for j in inner["export"]["results"]["jobs"]]
|
||||
self.assertEqual(kept_cameras, ["front_door", "back_door"])
|
||||
# Sibling fields like ``status`` must survive reshaping.
|
||||
self.assertEqual(inner["export"]["status"], "running")
|
||||
|
||||
def test_job_state_export_entry_dropped_when_no_jobs_allowed(self):
|
||||
payload = json.dumps(
|
||||
{
|
||||
"export": {
|
||||
"job_type": "export",
|
||||
"status": "running",
|
||||
"results": {
|
||||
"jobs": [
|
||||
{"job_type": "export", "camera": "garage", "id": "b"},
|
||||
]
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
self.assertIsNone(self._materialize(_ws("house_only"), "job_state", payload))
|
||||
|
||||
# --- Reshape: stats ---
|
||||
|
||||
def _stats_payload(self) -> str:
|
||||
return json.dumps(
|
||||
{
|
||||
"cameras": {
|
||||
"front_door": {"camera_fps": 5.0, "pid": 1234},
|
||||
"back_door": {"camera_fps": 5.0, "pid": 1235},
|
||||
"garage": {"camera_fps": 5.0, "pid": 1236},
|
||||
},
|
||||
"detectors": {"cpu": {"detection_start": 0.0, "inference_speed": 10}},
|
||||
"service": {"uptime": 12345, "version": "0.16.0"},
|
||||
"camera_fps": 15.0,
|
||||
"detection_fps": 6.0,
|
||||
}
|
||||
)
|
||||
|
||||
def test_stats_admin_sees_full_payload(self):
|
||||
message = self._materialize(_ws("admin"), "stats", self._stats_payload())
|
||||
envelope = json.loads(message) # type: ignore[arg-type]
|
||||
self.assertEqual(envelope["payload"], self._stats_payload())
|
||||
|
||||
def test_stats_restricted_filters_camera_keys_but_keeps_aggregates(self):
|
||||
message = self._materialize(_ws("house_only"), "stats", self._stats_payload())
|
||||
envelope = json.loads(message) # type: ignore[arg-type]
|
||||
inner = json.loads(envelope["payload"])
|
||||
self.assertEqual(set(inner["cameras"].keys()), {"front_door", "back_door"})
|
||||
self.assertNotIn("garage", inner["cameras"])
|
||||
# Aggregates, detectors, and service block must survive.
|
||||
self.assertEqual(inner["camera_fps"], 15.0)
|
||||
self.assertEqual(inner["detection_fps"], 6.0)
|
||||
self.assertIn("detectors", inner)
|
||||
self.assertIn("service", inner)
|
||||
|
||||
def test_stats_restricted_with_no_allowed_cameras_still_sends_aggregates(self):
|
||||
"""A restricted role whose allow-list contains only nonexistent cameras
|
||||
still gets the global aggregates and service block."""
|
||||
config = _build_config(extra_roles={"empty_role": ["nonexistent"]})
|
||||
from frigate.comms.ws import _parse_json_payload
|
||||
|
||||
payload = self._stats_payload()
|
||||
all_cameras = set(config.cameras.keys())
|
||||
scope = _classify_outbound("stats", all_cameras, _collect_zone_names(config))
|
||||
full = json.dumps({"topic": "stats", "payload": payload})
|
||||
message = _materialize_for_ws(
|
||||
_ws("empty_role"),
|
||||
"stats",
|
||||
full,
|
||||
scope,
|
||||
_parse_json_payload(payload),
|
||||
config,
|
||||
)
|
||||
envelope = json.loads(message) # type: ignore[arg-type]
|
||||
inner = json.loads(envelope["payload"])
|
||||
self.assertEqual(inner["cameras"], {})
|
||||
self.assertEqual(inner["camera_fps"], 15.0)
|
||||
self.assertIn("service", inner)
|
||||
|
||||
def test_stats_without_cameras_key_passes_through(self):
|
||||
"""A malformed stats payload missing the cameras sub-dict shouldn't
|
||||
break delivery for restricted users — fall back to the full message."""
|
||||
payload = json.dumps({"detectors": {}, "service": {}, "detection_fps": 0.0})
|
||||
message = self._materialize(_ws("house_only"), "stats", payload)
|
||||
envelope = json.loads(message) # type: ignore[arg-type]
|
||||
self.assertEqual(envelope["payload"], payload)
|
||||
|
||||
def test_job_state_export_entry_unchanged_for_admin(self):
|
||||
payload = json.dumps(
|
||||
{
|
||||
"export": {
|
||||
"job_type": "export",
|
||||
"status": "running",
|
||||
"results": {
|
||||
"jobs": [
|
||||
{"job_type": "export", "camera": "garage", "id": "b"},
|
||||
]
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
message = self._materialize(_ws("admin"), "job_state", payload)
|
||||
envelope = json.loads(message) # type: ignore[arg-type]
|
||||
self.assertEqual(envelope["payload"], payload)
|
||||
|
||||
def test_job_state_restricted_keeps_global_jobs(self):
|
||||
"""media_sync has no camera field; restricted users still see it."""
|
||||
payload = json.dumps(
|
||||
{"media_sync": {"job_type": "media_sync", "status": "running"}}
|
||||
)
|
||||
message = self._materialize(_ws("house_only"), "job_state", payload)
|
||||
envelope = json.loads(message) # type: ignore[arg-type]
|
||||
inner = json.loads(envelope["payload"])
|
||||
self.assertIn("media_sync", inner)
|
||||
|
||||
def test_job_state_debug_replay_nested_source_camera_filtered(self):
|
||||
"""debug_replay puts ``source_camera`` inside ``results`` (see
|
||||
jobs/debug_replay.py:to_dict). Restricted users must not receive
|
||||
entries whose nested source camera is unauthorized."""
|
||||
payload = json.dumps(
|
||||
{
|
||||
"debug_replay": {
|
||||
"id": "bd6dc99d-a7d",
|
||||
"job_type": "debug_replay",
|
||||
"status": "running",
|
||||
"start_time": 1.0,
|
||||
"end_time": None,
|
||||
"error_message": None,
|
||||
"results": {
|
||||
"current_step": "preparing_clip",
|
||||
"progress_percent": 0.0,
|
||||
"source_camera": "garage",
|
||||
"replay_camera_name": "_replay_garage",
|
||||
"start_ts": 0.0,
|
||||
"end_ts": 1.0,
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
self.assertIsNone(self._materialize(_ws("house_only"), "job_state", payload))
|
||||
|
||||
def test_job_state_debug_replay_nested_source_camera_allowed(self):
|
||||
payload = json.dumps(
|
||||
{
|
||||
"debug_replay": {
|
||||
"id": "bd6dc99d-a7d",
|
||||
"job_type": "debug_replay",
|
||||
"status": "running",
|
||||
"results": {
|
||||
"source_camera": "front_door",
|
||||
"replay_camera_name": "_replay_front_door",
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
message = self._materialize(_ws("house_only"), "job_state", payload)
|
||||
envelope = json.loads(message) # type: ignore[arg-type]
|
||||
inner = json.loads(envelope["payload"])
|
||||
self.assertIn("debug_replay", inner)
|
||||
self.assertEqual(
|
||||
inner["debug_replay"]["results"]["source_camera"], "front_door"
|
||||
)
|
||||
|
||||
|
||||
class _FakeManager:
|
||||
"""Minimal ws4py manager: holds clients and exposes a lock."""
|
||||
|
||||
def __init__(self, clients: list[Any]) -> None:
|
||||
self.lock = threading.Lock()
|
||||
self.websockets = {id(c): c for c in clients}
|
||||
|
||||
|
||||
class _FakeServer:
|
||||
def __init__(self, manager: _FakeManager) -> None:
|
||||
self.manager = manager
|
||||
|
||||
|
||||
class _CapturingWs(SimpleNamespace):
|
||||
"""Fake ws4py client that records what was sent."""
|
||||
|
||||
def __init__(self, role: str | None) -> None:
|
||||
environ = {} if role is None else {"HTTP_REMOTE_ROLE": role}
|
||||
super().__init__(environ=environ, terminated=False)
|
||||
self.sent: list[str] = []
|
||||
|
||||
def send(self, message: str) -> None: # noqa: D401 - matches ws4py API
|
||||
self.sent.append(message)
|
||||
|
||||
|
||||
class TestPublishEndToEnd(unittest.TestCase):
|
||||
"""Drive WebSocketClient.publish() against fake clients with different roles."""
|
||||
|
||||
def setUp(self):
|
||||
self.config = _build_config(
|
||||
extra_zones={"front_door": {"driveway": {"coordinates": "0,0,1,0,1,1,0,1"}}}
|
||||
)
|
||||
self.admin = _CapturingWs("admin")
|
||||
self.restricted = _CapturingWs("house_only")
|
||||
self.anon = _CapturingWs(None)
|
||||
self.client = WebSocketClient(self.config)
|
||||
self.client.websocket_server = _FakeServer(
|
||||
_FakeManager([self.admin, self.restricted, self.anon])
|
||||
)
|
||||
|
||||
def _payloads(self, ws: _CapturingWs) -> list[Any]:
|
||||
return [json.loads(m)["payload"] for m in ws.sent]
|
||||
|
||||
def test_global_topic_reaches_everyone(self):
|
||||
self.client.publish("model_state", "{}")
|
||||
self.assertEqual(len(self.admin.sent), 1)
|
||||
self.assertEqual(len(self.restricted.sent), 1)
|
||||
self.assertEqual(len(self.anon.sent), 1)
|
||||
|
||||
def test_camera_topic_filters_restricted_recipient(self):
|
||||
self.client.publish("garage/detect/state", "ON")
|
||||
self.assertEqual(len(self.admin.sent), 1)
|
||||
self.assertEqual(len(self.restricted.sent), 0)
|
||||
self.assertEqual(len(self.anon.sent), 0)
|
||||
|
||||
def test_camera_topic_allows_restricted_recipient_for_allowed_camera(self):
|
||||
self.client.publish("front_door/detect/state", "ON")
|
||||
self.assertEqual(len(self.admin.sent), 1)
|
||||
self.assertEqual(len(self.restricted.sent), 1)
|
||||
self.assertEqual(len(self.anon.sent), 0)
|
||||
|
||||
def test_events_payload_filtered(self):
|
||||
self.client.publish("events", json.dumps({"after": {"camera": "garage"}}))
|
||||
self.assertEqual(len(self.admin.sent), 1)
|
||||
self.assertEqual(len(self.restricted.sent), 0)
|
||||
|
||||
def test_camera_activity_reshaped_per_recipient(self):
|
||||
self.client.publish(
|
||||
"camera_activity",
|
||||
json.dumps(
|
||||
{
|
||||
"front_door": {"objects": 1},
|
||||
"back_door": {"objects": 0},
|
||||
"garage": {"objects": 2},
|
||||
}
|
||||
),
|
||||
)
|
||||
self.assertEqual(len(self.admin.sent), 1)
|
||||
admin_inner = json.loads(self._payloads(self.admin)[0])
|
||||
self.assertEqual(set(admin_inner.keys()), {"front_door", "back_door", "garage"})
|
||||
|
||||
self.assertEqual(len(self.restricted.sent), 1)
|
||||
restricted_inner = json.loads(self._payloads(self.restricted)[0])
|
||||
self.assertEqual(set(restricted_inner.keys()), {"front_door", "back_door"})
|
||||
|
||||
self.assertEqual(len(self.anon.sent), 0)
|
||||
|
||||
def test_birdseye_layout_blocked_for_restricted_and_anon(self):
|
||||
self.client.publish(
|
||||
"birdseye_layout",
|
||||
json.dumps({"front_door": {"x": 0, "y": 0, "width": 1, "height": 1}}),
|
||||
)
|
||||
self.assertEqual(len(self.admin.sent), 1)
|
||||
self.assertEqual(len(self.restricted.sent), 0)
|
||||
self.assertEqual(len(self.anon.sent), 0)
|
||||
|
||||
def test_zone_aggregate_blocked_for_restricted(self):
|
||||
self.client.publish("driveway/person", 2)
|
||||
self.assertEqual(len(self.admin.sent), 1)
|
||||
self.assertEqual(len(self.restricted.sent), 0)
|
||||
|
||||
def test_stats_reshaped_per_recipient(self):
|
||||
self.client.publish(
|
||||
"stats",
|
||||
json.dumps(
|
||||
{
|
||||
"cameras": {
|
||||
"front_door": {"camera_fps": 5.0},
|
||||
"garage": {"camera_fps": 5.0},
|
||||
},
|
||||
"service": {"uptime": 1},
|
||||
"camera_fps": 10.0,
|
||||
}
|
||||
),
|
||||
)
|
||||
self.assertEqual(len(self.admin.sent), 1)
|
||||
admin_inner = json.loads(self._payloads(self.admin)[0])
|
||||
self.assertEqual(set(admin_inner["cameras"].keys()), {"front_door", "garage"})
|
||||
|
||||
self.assertEqual(len(self.restricted.sent), 1)
|
||||
restricted_inner = json.loads(self._payloads(self.restricted)[0])
|
||||
self.assertEqual(set(restricted_inner["cameras"].keys()), {"front_door"})
|
||||
self.assertEqual(restricted_inner["camera_fps"], 10.0)
|
||||
self.assertIn("service", restricted_inner)
|
||||
|
||||
# Stats requires a role; anonymous gets nothing.
|
||||
self.assertEqual(len(self.anon.sent), 0)
|
||||
|
||||
def test_export_job_state_filters_results_jobs_per_recipient(self):
|
||||
self.client.publish(
|
||||
"job_state",
|
||||
json.dumps(
|
||||
{
|
||||
"export": {
|
||||
"job_type": "export",
|
||||
"status": "running",
|
||||
"results": {
|
||||
"jobs": [
|
||||
{"camera": "front_door", "id": "a"},
|
||||
{"camera": "garage", "id": "b"},
|
||||
]
|
||||
},
|
||||
}
|
||||
}
|
||||
),
|
||||
)
|
||||
self.assertEqual(len(self.admin.sent), 1)
|
||||
admin_inner = json.loads(self._payloads(self.admin)[0])
|
||||
self.assertEqual(
|
||||
[j["camera"] for j in admin_inner["export"]["results"]["jobs"]],
|
||||
["front_door", "garage"],
|
||||
)
|
||||
|
||||
self.assertEqual(len(self.restricted.sent), 1)
|
||||
restricted_inner = json.loads(self._payloads(self.restricted)[0])
|
||||
self.assertEqual(
|
||||
[j["camera"] for j in restricted_inner["export"]["results"]["jobs"]],
|
||||
["front_door"],
|
||||
)
|
||||
|
||||
def test_unknown_topic_dropped_for_everyone(self):
|
||||
self.client.publish("some_rogue_topic", "data")
|
||||
self.assertEqual(self.admin.sent, [])
|
||||
self.assertEqual(self.restricted.sent, [])
|
||||
self.assertEqual(self.anon.sent, [])
|
||||
|
||||
def test_terminated_client_is_skipped(self):
|
||||
self.restricted.terminated = True
|
||||
self.client.publish("front_door/detect/state", "ON")
|
||||
self.assertEqual(len(self.admin.sent), 1)
|
||||
self.assertEqual(len(self.restricted.sent), 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@ -357,6 +357,9 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
|
||||
def get_current_frame_time(self, camera: str) -> float:
|
||||
"""Returns the latest frame time for a given camera."""
|
||||
if camera not in self.camera_states:
|
||||
return 0.0
|
||||
|
||||
return self.camera_states[camera].current_frame_time
|
||||
|
||||
def set_sub_label(
|
||||
|
||||
@ -531,8 +531,7 @@ class TrackedObject:
|
||||
|
||||
directory = os.path.join(THUMB_DIR, self.camera_config.name)
|
||||
|
||||
if not os.path.exists(directory):
|
||||
os.makedirs(directory)
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
|
||||
thumb_bytes = self.get_thumbnail("webp")
|
||||
|
||||
|
||||
@ -492,7 +492,7 @@ def migrate_018_0(config: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]
|
||||
genai = new_config.get("genai")
|
||||
|
||||
if genai and genai.get("provider"):
|
||||
genai["roles"] = ["embeddings", "vision", "tools"]
|
||||
genai["roles"] = ["embeddings", "descriptions", "chat"]
|
||||
new_config["genai"] = {"default": genai}
|
||||
|
||||
# Remove deprecated sync_recordings from global record config
|
||||
@ -608,11 +608,14 @@ def migrate_018_0(config: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]
|
||||
|
||||
|
||||
def get_relative_coordinates(
|
||||
mask: Optional[Union[str, list]], frame_shape: tuple[int, int]
|
||||
mask: Optional[Union[str, list]],
|
||||
frame_shape: tuple[int, int],
|
||||
camera_name: str = "",
|
||||
) -> Union[str, list]:
|
||||
# masks and zones are saved as relative coordinates
|
||||
# we know if any points are > 1 then it is using the
|
||||
# old native resolution coordinates
|
||||
where = f" for camera {camera_name}" if camera_name else ""
|
||||
if mask:
|
||||
if isinstance(mask, list) and any(x > "1.0" for x in mask[0].split(",")):
|
||||
relative_masks = []
|
||||
@ -627,7 +630,7 @@ def get_relative_coordinates(
|
||||
|
||||
if x > frame_shape[1] or y > frame_shape[0]:
|
||||
logger.error(
|
||||
f"Not applying mask due to invalid coordinates. {x},{y} is outside of the detection resolution {frame_shape[1]}x{frame_shape[0]}. Use the editor in the UI to correct the mask."
|
||||
f"Not applying mask due to invalid coordinates{where}. {x},{y} is outside of the detection resolution {frame_shape[1]}x{frame_shape[0]}. Use the editor in the UI to correct the mask."
|
||||
)
|
||||
continue
|
||||
|
||||
@ -650,7 +653,7 @@ def get_relative_coordinates(
|
||||
|
||||
if x > frame_shape[1] or y > frame_shape[0]:
|
||||
logger.error(
|
||||
f"Not applying mask due to invalid coordinates. {x},{y} is outside of the detection resolution {frame_shape[1]}x{frame_shape[0]}. Use the editor in the UI to correct the mask."
|
||||
f"Not applying mask due to invalid coordinates{where}. {x},{y} is outside of the detection resolution {frame_shape[1]}x{frame_shape[0]}. Use the editor in the UI to correct the mask."
|
||||
)
|
||||
return []
|
||||
|
||||
|
||||
@ -393,8 +393,10 @@ def _read_intel_drm_fdinfo(target_pdev: Optional[str]) -> dict:
|
||||
return snapshot
|
||||
|
||||
|
||||
def get_intel_gpu_stats(intel_gpu_device: Optional[str]) -> Optional[dict[str, Any]]:
|
||||
"""Get stats by reading DRM fdinfo files.
|
||||
def get_intel_gpu_stats(
|
||||
intel_gpu_device: Optional[str],
|
||||
) -> Optional[dict[str, dict[str, Any]]]:
|
||||
"""Get stats by reading DRM fdinfo files, bucketed per-pdev.
|
||||
|
||||
Each DRM client FD exposes monotonic per-engine busy counters via
|
||||
/proc/<pid>/fdinfo/<fd> (i915 since kernel 5.19, Xe since first release).
|
||||
@ -402,7 +404,14 @@ def get_intel_gpu_stats(intel_gpu_device: Optional[str]) -> Optional[dict[str, A
|
||||
utilization. Render/3D and Compute are pooled into "compute"; Video and
|
||||
VideoEnhance into "dec". Overall "gpu" is the sum of those pools (clamped
|
||||
to 100%).
|
||||
|
||||
The return value is keyed by the GPU's drm-pdev string so multiple Intel
|
||||
GPUs in the same system are reported separately. Each entry carries a
|
||||
"name" populated from OpenVINO (falling back to the pdev) so callers can
|
||||
surface a real device name in the UI.
|
||||
"""
|
||||
from frigate.stats.intel_gpu_info import intel_gpu_name_resolver
|
||||
|
||||
target_pdev = _resolve_intel_gpu_pdev(intel_gpu_device)
|
||||
|
||||
snapshot_a = _read_intel_drm_fdinfo(target_pdev)
|
||||
@ -417,19 +426,21 @@ def get_intel_gpu_stats(intel_gpu_device: Optional[str]) -> Optional[dict[str, A
|
||||
if not snapshot_b or elapsed_ns <= 0:
|
||||
return None
|
||||
|
||||
engine_pct: dict[str, float] = {
|
||||
"render": 0.0,
|
||||
"video": 0.0,
|
||||
"video-enhance": 0.0,
|
||||
"compute": 0.0,
|
||||
}
|
||||
pid_pct: dict[str, float] = {}
|
||||
def _new_engine_pct() -> dict[str, float]:
|
||||
return {"render": 0.0, "video": 0.0, "video-enhance": 0.0, "compute": 0.0}
|
||||
|
||||
per_pdev_engine_pct: dict[str, dict[str, float]] = {}
|
||||
per_pdev_pid_pct: dict[str, dict[str, float]] = {}
|
||||
|
||||
for key, data_b in snapshot_b.items():
|
||||
data_a = snapshot_a.get(key)
|
||||
if not data_a or data_a["driver"] != data_b["driver"]:
|
||||
continue
|
||||
|
||||
pdev = key[0]
|
||||
engine_pct = per_pdev_engine_pct.setdefault(pdev, _new_engine_pct())
|
||||
pid_pct = per_pdev_pid_pct.setdefault(pdev, {})
|
||||
|
||||
client_total = 0.0
|
||||
for engine, (busy_b, total_b) in data_b["engines"].items():
|
||||
if engine not in engine_pct:
|
||||
@ -452,25 +463,37 @@ def get_intel_gpu_stats(intel_gpu_device: Optional[str]) -> Optional[dict[str, A
|
||||
|
||||
pid_pct[data_b["pid"]] = pid_pct.get(data_b["pid"], 0.0) + client_total
|
||||
|
||||
for engine in engine_pct:
|
||||
engine_pct[engine] = min(100.0, engine_pct[engine])
|
||||
if not per_pdev_engine_pct:
|
||||
return None
|
||||
|
||||
compute_pct = min(100.0, engine_pct["render"] + engine_pct["compute"])
|
||||
dec_pct = min(100.0, engine_pct["video"] + engine_pct["video-enhance"])
|
||||
overall_pct = min(100.0, compute_pct + dec_pct)
|
||||
names = intel_gpu_name_resolver.get_names()
|
||||
results: dict[str, dict[str, Any]] = {}
|
||||
|
||||
results: dict[str, Any] = {
|
||||
"gpu": f"{round(overall_pct, 2)}%",
|
||||
"mem": "-%",
|
||||
"compute": f"{round(compute_pct, 2)}%",
|
||||
"dec": f"{round(dec_pct, 2)}%",
|
||||
}
|
||||
for pdev, engine_pct in per_pdev_engine_pct.items():
|
||||
for engine in engine_pct:
|
||||
engine_pct[engine] = min(100.0, engine_pct[engine])
|
||||
|
||||
if pid_pct:
|
||||
results["clients"] = {
|
||||
pid: f"{round(min(100.0, pct), 2)}%" for pid, pct in pid_pct.items()
|
||||
compute_pct = min(100.0, engine_pct["render"] + engine_pct["compute"])
|
||||
dec_pct = min(100.0, engine_pct["video"] + engine_pct["video-enhance"])
|
||||
overall_pct = min(100.0, compute_pct + dec_pct)
|
||||
|
||||
entry: dict[str, Any] = {
|
||||
"name": names.get(pdev) or f"Intel GPU {pdev}",
|
||||
"vendor": "intel",
|
||||
"gpu": f"{round(overall_pct, 2)}%",
|
||||
"mem": "-%",
|
||||
"compute": f"{round(compute_pct, 2)}%",
|
||||
"dec": f"{round(dec_pct, 2)}%",
|
||||
}
|
||||
|
||||
pid_pct = per_pdev_pid_pct.get(pdev)
|
||||
if pid_pct:
|
||||
entry["clients"] = {
|
||||
pid: f"{round(min(100.0, pct), 2)}%" for pid, pct in pid_pct.items()
|
||||
}
|
||||
|
||||
results[pdev] = entry
|
||||
|
||||
return results
|
||||
|
||||
|
||||
@ -755,6 +778,41 @@ def get_hailo_temps() -> dict[str, float]:
|
||||
return temps
|
||||
|
||||
|
||||
def _go2rtc_arbitrary_exec_allowed() -> bool:
|
||||
"""Read the GO2RTC_ALLOW_ARBITRARY_EXEC override from env, docker
|
||||
secrets, or the Home Assistant add-on options file."""
|
||||
raw: Optional[str] = None
|
||||
if "GO2RTC_ALLOW_ARBITRARY_EXEC" in os.environ:
|
||||
raw = os.environ.get("GO2RTC_ALLOW_ARBITRARY_EXEC")
|
||||
elif (
|
||||
os.path.isdir("/run/secrets")
|
||||
and os.access("/run/secrets", os.R_OK)
|
||||
and "GO2RTC_ALLOW_ARBITRARY_EXEC" in os.listdir("/run/secrets")
|
||||
):
|
||||
try:
|
||||
with open("/run/secrets/GO2RTC_ALLOW_ARBITRARY_EXEC") as f:
|
||||
raw = f.read().strip()
|
||||
except OSError:
|
||||
raw = None
|
||||
elif os.path.isfile("/data/options.json"):
|
||||
try:
|
||||
with open("/data/options.json") as f:
|
||||
options = json.loads(f.read())
|
||||
raw = options.get("go2rtc_allow_arbitrary_exec")
|
||||
except (OSError, json.JSONDecodeError):
|
||||
raw = None
|
||||
|
||||
return raw is not None and str(raw).lower() in ("true", "1", "yes")
|
||||
|
||||
|
||||
def is_restricted_go2rtc_source(stream_source: str) -> bool:
|
||||
"""Check if a stream source is a restricted type (echo, expr, or exec)
|
||||
and the GO2RTC_ALLOW_ARBITRARY_EXEC override is not set."""
|
||||
if not stream_source.strip().startswith(("echo:", "expr:", "exec:")):
|
||||
return False
|
||||
return not _go2rtc_arbitrary_exec_allowed()
|
||||
|
||||
|
||||
def ffprobe_stream(ffmpeg, path: str, detailed: bool = False) -> sp.CompletedProcess:
|
||||
"""Run ffprobe on stream."""
|
||||
clean_path = escape_special_characters(path)
|
||||
|
||||
@ -150,29 +150,51 @@ def extract_translations_from_schema(
|
||||
# Handle anyOf cases
|
||||
elif "anyOf" in field_schema:
|
||||
for item in field_schema["anyOf"]:
|
||||
nested = None
|
||||
if item.get("type") == "null":
|
||||
continue
|
||||
if "properties" in item:
|
||||
nested = extract_translations_from_schema(item, defs=defs)
|
||||
elif "$ref" in item:
|
||||
ref_path = item["$ref"]
|
||||
if ref_path.startswith("#/$defs/"):
|
||||
ref_name = ref_path.split("/")[-1]
|
||||
if ref_name in defs:
|
||||
nested = extract_translations_from_schema(
|
||||
defs[ref_name], defs=defs
|
||||
)
|
||||
elif (
|
||||
"additionalProperties" in item
|
||||
and isinstance(item["additionalProperties"], dict)
|
||||
and "$ref" in item["additionalProperties"]
|
||||
):
|
||||
ref_path = item["additionalProperties"]["$ref"]
|
||||
if ref_path.startswith("#/$defs/"):
|
||||
ref_name = ref_path.split("/")[-1]
|
||||
if ref_name in defs:
|
||||
nested = extract_translations_from_schema(
|
||||
defs[ref_name], defs=defs
|
||||
)
|
||||
elif (
|
||||
"items" in item
|
||||
and isinstance(item["items"], dict)
|
||||
and ("$ref" in item["items"])
|
||||
):
|
||||
ref_path = item["items"]["$ref"]
|
||||
if ref_path.startswith("#/$defs/"):
|
||||
ref_name = ref_path.split("/")[-1]
|
||||
if ref_name in defs:
|
||||
nested = extract_translations_from_schema(
|
||||
defs[ref_name], defs=defs
|
||||
)
|
||||
|
||||
if nested:
|
||||
nested_without_root = {
|
||||
k: v
|
||||
for k, v in nested.items()
|
||||
if k not in ("label", "description")
|
||||
}
|
||||
field_translations.update(nested_without_root)
|
||||
elif "$ref" in item:
|
||||
ref_path = item["$ref"]
|
||||
if ref_path.startswith("#/$defs/"):
|
||||
ref_name = ref_path.split("/")[-1]
|
||||
if ref_name in defs:
|
||||
ref_schema = defs[ref_name]
|
||||
nested = extract_translations_from_schema(
|
||||
ref_schema, defs=defs
|
||||
)
|
||||
nested_without_root = {
|
||||
k: v
|
||||
for k, v in nested.items()
|
||||
if k not in ("label", "description")
|
||||
}
|
||||
field_translations.update(nested_without_root)
|
||||
|
||||
if field_translations:
|
||||
translations[field_name] = field_translations
|
||||
@ -342,6 +364,64 @@ def main():
|
||||
continue
|
||||
section_data.pop(key, None)
|
||||
|
||||
if field_name == "objects":
|
||||
# Produce a parallel `filters_attribute` block alongside `filters`,
|
||||
# with object-wording rewritten for attribute filters (face,
|
||||
# license_plate, courier logos). The frontend's
|
||||
# buildTranslationPath routes `filters.<attr>.<field>` lookups to
|
||||
# `filters_attribute.<field>` when `<attr>` is in
|
||||
# `model.all_attributes`. Keep this rewrite list explicit rather
|
||||
# than running a blanket s/object/attribute/ so unrelated
|
||||
# descriptions (e.g. "JSON object") never accidentally flip.
|
||||
filters_block = section_data.get("filters")
|
||||
if isinstance(filters_block, dict):
|
||||
attribute_rewrites = [
|
||||
("Object filters", "Attribute filters"),
|
||||
("detected objects", "detected attributes"),
|
||||
("object area", "attribute area"),
|
||||
("object type", "attribute"),
|
||||
("the object", "the attribute"),
|
||||
]
|
||||
|
||||
# Per-field overrides for cases where the generic rewrite
|
||||
# doesn't capture the attribute-specific semantics. Keys
|
||||
# match the FilterConfig field name; values are partial
|
||||
# overrides applied AFTER the generic rewrites.
|
||||
attribute_field_overrides: Dict[str, Dict[str, str]] = {
|
||||
"min_score": {
|
||||
"description": (
|
||||
"Minimum single-frame detection confidence required "
|
||||
"to associate this attribute with its parent object."
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
def rewrite(text: str) -> str:
|
||||
for source, replacement in attribute_rewrites:
|
||||
text = text.replace(source, replacement)
|
||||
return text
|
||||
|
||||
attribute_variant: Dict[str, Any] = {}
|
||||
for key, value in filters_block.items():
|
||||
if key in ("label", "description"):
|
||||
if isinstance(value, str):
|
||||
attribute_variant[key] = rewrite(value)
|
||||
continue
|
||||
if not isinstance(value, dict):
|
||||
continue
|
||||
field_trans: Dict[str, str] = {}
|
||||
if isinstance(value.get("label"), str):
|
||||
field_trans["label"] = rewrite(value["label"])
|
||||
if isinstance(value.get("description"), str):
|
||||
field_trans["description"] = rewrite(value["description"])
|
||||
overrides = attribute_field_overrides.get(key)
|
||||
if overrides:
|
||||
field_trans.update(overrides)
|
||||
if field_trans:
|
||||
attribute_variant[key] = field_trans
|
||||
if attribute_variant:
|
||||
section_data["filters_attribute"] = attribute_variant
|
||||
|
||||
if not section_data:
|
||||
logger.warning(f"No translations found for section: {field_name}")
|
||||
continue
|
||||
|
||||
55
web/e2e/specs/settings/detectors-and-model.spec.ts
Normal file
55
web/e2e/specs/settings/detectors-and-model.spec.ts
Normal file
@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Detectors and model settings page tests -- HIGH tier.
|
||||
*
|
||||
* Tests rendering of the merged page and navigation from the Frigate+ page.
|
||||
*/
|
||||
|
||||
import { test, expect } from "../../fixtures/frigate-test";
|
||||
|
||||
test.describe("Detectors and model Settings @high", () => {
|
||||
test("page renders with detector and model cards", async ({ frigateApp }) => {
|
||||
await frigateApp.goto("/settings?page=systemDetectorsAndModel");
|
||||
await frigateApp.page.waitForTimeout(2000);
|
||||
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
|
||||
|
||||
const text = await frigateApp.page.textContent("#pageRoot");
|
||||
expect(text).toContain("Detectors and model");
|
||||
expect(text?.toLowerCase()).toContain("detector hardware");
|
||||
expect(text?.toLowerCase()).toContain("detection model");
|
||||
});
|
||||
|
||||
test("Frigate+ page links to the merged page", async ({ frigateApp }) => {
|
||||
await frigateApp.goto("/settings?page=frigateplus");
|
||||
await frigateApp.page.waitForTimeout(2000);
|
||||
|
||||
const button = frigateApp.page.getByRole("button", {
|
||||
name: /Change in Detectors and model/,
|
||||
});
|
||||
|
||||
// Button only appears when Frigate+ is enabled in the test config; skip
|
||||
// the click assertion if it's not present.
|
||||
if ((await button.count()) > 0) {
|
||||
await button.first().click();
|
||||
await frigateApp.page.waitForURL(/page=systemDetectorsAndModel/);
|
||||
await expect(frigateApp.page.locator("#pageRoot")).toContainText(
|
||||
"Detectors and model",
|
||||
);
|
||||
} else {
|
||||
test.skip(
|
||||
true,
|
||||
"Frigate+ not enabled in this test config; skipping link assertion",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("old systemDetectionModel deep-link no longer routes here", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.goto("/settings?page=systemDetectionModel");
|
||||
await frigateApp.page.waitForTimeout(2000);
|
||||
// The old page key is no longer in allSettingsViews; the router
|
||||
// falls back to its default settings page (uiSettings).
|
||||
const text = await frigateApp.page.textContent("#pageRoot");
|
||||
expect(text).not.toContain("Detection model");
|
||||
});
|
||||
});
|
||||
235
web/e2e/specs/settings/go2rtc-streams.spec.ts
Normal file
235
web/e2e/specs/settings/go2rtc-streams.spec.ts
Normal file
@ -0,0 +1,235 @@
|
||||
/**
|
||||
* go2rtc streams settings page tests -- MEDIUM tier.
|
||||
*
|
||||
* Regression coverage for the compat-mode (ffmpeg:) URL editor: unknown
|
||||
* fragments like #timeout=10 must remain visible and editable when the
|
||||
* stream is using compatibility mode.
|
||||
*/
|
||||
|
||||
import { test, expect } from "../../fixtures/frigate-test";
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
const STREAM_NAME = "dome_sub";
|
||||
const FFMPEG_URL_WITH_TIMEOUT =
|
||||
"ffmpeg:rtsp://user:pass@192.168.0.20:554/Stream1#video=copy#audio=copy#timeout=10";
|
||||
|
||||
async function installRawPathsRoute(page: Page, streamUrl: string) {
|
||||
let lastSavedConfig: unknown = null;
|
||||
await page.route("**/api/config/raw_paths", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
cameras: {},
|
||||
go2rtc: { streams: { [STREAM_NAME]: [streamUrl] } },
|
||||
},
|
||||
}),
|
||||
);
|
||||
await page.route("**/api/config/set", async (route) => {
|
||||
lastSavedConfig = route.request().postDataJSON();
|
||||
await route.fulfill({ json: { success: true, require_restart: false } });
|
||||
});
|
||||
return {
|
||||
capturedConfig: () => lastSavedConfig,
|
||||
};
|
||||
}
|
||||
|
||||
async function expandStream(page: Page, streamName: string) {
|
||||
// Each StreamCard renders the stream name as an h4 next to a rename
|
||||
// button, with the chevron toggle as the last button in the header row.
|
||||
// Scope to the header row (h4's grandparent) and click that last button.
|
||||
const headerRow = page
|
||||
.locator(`h4:text-is("${streamName}")`)
|
||||
.locator("xpath=../..");
|
||||
await headerRow.getByRole("button").last().click();
|
||||
}
|
||||
|
||||
test.describe("go2rtc streams settings — ffmpeg compat mode @medium", () => {
|
||||
test("preserves unknown fragments like #timeout= in the URL input", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await installRawPathsRoute(frigateApp.page, FFMPEG_URL_WITH_TIMEOUT);
|
||||
await frigateApp.goto("/settings?page=systemGo2rtcStreams");
|
||||
|
||||
await expect(
|
||||
frigateApp.page.getByRole("heading", { name: STREAM_NAME }),
|
||||
).toBeVisible();
|
||||
|
||||
await expandStream(frigateApp.page, STREAM_NAME);
|
||||
|
||||
const urlInput = frigateApp.page.getByPlaceholder(
|
||||
"e.g., rtsp://user:pass@192.168.1.100/stream",
|
||||
);
|
||||
await expect(urlInput).toBeVisible();
|
||||
|
||||
// Focus the input so credential masking is bypassed and the raw value
|
||||
// is rendered — this matches how a user would inspect the URL before
|
||||
// editing it.
|
||||
await urlInput.focus();
|
||||
await expect(urlInput).toHaveValue(
|
||||
"rtsp://user:pass@192.168.0.20:554/Stream1#timeout=10",
|
||||
);
|
||||
});
|
||||
|
||||
test("lets the user add an extra fragment in compat mode", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
const capture = await installRawPathsRoute(
|
||||
frigateApp.page,
|
||||
FFMPEG_URL_WITH_TIMEOUT,
|
||||
);
|
||||
await frigateApp.goto("/settings?page=systemGo2rtcStreams");
|
||||
await expandStream(frigateApp.page, STREAM_NAME);
|
||||
|
||||
const urlInput = frigateApp.page.getByPlaceholder(
|
||||
"e.g., rtsp://user:pass@192.168.1.100/stream",
|
||||
);
|
||||
await urlInput.focus();
|
||||
await urlInput.fill(
|
||||
"rtsp://user:pass@192.168.0.20:554/Stream1#timeout=10#backchannel=0",
|
||||
);
|
||||
await urlInput.blur();
|
||||
|
||||
// Reopen and re-focus to assert the new value round-tripped through
|
||||
// parseFfmpegBaseAndExtras + buildFfmpegUrl back into the displayed text.
|
||||
await urlInput.focus();
|
||||
await expect(urlInput).toHaveValue(
|
||||
"rtsp://user:pass@192.168.0.20:554/Stream1#timeout=10#backchannel=0",
|
||||
);
|
||||
|
||||
// Save and verify the persisted URL includes both extras after the
|
||||
// recognized video/audio directives.
|
||||
await frigateApp.page.getByRole("button", { name: "Save" }).click();
|
||||
await expect
|
||||
.poll(() => capture.capturedConfig(), { timeout: 5_000 })
|
||||
.toMatchObject({
|
||||
config_data: {
|
||||
go2rtc: {
|
||||
streams: {
|
||||
[STREAM_NAME]: [
|
||||
"ffmpeg:rtsp://user:pass@192.168.0.20:554/Stream1#video=copy#audio=copy#timeout=10#backchannel=0",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("preserves repeatable #audio= fallback chain and lets the user add another codec", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
const capture = await installRawPathsRoute(
|
||||
frigateApp.page,
|
||||
// Idiomatic go2rtc fallback: copy if source has the codec, else transcode
|
||||
"ffmpeg:rtsp://user:pass@192.168.0.20:554/Stream1#video=copy#audio=copy#audio=opus",
|
||||
);
|
||||
await frigateApp.goto("/settings?page=systemGo2rtcStreams");
|
||||
await expandStream(frigateApp.page, STREAM_NAME);
|
||||
|
||||
// Two pre-populated audio rows — one per #audio= fragment.
|
||||
const audioLabel = frigateApp.page.locator(`label:text-is("Audio")`);
|
||||
const audioRowsContainer = audioLabel.locator("xpath=../..");
|
||||
await expect(audioRowsContainer.getByRole("combobox")).toHaveCount(2);
|
||||
await expect(audioRowsContainer.getByRole("combobox").first()).toHaveText(
|
||||
"Copy",
|
||||
);
|
||||
await expect(audioRowsContainer.getByRole("combobox").nth(1)).toHaveText(
|
||||
"Transcode to Opus",
|
||||
);
|
||||
|
||||
// Add a third audio codec via the LuPlus next to the "Audio" label.
|
||||
await audioRowsContainer
|
||||
.getByRole("button", { name: "Add audio codec" })
|
||||
.click();
|
||||
await expect(audioRowsContainer.getByRole("combobox")).toHaveCount(3);
|
||||
|
||||
// Change the newly-added entry to AAC.
|
||||
await audioRowsContainer.getByRole("combobox").nth(2).click();
|
||||
await frigateApp.page
|
||||
.getByRole("option", { name: "Transcode to AAC" })
|
||||
.click();
|
||||
|
||||
await frigateApp.page.getByRole("button", { name: "Save" }).click();
|
||||
await expect
|
||||
.poll(() => capture.capturedConfig(), { timeout: 5_000 })
|
||||
.toMatchObject({
|
||||
config_data: {
|
||||
go2rtc: {
|
||||
streams: {
|
||||
[STREAM_NAME]: [
|
||||
"ffmpeg:rtsp://user:pass@192.168.0.20:554/Stream1#video=copy#audio=copy#audio=opus#audio=aac",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("LuX is only shown on fallback rows and removes only that codec", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
const capture = await installRawPathsRoute(
|
||||
frigateApp.page,
|
||||
"ffmpeg:rtsp://user:pass@192.168.0.20:554/Stream1#video=copy#audio=copy#audio=opus",
|
||||
);
|
||||
await frigateApp.goto("/settings?page=systemGo2rtcStreams");
|
||||
await expandStream(frigateApp.page, STREAM_NAME);
|
||||
|
||||
const audioLabel = frigateApp.page.locator(`label:text-is("Audio")`);
|
||||
const audioRowsContainer = audioLabel.locator("xpath=../..");
|
||||
const removeButtons = audioRowsContainer.getByRole("button", {
|
||||
name: "Remove codec",
|
||||
});
|
||||
// Primary (audio=copy) row is permanent and has no X; only the audio=opus
|
||||
// fallback exposes a remove button.
|
||||
await expect(removeButtons).toHaveCount(1);
|
||||
|
||||
await removeButtons.first().click();
|
||||
await expect(audioRowsContainer.getByRole("combobox")).toHaveCount(1);
|
||||
await expect(audioRowsContainer.getByRole("combobox")).toHaveText("Copy");
|
||||
|
||||
await frigateApp.page.getByRole("button", { name: "Save" }).click();
|
||||
await expect
|
||||
.poll(() => capture.capturedConfig(), { timeout: 5_000 })
|
||||
.toMatchObject({
|
||||
config_data: {
|
||||
go2rtc: {
|
||||
streams: {
|
||||
[STREAM_NAME]: [
|
||||
"ffmpeg:rtsp://user:pass@192.168.0.20:554/Stream1#video=copy#audio=copy",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("picking Exclude on the primary row drops the #video= fragment entirely", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
const capture = await installRawPathsRoute(
|
||||
frigateApp.page,
|
||||
"ffmpeg:rtsp://user:pass@192.168.0.20:554/Stream1#video=copy#audio=copy",
|
||||
);
|
||||
await frigateApp.goto("/settings?page=systemGo2rtcStreams");
|
||||
await expandStream(frigateApp.page, STREAM_NAME);
|
||||
|
||||
const videoLabel = frigateApp.page.locator(`label:text-is("Video")`);
|
||||
const videoRowsContainer = videoLabel.locator("xpath=../..");
|
||||
await videoRowsContainer.getByRole("combobox").first().click();
|
||||
await frigateApp.page.getByRole("option", { name: "Exclude" }).click();
|
||||
|
||||
await frigateApp.page.getByRole("button", { name: "Save" }).click();
|
||||
await expect
|
||||
.poll(() => capture.capturedConfig(), { timeout: 5_000 })
|
||||
.toMatchObject({
|
||||
config_data: {
|
||||
go2rtc: {
|
||||
streams: {
|
||||
[STREAM_NAME]: [
|
||||
"ffmpeg:rtsp://user:pass@192.168.0.20:554/Stream1#audio=copy",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
33
web/package-lock.json
generated
33
web/package-lock.json
generated
@ -81,7 +81,7 @@
|
||||
"strftime": "^0.10.3",
|
||||
"swr": "^2.4.1",
|
||||
"tailwind-merge": "^2.4.0",
|
||||
"tailwind-scrollbar": "^3.1.0",
|
||||
"tailwind-scrollbar": "^4.0.2",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"use-long-press": "^3.2.0",
|
||||
"vaul": "^1.1.2",
|
||||
@ -5762,6 +5762,12 @@
|
||||
"undici-types": "~5.26.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/prismjs": {
|
||||
"version": "1.26.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.6.tgz",
|
||||
"integrity": "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||
@ -11896,6 +11902,19 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/prism-react-renderer": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/prism-react-renderer/-/prism-react-renderer-2.4.1.tgz",
|
||||
"integrity": "sha512-ey8Ls/+Di31eqzUxC46h8MksNuGx/n0AAC8uKpwFau4RPDYLuE3EXTp8N8G2vX2N7UC/+IXeNUnlWBGGcAG+Ig==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/prismjs": "^1.26.0",
|
||||
"clsx": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prop-types": {
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
@ -13328,14 +13347,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tailwind-scrollbar": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/tailwind-scrollbar/-/tailwind-scrollbar-3.1.0.tgz",
|
||||
"integrity": "sha512-pmrtDIZeHyu2idTejfV59SbaJyvp1VRjYxAjZBH0jnyrPRo6HL1kD5Glz8VPagasqr6oAx6M05+Tuw429Z8jxg==",
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/tailwind-scrollbar/-/tailwind-scrollbar-4.0.2.tgz",
|
||||
"integrity": "sha512-wAQiIxAPqk0MNTPptVe/xoyWi27y+NRGnTwvn4PQnbvB9kp8QUBiGl/wsfoVBHnQxTmhXJSNt9NHTmcz9EivFA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prism-react-renderer": "^2.4.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.13.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"tailwindcss": "3.x"
|
||||
"tailwindcss": "4.x"
|
||||
}
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
|
||||
@ -95,7 +95,7 @@
|
||||
"strftime": "^0.10.3",
|
||||
"swr": "^2.4.1",
|
||||
"tailwind-merge": "^2.4.0",
|
||||
"tailwind-scrollbar": "^3.1.0",
|
||||
"tailwind-scrollbar": "^4.0.2",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"use-long-press": "^3.2.0",
|
||||
"vaul": "^1.1.2",
|
||||
|
||||
@ -6,7 +6,8 @@
|
||||
"title": "يتم إعادة تشغيل فرايجيت",
|
||||
"content": "العد التنازلي",
|
||||
"button": "فرض إعادة التحميل الآن"
|
||||
}
|
||||
},
|
||||
"description": "هذا سيؤدي لإيقاف Frigate مؤقتا أثناء إعادة تشغيلها"
|
||||
},
|
||||
"explore": {
|
||||
"plus": {
|
||||
|
||||
@ -1,3 +1,6 @@
|
||||
{
|
||||
"label": "اعدادات الكاميرا"
|
||||
"label": "اعدادات الكاميرا",
|
||||
"name": {
|
||||
"label": "إسم الكاميرا"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1 +1,6 @@
|
||||
{}
|
||||
{
|
||||
"version": {
|
||||
"label": "إصدار الإعدادات الحالية",
|
||||
"description": "نسحة عددية أو نصية من الإعدادات الحالية الفعالة للمساعدة على اكتشاف الانتقال أو التغير في الصِّيَغ"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
{
|
||||
"audio": {
|
||||
"global": {
|
||||
"detection": "التحري العام"
|
||||
"detection": "التحري العام",
|
||||
"sensitivity": "الحساسية العامة"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1 +1,4 @@
|
||||
{}
|
||||
{
|
||||
"minimum": "يجب أن تكون {{limit}} على الأقل",
|
||||
"maximum": "يجب أن تكون {{limit}} كحد أقصى"
|
||||
}
|
||||
|
||||
3
web/public/locales/ar/views/chat.json
Normal file
3
web/public/locales/ar/views/chat.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"documentTitle": "المحادثات - Frigate"
|
||||
}
|
||||
3
web/public/locales/ar/views/motionSearch.json
Normal file
3
web/public/locales/ar/views/motionSearch.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"documentTitle": "البحث عن الحركة - Frigate"
|
||||
}
|
||||
1
web/public/locales/ar/views/replay.json
Normal file
1
web/public/locales/ar/views/replay.json
Normal file
@ -0,0 +1 @@
|
||||
{}
|
||||
1
web/public/locales/bg/views/chat.json
Normal file
1
web/public/locales/bg/views/chat.json
Normal file
@ -0,0 +1 @@
|
||||
{}
|
||||
1
web/public/locales/bg/views/motionSearch.json
Normal file
1
web/public/locales/bg/views/motionSearch.json
Normal file
@ -0,0 +1 @@
|
||||
{}
|
||||
1
web/public/locales/bg/views/replay.json
Normal file
1
web/public/locales/bg/views/replay.json
Normal file
@ -0,0 +1 @@
|
||||
{}
|
||||
503
web/public/locales/bs/audio.json
Normal file
503
web/public/locales/bs/audio.json
Normal file
@ -0,0 +1,503 @@
|
||||
{
|
||||
"speech": "Govor",
|
||||
"babbling": "Babavljanje",
|
||||
"bicycle": "Kolo",
|
||||
"yell": "Vik",
|
||||
"bellow": "Bubanj",
|
||||
"whoop": "Vrisak",
|
||||
"whispering": "Šaputanje",
|
||||
"laughter": "Smijeh",
|
||||
"snicker": "Prijem",
|
||||
"crying": "Plač",
|
||||
"sigh": "Usklik",
|
||||
"singing": "Pjevanje",
|
||||
"choir": "Hors",
|
||||
"yodeling": "Jodelanje",
|
||||
"chant": "Pjevanje",
|
||||
"mantra": "Mantra",
|
||||
"child_singing": "Dječje pjevanje",
|
||||
"synthetic_singing": "Sintetičko pjevanje",
|
||||
"rapping": "Rap",
|
||||
"humming": "Hum",
|
||||
"groan": "Grokot",
|
||||
"grunt": "Groktanje",
|
||||
"whistling": "Pucanje",
|
||||
"breathing": "Disanje",
|
||||
"wheeze": "Pijuckanje",
|
||||
"snoring": "Kicanje",
|
||||
"gasp": "Udah",
|
||||
"pant": "Pantanje",
|
||||
"snort": "Snortanje",
|
||||
"cough": "Kašljanje",
|
||||
"throat_clearing": "Očišćavanje grla",
|
||||
"sneeze": "Prašanje",
|
||||
"sniff": "Njuhanje",
|
||||
"run": "Trčanje",
|
||||
"shuffle": "Prelazak",
|
||||
"footsteps": "Koraci",
|
||||
"chewing": "Zubljanje",
|
||||
"biting": "Gubitak",
|
||||
"gargling": "Peranje grla",
|
||||
"stomach_rumble": "Grušenje",
|
||||
"burping": "Puknutje",
|
||||
"hiccup": "Kikot",
|
||||
"fart": "Pucanje",
|
||||
"hands": "Ruke",
|
||||
"finger_snapping": "Prašanje prstiju",
|
||||
"clapping": "Ključanje",
|
||||
"heartbeat": "Taktilno",
|
||||
"heart_murmur": "Šum srca",
|
||||
"cheering": "Pozdrav",
|
||||
"applause": "Pozdravljati",
|
||||
"chatter": "Šaputanje",
|
||||
"crowd": "Gomila",
|
||||
"children_playing": "Dječja igra",
|
||||
"animal": "Životinja",
|
||||
"pets": "Hrana",
|
||||
"dog": "Pas",
|
||||
"bark": "Glavu",
|
||||
"yip": "Jauk",
|
||||
"howl": "Vijuk",
|
||||
"bow_wow": "Vau vau",
|
||||
"growling": "Gručenje",
|
||||
"whimper_dog": "Pijuckanje psa",
|
||||
"cat": "Mačka",
|
||||
"purr": "Mrmor",
|
||||
"meow": "Mjau",
|
||||
"hiss": "Zujanje",
|
||||
"caterwaul": "Krik",
|
||||
"livestock": "Stoke",
|
||||
"horse": "Konj",
|
||||
"clip_clop": "Klik klok",
|
||||
"neigh": "Kijanje",
|
||||
"cattle": "Stoke",
|
||||
"moo": "Muu",
|
||||
"cowbell": "Kovčeg",
|
||||
"pig": "Svinja",
|
||||
"oink": "Roktanje",
|
||||
"goat": "Koza",
|
||||
"bleat": "Blejkanje",
|
||||
"sheep": "Ovca",
|
||||
"fowl": "Ptica",
|
||||
"chicken": "Pilica",
|
||||
"cluck": "Kukanje",
|
||||
"cock_a_doodle_doo": "Kukavica",
|
||||
"turkey": "Gusa",
|
||||
"gobble": "Gubljanje",
|
||||
"duck": "Kuja",
|
||||
"quack": "Kvaka",
|
||||
"goose": "Guska",
|
||||
"honk": "Trubljenje",
|
||||
"wild_animals": "Divlja životinja",
|
||||
"roaring_cats": "Vrišćeći mački",
|
||||
"roar": "Vrištanje",
|
||||
"bird": "Ptica",
|
||||
"chirp": "Pijuckanje",
|
||||
"squawk": "Krik",
|
||||
"pigeon": "Papiga",
|
||||
"coo": "Kukanje",
|
||||
"crow": "Vran",
|
||||
"caw": "Vranje",
|
||||
"owl": "Kukavica",
|
||||
"hoot": "Kukavica",
|
||||
"flapping_wings": "Mahanje krilima",
|
||||
"dogs": "Psi",
|
||||
"rats": "Štakori",
|
||||
"mouse": "Miš",
|
||||
"patter": "Topotanje",
|
||||
"insect": "Insekt",
|
||||
"cricket": "Cvrčak",
|
||||
"mosquito": "Komarac",
|
||||
"fly": "Muha",
|
||||
"buzz": "Zujanje",
|
||||
"frog": "Žaba",
|
||||
"croak": "Kreketanje",
|
||||
"snake": "Zmija",
|
||||
"rattle": "Zveckanje",
|
||||
"whale_vocalization": "Glasanje kita",
|
||||
"music": "Muzika",
|
||||
"musical_instrument": "Muzički instrument",
|
||||
"plucked_string_instrument": "Plucked String Instrument",
|
||||
"guitar": "Gitara",
|
||||
"electric_guitar": "Električna gitara",
|
||||
"bass_guitar": "Bas gitara",
|
||||
"acoustic_guitar": "Akustična gitara",
|
||||
"steel_guitar": "Steel gitara",
|
||||
"tapping": "Tapping",
|
||||
"strum": "Strum",
|
||||
"banjo": "Bendžo",
|
||||
"sitar": "Sitar",
|
||||
"mandolin": "Mandolina",
|
||||
"zither": "Citra",
|
||||
"ukulele": "Ukulele",
|
||||
"keyboard": "Klaviatura",
|
||||
"piano": "Klavir",
|
||||
"electric_piano": "Električni piano",
|
||||
"organ": "Orgulje",
|
||||
"electronic_organ": "Elektronski organ",
|
||||
"hammond_organ": "Hammond organ",
|
||||
"synthesizer": "Sintetizator",
|
||||
"sampler": "Sampler",
|
||||
"harpsichord": "Harfura",
|
||||
"percussion": "Percuzija",
|
||||
"drum_kit": "Set bubnjeva",
|
||||
"drum_machine": "Mašina za bubnjeve",
|
||||
"drum": "Bubanj",
|
||||
"snare_drum": "Bubanj sa zavojima",
|
||||
"rimshot": "Rimshot",
|
||||
"drum_roll": "Bubanj za roliranje",
|
||||
"bass_drum": "Bubanj za bas",
|
||||
"timpani": "Timpani",
|
||||
"tabla": "Tabla",
|
||||
"cymbal": "Cimbale",
|
||||
"hi_hat": "Hi-Hat",
|
||||
"wood_block": "Drveni blok",
|
||||
"tambourine": "Tamburina",
|
||||
"maraca": "Maraka",
|
||||
"gong": "Gong",
|
||||
"tubular_bells": "Cijevasti zvoni",
|
||||
"mallet_percussion": "Percusija s mljevima",
|
||||
"marimba": "Marimba",
|
||||
"glockenspiel": "Glockenspiel",
|
||||
"vibraphone": "Vibrafon",
|
||||
"steelpan": "Stelpan",
|
||||
"orchestra": "Orkestar",
|
||||
"brass_instrument": "Bronski instrument",
|
||||
"french_horn": "Francuski rog",
|
||||
"trumpet": "Truba",
|
||||
"trombone": "Trombon",
|
||||
"bowed_string_instrument": "Užadno strunski instrument",
|
||||
"string_section": "Strunski sekcija",
|
||||
"violin": "Violina",
|
||||
"pizzicato": "Pizzicato",
|
||||
"cello": "Celula",
|
||||
"double_bass": "Dvostruki bas",
|
||||
"wind_instrument": "Vjetreni instrument",
|
||||
"flute": "Flauta",
|
||||
"saxophone": "Saksafon",
|
||||
"clarinet": "Klarinet",
|
||||
"harp": "Harfa",
|
||||
"bell": "Zvono",
|
||||
"church_bell": "Crkveno zvono",
|
||||
"jingle_bell": "Zvono za igračke",
|
||||
"bicycle_bell": "Zvono za bicikl",
|
||||
"tuning_fork": "Zvučnik",
|
||||
"chime": "Zvono",
|
||||
"wind_chime": "Vjetrenjac",
|
||||
"harmonica": "Harmonika",
|
||||
"accordion": "Akkordon",
|
||||
"bagpipes": "Bogovina",
|
||||
"didgeridoo": "Didgeridoo",
|
||||
"theremin": "Teremin",
|
||||
"singing_bowl": "Pjevni čaša",
|
||||
"scratching": "Skrečing",
|
||||
"pop_music": "Pop muzika",
|
||||
"hip_hop_music": "Hip-Hop muzika",
|
||||
"beatboxing": "Bitboksing",
|
||||
"rock_music": "Rock muzika",
|
||||
"heavy_metal": "Heavy metal",
|
||||
"punk_rock": "Punk rock",
|
||||
"grunge": "Grandž",
|
||||
"progressive_rock": "Progressivni rock",
|
||||
"rock_and_roll": "Rock and roll",
|
||||
"psychedelic_rock": "Psihederički rock",
|
||||
"rhythm_and_blues": "Ritam i blues",
|
||||
"soul_music": "Soul glazba",
|
||||
"reggae": "Rege",
|
||||
"country": "Kantri",
|
||||
"swing_music": "Swing glazba",
|
||||
"bluegrass": "Bluegrass",
|
||||
"funk": "Fank",
|
||||
"folk_music": "Folklorno glazba",
|
||||
"middle_eastern_music": "Glazba Bliskog istoka",
|
||||
"jazz": "Džez",
|
||||
"disco": "Disko",
|
||||
"classical_music": "Klasična glazba",
|
||||
"opera": "Opera",
|
||||
"electronic_music": "Elektronska glazba",
|
||||
"house_music": "House glazba",
|
||||
"techno": "Tehno",
|
||||
"dubstep": "Dubstep",
|
||||
"drum_and_bass": "Drum i bass",
|
||||
"electronica": "Elektronika",
|
||||
"electronic_dance_music": "Elektronska plesna glazba",
|
||||
"ambient_music": "Ambient glazba",
|
||||
"trance_music": "Trance glazba",
|
||||
"music_of_latin_america": "Glazba Latinske Amerike",
|
||||
"salsa_music": "Salsa glazba",
|
||||
"flamenco": "Flamenko",
|
||||
"blues": "Bluz",
|
||||
"music_for_children": "Muzika za djecu",
|
||||
"new-age_music": "Muzika novog doba",
|
||||
"vocal_music": "Vokalna muzika",
|
||||
"a_capella": "A Capella",
|
||||
"music_of_africa": "Afrička muzika",
|
||||
"afrobeat": "Afrobeat",
|
||||
"christian_music": "Kršćanska muzika",
|
||||
"gospel_music": "Gospel muzika",
|
||||
"music_of_asia": "Azijatska muzika",
|
||||
"carnatic_music": "Karnatička muzika",
|
||||
"music_of_bollywood": "Bollywood muzika",
|
||||
"ska": "Ska",
|
||||
"traditional_music": "Tradicionalna muzika",
|
||||
"independent_music": "Nezavisna muzika",
|
||||
"song": "Pjesma",
|
||||
"background_music": "Pozadinska muzika",
|
||||
"theme_music": "Tema muzika",
|
||||
"jingle": "Jingle",
|
||||
"soundtrack_music": "Soundtrack muzika",
|
||||
"lullaby": "Pjesma za uspavanje",
|
||||
"video_game_music": "Muzika za video igre",
|
||||
"christmas_music": "Božićna muzika",
|
||||
"dance_music": "Dance muzika",
|
||||
"wedding_music": "Venčanska glazba",
|
||||
"happy_music": "Sretna glazba",
|
||||
"sad_music": "Tužna glazba",
|
||||
"tender_music": "Tenderna glazba",
|
||||
"exciting_music": "Uzbudljiva glazba",
|
||||
"angry_music": "Zlobna glazba",
|
||||
"scary_music": "Strašna glazba",
|
||||
"wind": "Vjetar",
|
||||
"rustling_leaves": "Šum listova",
|
||||
"wind_noise": "Šum vjetra",
|
||||
"thunderstorm": "Grmljavina",
|
||||
"thunder": "Grmljavac",
|
||||
"water": "Voda",
|
||||
"rain": "Kisa",
|
||||
"raindrop": "Kap kise",
|
||||
"rain_on_surface": "Kisa na površini",
|
||||
"stream": "Tok",
|
||||
"waterfall": "Padina",
|
||||
"ocean": "Okean",
|
||||
"waves": "Valovi",
|
||||
"steam": "Par",
|
||||
"gurgling": "Gurkanje",
|
||||
"fire": "Vatra",
|
||||
"crackle": "Krik",
|
||||
"vehicle": "Vozilo",
|
||||
"boat": "Brod",
|
||||
"sailboat": "Jedrilica",
|
||||
"rowboat": "Čamac",
|
||||
"motorboat": "Motorni čamac",
|
||||
"ship": "Brod",
|
||||
"motor_vehicle": "Motorno vozilo",
|
||||
"car": "Automobil",
|
||||
"toot": "Zvuk klaksona",
|
||||
"car_alarm": "Automobilski alarm",
|
||||
"power_windows": "Električna prozora",
|
||||
"skidding": "Klizanje",
|
||||
"tire_squeal": "Krik kotača",
|
||||
"car_passing_by": "Automobil prolazi",
|
||||
"race_car": "Racing automobil",
|
||||
"truck": "Kamion",
|
||||
"air_brake": "Vazdušni kočnici",
|
||||
"air_horn": "Vazdušni signal",
|
||||
"reversing_beeps": "Zvukovi za odlazak unazad",
|
||||
"ice_cream_truck": "Kamion za sladoled",
|
||||
"bus": "Autobus",
|
||||
"emergency_vehicle": "Hitni vozilo",
|
||||
"police_car": "Policijski automobil",
|
||||
"ambulance": "Ambulansa",
|
||||
"fire_engine": "Pogonski automobil",
|
||||
"motorcycle": "Motocikl",
|
||||
"traffic_noise": "Prometni šum",
|
||||
"rail_transport": "Željeznički transport",
|
||||
"train": "Vlak",
|
||||
"train_whistle": "Vlakovni svirac",
|
||||
"train_horn": "Vlakovni rohorn",
|
||||
"railroad_car": "Željeznički vagon",
|
||||
"train_wheels_squealing": "Vlakove točkove koje zavijaju",
|
||||
"subway": "Metropolitena",
|
||||
"aircraft": "Avion",
|
||||
"aircraft_engine": "Avionski motor",
|
||||
"jet_engine": "Reaktivni motor",
|
||||
"propeller": "Vijak",
|
||||
"helicopter": "Heličopter",
|
||||
"fixed-wing_aircraft": "Avion s krilima",
|
||||
"skateboard": "Skejtbord",
|
||||
"engine": "Motor",
|
||||
"light_engine": "Lagani motor",
|
||||
"dental_drill's_drill": "Stomatološki bušilica",
|
||||
"lawn_mower": "Kosilica",
|
||||
"chainsaw": "Pilica",
|
||||
"medium_engine": "Srednji motor",
|
||||
"heavy_engine": "Teški motor",
|
||||
"engine_knocking": "Kloping motora",
|
||||
"engine_starting": "Pokretanje motora",
|
||||
"idling": "Miris",
|
||||
"accelerating": "Ubrzavanje",
|
||||
"door": "Vrata",
|
||||
"doorbell": "Zvonce",
|
||||
"ding-dong": "Ding-dong",
|
||||
"sliding_door": "Klizna vrata",
|
||||
"slam": "Zatvaranje",
|
||||
"knock": "Kucanje",
|
||||
"tap": "Kucanje",
|
||||
"squeak": "Krik",
|
||||
"cupboard_open_or_close": "Otvorenje ili zatvaranje police",
|
||||
"drawer_open_or_close": "Otvorenje ili zatvaranje vunca",
|
||||
"dishes": "Posuđe",
|
||||
"cutlery": "Posuđe za jelo",
|
||||
"chopping": "Rezanje",
|
||||
"frying": "Praženje",
|
||||
"microwave_oven": "Mikrotalasna pećnica",
|
||||
"blender": "Miksere",
|
||||
"water_tap": "Kran",
|
||||
"sink": "Lavabo",
|
||||
"bathtub": "Kupatilo",
|
||||
"hair_dryer": "Sušilac za kosu",
|
||||
"toilet_flush": "Očišćavanje toaleta",
|
||||
"toothbrush": "Šetka za zube",
|
||||
"electric_toothbrush": "Električna šetka za zube",
|
||||
"vacuum_cleaner": "Praškoljac",
|
||||
"zipper": "Zatvarac",
|
||||
"keys_jangling": "Ključevi koji se škripi",
|
||||
"coin": "Novčanik",
|
||||
"scissors": "Škare",
|
||||
"electric_shaver": "Električni šavac",
|
||||
"shuffling_cards": "Premještanje karata",
|
||||
"typing": "Kucanje",
|
||||
"typewriter": "Tipkovnica",
|
||||
"computer_keyboard": "Računalna tipkovnica",
|
||||
"writing": "Pisanje",
|
||||
"alarm": "Alarm",
|
||||
"telephone": "Telefon",
|
||||
"telephone_bell_ringing": "Zvono telefona",
|
||||
"ringtone": "Ton za poziv",
|
||||
"telephone_dialing": "Pozivanje telefona",
|
||||
"dial_tone": "Ton za poziv",
|
||||
"busy_signal": "Signal zauzetosti",
|
||||
"alarm_clock": "Budilica",
|
||||
"siren": "Sirena",
|
||||
"civil_defense_siren": "Sirena za civilnu zaštitu",
|
||||
"buzzer": "Buzer",
|
||||
"smoke_detector": "Detektor dima",
|
||||
"fire_alarm": "Pozar alarm",
|
||||
"foghorn": "Mlazni svirac",
|
||||
"whistle": "Štiklja",
|
||||
"steam_whistle": "Parni zvono",
|
||||
"mechanisms": "Mehanizmi",
|
||||
"ratchet": "Ratchet",
|
||||
"clock": "Sat",
|
||||
"tick": "Tik",
|
||||
"tick-tock": "Tik-tak",
|
||||
"gears": "Zupčanici",
|
||||
"pulleys": "Koturači",
|
||||
"sewing_machine": "Šitna mašina",
|
||||
"mechanical_fan": "Mehanički ventilator",
|
||||
"air_conditioning": "Klima uređaj",
|
||||
"cash_register": "Gotovinska kasica",
|
||||
"printer": "Štampač",
|
||||
"camera": "Kamera",
|
||||
"single-lens_reflex_camera": "Kamera s jednim objektivom",
|
||||
"tools": "Alati",
|
||||
"hammer": "Klubica",
|
||||
"jackhammer": "Betonomijak",
|
||||
"sawing": "Sečenje",
|
||||
"filing": "Flešanje",
|
||||
"sanding": "Šljokanje",
|
||||
"power_tool": "Električni alat",
|
||||
"drill": "Bušilica",
|
||||
"explosion": "Eksplozija",
|
||||
"gunshot": "Pucanj",
|
||||
"machine_gun": "Automatska puška",
|
||||
"fusillade": "Fusiladža",
|
||||
"artillery_fire": "Pucanj topovima",
|
||||
"cap_gun": "Pistolj za pucanje",
|
||||
"fireworks": "Pucanje svjetiljki",
|
||||
"firecracker": "Svjetiljka",
|
||||
"burst": "Izbič",
|
||||
"eruption": "Eruptija",
|
||||
"boom": "Tutnjava",
|
||||
"wood": "Drvo",
|
||||
"chop": "Rezanje",
|
||||
"splinter": "Razlomak",
|
||||
"crack": "Klackanje",
|
||||
"glass": "Staklo",
|
||||
"chink": "Prozor",
|
||||
"shatter": "Razbijanje",
|
||||
"silence": "Tišina",
|
||||
"sound_effect": "Zvučni efekt",
|
||||
"environmental_noise": "Okolišni šum",
|
||||
"static": "Statički šum",
|
||||
"white_noise": "Bijeli šum",
|
||||
"pink_noise": "Rumeni šum",
|
||||
"television": "Televizija",
|
||||
"radio": "Radio",
|
||||
"field_recording": "Snimka na terenu",
|
||||
"scream": "Vrisak",
|
||||
"sodeling": "Sodeling",
|
||||
"chird": "Chird",
|
||||
"change_ringing": "Promjena zvona",
|
||||
"shofar": "Šofar",
|
||||
"liquid": "Tekućina",
|
||||
"splash": "Pljuskanje",
|
||||
"slosh": "Sloš",
|
||||
"squish": "Škripanje",
|
||||
"drip": "Kapanje",
|
||||
"pour": "Prelivanje",
|
||||
"trickle": "Tijek",
|
||||
"gush": "Gusenje",
|
||||
"fill": "Popunjavanje",
|
||||
"spray": "Sprajanje",
|
||||
"pump": "Pumpa",
|
||||
"stir": "Miješanje",
|
||||
"boiling": "Vrećenje",
|
||||
"sonar": "Sonar",
|
||||
"arrow": "Strela",
|
||||
"whoosh": "Šum",
|
||||
"thump": "Tupanje",
|
||||
"thunk": "Tunk",
|
||||
"electronic_tuner": "Elektronski tuner",
|
||||
"effects_unit": "Jedinica efekata",
|
||||
"chorus_effect": "Efekt korusa",
|
||||
"basketball_bounce": "Košarkaški skok",
|
||||
"bang": "Bum",
|
||||
"slap": "Pljeska",
|
||||
"whack": "Perc",
|
||||
"smash": "Sprem",
|
||||
"breaking": "Raskidanje",
|
||||
"bouncing": "Skakanje",
|
||||
"whip": "Škripanje",
|
||||
"flap": "Klizanje",
|
||||
"scratch": "Oštećenje",
|
||||
"scrape": "Prašenje",
|
||||
"rub": "Trenje",
|
||||
"roll": "Kotrljanje",
|
||||
"crushing": "Stiskanje",
|
||||
"crumpling": "Sklapanje",
|
||||
"tearing": "Raskidanje",
|
||||
"beep": "Bip",
|
||||
"ping": "Poziv",
|
||||
"ding": "Ding",
|
||||
"clang": "Zveket",
|
||||
"squeal": "Cika",
|
||||
"creak": "Škripa",
|
||||
"rustle": "Šuškanje",
|
||||
"whir": "Brujanje",
|
||||
"clatter": "Tropot",
|
||||
"sizzle": "Šištanje",
|
||||
"clicking": "Klikanje",
|
||||
"clickety_clack": "Klik-tak",
|
||||
"rumble": "Rumbljanje",
|
||||
"plop": "Pljus",
|
||||
"hum": "Pjevušenje",
|
||||
"zing": "Zing",
|
||||
"boing": "Boing",
|
||||
"crunch": "Crunch",
|
||||
"sine_wave": "Sinusna valna",
|
||||
"harmonic": "Harmonični",
|
||||
"chirp_tone": "Tanjirasti ton",
|
||||
"pulse": "Impuls",
|
||||
"inside": "Unutra",
|
||||
"outside": "Van",
|
||||
"reverberation": "Reverberacija",
|
||||
"echo": "Odjek",
|
||||
"noise": "Šum",
|
||||
"mains_hum": "Glavni šum",
|
||||
"distortion": "Distorzija",
|
||||
"sidetone": "Sidetone",
|
||||
"cacophony": "Kacofonija",
|
||||
"throbbing": "Tremor",
|
||||
"vibration": "Vibracija"
|
||||
}
|
||||
326
web/public/locales/bs/common.json
Normal file
326
web/public/locales/bs/common.json
Normal file
@ -0,0 +1,326 @@
|
||||
{
|
||||
"time": {
|
||||
"untilForTime": "Do {{time}}",
|
||||
"untilForRestart": "Do ponovnog pokretanja Frigate.",
|
||||
"untilRestart": "Do ponovnog pokretanja",
|
||||
"never": "Nikad",
|
||||
"ago": "{{timeAgo}} prije",
|
||||
"justNow": "Sada",
|
||||
"today": "Danas",
|
||||
"yesterday": "Jučer",
|
||||
"last7": "Prošlih 7 dana",
|
||||
"last14": "Prošlih 14 dana",
|
||||
"last30": "Prošlih 30 dana",
|
||||
"thisWeek": "Ova sedmica",
|
||||
"lastWeek": "Prošla sedmica",
|
||||
"thisMonth": "Ovaj mjesec",
|
||||
"lastMonth": "Prošli mjesec",
|
||||
"5minutes": "5 minuta",
|
||||
"10minutes": "10 minuta",
|
||||
"30minutes": "30 minuta",
|
||||
"1hour": "1 sat",
|
||||
"12hours": "12 sati",
|
||||
"24hours": "24 sata",
|
||||
"pm": "posle podne",
|
||||
"am": "pre podne",
|
||||
"yr": "{{time}} god",
|
||||
"year_one": "{{time}} godina",
|
||||
"year_few": "{{time}} godine",
|
||||
"year_other": "{{time}} godina",
|
||||
"mo": "{{time}} mjes",
|
||||
"month_one": "{{time}} mjesec",
|
||||
"month_few": "{{time}} mjeseca",
|
||||
"month_other": "{{time}} mjeseci",
|
||||
"d": "{{time}}d",
|
||||
"day_one": "{{time}} dan",
|
||||
"day_few": "{{time}} dana",
|
||||
"day_other": "{{time}} dana",
|
||||
"h": "{{time}}h",
|
||||
"hour_one": "{{time}} sat",
|
||||
"hour_few": "{{time}} sata",
|
||||
"hour_other": "{{time}} sati",
|
||||
"m": "{{time}}m",
|
||||
"minute_one": "{{time}} minuta",
|
||||
"minute_few": "{{time}} minute",
|
||||
"minute_other": "{{time}} minuta",
|
||||
"s": "{{time}}s",
|
||||
"second_one": "{{time}} sekunda",
|
||||
"second_few": "{{time}} sekunde",
|
||||
"second_other": "{{time}} sekundi",
|
||||
"formattedTimestamp": {
|
||||
"12hour": "MMM d, h:mm:ss aaa",
|
||||
"24hour": "MMM d, HH:mm:ss"
|
||||
},
|
||||
"formattedTimestamp2": {
|
||||
"12hour": "MM/dd h:mm:ssa",
|
||||
"24hour": "d MMM HH:mm:ss"
|
||||
},
|
||||
"formattedTimestampHourMinute": {
|
||||
"12hour": "h:mm aaa",
|
||||
"24hour": "HH:mm"
|
||||
},
|
||||
"formattedTimestampHourMinuteSecond": {
|
||||
"12hour": "h:mm:ss aaa",
|
||||
"24hour": "HH:mm:ss"
|
||||
},
|
||||
"formattedTimestampMonthDayHourMinute": {
|
||||
"12hour": "MMM d, h:mm aaa",
|
||||
"24hour": "MMM d, HH:mm"
|
||||
},
|
||||
"formattedTimestampMonthDayYear": {
|
||||
"12hour": "MMM d, yyyy",
|
||||
"24hour": "MMM d, yyyy"
|
||||
},
|
||||
"formattedTimestampMonthDayYearHourMinute": {
|
||||
"12hour": "MMM d yyyy, h:mm aaa",
|
||||
"24hour": "MMM d yyyy, HH:mm"
|
||||
},
|
||||
"formattedTimestampMonthDay": "MMM d",
|
||||
"formattedTimestampFilename": {
|
||||
"12hour": "MM-dd-yy-h-mm-ss-a",
|
||||
"24hour": "MM-dd-yy-HH-mm-ss"
|
||||
},
|
||||
"inProgress": "U toku",
|
||||
"invalidStartTime": "Neispravno početno vrijeme",
|
||||
"invalidEndTime": "Neispravno krajnje vrijeme"
|
||||
},
|
||||
"unit": {
|
||||
"speed": {
|
||||
"mph": "mph",
|
||||
"kph": "kph"
|
||||
},
|
||||
"length": {
|
||||
"feet": "fut",
|
||||
"meters": "metar"
|
||||
},
|
||||
"data": {
|
||||
"kbps": "kB/s",
|
||||
"mbps": "MB/s",
|
||||
"gbps": "GB/s",
|
||||
"kbph": "kB/hour",
|
||||
"mbph": "MB/hour",
|
||||
"gbph": "GB/hour"
|
||||
}
|
||||
},
|
||||
"label": {
|
||||
"back": "Povratak",
|
||||
"hide": "Sakrij {{item}}",
|
||||
"show": "Prikaži {{item}}",
|
||||
"ID": "ID",
|
||||
"none": "Nijedan",
|
||||
"all": "Sve",
|
||||
"other": "Ostalo"
|
||||
},
|
||||
"list": {
|
||||
"two": "{{0}} i {{1}}",
|
||||
"many": "{{items}}, i {{last}}",
|
||||
"separatorWithSpace": ", "
|
||||
},
|
||||
"field": {
|
||||
"optional": "Opcionalno",
|
||||
"internalID": "Unutarnji ID koji Frigate koristi u konfiguraciji i bazi podataka"
|
||||
},
|
||||
"button": {
|
||||
"add": "Dodaj",
|
||||
"apply": "Primijeni",
|
||||
"applying": "Primjenjuje se…",
|
||||
"reset": "Resetuj",
|
||||
"undo": "Poništi",
|
||||
"done": "Gotovo",
|
||||
"enabled": "Omogućeno",
|
||||
"enable": "Omogući",
|
||||
"disabled": "Onemogućeno",
|
||||
"disable": "Onemogući",
|
||||
"save": "Sačuvaj",
|
||||
"saving": "Sačuvanje…",
|
||||
"cancel": "Otkaži",
|
||||
"close": "Zatvori",
|
||||
"copy": "Kopiraj",
|
||||
"copiedToClipboard": "Kopirano u međuspremnik",
|
||||
"back": "Nazad",
|
||||
"history": "Historija",
|
||||
"fullscreen": "Pun ekran",
|
||||
"exitFullscreen": "Napusti pun ekran",
|
||||
"pictureInPicture": "Slika u slici",
|
||||
"twoWayTalk": "Dvostrani razgovor",
|
||||
"cameraAudio": "Zvuk kamere",
|
||||
"on": "Uključeno",
|
||||
"off": "Isključeno",
|
||||
"edit": "Uredi",
|
||||
"copyCoordinates": "Kopiraj koordinate",
|
||||
"delete": "Obriši",
|
||||
"yes": "Da",
|
||||
"no": "Ne",
|
||||
"download": "Preuzmi",
|
||||
"info": "Informacija",
|
||||
"suspended": "Otkazano",
|
||||
"unsuspended": "Ponovi",
|
||||
"play": "Reproduciraj",
|
||||
"unselect": "Odznači",
|
||||
"export": "Izvoz",
|
||||
"deleteNow": "Obriši sada",
|
||||
"next": "Sljedeće",
|
||||
"continue": "Nastavi",
|
||||
"modified": "Izmijenjeno",
|
||||
"overridden": "Preklopljeno",
|
||||
"resetToGlobal": "Vrati na globalno",
|
||||
"resetToDefault": "Vrati na podrazumijevano",
|
||||
"saveAll": "Sačuvaj sve",
|
||||
"savingAll": "Sačuvanje svih…",
|
||||
"undoAll": "Poništi sve",
|
||||
"retry": "Pokušaj ponovno"
|
||||
},
|
||||
"menu": {
|
||||
"system": "Sistem",
|
||||
"systemMetrics": "Sistem metrike",
|
||||
"configuration": "Konfiguracija",
|
||||
"systemLogs": "Sistemski zapisi",
|
||||
"profiles": "Profili",
|
||||
"settings": "Postavke",
|
||||
"configurationEditor": "Uređivač konfiguracije",
|
||||
"languages": "Jezici",
|
||||
"language": {
|
||||
"en": "Engleski (English)",
|
||||
"es": "Španjolski (Spanish)",
|
||||
"zhCN": "Jednostavni kineski (Simplified Chinese)",
|
||||
"hi": "Hindi (Hindi)",
|
||||
"fr": "Francuski (French)",
|
||||
"ar": "Arapski (Arabic)",
|
||||
"pt": "Portugalski (Portuguese)",
|
||||
"ptBR": "Portugalski brazilski (Brazilian Portuguese)",
|
||||
"ru": "Ruski (Russian)",
|
||||
"de": "Nemački (German)",
|
||||
"ja": "Japanski (Japanese)",
|
||||
"tr": "Turski (Turkish)",
|
||||
"it": "Talijanski (Italian)",
|
||||
"nl": "Nizozemski (Dutch)",
|
||||
"sv": "Švedski (Swedish)",
|
||||
"cs": "Češki (Czech)",
|
||||
"nb": "Norveški bokmål (Norwegian Bokmål)",
|
||||
"ko": "Koreanski (Korean)",
|
||||
"vi": "Vietnamski (Vietnamese)",
|
||||
"fa": "Perzijski (Persian)",
|
||||
"pl": "Polski (Poljski)",
|
||||
"uk": "Українська (Ukrajinski)",
|
||||
"he": "עברית (Hebrejski)",
|
||||
"el": "Ελληνικά (Grčki)",
|
||||
"ro": "Română (Romunski)",
|
||||
"hu": "Magyar (Mađarski)",
|
||||
"fi": "Suomi (Finski)",
|
||||
"da": "Dansk (Danski)",
|
||||
"sk": "Slovenčina (Slovački)",
|
||||
"yue": "粵語 (Kantonski)",
|
||||
"th": "ไทย (Tajski)",
|
||||
"ca": "Català (Katalonski)",
|
||||
"hr": "Hrvatski (Hrvatski)",
|
||||
"sr": "Српски (Srpski)",
|
||||
"sl": "Slovenščina (Slovenski)",
|
||||
"lt": "Lietuvių (Lietuvių)",
|
||||
"bg": "Български (Bugarinski)",
|
||||
"gl": "Galego (Galicijski)",
|
||||
"id": "Bahasa Indonesia (Indoneziski)",
|
||||
"ur": "اردو (Urdu)",
|
||||
"withSystem": {
|
||||
"label": "Koristite postavke sistema za jezik"
|
||||
}
|
||||
},
|
||||
"appearance": "Izgled",
|
||||
"darkMode": {
|
||||
"label": "Tamni režim",
|
||||
"light": "Svijetla",
|
||||
"dark": "Tamna",
|
||||
"withSystem": {
|
||||
"label": "Koristite postavke sistema za svjetlosni ili tamni režim"
|
||||
}
|
||||
},
|
||||
"withSystem": "Sistem",
|
||||
"theme": {
|
||||
"label": "Tema",
|
||||
"blue": "Plava",
|
||||
"green": "Zelena",
|
||||
"nord": "Nord",
|
||||
"red": "Crvena",
|
||||
"highcontrast": "Visok kontrast",
|
||||
"default": "Zadano"
|
||||
},
|
||||
"help": "Pomoć",
|
||||
"documentation": {
|
||||
"title": "Dokumentacija",
|
||||
"label": "Dokumentacija za Frigate"
|
||||
},
|
||||
"restart": "Ponovno pokreni Frigate",
|
||||
"live": {
|
||||
"title": "Uživo",
|
||||
"allCameras": "Sve Kamere",
|
||||
"cameras": {
|
||||
"title": "Kamere",
|
||||
"count_one": "{{count}} Kamera",
|
||||
"count_few": "{{count}} Kamere",
|
||||
"count_other": "{{count}} Kamere"
|
||||
}
|
||||
},
|
||||
"review": "Pregled",
|
||||
"explore": "Istraži",
|
||||
"export": "Izvoz",
|
||||
"actions": "Akcije",
|
||||
"uiPlayground": "UI Playground",
|
||||
"features": "Funkcije",
|
||||
"faceLibrary": "Biblioteka lica",
|
||||
"classification": "Klasifikacija",
|
||||
"chat": "Razgovor",
|
||||
"user": {
|
||||
"title": "Korisnik",
|
||||
"account": "Račun",
|
||||
"current": "Trenutni korisnik: {{user}}",
|
||||
"anonymous": "anons",
|
||||
"logout": "Odjava",
|
||||
"setPassword": "Postavi lozinku"
|
||||
}
|
||||
},
|
||||
"toast": {
|
||||
"copyUrlToClipboard": "URL kopiran u međuspremnik.",
|
||||
"save": {
|
||||
"title": "Sačuvaj",
|
||||
"error": {
|
||||
"title": "Nije uspješno sačuvana promjena konfiguracije: {{errorMessage}}",
|
||||
"noMessage": "Nije uspješno sačuvana promjena konfiguracije"
|
||||
},
|
||||
"success": "Uspješno sačuvana promjena konfiguracije."
|
||||
}
|
||||
},
|
||||
"role": {
|
||||
"title": "Uloga",
|
||||
"admin": "Administrator",
|
||||
"viewer": "Pregledač",
|
||||
"desc": "Admini imaju pun pristup svim funkcijama u korisničkom sučelju Frigate. Pregledači su ograničeni na pregled kamere, pregled stavki i povijesne snimke u korisničkom sučelju."
|
||||
},
|
||||
"pagination": {
|
||||
"label": "paginacija",
|
||||
"previous": {
|
||||
"title": "Prethodno",
|
||||
"label": "Idi na prethodnu stranicu"
|
||||
},
|
||||
"next": {
|
||||
"title": "Sljedeće",
|
||||
"label": "Idi na sljedeću stranicu"
|
||||
},
|
||||
"more": "Više stranica"
|
||||
},
|
||||
"accessDenied": {
|
||||
"documentTitle": "Pristup odbijen - Frigate",
|
||||
"title": "Pristup odbijen",
|
||||
"desc": "Nemate dozvolu za pregled ove stranice."
|
||||
},
|
||||
"notFound": {
|
||||
"documentTitle": "Nije pronađeno - Frigate",
|
||||
"title": "404",
|
||||
"desc": "Stranica nije pronađena"
|
||||
},
|
||||
"selectItem": "Odaberite {{item}}",
|
||||
"readTheDocumentation": "Pročitajte dokumentaciju",
|
||||
"information": {
|
||||
"pixels": "{{area}}px"
|
||||
},
|
||||
"no_items": "Nema stavki",
|
||||
"validation_errors": "Greške validacije"
|
||||
}
|
||||
16
web/public/locales/bs/components/auth.json
Normal file
16
web/public/locales/bs/components/auth.json
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"form": {
|
||||
"user": "Korisničko ime",
|
||||
"password": "Lozinka",
|
||||
"login": "Prijava",
|
||||
"firstTimeLogin": "Pokušavate se prijaviti prvi put? Vjerodajnice su ispisane u logovima Frigate.",
|
||||
"errors": {
|
||||
"usernameRequired": "Korisničko ime je obavezno",
|
||||
"passwordRequired": "Lozinka je obavezna",
|
||||
"rateLimit": "Premašen je limit brzine. Pokušajte kasnije.",
|
||||
"loginFailed": "Prijava nije uspješna",
|
||||
"unknownError": "Nepoznata greška. Provjerite zapise.",
|
||||
"webUnknownError": "Nepoznata greška. Provjerite konzolne zapise."
|
||||
}
|
||||
}
|
||||
}
|
||||
87
web/public/locales/bs/components/camera.json
Normal file
87
web/public/locales/bs/components/camera.json
Normal file
@ -0,0 +1,87 @@
|
||||
{
|
||||
"group": {
|
||||
"label": "Grupe kamere",
|
||||
"add": "Dodaj grupu kamere",
|
||||
"edit": "Uredi grupu kamera",
|
||||
"delete": {
|
||||
"label": "Obriši grupu kamere",
|
||||
"confirm": {
|
||||
"title": "Potvrdi brisanje",
|
||||
"desc": "Sigurno li želite da obrišete grupu kamere <em>{{name}}</em>?"
|
||||
}
|
||||
},
|
||||
"name": {
|
||||
"label": "Ime",
|
||||
"placeholder": "Unesite ime…",
|
||||
"errorMessage": {
|
||||
"mustLeastCharacters": "Ime grupe kamere mora imati najmanje 2 karaktera.",
|
||||
"exists": "Ime grupe kamere već postoji.",
|
||||
"nameMustNotPeriod": "Ime grupe kamere ne smije sadržavati tačku.",
|
||||
"invalid": "Neispravno ime grupe kamere."
|
||||
}
|
||||
},
|
||||
"cameras": {
|
||||
"label": "Kamere",
|
||||
"desc": "Odaberite kamere za ovu grupu."
|
||||
},
|
||||
"icon": "Ikona",
|
||||
"success": "Grupa kamere ({{name}}) je sačuvana.",
|
||||
"camera": {
|
||||
"birdseye": "Birdseye",
|
||||
"setting": {
|
||||
"label": "Postavke prenošenja kamere",
|
||||
"title": "Postavke prenošenja {{cameraName}}",
|
||||
"desc": "Promijenite opcije uživo prenošenja za tablicu upravljanja ove grupe kamere. <em>Ove postavke su specifične za uređaj/pretvarač.</em>",
|
||||
"audioIsAvailable": "Audio je dostupan za ovaj stream",
|
||||
"audioIsUnavailable": "Zvuk nije dostupan za ovaj tok",
|
||||
"audio": {
|
||||
"tips": {
|
||||
"title": "Audio mora biti izlaz iz vaše kamere i konfiguriran u go2rtc za ovaj stream."
|
||||
}
|
||||
},
|
||||
"stream": "Tok",
|
||||
"placeholder": "Odaberite tok",
|
||||
"streamMethod": {
|
||||
"label": "Način prenošenja",
|
||||
"placeholder": "Odaberite način prenošenja",
|
||||
"method": {
|
||||
"noStreaming": {
|
||||
"label": "Bez prenošenja",
|
||||
"desc": "Slike kamere će se ažurirati samo jednom na minut i neće se dogoditi uživo prenošenje."
|
||||
},
|
||||
"smartStreaming": {
|
||||
"label": "Pametno prenošenje (preporučeno)",
|
||||
"desc": "Pametno prenošenje će ažurirati sliku kamere jednom na minut kada se ne događa detektovana aktivnost kako bi se uštedjelo na širovini i resursima. Kada se detektuje aktivnost, slika se glatko prebacuje u uživo prenošenje."
|
||||
},
|
||||
"continuousStreaming": {
|
||||
"label": "Neprekidno prenošenje",
|
||||
"desc": {
|
||||
"title": "Slika kamere uvijek će biti živo prenošenje kada je vidljiva na ploči, čak i ako se ne detektira aktivnost.",
|
||||
"warning": "Neprekidno prenošenje može uzrokovati visoku upotrebu širine pojasa i probleme s performansama. Koristite s oprezom."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"compatibilityMode": {
|
||||
"label": "Režim kompatibilnosti",
|
||||
"desc": "Omogućite ovu opciju samo ako se živo prenošenje vaše kamere prikazuje s bojnim artefaktima i dijagonalnom linijom na desnoj strani slike."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"debug": {
|
||||
"options": {
|
||||
"label": "Postavke",
|
||||
"title": "Opcije",
|
||||
"showOptions": "Prikaži opcije",
|
||||
"hideOptions": "Sakrij opcije"
|
||||
},
|
||||
"boundingBox": "Okvir",
|
||||
"timestamp": "Vremenski pečat",
|
||||
"zones": "Zone",
|
||||
"mask": "Maska",
|
||||
"motion": "Kretanje",
|
||||
"regions": "Regije",
|
||||
"paths": "Putanje"
|
||||
}
|
||||
}
|
||||
197
web/public/locales/bs/components/dialog.json
Normal file
197
web/public/locales/bs/components/dialog.json
Normal file
@ -0,0 +1,197 @@
|
||||
{
|
||||
"restart": {
|
||||
"title": "Sigurni li ste da želite ponovno pokrenuti Frigate?",
|
||||
"description": "Ovo privremeno zaustavi Frigate dok se ponovno pokreće.",
|
||||
"button": "Ponovno pokretanje",
|
||||
"restarting": {
|
||||
"title": "Frigate se ponovo pokreće",
|
||||
"content": "Ova stranica će se ponovno učitati za {{countdown}} sekundi.",
|
||||
"button": "Silovito ponovno učitavanje sada"
|
||||
}
|
||||
},
|
||||
"explore": {
|
||||
"plus": {
|
||||
"submitToPlus": {
|
||||
"label": "Pošalji na Frigate+",
|
||||
"desc": "Predmeti u lokacijama koje želite izbjeći nisu lažni pozitivi. Pošiljanje ih kao lažne pozitive zbunjuje model."
|
||||
},
|
||||
"review": {
|
||||
"question": {
|
||||
"label": "Potvrdite ovu oznaku za Frigate Plus",
|
||||
"ask_a": "Je li ovaj objekt <code>{{label}}</code>?",
|
||||
"ask_an": "Je li ovaj objekt <code>{{label}}</code>?",
|
||||
"ask_full": "Je li ovaj objekt <code>{{untranslatedLabel}}</code> ({{translatedLabel}})?"
|
||||
},
|
||||
"state": {
|
||||
"submitted": "Pošlato"
|
||||
}
|
||||
}
|
||||
},
|
||||
"video": {
|
||||
"viewInHistory": "Pregledajte u povijesti"
|
||||
}
|
||||
},
|
||||
"export": {
|
||||
"time": {
|
||||
"fromTimeline": "Odaberite iz vremenske linije",
|
||||
"lastHour_one": "Prošli sat",
|
||||
"lastHour_few": "Prošla {{count}} sata",
|
||||
"lastHour_other": "Prošlih {{count}} sati",
|
||||
"custom": "Prilagođeno",
|
||||
"start": {
|
||||
"title": "Vrijeme početka",
|
||||
"label": "Odaberite vrijeme početka"
|
||||
},
|
||||
"end": {
|
||||
"title": "Vrijeme kraja",
|
||||
"label": "Odaberite vrijeme kraja"
|
||||
}
|
||||
},
|
||||
"name": {
|
||||
"placeholder": "Nazovite izvoz"
|
||||
},
|
||||
"case": {
|
||||
"newCaseOption": "Napravite novi slučaj",
|
||||
"newCaseNamePlaceholder": "Novo ime slučaja",
|
||||
"newCaseDescriptionPlaceholder": "Opis slučaja",
|
||||
"label": "Slučaj",
|
||||
"nonAdminHelp": "Za ove izvoze će se stvoriti novi slučaj.",
|
||||
"placeholder": "Odaberite slučaj"
|
||||
},
|
||||
"select": "Odaberite",
|
||||
"export": "Izvoz",
|
||||
"queueing": "Stavljanje izvoza u red...",
|
||||
"selectOrExport": "Odaberite ili izvozite",
|
||||
"tabs": {
|
||||
"export": "Jedna kamera",
|
||||
"multiCamera": "Više kamera"
|
||||
},
|
||||
"multiCamera": {
|
||||
"timeRange": "Vremenski opseg",
|
||||
"selectFromTimeline": "Odaberite iz vremenske linije",
|
||||
"cameraSelection": "Kamere",
|
||||
"cameraSelectionHelp": "Kamere s praćenim objektima u ovom vremenskom opsegu su preselektirane",
|
||||
"checkingActivity": "Provjeravamo aktivnost kamere...",
|
||||
"noCameras": "Nema dostupnih kamera",
|
||||
"detectionCount_one": "1 praćen objekt",
|
||||
"detectionCount_few": "{{count}} praćena objekta",
|
||||
"detectionCount_other": "{{count}} praćenih objekata",
|
||||
"nameLabel": "Ime izvoza",
|
||||
"namePlaceholder": "Nepovlačenje baznog imena za ove izvoze",
|
||||
"queueingButton": "Stavljanje izvoza u red...",
|
||||
"exportButton_one": "Izvoz 1 kamere",
|
||||
"exportButton_few": "Izvoz {{count}} kamere",
|
||||
"exportButton_other": "Izvoz {{count}} kamera"
|
||||
},
|
||||
"multi": {
|
||||
"title_one": "Izvoz 1 pregleda",
|
||||
"title_few": "Izvoz {{count}} pregleda",
|
||||
"title_other": "Izvoz {{count}} pregleda",
|
||||
"description": "Izvoz svakog odabranih pregleda. Svi izvozi bit će grupirani pod jedan slučaj.",
|
||||
"descriptionNoCase": "Izvoz svakog odabranih pregleda.",
|
||||
"caseNamePlaceholder": "Pregled izvoza - {{date}}",
|
||||
"exportButton_one": "Izvoz 1 pregleda",
|
||||
"exportButton_few": "Izvoz {{count}} pregleda",
|
||||
"exportButton_other": "Izvoz {{count}} pregleda",
|
||||
"exportingButton": "Izvoz...",
|
||||
"toast": {
|
||||
"started_one": "Pokrenut 1 izvoz. Otvaranje slučaja sada.",
|
||||
"started_few": "Pokrenuta {{count}} izvoza. Otvaranje slučaja sada.",
|
||||
"started_other": "Pokrenuto {{count}} izvoza. Otvaranje slučaja sada.",
|
||||
"startedNoCase_one": "Pokrenut 1 izvoz.",
|
||||
"startedNoCase_few": "Pokrenuta {{count}} izvoza.",
|
||||
"startedNoCase_other": "Pokrenuto {{count}} izvoza.",
|
||||
"partial": "Pokrenuto {{successful}} od {{total}} izvoza. Neuspješno: {{failedItems}}",
|
||||
"failed": "Neuspješno pokretanje {{total}} izvoza. Neuspješno: {{failedItems}}"
|
||||
}
|
||||
},
|
||||
"toast": {
|
||||
"success": "Uspješno pokrenut izvoz. Pregledajte datoteku na stranici izvoza.",
|
||||
"queued": "Izvoz u redu. Pregledajte napredak na stranici izvoza.",
|
||||
"view": "Pregled",
|
||||
"batchSuccess_one": "Pokrenut 1 izvoz. Otvaranje slučaja sada.",
|
||||
"batchSuccess_few": "Pokrenuta {{count}} izvoza. Otvaranje slučaja sada.",
|
||||
"batchSuccess_other": "Pokrenuto {{count}} izvoza. Otvaranje slučaja sada.",
|
||||
"batchPartial": "Pokrenuto {{successful}} od {{total}} izvoza. Neuspješne kamere: {{failedCameras}}",
|
||||
"batchFailed": "Neuspješno pokretanje {{total}} izvoza. Neuspješne kamere: {{failedCameras}}",
|
||||
"batchQueuedSuccess_one": "U red stavljen 1 izvoz. Otvaranje slučaja sada.",
|
||||
"batchQueuedSuccess_few": "U red stavljena {{count}} izvoza. Otvaranje slučaja sada.",
|
||||
"batchQueuedSuccess_other": "U red stavljeno {{count}} izvoza. Otvaranje slučaja sada.",
|
||||
"batchQueuedPartial": "U redu {{successful}} od {{total}} izvoza. Neuspješne kamere: {{failedCameras}}",
|
||||
"batchQueueFailed": "Neuspješno dodavanje {{total}} izvoza. Neuspješne kamere: {{failedCameras}}",
|
||||
"error": {
|
||||
"failed": "Neuspješno dodavanje izvoza: {{error}}",
|
||||
"endTimeMustAfterStartTime": "Krajnje vrijeme mora biti nakon početnog vremena",
|
||||
"noVaildTimeSelected": "Nije odabran valjan vremenski opseg"
|
||||
}
|
||||
},
|
||||
"fromTimeline": {
|
||||
"saveExport": "Sačuvaj izvoz",
|
||||
"queueingExport": "Kopiranje izvoza...",
|
||||
"previewExport": "Pregled izvoza",
|
||||
"useThisRange": "Koristi ovaj opseg"
|
||||
}
|
||||
},
|
||||
"streaming": {
|
||||
"label": "Tok",
|
||||
"restreaming": {
|
||||
"disabled": "Restreaming nije omogućeno za ovu kameru.",
|
||||
"desc": {
|
||||
"title": "Postavite go2rtc za dodatne opcije uživog pregleda i zvuk za ovu kameru."
|
||||
}
|
||||
},
|
||||
"showStats": {
|
||||
"label": "Prikaži statistiku strima",
|
||||
"desc": "Omogući ovu opciju da prikaže statistiku prijenosa kao preklapanje na toku kamere."
|
||||
},
|
||||
"debugView": "Pregled za otklanjanje grešaka"
|
||||
},
|
||||
"search": {
|
||||
"saveSearch": {
|
||||
"label": "Sačuvaj pretragu",
|
||||
"desc": "Navedite ime za ovu sačuvanu pretragu.",
|
||||
"placeholder": "Unesite ime za svoju pretragu",
|
||||
"overwrite": "{{searchName}} već postoji. Sačuvavanje će prebrisati postojet će vrijednost.",
|
||||
"success": "Pretraga ({{searchName}}) je sačuvana.",
|
||||
"button": {
|
||||
"save": {
|
||||
"label": "Sačuvaj ovu pretragu"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"recording": {
|
||||
"shareTimestamp": {
|
||||
"label": "Dijeli vremensku oznaku",
|
||||
"title": "Dijeli vremensku oznaku",
|
||||
"description": "Dijelite URL označen vremenom trenutne pozicije igrača ili odaberite prilagođenu vremensku oznaku. Napomena: ovo nije javni URL za dijeljenje i dostupan je samo korisnicima koji imaju pristup Frigate i ovoj kameri.",
|
||||
"custom": "Prilagođena vremenska oznaka",
|
||||
"button": "URL za dijeljenje vremenske oznake",
|
||||
"shareTitle": "Vremenska oznaka pregleda Frigate: {{camera}}"
|
||||
},
|
||||
"confirmDelete": {
|
||||
"title": "Potvrdi brisanje",
|
||||
"desc": {
|
||||
"selected": "Sigurni li ste da želite izbrisati sve snimljeno video povezano s ovim preglednim stavkom?<br /><br />Zadržite tipku <em>Shift</em> da biste preskočili ovaj dijalog u budućnosti."
|
||||
},
|
||||
"toast": {
|
||||
"success": "Video snimke povezane s odabranim preglednim stavcima uspješno su izbrisane.",
|
||||
"error": "Neuspješno brisanje: {{error}}"
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
"export": "Izvoz",
|
||||
"markAsReviewed": "Označi kao pregledano",
|
||||
"markAsUnreviewed": "Označi kao nepregledano",
|
||||
"deleteNow": "Obriši sada"
|
||||
}
|
||||
},
|
||||
"imagePicker": {
|
||||
"selectImage": "Odaberite minijaturu praćenog objekta",
|
||||
"unknownLabel": "Sačuvana slika izazivača",
|
||||
"search": {
|
||||
"placeholder": "Pretraga po oznaci ili podoznaci..."
|
||||
},
|
||||
"noImages": "Nema mini prikaza za ovu kameru"
|
||||
}
|
||||
}
|
||||
140
web/public/locales/bs/components/filter.json
Normal file
140
web/public/locales/bs/components/filter.json
Normal file
@ -0,0 +1,140 @@
|
||||
{
|
||||
"filter": "Filtar",
|
||||
"classes": {
|
||||
"label": "Klase",
|
||||
"all": {
|
||||
"title": "Sve klase"
|
||||
},
|
||||
"count_one": "{{count}} Klasa",
|
||||
"count_other": "{{count}} Klase"
|
||||
},
|
||||
"labels": {
|
||||
"label": "Oznake",
|
||||
"all": {
|
||||
"title": "Sve oznake",
|
||||
"short": "Oznake"
|
||||
},
|
||||
"count_one": "{{count}} Oznaka",
|
||||
"count_other": "{{count}} Oznake"
|
||||
},
|
||||
"zones": {
|
||||
"label": "Zone",
|
||||
"all": {
|
||||
"title": "Sve zone",
|
||||
"short": "Zone"
|
||||
}
|
||||
},
|
||||
"dates": {
|
||||
"selectPreset": "Odaberite predpostavku…",
|
||||
"all": {
|
||||
"title": "Svi datumi",
|
||||
"short": "Datumi"
|
||||
}
|
||||
},
|
||||
"more": "Više filtera",
|
||||
"reset": {
|
||||
"label": "Poništi filtere na zadane vrijednosti"
|
||||
},
|
||||
"timeRange": "Vremenski opseg",
|
||||
"subLabels": {
|
||||
"label": "Podoznake",
|
||||
"all": "Sve podoznake"
|
||||
},
|
||||
"attributes": {
|
||||
"label": "Atributi klasifikacije",
|
||||
"all": "Svi atributi"
|
||||
},
|
||||
"score": "Rezultat",
|
||||
"estimatedSpeed": "Procijenjena brzina ({{unit}})",
|
||||
"features": {
|
||||
"label": "Funkcije",
|
||||
"hasSnapshot": "Ima snimak",
|
||||
"hasVideoClip": "Ima video zapis",
|
||||
"submittedToFrigatePlus": {
|
||||
"label": "Predano Frigate+",
|
||||
"tips": "Prvo morate filtrirati prateće objekte koji imaju snimak.<br /><br />Prateći objekti bez snimka ne mogu se poslati na Frigate+."
|
||||
}
|
||||
},
|
||||
"sort": {
|
||||
"label": "Sortiraj",
|
||||
"dateAsc": "Datum (Uzlazno)",
|
||||
"dateDesc": "Datum (Silazno)",
|
||||
"scoreAsc": "Ocjena objekta (Uzlazno)",
|
||||
"scoreDesc": "Ocjena objekta (Silazno)",
|
||||
"speedAsc": "Procijenjena brzina (Uzlazno)",
|
||||
"speedDesc": "Procijenjena brzina (Silazno)",
|
||||
"relevance": "Relevantnost"
|
||||
},
|
||||
"cameras": {
|
||||
"label": "Filter kamere",
|
||||
"all": {
|
||||
"title": "Sve Kamere",
|
||||
"short": "Kamere"
|
||||
}
|
||||
},
|
||||
"review": {
|
||||
"showReviewed": "Prikaži pregledane"
|
||||
},
|
||||
"motion": {
|
||||
"showMotionOnly": "Prikaži samo pokret"
|
||||
},
|
||||
"explore": {
|
||||
"settings": {
|
||||
"title": "Postavke",
|
||||
"defaultView": {
|
||||
"title": "Zadani prikaz",
|
||||
"desc": "Kada nisu odabrani filteri, prikazuje se sažetak najnovijih pratećih objekata po oznaci, ili prikazuje se mreža bez filtriranja.",
|
||||
"summary": "Sažetak",
|
||||
"unfilteredGrid": "Mreža bez filtriranja"
|
||||
},
|
||||
"gridColumns": {
|
||||
"title": "Kolone mreže",
|
||||
"desc": "Odaberite broj kolona u prikazu mreže."
|
||||
},
|
||||
"searchSource": {
|
||||
"label": "Izvor pretrage",
|
||||
"desc": "Odaberite da li ćete pretraživati miniaturne slike ili opise vaših praćenih objekata.",
|
||||
"options": {
|
||||
"thumbnailImage": "Miniaturna slika",
|
||||
"description": "Opis"
|
||||
}
|
||||
}
|
||||
},
|
||||
"date": {
|
||||
"selectDateBy": {
|
||||
"label": "Odaberite datum za filtriranje"
|
||||
}
|
||||
}
|
||||
},
|
||||
"logSettings": {
|
||||
"label": "Filtrirajte nivo zapisa",
|
||||
"filterBySeverity": "Filtrirajte zapise prema ozbiljnosti",
|
||||
"loading": {
|
||||
"title": "Učitavanje",
|
||||
"desc": "Kada se panel zapisa pomakne do dna, novi zapisi automatski se prikazuju kada se dodaju."
|
||||
},
|
||||
"disableLogStreaming": "Onemogući praćenje zapisa",
|
||||
"allLogs": "Svi zapisi"
|
||||
},
|
||||
"trackedObjectDelete": {
|
||||
"title": "Potvrdi brisanje",
|
||||
"desc": "Brisanje ovih {{objectLength}} praćenih objekata uklanja snimku, bilo koje sačuvane ugradnje, i sve povezane uloge objekata. Snimljeni materijal ovih praćenih objekata u pogledu Historija <em>NEĆE</em> biti obrisan.<br /><br />Sigurni ste da želite nastaviti?<br /><br />Zadržite tipku <em>Shift</em> da biste preskočili ovaj dijalog u budućnosti.",
|
||||
"toast": {
|
||||
"success": "Praćeni objekti uspješno obrisani.",
|
||||
"error": "Neuspješno brisanje praćenih objekata: {{errorMessage}}"
|
||||
}
|
||||
},
|
||||
"zoneMask": {
|
||||
"filterBy": "Filtriraj po maski zone"
|
||||
},
|
||||
"recognizedLicensePlates": {
|
||||
"title": "Prepoznate tablice",
|
||||
"loadFailed": "Neuspješno učitavanje prepoznatih tablica.",
|
||||
"loading": "Učitavanje prepoznatih tablica…",
|
||||
"placeholder": "Unesite za pretragu tablica…",
|
||||
"noLicensePlatesFound": "Nema pronađenih tablica.",
|
||||
"selectPlatesFromList": "Odaberite jednu ili više tablica iz liste.",
|
||||
"selectAll": "Odaberite sve",
|
||||
"clearAll": "Očistite sve"
|
||||
}
|
||||
}
|
||||
8
web/public/locales/bs/components/icons.json
Normal file
8
web/public/locales/bs/components/icons.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"iconPicker": {
|
||||
"selectIcon": "Odaberite ikonu",
|
||||
"search": {
|
||||
"placeholder": "Pretražite ikonu…"
|
||||
}
|
||||
}
|
||||
}
|
||||
10
web/public/locales/bs/components/input.json
Normal file
10
web/public/locales/bs/components/input.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"button": {
|
||||
"downloadVideo": {
|
||||
"label": "Preuzimanje videa",
|
||||
"toast": {
|
||||
"success": "Vaš video stavke pregleda je započelo preuzimanje."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
52
web/public/locales/bs/components/player.json
Normal file
52
web/public/locales/bs/components/player.json
Normal file
@ -0,0 +1,52 @@
|
||||
{
|
||||
"noRecordingsFoundForThisTime": "Nisu pronađeni snimci za ovo vrijeme",
|
||||
"noPreviewFound": "Nije pronađen pregled",
|
||||
"noPreviewFoundFor": "Nije pronađen pregled za {{cameraName}}",
|
||||
"submitFrigatePlus": {
|
||||
"title": "Pošalji ovaj okvir Frigate+?",
|
||||
"submit": "Pošalji",
|
||||
"previewError": "Nije moguće učitati prikaz snimke. Snimka možda trenutno nije dostupna."
|
||||
},
|
||||
"livePlayerRequiredIOSVersion": "Za ovaj tip uživo prijenosa potreban je iOS 17.1 ili noviji.",
|
||||
"streamOffline": {
|
||||
"title": "Prijenos je offline",
|
||||
"desc": "Nisu primljeni okviri na {{cameraName}} <code>detect</code> prijenos, provjerite zapise o greškama"
|
||||
},
|
||||
"cameraDisabled": "Kamera je onemogućena",
|
||||
"stats": {
|
||||
"streamType": {
|
||||
"title": "Tip prijenosa:",
|
||||
"short": "Tip"
|
||||
},
|
||||
"bandwidth": {
|
||||
"title": "Širina pojasa:",
|
||||
"short": "Širina pojasa"
|
||||
},
|
||||
"latency": {
|
||||
"title": "Kasnjenje:",
|
||||
"value": "{{seconds}} sekundi",
|
||||
"short": {
|
||||
"title": "Kasnjenje",
|
||||
"value": "{{seconds}} sek"
|
||||
}
|
||||
},
|
||||
"totalFrames": "Ukupno okvira:",
|
||||
"droppedFrames": {
|
||||
"title": "Izgubljeni okviri:",
|
||||
"short": {
|
||||
"title": "Izgubljeni",
|
||||
"value": "{{droppedFrames}} okvira"
|
||||
}
|
||||
},
|
||||
"decodedFrames": "Dekodirani okviri:",
|
||||
"droppedFrameRate": "Stopa izgubljenih okvira:"
|
||||
},
|
||||
"toast": {
|
||||
"success": {
|
||||
"submittedFrigatePlus": "Uspješno je poslano okvir Frigate+"
|
||||
},
|
||||
"error": {
|
||||
"submitFrigatePlusFailed": "Neuspješno slanje okvira Frigate+"
|
||||
}
|
||||
}
|
||||
}
|
||||
949
web/public/locales/bs/config/cameras.json
Normal file
949
web/public/locales/bs/config/cameras.json
Normal file
@ -0,0 +1,949 @@
|
||||
{
|
||||
"label": "KameraKonfig",
|
||||
"zones": {
|
||||
"label": "Zone",
|
||||
"description": "Zona omogućava da definirate specifičnu područje okvira da biste odredili je li objekt unutar određenog područja.",
|
||||
"friendly_name": {
|
||||
"label": "Ime zone",
|
||||
"description": "Korisničko ime za zonu, prikazano u UI Frigate. Ako nije postavljeno, koristi se oblikovana verzija imena zone."
|
||||
},
|
||||
"enabled": {
|
||||
"label": "Omogućeno",
|
||||
"description": "Omogući ili onemogući ovu zonu. Onemogućene zone zanemaruju se tijekom izvršavanja."
|
||||
},
|
||||
"enabled_in_config": {
|
||||
"label": "Zapamti originalno stanje zone."
|
||||
},
|
||||
"filters": {
|
||||
"label": "Filtri zone",
|
||||
"description": "Filtri za primjenu na objekte unutar ove zone. Koriste se za smanjenje lažnih pozitiva ili ograničavanje kojih objekata se smatraju prisutnim u zoni.",
|
||||
"min_area": {
|
||||
"label": "Minimalna površina objekta",
|
||||
"description": "Minimalna površina okvira (pikseli ili postotak) potrebna za ovaj tip objekta. Može biti pikseli (cijeli broj) ili postotak (float između 0.000001 i 0.99)."
|
||||
},
|
||||
"max_area": {
|
||||
"label": "Maksimalna površina objekta",
|
||||
"description": "Maksimalna površina okvira (pikseli ili postotak) dozvoljena za ovaj tip objekta. Može biti pikseli (cijeli broj) ili postotak (float između 0.000001 i 0.99)."
|
||||
},
|
||||
"min_ratio": {
|
||||
"label": "Minimalni omjer visine/širine",
|
||||
"description": "Minimalni omjer širine/visine potreban da bi okvir bio prihvaćen."
|
||||
},
|
||||
"max_ratio": {
|
||||
"label": "Maksimalni omjer visine/širine",
|
||||
"description": "Maksimalni omjer širine/visine dozvoljen da bi okvir bio prihvaćen."
|
||||
},
|
||||
"threshold": {
|
||||
"label": "Prag pouzdanosti",
|
||||
"description": "Prosjek pragova pouzdanosti detekcije potreban da bi objekt bio smatravan pravim pozitivom."
|
||||
},
|
||||
"min_score": {
|
||||
"label": "Minimalna pouzdanost",
|
||||
"description": "Minimalna pouzdanost detekcije po okviru potrebna da bi objekt bio brojan."
|
||||
},
|
||||
"mask": {
|
||||
"label": "Maska filtriranja",
|
||||
"description": "Koordinate poligona koje definiraju područje na kojem se ovaj filter primjenjuje unutar okvira."
|
||||
},
|
||||
"raw_mask": {
|
||||
"label": "Ručna maska"
|
||||
}
|
||||
},
|
||||
"coordinates": {
|
||||
"label": "Koordinate",
|
||||
"description": "Koordinate poligona koje definiraju područje zone. Može biti niz razdvojen zarezom ili lista nizova koordinata. Koordinate trebaju biti relativne (0-1) ili apsolutne (stariji format)."
|
||||
},
|
||||
"distances": {
|
||||
"label": "Stvarne udaljenosti",
|
||||
"description": "Nepovlačni stvarne udaljenosti za svaku stranu kvadrilateralne zone, koristi se za izračun brzine ili udaljenosti. Moraju imati tačno 4 vrijednosti ako su postavljene."
|
||||
},
|
||||
"inertia": {
|
||||
"label": "Okviri inertnosti",
|
||||
"description": "Broj uzastopnih okvira u kojima mora biti detektovan objekt u zoni da bi bio smatravan prisutnim. Pomaže u filtriranju privremenih detekcija."
|
||||
},
|
||||
"loitering_time": {
|
||||
"label": "Sekunde loiteranja",
|
||||
"description": "Broj sekundi koje objekt mora ostati u zoni da bi bio smatravan loiteranjem. Postaviti na 0 za onemogućavanje detekcije loiteranja."
|
||||
},
|
||||
"speed_threshold": {
|
||||
"label": "Minimalna brzina",
|
||||
"description": "Minimalna brzina (u stvarnim jedinicama ako su udaljenosti postavljene) potrebna da bi objekt bio smatravan prisutnim u zoni. Koristi se za zone koje se aktiviraju na osnovu brzine."
|
||||
},
|
||||
"objects": {
|
||||
"label": "Objekti koji izazivaju",
|
||||
"description": "Lista tipova objekata (iz labelmapa) koji mogu izazvati ovu zonu. Može biti niz ili lista nizova. Ako je prazna, svi objekti se uzimaju u obzir."
|
||||
}
|
||||
},
|
||||
"name": {
|
||||
"label": "Ime kamere",
|
||||
"description": "Ime kamere je obavezno"
|
||||
},
|
||||
"friendly_name": {
|
||||
"label": "Prijateljsko ime",
|
||||
"description": "Prijateljsko ime kamere korišteno u korisničkom sučelju Frigate"
|
||||
},
|
||||
"enabled": {
|
||||
"label": "Omogućeno",
|
||||
"description": "Omogućeno"
|
||||
},
|
||||
"audio": {
|
||||
"label": "Audio događaji",
|
||||
"description": "Postavke za detekciju događaja temeljene na audio.",
|
||||
"enabled": {
|
||||
"label": "Omogući detekciju zvuka",
|
||||
"description": "Omogući ili onemogući detekciju događaja temeljenu na audio za ovu kameru."
|
||||
},
|
||||
"max_not_heard": {
|
||||
"label": "Vrijeme trajanja do kraja",
|
||||
"description": "Količina sekundi bez konfiguriranog tipa zvuka prije nego što se audio događaj završi."
|
||||
},
|
||||
"min_volume": {
|
||||
"label": "Minimalna zapremina",
|
||||
"description": "Minimalni prag RMS zapremine potreban za pokretanje detekcije zvuka; niže vrijednosti povećavaju osjetljivost (npr. 200 visoko, 500 srednje, 1000 nisko)."
|
||||
},
|
||||
"listen": {
|
||||
"label": "Tipovi slušanja",
|
||||
"description": "Popis tipova audio događaja za detekciju (npr. zavijanje, požarne zvona, vrisak, govorenje, vikanje)."
|
||||
},
|
||||
"filters": {
|
||||
"label": "Audio filteri",
|
||||
"description": "Postavke filtera po tipu zvuka kao što su pragovi pouzdanosti za smanjenje lažnih pozitiva."
|
||||
},
|
||||
"enabled_in_config": {
|
||||
"label": "Originalno stanje zvuka",
|
||||
"description": "Indikuje je li detekcija zvuka izvorno omogućena u statičkoj konfiguracijskoj datoteci."
|
||||
},
|
||||
"num_threads": {
|
||||
"label": "Dretve detekcije",
|
||||
"description": "Broj dretvi za korištenje za obradu detekcije zvuka."
|
||||
}
|
||||
},
|
||||
"audio_transcription": {
|
||||
"label": "Transkripcija zvuka",
|
||||
"description": "Postavke za transkripciju živog i govornog zvuka korištenih za događaje i žive podnaslove.",
|
||||
"enabled": {
|
||||
"label": "Omogući transkripciju",
|
||||
"description": "Omogući ili onemogući transkripciju audio događaja pokrenutu ručno."
|
||||
},
|
||||
"enabled_in_config": {
|
||||
"label": "Originalni stanje transkripcije"
|
||||
},
|
||||
"live_enabled": {
|
||||
"label": "Uživo transkripcija",
|
||||
"description": "Omogući streaming uživo transkripcije za audio dok se prima."
|
||||
}
|
||||
},
|
||||
"birdseye": {
|
||||
"label": "Birdseye",
|
||||
"description": "Postavke za sastavni prikaz Birdseye koji kombinuje više snimke kamere u jedinstveni raspored.",
|
||||
"enabled": {
|
||||
"label": "Omogući Birdseye",
|
||||
"description": "Omogući ili onemogući funkciju prikaza Birdseye."
|
||||
},
|
||||
"mode": {
|
||||
"label": "Način praćenja",
|
||||
"description": "Način uključivanja kamera u Birdseye: 'objekti', 'kretanje' ili 'kontinuirano'."
|
||||
},
|
||||
"order": {
|
||||
"label": "Pozicija",
|
||||
"description": "Numerička pozicija koja kontroliše redoslijed kamera u rasporedu Birdseye."
|
||||
}
|
||||
},
|
||||
"detect": {
|
||||
"label": "Detekcija objekata",
|
||||
"description": "Postavke za ulogu detekcije/detekcija koja se koristi za pokretanje detekcije objekata i inicijalizaciju praćenja.",
|
||||
"enabled": {
|
||||
"label": "Omogući detekciju objekata",
|
||||
"description": "Omogući ili onemogući detekciju objekata za ovu kameru."
|
||||
},
|
||||
"height": {
|
||||
"label": "Visina detekcije",
|
||||
"description": "Visina (pikseli) okvira korištenih za detekciju stream-a; ostavite prazno za korištenje originalne rezolucije stream-a."
|
||||
},
|
||||
"width": {
|
||||
"label": "Širina detekcije",
|
||||
"description": "Širina (pikseli) okvira korištenih za detekciju stream-a; ostavite prazno za korištenje originalne rezolucije stream-a."
|
||||
},
|
||||
"fps": {
|
||||
"label": "Detekcija FPS",
|
||||
"description": "Željeni broj okvira po sekundi za pokretanje detekcije; niže vrijednosti smanjuju upotrebu CPU-a (preporučena vrijednost je 5, postavite više - najviše 10 - samo ako praćite vrlo brze objekte)."
|
||||
},
|
||||
"min_initialized": {
|
||||
"label": "Minimalni broj okvira inicijalizacije",
|
||||
"description": "Broj uzastopnih detekcija potreban prije stvaranja praćenog objekta. Povećajte da biste smanjili lažne inicijalizacije. Zadana vrijednost je fps podijeljeno sa 2."
|
||||
},
|
||||
"max_disappeared": {
|
||||
"label": "Maksimalni broj okvira koji su nestali",
|
||||
"description": "Broj okvira bez detekcije prije nego što se praćeni objekt smatra izgubljenim."
|
||||
},
|
||||
"stationary": {
|
||||
"label": "Konfiguracija stacionarnih objekata",
|
||||
"description": "Postavke za detekciju i upravljanje objektima koji ostaju stacionarni tokom određenog vremena.",
|
||||
"interval": {
|
||||
"label": "Stacionarni interval",
|
||||
"description": "Kako često (u snimcima) pokretati provjeru detekcije da biste potvrdili stacionarni objekt."
|
||||
},
|
||||
"threshold": {
|
||||
"label": "Stacionarni prag",
|
||||
"description": "Broj snimaka bez promjene pozicije potreban da bi objekt bio označen kao stacionarni."
|
||||
},
|
||||
"max_frames": {
|
||||
"label": "Maksimalni snimci",
|
||||
"description": "Ograničava koliko dugo se stacionarni objekti praćaju prije nego što se odbacuju.",
|
||||
"default": {
|
||||
"label": "Zadani maksimalni snimci",
|
||||
"description": "Zadani maksimalni broj snimaka za praćenje stacionarnog objekta prije prestanka."
|
||||
},
|
||||
"objects": {
|
||||
"label": "Maksimalni snimci po objektu",
|
||||
"description": "Podešavanja po objektu za maksimalni broj snimaka za praćenje stacionarnih objekata."
|
||||
}
|
||||
},
|
||||
"classifier": {
|
||||
"label": "Omogući vizualni klasifikator",
|
||||
"description": "Koristi vizualni klasifikator za detekciju pravozadanih stacionarnih objekata čak i kada se okviri tresu."
|
||||
}
|
||||
},
|
||||
"annotation_offset": {
|
||||
"label": "Pomak oznake",
|
||||
"description": "Milisekunde za pomak detektiranih oznaka kako bi se bolje poravnali vremenski okviri s snimcima; može biti pozitivan ili negativan."
|
||||
}
|
||||
},
|
||||
"face_recognition": {
|
||||
"label": "Prepoznavanje lica",
|
||||
"description": "Postavke za detekciju i prepoznavanje lica za ovu kameru.",
|
||||
"enabled": {
|
||||
"label": "Omogući prepoznavanje lica",
|
||||
"description": "Omogući ili onemogući prepoznavanje lica."
|
||||
},
|
||||
"min_area": {
|
||||
"label": "Minimalna površina lica",
|
||||
"description": "Minimalna površina (pikseli) detektiranog okvira lica potrebna za pokušaj prepoznavanja."
|
||||
}
|
||||
},
|
||||
"ffmpeg": {
|
||||
"label": "FFmpeg",
|
||||
"description": "Postavke FFmpeg uključuju putanju binarne datoteke, argumente, opcije hwaccel i izlazne argumente po ulozi.",
|
||||
"path": {
|
||||
"label": "Putanja do FFmpeg binarne datoteke",
|
||||
"description": "Putanja do FFmpeg binarne datoteke ili verzija alias (\"5.0\" ili \"7.0\")."
|
||||
},
|
||||
"global_args": {
|
||||
"label": "Globalni argumenti FFmpeg-a",
|
||||
"description": "Globalni argumenti prebačeni na procese FFmpeg."
|
||||
},
|
||||
"hwaccel_args": {
|
||||
"label": "Argumenti za ubrzanje hardvera",
|
||||
"description": "Argumenti za ubrzanje hardvera za FFmpeg. Preporučuju se predložci specifični za dobavljača."
|
||||
},
|
||||
"input_args": {
|
||||
"label": "Unos argumenata",
|
||||
"description": "Ulazni argumenti primjenjeni na ulazne snimke FFmpeg."
|
||||
},
|
||||
"output_args": {
|
||||
"label": "Izlazni argumenti",
|
||||
"description": "Zadani izlazni argumenti korišteni za različite uloge FFmpeg-a poput detekcije i snimanja.",
|
||||
"detect": {
|
||||
"label": "Izlazni argumenti za detekciju",
|
||||
"description": "Zadani izlazni argumenti za snimke uloga detekcije."
|
||||
},
|
||||
"record": {
|
||||
"label": "Izlazni argumenti za snimanje",
|
||||
"description": "Zadani izlazni argumenti za snimke uloga snimanja."
|
||||
}
|
||||
},
|
||||
"retry_interval": {
|
||||
"label": "Vrijeme ponovnog pokušaja FFmpeg-a",
|
||||
"description": "Sekunde koje treba čekati prije nego što se pokuša ponovno uspostaviti veza s tokom kamere nakon neuspjeha. Zadano je 10."
|
||||
},
|
||||
"apple_compatibility": {
|
||||
"label": "Kompatibilnost s Apple-om",
|
||||
"description": "Omogući označavanje HEVC za bolju kompatibilnost s igračima Apple-a prilikom snimanja H.265."
|
||||
},
|
||||
"gpu": {
|
||||
"label": "Indeks GPU-a",
|
||||
"description": "Zadani indeks GPU-a korišten za ubrzanje hardvera ako je dostupan."
|
||||
},
|
||||
"inputs": {
|
||||
"label": "Ulazni podaci kamere",
|
||||
"description": "Popis definicija ulaznih tokova (putanje i uloge) za ovu kameru.",
|
||||
"path": {
|
||||
"label": "Putanja ulaza",
|
||||
"description": "URL ili putanja ulaznog toka kamere."
|
||||
},
|
||||
"roles": {
|
||||
"label": "Uloge ulaza",
|
||||
"description": "Uloge za ovaj ulazni tok."
|
||||
},
|
||||
"global_args": {
|
||||
"label": "Globalni argumenti FFmpeg-a",
|
||||
"description": "Globalni argumenti FFmpeg-a za ovaj ulazni tok."
|
||||
},
|
||||
"hwaccel_args": {
|
||||
"label": "Argumenti za ubrzanje hardvera",
|
||||
"description": "Argumenti za ubrzanje hardvera za ovaj ulazni stream."
|
||||
},
|
||||
"input_args": {
|
||||
"label": "Unos argumenata",
|
||||
"description": "Argumeti unosa specifični za ovaj stream."
|
||||
}
|
||||
}
|
||||
},
|
||||
"live": {
|
||||
"label": "Uživo prikaz",
|
||||
"description": "Postavke korištenje Web UI za kontrolu izbora živog streama, rezolucije i kvalitete.",
|
||||
"streams": {
|
||||
"label": "Imena živih streamova",
|
||||
"description": "Mapiranje konfiguriranih imena streamova na imena restream/go2rtc korишtena za uživo prikaz."
|
||||
},
|
||||
"height": {
|
||||
"label": "Visina uživo",
|
||||
"description": "Visina (piksela) za prikaz jsmpeg živog streama u Web UI; mora biti <= visina detektiranog streama."
|
||||
},
|
||||
"quality": {
|
||||
"label": "Kvalitet uživo",
|
||||
"description": "Kvalitet kodiranja za jsmpeg stream (1 najviši, 31 najniži)."
|
||||
}
|
||||
},
|
||||
"lpr": {
|
||||
"label": "Prepoznavanje tablice vozila",
|
||||
"description": "Postavke prepoznavanja tablice vozila uključujući pragovi detekcije, formatiranje i poznate tablice.",
|
||||
"enabled": {
|
||||
"label": "Omogući LPR",
|
||||
"description": "Omogući ili onemogući LPR na ovoj kameri."
|
||||
},
|
||||
"expire_time": {
|
||||
"label": "Sekunde isteka",
|
||||
"description": "Vrijeme u sekundama nakon kojeg nevidljiva tablica istječe iz praćenja (samo za dedikovane LPR kamere)."
|
||||
},
|
||||
"min_area": {
|
||||
"label": "Minimalna površina tablice",
|
||||
"description": "Minimalna površina tablice (piksela) potrebna za pokušaj prepoznavanja."
|
||||
},
|
||||
"enhancement": {
|
||||
"label": "Nivo poboljšanja",
|
||||
"description": "Nivo poboljšanja (0-10) za primjenu na isječke tablice prije OCR-a; veće vrijednosti ne moraju uvijek poboljšati rezultate, nivoi iznad 5 mogu raditi samo s tablicama u noćnom vremenu i trebaju se koristiti s oprezom."
|
||||
}
|
||||
},
|
||||
"motion": {
|
||||
"label": "Detekcija pokreta",
|
||||
"description": "Zadane postavke detekcije pokreta za ovu kameru.",
|
||||
"enabled": {
|
||||
"label": "Omogući detekciju pokreta",
|
||||
"description": "Omogući ili onemogući detekciju pokreta za ovu kameru."
|
||||
},
|
||||
"threshold": {
|
||||
"label": "Prag pokreta",
|
||||
"description": "Prag razlike piksela korišten za detektor pokreta; veće vrijednosti smanjuju osjetljivost (opseg 1-255)."
|
||||
},
|
||||
"lightning_threshold": {
|
||||
"label": "Prag munje",
|
||||
"description": "Prag za detekciju i zanemarivanje kratkih iskri svjetlosti (niže vrijednosti povećavaju osjetljivost, vrijednosti između 0.3 i 1.0). Ovo ne spriječava detekciju pokreta u potpunosti; jednostavno zaustavlja detektor da analizira dodatne okvire nakon što se prag premaši. Snimci temeljeni na pokretima i dalje se stvaraju tijekom ovih događaja."
|
||||
},
|
||||
"skip_motion_threshold": {
|
||||
"label": "Preskoči prag pokreta",
|
||||
"description": "Ako se postavi na vrijednost između 0.0 i 1.0, i ako se više od ovog udjela slike promijeni u jednom okviru, detektor neće vratiti kutije pokreta i odmah će se ponovno kalibrirati. Ovo može uštedjeti CPU i smanjiti lažne pozitive tijekom munje, oluje itd., ali može propustiti stvarne događaje kao što je automatsko praćenje objekta PTZ kamerom. Tržište je između izgube nekoliko megabajta snimaka i pregleda nekoliko kratkih zapisnika. Ostavite nepostavljeno (Nijedno) za onemogućavanje ove funkcije."
|
||||
},
|
||||
"improve_contrast": {
|
||||
"label": "Poboljšaj kontrast",
|
||||
"description": "Primijeni poboljšanje kontrasta na okvire prije analize pokreta kako bi pomoću detekcije."
|
||||
},
|
||||
"contour_area": {
|
||||
"label": "Površina kontura",
|
||||
"description": "Minimalna površina kontura u pikselima potrebna za brojanje kontura pokreta."
|
||||
},
|
||||
"delta_alpha": {
|
||||
"label": "Delta alfa",
|
||||
"description": "Faktor alfa spajanja korišten za razliku okvira za izračun pokreta."
|
||||
},
|
||||
"frame_alpha": {
|
||||
"label": "Alfa okvira",
|
||||
"description": "Vrijednost alfa korištena prilikom spajanja okvira za predobradbu pokreta."
|
||||
},
|
||||
"frame_height": {
|
||||
"label": "Visina okvira",
|
||||
"description": "Visina u pikselima na koju se skaliraju okviri prilikom izračuna pokreta."
|
||||
},
|
||||
"mask": {
|
||||
"label": "Koordinate maska",
|
||||
"description": "Uredno x,y koordinate koje definiraju poligon maska pokreta za uključivanje/isključivanje područja."
|
||||
},
|
||||
"mqtt_off_delay": {
|
||||
"label": "MQTT zakasnjenje isključivanja",
|
||||
"description": "Sekunde koje se čekaju nakon posljednjeg pokreta prije objave MQTT 'isključeno' stanje."
|
||||
},
|
||||
"enabled_in_config": {
|
||||
"label": "Originalno stanje pokreta",
|
||||
"description": "Indikira je li detekcija pokreta bila omogućena u originalnoj statičkoj konfiguraciji."
|
||||
},
|
||||
"raw_mask": {
|
||||
"label": "Ručna maska"
|
||||
}
|
||||
},
|
||||
"objects": {
|
||||
"label": "Objekti",
|
||||
"description": "Zadani parametri praćenja objekata uključujući koje oznake praćenja i filtre po objektu.",
|
||||
"track": {
|
||||
"label": "Objekti za praćenje",
|
||||
"description": "Popis oznaka objekata za praćenje za ovu kameru."
|
||||
},
|
||||
"filters": {
|
||||
"label": "Filtar objekata",
|
||||
"description": "Filtar primijenjen na detektirane objekte kako bi se smanjila broj lažnih pozitiva (površina, omjer, pouzdanost).",
|
||||
"min_area": {
|
||||
"label": "Minimalna površina objekta",
|
||||
"description": "Minimalna površina okvira (pikseli ili postotak) potrebna za ovaj tip objekta. Može biti pikseli (cijeli broj) ili postotak (float između 0.000001 i 0.99)."
|
||||
},
|
||||
"max_area": {
|
||||
"label": "Maksimalna površina objekta",
|
||||
"description": "Maksimalna površina okvira (pikseli ili postotak) dozvoljena za ovaj tip objekta. Može biti pikseli (cijeli broj) ili postotak (float između 0.000001 i 0.99)."
|
||||
},
|
||||
"min_ratio": {
|
||||
"label": "Minimalni omjer visine/širine",
|
||||
"description": "Minimalni omjer širine/visine potreban da bi okvir bio prihvaćen."
|
||||
},
|
||||
"max_ratio": {
|
||||
"label": "Maksimalni omjer visine/širine",
|
||||
"description": "Maksimalni omjer širine/visine dozvoljen da bi okvir bio prihvaćen."
|
||||
},
|
||||
"threshold": {
|
||||
"label": "Prag pouzdanosti",
|
||||
"description": "Prosjek pragova pouzdanosti detekcije potreban da bi objekt bio smatravan pravim pozitivom."
|
||||
},
|
||||
"min_score": {
|
||||
"label": "Minimalna pouzdanost",
|
||||
"description": "Minimalna pouzdanost detekcije po okviru potrebna da bi objekt bio brojan."
|
||||
},
|
||||
"mask": {
|
||||
"label": "Maska filtriranja",
|
||||
"description": "Koordinate poligona koje definiraju područje na kojem se ovaj filter primjenjuje unutar okvira."
|
||||
},
|
||||
"raw_mask": {
|
||||
"label": "Ručna maska"
|
||||
}
|
||||
},
|
||||
"mask": {
|
||||
"label": "Maska objekta",
|
||||
"description": "Poligonalna maska korištena za spriječavanje detekcije objekta u određenim područjima."
|
||||
},
|
||||
"raw_mask": {
|
||||
"label": "Ručna maska"
|
||||
},
|
||||
"genai": {
|
||||
"label": "Konfiguracija GenAI objekta",
|
||||
"description": "Opcije GenAI za opisivanje praćenih objekata i slanje okvira za generisanje.",
|
||||
"enabled": {
|
||||
"label": "Omogući GenAI",
|
||||
"description": "Omogući generisanje opisa za praćene objekte po zadanim postavkama."
|
||||
},
|
||||
"use_snapshot": {
|
||||
"label": "Koristi snimke",
|
||||
"description": "Koristi snimke objekata umjesto miniaturnih slika za generisanje opisa GenAI."
|
||||
},
|
||||
"prompt": {
|
||||
"label": "Naslovni prompt",
|
||||
"description": "Zadani šablon upita korišten za generisanje opisa pomoću GenAI."
|
||||
},
|
||||
"object_prompts": {
|
||||
"label": "Prompti za objekte",
|
||||
"description": "Prompti po objektu za prilagođavanje izlaza GenAI za specifične oznake."
|
||||
},
|
||||
"objects": {
|
||||
"label": "GenAI objekti",
|
||||
"description": "Popis oznaka objekata koje se po defaultu šalju GenAI."
|
||||
},
|
||||
"required_zones": {
|
||||
"label": "Potrebne zone",
|
||||
"description": "Zone koje moraju biti unesene za objekte da bi se kvalifikovali za generisanje opisa GenAI."
|
||||
},
|
||||
"debug_save_thumbnails": {
|
||||
"label": "Sačuvajte miniaturne slike",
|
||||
"description": "Sačuvaj miniaturne slike koje se šalju GenAI za ispravljanje i pregled."
|
||||
},
|
||||
"send_triggers": {
|
||||
"label": "GenAI izazivači",
|
||||
"description": "Definiše kada bi se trebale slati okvir za GenAI (na kraju, nakon ažuriranja, itd.).",
|
||||
"tracked_object_end": {
|
||||
"label": "Pošalji na kraju",
|
||||
"description": "Pošalji zahtjev GenAI kada praćeni objekt završi."
|
||||
},
|
||||
"after_significant_updates": {
|
||||
"label": "Raniji GenAI izazivač",
|
||||
"description": "Pošalji zahtjev GenAI nakon određenog broja značajnih ažuriranja za praćeni objekt."
|
||||
}
|
||||
},
|
||||
"enabled_in_config": {
|
||||
"label": "Originalno stanje GenAI",
|
||||
"description": "Pokazuje je li GenAI bio omogućen u originalnoj statičkoj konfiguraciji."
|
||||
}
|
||||
}
|
||||
},
|
||||
"record": {
|
||||
"label": "Snimanje",
|
||||
"description": "Postavke snimanja i zadržavanja za ovu kameru.",
|
||||
"enabled": {
|
||||
"label": "Omogući snimanje",
|
||||
"description": "Omogući ili onemogući snimanje za ovu kameru."
|
||||
},
|
||||
"expire_interval": {
|
||||
"label": "Interval čišćenja snimanja",
|
||||
"description": "Minute između čišćenja koja uklanjaju istekle segmente snimaka."
|
||||
},
|
||||
"continuous": {
|
||||
"label": "Neprekidna retencija",
|
||||
"description": "Broj dana za čuvanje snimaka bez obzira na praćene objekte ili pokret. Postavite na 0 ako želite da čuvate samo snimke upozorenja i detekcije.",
|
||||
"days": {
|
||||
"label": "Dane zadržavanja",
|
||||
"description": "Dana za čuvanje snimaka."
|
||||
}
|
||||
},
|
||||
"motion": {
|
||||
"label": "Retencija pokreta",
|
||||
"description": "Broj dana za čuvanje snimaka izazvanih pokretom bez obzira na praćene objekte. Postavite na 0 ako želite da čuvate samo snimke upozorenja i detekcije.",
|
||||
"days": {
|
||||
"label": "Dane zadržavanja",
|
||||
"description": "Dana za čuvanje snimaka."
|
||||
}
|
||||
},
|
||||
"detections": {
|
||||
"label": "Retencija detekcije",
|
||||
"description": "Postavke retencije snimaka za događaje detekcije uključujući trajanje pre/post snimanja.",
|
||||
"pre_capture": {
|
||||
"label": "Sekundi pre snimanja",
|
||||
"description": "Broj sekundi prije događaja detekcije koje treba uključiti u snimak."
|
||||
},
|
||||
"post_capture": {
|
||||
"label": "Sekunde nakon snimanja",
|
||||
"description": "Broj sekundi nakon događaja detekcije koje se uključuju u snimanje."
|
||||
},
|
||||
"retain": {
|
||||
"label": "Zadržavanje događaja",
|
||||
"description": "Postavke zadržavanja za snimke događaja detekcije.",
|
||||
"days": {
|
||||
"label": "Dane zadržavanja",
|
||||
"description": "Broj dana za koje se zadržavaju snimke događaja detekcije."
|
||||
},
|
||||
"mode": {
|
||||
"label": "Način zadržavanja",
|
||||
"description": "Način zadržavanja: sve (sačuvati sve segmente), pokret (sačuvati segmente s pokretom), ili aktivni_objekti (sačuvati segmente s aktivnim objektima)."
|
||||
}
|
||||
}
|
||||
},
|
||||
"alerts": {
|
||||
"label": "Retencija upozorenja",
|
||||
"description": "Postavke retencije snimaka za događaje upozorenja uključujući trajanje pre/post snimanja.",
|
||||
"pre_capture": {
|
||||
"label": "Sekundi pre snimanja",
|
||||
"description": "Broj sekundi prije događaja detekcije koje treba uključiti u snimak."
|
||||
},
|
||||
"post_capture": {
|
||||
"label": "Sekunde nakon snimanja",
|
||||
"description": "Broj sekundi nakon događaja detekcije koje se uključuju u snimanje."
|
||||
},
|
||||
"retain": {
|
||||
"label": "Zadržavanje događaja",
|
||||
"description": "Postavke zadržavanja za snimke događaja detekcije.",
|
||||
"days": {
|
||||
"label": "Dane zadržavanja",
|
||||
"description": "Broj dana za koje se zadržavaju snimke događaja detekcije."
|
||||
},
|
||||
"mode": {
|
||||
"label": "Način zadržavanja",
|
||||
"description": "Način zadržavanja: sve (sačuvati sve segmente), pokret (sačuvati segmente s pokretom), ili aktivni_objekti (sačuvati segmente s aktivnim objektima)."
|
||||
}
|
||||
}
|
||||
},
|
||||
"export": {
|
||||
"label": "Konfiguracija izvoza",
|
||||
"description": "Postavke koje se koriste prilikom izvoza snimaka kao što su timelapse i ubrzavanje dretve.",
|
||||
"hwaccel_args": {
|
||||
"label": "Argumeti ubrzavanja dretve za izvoz",
|
||||
"description": "Argumeti ubrzavanja dretve za operacije izvoza/prenosa."
|
||||
},
|
||||
"max_concurrent": {
|
||||
"label": "Maksimalan broj istovremenih izvoza",
|
||||
"description": "Maksimalan broj poslova izvoza koji se obrađuju istovremeno."
|
||||
}
|
||||
},
|
||||
"preview": {
|
||||
"label": "Konfiguracija pregleda",
|
||||
"description": "Postavke koje kontrolišu kvalitet pregleda snimanja prikazanih u UI.",
|
||||
"quality": {
|
||||
"label": "Kvaliteta pregleda",
|
||||
"description": "Nivo kvalitete pregleda (vrlo_nizak, nizak, srednji, visok, vrlo_visok)."
|
||||
}
|
||||
},
|
||||
"enabled_in_config": {
|
||||
"label": "Originalno stanje snimanja",
|
||||
"description": "Pokazuje je li snimanje bilo omogućeno u originalnoj statičkoj konfiguraciji."
|
||||
}
|
||||
},
|
||||
"review": {
|
||||
"label": "Pregled",
|
||||
"description": "Postavke koje kontrolišu upozorenja, detekcije i sažetke pregleda GenAI korišteni od strane UI i skladišta za ovu kameru.",
|
||||
"alerts": {
|
||||
"label": "Konfiguracija upozorenja",
|
||||
"description": "Postavke za koje objekti praćeni generišu upozorenja i kako se upozorenja zadržavaju.",
|
||||
"enabled": {
|
||||
"label": "Omogući upozorenja",
|
||||
"description": "Omogući ili onemogući generisanje upozorenja za ovu kameru."
|
||||
},
|
||||
"labels": {
|
||||
"label": "Oznake upozorenja",
|
||||
"description": "Lista oznaka objekata koje se smatraju upozorenjima (npr. automobil, osoba)."
|
||||
},
|
||||
"required_zones": {
|
||||
"label": "Potrebne zone",
|
||||
"description": "Zone koje objekt mora ući da bi se smatrao upozorenjem; ostavite prazno da omogućite bilo koju zonu."
|
||||
},
|
||||
"enabled_in_config": {
|
||||
"label": "Originalno stanje upozorenja",
|
||||
"description": "Pratiti je li upozorenja izvorno omogućena u statičkoj konfiguraciji."
|
||||
},
|
||||
"cutoff_time": {
|
||||
"label": "Vrijeme prekida upozorenja",
|
||||
"description": "Sekunde koje treba čekati nakon što nema aktivnosti koja uzrokuje upozorenje prije nego se prekine upozorenje."
|
||||
}
|
||||
},
|
||||
"detections": {
|
||||
"label": "Konfiguracija detekcija",
|
||||
"description": "Postavke koje objekti koje se praćenje generišu detekcije (nepozornja) i kako se detekcije čuvaju.",
|
||||
"enabled": {
|
||||
"label": "Omogući detekcije",
|
||||
"description": "Omogući ili onemogući događaje detekcije za ovu kameru."
|
||||
},
|
||||
"labels": {
|
||||
"label": "Oznake detekcije",
|
||||
"description": "Popis oznaka objekata koje kvalifikuju kao događaji detekcije."
|
||||
},
|
||||
"required_zones": {
|
||||
"label": "Potrebne zone",
|
||||
"description": "Zone koje objekt mora ući da bi se smatrao detekcijom; ostavite prazno da omogućite bilo koju zonu."
|
||||
},
|
||||
"cutoff_time": {
|
||||
"label": "Vrijeme prekida detekcija",
|
||||
"description": "Sekunde koje treba čekati nakon što nema aktivnosti koja uzrokuje detekciju prije nego se prekine detekcija."
|
||||
},
|
||||
"enabled_in_config": {
|
||||
"label": "Originalno stanje detekcija",
|
||||
"description": "Pratiti je li detekcije izvorno omogućene u statičkoj konfiguraciji."
|
||||
}
|
||||
},
|
||||
"genai": {
|
||||
"label": "Konfiguracija GenAI",
|
||||
"description": "Kontrolira korištenje generativne AI za proizvodnju opisa i sažetaka stavki za pregled.",
|
||||
"enabled": {
|
||||
"label": "Omogući opise GenAI",
|
||||
"description": "Omogući ili onemogući opise i sažetke generirane GenAI za stavke za pregled."
|
||||
},
|
||||
"alerts": {
|
||||
"label": "Omogući GenAI za upozorenja",
|
||||
"description": "Koristi GenAI za generiranje opisa stavki upozorenja."
|
||||
},
|
||||
"detections": {
|
||||
"label": "Omogući GenAI za detekcije",
|
||||
"description": "Koristite GenAI za generiranje opisa predmeta detekcije."
|
||||
},
|
||||
"image_source": {
|
||||
"label": "Pregledajte izvor slike",
|
||||
"description": "Izvor slika poslatih GenAIJ-u ('preview' ili 'recordings'); 'recordings' koristi kvalitetnije okvire, ali više tokena."
|
||||
},
|
||||
"additional_concerns": {
|
||||
"label": "Dodatne brige",
|
||||
"description": "Popis dodatnih briga ili napomena koje GenAI treba uzeti u obzir prilikom procjene aktivnosti na ovoj kameri."
|
||||
},
|
||||
"debug_save_thumbnails": {
|
||||
"label": "Sačuvajte miniaturne slike",
|
||||
"description": "Sačuvajte miniaturne slike koje se šalju GenAI provajderu za ispravljanje grešaka i pregled."
|
||||
},
|
||||
"enabled_in_config": {
|
||||
"label": "Originalno stanje GenAI",
|
||||
"description": "Pratiti je li pregled GenAI izvorno omogućen u statičkoj konfiguraciji."
|
||||
},
|
||||
"preferred_language": {
|
||||
"label": "Preferirani jezik",
|
||||
"description": "Preferirani jezik za zahtijevanje od GenAI provajdera za generirane odgovore."
|
||||
},
|
||||
"activity_context_prompt": {
|
||||
"label": "Prompt konteksta aktivnosti",
|
||||
"description": "Prilagođeni prompt koji opisuje što je i što nije sumnjivo ponašanje kako bi pružio kontekst za sažetke GenAI."
|
||||
}
|
||||
}
|
||||
},
|
||||
"semantic_search": {
|
||||
"label": "Semantička pretraga",
|
||||
"description": "Postavke za semantičku pretragu koja konstruira i upita uključivanje objekata kako bi pronašla slične stavke.",
|
||||
"triggers": {
|
||||
"label": "Pokretači",
|
||||
"description": "Akcije i kriteriji za usklađivanje za pokretače semantičke pretrage specifične za kameru.",
|
||||
"friendly_name": {
|
||||
"label": "Prijateljsko ime",
|
||||
"description": "Nepovlačno prijateljsko ime prikazano u korisničkom sučelju za ovaj pokretač."
|
||||
},
|
||||
"enabled": {
|
||||
"label": "Omogući ovaj pokretač",
|
||||
"description": "Omogući ili onemogući ovaj pokretač semantičke pretrage."
|
||||
},
|
||||
"type": {
|
||||
"label": "Tip pokretača",
|
||||
"description": "Tip pokretača: 'thumbnail' (uspoređivanje slikom) ili 'description' (uspoređivanje teksta)."
|
||||
},
|
||||
"data": {
|
||||
"label": "Sadržaj pokretača",
|
||||
"description": "Tekstualni izraz ili ID miniaturne slike za uspoređivanje s praćenim objektima."
|
||||
},
|
||||
"threshold": {
|
||||
"label": "Prag aktivacije",
|
||||
"description": "Minimalna ocjena sličnosti (0-1) potrebna za aktivaciju ovog izazivača."
|
||||
},
|
||||
"actions": {
|
||||
"label": "Akcije izazivača",
|
||||
"description": "Popis akcija koje se izvršavaju kada izazivač odgovara (obavijest, pod_naziv, atribute)."
|
||||
}
|
||||
}
|
||||
},
|
||||
"snapshots": {
|
||||
"label": "Snimci",
|
||||
"description": "Postavke za snimke generirane preko API-ja za praćene objekte za ovu kameru.",
|
||||
"enabled": {
|
||||
"label": "Omogući snimke",
|
||||
"description": "Omogući ili onemogući snimanje snimaka za ovu kameru."
|
||||
},
|
||||
"timestamp": {
|
||||
"label": "Preklapanje vremenske oznake",
|
||||
"description": "Preklopiti vremensku oznaku na snimke iz API-ja."
|
||||
},
|
||||
"bounding_box": {
|
||||
"label": "Preklapanje okvira",
|
||||
"description": "Crtanje okvira za praćene objekte na snimke iz API-ja."
|
||||
},
|
||||
"crop": {
|
||||
"label": "Izrezivanje snimke",
|
||||
"description": "Izrezivanje snimki iz API-ja do okvira detektiranog objekta."
|
||||
},
|
||||
"required_zones": {
|
||||
"label": "Potrebne zone",
|
||||
"description": "Zone koje objekt mora ući da bi snimka bila sačuvana."
|
||||
},
|
||||
"height": {
|
||||
"label": "Visina snimke",
|
||||
"description": "Visina (pikseli) za promjenu veličine snimki iz API-ja; ostavite prazno da biste sačuvali originalnu veličinu."
|
||||
},
|
||||
"retain": {
|
||||
"label": "Zadržavanje snimki",
|
||||
"description": "Postavke zadržavanja snimki uključujući zadane dane i prekriženja po objektu.",
|
||||
"default": {
|
||||
"label": "Zadano zadržavanje",
|
||||
"description": "Zadani broj dana za zadržavanje snimki."
|
||||
},
|
||||
"mode": {
|
||||
"label": "Način zadržavanja",
|
||||
"description": "Način zadržavanja: sve (sačuvati sve segmente), pokret (sačuvati segmente s pokretom), ili aktivni_objekti (sačuvati segmente s aktivnim objektima)."
|
||||
},
|
||||
"objects": {
|
||||
"label": "Zadržavanje objekata",
|
||||
"description": "Prekriženja po objektu za dane zadržavanja snimki."
|
||||
}
|
||||
},
|
||||
"quality": {
|
||||
"label": "Kvaliteta snimka",
|
||||
"description": "Kvaliteta kodiranja za sačuvane snimke (0-100)."
|
||||
}
|
||||
},
|
||||
"timestamp_style": {
|
||||
"label": "Stil vremenske oznake",
|
||||
"description": "Opcije stilizacije za vremenske oznake u snimcima i snimcima.",
|
||||
"position": {
|
||||
"label": "Pozicija vremenske oznake",
|
||||
"description": "Pozicija vremenske oznake na slici (tl/tr/bl/br)."
|
||||
},
|
||||
"format": {
|
||||
"label": "Format vremenske oznake",
|
||||
"description": "String formata datuma i vremena korišten za vremenske oznake (Python format koda za datum i vrijeme)."
|
||||
},
|
||||
"color": {
|
||||
"label": "Boja vremenske oznake",
|
||||
"description": "RGB vrijednosti boja za tekst vremenske oznake (sve vrijednosti 0-255).",
|
||||
"red": {
|
||||
"label": "Crvena",
|
||||
"description": "Crveni komponent (0-255) za boju vremenske oznake."
|
||||
},
|
||||
"green": {
|
||||
"label": "Zelena",
|
||||
"description": "Zeleni komponent (0-255) za boju vremenske oznake."
|
||||
},
|
||||
"blue": {
|
||||
"label": "Plava",
|
||||
"description": "Plavi komponent (0-255) za boju vremenske oznake."
|
||||
}
|
||||
},
|
||||
"thickness": {
|
||||
"label": "Debljina vremenske oznake",
|
||||
"description": "Debljina linije teksta vremenske oznake."
|
||||
},
|
||||
"effect": {
|
||||
"label": "Efekt vremenske oznake",
|
||||
"description": "Vizualni efekt za tekst vremenske oznake (none, solid, shadow)."
|
||||
}
|
||||
},
|
||||
"best_image_timeout": {
|
||||
"label": "Vrijeme čekanja za najbolju sliku",
|
||||
"description": "Koliko dugo čekati na sliku s najvišim stupnjem pouzdanosti."
|
||||
},
|
||||
"mqtt": {
|
||||
"label": "MQTT",
|
||||
"description": "Postavke objave slika preko MQTT.",
|
||||
"enabled": {
|
||||
"label": "Pošalji sliku",
|
||||
"description": "Omogući objavljivanje snimaka slika za objekte na MQTT teme za ovu kameru."
|
||||
},
|
||||
"timestamp": {
|
||||
"label": "Dodaj vremensku oznaku",
|
||||
"description": "Preklopiti vremensku oznaku na slike objavljene preko MQTT."
|
||||
},
|
||||
"bounding_box": {
|
||||
"label": "Dodaj okvir",
|
||||
"description": "Crtaj okvire na slikama objavljenim preko MQTT."
|
||||
},
|
||||
"crop": {
|
||||
"label": "Iscijepi sliku",
|
||||
"description": "Iscijepi slike objavljene preko MQTT na okvir detektiranog objekta."
|
||||
},
|
||||
"height": {
|
||||
"label": "Visina slike",
|
||||
"description": "Visina (piksela) za promjenu veličine slika objavljenih preko MQTT."
|
||||
},
|
||||
"required_zones": {
|
||||
"label": "Potrebne zone",
|
||||
"description": "Zone koje objekt mora ući da bi se slika preko MQTT objavila."
|
||||
},
|
||||
"quality": {
|
||||
"label": "Kvaliteta JPEG",
|
||||
"description": "Kvaliteta JPEG za slike objavljene preko MQTT (0-100)."
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"label": "Obavještenja",
|
||||
"description": "Postavke za omogućavanje i kontrolu obavijesti za ovu kameru.",
|
||||
"enabled": {
|
||||
"label": "Omogući obavijesti",
|
||||
"description": "Omogući ili onemogući obavijesti za ovu kameru."
|
||||
},
|
||||
"email": {
|
||||
"label": "E-mail za obavijesti",
|
||||
"description": "Adresa e-maila koja se koristi za obavijesti putem push-a ili je potrebna određenim dobavljačima obavijesti."
|
||||
},
|
||||
"cooldown": {
|
||||
"label": "Period hlađenja",
|
||||
"description": "Period hlađenja (sekunde) između obavijesti kako bi se izbjeglo spaming primateljima."
|
||||
},
|
||||
"enabled_in_config": {
|
||||
"label": "Originalno stanje obavijesti",
|
||||
"description": "Pokazuje je li obavijesti bile omogućene u originalnoj statičkoj konfiguraciji."
|
||||
}
|
||||
},
|
||||
"onvif": {
|
||||
"label": "ONVIF",
|
||||
"description": "Postavke povezivanja preko ONVIF i automatskog praćenja PTZ za ovu kameru.",
|
||||
"host": {
|
||||
"label": "Gost ONVIF",
|
||||
"description": "Gost (i opcionalni shema) za uslugu ONVIF za ovu kameru."
|
||||
},
|
||||
"port": {
|
||||
"label": "Port ONVIF",
|
||||
"description": "Broj porta za uslugu ONVIF."
|
||||
},
|
||||
"user": {
|
||||
"label": "Korisničko ime za ONVIF",
|
||||
"description": "Korisničko ime za autentifikaciju ONVIF; neki uređaji zahtijevaju korisnika admin za ONVIF."
|
||||
},
|
||||
"password": {
|
||||
"label": "Lozinka za ONVIF",
|
||||
"description": "Lozinka za autentifikaciju ONVIF."
|
||||
},
|
||||
"tls_insecure": {
|
||||
"label": "Onemogući provjeru TLS",
|
||||
"description": "Preskoči provjeru TLS i onemogući digest autentifikaciju za ONVIF (nebezbedno; koristiti samo u sigurnim mrežama)."
|
||||
},
|
||||
"profile": {
|
||||
"label": "ONVIF profil",
|
||||
"description": "Specifičan ONVIF medij profil za korištenje za kontrolu PTZ, prilagođen tokenom ili imenom. Ako nije postavljen, prvi profil s važećom konfiguracijom PTZ automatski se odabire."
|
||||
},
|
||||
"autotracking": {
|
||||
"label": "Autotračenje",
|
||||
"description": "Automatski praćenje pokretanja objekata i držanje ih u sredini okvira korištenjem pokreta kamere PTZ.",
|
||||
"enabled": {
|
||||
"label": "Omogući automatsko praćenje",
|
||||
"description": "Omogući ili onemogući automatsko praćenje kamere PTZ detektiranih objekata."
|
||||
},
|
||||
"calibrate_on_startup": {
|
||||
"label": "Kalibriraj na početku",
|
||||
"description": "Mjeri brzine motora PTZ pri pokretanju kako bi poboljšao preciznost praćenja. Frigate će ažurirati konfiguraciju s težinama pokreta nakon kalibracije."
|
||||
},
|
||||
"zooming": {
|
||||
"label": "Režim zumiranja",
|
||||
"description": "Kontrola ponašanja zumiranja: onemogućeno (samo pan/tilt), apsolutno (najkompatibilnije) ili relativno (konkurentno pan/tilt/zum)."
|
||||
},
|
||||
"zoom_factor": {
|
||||
"label": "Faktor zumiranja",
|
||||
"description": "Kontrola razine zumiranja na praćenim objektima. Niže vrijednosti drže više scene u pogledu; više vrijednosti zumiraju bliže, ali mogu izgubiti praćenje. Vrijednosti između 0.1 i 0.75."
|
||||
},
|
||||
"track": {
|
||||
"label": "Praćeni objekti",
|
||||
"description": "Popis vrsta objekata koji trebaju pokrenuti automatsko praćenje."
|
||||
},
|
||||
"required_zones": {
|
||||
"label": "Potrebne zone",
|
||||
"description": "Objekti moraju ući u jednu od ovih zona prije nego što započne automatsko praćenje."
|
||||
},
|
||||
"return_preset": {
|
||||
"label": "Povratak na predpostavku",
|
||||
"description": "Ime predpostavke konfigurirano u firmware kamere za povratak nakon završetka praćenja."
|
||||
},
|
||||
"timeout": {
|
||||
"label": "Vrijeme čekanja povratka",
|
||||
"description": "Čekajte ovaj broj sekundi nakon gubitka praćenja prije povratka kamere na predpostavljeno mjesto."
|
||||
},
|
||||
"movement_weights": {
|
||||
"label": "Težine pokreta",
|
||||
"description": "Vrijednosti kalibracije automatski generirane kroz kalibraciju kamere. Ne mijenjajte ručno."
|
||||
},
|
||||
"enabled_in_config": {
|
||||
"label": "Originalni stanje autotračenja",
|
||||
"description": "Unutarnje polje za praćenje je li autotračenje bilo omogućeno u konfiguraciji."
|
||||
}
|
||||
},
|
||||
"ignore_time_mismatch": {
|
||||
"label": "Zanemari razliku u vremenu",
|
||||
"description": "Zanemari razlike u sinhronizaciji vremena između kamere i Frigate servera za komunikaciju ONVIF."
|
||||
}
|
||||
},
|
||||
"type": {
|
||||
"label": "Tip kamere",
|
||||
"description": "Tip kamere"
|
||||
},
|
||||
"ui": {
|
||||
"label": "Korisnički interfejs kamere",
|
||||
"description": "Prikaz redoslijeda i vidljivosti za ovu kameru u UI. Redoslijed utječe na zadani nadzorno pločo. Za detaljniju kontrolu koristite grupe kamere.",
|
||||
"order": {
|
||||
"label": "Redoslijed UI",
|
||||
"description": "Numerički redoslijed koristi se za sortiranje kamere u UI (zadani nadzorno pločo i popisi); veći brojevi pojavljuju se kasnije."
|
||||
},
|
||||
"dashboard": {
|
||||
"label": "Prikaži u UI",
|
||||
"description": "Prekidač je li ova kamera vidljiva svuda u UI Frigate. Onemogućavanje ovoga zahtijeva ručno uređivanje konfiguracije za ponovno prikazivanje ove kamere u UI."
|
||||
}
|
||||
},
|
||||
"webui_url": {
|
||||
"label": "URL kamere",
|
||||
"description": "URL za pristup kamere izravno iz stranice sustava"
|
||||
},
|
||||
"profiles": {
|
||||
"label": "Profili",
|
||||
"description": "Imenovane konfiguracijske profile s parcijalnim preklopima koji se mogu aktivirati tijekom izvršavanja."
|
||||
},
|
||||
"enabled_in_config": {
|
||||
"label": "Originalno stanje kamere",
|
||||
"description": "Pratite originalno stanje kamere."
|
||||
}
|
||||
}
|
||||
1596
web/public/locales/bs/config/global.json
Normal file
1596
web/public/locales/bs/config/global.json
Normal file
File diff suppressed because it is too large
Load Diff
73
web/public/locales/bs/config/groups.json
Normal file
73
web/public/locales/bs/config/groups.json
Normal file
@ -0,0 +1,73 @@
|
||||
{
|
||||
"audio": {
|
||||
"global": {
|
||||
"detection": "Globalna detekcija",
|
||||
"sensitivity": "Globalna osjetljivost"
|
||||
},
|
||||
"cameras": {
|
||||
"detection": "Detekcija",
|
||||
"sensitivity": "Osjetljivost"
|
||||
}
|
||||
},
|
||||
"timestamp_style": {
|
||||
"global": {
|
||||
"appearance": "Globalno izgled"
|
||||
},
|
||||
"cameras": {
|
||||
"appearance": "Izgled"
|
||||
}
|
||||
},
|
||||
"motion": {
|
||||
"global": {
|
||||
"sensitivity": "Globalna osjetljivost",
|
||||
"algorithm": "Globalni algoritam"
|
||||
},
|
||||
"cameras": {
|
||||
"sensitivity": "Osjetljivost",
|
||||
"algorithm": "Algoritam"
|
||||
}
|
||||
},
|
||||
"snapshots": {
|
||||
"global": {
|
||||
"display": "Globalno prikazivanje"
|
||||
},
|
||||
"cameras": {
|
||||
"display": "Prikazivanje"
|
||||
}
|
||||
},
|
||||
"detect": {
|
||||
"global": {
|
||||
"resolution": "Globalna rezolucija",
|
||||
"tracking": "Globalno praćenje"
|
||||
},
|
||||
"cameras": {
|
||||
"resolution": "Rezolucija",
|
||||
"tracking": "Praćenje"
|
||||
}
|
||||
},
|
||||
"objects": {
|
||||
"global": {
|
||||
"tracking": "Globalno praćenje",
|
||||
"filtering": "Globalno filtriranje"
|
||||
},
|
||||
"cameras": {
|
||||
"tracking": "Praćenje",
|
||||
"filtering": "Filtriranje"
|
||||
}
|
||||
},
|
||||
"record": {
|
||||
"global": {
|
||||
"retention": "Globalno zadržavanje",
|
||||
"events": "Globalni događaji"
|
||||
},
|
||||
"cameras": {
|
||||
"retention": "Zadržavanje",
|
||||
"events": "Događaji"
|
||||
}
|
||||
},
|
||||
"ffmpeg": {
|
||||
"cameras": {
|
||||
"cameraFfmpeg": "Argumeni FFmpeg specifični za kameru"
|
||||
}
|
||||
}
|
||||
}
|
||||
32
web/public/locales/bs/config/validation.json
Normal file
32
web/public/locales/bs/config/validation.json
Normal file
@ -0,0 +1,32 @@
|
||||
{
|
||||
"minimum": "Bar {{limit}}",
|
||||
"maximum": "Mora biti najviše {{limit}}",
|
||||
"exclusiveMinimum": "Mora biti veći od {{limit}}",
|
||||
"exclusiveMaximum": "Mora biti manje od {{limit}}",
|
||||
"minLength": "Bar {{limit}} znak(ovi)",
|
||||
"maxLength": "Mora biti najviše {{limit}} znak(ovi)",
|
||||
"minItems": "Mora imati bar {{limit}} stavke",
|
||||
"maxItems": "Mora imati najviše {{limit}} stavke",
|
||||
"pattern": "Neispravan format",
|
||||
"required": "Ovo polje je obavezno",
|
||||
"type": "Neispravan tip vrijednosti",
|
||||
"enum": "Mora biti jedan od dopuštenih vrijednosti",
|
||||
"const": "Vrijednost se ne podudara s očekivanom konstantom",
|
||||
"uniqueItems": "Sve stavke moraju biti jedinstvene",
|
||||
"format": "Neispravan format",
|
||||
"additionalProperties": "Nepoznato svojstvo nije dozvoljeno",
|
||||
"oneOf": "Mora se podudarati s točno jednim od dopuštenih shema",
|
||||
"anyOf": "Mora se podudarati s bar jednim od dopuštenih shema",
|
||||
"proxy": {
|
||||
"header_map": {
|
||||
"roleHeaderRequired": "Zaglavlje uloge je obavezno kada su konfigurirane mapiranja uloga."
|
||||
}
|
||||
},
|
||||
"ffmpeg": {
|
||||
"inputs": {
|
||||
"rolesUnique": "Svaka uloga može biti dodijeljena samo jednom ulaznom toku.",
|
||||
"detectRequired": "Bar jedan ulazni tok mora biti dodijeljen ulozi 'detektirati'.",
|
||||
"hwaccelDetectOnly": "Samo ulazni tok s ulogom detektiranja može definirati argumente ubrzavanja hardvera."
|
||||
}
|
||||
}
|
||||
}
|
||||
125
web/public/locales/bs/objects.json
Normal file
125
web/public/locales/bs/objects.json
Normal file
@ -0,0 +1,125 @@
|
||||
{
|
||||
"person": "Ljudsko bit će",
|
||||
"bicycle": "Kolo",
|
||||
"animal": "Životinja",
|
||||
"dog": "Pas",
|
||||
"bark": "Glavu",
|
||||
"cat": "Mačka",
|
||||
"horse": "Konj",
|
||||
"goat": "Koza",
|
||||
"sheep": "Ovca",
|
||||
"bird": "Ptica",
|
||||
"mouse": "Miš",
|
||||
"keyboard": "Klaviatura",
|
||||
"vehicle": "Vozilo",
|
||||
"boat": "Brod",
|
||||
"car": "Automobil",
|
||||
"bus": "Autobus",
|
||||
"motorcycle": "Motocikl",
|
||||
"train": "Vlak",
|
||||
"skateboard": "Skejtbord",
|
||||
"door": "Vrata",
|
||||
"blender": "Miksere",
|
||||
"sink": "Lavabo",
|
||||
"hair_dryer": "Sušilac za kosu",
|
||||
"toothbrush": "Šetka za zube",
|
||||
"scissors": "Škare",
|
||||
"clock": "Sat",
|
||||
"airplane": "Avion",
|
||||
"traffic_light": "Svetofor",
|
||||
"fire_hydrant": "Vatrostaničar",
|
||||
"street_sign": "Ulični znak",
|
||||
"stop_sign": "Znak zaustavljanja",
|
||||
"parking_meter": "Parkirni metar",
|
||||
"bench": "Banko",
|
||||
"cow": "Korova",
|
||||
"elephant": "Slon",
|
||||
"bear": "Medvjed",
|
||||
"zebra": "Zebra",
|
||||
"giraffe": "Žirafa",
|
||||
"hat": "Kaputa",
|
||||
"backpack": "Torba",
|
||||
"umbrella": "Kreveta",
|
||||
"shoe": "Cizma",
|
||||
"eye_glasses": "Očna stakla",
|
||||
"handbag": "Ručna torba",
|
||||
"tie": "Kremplj",
|
||||
"suitcase": "Kufer",
|
||||
"frisbee": "Frizbi",
|
||||
"skis": "Ski",
|
||||
"snowboard": "Snjegobord",
|
||||
"sports_ball": "Sportska lopta",
|
||||
"kite": "Let",
|
||||
"baseball_bat": "Batsa za baseball",
|
||||
"baseball_glove": "Rukavica za baseball",
|
||||
"surfboard": "Surfbord",
|
||||
"tennis_racket": "Teniski raketa",
|
||||
"bottle": "Bocica",
|
||||
"plate": "Ploča",
|
||||
"wine_glass": "Vinsko čaša",
|
||||
"cup": "Kupa",
|
||||
"fork": "Škarpe",
|
||||
"knife": "Nož",
|
||||
"spoon": "Lajna",
|
||||
"bowl": "Tanjir",
|
||||
"banana": "Banana",
|
||||
"apple": "Jabuka",
|
||||
"sandwich": "Sandučić",
|
||||
"orange": "Portakal",
|
||||
"broccoli": "Brobkoli",
|
||||
"carrot": "Mahunika",
|
||||
"hot_dog": "Hot dog",
|
||||
"pizza": "Pica",
|
||||
"donut": "Krofna",
|
||||
"cake": "Torta",
|
||||
"chair": "Stolica",
|
||||
"couch": "Divan",
|
||||
"potted_plant": "Ukrasna biljka",
|
||||
"bed": "Krevet",
|
||||
"mirror": "Zrcalo",
|
||||
"dining_table": "Stol za ručak",
|
||||
"window": "Prozor",
|
||||
"desk": "Radni stol",
|
||||
"toilet": "Toalet",
|
||||
"tv": "TV",
|
||||
"laptop": "Laptop",
|
||||
"remote": "Udaljeno upravljanje",
|
||||
"cell_phone": "Mobilni telefon",
|
||||
"microwave": "Mikrotalasna pećnica",
|
||||
"oven": "Pećnica",
|
||||
"toaster": "Tostera",
|
||||
"refrigerator": "Hladnjak",
|
||||
"book": "Knjiga",
|
||||
"vase": "Vaza",
|
||||
"teddy_bear": "Biberon",
|
||||
"hair_brush": "Kosmetička četka",
|
||||
"squirrel": "Šumski pas",
|
||||
"deer": "Jelen",
|
||||
"fox": "Lisica",
|
||||
"rabbit": "Zajac",
|
||||
"raccoon": "Rakun",
|
||||
"robot_lawnmower": "Robotska kosilica",
|
||||
"waste_bin": "Kanta za otpad",
|
||||
"on_demand": "Na zahtjev",
|
||||
"face": "Lice",
|
||||
"license_plate": "Tablica",
|
||||
"package": "Paket",
|
||||
"bbq_grill": "Grill za BBQ",
|
||||
"amazon": "Amazon",
|
||||
"usps": "USPS",
|
||||
"ups": "UPS",
|
||||
"fedex": "FedEx",
|
||||
"dhl": "DHL",
|
||||
"an_post": "An Post",
|
||||
"purolator": "Purolator",
|
||||
"postnl": "PostNL",
|
||||
"nzpost": "NZPost",
|
||||
"postnord": "PostNord",
|
||||
"gls": "GLS",
|
||||
"dpd": "DPD",
|
||||
"canada_post": "Canada Post",
|
||||
"royal_mail": "Royal Mail",
|
||||
"school_bus": "Školski autobus",
|
||||
"skunk": "Mrdka",
|
||||
"kangaroo": "Kanguru"
|
||||
}
|
||||
46
web/public/locales/bs/views/chat.json
Normal file
46
web/public/locales/bs/views/chat.json
Normal file
@ -0,0 +1,46 @@
|
||||
{
|
||||
"documentTitle": "Razgovor - Frigate",
|
||||
"title": "Frigate razgovor",
|
||||
"subtitle": "Vaš AI asistent za upravljanje kamerama i insighti",
|
||||
"placeholder": "Pitajte bilo što...",
|
||||
"error": "Nešto je pošlo po zlu. Molimo pokušajte ponovo.",
|
||||
"processing": "Obrađivanje...",
|
||||
"toolsUsed": "Korišteno: {{tools}}",
|
||||
"showTools": "Prikaži alate ({{count}})",
|
||||
"hideTools": "Sakrij alate",
|
||||
"call": "Poziv",
|
||||
"result": "Rezultat",
|
||||
"arguments": "Argumenti:",
|
||||
"response": "Odgovor:",
|
||||
"attachment_chip_label": "{{label}} na {{camera}}",
|
||||
"attachment_chip_remove": "Ukloni privitak",
|
||||
"open_in_explore": "Otvori u Explore",
|
||||
"attach_event_aria": "Prikači događaj {{eventId}}",
|
||||
"attachment_picker_paste_label": "Ili zalijepite ID događaja",
|
||||
"attachment_picker_attach": "Prikači",
|
||||
"attachment_picker_placeholder": "Prikači događaj",
|
||||
"quick_reply_find_similar": "Pronađi slične susreti",
|
||||
"quick_reply_tell_me_more": "Recite mi više o ovome",
|
||||
"quick_reply_when_else": "Kada je još puta vidjeno?",
|
||||
"quick_reply_find_similar_text": "Pronađi slične susreti za ovaj.",
|
||||
"quick_reply_tell_me_more_text": "Recite mi više o ovom.",
|
||||
"quick_reply_when_else_text": "Kada je to još puta vidjeno?",
|
||||
"anchor": "Referenca",
|
||||
"similarity_score": "Sličnost",
|
||||
"no_similar_objects_found": "Nisu pronađeni slični objekti.",
|
||||
"semantic_search_required": "Semantička pretraga mora biti omogućena da bi se pronašli slični objekti.",
|
||||
"send": "Pošalji",
|
||||
"suggested_requests": "Pokušaj pitati:",
|
||||
"starting_requests": {
|
||||
"show_recent_events": "Prikaži nedavne događaje",
|
||||
"show_camera_status": "Prikaži status kamere",
|
||||
"recap": "Što se desilo dok sam bio odsutan?",
|
||||
"watch_camera": "Pratite kameru za aktivnost"
|
||||
},
|
||||
"starting_requests_prompts": {
|
||||
"show_recent_events": "Prikaži mi nedavne događaje iz posljednjeg sata",
|
||||
"show_camera_status": "Koji je trenutni status mojih kamera?",
|
||||
"recap": "Što se desilo dok sam bio odsutan?",
|
||||
"watch_camera": "Pratite ulazna vrata i obavijestite me ako netko dođe"
|
||||
}
|
||||
}
|
||||
205
web/public/locales/bs/views/classificationModel.json
Normal file
205
web/public/locales/bs/views/classificationModel.json
Normal file
@ -0,0 +1,205 @@
|
||||
{
|
||||
"documentTitle": "Modeli klasifikacije - Frigate",
|
||||
"details": {
|
||||
"scoreInfo": "Ocjena predstavlja prosjek pouzdanosti klasifikacije kroz sve detekcije ovog objekta.",
|
||||
"none": "Nijedan",
|
||||
"unknown": "Nepoznato"
|
||||
},
|
||||
"button": {
|
||||
"deleteClassificationAttempts": "Obriši slike klasifikacije",
|
||||
"renameCategory": "Preimenuj klasu",
|
||||
"deleteCategory": "Obriši klasu",
|
||||
"deleteImages": "Obriši slike",
|
||||
"trainModel": "Obuci model",
|
||||
"addClassification": "Dodaj klasifikaciju",
|
||||
"deleteModels": "Obriši modele",
|
||||
"editModel": "Uredi model"
|
||||
},
|
||||
"tooltip": {
|
||||
"trainingInProgress": "Model trenutno obučava",
|
||||
"noNewImages": "Nema novih slika za obuku. Prvo klasificirajte više slika u skupu podataka.",
|
||||
"noChanges": "Nema promjena u skupu podataka od posljednje obuke.",
|
||||
"modelNotReady": "Model nije spreman za obuku"
|
||||
},
|
||||
"toast": {
|
||||
"success": {
|
||||
"deletedModel_one": "Uspješno obrisan {{count}} model",
|
||||
"deletedModel_few": "Uspješno obrisani {{count}} modeli",
|
||||
"deletedModel_other": "Uspješno obrisani {{count}} modeli",
|
||||
"categorizedImage": "Uspješno klasificirana slika",
|
||||
"reclassifiedImage": "Uspješno ponovno klasificirana slika",
|
||||
"trainedModel": "Uspješno obučen model.",
|
||||
"trainingModel": "Uspješno pokrenuta obuka modela.",
|
||||
"updatedModel": "Uspješno ažurirana konfiguracija modela",
|
||||
"renamedCategory": "Uspješno preimenovana klasa u {{name}}",
|
||||
"deletedCategory_one": "Obrisana {{count}} klasa",
|
||||
"deletedCategory_few": "Obrisane {{count}} klase",
|
||||
"deletedCategory_other": "Obrisane {{count}} klase",
|
||||
"deletedImage_one": "Izbrisana {{count}} slika",
|
||||
"deletedImage_few": "Izbrisane {{count}} slike",
|
||||
"deletedImage_other": "Izbrisane {{count}} slike"
|
||||
},
|
||||
"error": {
|
||||
"deleteImageFailed": "Neuspješno brisanje: {{errorMessage}}",
|
||||
"deleteCategoryFailed": "Neuspješno brisanje klase: {{errorMessage}}",
|
||||
"deleteModelFailed": "Neuspješno brisanje modela: {{errorMessage}}",
|
||||
"categorizeFailed": "Neuspješno kategoriziranje slike: {{errorMessage}}",
|
||||
"trainingFailed": "Obuka modela nije uspješna. Provjerite zapise Frigate za detalje.",
|
||||
"trainingFailedToStart": "Neuspješno pokretanje obuke modela: {{errorMessage}}",
|
||||
"updateModelFailed": "Neuspješno ažuriranje modela: {{errorMessage}}",
|
||||
"renameCategoryFailed": "Neuspješno preimenovanje klase: {{errorMessage}}",
|
||||
"reclassifyFailed": "Neuspješno ponovno klasifikovanje slike: {{errorMessage}}"
|
||||
}
|
||||
},
|
||||
"deleteCategory": {
|
||||
"title": "Izbriši klasu",
|
||||
"desc": "Sigurni li ste da želite izbrisati klasu {{name}}? Ovo će trajno izbrisati sve povezane slike i zahtijevati ponovnu obuku modela.",
|
||||
"minClassesTitle": "Nemoguće izbrisati klasu",
|
||||
"minClassesDesc": "Model klasifikacije mora imati najmanje 2 klase. Dodajte još jednu klasu prije brisanja ove."
|
||||
},
|
||||
"deleteModel": {
|
||||
"title": "Izbriši model klasifikacije",
|
||||
"single": "Sigurni li ste da želite izbrisati {{name}}? Ovo će trajno izbrisati sve povezane podatke uključujući slike i podatke o obuci. Ova akcija ne može biti poništena.",
|
||||
"desc_one": "Sigurni li ste da želite izbrisati {{count}} model? Ovo će trajno izbrisati sve povezane podatke uključujući slike i podatke o obuci. Ova akcija ne može biti poništena.",
|
||||
"desc_few": "Sigurni li ste da želite izbrisati {{count}} modele? Ovo će trajno izbrisati sve povezane podatke uključujući slike i podatke o obuci. Ova akcija ne može biti poništena.",
|
||||
"desc_other": "Sigurni li ste da želite izbrisati {{count}} modele? Ovo će trajno izbrisati sve povezane podatke uključujući slike i podatke o obuci. Ova akcija ne može biti poništena."
|
||||
},
|
||||
"edit": {
|
||||
"title": "Uredi model klasifikacije",
|
||||
"descriptionState": "Uredi klase za ovaj model klasifikacije stanja. Promjene će zahtijevati ponovnu obuku modela.",
|
||||
"descriptionObject": "Uredi vrstu objekta i vrstu klasifikacije za ovaj model klasifikacije objekta.",
|
||||
"stateClassesInfo": "Napomena: Promjene klasa stanja zahtijevaju ponovnu obuku modela sa ažuriranim klasama."
|
||||
},
|
||||
"deleteDatasetImages": {
|
||||
"title": "Izbriši slike skupa podataka",
|
||||
"desc_one": "Sigurni li ste da želite izbrisati {{count}} sliku iz {{dataset}}? Ova akcija ne može biti poništena i zahtijevati će ponovnu obuku modela.",
|
||||
"desc_few": "Sigurni li ste da želite obrisati {{count}} slike iz {{dataset}}? Ova akcija ne može se poništiti i zahtijevat će ponovno treniranje modela.",
|
||||
"desc_other": "Sigurni li ste da želite obrisati {{count}} slike iz {{dataset}}? Ova akcija ne može se poništiti i zahtijevat će ponovno treniranje modela."
|
||||
},
|
||||
"deleteTrainImages": {
|
||||
"title": "Obriši slike za treniranje",
|
||||
"desc_one": "Sigurni li ste da želite obrisati {{count}} sliku? Ova akcija ne može se poništiti.",
|
||||
"desc_few": "Sigurni li ste da želite obrisati {{count}} slike? Ova akcija ne može se poništiti.",
|
||||
"desc_other": "Sigurni li ste da želite obrisati {{count}} slike? Ova akcija ne može se poništiti."
|
||||
},
|
||||
"renameCategory": {
|
||||
"title": "Preimenuj klasu",
|
||||
"desc": "Unesite novo ime za {{name}}. Za promjenu imena će vam se zahtijevati ponovno treniranje modela."
|
||||
},
|
||||
"description": {
|
||||
"invalidName": "Neprihvatljivo ime. Imena mogu sadržavati samo slova, brojeve, razmake, aposrofe, donje crte i crte."
|
||||
},
|
||||
"train": {
|
||||
"title": "Nedavne klasifikacije",
|
||||
"titleShort": "Nedavno",
|
||||
"aria": "Odaberite nedavne klasifikacije"
|
||||
},
|
||||
"categories": "Klase",
|
||||
"createCategory": {
|
||||
"new": "Stvori novu klasu"
|
||||
},
|
||||
"categorizeImageAs": "Klasificiraj sliku kao:",
|
||||
"categorizeImage": "Klasificiraj sliku",
|
||||
"reclassifyImageAs": "Ponovno klasificiraj sliku kao:",
|
||||
"reclassifyImage": "Ponovno klasificiraj sliku",
|
||||
"menu": {
|
||||
"objects": "Objekti",
|
||||
"states": "Stanja"
|
||||
},
|
||||
"noModels": {
|
||||
"object": {
|
||||
"title": "Nema modela za klasifikaciju objekata",
|
||||
"description": "Stvorite prilagođeni model za klasifikaciju detektiranih objekata.",
|
||||
"buttonText": "Stvori model objekta"
|
||||
},
|
||||
"state": {
|
||||
"title": "Nema modela za klasifikaciju stanja",
|
||||
"description": "Stvorite prilagođeni model za praćenje i klasifikaciju promjena stanja u određenim područjima kamere.",
|
||||
"buttonText": "Stvori model stanja"
|
||||
}
|
||||
},
|
||||
"wizard": {
|
||||
"title": "Stvori novu klasifikaciju",
|
||||
"steps": {
|
||||
"nameAndDefine": "Ime i definicija",
|
||||
"stateArea": "Područje stanja",
|
||||
"chooseExamples": "Odaberite primjere"
|
||||
},
|
||||
"step1": {
|
||||
"description": "Modeli stanja nadziraju fiksne područja kamere za promjene (npr. vrata otvorena/zatvorena). Modeli objekata dodaju klasifikacije detektiranim objektima (npr. poznati životinje, dostavljači, itd.).",
|
||||
"name": "Ime",
|
||||
"namePlaceholder": "Unesite ime modela...",
|
||||
"type": "Tip",
|
||||
"typeState": "Stanje",
|
||||
"typeObject": "Objekt",
|
||||
"objectLabel": "Oznaka objekta",
|
||||
"objectLabelPlaceholder": "Odaberite vrstu objekta...",
|
||||
"classificationType": "Vrsta klasifikacije",
|
||||
"classificationTypeTip": "Učite više o vrstama klasifikacije",
|
||||
"classificationTypeDesc": "Podoznake dodaju dodatni tekst oznaci objekta (npr. 'Ljudsko bit će: UPS'). Atributi su pretraživi metapodaci pohranjeni zasebno u metapodacima objekta.",
|
||||
"classificationSubLabel": "Podoznaka",
|
||||
"classificationAttribute": "Atribut",
|
||||
"classes": "Klase",
|
||||
"states": "Stanja",
|
||||
"classesTip": "Učite više o klasama",
|
||||
"classesStateDesc": "Definirajte različita stanja koja može imati područje kamere. Na primjer: 'otvoreno' i 'zatvoreno' za vrata garaže.",
|
||||
"classesObjectDesc": "Definirajte različite kategorije u koje ćete klasificirati detektirane objekte. Na primjer: 'dostavljac', 'stanovnik', 'stranac' za klasifikaciju ljudi.",
|
||||
"classPlaceholder": "Unesite ime klase...",
|
||||
"errors": {
|
||||
"nameRequired": "Ime modela je obavezno",
|
||||
"nameLength": "Ime modela mora imati 64 znaka ili manje",
|
||||
"nameOnlyNumbers": "Ime modela ne može sadržavati samo brojeve",
|
||||
"classRequired": "Potrebna je bar jedna klasa",
|
||||
"classesUnique": "Imena klasa moraju biti jedinstvena",
|
||||
"noneNotAllowed": "Klasa 'none' nije dozvoljena",
|
||||
"stateRequiresTwoClasses": "Modeli stanja zahtijevaju bar dvije klase",
|
||||
"objectLabelRequired": "Molimo odaberite oznaku objekta",
|
||||
"objectTypeRequired": "Molimo odaberite vrstu klasifikacije"
|
||||
}
|
||||
},
|
||||
"step2": {
|
||||
"description": "Odaberite kamere i definirajte područje koje ćete nadzirati za svaku kameru. Model će klasificirati stanje ovih područja.",
|
||||
"cameras": "Kamere",
|
||||
"selectCamera": "Odaberite Kameru",
|
||||
"noCameras": "Kliknite + za dodavanje kamera",
|
||||
"selectCameraPrompt": "Odaberite kameru iz popisa da biste definirali njezino područje nadzora"
|
||||
},
|
||||
"step3": {
|
||||
"selectImagesPrompt": "Odaberite sve slike s: {{className}}",
|
||||
"selectImagesDescription": "Kliknite na slike da biste ih odabrali. Kliknite Nadalje kada završite s ovom klasifikacijom.",
|
||||
"allImagesRequired_one": "Molimo klasificirajte sve slike. Preostala je {{count}} slika.",
|
||||
"allImagesRequired_few": "Molimo klasificirajte sve slike. Preostale su {{count}} slike.",
|
||||
"allImagesRequired_other": "Molimo klasificirajte sve slike. Preostale su {{count}} slike.",
|
||||
"generating": {
|
||||
"title": "Generisanje uzoraka slika",
|
||||
"description": "Frigate učitava reprezentativne slike iz vaših snimaka. Ovo može trajati trenutak..."
|
||||
},
|
||||
"training": {
|
||||
"title": "Obučavanje modela",
|
||||
"description": "Vaš model se trenutno obučava u pozadini. Zatvorite ovaj dijalog, a vaš model će započeti raditi odmah kada se obuka završi."
|
||||
},
|
||||
"retryGenerate": "Ponovno generisanje",
|
||||
"noImages": "Nema generisanih uzoraka slika",
|
||||
"classifying": "Klasifikacija i obuka...",
|
||||
"trainingStarted": "Obuka je uspješno pokrenuta",
|
||||
"modelCreated": "Model je uspješno stvoren. Koristite pogled Najnovije klasifikacije da dodate slike za nedostajuće stanja, a zatim obučite model.",
|
||||
"errors": {
|
||||
"noCameras": "Nema konfigurisanih kamera",
|
||||
"noObjectLabel": "Nije odabrana oznaka objekta",
|
||||
"generateFailed": "Neuspješno generisanje primera: {{error}}",
|
||||
"generationFailed": "Generisanje nije uspješno. Molimo pokušajte ponovo.",
|
||||
"classifyFailed": "Neuspješna klasifikacija slika: {{error}}"
|
||||
},
|
||||
"generateSuccess": "Uspješno generisane uzorak slike",
|
||||
"refreshExamples": "Generiši nove primjere",
|
||||
"refreshConfirm": {
|
||||
"title": "Generiši nove primjere?",
|
||||
"description": "Ovo će generisati novi skup slika i obrisati sve odabire, uključujući prethodne klase. Trebat će vam ponovno odabrati primjere za sve klase."
|
||||
},
|
||||
"missingStatesWarning": {
|
||||
"title": "Primjeri nedostajućih klasa",
|
||||
"description": "Nisu sve klase imale primjere. Pokušajte generisanje novih primjera da biste pronašli nedostajuću klasu, ili nastavite i koristite pogled Najnovije klasifikacije da biste kasnije dodali slike."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
18
web/public/locales/bs/views/configEditor.json
Normal file
18
web/public/locales/bs/views/configEditor.json
Normal file
@ -0,0 +1,18 @@
|
||||
{
|
||||
"documentTitle": "Uređivač konfiguracije - Frigate",
|
||||
"configEditor": "Uređivač konfiguracije",
|
||||
"safeConfigEditor": "Uređivač konfiguracije (Sigurnosni režim)",
|
||||
"safeModeDescription": "Frigate je u sigurnosnom režimu zbog greške u validaciji konfiguracije.",
|
||||
"copyConfig": "Kopiraj konfiguraciju",
|
||||
"saveAndRestart": "Sačuvaj i ponovo pokreni",
|
||||
"saveOnly": "Sačuvaj samo",
|
||||
"confirm": "Napusti bez čuvanja?",
|
||||
"toast": {
|
||||
"success": {
|
||||
"copyToClipboard": "Konfiguracija kopirana u međuspremnik."
|
||||
},
|
||||
"error": {
|
||||
"savingError": "Greška prilikom čuvanja konfiguracije"
|
||||
}
|
||||
}
|
||||
}
|
||||
92
web/public/locales/bs/views/events.json
Normal file
92
web/public/locales/bs/views/events.json
Normal file
@ -0,0 +1,92 @@
|
||||
{
|
||||
"alerts": "Upozorenja",
|
||||
"detections": "Detekcije",
|
||||
"camera": "Kamera",
|
||||
"motion": {
|
||||
"label": "Kretanje",
|
||||
"only": "Samo pokret"
|
||||
},
|
||||
"allCameras": "Sve Kamere",
|
||||
"empty": {
|
||||
"alert": "Nema upozorenja za pregled",
|
||||
"detection": "Nema detekcija za pregled",
|
||||
"motion": "Nema podataka o pokretu",
|
||||
"recordingsDisabled": {
|
||||
"title": "Snimci moraju biti omogućeni",
|
||||
"description": "Pregledni stavci mogu se stvarati samo za kameru kada su snimci omogućeni za tu kameru."
|
||||
}
|
||||
},
|
||||
"timeline": {
|
||||
"label": "vremenska linija",
|
||||
"aria": "Odaberite vremensku liniju"
|
||||
},
|
||||
"zoomIn": "Uvećajte",
|
||||
"zoomOut": "Umanjite",
|
||||
"events": {
|
||||
"label": "Događaji",
|
||||
"aria": "Odaberite događaje",
|
||||
"noFoundForTimePeriod": "Nema događaja za ovaj vremenski period."
|
||||
},
|
||||
"detail": {
|
||||
"label": "Detalj",
|
||||
"noDataFound": "Nema detaljnih podataka za pregled",
|
||||
"aria": "Prekidač pregleda detalja",
|
||||
"trackedObject_one": "{{count}} objekt",
|
||||
"trackedObject_other": "{{count}} objekta",
|
||||
"noObjectDetailData": "Nema dostupnih detaljnih podataka o objektu.",
|
||||
"settings": "Postavke pregleda detalja",
|
||||
"alwaysExpandActive": {
|
||||
"title": "Uvijek proširujte aktivno",
|
||||
"desc": "Uvijek proširite detalje objekta aktivnog stavka pregleda kada su dostupni."
|
||||
}
|
||||
},
|
||||
"objectTrack": {
|
||||
"trackedPoint": "Praćeni točka",
|
||||
"clickToSeek": "Kliknite da biste prešli na ovo vrijeme"
|
||||
},
|
||||
"documentTitle": "Pregled - Frigate",
|
||||
"recordings": {
|
||||
"documentTitle": "Snimci - Frigate",
|
||||
"invalidSharedLink": "Nemoguće otvoriti vezu za snimak sa vremenskom oznakom zbog greške u parsiranju.",
|
||||
"invalidSharedCamera": "Nemoguće otvoriti vezu za snimak sa vremenskom oznakom zbog nepoznate ili neovlašćene kamere."
|
||||
},
|
||||
"calendarFilter": {
|
||||
"last24Hours": "Poslednje 24 sata"
|
||||
},
|
||||
"markAsReviewed": "Označi kao pregledano",
|
||||
"markTheseItemsAsReviewed": "Označi ove stavke kao pregledane",
|
||||
"newReviewItems": {
|
||||
"label": "Pregledaj nove stavke za pregled",
|
||||
"button": "Nove stavke za pregled"
|
||||
},
|
||||
"selected_one": "{{count}} odabrano",
|
||||
"selected_other": "{{count}} odabrano",
|
||||
"select_all": "Sve",
|
||||
"detected": "detektovano",
|
||||
"normalActivity": "Normal",
|
||||
"needsReview": "Treba pregledati",
|
||||
"securityConcern": "Sigurnosna zabrinutost",
|
||||
"motionSearch": {
|
||||
"menuItem": "Pretraga kretanja",
|
||||
"openMenu": "Opcije kamere"
|
||||
},
|
||||
"motionPreviews": {
|
||||
"menuItem": "Pregledaj preglednike kretanja",
|
||||
"title": "Preglednici kretanja: {{camera}}",
|
||||
"mobileSettingsTitle": "Postavke preglednika kretanja",
|
||||
"mobileSettingsDesc": "Prilagodite brzinu reprodukcije i osvetljavanje, i odaberite datum za pregled snimaka samo sa kretanjem.",
|
||||
"dim": "Osvetljavanje",
|
||||
"dimAria": "Prilagodite intenzitet osvetljavanja",
|
||||
"dimDesc": "Povećajte osvetljavanje da biste povećali vidljivost područja kretanja.",
|
||||
"speed": "Brzina",
|
||||
"speedAria": "Odaberite brzinu reprodukcije preglednika",
|
||||
"speedDesc": "Odaberite koliko brzo će se pregledni snimci reproducirati.",
|
||||
"back": "Nazad",
|
||||
"empty": "Nema pregleda dostupnih",
|
||||
"noPreview": "Pregled nije dostupan",
|
||||
"seekAria": "Pretражuj {{camera}} igraču do {{time}}",
|
||||
"filter": "Filtar",
|
||||
"filterDesc": "Odaberite područja da biste prikazali samo klipove sa kretanjem u tim područjima.",
|
||||
"filterClear": "Očisti"
|
||||
}
|
||||
}
|
||||
267
web/public/locales/bs/views/explore.json
Normal file
267
web/public/locales/bs/views/explore.json
Normal file
@ -0,0 +1,267 @@
|
||||
{
|
||||
"documentTitle": "Istraživanje - Frigate",
|
||||
"generativeAI": "Generativna AI",
|
||||
"exploreMore": "Istražite više {{label}} objekata",
|
||||
"exploreIsUnavailable": {
|
||||
"title": "Istraživanje nije dostupno",
|
||||
"embeddingsReindexing": {
|
||||
"context": "Istraživanje može se koristiti nakon što se reindeksiranje ugrađenih objekata završi.",
|
||||
"startingUp": "Pokretanje…",
|
||||
"estimatedTime": "Procijenjeno preostalo vrijeme:",
|
||||
"finishingShortly": "Završetak uskoro",
|
||||
"step": {
|
||||
"thumbnailsEmbedded": "Ugrađene miniaturne slike: ",
|
||||
"descriptionsEmbedded": "Ugrađene opise: ",
|
||||
"trackedObjectsProcessed": "Obrađeni praćeni objekti: "
|
||||
}
|
||||
},
|
||||
"downloadingModels": {
|
||||
"context": "Frigate preuzima potrebne modele ugrađenih objekata kako bi podržao funkciju Semantičke pretrage. Ovo može trajati nekoliko minuta ovisno o brzini vaše mreže.",
|
||||
"setup": {
|
||||
"visionModel": "Model vida",
|
||||
"visionModelFeatureExtractor": "Izvođač značajki modela vida",
|
||||
"textModel": "Model teksta",
|
||||
"textTokenizer": "Tokenizator teksta"
|
||||
},
|
||||
"tips": {
|
||||
"context": "Moguće je da želite ponovno indeksirati ugrađene objekte koji se prate nakon što se modele preuzmu."
|
||||
},
|
||||
"error": "Dogodila se greška. Provjerite zapise Frigate."
|
||||
}
|
||||
},
|
||||
"trackedObjectDetails": "Detalji praćenih objekata",
|
||||
"type": {
|
||||
"details": "Detalji",
|
||||
"snapshot": "Snimak",
|
||||
"thumbnail": "miniaturna slika",
|
||||
"video": "Video",
|
||||
"tracking_details": "detalji praćenja"
|
||||
},
|
||||
"trackingDetails": {
|
||||
"title": "Detalji praćenja",
|
||||
"noImageFound": "Nije pronađena slika za ovaj vremenski moment.",
|
||||
"createObjectMask": "Kreirajte masku objekta",
|
||||
"adjustAnnotationSettings": "Prilagodite postavke oznaka",
|
||||
"scrollViewTips": "Kliknite da biste vidjeli važne trenutke životnog ciklusa ovog objekta.",
|
||||
"autoTrackingTips": "Pozicije okvirnih kutija neće biti tačne za autotracking kamere.",
|
||||
"count": "{{first}} od {{second}}",
|
||||
"trackedPoint": "Praćena tačka",
|
||||
"lifecycleItemDesc": {
|
||||
"visible": "{{label}} detektovan",
|
||||
"entered_zone": "{{label}} ušao u {{zones}}",
|
||||
"active": "{{label}} postao aktivno",
|
||||
"stationary": "{{label}} postao stacionarno",
|
||||
"attribute": {
|
||||
"faceOrLicense_plate": "{{attribute}} detektovan za {{label}}",
|
||||
"other": "{{label}} prepoznat kao {{attribute}}"
|
||||
},
|
||||
"gone": "{{label}} otišao",
|
||||
"heard": "{{label}} čujeo",
|
||||
"external": "{{label}} detektovan",
|
||||
"header": {
|
||||
"zones": "Zone",
|
||||
"ratio": "Omjer",
|
||||
"area": "Površina",
|
||||
"score": "Rezultat",
|
||||
"computedScore": "Izračunata ocjena",
|
||||
"topScore": "Najbolja ocjena",
|
||||
"toggleAdvancedScores": "Prekidač naprednih ocjena"
|
||||
}
|
||||
},
|
||||
"annotationSettings": {
|
||||
"title": "Postavke oznaka",
|
||||
"showAllZones": {
|
||||
"title": "Prikaži sve zone",
|
||||
"desc": "Uvijek prikazujte zone na okvirima gdje su objekti ušli u zonu."
|
||||
},
|
||||
"offset": {
|
||||
"label": "Pomak oznaka",
|
||||
"desc": "Ova podatka dolaze iz vaše kamere detektovane snimke, ali se preklapaju na slikama iz snimke snimke. Vjerojatno nije moguće da su dva toka savršeno sinhronizirana. Kao rezultat, okvirni kutiji i snimke neće se savršeno poklopiti. Možete koristiti ovu postavku da pomaknete oznake unaprijed ili unazad u vremenu da bi ih bolje uskladili s snimljenim snimkom.",
|
||||
"millisecondsToOffset": "Milisekunde za pomak detektovanih oznaka. <em>Podrazumevano: 0</em>",
|
||||
"tips": "Smanjite vrijednost ako je reprodukcija videa ispred kutija i tačaka putanje, a povećajte vrijednost ako je reprodukcija videa iza njih. Ova vrijednost može biti negativna.",
|
||||
"toast": {
|
||||
"success": "Pomak anotacije za {{camera}} je sačuvan u konfiguracionu datoteku."
|
||||
}
|
||||
}
|
||||
},
|
||||
"carousel": {
|
||||
"previous": "Prethodni slajd",
|
||||
"next": "Sljedeći slajd"
|
||||
}
|
||||
},
|
||||
"details": {
|
||||
"item": {
|
||||
"title": "Pregled detalja stavke",
|
||||
"desc": "Detalji stavke za pregled",
|
||||
"button": {
|
||||
"share": "Dijelite ovu stavku za pregled",
|
||||
"viewInExplore": "Pregledajte u Explore"
|
||||
},
|
||||
"tips": {
|
||||
"mismatch_one": "{{count}} nedostupan objekat je detektovan i uključen u ovu stavku pregleda. Ti objekti se nisu kvalifikovali kao upozorenje ili detekcija, ili su već očišćeni/obrisani.",
|
||||
"mismatch_few": "{{count}} nedostupnih objekata je detektovano i uključeno u ovu stavku pregleda. Ti objekti se nisu kvalifikovali kao upozorenje ili detekciju, ili su već očišćeni/obrisani.",
|
||||
"mismatch_other": "{{count}} nedostupnih objekata je detektovano i uključeno u ovu stavku pregleda. Ti objekti se nisu kvalifikovali kao upozorenje ili detekciju, ili su već očišćeni/obrisani.",
|
||||
"hasMissingObjects": "Prilagodite svoju konfiguraciju ako želite da Frigate sačuva pratiti objekte za sljedeće oznake: <em>{{objects}}</em>"
|
||||
},
|
||||
"toast": {
|
||||
"success": {
|
||||
"regenerate": "Zahtjev za novi opis je poslat {{provider}}. Ovisno o brzini vašeg provajdera, novi opis može potrajati neko vrijeme da se ponovno generira.",
|
||||
"updatedSublabel": "Uspješno ažurirana podjezika.",
|
||||
"updatedLPR": "Uspješno ažurirana tablica.",
|
||||
"updatedAttributes": "Uspješno ažurirana atribute.",
|
||||
"audioTranscription": "Uspješno zahtjev za audio transkripciju. Ovisno o brzini vašeg Frigate servera, transkripcija može potrajati neko vrijeme da se završi."
|
||||
},
|
||||
"error": {
|
||||
"regenerate": "Neuspješno poziv {{provider}} za novi opis: {{errorMessage}}",
|
||||
"updatedSublabelFailed": "Neuspješno ažuriranje podjezika: {{errorMessage}}",
|
||||
"updatedLPRFailed": "Neuspješno ažuriranje tablice: {{errorMessage}}",
|
||||
"updatedAttributesFailed": "Neuspješno ažuriranje atribute: {{errorMessage}}",
|
||||
"audioTranscription": "Neuspješno zahtjev za audio transkripciju: {{errorMessage}}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"label": "Oznaka",
|
||||
"editSubLabel": {
|
||||
"title": "Uredi podjeziku",
|
||||
"desc": "Unesite novu podjeziku za ovaj {{label}}",
|
||||
"descNoLabel": "Unesite novu podjeziku za ovaj pratiti objekt"
|
||||
},
|
||||
"editLPR": {
|
||||
"title": "Uredi tablica",
|
||||
"desc": "Unesite novu vrijednost tablice za ovaj {{label}}",
|
||||
"descNoLabel": "Unesite novu vrijednost tablice za ovaj praćeni objekt"
|
||||
},
|
||||
"editAttributes": {
|
||||
"title": "Uredi atribute",
|
||||
"desc": "Odaberite atribute klasifikacije za ovaj {{label}}"
|
||||
},
|
||||
"snapshotScore": {
|
||||
"label": "Snimak Rezultat"
|
||||
},
|
||||
"topScore": {
|
||||
"label": "Najbolji Rezultat",
|
||||
"info": "Najbolji rezultat je najviši srednji rezultat za praćeni objekt, pa se može razlikovati od rezultata prikazanog na minijaturi rezultata pretrage."
|
||||
},
|
||||
"score": {
|
||||
"label": "Rezultat"
|
||||
},
|
||||
"recognizedLicensePlate": "Prepoznata tablica",
|
||||
"attributes": "Atributi klasifikacije",
|
||||
"estimatedSpeed": "Procijenjena brzina",
|
||||
"objects": "Objekti",
|
||||
"camera": "Kamera",
|
||||
"zones": "Zone",
|
||||
"timestamp": "Vremenski pečat",
|
||||
"button": {
|
||||
"findSimilar": "Pronađi slične",
|
||||
"regenerate": {
|
||||
"title": "Regeneriraj",
|
||||
"label": "Regeneriraj opis praćenog objekta"
|
||||
}
|
||||
},
|
||||
"description": {
|
||||
"label": "Opis",
|
||||
"placeholder": "Opis praćenog objekta",
|
||||
"aiTips": "Frigate neće tražiti opis od vašeg generativnog AI provajdera dok se životni vijek praćenog objekta ne završi."
|
||||
},
|
||||
"expandRegenerationMenu": "Proširi izbornik regeneracije",
|
||||
"regenerateFromSnapshot": "Regeneriraj iz snimka",
|
||||
"regenerateFromThumbnails": "Regeneriraj iz minijatura",
|
||||
"tips": {
|
||||
"descriptionSaved": "Uspješno sačuvan opis",
|
||||
"saveDescriptionFailed": "Neuspješno ažuriranje opisa: {{errorMessage}}"
|
||||
},
|
||||
"title": {
|
||||
"label": "Naslov"
|
||||
},
|
||||
"scoreInfo": "Informacije o rezultatu"
|
||||
},
|
||||
"itemMenu": {
|
||||
"downloadVideo": {
|
||||
"label": "Preuzmi video",
|
||||
"aria": "Preuzmi video"
|
||||
},
|
||||
"downloadSnapshot": {
|
||||
"label": "Preuzmi snimak",
|
||||
"aria": "Preuzmi snimak"
|
||||
},
|
||||
"downloadCleanSnapshot": {
|
||||
"label": "Preuzmi čist snimak",
|
||||
"aria": "Preuzmi čist snimak"
|
||||
},
|
||||
"viewTrackingDetails": {
|
||||
"label": "Pregledaj detalje praćenja",
|
||||
"aria": "Prikaži detalje praćenja"
|
||||
},
|
||||
"findSimilar": {
|
||||
"label": "Pronađi slične",
|
||||
"aria": "Pronađi slične praćene objekte"
|
||||
},
|
||||
"addTrigger": {
|
||||
"label": "Dodaj izazov",
|
||||
"aria": "Dodaj izazov za ovaj praćeni objekt"
|
||||
},
|
||||
"audioTranscription": {
|
||||
"label": "Transkriptiraj",
|
||||
"aria": "Zatraži transkripciju zvuka"
|
||||
},
|
||||
"submitToPlus": {
|
||||
"label": "Pošalji na Frigate+",
|
||||
"aria": "Pošalji na Frigate Plus"
|
||||
},
|
||||
"viewInHistory": {
|
||||
"label": "Pregledajte u povijesti",
|
||||
"aria": "Pregledajte u povijesti"
|
||||
},
|
||||
"deleteTrackedObject": {
|
||||
"label": "Obriši ovaj praćeni objekt"
|
||||
},
|
||||
"showObjectDetails": {
|
||||
"label": "Prikaži put objekta"
|
||||
},
|
||||
"hideObjectDetails": {
|
||||
"label": "Sakrij put objekta"
|
||||
},
|
||||
"debugReplay": {
|
||||
"label": "Debug ponovno snimanje",
|
||||
"aria": "Pregledaj ovaj praćeni objekt u pogledu debug ponovnog snimanja"
|
||||
},
|
||||
"more": {
|
||||
"aria": "Više"
|
||||
}
|
||||
},
|
||||
"dialog": {
|
||||
"confirmDelete": {
|
||||
"title": "Potvrdi brisanje",
|
||||
"desc": "Brisanje ovog praćenog objekta uklanja snimak, bilo kakve sačuvane ugradnje, i sve povezane unose detalja praćenja. Snimljeni materijal ovog praćenog objekta u pogledu povijesti <em>NEĆE</em> biti obrisan.<br /><br />Sigurno li želite nastaviti?"
|
||||
},
|
||||
"toast": {
|
||||
"error": "Greška prilikom brisanja ovog praćenog objekta: {{errorMessage}}"
|
||||
}
|
||||
},
|
||||
"noTrackedObjects": "Nijedan praćeni objekt nije pronađen",
|
||||
"fetchingTrackedObjectsFailed": "Greška prilikom dohvaćanja praćenih objekata: {{errorMessage}}",
|
||||
"trackedObjectsCount_one": "{{count}} praćeni objekt ",
|
||||
"trackedObjectsCount_few": "{{count}} praćena objekta ",
|
||||
"trackedObjectsCount_other": "{{count}} praćena objekta ",
|
||||
"searchResult": {
|
||||
"tooltip": "Pronađeno {{type}} na {{confidence}}%",
|
||||
"previousTrackedObject": "Prethodni praćeni objekt",
|
||||
"nextTrackedObject": "Sljedeći praćeni objekt",
|
||||
"deleteTrackedObject": {
|
||||
"toast": {
|
||||
"success": "Praćeni objekt je uspješno obrisan.",
|
||||
"error": "Neuspješno brisanje praćenog objekta: {{errorMessage}}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"aiAnalysis": {
|
||||
"title": "Analiza AI"
|
||||
},
|
||||
"concerns": {
|
||||
"label": "Pitanja"
|
||||
},
|
||||
"objectLifecycle": {
|
||||
"noImageFound": "Nije pronađena slika za ovaj praćeni objekt."
|
||||
}
|
||||
}
|
||||
128
web/public/locales/bs/views/exports.json
Normal file
128
web/public/locales/bs/views/exports.json
Normal file
@ -0,0 +1,128 @@
|
||||
{
|
||||
"search": "Pretraga",
|
||||
"documentTitle": "Izvoz - Frigate",
|
||||
"selected_one": "{{count}} odabrano",
|
||||
"selected_other": "{{count}} odabrano",
|
||||
"noExports": "Nijedan izvoz nije pronađen",
|
||||
"headings": {
|
||||
"cases": "Slučajevi",
|
||||
"uncategorizedExports": "Nekategorizirani izvozi"
|
||||
},
|
||||
"deleteExport": {
|
||||
"label": "Obriši izvoz",
|
||||
"desc": "Da li ste sigurni da želite da obrišete {{exportName}}?"
|
||||
},
|
||||
"editExport": {
|
||||
"title": "Preimenuj izvoz",
|
||||
"desc": "Unesite novi naziv za ovaj izvoz.",
|
||||
"saveExport": "Sačuvaj izvoz"
|
||||
},
|
||||
"tooltip": {
|
||||
"shareExport": "Dijeli izvoz",
|
||||
"downloadVideo": "Preuzmi video",
|
||||
"editName": "Uredi naziv",
|
||||
"deleteExport": "Obriši izvoz",
|
||||
"assignToCase": "Dodaj u slučaj",
|
||||
"removeFromCase": "Ukloni iz slučaja"
|
||||
},
|
||||
"toolbar": {
|
||||
"newCase": "Novi slučaj",
|
||||
"addExport": "Dodaj izvoz",
|
||||
"editCase": "Uredi slučaj",
|
||||
"deleteCase": "Obriši slučaj"
|
||||
},
|
||||
"toast": {
|
||||
"error": {
|
||||
"renameExportFailed": "Neuspješno preimenovanje izvoza: {{errorMessage}}",
|
||||
"assignCaseFailed": "Neuspješno ažuriranje dodjele slučaja: {{errorMessage}}",
|
||||
"caseSaveFailed": "Neuspješno čuvanje slučaja: {{errorMessage}}",
|
||||
"caseDeleteFailed": "Neuspješno brisanje slučaja: {{errorMessage}}"
|
||||
}
|
||||
},
|
||||
"deleteCase": {
|
||||
"label": "Obriši slučaj",
|
||||
"desc": "Da li ste sigurni da želite da obrišete {{caseName}}?",
|
||||
"descKeepExports": "Izvozi će ostati dostupni kao nekategorizirani izvozi.",
|
||||
"descDeleteExports": "Svi izvozi u ovom slučaju trajno će biti obrisani.",
|
||||
"deleteExports": "Takođe izbriši izvoze"
|
||||
},
|
||||
"caseDialog": {
|
||||
"title": "Dodaj u slučaj",
|
||||
"description": "Odaberite postojeći slučaj ili napravite novi.",
|
||||
"selectLabel": "Slučaj",
|
||||
"newCaseOption": "Napravite novi slučaj",
|
||||
"nameLabel": "Ime slučaja",
|
||||
"descriptionLabel": "Opis"
|
||||
},
|
||||
"caseCard": {
|
||||
"emptyCase": "Nema još izvoza"
|
||||
},
|
||||
"jobCard": {
|
||||
"defaultName": "{{camera}} izvoz",
|
||||
"queued": "U redu",
|
||||
"running": "Pokretanje",
|
||||
"preparing": "Priprema",
|
||||
"copying": "Kopiranje",
|
||||
"encoding": "Kodiranje",
|
||||
"encodingRetry": "Kodiranje (ponovi)",
|
||||
"finalizing": "Završavanje"
|
||||
},
|
||||
"caseView": {
|
||||
"noDescription": "Nema opisa",
|
||||
"createdAt": "Kreirano {{value}}",
|
||||
"exportCount_one": "1 izvoz",
|
||||
"exportCount_other": "{{count}} izvozi",
|
||||
"cameraCount_one": "1 kamera",
|
||||
"cameraCount_other": "{{count}} kamere",
|
||||
"showMore": "Prikaži više",
|
||||
"showLess": "Prikaži manje",
|
||||
"emptyTitle": "Ovaj slučaj je prazan",
|
||||
"emptyDescription": "Dodaj postojet će nekategorizirane izvoze kako bi slučaj ostao organizovan.",
|
||||
"emptyDescriptionNoExports": "Nema dostupnih nekategoriziranih izvoza koje je moguće dodati još."
|
||||
},
|
||||
"caseEditor": {
|
||||
"createTitle": "Kreiraj slučaj",
|
||||
"editTitle": "Uredi slučaj",
|
||||
"namePlaceholder": "Ime slučaja",
|
||||
"descriptionPlaceholder": "Dodaj napomene ili kontekst za ovaj slučaj"
|
||||
},
|
||||
"addExportDialog": {
|
||||
"title": "Dodaj izvoz u {{caseName}}",
|
||||
"searchPlaceholder": "Pretraga nekategoriziranih izvoza",
|
||||
"empty": "Nema nekategoriziranih izvoza koji odgovaraju ovoj pretrazi.",
|
||||
"addButton_one": "Dodaj 1 izvoz",
|
||||
"addButton_other": "Dodaj {{count}} izvoza",
|
||||
"adding": "Dodavanje..."
|
||||
},
|
||||
"bulkActions": {
|
||||
"addToCase": "Dodaj u slučaj",
|
||||
"moveToCase": "Premjesti u slučaj",
|
||||
"removeFromCase": "Ukloni iz slučaja",
|
||||
"delete": "Obriši",
|
||||
"deleteNow": "Obriši sada"
|
||||
},
|
||||
"bulkDelete": {
|
||||
"title": "Obriši izvoze",
|
||||
"desc_one": "Sigurni li ste da želite obrisati {{count}} izvoz?",
|
||||
"desc_other": "Sigurni li ste da želite obrisati {{count}} izvoze?"
|
||||
},
|
||||
"bulkRemoveFromCase": {
|
||||
"title": "Ukloni iz slučaja",
|
||||
"desc_one": "Ukloni {{count}} izvoz iz ovog slučaja?",
|
||||
"desc_other": "Ukloni {{count}} izvoze iz ovog slučaja?",
|
||||
"descKeepExports": "Izvozi će biti premješteni u nekategorizirane.",
|
||||
"descDeleteExports": "Izvozi će biti trajno obrisani.",
|
||||
"deleteExports": "Umjesto toga, obriši izvoze"
|
||||
},
|
||||
"bulkToast": {
|
||||
"success": {
|
||||
"delete": "Uspješno obrisani izvozi",
|
||||
"reassign": "Uspješno ažurirana dodjela slučaja",
|
||||
"remove": "Uspješno uklonjeni izvozi iz slučaja"
|
||||
},
|
||||
"error": {
|
||||
"deleteFailed": "Neuspješno brisanje izvoza: {{errorMessage}}",
|
||||
"reassignFailed": "Neuspješno ažuriranje dodjele slučaja: {{errorMessage}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
98
web/public/locales/bs/views/faceLibrary.json
Normal file
98
web/public/locales/bs/views/faceLibrary.json
Normal file
@ -0,0 +1,98 @@
|
||||
{
|
||||
"description": {
|
||||
"addFace": "Dodajte novu kolekciju u Biblioteku lica prema učitavanju svoje prve slike.",
|
||||
"placeholder": "Unesite ime za ovu kolekciju",
|
||||
"invalidName": "Neprihvatljivo ime. Imena mogu sadržavati samo slova, brojeve, razmake, aposrofe, donje crte i crte.",
|
||||
"nameCannotContainHash": "Ime ne može sadržavati #."
|
||||
},
|
||||
"details": {
|
||||
"unknown": "Nepoznato",
|
||||
"timestamp": "Vremenski pečat",
|
||||
"scoreInfo": "Ocjena je težinski prosjek svih ocjena lica, težinski određen prema veličini lica u svakoj slici."
|
||||
},
|
||||
"train": {
|
||||
"titleShort": "Nedavno",
|
||||
"title": "Najnovije prepoznavanja",
|
||||
"aria": "Odaberite nedavna prepoznavanja",
|
||||
"empty": "Nema nedavnih pokušaja prepoznavanja lica"
|
||||
},
|
||||
"documentTitle": "Biblioteka lica - Frigate",
|
||||
"uploadFaceImage": {
|
||||
"title": "Učitajte sliku lica",
|
||||
"desc": "Učitajte sliku za skeniranje lica i uključite za {{pageToggle}}"
|
||||
},
|
||||
"collections": "Kolekcije",
|
||||
"createFaceLibrary": {
|
||||
"new": "Stvori novo lice",
|
||||
"nextSteps": "Da biste izgradili čvrstu osnovu:<li>Koristite karticu Najnovije prepoznavanja da biste odabrali i trenirali se na slikama za svaku detektiranu osobu.</li><li>Fokusirajte se na slike iz pravog ugla za najbolje rezultate; izbjegavajte slike za treniranje koje prikazuju lica pod uglom.</li></ul>"
|
||||
},
|
||||
"steps": {
|
||||
"faceName": "Unesite ime lica",
|
||||
"uploadFace": "Učitajte sliku lica",
|
||||
"nextSteps": "Sljedeći koraci",
|
||||
"description": {
|
||||
"uploadFace": "Učitajte sliku od {{name}} koja prikazuje njihovo lice iz pravog ugla. Slika ne mora biti izrezana samo na njihovo lice."
|
||||
}
|
||||
},
|
||||
"deleteFaceLibrary": {
|
||||
"title": "Izbrišite ime",
|
||||
"desc": "Da li ste sigurni da želite izbrisati kolekciju {{name}}? Ovo će trajno izbrisati sva povezana lica."
|
||||
},
|
||||
"deleteFaceAttempts": {
|
||||
"title": "Izbrišite lica",
|
||||
"desc_one": "Da li ste sigurni da želite izbrisati {{count}} lice? Ova akcija ne može se poništiti.",
|
||||
"desc_few": "Da li ste sigurni da želite izbrisati {{count}} lica? Ova akcija ne može se poništiti.",
|
||||
"desc_other": "Da li ste sigurni da želite izbrisati {{count}} lica? Ova akcija ne može se poništiti."
|
||||
},
|
||||
"renameFace": {
|
||||
"title": "Preimenujte lice",
|
||||
"desc": "Unesite novo ime za {{name}}"
|
||||
},
|
||||
"button": {
|
||||
"deleteFaceAttempts": "Izbrišite lica",
|
||||
"addFace": "Dodaj lice",
|
||||
"renameFace": "Preimenuj lice",
|
||||
"deleteFace": "Obriši lice",
|
||||
"uploadImage": "Prenesi sliku",
|
||||
"reprocessFace": "Ponovno obradi lice"
|
||||
},
|
||||
"imageEntry": {
|
||||
"validation": {
|
||||
"selectImage": "Molimo izaberite datoteku slike."
|
||||
},
|
||||
"dropActive": "Pustite sliku ovdje…",
|
||||
"dropInstructions": "Povucite i ispišite, zalijepite sliku ovdje ili kliknite za odabir",
|
||||
"maxSize": "Maksimalna veličina: {{size}}MB"
|
||||
},
|
||||
"nofaces": "Nema dostupnih lica",
|
||||
"trainFaceAs": "Obuči lice kao:",
|
||||
"trainFace": "Obuči lice",
|
||||
"reclassifyFaceAs": "Ponovno klasificiraj lice kao:",
|
||||
"reclassifyFace": "Ponovno klasificiraj lice",
|
||||
"toast": {
|
||||
"success": {
|
||||
"uploadedImage": "Uspješno prenesena slika.",
|
||||
"addFaceLibrary": "{{name}} je uspješno dodan u biblioteku lica!",
|
||||
"deletedFace_one": "Uspješno obrisano {{count}} lice.",
|
||||
"deletedFace_few": "Uspješno obrisana {{count}} lica.",
|
||||
"deletedFace_other": "Uspješno obrisana {{count}} lica.",
|
||||
"deletedName_one": "{{count}} lice je uspješno obrisano.",
|
||||
"deletedName_few": "{{count}} lica su uspješno obrisana.",
|
||||
"deletedName_other": "{{count}} lica su uspješno obrisana.",
|
||||
"renamedFace": "Uspješno preimenovan lice na {{name}}",
|
||||
"trainedFace": "Uspješno obučeno lice.",
|
||||
"reclassifiedFace": "Uspješno ponovno klasificirano lice.",
|
||||
"updatedFaceScore": "Uspješno ažurirana ocjena lica na {{name}} ({{score}})."
|
||||
},
|
||||
"error": {
|
||||
"uploadingImageFailed": "Nije uspješno prenijeti sliku: {{errorMessage}}",
|
||||
"addFaceLibraryFailed": "Nije uspješno postaviti ime lica: {{errorMessage}}",
|
||||
"deleteFaceFailed": "Neuspješno brisanje: {{errorMessage}}",
|
||||
"deleteNameFailed": "Nije uspješno obrisati ime: {{errorMessage}}",
|
||||
"renameFaceFailed": "Nije uspješno preimenovati lice: {{errorMessage}}",
|
||||
"trainFailed": "Nije uspješno trenirati: {{errorMessage}}",
|
||||
"reclassifyFailed": "Nije uspješno ponovno klasifikovati lice: {{errorMessage}}",
|
||||
"updateFaceScoreFailed": "Nije uspješno ažurirati bodove lica: {{errorMessage}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
199
web/public/locales/bs/views/live.json
Normal file
199
web/public/locales/bs/views/live.json
Normal file
@ -0,0 +1,199 @@
|
||||
{
|
||||
"documentTitle": {
|
||||
"default": "Uživo - Frigate",
|
||||
"withCamera": "{{camera}} - Uživo - Frigate"
|
||||
},
|
||||
"lowBandwidthMode": "Nizopojasni režim",
|
||||
"twoWayTalk": {
|
||||
"enable": "Omogući dvostrani razgovor",
|
||||
"disable": "Onemogući dvostrani razgovor"
|
||||
},
|
||||
"cameraAudio": {
|
||||
"enable": "Omogući zvuk kamere",
|
||||
"disable": "Onemogući zvuk kamere"
|
||||
},
|
||||
"ptz": {
|
||||
"move": {
|
||||
"clickMove": {
|
||||
"label": "Kliknite unutar okvira da biste centrirali kameru",
|
||||
"enable": "Omogući klik za pomak",
|
||||
"enableWithZoom": "Omogući klik za pomak / povucite za uvećanje",
|
||||
"disable": "Onemogući klik za pomak"
|
||||
},
|
||||
"left": {
|
||||
"label": "Pomaknite PTZ kameru ulevo"
|
||||
},
|
||||
"up": {
|
||||
"label": "Pomaknite PTZ kameru gore"
|
||||
},
|
||||
"down": {
|
||||
"label": "Pomaknite PTZ kameru dolje"
|
||||
},
|
||||
"right": {
|
||||
"label": "Pomaknite PTZ kameru udesno"
|
||||
}
|
||||
},
|
||||
"zoom": {
|
||||
"in": {
|
||||
"label": "Uvećajte PTZ kameru"
|
||||
},
|
||||
"out": {
|
||||
"label": "Umanjite PTZ kameru"
|
||||
}
|
||||
},
|
||||
"focus": {
|
||||
"in": {
|
||||
"label": "Fokusirajte PTZ kameru unapred"
|
||||
},
|
||||
"out": {
|
||||
"label": "Fokusirajte PTZ kameru unazad"
|
||||
}
|
||||
},
|
||||
"frame": {
|
||||
"center": {
|
||||
"label": "Kliknite unutar okvira da biste centrirali PTZ kameru"
|
||||
}
|
||||
},
|
||||
"presets": "Preseti PTZ kamere"
|
||||
},
|
||||
"camera": {
|
||||
"enable": "Omogući kameru",
|
||||
"disable": "Onemogući kameru"
|
||||
},
|
||||
"muteCameras": {
|
||||
"enable": "Utišajte sve kamere",
|
||||
"disable": "Ponovo uključite zvuk za sve kamere"
|
||||
},
|
||||
"detect": {
|
||||
"enable": "Omogući detekciju",
|
||||
"disable": "Onemogući detekciju"
|
||||
},
|
||||
"recording": {
|
||||
"enable": "Omogući snimanje",
|
||||
"disable": "Onemogući snimanje"
|
||||
},
|
||||
"snapshots": {
|
||||
"enable": "Omogući snimke",
|
||||
"disable": "Onemogući snimke"
|
||||
},
|
||||
"snapshot": {
|
||||
"takeSnapshot": "Preuzmi trenutni snimak",
|
||||
"noVideoSource": "Nema dostupnog video izvora za snimak.",
|
||||
"captureFailed": "Neuspješno snimanje trenutnog snimka.",
|
||||
"downloadStarted": "Preuzimanje trenutnog snimka započeto."
|
||||
},
|
||||
"audioDetect": {
|
||||
"enable": "Omogući detekciju zvuka",
|
||||
"disable": "Onemogući detekciju zvuka"
|
||||
},
|
||||
"transcription": {
|
||||
"enable": "Omogući prepoznavanje zvuka uživo",
|
||||
"disable": "Onemogući prepoznavanje zvuka uživo"
|
||||
},
|
||||
"autotracking": {
|
||||
"enable": "Omogući automatsko praćenje",
|
||||
"disable": "Onemogući automatsko praćenje"
|
||||
},
|
||||
"streamStats": {
|
||||
"enable": "Prikaži statistiku prijenosa",
|
||||
"disable": "Sakrij statistiku prijenosa"
|
||||
},
|
||||
"manualRecording": {
|
||||
"title": "Na zahtjev",
|
||||
"tips": "Preuzmi trenutni snimak ili pokreni ručni događaj na temelju postavki trajanja snimanja ove kamere.",
|
||||
"playInBackground": {
|
||||
"label": "Ponovno postavi stream",
|
||||
"desc": "Omogući ovu opciju da nastavi streamanje kada je pokazivač sakriven."
|
||||
},
|
||||
"showStats": {
|
||||
"label": "Prikaži statistiku",
|
||||
"desc": "Omogući ovu opciju da prikaže statistiku prijenosa kao preklapanje na toku kamere."
|
||||
},
|
||||
"debugView": "Pregled za otklanjanje grešaka",
|
||||
"start": "Počni snimanje na zahtjev",
|
||||
"started": "Pokrenuto ručno snimanje na zahtjev.",
|
||||
"failedToStart": "Neuspješno pokretanje ručnog snimanja na zahtjev.",
|
||||
"recordDisabledTips": "Kako je snimanje onemogućeno ili ograničeno u konfiguraciji za ovu kameru, spremat će se samo snimak.",
|
||||
"end": "Završi snimanje na zahtjev",
|
||||
"ended": "Završeno ručno snimanje na zahtjev.",
|
||||
"failedToEnd": "Neuspješno završavanje ručnog snimanja na zahtjev."
|
||||
},
|
||||
"streamingSettings": "Postavke streamanja",
|
||||
"notifications": "Obavještenja",
|
||||
"audio": "Audio",
|
||||
"suspend": {
|
||||
"forTime": "Pauziraj za: "
|
||||
},
|
||||
"stream": {
|
||||
"title": "Tok",
|
||||
"audio": {
|
||||
"tips": {
|
||||
"title": "Audio mora biti izlaz iz vaše kamere i konfiguriran u go2rtc za ovaj stream."
|
||||
},
|
||||
"available": "Audio je dostupan za ovaj stream",
|
||||
"unavailable": "Audio nije dostupan za ovaj stream"
|
||||
},
|
||||
"debug": {
|
||||
"picker": "Izbor streama nije dostupan u režimu debuga. Pregled debuga uvijek koristi stream dodeljen ulozi detekcije."
|
||||
},
|
||||
"twoWayTalk": {
|
||||
"tips": "Vaš uređaj mora podržavati funkciju, a WebRTC mora biti konfiguriran za dvosmernu komunikaciju.",
|
||||
"available": "Dvosmerna komunikacija je dostupna za ovaj stream",
|
||||
"unavailable": "Dvosmerna komunikacija nije dostupna za ovaj stream"
|
||||
},
|
||||
"lowBandwidth": {
|
||||
"tips": "Živo prikazivanje je u režimu niske propusnosti zbog buferiranja ili grešaka u streamu.",
|
||||
"resetStream": "Ponovno postavi stream"
|
||||
},
|
||||
"playInBackground": {
|
||||
"label": "Ponovno postavi stream",
|
||||
"tips": "Omogući ovu opciju da nastavi streamanje kada je pokazivač sakriven."
|
||||
}
|
||||
},
|
||||
"cameraSettings": {
|
||||
"title": "{{camera}} Postavke",
|
||||
"cameraEnabled": "Kamera omogućena",
|
||||
"objectDetection": "Detekcija objekata",
|
||||
"recording": "Snimanje",
|
||||
"snapshots": "Snimci",
|
||||
"audioDetection": "Detekcija zvuka",
|
||||
"transcription": "Transkripcija zvuka",
|
||||
"autotracking": "Autotračenje"
|
||||
},
|
||||
"history": {
|
||||
"label": "Prikaži povijesne snimke"
|
||||
},
|
||||
"effectiveRetainMode": {
|
||||
"modes": {
|
||||
"all": "Sve",
|
||||
"motion": "Kretanje",
|
||||
"active_objects": "Aktivni objekti"
|
||||
}
|
||||
},
|
||||
"editLayout": {
|
||||
"label": "Uredi raspored",
|
||||
"group": {
|
||||
"label": "Uredi grupu kamera"
|
||||
},
|
||||
"exitEdit": "Izađi iz uređivanja"
|
||||
},
|
||||
"noCameras": {
|
||||
"title": "Nema konfiguriranih kamera",
|
||||
"description": "Počnite tako što ćete povezati kameru s Frigate.",
|
||||
"buttonText": "Dodaj kameru",
|
||||
"restricted": {
|
||||
"title": "Nema dostupnih kamera",
|
||||
"description": "Nemate dozvolu za pregled bilo koje kamere u ovoj grupi."
|
||||
},
|
||||
"default": {
|
||||
"title": "Nema konfiguriranih kamera",
|
||||
"description": "Počnite tako što ćete povezati kameru s Frigate.",
|
||||
"buttonText": "Dodaj kameru"
|
||||
},
|
||||
"group": {
|
||||
"title": "Nema kamera u grupi",
|
||||
"description": "Ova grupa kamera nema dodeljene ili omogućene kamere.",
|
||||
"buttonText": "Upravljajte grupama"
|
||||
}
|
||||
}
|
||||
}
|
||||
77
web/public/locales/bs/views/motionSearch.json
Normal file
77
web/public/locales/bs/views/motionSearch.json
Normal file
@ -0,0 +1,77 @@
|
||||
{
|
||||
"documentTitle": "Pretraga pokreta - Frigate",
|
||||
"title": "Pretraga pokreta",
|
||||
"description": "Nacrtaj poligon da biste definirali regiju interesa, a zatim navedite vremenski raspon za pretragu promjena pokreta unutar te regije.",
|
||||
"selectCamera": "Pretraga pokreta učitava se",
|
||||
"startSearch": "Počni pretragu",
|
||||
"searchStarted": "Pretraga započeta",
|
||||
"searchCancelled": "Pretraga otkazana",
|
||||
"cancelSearch": "Otkaži",
|
||||
"searching": "Pretraga u toku.",
|
||||
"searchComplete": "Pretraga završena",
|
||||
"noResultsYet": "Pokrenite pretragu da biste pronašli promjene pokreta u odabranoj regiji",
|
||||
"noChangesFound": "Nisu otkrivene promjene piksela u odabranoj regiji",
|
||||
"changesFound_one": "Pronađeno {{count}} promjena pokreta",
|
||||
"changesFound_few": "Pronađeno {{count}} nekoliko pokreta",
|
||||
"changesFound_other": "Pronađeno {{count}} promjene pokreta",
|
||||
"framesProcessed": "{{count}} okvir procesiran",
|
||||
"jumpToTime": "Preskoči na ovo vrijeme",
|
||||
"results": "Rezultati",
|
||||
"showSegmentHeatmap": "Top mapa",
|
||||
"newSearch": "Nova pretraga",
|
||||
"clearResults": "Očisti rezultate",
|
||||
"clearROI": "Očisti poligon",
|
||||
"polygonControls": {
|
||||
"points_one": "{{count}} tačka",
|
||||
"points_few": "{{count}} tačke",
|
||||
"points_other": "{{count}} tačke",
|
||||
"undo": "Poništi posljednju tačku",
|
||||
"reset": "Ponovi poligon"
|
||||
},
|
||||
"motionHeatmapLabel": "Top mapa pokreta",
|
||||
"dialog": {
|
||||
"title": "Pretraga pokreta",
|
||||
"cameraLabel": "Kamera",
|
||||
"previewAlt": "Pregled kamere za {{camera}}"
|
||||
},
|
||||
"timeRange": {
|
||||
"title": "Opseg pretrage",
|
||||
"start": "Početno vrijeme",
|
||||
"end": "Krajnje vrijeme"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Postavke pretrage",
|
||||
"parallelMode": "Paralelni način",
|
||||
"parallelModeDesc": "Skeniranje više segmenata snimaka istovremeno (brže, ali značajno intenzivnije za CPU)",
|
||||
"threshold": "Praga osjetljivosti",
|
||||
"thresholdDesc": "Niže vrijednosti detektiraju manje promjene (1-255)",
|
||||
"minArea": "Minimalna površina promjene",
|
||||
"minAreaDesc": "Minimalni postotak područja interesa koji mora promijeniti da bi se smatrao značajnim",
|
||||
"frameSkip": "Preskoči okvir",
|
||||
"frameSkipDesc": "Obrađujte svaki N-ti okvir. Postavite ovo na brzinu okvira vaše kamere da biste obradili jedan okvir po sekundi (npr. 5 za 5 FPS kameru, 30 za 30 FPS kameru). Više vrijednosti će biti brže, ali mogu propustiti kratke događaje pokreta.",
|
||||
"maxResults": "Maksimalni rezultati",
|
||||
"maxResultsDesc": "Zaustavi nakon ovog broja odgovarajućih vremenskih oznaka"
|
||||
},
|
||||
"errors": {
|
||||
"noCamera": "Molimo odaberite kameru",
|
||||
"noROI": "Molimo nacrtajte područje interesa",
|
||||
"noTimeRange": "Molimo odaberite vremenski opseg",
|
||||
"invalidTimeRange": "Krajnje vrijeme mora biti nakon početnog vremena",
|
||||
"searchFailed": "Pretraga neuspješna: {{message}}",
|
||||
"polygonTooSmall": "Poligon mora imati najmanje 3 točke",
|
||||
"unknown": "Nepoznata greška"
|
||||
},
|
||||
"changePercentage": "{{percentage}}% promijenjeno",
|
||||
"metrics": {
|
||||
"title": "Metrike pretrage",
|
||||
"segmentsScanned": "Skenirani segmenti",
|
||||
"segmentsProcessed": "Obrađeno",
|
||||
"segmentsSkippedInactive": "Preskočeno (bez aktivnosti)",
|
||||
"segmentsSkippedHeatmap": "Preskočeno (bez preklapanja ROI)",
|
||||
"fallbackFullRange": "Povratni put skeniranje cijelog opsega",
|
||||
"framesDecoded": "Dekodirani okviri",
|
||||
"wallTime": "Vrijeme pretrage",
|
||||
"segmentErrors": "Greške segmenta",
|
||||
"seconds": "{{seconds}}s"
|
||||
}
|
||||
}
|
||||
12
web/public/locales/bs/views/recording.json
Normal file
12
web/public/locales/bs/views/recording.json
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"filter": "Filtar",
|
||||
"export": "Izvoz",
|
||||
"calendar": "Kalendar",
|
||||
"filters": "Filtari",
|
||||
"toast": {
|
||||
"error": {
|
||||
"noValidTimeSelected": "Nije odabran valjan vremenski opseg",
|
||||
"endTimeMustAfterStartTime": "Krajnje vrijeme mora biti nakon početnog vremena"
|
||||
}
|
||||
}
|
||||
}
|
||||
59
web/public/locales/bs/views/replay.json
Normal file
59
web/public/locales/bs/views/replay.json
Normal file
@ -0,0 +1,59 @@
|
||||
{
|
||||
"title": "Debug ponavljanje",
|
||||
"description": "Ponovno prikazivanje snimaka kamere za ispitivanje. Lista objekata prikazuje zakasnjelje sažetak detektiranih objekata, a kartica Zapisi prikazuje tok unutrašnjih poruka Frigate iz snimaka ponavljanja.",
|
||||
"websocket_messages": "Poruke",
|
||||
"dialog": {
|
||||
"title": "Počni debug ponavljanje",
|
||||
"description": "Kreiraj privremenu kameru za ponavljanje koja ponavlja povijesne snimke za ispitivanje problema detekcije i praćenja objekata. Kamera za ponavljanje će imati istu konfiguraciju detekcije kao i izvorna kamera. Odaberite vremenski raspon za početak.",
|
||||
"camera": "Izvorna kamera",
|
||||
"timeRange": "Vremenski opseg",
|
||||
"preset": {
|
||||
"1m": "Posljednja 1 minuta",
|
||||
"5m": "Posljednje 5 minuta",
|
||||
"timeline": "Iz vremenske linije",
|
||||
"custom": "Prilagođeno"
|
||||
},
|
||||
"startButton": "Počni ponavljanje",
|
||||
"selectFromTimeline": "Odaberite",
|
||||
"starting": "Pokretanje ponavljanja...",
|
||||
"startLabel": "Početak",
|
||||
"endLabel": "Kraj",
|
||||
"toast": {
|
||||
"error": "Neuspješno pokretanje debug ponavljanja: {{error}}",
|
||||
"alreadyActive": "Već postoji aktivna sesija ponavljanja",
|
||||
"stopError": "Neuspješno zaustavljanje debug ponavljanja: {{error}}",
|
||||
"goToReplay": "Idi na ponavljanje"
|
||||
}
|
||||
},
|
||||
"page": {
|
||||
"noSession": "Nema aktivne sesije ponavljanja",
|
||||
"noSessionDesc": "Pokrenite debug ponavljanje iz pogleda Povijest klikom na dugme Debug Replay u alatnoj traci.",
|
||||
"goToRecordings": "Idi na povijest",
|
||||
"sourceCamera": "Izvorna kamera",
|
||||
"replayCamera": "Kamera za ponavljanje",
|
||||
"initializingReplay": "Inicijalizacija ponavljanja...",
|
||||
"stoppingReplay": "Zaustavljanje ponavljanja...",
|
||||
"stopReplay": "Zaustavi ponavljanje",
|
||||
"confirmStop": {
|
||||
"title": "Zaustavi režim ponavljanja za debagovanje?",
|
||||
"description": "Ovo će zaustaviti sesiju ponavljanja i očistiti sve privremene podatke. Sigurni li?",
|
||||
"confirm": "Zaustavi ponavljanje",
|
||||
"cancel": "Otkaži"
|
||||
},
|
||||
"activity": "Aktivnost",
|
||||
"objects": "Popis objekata",
|
||||
"audioDetections": "Audio detekcije",
|
||||
"noActivity": "Nema detektovane aktivnosti",
|
||||
"activeTracking": "Aktivno praćenje",
|
||||
"noActiveTracking": "Nema aktivnog praćenja",
|
||||
"configuration": "Konfiguracija",
|
||||
"configurationDesc": "Podesiti precizno detekciju pokreta i praćenje objekata za kameru za debagovanje ponavljanja. Promjene se ne čuvaju u datoteci konfiguracije Frigate.",
|
||||
"preparingClip": "Pripremam klip…",
|
||||
"preparingClipDesc": "Frigate spaja snimke za odabrani vremenski raspon. Ovo može potrajati minut za duže raspone.",
|
||||
"startingCamera": "Pokretanje ponovnog pokretanja otklanjanja grešaka…",
|
||||
"startError": {
|
||||
"title": "Neuspjelo pokretanje ponovnog prikaza otklanjanja grešaka",
|
||||
"back": "Povratak na historiju"
|
||||
}
|
||||
}
|
||||
}
|
||||
73
web/public/locales/bs/views/search.json
Normal file
73
web/public/locales/bs/views/search.json
Normal file
@ -0,0 +1,73 @@
|
||||
{
|
||||
"search": "Pretraga",
|
||||
"button": {
|
||||
"save": "Sačuvaj pretragu",
|
||||
"clear": "Očisti pretragu",
|
||||
"delete": "Obriši sačuvanu pretragu",
|
||||
"filterInformation": "Filtrirajte informacije",
|
||||
"filterActive": "Filtari aktivni"
|
||||
},
|
||||
"savedSearches": "Sačuvane pretrage",
|
||||
"searchFor": "Pretraga za {{inputValue}}",
|
||||
"trackedObjectId": "ID praćenog objekta",
|
||||
"filter": {
|
||||
"label": {
|
||||
"cameras": "Kamere",
|
||||
"labels": "Oznake",
|
||||
"zones": "Zone",
|
||||
"sub_labels": "Podoznake",
|
||||
"attributes": "Atributi",
|
||||
"search_type": "Tip pretrage",
|
||||
"time_range": "Vremenski opseg",
|
||||
"before": "Prije",
|
||||
"after": "Nakon",
|
||||
"min_score": "Min. bodovi",
|
||||
"max_score": "Max. bodovi",
|
||||
"min_speed": "Min. brzina",
|
||||
"max_speed": "Max. brzina",
|
||||
"recognized_license_plate": "Prepoznata tablica",
|
||||
"has_clip": "Ima klip",
|
||||
"has_snapshot": "Ima snimak"
|
||||
},
|
||||
"searchType": {
|
||||
"thumbnail": "Minijatura",
|
||||
"description": "Opis"
|
||||
},
|
||||
"toast": {
|
||||
"error": {
|
||||
"beforeDateBeLaterAfter": "Datum 'before' mora biti kasniji od datuma 'after'.",
|
||||
"afterDatebeEarlierBefore": "Datum 'after' mora biti raniji od datuma 'before'.",
|
||||
"minScoreMustBeLessOrEqualMaxScore": "Vrijednost 'min_score' mora biti manja ili jednaka vrijednosti 'max_score'.",
|
||||
"maxScoreMustBeGreaterOrEqualMinScore": "Vrijednost 'max_score' mora biti veća ili jednaka vrijednosti 'min_score'.",
|
||||
"minSpeedMustBeLessOrEqualMaxSpeed": "Vrijednost 'min_speed' mora biti manja ili jednaka vrijednosti 'max_speed'.",
|
||||
"maxSpeedMustBeGreaterOrEqualMinSpeed": "Vrijednost 'max_speed' mora biti veća ili jednaka vrijednosti 'min_speed'."
|
||||
}
|
||||
},
|
||||
"tips": {
|
||||
"title": "Kako koristiti tekstualne filtere",
|
||||
"desc": {
|
||||
"text": "Filteri vam pomažu da sužite rezultate pretrage. Evo kako ih koristiti u polju za unos:",
|
||||
"step1": "Unesite ime ključa filtera, zatim dvojtočku (npr. \"kamere:\").",
|
||||
"step2": "Izaberite vrijednost iz predloga ili unesite vlastitu.",
|
||||
"step3": "Koristite više filtera dodavanjem jednog za drugim s razmakom između.",
|
||||
"step4": "Filteri datuma (pre: i nakon:) koriste {{DateFormat}} format.",
|
||||
"step5": "Filter raspona vremena koristi format {{exampleTime}}.",
|
||||
"step6": "Uklonite filtre klikom na 'x' pored njih.",
|
||||
"exampleLabel": "Primjer:"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
"currentFilterType": "Vrijednosti filtera",
|
||||
"noFilters": "Filtari",
|
||||
"activeFilters": "Aktivni filteri"
|
||||
}
|
||||
},
|
||||
"similaritySearch": {
|
||||
"title": "Pretraga sličnosti",
|
||||
"active": "Pretraga sličnosti aktivna",
|
||||
"clear": "Očisti pretragu sličnosti"
|
||||
},
|
||||
"placeholder": {
|
||||
"search": "Pretraži…"
|
||||
}
|
||||
}
|
||||
1698
web/public/locales/bs/views/settings.json
Normal file
1698
web/public/locales/bs/views/settings.json
Normal file
File diff suppressed because it is too large
Load Diff
256
web/public/locales/bs/views/system.json
Normal file
256
web/public/locales/bs/views/system.json
Normal file
@ -0,0 +1,256 @@
|
||||
{
|
||||
"documentTitle": {
|
||||
"cameras": "Statistika kamere - Frigate",
|
||||
"storage": "Statistika skladišta - Frigate",
|
||||
"general": "Opća statistika - Frigate",
|
||||
"enrichments": "Statistika bogatstva - Frigate",
|
||||
"logs": {
|
||||
"frigate": "Zapisi Frigate - Frigate",
|
||||
"go2rtc": "Zapisi Go2RTC - Frigate",
|
||||
"nginx": "Zapisi Nginx - Frigate",
|
||||
"websocket": "Zapisi poruka - Frigate"
|
||||
}
|
||||
},
|
||||
"title": "Sistem",
|
||||
"metrics": "Sistem metrike",
|
||||
"logs": {
|
||||
"websocket": {
|
||||
"label": "Zapisi",
|
||||
"pause": "Pauziraj",
|
||||
"resume": "Nastavi",
|
||||
"clear": "Očisti",
|
||||
"filter": {
|
||||
"all": "Svi temi",
|
||||
"topics": "Teme",
|
||||
"events": "Događaji",
|
||||
"reviews": "Pregledi",
|
||||
"classification": "Klasifikacija",
|
||||
"face_recognition": "Prepoznavanje lica",
|
||||
"lpr": "LPR",
|
||||
"camera_activity": "Aktivnost kamere",
|
||||
"system": "Sistem",
|
||||
"camera": "Kamera",
|
||||
"all_cameras": "Sve kamere",
|
||||
"cameras_count_one": "{{count}} Kamera",
|
||||
"cameras_count_other": "{{count}} Kamere"
|
||||
},
|
||||
"empty": "Nema još prihvaćenih poruka",
|
||||
"count_one": "{{count}} poruka",
|
||||
"count_other": "{{count}} poruke",
|
||||
"expanded": {
|
||||
"payload": "Opterećenje"
|
||||
}
|
||||
},
|
||||
"download": {
|
||||
"label": "Preuzimanje zapisa"
|
||||
},
|
||||
"copy": {
|
||||
"label": "Kopiraj u clipboard",
|
||||
"success": "Zapisi su kopirani u clipboard",
|
||||
"error": "Nije moguće kopirati zapise u clipboard"
|
||||
},
|
||||
"type": {
|
||||
"label": "Tip",
|
||||
"timestamp": "Vremenski pečat",
|
||||
"tag": "Oznaka",
|
||||
"message": "Poruka"
|
||||
},
|
||||
"tips": "Zapisi se prenose sa servera",
|
||||
"toast": {
|
||||
"error": {
|
||||
"fetchingLogsFailed": "Greška prilikom preuzimanja zapisa: {{errorMessage}}",
|
||||
"whileStreamingLogs": "Greška prilikom prijenosa protokola: {{errorMessage}}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"general": {
|
||||
"title": "Općenito",
|
||||
"detector": {
|
||||
"title": "Detektori",
|
||||
"inferenceSpeed": "Brzina zaključivanja detektora",
|
||||
"temperature": "Temperatura detektora",
|
||||
"cpuUsage": "Korištenje CPU detektora",
|
||||
"cpuUsageInformation": "CPU korištena za pripremu ulaznih i izlaznih podataka za/iz modela detekcije. Ova vrijednost ne mjeri korištenje zaključivanja, čak i ako se koristi GPU ili ubrzivač.",
|
||||
"memoryUsage": "Korištenje memorije detektora"
|
||||
},
|
||||
"hardwareInfo": {
|
||||
"title": "Hardverske informacije",
|
||||
"gpuUsage": "Korištenje GPU",
|
||||
"gpuMemory": "Memorija GPU",
|
||||
"gpuEncoder": "Kodiralo GPU",
|
||||
"gpuCompute": "GPU Izračunavanje / Kodiranje",
|
||||
"gpuDecoder": "Dekodiranje GPU",
|
||||
"gpuTemperature": "Temperatura GPU",
|
||||
"gpuInfo": {
|
||||
"vainfoOutput": {
|
||||
"title": "Vainfo Izlaz",
|
||||
"returnCode": "Kod povratka: {{code}}",
|
||||
"processOutput": "Izlaz procesa:",
|
||||
"processError": "Greška procesa:"
|
||||
},
|
||||
"nvidiaSMIOutput": {
|
||||
"title": "Nvidia SMI Izlaz",
|
||||
"name": "Ime: {{name}}",
|
||||
"driver": "Vozač: {{driver}}",
|
||||
"cudaComputerCapability": "CUDA sposobnost izračunavanja: {{cuda_compute}}",
|
||||
"vbios": "VBios informacije: {{vbios}}"
|
||||
},
|
||||
"closeInfo": {
|
||||
"label": "Zatvori informacije GPU"
|
||||
},
|
||||
"copyInfo": {
|
||||
"label": "Kopiraj informacije GPU"
|
||||
},
|
||||
"toast": {
|
||||
"success": "Kopirano informacije GPU u međuspremnik"
|
||||
}
|
||||
},
|
||||
"npuUsage": "Korišćenje NPU",
|
||||
"npuMemory": "Memorija NPU",
|
||||
"npuTemperature": "Temperatura NPU",
|
||||
"intelGpuWarning": {
|
||||
"title": "Upozorenje o statistikama Intel GPU",
|
||||
"message": "Statistike GPU nedostupne",
|
||||
"description": "Ovo je poznati bug u alatima za prikaz statistika Intel GPU (intel_gpu_top) gdje će se prekiniti i ponovo vratiti GPU korišćenje od 0% čak i u slučajevima kada se hardverska akceleracija i detekcija objekata ispravno izvršavaju na (i)GPU. Ovo nije bug Frigate. Možete ponovo pokrenuti host kako biste privremeno popravili problem i potvrdili da GPU radi ispravno. Ovo ne utiče na performanse."
|
||||
}
|
||||
},
|
||||
"otherProcesses": {
|
||||
"title": "Drugi procesi",
|
||||
"processCpuUsage": "Korišćenje CPU procesa",
|
||||
"processMemoryUsage": "Korišćenje memorije procesa",
|
||||
"series": {
|
||||
"go2rtc": "go2rtc",
|
||||
"recording": "Snimanje",
|
||||
"review_segment": "pregled segmenta",
|
||||
"embeddings": "Ugrađivanja",
|
||||
"audio_detector": "audio detektor"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
"title": "Skladište",
|
||||
"overview": "Pregled",
|
||||
"recordings": {
|
||||
"title": "Snimci",
|
||||
"tips": "Ova vrijednost predstavlja ukupno skladište koje se koristi za snimke u bazi podataka Frigate. Frigate ne praćenje korišćenje skladišta za sve datoteke na vašem disku.",
|
||||
"earliestRecording": "Najstariji dostupni snimak:"
|
||||
},
|
||||
"shm": {
|
||||
"title": "Alokacija SHM (deljenja memorije)",
|
||||
"warning": "Trenutna veličina SHM od {{total}}MB je prevelika. Povećajte je na najmanje {{min_shm}}MB.",
|
||||
"frameLifetime": {
|
||||
"title": "Vijek trajanja okvira",
|
||||
"description": "Svaka kamera ima {{frames}} slotova za okvire u deljenoj memoriji. Na najbržoj brzini okvira kamere, svaki okvir je dostupan za približno {{lifetime}}s prije nego što se prepiše."
|
||||
}
|
||||
},
|
||||
"cameraStorage": {
|
||||
"title": "Skladište kamere",
|
||||
"camera": "Kamera",
|
||||
"unusedStorageInformation": "Informacije o neiskorišćenom skladištu",
|
||||
"storageUsed": "Skladište",
|
||||
"percentageOfTotalUsed": "Postotak ukupno",
|
||||
"bandwidth": "Širina pojasa",
|
||||
"unused": {
|
||||
"title": "Neiskorišćeno",
|
||||
"tips": "Ova vrijednost može nepravilno predstavljati slobodno prostor dostupan Frigate ako imate druge datoteke pohranjene na vašem disku izvan snimaka Frigate. Frigate ne praćenje korišćenje skladišta izvan svojih snimaka."
|
||||
}
|
||||
}
|
||||
},
|
||||
"cameras": {
|
||||
"title": "Kamere",
|
||||
"overview": "Pregled",
|
||||
"info": {
|
||||
"aspectRatio": "odnos stranica",
|
||||
"cameraProbeInfo": "{{camera}} Informacije o ispitivanju kamere",
|
||||
"streamDataFromFFPROBE": "Podaci o prijenosu se dobijaju pomoću <code>ffprobe</code>.",
|
||||
"fetching": "Prenošenje podataka o kameri",
|
||||
"stream": "Prijenos {{idx}}",
|
||||
"video": "Video:",
|
||||
"codec": "Kodek:",
|
||||
"resolution": "Rješenje:",
|
||||
"fps": "FPS:",
|
||||
"unknown": "Nepoznato",
|
||||
"audio": "Zvuk:",
|
||||
"error": "Greška: {{error}}",
|
||||
"tips": {
|
||||
"title": "Informacije o ispitivanju kamere"
|
||||
}
|
||||
},
|
||||
"framesAndDetections": "Okviri / Detekcije",
|
||||
"label": {
|
||||
"camera": "Kamera",
|
||||
"detect": "detektirati",
|
||||
"skipped": "preskočeno",
|
||||
"ffmpeg": "FFmpeg",
|
||||
"capture": "snimiti",
|
||||
"overallFramesPerSecond": "ukupni okviri po sekundi",
|
||||
"overallDetectionsPerSecond": "ukupne detekcije po sekundi",
|
||||
"overallSkippedDetectionsPerSecond": "ukupno preskočene detekcije po sekundi",
|
||||
"cameraFfmpeg": "{{camName}} FFmpeg",
|
||||
"cameraCapture": "{{camName}} snimiti",
|
||||
"cameraDetect": "{{camName}} detektirati",
|
||||
"cameraGpu": "{{camName}} GPU",
|
||||
"cameraFramesPerSecond": "{{camName}} okviri po sekundi",
|
||||
"cameraDetectionsPerSecond": "{{camName}} detekcije po sekundi",
|
||||
"cameraSkippedDetectionsPerSecond": "{{camName}} preskočenih detekcija u sekundi"
|
||||
},
|
||||
"connectionQuality": {
|
||||
"title": "Kvaliteta veze",
|
||||
"excellent": "Izuzetno dobra",
|
||||
"fair": "Uredna",
|
||||
"poor": "Loša",
|
||||
"unusable": "Nepogodna",
|
||||
"fps": "FPS",
|
||||
"expectedFps": "Očekivani FPS",
|
||||
"reconnectsLastHour": "Ponovne povezivanja (posljednje satu)",
|
||||
"stallsLastHour": "Pauze (posljednje satu)"
|
||||
},
|
||||
"toast": {
|
||||
"success": {
|
||||
"copyToClipboard": "Podaci o testiranju kopirani u clipboard."
|
||||
},
|
||||
"error": {
|
||||
"unableToProbeCamera": "Nemoguće testiranje kamere: {{errorMessage}}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"lastRefreshed": "Posljednje ažuriranje: ",
|
||||
"stats": {
|
||||
"ffmpegHighCpuUsage": "{{camera}} ima visoku upotrebu CPU za FFmpeg ({{ffmpegAvg}}%)",
|
||||
"detectHighCpuUsage": "{{camera}} ima visoku upotrebu CPU za detekciju ({{detectAvg}}%)",
|
||||
"healthy": "Sistem je zdrav",
|
||||
"reindexingEmbeddings": "Ponovno indeksiranje ugrađenih vjerodajnica ({{processed}}% završeno)",
|
||||
"cameraIsOffline": "{{camera}} je offline",
|
||||
"detectIsSlow": "{{detect}} je spor ({{speed}} ms)",
|
||||
"detectIsVerySlow": "{{detect}} je vrlo spor ({{speed}} ms)",
|
||||
"shmTooLow": "/dev/shm alokacija ({{total}} MB) treba povećati na najmanje {{min}} MB.",
|
||||
"debugReplayActive": "Debug ponavljanje sesije je aktivno"
|
||||
},
|
||||
"enrichments": {
|
||||
"title": "Obogaćivanja",
|
||||
"infPerSecond": "Inferencije po sekundi",
|
||||
"averageInf": "Prosjek vremena inferencije",
|
||||
"embeddings": {
|
||||
"image_embedding": "Slika ugrađenih vjerodajnica",
|
||||
"text_embedding": "Tekst ugrađenih vjerodajnica",
|
||||
"face_recognition": "Prepoznavanje lica",
|
||||
"plate_recognition": "Prepoznavanje ploča",
|
||||
"image_embedding_speed": "Brzina ugradnje slika",
|
||||
"face_embedding_speed": "Brzina ugradnje lica",
|
||||
"face_recognition_speed": "Brzina prepoznavanja lica",
|
||||
"plate_recognition_speed": "Brzina prepoznavanja ploča",
|
||||
"text_embedding_speed": "Brzina ugradnje teksta",
|
||||
"yolov9_plate_detection_speed": "Brzina detekcije ploča YOLOv9",
|
||||
"yolov9_plate_detection": "Detekcija ploča YOLOv9",
|
||||
"review_description": "Pregled opisa",
|
||||
"review_description_speed": "Brzina pregleda opisa",
|
||||
"review_description_events_per_second": "Pregled opisa",
|
||||
"object_description": "Opis objekta",
|
||||
"object_description_speed": "Brzina opisa objekta",
|
||||
"object_description_events_per_second": "Opis objekta",
|
||||
"classification": "{{name}} Klasifikacija",
|
||||
"classification_speed": "{{name}} Brzina klasifikacije",
|
||||
"classification_events_per_second": "{{name}} Događaji klasifikacije po sekundi"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -138,7 +138,7 @@
|
||||
"plucked_string_instrument": "Instrument de corda pinçada",
|
||||
"guitar": "Guitarra",
|
||||
"electric_guitar": "Guitarra elèctrica",
|
||||
"bass_guitar": "Baix",
|
||||
"bass_guitar": "Guitarra baixa",
|
||||
"acoustic_guitar": "Guitarra acústica",
|
||||
"steel_guitar": "Guitarra steel",
|
||||
"tapping": "Tapping",
|
||||
|
||||
@ -49,7 +49,8 @@
|
||||
"gl": "Galego (Gallec)",
|
||||
"id": "Bahasa Indonesia (Indonesi)",
|
||||
"ur": "اردو (Urdú)",
|
||||
"hr": "Hrvatski (croat)"
|
||||
"hr": "Hrvatski (croat)",
|
||||
"bs": "Bosanski (Bosni)"
|
||||
},
|
||||
"system": "Sistema",
|
||||
"systemMetrics": "Mètriques del sistema",
|
||||
@ -242,7 +243,7 @@
|
||||
"done": "Fet",
|
||||
"disabled": "Deshabilitat",
|
||||
"disable": "Deshabilitar",
|
||||
"save": "Guardar",
|
||||
"save": "Desa",
|
||||
"copy": "Copiar",
|
||||
"back": "Enrere",
|
||||
"pictureInPicture": "Imatge en Imatge",
|
||||
|
||||
@ -13,7 +13,7 @@
|
||||
"description": "Habilitat"
|
||||
},
|
||||
"audio": {
|
||||
"label": "Esdeveniments d'àudio",
|
||||
"label": "Detecció d'àudio",
|
||||
"description": "Configuració per a la detecció d'esdeveniments basats en àudio per a aquesta càmera.",
|
||||
"enabled": {
|
||||
"label": "Habilita la detecció d'àudio",
|
||||
@ -33,7 +33,11 @@
|
||||
},
|
||||
"filters": {
|
||||
"label": "Filtres d'àudio",
|
||||
"description": "Paràmetres de filtre per-àudio-tipus, com ara llindars de confiança utilitzats per reduir falsos positius."
|
||||
"description": "Paràmetres de filtre per-àudio-tipus, com ara llindars de confiança utilitzats per reduir falsos positius.",
|
||||
"threshold": {
|
||||
"label": "Confiança mínima de l'àudio",
|
||||
"description": "Llindar mínim de confiança per a l'esdeveniment d'àudio a comptar."
|
||||
}
|
||||
},
|
||||
"enabled_in_config": {
|
||||
"label": "Estat d'àudio original",
|
||||
@ -485,6 +489,10 @@
|
||||
"hwaccel_args": {
|
||||
"label": "Exporta els arguments de l'hwaccel",
|
||||
"description": "Args d'acceleració de maquinari a utilitzar per a operacions d'exportació/transcodificació."
|
||||
},
|
||||
"max_concurrent": {
|
||||
"label": "Màxim d'exportacions concurrents",
|
||||
"description": "Nombre màxim de treballs d'exportació a processar al mateix temps."
|
||||
}
|
||||
},
|
||||
"preview": {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user