mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-12-06 13:34:13 +03:00
Compare commits
40 Commits
f9faa7ebfd
...
0c4edc10b5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c4edc10b5 | ||
|
|
9d4aac2b8e | ||
|
|
aa09132dfd | ||
|
|
24766ce427 | ||
|
|
97b29d177a | ||
|
|
1a75251ffb | ||
|
|
048475e750 | ||
|
|
1b57fb15a7 | ||
|
|
cd606ad240 | ||
|
|
de2144f158 | ||
|
|
e79ff9a079 | ||
|
|
fe47620153 | ||
|
|
8520ade5c4 | ||
|
|
1c7ed45f21 | ||
|
|
130c7c9eec | ||
|
|
26e630aa8c | ||
|
|
a478da45a3 | ||
|
|
694f72d577 | ||
|
|
1e42cedf9e | ||
|
|
a35a0fc8ba | ||
|
|
10b7ffe3d1 | ||
|
|
42c6cfc9a2 | ||
|
|
e8bf570d21 | ||
|
|
cdbd9038b8 | ||
|
|
1e05abb0ea | ||
|
|
70d1c2e041 | ||
|
|
f4d128b3ee | ||
|
|
dd64ffca6c | ||
|
|
fce1f78bdc | ||
|
|
69ca63d608 | ||
|
|
111b83e8e3 | ||
|
|
198733b729 | ||
|
|
03d9fd6f19 | ||
|
|
f90a54f1d9 | ||
|
|
bbec4c4a60 | ||
|
|
9fe16d7b17 | ||
|
|
dc886b11f3 | ||
|
|
3bbe24f5f8 | ||
|
|
2a9c028f55 | ||
|
|
aa8b423b68 |
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@ -15,7 +15,7 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
PYTHON_VERSION: 3.9
|
||||
PYTHON_VERSION: 3.11
|
||||
|
||||
jobs:
|
||||
amd64_build:
|
||||
|
||||
51
README_CN.md
51
README_CN.md
@ -1,28 +1,31 @@
|
||||
<p align="center">
|
||||
<img align="center" alt="logo" src="docs/static/img/frigate.png">
|
||||
<img align="center" alt="logo" src="docs/static/img/branding/frigate.png">
|
||||
</p>
|
||||
|
||||
# Frigate - 一个具有实时目标检测的本地NVR
|
||||
# Frigate NVR™ - 一个具有实时目标检测的本地 NVR
|
||||
|
||||
[English](https://github.com/blakeblackshear/frigate) | \[简体中文\]
|
||||
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
|
||||
<a href="https://hosted.weblate.org/engage/frigate-nvr/-/zh_Hans/">
|
||||
<img src="https://hosted.weblate.org/widget/frigate-nvr/-/zh_Hans/svg-badge.svg" alt="翻译状态" />
|
||||
</a>
|
||||
|
||||
一个完整的本地网络视频录像机(NVR),专为[Home Assistant](https://www.home-assistant.io)设计,具备AI物体检测功能。使用OpenCV和TensorFlow在本地为IP摄像头执行实时物体检测。
|
||||
一个完整的本地网络视频录像机(NVR),专为[Home Assistant](https://www.home-assistant.io)设计,具备 AI 目标/物体检测功能。使用 OpenCV 和 TensorFlow 在本地为 IP 摄像头执行实时物体检测。
|
||||
|
||||
强烈推荐使用GPU或者AI加速器(例如[Google Coral加速器](https://coral.ai/products/) 或者 [Hailo](https://hailo.ai/))。它们的性能甚至超过目前的顶级CPU,并且可以以极低的耗电实现更优的性能。
|
||||
- 通过[自定义组件](https://github.com/blakeblackshear/frigate-hass-integration)与Home Assistant紧密集成
|
||||
- 设计上通过仅在必要时和必要地点寻找物体,最大限度地减少资源使用并最大化性能
|
||||
强烈推荐使用 GPU 或者 AI 加速器(例如[Google Coral 加速器](https://coral.ai/products/) 或者 [Hailo](https://hailo.ai/)等)。它们的运行效率远远高于现在的顶级 CPU,并且功耗也极低。
|
||||
|
||||
- 通过[自定义组件](https://github.com/blakeblackshear/frigate-hass-integration)与 Home Assistant 紧密集成
|
||||
- 设计上通过仅在必要时和必要地点寻找目标,最大限度地减少资源使用并最大化性能
|
||||
- 大量利用多进程处理,强调实时性而非处理每一帧
|
||||
- 使用非常低开销的运动检测来确定运行物体检测的位置
|
||||
- 使用TensorFlow进行物体检测,运行在单独的进程中以达到最大FPS
|
||||
- 通过MQTT进行通信,便于集成到其他系统中
|
||||
- 使用非常低开销的画面变动检测(也叫运动检测)来确定运行目标检测的位置
|
||||
- 使用 TensorFlow 进行目标检测,并运行在单独的进程中以达到最大 FPS
|
||||
- 通过 MQTT 进行通信,便于集成到其他系统中
|
||||
- 根据检测到的物体设置保留时间进行视频录制
|
||||
- 24/7全天候录制
|
||||
- 通过RTSP重新流传输以减少摄像头的连接数
|
||||
- 支持WebRTC和MSE,实现低延迟的实时观看
|
||||
- 24/7 全天候录制
|
||||
- 通过 RTSP 重新流传输以减少摄像头的连接数
|
||||
- 支持 WebRTC 和 MSE,实现低延迟的实时观看
|
||||
|
||||
## 社区中文翻译文档
|
||||
|
||||
@ -32,39 +35,55 @@
|
||||
|
||||
如果您想通过捐赠支持开发,请使用 [Github Sponsors](https://github.com/sponsors/blakeblackshear)。
|
||||
|
||||
## 协议
|
||||
|
||||
本项目采用 **MIT 许可证**授权。
|
||||
**代码部分**:本代码库中的源代码、配置文件和文档均遵循 [MIT 许可证](LICENSE)。您可以自由使用、修改和分发这些代码,但必须保留原始版权声明。
|
||||
|
||||
**商标部分**:“Frigate”名称、“Frigate NVR”品牌以及 Frigate 的 Logo 为 **Frigate LLC 的商标**,**不在** MIT 许可证覆盖范围内。
|
||||
有关品牌资产的规范使用详情,请参阅我们的[《商标政策》](TRADEMARK.md)。
|
||||
|
||||
## 截图
|
||||
|
||||
### 实时监控面板
|
||||
|
||||
<div>
|
||||
<img width="800" alt="实时监控面板" src="https://github.com/blakeblackshear/frigate/assets/569905/5e713cb9-9db5-41dc-947a-6937c3bc376e">
|
||||
</div>
|
||||
|
||||
### 简单的核查工作流程
|
||||
|
||||
<div>
|
||||
<img width="800" alt="简单的审查工作流程" src="https://github.com/blakeblackshear/frigate/assets/569905/6fed96e8-3b18-40e5-9ddc-31e6f3c9f2ff">
|
||||
</div>
|
||||
|
||||
### 多摄像头可按时间轴查看
|
||||
|
||||
<div>
|
||||
<img width="800" alt="多摄像头可按时间轴查看" src="https://github.com/blakeblackshear/frigate/assets/569905/d6788a15-0eeb-4427-a8d4-80b93cae3d74">
|
||||
</div>
|
||||
|
||||
### 内置遮罩和区域编辑器
|
||||
|
||||
<div>
|
||||
<img width="800" alt="内置遮罩和区域编辑器" src="https://github.com/blakeblackshear/frigate/assets/569905/d7885fc3-bfe6-452f-b7d0-d957cb3e31f5">
|
||||
</div>
|
||||
|
||||
|
||||
## 翻译
|
||||
|
||||
我们使用 [Weblate](https://hosted.weblate.org/projects/frigate-nvr/) 平台提供翻译支持,欢迎参与进来一起完善。
|
||||
|
||||
|
||||
## 非官方中文讨论社区
|
||||
欢迎加入中文讨论QQ群:[1043861059](https://qm.qq.com/q/7vQKsTmSz)
|
||||
|
||||
欢迎加入中文讨论 QQ 群:[1043861059](https://qm.qq.com/q/7vQKsTmSz)
|
||||
|
||||
Bilibili:https://space.bilibili.com/3546894915602564
|
||||
|
||||
|
||||
## 中文社区赞助商
|
||||
|
||||
[](https://edgeone.ai/zh?from=github)
|
||||
本项目 CDN 加速及安全防护由 Tencent EdgeOne 赞助
|
||||
|
||||
---
|
||||
|
||||
**Copyright © 2025 Frigate LLC.**
|
||||
|
||||
@ -81,3 +81,5 @@ librosa==0.11.*
|
||||
soundfile==0.13.*
|
||||
# DeGirum detector
|
||||
degirum == 0.16.*
|
||||
# Memory profiling
|
||||
memray == 1.15.*
|
||||
|
||||
@ -15,7 +15,7 @@ ARG AMDGPU
|
||||
|
||||
RUN apt update -qq && \
|
||||
apt install -y wget gpg && \
|
||||
wget -O rocm.deb https://repo.radeon.com/amdgpu-install/7.0.2/ubuntu/jammy/amdgpu-install_7.0.2.70002-1_all.deb && \
|
||||
wget -O rocm.deb https://repo.radeon.com/amdgpu-install/7.1.1/ubuntu/jammy/amdgpu-install_7.1.1.70101-1_all.deb && \
|
||||
apt install -y ./rocm.deb && \
|
||||
apt update && \
|
||||
apt install -qq -y rocm
|
||||
|
||||
@ -1 +1 @@
|
||||
onnxruntime-migraphx @ https://github.com/NickM-27/frigate-onnxruntime-rocm/releases/download/v7.0.2/onnxruntime_migraphx-1.23.1-cp311-cp311-linux_x86_64.whl
|
||||
onnxruntime-migraphx @ https://github.com/NickM-27/frigate-onnxruntime-rocm/releases/download/v7.1.0/onnxruntime_migraphx-1.23.1-cp311-cp311-linux_x86_64.whl
|
||||
@ -2,7 +2,7 @@ variable "AMDGPU" {
|
||||
default = "gfx900"
|
||||
}
|
||||
variable "ROCM" {
|
||||
default = "7.0.2"
|
||||
default = "7.1.1"
|
||||
}
|
||||
variable "HSA_OVERRIDE_GFX_VERSION" {
|
||||
default = ""
|
||||
|
||||
@ -75,7 +75,13 @@ audio:
|
||||
|
||||
### Audio Transcription
|
||||
|
||||
Frigate supports fully local audio transcription using either `sherpa-onnx` or OpenAI’s open-source Whisper models via `faster-whisper`. To enable transcription, enable it in your config. Note that audio detection must also be enabled as described above in order to use audio transcription features.
|
||||
Frigate supports fully local audio transcription using either `sherpa-onnx` or OpenAI’s open-source Whisper models via `faster-whisper`. The goal of this feature is to support Semantic Search for `speech` audio events. Frigate is not intended to act as a continuous, fully-automatic speech transcription service — automatically transcribing all speech (or queuing many audio events for transcription) requires substantial CPU (or GPU) resources and is impractical on most systems. For this reason, transcriptions for events are initiated manually from the UI or the API rather than being run continuously in the background.
|
||||
|
||||
Transcription accuracy also depends heavily on the quality of your camera's microphone and recording conditions. Many cameras use inexpensive microphones, and distance to the speaker, low audio bitrate, or background noise can significantly reduce transcription quality. If you need higher accuracy, more robust long-running queues, or large-scale automatic transcription, consider using the HTTP API in combination with an automation platform and a cloud transcription service.
|
||||
|
||||
#### Configuration
|
||||
|
||||
To enable transcription, enable it in your config. Note that audio detection must also be enabled as described above in order to use audio transcription features.
|
||||
|
||||
```yaml
|
||||
audio_transcription:
|
||||
@ -144,4 +150,26 @@ In order to use transcription and translation for past events, you must enable a
|
||||
|
||||
The transcribed/translated speech will appear in the description box in the Tracked Object Details pane. If Semantic Search is enabled, embeddings are generated for the transcription text and are fully searchable using the description search type.
|
||||
|
||||
Recorded `speech` events will always use a `whisper` model, regardless of the `model_size` config setting. Without a GPU, generating transcriptions for longer `speech` events may take a fair amount of time, so be patient.
|
||||
:::note
|
||||
|
||||
Only one `speech` event may be transcribed at a time. Frigate does not automatically transcribe `speech` events or implement a queue for long-running transcription model inference.
|
||||
|
||||
:::
|
||||
|
||||
Recorded `speech` events will always use a `whisper` model, regardless of the `model_size` config setting. Without a supported Nvidia GPU, generating transcriptions for longer `speech` events may take a fair amount of time, so be patient.
|
||||
|
||||
#### FAQ
|
||||
|
||||
1. Why doesn't Frigate automatically transcribe all `speech` events?
|
||||
|
||||
Frigate does not implement a queue mechanism for speech transcription, and adding one is not trivial. A proper queue would need backpressure, prioritization, memory/disk buffering, retry logic, crash recovery, and safeguards to prevent unbounded growth when events outpace processing. That’s a significant amount of complexity for a feature that, in most real-world environments, would mostly just churn through low-value noise.
|
||||
|
||||
Because transcription is **serialized (one event at a time)** and speech events can be generated far faster than they can be processed, an auto-transcribe toggle would very quickly create an ever-growing backlog and degrade core functionality. For the amount of engineering and risk involved, it adds **very little practical value** for the majority of deployments, which are often on low-powered, edge hardware.
|
||||
|
||||
If you hear speech that’s actually important and worth saving/indexing for the future, **just press the transcribe button in Explore** on that specific `speech` event - that keeps things explicit, reliable, and under your control.
|
||||
|
||||
2. Why don't you save live transcription text and use that for `speech` events?
|
||||
|
||||
There’s no guarantee that a `speech` event is even created from the exact audio that went through the transcription model. Live transcription and `speech` event creation are **separate, asynchronous processes**. Even when both are correctly configured, trying to align the **precise start and end time of a speech event** with whatever audio the model happened to be processing at that moment is unreliable.
|
||||
|
||||
Automatically persisting that data would often result in **misaligned, partial, or irrelevant transcripts**, while still incurring all of the CPU, storage, and privacy costs of transcription. That’s why Frigate treats transcription as an **explicit, user-initiated action** rather than an automatic side-effect of every `speech` event.
|
||||
|
||||
@ -232,7 +232,7 @@ When your browser runs into problems playing back your camera streams, it will l
|
||||
- **mse-decode**
|
||||
|
||||
- What it means: The browser reported a decoding error while trying to play the stream, which usually is a result of a codec incompatibility or corrupted frames.
|
||||
- What to try: Ensure your camera/restream is using H.264 video and AAC audio (these are the most compatible). If your camera uses a non-standard audio codec, configure `go2rtc` to transcode the stream to AAC. Try another browser (some browsers have stricter MSE/codec support) and, for iPhone, ensure you're on iOS 17.1 or newer.
|
||||
- What to try: Check the browser console for the supported and negotiated codecs. Ensure your camera/restream is using H.264 video and AAC audio (these are the most compatible). If your camera uses a non-standard audio codec, configure `go2rtc` to transcode the stream to AAC. Try another browser (some browsers have stricter MSE/codec support) and, for iPhone, ensure you're on iOS 17.1 or newer.
|
||||
|
||||
- Possible console messages from the player code:
|
||||
|
||||
|
||||
@ -700,11 +700,11 @@ genai:
|
||||
# Optional: Configuration for audio transcription
|
||||
# NOTE: only the enabled option can be overridden at the camera level
|
||||
audio_transcription:
|
||||
# Optional: Enable license plate recognition (default: shown below)
|
||||
# Optional: Enable live and speech event audio transcription (default: shown below)
|
||||
enabled: False
|
||||
# Optional: The device to run the models on (default: shown below)
|
||||
# Optional: The device to run the models on for live transcription. (default: shown below)
|
||||
device: CPU
|
||||
# Optional: Set the model size used for transcription. (default: shown below)
|
||||
# Optional: Set the model size used for live transcription. (default: shown below)
|
||||
model_size: small
|
||||
# Optional: Set the language used for transcription translation. (default: shown below)
|
||||
# List of language codes: https://github.com/openai/whisper/blob/main/whisper/tokenizer.py#L10
|
||||
|
||||
@ -159,7 +159,7 @@ Inference speeds vary greatly depending on the CPU or GPU used, some known examp
|
||||
| Intel HD 530 | 15 - 35 ms | | | | Can only run one detector instance |
|
||||
| Intel HD 620 | 15 - 25 ms | | 320: ~ 35 ms | | |
|
||||
| Intel HD 630 | ~ 15 ms | | 320: ~ 30 ms | | |
|
||||
| Intel UHD 730 | ~ 10 ms | | 320: ~ 19 ms 640: ~ 54 ms | | |
|
||||
| Intel UHD 730 | ~ 10 ms | t-320: 14ms s-320: 24ms t-640: 34ms s-640: 65ms | 320: ~ 19 ms 640: ~ 54 ms | | |
|
||||
| Intel UHD 770 | ~ 15 ms | t-320: ~ 16 ms s-320: ~ 20 ms s-640: ~ 40 ms | 320: ~ 20 ms 640: ~ 46 ms | | |
|
||||
| Intel N100 | ~ 15 ms | s-320: 30 ms | 320: ~ 25 ms | | Can only run one detector instance |
|
||||
| Intel N150 | ~ 15 ms | t-320: 16 ms s-320: 24 ms | | | |
|
||||
|
||||
129
docs/docs/troubleshooting/memory.md
Normal file
129
docs/docs/troubleshooting/memory.md
Normal file
@ -0,0 +1,129 @@
|
||||
---
|
||||
id: memory
|
||||
title: Memory Troubleshooting
|
||||
---
|
||||
|
||||
Frigate includes built-in memory profiling using [memray](https://bloomberg.github.io/memray/) to help diagnose memory issues. This feature allows you to profile specific Frigate modules to identify memory leaks, excessive allocations, or other memory-related problems.
|
||||
|
||||
## Enabling Memory Profiling
|
||||
|
||||
Memory profiling is controlled via the `FRIGATE_MEMRAY_MODULES` environment variable. Set it to a comma-separated list of module names you want to profile:
|
||||
|
||||
```bash
|
||||
export FRIGATE_MEMRAY_MODULES="frigate.review_segment_manager,frigate.capture"
|
||||
```
|
||||
|
||||
### Module Names
|
||||
|
||||
Frigate processes are named using a module-based naming scheme. Common module names include:
|
||||
|
||||
- `frigate.review_segment_manager` - Review segment processing
|
||||
- `frigate.recording_manager` - Recording management
|
||||
- `frigate.capture` - Camera capture processes (all cameras with this module name)
|
||||
- `frigate.process` - Camera processing/tracking (all cameras with this module name)
|
||||
- `frigate.output` - Output processing
|
||||
- `frigate.audio_manager` - Audio processing
|
||||
- `frigate.embeddings` - Embeddings processing
|
||||
|
||||
You can also specify the full process name (including camera-specific identifiers) if you want to profile a specific camera:
|
||||
|
||||
```bash
|
||||
export FRIGATE_MEMRAY_MODULES="frigate.capture:front_door"
|
||||
```
|
||||
|
||||
When you specify a module name (e.g., `frigate.capture`), all processes with that module prefix will be profiled. For example, `frigate.capture` will profile all camera capture processes.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Binary File Creation**: When profiling is enabled, memray creates a binary file (`.bin`) in `/config/memray_reports/` that is updated continuously in real-time as the process runs.
|
||||
|
||||
2. **Automatic HTML Generation**: On normal process exit, Frigate automatically:
|
||||
|
||||
- Stops memray tracking
|
||||
- Generates an HTML flamegraph report
|
||||
- Saves it to `/config/memray_reports/<module_name>.html`
|
||||
|
||||
3. **Crash Recovery**: If a process crashes (SIGKILL, segfault, etc.), the binary file is preserved with all data up to the crash point. You can manually generate the HTML report from the binary file.
|
||||
|
||||
## Viewing Reports
|
||||
|
||||
### Automatic Reports
|
||||
|
||||
After a process exits normally, you'll find HTML reports in `/config/memray_reports/`. Open these files in a web browser to view interactive flamegraphs showing memory usage patterns.
|
||||
|
||||
### Manual Report Generation
|
||||
|
||||
If a process crashes or you want to generate a report from an existing binary file, you can manually create the HTML report:
|
||||
|
||||
```bash
|
||||
memray flamegraph /config/memray_reports/<module_name>.bin
|
||||
```
|
||||
|
||||
This will generate an HTML file that you can open in your browser.
|
||||
|
||||
## Understanding the Reports
|
||||
|
||||
Memray flamegraphs show:
|
||||
|
||||
- **Memory allocations over time**: See where memory is being allocated in your code
|
||||
- **Call stacks**: Understand the full call chain leading to allocations
|
||||
- **Memory hotspots**: Identify functions or code paths that allocate the most memory
|
||||
- **Memory leaks**: Spot patterns where memory is allocated but not freed
|
||||
|
||||
The interactive HTML reports allow you to:
|
||||
|
||||
- Zoom into specific time ranges
|
||||
- Filter by function names
|
||||
- View detailed allocation information
|
||||
- Export data for further analysis
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Profile During Issues**: Enable profiling when you're experiencing memory issues, not all the time, as it adds some overhead.
|
||||
|
||||
2. **Profile Specific Modules**: Instead of profiling everything, focus on the modules you suspect are causing issues.
|
||||
|
||||
3. **Let Processes Run**: Allow processes to run for a meaningful duration to capture representative memory usage patterns.
|
||||
|
||||
4. **Check Binary Files**: If HTML reports aren't generated automatically (e.g., after a crash), check for `.bin` files in `/config/memray_reports/` and generate reports manually.
|
||||
|
||||
5. **Compare Reports**: Generate reports at different times to compare memory usage patterns and identify trends.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### No Reports Generated
|
||||
|
||||
- Check that the environment variable is set correctly
|
||||
- Verify the module name matches exactly (case-sensitive)
|
||||
- Check logs for memray-related errors
|
||||
- Ensure `/config/memray_reports/` directory exists and is writable
|
||||
|
||||
### Process Crashed Before Report Generation
|
||||
|
||||
- Look for `.bin` files in `/config/memray_reports/`
|
||||
- Manually generate HTML reports using: `memray flamegraph <file>.bin`
|
||||
- The binary file contains all data up to the crash point
|
||||
|
||||
### Reports Show No Data
|
||||
|
||||
- Ensure the process ran long enough to generate meaningful data
|
||||
- Check that memray is properly installed (included by default in Frigate)
|
||||
- Verify the process actually started and ran (check process logs)
|
||||
|
||||
## Example Usage
|
||||
|
||||
```bash
|
||||
# Enable profiling for review and capture modules
|
||||
export FRIGATE_MEMRAY_MODULES="frigate.review_segment_manager,frigate.capture"
|
||||
|
||||
# Start Frigate
|
||||
# ... let it run for a while ...
|
||||
|
||||
# Check for reports
|
||||
ls -lh /config/memray_reports/
|
||||
|
||||
# If a process crashed, manually generate report
|
||||
memray flamegraph /config/memray_reports/frigate_capture_front_door.bin
|
||||
```
|
||||
|
||||
For more information about memray and interpreting reports, see the [official memray documentation](https://bloomberg.github.io/memray/).
|
||||
@ -131,6 +131,7 @@ const sidebars: SidebarsConfig = {
|
||||
"troubleshooting/recordings",
|
||||
"troubleshooting/gpu",
|
||||
"troubleshooting/edgetpu",
|
||||
"troubleshooting/memory",
|
||||
],
|
||||
Development: [
|
||||
"development/contributing",
|
||||
|
||||
@ -4,10 +4,15 @@
|
||||
border-bottom: 1px solid #ffd166;
|
||||
text-align: center;
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.alert a {
|
||||
[data-theme="dark"] .alert {
|
||||
background: #3b2f0b;
|
||||
border-bottom: 1px solid #665c22;
|
||||
}
|
||||
|
||||
.alert a {
|
||||
color: #1890ff;
|
||||
font-weight: 500;
|
||||
margin-left: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
@ -23,7 +23,7 @@ from markupsafe import escape
|
||||
from peewee import SQL, fn, operator
|
||||
from pydantic import ValidationError
|
||||
|
||||
from frigate.api.auth import require_role
|
||||
from frigate.api.auth import allow_any_authenticated, allow_public, require_role
|
||||
from frigate.api.defs.query.app_query_parameters import AppTimelineHourlyQueryParameters
|
||||
from frigate.api.defs.request.app_body import AppConfigSetBody
|
||||
from frigate.api.defs.tags import Tags
|
||||
@ -56,29 +56,33 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=[Tags.app])
|
||||
|
||||
|
||||
@router.get("/", response_class=PlainTextResponse)
|
||||
@router.get(
|
||||
"/", response_class=PlainTextResponse, dependencies=[Depends(allow_public())]
|
||||
)
|
||||
def is_healthy():
|
||||
return "Frigate is running. Alive and healthy!"
|
||||
|
||||
|
||||
@router.get("/config/schema.json")
|
||||
@router.get("/config/schema.json", dependencies=[Depends(allow_public())])
|
||||
def config_schema(request: Request):
|
||||
return Response(
|
||||
content=request.app.frigate_config.schema_json(), media_type="application/json"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/version", response_class=PlainTextResponse)
|
||||
@router.get(
|
||||
"/version", response_class=PlainTextResponse, dependencies=[Depends(allow_public())]
|
||||
)
|
||||
def version():
|
||||
return VERSION
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
@router.get("/stats", dependencies=[Depends(allow_any_authenticated())])
|
||||
def stats(request: Request):
|
||||
return JSONResponse(content=request.app.stats_emitter.get_latest_stats())
|
||||
|
||||
|
||||
@router.get("/stats/history")
|
||||
@router.get("/stats/history", dependencies=[Depends(allow_any_authenticated())])
|
||||
def stats_history(request: Request, keys: str = None):
|
||||
if keys:
|
||||
keys = keys.split(",")
|
||||
@ -86,7 +90,7 @@ def stats_history(request: Request, keys: str = None):
|
||||
return JSONResponse(content=request.app.stats_emitter.get_stats_history(keys))
|
||||
|
||||
|
||||
@router.get("/metrics")
|
||||
@router.get("/metrics", dependencies=[Depends(allow_any_authenticated())])
|
||||
def metrics(request: Request):
|
||||
"""Expose Prometheus metrics endpoint and update metrics with latest stats"""
|
||||
# Retrieve the latest statistics and update the Prometheus metrics
|
||||
@ -103,7 +107,7 @@ def metrics(request: Request):
|
||||
return Response(content=content, media_type=content_type)
|
||||
|
||||
|
||||
@router.get("/config")
|
||||
@router.get("/config", dependencies=[Depends(allow_any_authenticated())])
|
||||
def config(request: Request):
|
||||
config_obj: FrigateConfig = request.app.frigate_config
|
||||
config: dict[str, dict[str, Any]] = config_obj.model_dump(
|
||||
@ -209,7 +213,7 @@ def config_raw_paths(request: Request):
|
||||
return JSONResponse(content=raw_paths)
|
||||
|
||||
|
||||
@router.get("/config/raw")
|
||||
@router.get("/config/raw", dependencies=[Depends(allow_any_authenticated())])
|
||||
def config_raw():
|
||||
config_file = find_config_file()
|
||||
|
||||
@ -452,7 +456,7 @@ def config_set(request: Request, body: AppConfigSetBody):
|
||||
)
|
||||
|
||||
|
||||
@router.get("/vainfo")
|
||||
@router.get("/vainfo", dependencies=[Depends(allow_any_authenticated())])
|
||||
def vainfo():
|
||||
vainfo = vainfo_hwaccel()
|
||||
return JSONResponse(
|
||||
@ -472,12 +476,16 @@ def vainfo():
|
||||
)
|
||||
|
||||
|
||||
@router.get("/nvinfo")
|
||||
@router.get("/nvinfo", dependencies=[Depends(allow_any_authenticated())])
|
||||
def nvinfo():
|
||||
return JSONResponse(content=get_nvidia_driver_info())
|
||||
|
||||
|
||||
@router.get("/logs/{service}", tags=[Tags.logs])
|
||||
@router.get(
|
||||
"/logs/{service}",
|
||||
tags=[Tags.logs],
|
||||
dependencies=[Depends(allow_any_authenticated())],
|
||||
)
|
||||
async def logs(
|
||||
service: str = Path(enum=["frigate", "nginx", "go2rtc"]),
|
||||
download: Optional[str] = None,
|
||||
@ -585,7 +593,7 @@ def restart():
|
||||
)
|
||||
|
||||
|
||||
@router.get("/labels")
|
||||
@router.get("/labels", dependencies=[Depends(allow_any_authenticated())])
|
||||
def get_labels(camera: str = ""):
|
||||
try:
|
||||
if camera:
|
||||
@ -603,7 +611,7 @@ def get_labels(camera: str = ""):
|
||||
return JSONResponse(content=labels)
|
||||
|
||||
|
||||
@router.get("/sub_labels")
|
||||
@router.get("/sub_labels", dependencies=[Depends(allow_any_authenticated())])
|
||||
def get_sub_labels(split_joined: Optional[int] = None):
|
||||
try:
|
||||
events = Event.select(Event.sub_label).distinct()
|
||||
@ -634,7 +642,7 @@ def get_sub_labels(split_joined: Optional[int] = None):
|
||||
return JSONResponse(content=sub_labels)
|
||||
|
||||
|
||||
@router.get("/plus/models")
|
||||
@router.get("/plus/models", dependencies=[Depends(allow_any_authenticated())])
|
||||
def plusModels(request: Request, filterByCurrentModelDetector: bool = False):
|
||||
if not request.app.frigate_config.plus_api.is_active():
|
||||
return JSONResponse(
|
||||
@ -676,7 +684,9 @@ def plusModels(request: Request, filterByCurrentModelDetector: bool = False):
|
||||
return JSONResponse(content=validModels)
|
||||
|
||||
|
||||
@router.get("/recognized_license_plates")
|
||||
@router.get(
|
||||
"/recognized_license_plates", dependencies=[Depends(allow_any_authenticated())]
|
||||
)
|
||||
def get_recognized_license_plates(split_joined: Optional[int] = None):
|
||||
try:
|
||||
query = (
|
||||
@ -710,7 +720,7 @@ def get_recognized_license_plates(split_joined: Optional[int] = None):
|
||||
return JSONResponse(content=recognized_license_plates)
|
||||
|
||||
|
||||
@router.get("/timeline")
|
||||
@router.get("/timeline", dependencies=[Depends(allow_any_authenticated())])
|
||||
def timeline(camera: str = "all", limit: int = 100, source_id: Optional[str] = None):
|
||||
clauses = []
|
||||
|
||||
@ -747,7 +757,7 @@ def timeline(camera: str = "all", limit: int = 100, source_id: Optional[str] = N
|
||||
return JSONResponse(content=[t for t in timeline])
|
||||
|
||||
|
||||
@router.get("/timeline/hourly")
|
||||
@router.get("/timeline/hourly", dependencies=[Depends(allow_any_authenticated())])
|
||||
def hourly_timeline(params: AppTimelineHourlyQueryParameters = Depends()):
|
||||
"""Get hourly summary for timeline."""
|
||||
cameras = params.cameras
|
||||
|
||||
@ -32,10 +32,178 @@ from frigate.models import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def require_admin_by_default():
|
||||
"""
|
||||
Global admin requirement dependency for all endpoints by default.
|
||||
|
||||
This is set as the default dependency on the FastAPI app to ensure all
|
||||
endpoints require admin access unless explicitly overridden with
|
||||
allow_public(), allow_any_authenticated(), or require_role().
|
||||
|
||||
Port 5000 (internal) always has admin role set by the /auth endpoint,
|
||||
so this check passes automatically for internal requests.
|
||||
|
||||
Certain paths are exempted from the global admin check because they must
|
||||
be accessible before authentication (login, auth) or they have their own
|
||||
route-level authorization dependencies that handle access control.
|
||||
"""
|
||||
# Paths that have route-level auth dependencies and should bypass global admin check
|
||||
# These paths still have authorization - it's handled by their route-level dependencies
|
||||
EXEMPT_PATHS = {
|
||||
# Public auth endpoints (allow_public)
|
||||
"/auth",
|
||||
"/auth/first_time_login",
|
||||
"/login",
|
||||
# Authenticated user endpoints (allow_any_authenticated)
|
||||
"/logout",
|
||||
"/profile",
|
||||
# Public info endpoints (allow_public)
|
||||
"/",
|
||||
"/version",
|
||||
"/config/schema.json",
|
||||
# Authenticated user endpoints (allow_any_authenticated)
|
||||
"/metrics",
|
||||
"/stats",
|
||||
"/stats/history",
|
||||
"/config",
|
||||
"/config/raw",
|
||||
"/vainfo",
|
||||
"/nvinfo",
|
||||
"/labels",
|
||||
"/sub_labels",
|
||||
"/plus/models",
|
||||
"/recognized_license_plates",
|
||||
"/timeline",
|
||||
"/timeline/hourly",
|
||||
"/recordings/storage",
|
||||
"/recordings/summary",
|
||||
"/recordings/unavailable",
|
||||
"/go2rtc/streams",
|
||||
"/event_ids",
|
||||
"/events",
|
||||
"/exports",
|
||||
}
|
||||
|
||||
# Path prefixes that should be exempt (for paths with parameters)
|
||||
EXEMPT_PREFIXES = (
|
||||
"/logs/", # /logs/{service}
|
||||
"/review", # /review, /review/{id}, /review/summary, /review_ids, etc.
|
||||
"/reviews/", # /reviews/viewed, /reviews/delete
|
||||
"/events/", # /events/{id}/thumbnail, /events/summary, etc. (camera-scoped)
|
||||
"/export/", # /export/{camera}/start/..., /export/{id}/rename, /export/{id}
|
||||
"/go2rtc/streams/", # /go2rtc/streams/{camera}
|
||||
"/users/", # /users/{username}/password (has own auth)
|
||||
"/preview/", # /preview/{file}/thumbnail.jpg
|
||||
"/exports/", # /exports/{export_id}
|
||||
"/vod/", # /vod/{camera_name}/...
|
||||
"/notifications/", # /notifications/pubkey, /notifications/register
|
||||
)
|
||||
|
||||
async def admin_checker(request: Request):
|
||||
path = request.url.path
|
||||
|
||||
# Check exact path matches
|
||||
if path in EXEMPT_PATHS:
|
||||
return
|
||||
|
||||
# Check prefix matches for parameterized paths
|
||||
if path.startswith(EXEMPT_PREFIXES):
|
||||
return
|
||||
|
||||
# Dynamic camera path exemption:
|
||||
# Any path whose first segment matches a configured camera name should
|
||||
# bypass the global admin requirement. These endpoints enforce access
|
||||
# via route-level dependencies (e.g. require_camera_access) to ensure
|
||||
# per-camera authorization. This allows non-admin authenticated users
|
||||
# (e.g. viewer role) to access camera-specific resources without
|
||||
# needing admin privileges.
|
||||
try:
|
||||
if path.startswith("/"):
|
||||
first_segment = path.split("/", 2)[1]
|
||||
if (
|
||||
first_segment
|
||||
and first_segment in request.app.frigate_config.cameras
|
||||
):
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# For all other paths, require admin role
|
||||
# Port 5000 (internal) requests have admin role set automatically
|
||||
role = request.headers.get("remote-role")
|
||||
if role == "admin":
|
||||
return
|
||||
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Access denied. A user with the admin role is required.",
|
||||
)
|
||||
|
||||
return admin_checker
|
||||
|
||||
|
||||
def _is_authenticated(request: Request) -> bool:
|
||||
"""
|
||||
Helper to determine if a request is from an authenticated user.
|
||||
|
||||
Returns True if the request has a valid authenticated user (not anonymous).
|
||||
Port 5000 internal requests are considered anonymous despite having admin role.
|
||||
"""
|
||||
username = request.headers.get("remote-user")
|
||||
return username is not None and username != "anonymous"
|
||||
|
||||
|
||||
def allow_public():
|
||||
"""
|
||||
Override dependency to allow unauthenticated access to an endpoint.
|
||||
|
||||
Use this for endpoints that should be publicly accessible without
|
||||
authentication, such as login page, health checks, or pre-auth info.
|
||||
|
||||
Example:
|
||||
@router.get("/public-endpoint", dependencies=[Depends(allow_public())])
|
||||
"""
|
||||
|
||||
async def public_checker(request: Request):
|
||||
return # Always allow
|
||||
|
||||
return public_checker
|
||||
|
||||
|
||||
def allow_any_authenticated():
|
||||
"""
|
||||
Override dependency to allow any authenticated user (bypass admin requirement).
|
||||
|
||||
Allows:
|
||||
- Port 5000 internal requests (have admin role despite anonymous user)
|
||||
- Any authenticated user with a real username (not "anonymous")
|
||||
|
||||
Rejects:
|
||||
- Port 8971 requests with anonymous user (auth disabled, no proxy auth)
|
||||
|
||||
Example:
|
||||
@router.get("/authenticated-endpoint", dependencies=[Depends(allow_any_authenticated())])
|
||||
"""
|
||||
|
||||
async def auth_checker(request: Request):
|
||||
# Port 5000 requests have admin role and should be allowed
|
||||
role = request.headers.get("remote-role")
|
||||
if role == "admin":
|
||||
return
|
||||
|
||||
# Otherwise require a real authenticated user (not anonymous)
|
||||
if not _is_authenticated(request):
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
return
|
||||
|
||||
return auth_checker
|
||||
|
||||
|
||||
router = APIRouter(tags=[Tags.auth])
|
||||
|
||||
|
||||
@router.get("/auth/first_time_login")
|
||||
@router.get("/auth/first_time_login", dependencies=[Depends(allow_public())])
|
||||
def first_time_login(request: Request):
|
||||
"""Return whether the admin first-time login help flag is set in config.
|
||||
|
||||
@ -352,7 +520,7 @@ def resolve_role(
|
||||
|
||||
|
||||
# Endpoints
|
||||
@router.get("/auth")
|
||||
@router.get("/auth", dependencies=[Depends(allow_public())])
|
||||
def auth(request: Request):
|
||||
auth_config: AuthConfig = request.app.frigate_config.auth
|
||||
proxy_config: ProxyConfig = request.app.frigate_config.proxy
|
||||
@ -478,7 +646,7 @@ def auth(request: Request):
|
||||
return fail_response
|
||||
|
||||
|
||||
@router.get("/profile")
|
||||
@router.get("/profile", dependencies=[Depends(allow_any_authenticated())])
|
||||
def profile(request: Request):
|
||||
username = request.headers.get("remote-user", "anonymous")
|
||||
role = request.headers.get("remote-role", "viewer")
|
||||
@ -492,7 +660,7 @@ def profile(request: Request):
|
||||
)
|
||||
|
||||
|
||||
@router.get("/logout")
|
||||
@router.get("/logout", dependencies=[Depends(allow_any_authenticated())])
|
||||
def logout(request: Request):
|
||||
auth_config: AuthConfig = request.app.frigate_config.auth
|
||||
response = RedirectResponse("/login", status_code=303)
|
||||
@ -503,7 +671,7 @@ def logout(request: Request):
|
||||
limiter = Limiter(key_func=get_remote_addr)
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
@router.post("/login", dependencies=[Depends(allow_public())])
|
||||
@limiter.limit(limit_value=rateLimiter.get_limit)
|
||||
def login(request: Request, body: AppPostLoginBody):
|
||||
JWT_COOKIE_NAME = request.app.frigate_config.auth.cookie_name
|
||||
@ -578,13 +746,21 @@ def create_user(
|
||||
return JSONResponse(content={"username": body.username})
|
||||
|
||||
|
||||
@router.delete("/users/{username}")
|
||||
def delete_user(username: str):
|
||||
@router.delete("/users/{username}", dependencies=[Depends(require_role(["admin"]))])
|
||||
def delete_user(request: Request, username: str):
|
||||
# Prevent deletion of the built-in admin user
|
||||
if username == "admin":
|
||||
return JSONResponse(
|
||||
content={"message": "Cannot delete admin user"}, status_code=403
|
||||
)
|
||||
|
||||
User.delete_by_id(username)
|
||||
return JSONResponse(content={"success": True})
|
||||
|
||||
|
||||
@router.put("/users/{username}/password")
|
||||
@router.put(
|
||||
"/users/{username}/password", dependencies=[Depends(allow_any_authenticated())]
|
||||
)
|
||||
async def update_password(
|
||||
request: Request,
|
||||
username: str,
|
||||
|
||||
@ -15,7 +15,11 @@ from onvif import ONVIFCamera, ONVIFError
|
||||
from zeep.exceptions import Fault, TransportError
|
||||
from zeep.transports import AsyncTransport
|
||||
|
||||
from frigate.api.auth import require_role
|
||||
from frigate.api.auth import (
|
||||
allow_any_authenticated,
|
||||
require_camera_access,
|
||||
require_role,
|
||||
)
|
||||
from frigate.api.defs.tags import Tags
|
||||
from frigate.config.config import FrigateConfig
|
||||
from frigate.util.builtin import clean_camera_user_pass
|
||||
@ -50,7 +54,7 @@ def _is_valid_host(host: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
@router.get("/go2rtc/streams")
|
||||
@router.get("/go2rtc/streams", dependencies=[Depends(allow_any_authenticated())])
|
||||
def go2rtc_streams():
|
||||
r = requests.get("http://127.0.0.1:1984/api/streams")
|
||||
if not r.ok:
|
||||
@ -66,7 +70,9 @@ def go2rtc_streams():
|
||||
return JSONResponse(content=stream_data)
|
||||
|
||||
|
||||
@router.get("/go2rtc/streams/{camera_name}")
|
||||
@router.get(
|
||||
"/go2rtc/streams/{camera_name}", dependencies=[Depends(require_camera_access)]
|
||||
)
|
||||
def go2rtc_camera_stream(request: Request, camera_name: str):
|
||||
r = requests.get(
|
||||
f"http://127.0.0.1:1984/api/streams?src={camera_name}&video=all&audio=allµphone"
|
||||
@ -161,7 +167,7 @@ def go2rtc_delete_stream(stream_name: str):
|
||||
)
|
||||
|
||||
|
||||
@router.get("/ffprobe")
|
||||
@router.get("/ffprobe", dependencies=[Depends(require_role(["admin"]))])
|
||||
def ffprobe(request: Request, paths: str = "", detailed: bool = False):
|
||||
path_param = paths
|
||||
|
||||
|
||||
@ -542,6 +542,7 @@ def transcribe_audio(request: Request, body: AudioTranscriptionBody):
|
||||
status_code=409, # 409 Conflict
|
||||
)
|
||||
else:
|
||||
logger.debug(f"Failed to transcribe audio, response: {response}")
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
@ -869,6 +870,46 @@ def categorize_classification_image(request: Request, name: str, body: dict = No
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/classification/{name}/dataset/{category}/create",
|
||||
response_model=GenericResponse,
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
summary="Create an empty classification category folder",
|
||||
description="""Creates an empty folder for a classification category.
|
||||
This is used to create folders for categories that don't have images yet.
|
||||
Returns a success message or an error if the name is invalid.""",
|
||||
)
|
||||
def create_classification_category(request: Request, name: str, category: str):
|
||||
config: FrigateConfig = request.app.frigate_config
|
||||
|
||||
if name not in config.classification.custom:
|
||||
return JSONResponse(
|
||||
content=(
|
||||
{
|
||||
"success": False,
|
||||
"message": f"{name} is not a known classification model.",
|
||||
}
|
||||
),
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
category_folder = os.path.join(
|
||||
CLIPS_DIR, sanitize_filename(name), "dataset", sanitize_filename(category)
|
||||
)
|
||||
|
||||
os.makedirs(category_folder, exist_ok=True)
|
||||
|
||||
return JSONResponse(
|
||||
content=(
|
||||
{
|
||||
"success": True,
|
||||
"message": f"Successfully created category folder: {category}",
|
||||
}
|
||||
),
|
||||
status_code=200,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/classification/{name}/train/delete",
|
||||
response_model=GenericResponse,
|
||||
|
||||
@ -22,6 +22,7 @@ from peewee import JOIN, DoesNotExist, fn, operator
|
||||
from playhouse.shortcuts import model_to_dict
|
||||
|
||||
from frigate.api.auth import (
|
||||
allow_any_authenticated,
|
||||
get_allowed_cameras_for_filter,
|
||||
require_camera_access,
|
||||
require_role,
|
||||
@ -69,6 +70,7 @@ router = APIRouter(tags=[Tags.events])
|
||||
@router.get(
|
||||
"/events",
|
||||
response_model=list[EventResponse],
|
||||
dependencies=[Depends(allow_any_authenticated())],
|
||||
summary="Get events",
|
||||
description="Returns a list of events.",
|
||||
)
|
||||
@ -343,6 +345,7 @@ def events(
|
||||
@router.get(
|
||||
"/events/explore",
|
||||
response_model=list[EventResponse],
|
||||
dependencies=[Depends(allow_any_authenticated())],
|
||||
summary="Get summary of objects.",
|
||||
description="""Gets a summary of objects from the database.
|
||||
Returns a list of objects with a max of `limit` objects for each label.
|
||||
@ -435,6 +438,7 @@ def events_explore(
|
||||
@router.get(
|
||||
"/event_ids",
|
||||
response_model=list[EventResponse],
|
||||
dependencies=[Depends(allow_any_authenticated())],
|
||||
summary="Get events by ids.",
|
||||
description="""Gets events by a list of ids.
|
||||
Returns a list of events.
|
||||
@ -468,6 +472,7 @@ async def event_ids(ids: str, request: Request):
|
||||
|
||||
@router.get(
|
||||
"/events/search",
|
||||
dependencies=[Depends(allow_any_authenticated())],
|
||||
summary="Search events.",
|
||||
description="""Searches for events in the database.
|
||||
Returns a list of events.
|
||||
@ -808,7 +813,7 @@ def events_search(
|
||||
return JSONResponse(content=processed_events)
|
||||
|
||||
|
||||
@router.get("/events/summary")
|
||||
@router.get("/events/summary", dependencies=[Depends(allow_any_authenticated())])
|
||||
def events_summary(
|
||||
params: EventsSummaryQueryParams = Depends(),
|
||||
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
|
||||
@ -918,6 +923,7 @@ def events_summary(
|
||||
@router.get(
|
||||
"/events/{event_id}",
|
||||
response_model=EventResponse,
|
||||
dependencies=[Depends(allow_any_authenticated())],
|
||||
summary="Get event by id.",
|
||||
description="Gets an event by its id.",
|
||||
)
|
||||
@ -961,6 +967,7 @@ def set_retain(event_id: str):
|
||||
@router.post(
|
||||
"/events/{event_id}/plus",
|
||||
response_model=EventUploadPlusResponse,
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
summary="Send event to Frigate+.",
|
||||
description="""Sends an event to Frigate+.
|
||||
Returns a success message or an error if the event is not found.
|
||||
@ -1101,6 +1108,7 @@ async def send_to_plus(request: Request, event_id: str, body: SubmitPlusBody = N
|
||||
@router.put(
|
||||
"/events/{event_id}/false_positive",
|
||||
response_model=EventUploadPlusResponse,
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
summary="Submit false positive to Frigate+",
|
||||
description="""Submit an event as a false positive to Frigate+.
|
||||
This endpoint is the same as the standard Frigate+ submission endpoint,
|
||||
@ -1753,7 +1761,7 @@ def create_trigger_embedding(
|
||||
body.data, (base64.b64encode(thumbnail).decode("ASCII"))
|
||||
)
|
||||
|
||||
if embedding is None:
|
||||
if not embedding:
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
@ -1888,7 +1896,7 @@ def update_trigger_embedding(
|
||||
body.data, (base64.b64encode(thumbnail).decode("ASCII"))
|
||||
)
|
||||
|
||||
if embedding is None:
|
||||
if not embedding:
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
|
||||
@ -14,6 +14,7 @@ from peewee import DoesNotExist
|
||||
from playhouse.shortcuts import model_to_dict
|
||||
|
||||
from frigate.api.auth import (
|
||||
allow_any_authenticated,
|
||||
get_allowed_cameras_for_filter,
|
||||
require_camera_access,
|
||||
require_role,
|
||||
@ -44,6 +45,7 @@ router = APIRouter(tags=[Tags.export])
|
||||
@router.get(
|
||||
"/exports",
|
||||
response_model=ExportsResponse,
|
||||
dependencies=[Depends(allow_any_authenticated())],
|
||||
summary="Get exports",
|
||||
description="""Gets all exports from the database for cameras the user has access to.
|
||||
Returns a list of exports ordered by date (most recent first).""",
|
||||
@ -272,6 +274,7 @@ async def export_delete(event_id: str, request: Request):
|
||||
@router.get(
|
||||
"/exports/{export_id}",
|
||||
response_model=ExportModel,
|
||||
dependencies=[Depends(allow_any_authenticated())],
|
||||
summary="Get a single export",
|
||||
description="""Gets a specific export by ID. The user must have access to the camera
|
||||
associated with the export.""",
|
||||
|
||||
@ -2,7 +2,7 @@ import logging
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi import Depends, FastAPI, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from joserfc.jwk import OctKey
|
||||
from playhouse.sqliteq import SqliteQueueDatabase
|
||||
@ -24,7 +24,7 @@ from frigate.api import (
|
||||
preview,
|
||||
review,
|
||||
)
|
||||
from frigate.api.auth import get_jwt_secret, limiter
|
||||
from frigate.api.auth import get_jwt_secret, limiter, require_admin_by_default
|
||||
from frigate.comms.event_metadata_updater import (
|
||||
EventMetadataPublisher,
|
||||
)
|
||||
@ -62,11 +62,15 @@ def create_fastapi_app(
|
||||
stats_emitter: StatsEmitter,
|
||||
event_metadata_updater: EventMetadataPublisher,
|
||||
config_publisher: CameraConfigUpdatePublisher,
|
||||
enforce_default_admin: bool = True,
|
||||
):
|
||||
logger.info("Starting FastAPI app")
|
||||
app = FastAPI(
|
||||
debug=False,
|
||||
swagger_ui_parameters={"apisSorter": "alpha", "operationsSorter": "alpha"},
|
||||
dependencies=[Depends(require_admin_by_default())]
|
||||
if enforce_default_admin
|
||||
else [],
|
||||
)
|
||||
|
||||
# update the request_address with the x-forwarded-for header from nginx
|
||||
|
||||
@ -22,7 +22,11 @@ from pathvalidate import sanitize_filename
|
||||
from peewee import DoesNotExist, fn, operator
|
||||
from tzlocal import get_localzone_name
|
||||
|
||||
from frigate.api.auth import get_allowed_cameras_for_filter, require_camera_access
|
||||
from frigate.api.auth import (
|
||||
allow_any_authenticated,
|
||||
get_allowed_cameras_for_filter,
|
||||
require_camera_access,
|
||||
)
|
||||
from frigate.api.defs.query.media_query_parameters import (
|
||||
Extension,
|
||||
MediaEventsSnapshotQueryParams,
|
||||
@ -393,7 +397,7 @@ async def submit_recording_snapshot_to_plus(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/recordings/storage")
|
||||
@router.get("/recordings/storage", dependencies=[Depends(allow_any_authenticated())])
|
||||
def get_recordings_storage_usage(request: Request):
|
||||
recording_stats = request.app.stats_emitter.get_latest_stats()["service"][
|
||||
"storage"
|
||||
@ -417,7 +421,7 @@ def get_recordings_storage_usage(request: Request):
|
||||
return JSONResponse(content=camera_usages)
|
||||
|
||||
|
||||
@router.get("/recordings/summary")
|
||||
@router.get("/recordings/summary", dependencies=[Depends(allow_any_authenticated())])
|
||||
def all_recordings_summary(
|
||||
request: Request,
|
||||
params: MediaRecordingsSummaryQueryParams = Depends(),
|
||||
@ -635,7 +639,11 @@ async def recordings(
|
||||
return JSONResponse(content=list(recordings))
|
||||
|
||||
|
||||
@router.get("/recordings/unavailable", response_model=list[dict])
|
||||
@router.get(
|
||||
"/recordings/unavailable",
|
||||
response_model=list[dict],
|
||||
dependencies=[Depends(allow_any_authenticated())],
|
||||
)
|
||||
async def no_recordings(
|
||||
request: Request,
|
||||
params: MediaRecordingsAvailabilityQueryParams = Depends(),
|
||||
@ -937,6 +945,7 @@ async def vod_hour(
|
||||
|
||||
@router.get(
|
||||
"/vod/event/{event_id}",
|
||||
dependencies=[Depends(allow_any_authenticated())],
|
||||
description="Returns an HLS playlist for the specified object. Append /master.m3u8 or /index.m3u8 for HLS playback.",
|
||||
)
|
||||
async def vod_event(
|
||||
@ -1053,7 +1062,10 @@ async def event_snapshot(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/events/{event_id}/thumbnail.{extension}")
|
||||
@router.get(
|
||||
"/events/{event_id}/thumbnail.{extension}",
|
||||
dependencies=[Depends(require_camera_access)],
|
||||
)
|
||||
async def event_thumbnail(
|
||||
request: Request,
|
||||
event_id: str,
|
||||
@ -1251,7 +1263,10 @@ def grid_snapshot(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/events/{event_id}/snapshot-clean.webp")
|
||||
@router.get(
|
||||
"/events/{event_id}/snapshot-clean.webp",
|
||||
dependencies=[Depends(require_camera_access)],
|
||||
)
|
||||
def event_snapshot_clean(request: Request, event_id: str, download: bool = False):
|
||||
webp_bytes = None
|
||||
try:
|
||||
@ -1375,7 +1390,9 @@ def event_snapshot_clean(request: Request, event_id: str, download: bool = False
|
||||
)
|
||||
|
||||
|
||||
@router.get("/events/{event_id}/clip.mp4")
|
||||
@router.get(
|
||||
"/events/{event_id}/clip.mp4", dependencies=[Depends(require_camera_access)]
|
||||
)
|
||||
async def event_clip(
|
||||
request: Request,
|
||||
event_id: str,
|
||||
@ -1403,7 +1420,9 @@ async def event_clip(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/events/{event_id}/preview.gif")
|
||||
@router.get(
|
||||
"/events/{event_id}/preview.gif", dependencies=[Depends(require_camera_access)]
|
||||
)
|
||||
def event_preview(request: Request, event_id: str):
|
||||
try:
|
||||
event: Event = Event.get(Event.id == event_id)
|
||||
@ -1756,7 +1775,7 @@ def preview_mp4(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/review/{event_id}/preview")
|
||||
@router.get("/review/{event_id}/preview", dependencies=[Depends(require_camera_access)])
|
||||
def review_preview(
|
||||
request: Request,
|
||||
event_id: str,
|
||||
@ -1782,8 +1801,12 @@ def review_preview(
|
||||
return preview_mp4(request, review.camera, start_ts, end_ts)
|
||||
|
||||
|
||||
@router.get("/preview/{file_name}/thumbnail.jpg")
|
||||
@router.get("/preview/{file_name}/thumbnail.webp")
|
||||
@router.get(
|
||||
"/preview/{file_name}/thumbnail.jpg", dependencies=[Depends(require_camera_access)]
|
||||
)
|
||||
@router.get(
|
||||
"/preview/{file_name}/thumbnail.webp", dependencies=[Depends(require_camera_access)]
|
||||
)
|
||||
def preview_thumbnail(file_name: str):
|
||||
"""Get a thumbnail from the cached preview frames."""
|
||||
if len(file_name) > 1000:
|
||||
|
||||
@ -5,11 +5,12 @@ import os
|
||||
from typing import Any
|
||||
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from peewee import DoesNotExist
|
||||
from py_vapid import Vapid01, utils
|
||||
|
||||
from frigate.api.auth import allow_any_authenticated
|
||||
from frigate.api.defs.tags import Tags
|
||||
from frigate.const import CONFIG_DIR
|
||||
from frigate.models import User
|
||||
@ -21,6 +22,7 @@ router = APIRouter(tags=[Tags.notifications])
|
||||
|
||||
@router.get(
|
||||
"/notifications/pubkey",
|
||||
dependencies=[Depends(allow_any_authenticated())],
|
||||
summary="Get VAPID public key",
|
||||
description="""Gets the VAPID public key for the notifications.
|
||||
Returns the public key or an error if notifications are not enabled.
|
||||
@ -47,6 +49,7 @@ def get_vapid_pub_key(request: Request):
|
||||
|
||||
@router.post(
|
||||
"/notifications/register",
|
||||
dependencies=[Depends(allow_any_authenticated())],
|
||||
summary="Register notifications",
|
||||
description="""Registers a notifications subscription.
|
||||
Returns a success message or an error if the subscription is not provided.
|
||||
|
||||
@ -14,6 +14,7 @@ from peewee import Case, DoesNotExist, IntegrityError, fn, operator
|
||||
from playhouse.shortcuts import model_to_dict
|
||||
|
||||
from frigate.api.auth import (
|
||||
allow_any_authenticated,
|
||||
get_allowed_cameras_for_filter,
|
||||
get_current_user,
|
||||
require_camera_access,
|
||||
@ -43,7 +44,11 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=[Tags.review])
|
||||
|
||||
|
||||
@router.get("/review", response_model=list[ReviewSegmentResponse])
|
||||
@router.get(
|
||||
"/review",
|
||||
response_model=list[ReviewSegmentResponse],
|
||||
dependencies=[Depends(allow_any_authenticated())],
|
||||
)
|
||||
async def review(
|
||||
params: ReviewQueryParams = Depends(),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
@ -152,7 +157,11 @@ async def review(
|
||||
return JSONResponse(content=[r for r in review_query])
|
||||
|
||||
|
||||
@router.get("/review_ids", response_model=list[ReviewSegmentResponse])
|
||||
@router.get(
|
||||
"/review_ids",
|
||||
response_model=list[ReviewSegmentResponse],
|
||||
dependencies=[Depends(allow_any_authenticated())],
|
||||
)
|
||||
async def review_ids(request: Request, ids: str):
|
||||
ids = ids.split(",")
|
||||
|
||||
@ -186,7 +195,11 @@ async def review_ids(request: Request, ids: str):
|
||||
)
|
||||
|
||||
|
||||
@router.get("/review/summary", response_model=ReviewSummaryResponse)
|
||||
@router.get(
|
||||
"/review/summary",
|
||||
response_model=ReviewSummaryResponse,
|
||||
dependencies=[Depends(allow_any_authenticated())],
|
||||
)
|
||||
async def review_summary(
|
||||
params: ReviewSummaryQueryParams = Depends(),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
@ -461,7 +474,11 @@ async def review_summary(
|
||||
return JSONResponse(content=data)
|
||||
|
||||
|
||||
@router.post("/reviews/viewed", response_model=GenericResponse)
|
||||
@router.post(
|
||||
"/reviews/viewed",
|
||||
response_model=GenericResponse,
|
||||
dependencies=[Depends(allow_any_authenticated())],
|
||||
)
|
||||
async def set_multiple_reviewed(
|
||||
request: Request,
|
||||
body: ReviewModifyMultipleBody,
|
||||
@ -560,7 +577,9 @@ def delete_reviews(body: ReviewModifyMultipleBody):
|
||||
|
||||
|
||||
@router.get(
|
||||
"/review/activity/motion", response_model=list[ReviewActivityMotionResponse]
|
||||
"/review/activity/motion",
|
||||
response_model=list[ReviewActivityMotionResponse],
|
||||
dependencies=[Depends(allow_any_authenticated())],
|
||||
)
|
||||
def motion_activity(
|
||||
params: ReviewActivityMotionQueryParams = Depends(),
|
||||
@ -644,7 +663,11 @@ def motion_activity(
|
||||
return JSONResponse(content=normalized)
|
||||
|
||||
|
||||
@router.get("/review/event/{event_id}", response_model=ReviewSegmentResponse)
|
||||
@router.get(
|
||||
"/review/event/{event_id}",
|
||||
response_model=ReviewSegmentResponse,
|
||||
dependencies=[Depends(allow_any_authenticated())],
|
||||
)
|
||||
async def get_review_from_event(request: Request, event_id: str):
|
||||
try:
|
||||
review = ReviewSegment.get(
|
||||
@ -659,7 +682,11 @@ async def get_review_from_event(request: Request, event_id: str):
|
||||
)
|
||||
|
||||
|
||||
@router.get("/review/{review_id}", response_model=ReviewSegmentResponse)
|
||||
@router.get(
|
||||
"/review/{review_id}",
|
||||
response_model=ReviewSegmentResponse,
|
||||
dependencies=[Depends(allow_any_authenticated())],
|
||||
)
|
||||
async def get_review(request: Request, review_id: str):
|
||||
try:
|
||||
review = ReviewSegment.get(ReviewSegment.id == review_id)
|
||||
@ -672,7 +699,11 @@ async def get_review(request: Request, review_id: str):
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/review/{review_id}/viewed", response_model=GenericResponse)
|
||||
@router.delete(
|
||||
"/review/{review_id}/viewed",
|
||||
response_model=GenericResponse,
|
||||
dependencies=[Depends(allow_any_authenticated())],
|
||||
)
|
||||
async def set_not_reviewed(
|
||||
review_id: str,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
@ -710,6 +741,7 @@ async def set_not_reviewed(
|
||||
|
||||
@router.post(
|
||||
"/review/summarize/start/{start_ts}/end/{end_ts}",
|
||||
dependencies=[Depends(allow_any_authenticated())],
|
||||
description="Use GenAI to summarize review items over a period of time.",
|
||||
)
|
||||
def generate_review_summary(request: Request, start_ts: float, end_ts: float):
|
||||
|
||||
@ -23,6 +23,7 @@ from frigate.const import (
|
||||
NOTIFICATION_TEST,
|
||||
REQUEST_REGION_GRID,
|
||||
UPDATE_AUDIO_ACTIVITY,
|
||||
UPDATE_AUDIO_TRANSCRIPTION_STATE,
|
||||
UPDATE_BIRDSEYE_LAYOUT,
|
||||
UPDATE_CAMERA_ACTIVITY,
|
||||
UPDATE_EMBEDDINGS_REINDEX_PROGRESS,
|
||||
@ -61,6 +62,7 @@ class Dispatcher:
|
||||
self.model_state: dict[str, ModelStatusTypesEnum] = {}
|
||||
self.embeddings_reindex: dict[str, Any] = {}
|
||||
self.birdseye_layout: dict[str, Any] = {}
|
||||
self.audio_transcription_state: str = "idle"
|
||||
self._camera_settings_handlers: dict[str, Callable] = {
|
||||
"audio": self._on_audio_command,
|
||||
"audio_transcription": self._on_audio_transcription_command,
|
||||
@ -178,6 +180,19 @@ class Dispatcher:
|
||||
def handle_model_state() -> None:
|
||||
self.publish("model_state", json.dumps(self.model_state.copy()))
|
||||
|
||||
def handle_update_audio_transcription_state() -> None:
|
||||
if payload:
|
||||
self.audio_transcription_state = payload
|
||||
self.publish(
|
||||
"audio_transcription_state",
|
||||
json.dumps(self.audio_transcription_state),
|
||||
)
|
||||
|
||||
def handle_audio_transcription_state() -> None:
|
||||
self.publish(
|
||||
"audio_transcription_state", json.dumps(self.audio_transcription_state)
|
||||
)
|
||||
|
||||
def handle_update_embeddings_reindex_progress() -> None:
|
||||
self.embeddings_reindex = payload
|
||||
self.publish(
|
||||
@ -264,10 +279,12 @@ class Dispatcher:
|
||||
UPDATE_MODEL_STATE: handle_update_model_state,
|
||||
UPDATE_EMBEDDINGS_REINDEX_PROGRESS: handle_update_embeddings_reindex_progress,
|
||||
UPDATE_BIRDSEYE_LAYOUT: handle_update_birdseye_layout,
|
||||
UPDATE_AUDIO_TRANSCRIPTION_STATE: handle_update_audio_transcription_state,
|
||||
NOTIFICATION_TEST: handle_notification_test,
|
||||
"restart": handle_restart,
|
||||
"embeddingsReindexProgress": handle_embeddings_reindex_progress,
|
||||
"modelState": handle_model_state,
|
||||
"audioTranscriptionState": handle_audio_transcription_state,
|
||||
"birdseyeLayout": handle_birdseye_layout,
|
||||
"onConnect": handle_on_connect,
|
||||
}
|
||||
|
||||
@ -375,7 +375,19 @@ class WebPushClient(Communicator):
|
||||
ended = state == "end" or state == "genai"
|
||||
|
||||
if state == "genai" and payload["after"]["data"]["metadata"]:
|
||||
title = payload["after"]["data"]["metadata"]["title"]
|
||||
base_title = payload["after"]["data"]["metadata"]["title"]
|
||||
threat_level = payload["after"]["data"]["metadata"].get(
|
||||
"potential_threat_level", 0
|
||||
)
|
||||
|
||||
# Add prefix for threat levels 1 and 2
|
||||
if threat_level == 1:
|
||||
title = f"Needs Review: {base_title}"
|
||||
elif threat_level == 2:
|
||||
title = f"Security Concern: {base_title}"
|
||||
else:
|
||||
title = base_title
|
||||
|
||||
message = payload["after"]["data"]["metadata"]["scene"]
|
||||
else:
|
||||
title = f"{titlecase(', '.join(sorted_objects).replace('_', ' '))}{' was' if state == 'end' else ''} detected in {titlecase(', '.join(payload['after']['data']['zones']).replace('_', ' '))}"
|
||||
|
||||
@ -113,6 +113,7 @@ CLEAR_ONGOING_REVIEW_SEGMENTS = "clear_ongoing_review_segments"
|
||||
UPDATE_CAMERA_ACTIVITY = "update_camera_activity"
|
||||
UPDATE_AUDIO_ACTIVITY = "update_audio_activity"
|
||||
EXPIRE_AUDIO_ACTIVITY = "expire_audio_activity"
|
||||
UPDATE_AUDIO_TRANSCRIPTION_STATE = "update_audio_transcription_state"
|
||||
UPDATE_EVENT_DESCRIPTION = "update_event_description"
|
||||
UPDATE_REVIEW_DESCRIPTION = "update_review_description"
|
||||
UPDATE_MODEL_STATE = "update_model_state"
|
||||
|
||||
@ -13,6 +13,7 @@ from frigate.config import FrigateConfig
|
||||
from frigate.const import (
|
||||
CACHE_DIR,
|
||||
MODEL_CACHE_DIR,
|
||||
UPDATE_AUDIO_TRANSCRIPTION_STATE,
|
||||
UPDATE_EVENT_DESCRIPTION,
|
||||
)
|
||||
from frigate.data_processing.types import PostProcessDataEnum
|
||||
@ -190,6 +191,8 @@ class AudioTranscriptionPostProcessor(PostProcessorApi):
|
||||
self.transcription_running = False
|
||||
self.transcription_thread = None
|
||||
|
||||
self.requestor.send_data(UPDATE_AUDIO_TRANSCRIPTION_STATE, "idle")
|
||||
|
||||
def handle_request(self, topic: str, request_data: dict[str, any]) -> str | None:
|
||||
if topic == "transcribe_audio":
|
||||
event = request_data["event"]
|
||||
@ -203,6 +206,8 @@ class AudioTranscriptionPostProcessor(PostProcessorApi):
|
||||
|
||||
# Mark as running and start the thread
|
||||
self.transcription_running = True
|
||||
self.requestor.send_data(UPDATE_AUDIO_TRANSCRIPTION_STATE, "processing")
|
||||
|
||||
self.transcription_thread = threading.Thread(
|
||||
target=self._transcription_wrapper, args=(event,), daemon=True
|
||||
)
|
||||
|
||||
@ -12,6 +12,7 @@ from typing import Any
|
||||
|
||||
import cv2
|
||||
from peewee import DoesNotExist
|
||||
from titlecase import titlecase
|
||||
|
||||
from frigate.comms.embeddings_updater import EmbeddingsRequestEnum
|
||||
from frigate.comms.inter_process import InterProcessRequestor
|
||||
@ -455,14 +456,14 @@ def run_analysis(
|
||||
|
||||
for i, verified_label in enumerate(final_data["data"]["verified_objects"]):
|
||||
object_type = verified_label.replace("-verified", "").replace("_", " ")
|
||||
name = sub_labels_list[i].replace("_", " ").title()
|
||||
name = titlecase(sub_labels_list[i].replace("_", " "))
|
||||
unified_objects.append(f"{name} ({object_type})")
|
||||
|
||||
for label in objects_list:
|
||||
if "-verified" in label:
|
||||
continue
|
||||
elif label in labelmap_objects:
|
||||
object_type = label.replace("_", " ").title()
|
||||
object_type = titlecase(label.replace("_", " "))
|
||||
|
||||
if label in attribute_labels:
|
||||
unified_objects.append(f"{object_type} (delivery/service)")
|
||||
|
||||
@ -99,6 +99,42 @@ class CustomStateClassificationProcessor(RealTimeProcessorApi):
|
||||
if self.inference_speed:
|
||||
self.inference_speed.update(duration)
|
||||
|
||||
def _should_save_image(
|
||||
self, camera: str, detected_state: str, score: float = 1.0
|
||||
) -> bool:
|
||||
"""
|
||||
Determine if we should save the image for training.
|
||||
Save when:
|
||||
- State is changing or being verified (regardless of score)
|
||||
- Score is less than 100% (even if state matches, useful for training)
|
||||
Don't save when:
|
||||
- State is stable (matches current_state) AND score is 100%
|
||||
"""
|
||||
if camera not in self.state_history:
|
||||
# First detection for this camera, save it
|
||||
return True
|
||||
|
||||
verification = self.state_history[camera]
|
||||
current_state = verification.get("current_state")
|
||||
pending_state = verification.get("pending_state")
|
||||
|
||||
# Save if there's a pending state change being verified
|
||||
if pending_state is not None:
|
||||
return True
|
||||
|
||||
# Save if the detected state differs from the current verified state
|
||||
# (state is changing)
|
||||
if current_state is not None and detected_state != current_state:
|
||||
return True
|
||||
|
||||
# If score is less than 100%, save even if state matches
|
||||
# (useful for training to improve confidence)
|
||||
if score < 1.0:
|
||||
return True
|
||||
|
||||
# Don't save if state is stable (detected_state == current_state) AND score is 100%
|
||||
return False
|
||||
|
||||
def verify_state_change(self, camera: str, detected_state: str) -> str | None:
|
||||
"""
|
||||
Verify state change requires 3 consecutive identical states before publishing.
|
||||
@ -212,6 +248,8 @@ class CustomStateClassificationProcessor(RealTimeProcessorApi):
|
||||
return
|
||||
|
||||
if self.interpreter is None:
|
||||
# When interpreter is None, always save (score is 0.0, which is < 1.0)
|
||||
if self._should_save_image(camera, "unknown", 0.0):
|
||||
write_classification_attempt(
|
||||
self.train_dir,
|
||||
cv2.cvtColor(frame, cv2.COLOR_RGB2BGR),
|
||||
@ -236,12 +274,15 @@ class CustomStateClassificationProcessor(RealTimeProcessorApi):
|
||||
score = round(probs[best_id], 2)
|
||||
self.__update_metrics(datetime.datetime.now().timestamp() - now)
|
||||
|
||||
detected_state = self.labelmap[best_id]
|
||||
|
||||
if self._should_save_image(camera, detected_state, score):
|
||||
write_classification_attempt(
|
||||
self.train_dir,
|
||||
cv2.cvtColor(frame, cv2.COLOR_RGB2BGR),
|
||||
"none-none",
|
||||
now,
|
||||
self.labelmap[best_id],
|
||||
detected_state,
|
||||
score,
|
||||
)
|
||||
|
||||
@ -251,7 +292,6 @@ class CustomStateClassificationProcessor(RealTimeProcessorApi):
|
||||
)
|
||||
return
|
||||
|
||||
detected_state = self.labelmap[best_id]
|
||||
verified_state = self.verify_state_change(camera, detected_state)
|
||||
|
||||
if verified_state is not None:
|
||||
@ -405,9 +445,6 @@ class CustomObjectClassificationProcessor(RealTimeProcessorApi):
|
||||
if obj_data.get("end_time") is not None:
|
||||
return
|
||||
|
||||
if obj_data.get("stationary"):
|
||||
return
|
||||
|
||||
object_id = obj_data["id"]
|
||||
|
||||
if (
|
||||
|
||||
@ -2,7 +2,6 @@ import glob
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from queue import Queue
|
||||
@ -55,6 +54,9 @@ class MemryXDetector(DetectionApi):
|
||||
)
|
||||
return
|
||||
|
||||
# Initialize stop_event as None, will be set later by set_stop_event()
|
||||
self.stop_event = None
|
||||
|
||||
model_cfg = getattr(detector_config, "model", None)
|
||||
|
||||
# Check if model_type was explicitly set by the user
|
||||
@ -363,27 +365,44 @@ class MemryXDetector(DetectionApi):
|
||||
def process_input(self):
|
||||
"""Input callback function: wait for frames in the input queue, preprocess, and send to MX3 (return)"""
|
||||
while True:
|
||||
# Check if shutdown is requested
|
||||
if self.stop_event and self.stop_event.is_set():
|
||||
logger.debug("[process_input] Stop event detected, returning None")
|
||||
return None
|
||||
try:
|
||||
# Wait for a frame from the queue (blocking call)
|
||||
frame = self.capture_queue.get(
|
||||
block=True
|
||||
) # Blocks until data is available
|
||||
# Wait for a frame from the queue with timeout to check stop_event periodically
|
||||
frame = self.capture_queue.get(block=True, timeout=0.5)
|
||||
|
||||
return frame
|
||||
|
||||
except Exception as e:
|
||||
logger.info(f"[process_input] Error processing input: {e}")
|
||||
time.sleep(0.1) # Prevent busy waiting in case of error
|
||||
# Silently handle queue.Empty timeouts (expected during normal operation)
|
||||
# Log any other unexpected exceptions
|
||||
if "Empty" not in str(type(e).__name__):
|
||||
logger.warning(f"[process_input] Unexpected error: {e}")
|
||||
# Loop continues and will check stop_event at the top
|
||||
|
||||
def receive_output(self):
|
||||
"""Retrieve processed results from MemryX output queue + a copy of the original frame"""
|
||||
connection_id = (
|
||||
self.capture_id_queue.get()
|
||||
try:
|
||||
# Get connection ID with timeout
|
||||
connection_id = self.capture_id_queue.get(
|
||||
block=True, timeout=1.0
|
||||
) # Get the corresponding connection ID
|
||||
detections = self.output_queue.get() # Get detections from MemryX
|
||||
|
||||
return connection_id, detections
|
||||
|
||||
except Exception as e:
|
||||
# On timeout or stop event, return None
|
||||
if self.stop_event and self.stop_event.is_set():
|
||||
logger.debug("[receive_output] Stop event detected, exiting")
|
||||
# Silently handle queue.Empty timeouts, they're expected during normal operation
|
||||
elif "Empty" not in str(type(e).__name__):
|
||||
logger.warning(f"[receive_output] Error receiving output: {e}")
|
||||
|
||||
return None, None
|
||||
|
||||
def post_process_yolonas(self, output):
|
||||
predictions = output[0]
|
||||
|
||||
@ -831,6 +850,19 @@ class MemryXDetector(DetectionApi):
|
||||
f"{self.memx_model_type} is currently not supported for memryx. See the docs for more info on supported models."
|
||||
)
|
||||
|
||||
def set_stop_event(self, stop_event):
|
||||
"""Set the stop event for graceful shutdown."""
|
||||
self.stop_event = stop_event
|
||||
|
||||
def shutdown(self):
|
||||
"""Gracefully shutdown the MemryX accelerator"""
|
||||
try:
|
||||
if hasattr(self, "accl") and self.accl is not None:
|
||||
self.accl.shutdown()
|
||||
logger.info("MemryX accelerator shutdown complete")
|
||||
except Exception as e:
|
||||
logger.error(f"Error during MemryX shutdown: {e}")
|
||||
|
||||
def detect_raw(self, tensor_input: np.ndarray):
|
||||
"""Removed synchronous detect_raw() function so that we only use async"""
|
||||
return 0
|
||||
|
||||
@ -205,14 +205,20 @@ Rules for the report:
|
||||
- Group bullets under subheadings when multiple events fall into the same category (e.g., Vehicle Activity, Porch Activity, Unusual Behavior).
|
||||
|
||||
- Threat levels
|
||||
- Always show (threat level: X) for each event.
|
||||
- Always show the threat level for each event using these labels:
|
||||
- Threat level 0: "Normal"
|
||||
- Threat level 1: "Needs review"
|
||||
- Threat level 2: "Security concern"
|
||||
- Format as (threat level: Normal), (threat level: Needs review), or (threat level: Security concern).
|
||||
- If multiple events at the same time share the same threat level, only state it once.
|
||||
|
||||
- Final assessment
|
||||
- End with a Final Assessment section.
|
||||
- If all events are threat level 1 with no escalation:
|
||||
- If all events are threat level 0:
|
||||
Final assessment: Only normal residential activity observed during this period.
|
||||
- If threat level 2+ events are present, clearly summarize them as Potential concerns requiring review.
|
||||
- If threat level 1 events are present:
|
||||
Final assessment: Some activity requires review but no security concerns identified.
|
||||
- If threat level 2 events are present, clearly summarize them as Security concerns requiring immediate attention.
|
||||
|
||||
- Conciseness
|
||||
- Do not repeat benign clothing/appearance details unless they distinguish individuals.
|
||||
|
||||
@ -43,6 +43,7 @@ class BaseLocalDetector(ObjectDetector):
|
||||
self,
|
||||
detector_config: BaseDetectorConfig = None,
|
||||
labels: str = None,
|
||||
stop_event: MpEvent = None,
|
||||
):
|
||||
self.fps = EventsPerSecond()
|
||||
if labels is None:
|
||||
@ -60,6 +61,10 @@ class BaseLocalDetector(ObjectDetector):
|
||||
|
||||
self.detect_api = create_detector(detector_config)
|
||||
|
||||
# If the detector supports stop_event, pass it
|
||||
if hasattr(self.detect_api, "set_stop_event") and stop_event:
|
||||
self.detect_api.set_stop_event(stop_event)
|
||||
|
||||
def _transform_input(self, tensor_input: np.ndarray) -> np.ndarray:
|
||||
if self.input_transform:
|
||||
tensor_input = np.transpose(tensor_input, self.input_transform)
|
||||
@ -240,6 +245,10 @@ class AsyncDetectorRunner(FrigateProcess):
|
||||
while not self.stop_event.is_set():
|
||||
connection_id, detections = self._detector.async_receive_output()
|
||||
|
||||
# Handle timeout case (queue.Empty) - just continue
|
||||
if connection_id is None:
|
||||
continue
|
||||
|
||||
if not self.send_times:
|
||||
# guard; shouldn't happen if send/recv are balanced
|
||||
continue
|
||||
@ -266,21 +275,38 @@ class AsyncDetectorRunner(FrigateProcess):
|
||||
|
||||
self._frame_manager = SharedMemoryFrameManager()
|
||||
self._publisher = ObjectDetectorPublisher()
|
||||
self._detector = AsyncLocalObjectDetector(detector_config=self.detector_config)
|
||||
self._detector = AsyncLocalObjectDetector(
|
||||
detector_config=self.detector_config, stop_event=self.stop_event
|
||||
)
|
||||
|
||||
for name in self.cameras:
|
||||
self.create_output_shm(name)
|
||||
|
||||
t_detect = threading.Thread(target=self._detect_worker, daemon=True)
|
||||
t_result = threading.Thread(target=self._result_worker, daemon=True)
|
||||
t_detect = threading.Thread(target=self._detect_worker, daemon=False)
|
||||
t_result = threading.Thread(target=self._result_worker, daemon=False)
|
||||
t_detect.start()
|
||||
t_result.start()
|
||||
|
||||
try:
|
||||
while not self.stop_event.is_set():
|
||||
time.sleep(0.5)
|
||||
|
||||
logger.info(
|
||||
"Stop event detected, waiting for detector threads to finish..."
|
||||
)
|
||||
|
||||
# Wait for threads to finish processing
|
||||
t_detect.join(timeout=5)
|
||||
t_result.join(timeout=5)
|
||||
|
||||
# Shutdown the AsyncDetector
|
||||
self._detector.detect_api.shutdown()
|
||||
|
||||
self._publisher.stop()
|
||||
logger.info("Exited async detection process...")
|
||||
except Exception as e:
|
||||
logger.error(f"Error during async detector shutdown: {e}")
|
||||
finally:
|
||||
logger.info("Exited Async detection process...")
|
||||
|
||||
|
||||
class ObjectDetectProcess:
|
||||
@ -308,7 +334,7 @@ class ObjectDetectProcess:
|
||||
# if the process has already exited on its own, just return
|
||||
if self.detect_process and self.detect_process.exitcode:
|
||||
return
|
||||
self.detect_process.terminate()
|
||||
|
||||
logging.info("Waiting for detection process to exit gracefully...")
|
||||
self.detect_process.join(timeout=30)
|
||||
if self.detect_process.exitcode is None:
|
||||
|
||||
@ -190,7 +190,11 @@ class OnvifController:
|
||||
ptz: ONVIFService = await onvif.create_ptz_service()
|
||||
self.cams[camera_name]["ptz"] = ptz
|
||||
|
||||
try:
|
||||
imaging: ONVIFService = await onvif.create_imaging_service()
|
||||
except (Fault, ONVIFError, TransportError, Exception) as e:
|
||||
logger.debug(f"Imaging service not supported for {camera_name}: {e}")
|
||||
imaging = None
|
||||
self.cams[camera_name]["imaging"] = imaging
|
||||
try:
|
||||
video_sources = await media.GetVideoSources()
|
||||
@ -381,7 +385,10 @@ class OnvifController:
|
||||
f"Disabling autotracking zooming for {camera_name}: Absolute zoom not supported. Exception: {e}"
|
||||
)
|
||||
|
||||
if self.cams[camera_name]["video_source_token"] is not None:
|
||||
if (
|
||||
self.cams[camera_name]["video_source_token"] is not None
|
||||
and imaging is not None
|
||||
):
|
||||
try:
|
||||
imaging_capabilities = await imaging.GetImagingSettings(
|
||||
{"VideoSourceToken": self.cams[camera_name]["video_source_token"]}
|
||||
@ -421,6 +428,7 @@ class OnvifController:
|
||||
if (
|
||||
"focus" in self.cams[camera_name]["features"]
|
||||
and self.cams[camera_name]["video_source_token"]
|
||||
and self.cams[camera_name]["imaging"] is not None
|
||||
):
|
||||
try:
|
||||
stop_request = self.cams[camera_name]["imaging"].create_type("Stop")
|
||||
@ -648,6 +656,7 @@ class OnvifController:
|
||||
if (
|
||||
"focus" not in self.cams[camera_name]["features"]
|
||||
or not self.cams[camera_name]["video_source_token"]
|
||||
or self.cams[camera_name]["imaging"] is None
|
||||
):
|
||||
logger.error(f"{camera_name} does not support ONVIF continuous focus.")
|
||||
return
|
||||
|
||||
@ -3,6 +3,8 @@ import logging
|
||||
import os
|
||||
import unittest
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.testclient import TestClient
|
||||
from peewee_migrate import Router
|
||||
from playhouse.sqlite_ext import SqliteExtDatabase
|
||||
from playhouse.sqliteq import SqliteQueueDatabase
|
||||
@ -16,6 +18,20 @@ from frigate.review.types import SeverityEnum
|
||||
from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS
|
||||
|
||||
|
||||
class AuthTestClient(TestClient):
|
||||
"""TestClient that automatically adds auth headers to all requests."""
|
||||
|
||||
def request(self, *args, **kwargs):
|
||||
# Add default auth headers if not already present
|
||||
headers = kwargs.get("headers") or {}
|
||||
if "remote-user" not in headers:
|
||||
headers["remote-user"] = "admin"
|
||||
if "remote-role" not in headers:
|
||||
headers["remote-role"] = "admin"
|
||||
kwargs["headers"] = headers
|
||||
return super().request(*args, **kwargs)
|
||||
|
||||
|
||||
class BaseTestHttp(unittest.TestCase):
|
||||
def setUp(self, models):
|
||||
# setup clean database for each test run
|
||||
@ -113,7 +129,9 @@ class BaseTestHttp(unittest.TestCase):
|
||||
pass
|
||||
|
||||
def create_app(self, stats=None, event_metadata_publisher=None):
|
||||
return create_fastapi_app(
|
||||
from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user
|
||||
|
||||
app = create_fastapi_app(
|
||||
FrigateConfig(**self.minimal_config),
|
||||
self.db,
|
||||
None,
|
||||
@ -123,8 +141,33 @@ class BaseTestHttp(unittest.TestCase):
|
||||
stats,
|
||||
event_metadata_publisher,
|
||||
None,
|
||||
enforce_default_admin=False,
|
||||
)
|
||||
|
||||
# Default test mocks for authentication
|
||||
# Tests can override these in their setUp if needed
|
||||
# This mock uses headers set by AuthTestClient
|
||||
async def mock_get_current_user(request: Request):
|
||||
username = request.headers.get("remote-user")
|
||||
role = request.headers.get("remote-role")
|
||||
if not username or not role:
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
return JSONResponse(
|
||||
content={"message": "No authorization headers."}, status_code=401
|
||||
)
|
||||
return {"username": username, "role": role}
|
||||
|
||||
async def mock_get_allowed_cameras_for_filter(request: Request):
|
||||
return list(self.minimal_config.get("cameras", {}).keys())
|
||||
|
||||
app.dependency_overrides[get_current_user] = mock_get_current_user
|
||||
app.dependency_overrides[get_allowed_cameras_for_filter] = (
|
||||
mock_get_allowed_cameras_for_filter
|
||||
)
|
||||
|
||||
return app
|
||||
|
||||
def insert_mock_event(
|
||||
self,
|
||||
id: str,
|
||||
|
||||
@ -1,10 +1,8 @@
|
||||
from unittest.mock import Mock
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from frigate.models import Event, Recordings, ReviewSegment
|
||||
from frigate.stats.emitter import StatsEmitter
|
||||
from frigate.test.http_api.base_http_test import BaseTestHttp
|
||||
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
|
||||
|
||||
|
||||
class TestHttpApp(BaseTestHttp):
|
||||
@ -20,7 +18,7 @@ class TestHttpApp(BaseTestHttp):
|
||||
stats.get_latest_stats.return_value = self.test_stats
|
||||
app = super().create_app(stats)
|
||||
|
||||
with TestClient(app) as client:
|
||||
with AuthTestClient(app) as client:
|
||||
response = client.get("/stats")
|
||||
response_json = response.json()
|
||||
assert response_json == self.test_stats
|
||||
|
||||
@ -1,14 +1,13 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from frigate.api.auth import (
|
||||
get_allowed_cameras_for_filter,
|
||||
get_current_user,
|
||||
)
|
||||
from frigate.models import Event, Recordings, ReviewSegment
|
||||
from frigate.test.http_api.base_http_test import BaseTestHttp
|
||||
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
|
||||
|
||||
|
||||
class TestCameraAccessEventReview(BaseTestHttp):
|
||||
@ -16,9 +15,17 @@ class TestCameraAccessEventReview(BaseTestHttp):
|
||||
super().setUp([Event, ReviewSegment, Recordings])
|
||||
self.app = super().create_app()
|
||||
|
||||
# Mock get_current_user to return valid user for all tests
|
||||
async def mock_get_current_user():
|
||||
return {"username": "test_user", "role": "user"}
|
||||
# Mock get_current_user for all tests
|
||||
async def mock_get_current_user(request: Request):
|
||||
username = request.headers.get("remote-user")
|
||||
role = request.headers.get("remote-role")
|
||||
if not username or not role:
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
return JSONResponse(
|
||||
content={"message": "No authorization headers."}, status_code=401
|
||||
)
|
||||
return {"username": username, "role": role}
|
||||
|
||||
self.app.dependency_overrides[get_current_user] = mock_get_current_user
|
||||
|
||||
@ -30,21 +37,25 @@ class TestCameraAccessEventReview(BaseTestHttp):
|
||||
super().insert_mock_event("event1", camera="front_door")
|
||||
super().insert_mock_event("event2", camera="back_door")
|
||||
|
||||
self.app.dependency_overrides[get_allowed_cameras_for_filter] = lambda: [
|
||||
"front_door"
|
||||
]
|
||||
with TestClient(self.app) as client:
|
||||
async def mock_cameras(request: Request):
|
||||
return ["front_door"]
|
||||
|
||||
self.app.dependency_overrides[get_allowed_cameras_for_filter] = mock_cameras
|
||||
with AuthTestClient(self.app) as client:
|
||||
resp = client.get("/events")
|
||||
assert resp.status_code == 200
|
||||
ids = [e["id"] for e in resp.json()]
|
||||
assert "event1" in ids
|
||||
assert "event2" not in ids
|
||||
|
||||
self.app.dependency_overrides[get_allowed_cameras_for_filter] = lambda: [
|
||||
async def mock_cameras(request: Request):
|
||||
return [
|
||||
"front_door",
|
||||
"back_door",
|
||||
]
|
||||
with TestClient(self.app) as client:
|
||||
|
||||
self.app.dependency_overrides[get_allowed_cameras_for_filter] = mock_cameras
|
||||
with AuthTestClient(self.app) as client:
|
||||
resp = client.get("/events")
|
||||
assert resp.status_code == 200
|
||||
ids = [e["id"] for e in resp.json()]
|
||||
@ -54,21 +65,25 @@ class TestCameraAccessEventReview(BaseTestHttp):
|
||||
super().insert_mock_review_segment("rev1", camera="front_door")
|
||||
super().insert_mock_review_segment("rev2", camera="back_door")
|
||||
|
||||
self.app.dependency_overrides[get_allowed_cameras_for_filter] = lambda: [
|
||||
"front_door"
|
||||
]
|
||||
with TestClient(self.app) as client:
|
||||
async def mock_cameras(request: Request):
|
||||
return ["front_door"]
|
||||
|
||||
self.app.dependency_overrides[get_allowed_cameras_for_filter] = mock_cameras
|
||||
with AuthTestClient(self.app) as client:
|
||||
resp = client.get("/review")
|
||||
assert resp.status_code == 200
|
||||
ids = [r["id"] for r in resp.json()]
|
||||
assert "rev1" in ids
|
||||
assert "rev2" not in ids
|
||||
|
||||
self.app.dependency_overrides[get_allowed_cameras_for_filter] = lambda: [
|
||||
async def mock_cameras(request: Request):
|
||||
return [
|
||||
"front_door",
|
||||
"back_door",
|
||||
]
|
||||
with TestClient(self.app) as client:
|
||||
|
||||
self.app.dependency_overrides[get_allowed_cameras_for_filter] = mock_cameras
|
||||
with AuthTestClient(self.app) as client:
|
||||
resp = client.get("/review")
|
||||
assert resp.status_code == 200
|
||||
ids = [r["id"] for r in resp.json()]
|
||||
@ -84,7 +99,7 @@ class TestCameraAccessEventReview(BaseTestHttp):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
with patch("frigate.api.event.require_camera_access", mock_require_allowed):
|
||||
with TestClient(self.app) as client:
|
||||
with AuthTestClient(self.app) as client:
|
||||
resp = client.get("/events/event1")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["id"] == "event1"
|
||||
@ -94,7 +109,7 @@ class TestCameraAccessEventReview(BaseTestHttp):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
with patch("frigate.api.event.require_camera_access", mock_require_disallowed):
|
||||
with TestClient(self.app) as client:
|
||||
with AuthTestClient(self.app) as client:
|
||||
resp = client.get("/events/event1")
|
||||
assert resp.status_code == 403
|
||||
|
||||
@ -108,7 +123,7 @@ class TestCameraAccessEventReview(BaseTestHttp):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
with patch("frigate.api.review.require_camera_access", mock_require_allowed):
|
||||
with TestClient(self.app) as client:
|
||||
with AuthTestClient(self.app) as client:
|
||||
resp = client.get("/review/rev1")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["id"] == "rev1"
|
||||
@ -118,7 +133,7 @@ class TestCameraAccessEventReview(BaseTestHttp):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
with patch("frigate.api.review.require_camera_access", mock_require_disallowed):
|
||||
with TestClient(self.app) as client:
|
||||
with AuthTestClient(self.app) as client:
|
||||
resp = client.get("/review/rev1")
|
||||
assert resp.status_code == 403
|
||||
|
||||
@ -126,21 +141,25 @@ class TestCameraAccessEventReview(BaseTestHttp):
|
||||
super().insert_mock_event("event1", camera="front_door")
|
||||
super().insert_mock_event("event2", camera="back_door")
|
||||
|
||||
self.app.dependency_overrides[get_allowed_cameras_for_filter] = lambda: [
|
||||
"front_door"
|
||||
]
|
||||
with TestClient(self.app) as client:
|
||||
async def mock_cameras(request: Request):
|
||||
return ["front_door"]
|
||||
|
||||
self.app.dependency_overrides[get_allowed_cameras_for_filter] = mock_cameras
|
||||
with AuthTestClient(self.app) as client:
|
||||
resp = client.get("/events", params={"cameras": "all"})
|
||||
assert resp.status_code == 200
|
||||
ids = [e["id"] for e in resp.json()]
|
||||
assert "event1" in ids
|
||||
assert "event2" not in ids
|
||||
|
||||
self.app.dependency_overrides[get_allowed_cameras_for_filter] = lambda: [
|
||||
async def mock_cameras(request: Request):
|
||||
return [
|
||||
"front_door",
|
||||
"back_door",
|
||||
]
|
||||
with TestClient(self.app) as client:
|
||||
|
||||
self.app.dependency_overrides[get_allowed_cameras_for_filter] = mock_cameras
|
||||
with AuthTestClient(self.app) as client:
|
||||
resp = client.get("/events", params={"cameras": "all"})
|
||||
assert resp.status_code == 200
|
||||
ids = [e["id"] for e in resp.json()]
|
||||
@ -150,20 +169,24 @@ class TestCameraAccessEventReview(BaseTestHttp):
|
||||
super().insert_mock_event("event1", camera="front_door")
|
||||
super().insert_mock_event("event2", camera="back_door")
|
||||
|
||||
self.app.dependency_overrides[get_allowed_cameras_for_filter] = lambda: [
|
||||
"front_door"
|
||||
]
|
||||
with TestClient(self.app) as client:
|
||||
async def mock_cameras(request: Request):
|
||||
return ["front_door"]
|
||||
|
||||
self.app.dependency_overrides[get_allowed_cameras_for_filter] = mock_cameras
|
||||
with AuthTestClient(self.app) as client:
|
||||
resp = client.get("/events/summary")
|
||||
assert resp.status_code == 200
|
||||
summary_list = resp.json()
|
||||
assert len(summary_list) == 1
|
||||
|
||||
self.app.dependency_overrides[get_allowed_cameras_for_filter] = lambda: [
|
||||
async def mock_cameras(request: Request):
|
||||
return [
|
||||
"front_door",
|
||||
"back_door",
|
||||
]
|
||||
with TestClient(self.app) as client:
|
||||
|
||||
self.app.dependency_overrides[get_allowed_cameras_for_filter] = mock_cameras
|
||||
with AuthTestClient(self.app) as client:
|
||||
resp = client.get("/events/summary")
|
||||
summary_list = resp.json()
|
||||
assert len(summary_list) == 2
|
||||
|
||||
@ -2,14 +2,13 @@ from datetime import datetime
|
||||
from typing import Any
|
||||
from unittest.mock import Mock
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from playhouse.shortcuts import model_to_dict
|
||||
|
||||
from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user
|
||||
from frigate.comms.event_metadata_updater import EventMetadataPublisher
|
||||
from frigate.models import Event, Recordings, ReviewSegment, Timeline
|
||||
from frigate.stats.emitter import StatsEmitter
|
||||
from frigate.test.http_api.base_http_test import BaseTestHttp
|
||||
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp, Request
|
||||
from frigate.test.test_storage import _insert_mock_event
|
||||
|
||||
|
||||
@ -18,14 +17,26 @@ class TestHttpApp(BaseTestHttp):
|
||||
super().setUp([Event, Recordings, ReviewSegment, Timeline])
|
||||
self.app = super().create_app()
|
||||
|
||||
# Mock auth to bypass camera access for tests
|
||||
async def mock_get_current_user(request: Any):
|
||||
return {"username": "test_user", "role": "admin"}
|
||||
# Mock get_current_user for all tests
|
||||
async def mock_get_current_user(request: Request):
|
||||
username = request.headers.get("remote-user")
|
||||
role = request.headers.get("remote-role")
|
||||
if not username or not role:
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
return JSONResponse(
|
||||
content={"message": "No authorization headers."}, status_code=401
|
||||
)
|
||||
return {"username": username, "role": role}
|
||||
|
||||
self.app.dependency_overrides[get_current_user] = mock_get_current_user
|
||||
self.app.dependency_overrides[get_allowed_cameras_for_filter] = lambda: [
|
||||
"front_door"
|
||||
]
|
||||
|
||||
async def mock_get_allowed_cameras_for_filter(request: Request):
|
||||
return ["front_door"]
|
||||
|
||||
self.app.dependency_overrides[get_allowed_cameras_for_filter] = (
|
||||
mock_get_allowed_cameras_for_filter
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
self.app.dependency_overrides.clear()
|
||||
@ -35,20 +46,20 @@ class TestHttpApp(BaseTestHttp):
|
||||
################################### GET /events Endpoint #########################################################
|
||||
####################################################################################################################
|
||||
def test_get_event_list_no_events(self):
|
||||
with TestClient(self.app) as client:
|
||||
with AuthTestClient(self.app) as client:
|
||||
events = client.get("/events").json()
|
||||
assert len(events) == 0
|
||||
|
||||
def test_get_event_list_no_match_event_id(self):
|
||||
id = "123456.random"
|
||||
with TestClient(self.app) as client:
|
||||
with AuthTestClient(self.app) as client:
|
||||
super().insert_mock_event(id)
|
||||
events = client.get("/events", params={"event_id": "abc"}).json()
|
||||
assert len(events) == 0
|
||||
|
||||
def test_get_event_list_match_event_id(self):
|
||||
id = "123456.random"
|
||||
with TestClient(self.app) as client:
|
||||
with AuthTestClient(self.app) as client:
|
||||
super().insert_mock_event(id)
|
||||
events = client.get("/events", params={"event_id": id}).json()
|
||||
assert len(events) == 1
|
||||
@ -58,7 +69,7 @@ class TestHttpApp(BaseTestHttp):
|
||||
now = int(datetime.now().timestamp())
|
||||
|
||||
id = "123456.random"
|
||||
with TestClient(self.app) as client:
|
||||
with AuthTestClient(self.app) as client:
|
||||
super().insert_mock_event(id, now, now + 1)
|
||||
events = client.get(
|
||||
"/events", params={"max_length": 1, "min_length": 1}
|
||||
@ -69,7 +80,7 @@ class TestHttpApp(BaseTestHttp):
|
||||
def test_get_event_list_no_match_max_length(self):
|
||||
now = int(datetime.now().timestamp())
|
||||
|
||||
with TestClient(self.app) as client:
|
||||
with AuthTestClient(self.app) as client:
|
||||
id = "123456.random"
|
||||
super().insert_mock_event(id, now, now + 2)
|
||||
events = client.get("/events", params={"max_length": 1}).json()
|
||||
@ -78,7 +89,7 @@ class TestHttpApp(BaseTestHttp):
|
||||
def test_get_event_list_no_match_min_length(self):
|
||||
now = int(datetime.now().timestamp())
|
||||
|
||||
with TestClient(self.app) as client:
|
||||
with AuthTestClient(self.app) as client:
|
||||
id = "123456.random"
|
||||
super().insert_mock_event(id, now, now + 2)
|
||||
events = client.get("/events", params={"min_length": 3}).json()
|
||||
@ -88,7 +99,7 @@ class TestHttpApp(BaseTestHttp):
|
||||
id = "123456.random"
|
||||
id2 = "54321.random"
|
||||
|
||||
with TestClient(self.app) as client:
|
||||
with AuthTestClient(self.app) as client:
|
||||
super().insert_mock_event(id)
|
||||
events = client.get("/events").json()
|
||||
assert len(events) == 1
|
||||
@ -108,14 +119,14 @@ class TestHttpApp(BaseTestHttp):
|
||||
def test_get_event_list_no_match_has_clip(self):
|
||||
now = int(datetime.now().timestamp())
|
||||
|
||||
with TestClient(self.app) as client:
|
||||
with AuthTestClient(self.app) as client:
|
||||
id = "123456.random"
|
||||
super().insert_mock_event(id, now, now + 2)
|
||||
events = client.get("/events", params={"has_clip": 0}).json()
|
||||
assert len(events) == 0
|
||||
|
||||
def test_get_event_list_has_clip(self):
|
||||
with TestClient(self.app) as client:
|
||||
with AuthTestClient(self.app) as client:
|
||||
id = "123456.random"
|
||||
super().insert_mock_event(id, has_clip=True)
|
||||
events = client.get("/events", params={"has_clip": 1}).json()
|
||||
@ -123,7 +134,7 @@ class TestHttpApp(BaseTestHttp):
|
||||
assert events[0]["id"] == id
|
||||
|
||||
def test_get_event_list_sort_score(self):
|
||||
with TestClient(self.app) as client:
|
||||
with AuthTestClient(self.app) as client:
|
||||
id = "123456.random"
|
||||
id2 = "54321.random"
|
||||
super().insert_mock_event(id, top_score=37, score=37, data={"score": 50})
|
||||
@ -141,7 +152,7 @@ class TestHttpApp(BaseTestHttp):
|
||||
def test_get_event_list_sort_start_time(self):
|
||||
now = int(datetime.now().timestamp())
|
||||
|
||||
with TestClient(self.app) as client:
|
||||
with AuthTestClient(self.app) as client:
|
||||
id = "123456.random"
|
||||
id2 = "54321.random"
|
||||
super().insert_mock_event(id, start_time=now + 3)
|
||||
@ -159,7 +170,7 @@ class TestHttpApp(BaseTestHttp):
|
||||
def test_get_good_event(self):
|
||||
id = "123456.random"
|
||||
|
||||
with TestClient(self.app) as client:
|
||||
with AuthTestClient(self.app) as client:
|
||||
super().insert_mock_event(id)
|
||||
event = client.get(f"/events/{id}").json()
|
||||
|
||||
@ -171,7 +182,7 @@ class TestHttpApp(BaseTestHttp):
|
||||
id = "123456.random"
|
||||
bad_id = "654321.other"
|
||||
|
||||
with TestClient(self.app) as client:
|
||||
with AuthTestClient(self.app) as client:
|
||||
super().insert_mock_event(id)
|
||||
event_response = client.get(f"/events/{bad_id}")
|
||||
assert event_response.status_code == 404
|
||||
@ -180,7 +191,7 @@ class TestHttpApp(BaseTestHttp):
|
||||
def test_delete_event(self):
|
||||
id = "123456.random"
|
||||
|
||||
with TestClient(self.app) as client:
|
||||
with AuthTestClient(self.app) as client:
|
||||
super().insert_mock_event(id)
|
||||
event = client.get(f"/events/{id}").json()
|
||||
assert event
|
||||
@ -193,7 +204,7 @@ class TestHttpApp(BaseTestHttp):
|
||||
def test_event_retention(self):
|
||||
id = "123456.random"
|
||||
|
||||
with TestClient(self.app) as client:
|
||||
with AuthTestClient(self.app) as client:
|
||||
super().insert_mock_event(id)
|
||||
client.post(f"/events/{id}/retain", headers={"remote-role": "admin"})
|
||||
event = client.get(f"/events/{id}").json()
|
||||
@ -212,12 +223,11 @@ class TestHttpApp(BaseTestHttp):
|
||||
morning = 1656590400 # 06/30/2022 6 am (GMT)
|
||||
evening = 1656633600 # 06/30/2022 6 pm (GMT)
|
||||
|
||||
with TestClient(self.app) as client:
|
||||
with AuthTestClient(self.app) as client:
|
||||
super().insert_mock_event(morning_id, morning)
|
||||
super().insert_mock_event(evening_id, evening)
|
||||
# both events come back
|
||||
events = client.get("/events").json()
|
||||
print("events!!!", events)
|
||||
assert events
|
||||
assert len(events) == 2
|
||||
# morning event is excluded
|
||||
@ -248,7 +258,7 @@ class TestHttpApp(BaseTestHttp):
|
||||
|
||||
mock_event_updater.publish.side_effect = update_event
|
||||
|
||||
with TestClient(app) as client:
|
||||
with AuthTestClient(app) as client:
|
||||
super().insert_mock_event(id)
|
||||
new_sub_label_response = client.post(
|
||||
f"/events/{id}/sub_label",
|
||||
@ -285,7 +295,7 @@ class TestHttpApp(BaseTestHttp):
|
||||
|
||||
mock_event_updater.publish.side_effect = update_event
|
||||
|
||||
with TestClient(app) as client:
|
||||
with AuthTestClient(app) as client:
|
||||
super().insert_mock_event(id)
|
||||
client.post(
|
||||
f"/events/{id}/sub_label",
|
||||
@ -301,7 +311,7 @@ class TestHttpApp(BaseTestHttp):
|
||||
####################################################################################################################
|
||||
def test_get_metrics(self):
|
||||
"""ensure correct prometheus metrics api response"""
|
||||
with TestClient(self.app) as client:
|
||||
with AuthTestClient(self.app) as client:
|
||||
ts_start = datetime.now().timestamp()
|
||||
ts_end = ts_start + 30
|
||||
_insert_mock_event(
|
||||
|
||||
@ -1,14 +1,13 @@
|
||||
"""Unit tests for recordings/media API endpoints."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import pytz
|
||||
from fastapi.testclient import TestClient
|
||||
from fastapi import Request
|
||||
|
||||
from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user
|
||||
from frigate.models import Recordings
|
||||
from frigate.test.http_api.base_http_test import BaseTestHttp
|
||||
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
|
||||
|
||||
|
||||
class TestHttpMedia(BaseTestHttp):
|
||||
@ -19,15 +18,26 @@ class TestHttpMedia(BaseTestHttp):
|
||||
super().setUp([Recordings])
|
||||
self.app = super().create_app()
|
||||
|
||||
# Mock auth to bypass camera access for tests
|
||||
async def mock_get_current_user(request: Any):
|
||||
return {"username": "test_user", "role": "admin"}
|
||||
# Mock get_current_user for all tests
|
||||
async def mock_get_current_user(request: Request):
|
||||
username = request.headers.get("remote-user")
|
||||
role = request.headers.get("remote-role")
|
||||
if not username or not role:
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
return JSONResponse(
|
||||
content={"message": "No authorization headers."}, status_code=401
|
||||
)
|
||||
return {"username": username, "role": role}
|
||||
|
||||
self.app.dependency_overrides[get_current_user] = mock_get_current_user
|
||||
self.app.dependency_overrides[get_allowed_cameras_for_filter] = lambda: [
|
||||
"front_door",
|
||||
"back_door",
|
||||
]
|
||||
|
||||
async def mock_get_allowed_cameras_for_filter(request: Request):
|
||||
return ["front_door"]
|
||||
|
||||
self.app.dependency_overrides[get_allowed_cameras_for_filter] = (
|
||||
mock_get_allowed_cameras_for_filter
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
"""Clean up after tests."""
|
||||
@ -52,7 +62,7 @@ class TestHttpMedia(BaseTestHttp):
|
||||
# March 11, 2024 at 12:00 PM EDT (after DST)
|
||||
march_11_noon = tz.localize(datetime(2024, 3, 11, 12, 0, 0)).timestamp()
|
||||
|
||||
with TestClient(self.app) as client:
|
||||
with AuthTestClient(self.app) as client:
|
||||
# Insert recordings for each day
|
||||
Recordings.insert(
|
||||
id="recording_march_9",
|
||||
@ -128,7 +138,7 @@ class TestHttpMedia(BaseTestHttp):
|
||||
# November 4, 2024 at 12:00 PM EST (after DST)
|
||||
nov_4_noon = tz.localize(datetime(2024, 11, 4, 12, 0, 0)).timestamp()
|
||||
|
||||
with TestClient(self.app) as client:
|
||||
with AuthTestClient(self.app) as client:
|
||||
# Insert recordings for each day
|
||||
Recordings.insert(
|
||||
id="recording_nov_2",
|
||||
@ -195,7 +205,15 @@ class TestHttpMedia(BaseTestHttp):
|
||||
# March 10, 2024 at 3:00 PM EDT (after DST transition)
|
||||
march_10_afternoon = tz.localize(datetime(2024, 3, 10, 15, 0, 0)).timestamp()
|
||||
|
||||
with TestClient(self.app) as client:
|
||||
with AuthTestClient(self.app) as client:
|
||||
# Override allowed cameras for this test to include both
|
||||
async def mock_get_allowed_cameras_for_filter(_request: Request):
|
||||
return ["front_door", "back_door"]
|
||||
|
||||
self.app.dependency_overrides[get_allowed_cameras_for_filter] = (
|
||||
mock_get_allowed_cameras_for_filter
|
||||
)
|
||||
|
||||
# Insert recordings for front_door on March 9
|
||||
Recordings.insert(
|
||||
id="front_march_9",
|
||||
@ -236,6 +254,14 @@ class TestHttpMedia(BaseTestHttp):
|
||||
assert summary["2024-03-09"] is True
|
||||
assert summary["2024-03-10"] is True
|
||||
|
||||
# Reset dependency override back to default single camera for other tests
|
||||
async def reset_allowed_cameras(_request: Request):
|
||||
return ["front_door"]
|
||||
|
||||
self.app.dependency_overrides[get_allowed_cameras_for_filter] = (
|
||||
reset_allowed_cameras
|
||||
)
|
||||
|
||||
def test_recordings_summary_at_dst_transition_time(self):
|
||||
"""
|
||||
Test recordings that span the exact DST transition time.
|
||||
@ -250,7 +276,7 @@ class TestHttpMedia(BaseTestHttp):
|
||||
# This is 1.5 hours of actual time but spans the "missing" hour
|
||||
after_transition = tz.localize(datetime(2024, 3, 10, 3, 30, 0)).timestamp()
|
||||
|
||||
with TestClient(self.app) as client:
|
||||
with AuthTestClient(self.app) as client:
|
||||
Recordings.insert(
|
||||
id="recording_during_transition",
|
||||
path="/media/recordings/transition.mp4",
|
||||
@ -283,7 +309,7 @@ class TestHttpMedia(BaseTestHttp):
|
||||
march_9_utc = datetime(2024, 3, 9, 17, 0, 0, tzinfo=timezone.utc).timestamp()
|
||||
march_10_utc = datetime(2024, 3, 10, 17, 0, 0, tzinfo=timezone.utc).timestamp()
|
||||
|
||||
with TestClient(self.app) as client:
|
||||
with AuthTestClient(self.app) as client:
|
||||
Recordings.insert(
|
||||
id="recording_march_9_utc",
|
||||
path="/media/recordings/march_9_utc.mp4",
|
||||
@ -325,7 +351,7 @@ class TestHttpMedia(BaseTestHttp):
|
||||
"""
|
||||
Test recordings summary when no recordings exist.
|
||||
"""
|
||||
with TestClient(self.app) as client:
|
||||
with AuthTestClient(self.app) as client:
|
||||
response = client.get(
|
||||
"/recordings/summary",
|
||||
params={"timezone": "America/New_York", "cameras": "all"},
|
||||
@ -342,7 +368,7 @@ class TestHttpMedia(BaseTestHttp):
|
||||
tz = pytz.timezone("America/New_York")
|
||||
march_10_noon = tz.localize(datetime(2024, 3, 10, 12, 0, 0)).timestamp()
|
||||
|
||||
with TestClient(self.app) as client:
|
||||
with AuthTestClient(self.app) as client:
|
||||
# Insert recordings for both cameras
|
||||
Recordings.insert(
|
||||
id="front_recording",
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from fastapi import Request
|
||||
from peewee import DoesNotExist
|
||||
|
||||
from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user
|
||||
from frigate.models import Event, Recordings, ReviewSegment, UserReviewStatus
|
||||
from frigate.review.types import SeverityEnum
|
||||
from frigate.test.http_api.base_http_test import BaseTestHttp
|
||||
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
|
||||
|
||||
|
||||
class TestHttpReview(BaseTestHttp):
|
||||
@ -16,14 +16,26 @@ class TestHttpReview(BaseTestHttp):
|
||||
self.user_id = "admin"
|
||||
|
||||
# Mock get_current_user for all tests
|
||||
async def mock_get_current_user():
|
||||
return {"username": self.user_id, "role": "admin"}
|
||||
# This mock uses headers set by AuthTestClient
|
||||
async def mock_get_current_user(request: Request):
|
||||
username = request.headers.get("remote-user")
|
||||
role = request.headers.get("remote-role")
|
||||
if not username or not role:
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
return JSONResponse(
|
||||
content={"message": "No authorization headers."}, status_code=401
|
||||
)
|
||||
return {"username": username, "role": role}
|
||||
|
||||
self.app.dependency_overrides[get_current_user] = mock_get_current_user
|
||||
|
||||
self.app.dependency_overrides[get_allowed_cameras_for_filter] = lambda: [
|
||||
"front_door"
|
||||
]
|
||||
async def mock_get_allowed_cameras_for_filter(request: Request):
|
||||
return ["front_door"]
|
||||
|
||||
self.app.dependency_overrides[get_allowed_cameras_for_filter] = (
|
||||
mock_get_allowed_cameras_for_filter
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
self.app.dependency_overrides.clear()
|
||||
@ -57,7 +69,7 @@ class TestHttpReview(BaseTestHttp):
|
||||
but ends after is included in the results."""
|
||||
now = datetime.now().timestamp()
|
||||
|
||||
with TestClient(self.app) as client:
|
||||
with AuthTestClient(self.app) as client:
|
||||
super().insert_mock_review_segment("123456.random", now, now + 2)
|
||||
response = client.get("/review")
|
||||
assert response.status_code == 200
|
||||
@ -67,7 +79,7 @@ class TestHttpReview(BaseTestHttp):
|
||||
def test_get_review_no_filters(self):
|
||||
now = datetime.now().timestamp()
|
||||
|
||||
with TestClient(self.app) as client:
|
||||
with AuthTestClient(self.app) as client:
|
||||
id = "123456.random"
|
||||
super().insert_mock_review_segment(id, now - 2, now - 1)
|
||||
response = client.get("/review")
|
||||
@ -81,7 +93,7 @@ class TestHttpReview(BaseTestHttp):
|
||||
"""Test that review items outside the range are not returned."""
|
||||
now = datetime.now().timestamp()
|
||||
|
||||
with TestClient(self.app) as client:
|
||||
with AuthTestClient(self.app) as client:
|
||||
id = "123456.random"
|
||||
super().insert_mock_review_segment(id, now - 2, now - 1)
|
||||
super().insert_mock_review_segment(f"{id}2", now + 4, now + 5)
|
||||
@ -97,7 +109,7 @@ class TestHttpReview(BaseTestHttp):
|
||||
def test_get_review_with_time_filter(self):
|
||||
now = datetime.now().timestamp()
|
||||
|
||||
with TestClient(self.app) as client:
|
||||
with AuthTestClient(self.app) as client:
|
||||
id = "123456.random"
|
||||
super().insert_mock_review_segment(id, now, now + 2)
|
||||
params = {
|
||||
@ -113,7 +125,7 @@ class TestHttpReview(BaseTestHttp):
|
||||
def test_get_review_with_limit_filter(self):
|
||||
now = datetime.now().timestamp()
|
||||
|
||||
with TestClient(self.app) as client:
|
||||
with AuthTestClient(self.app) as client:
|
||||
id = "123456.random"
|
||||
id2 = "654321.random"
|
||||
super().insert_mock_review_segment(id, now, now + 2)
|
||||
@ -132,7 +144,7 @@ class TestHttpReview(BaseTestHttp):
|
||||
def test_get_review_with_severity_filters_no_matches(self):
|
||||
now = datetime.now().timestamp()
|
||||
|
||||
with TestClient(self.app) as client:
|
||||
with AuthTestClient(self.app) as client:
|
||||
id = "123456.random"
|
||||
super().insert_mock_review_segment(id, now, now + 2, SeverityEnum.detection)
|
||||
params = {
|
||||
@ -149,7 +161,7 @@ class TestHttpReview(BaseTestHttp):
|
||||
def test_get_review_with_severity_filters(self):
|
||||
now = datetime.now().timestamp()
|
||||
|
||||
with TestClient(self.app) as client:
|
||||
with AuthTestClient(self.app) as client:
|
||||
id = "123456.random"
|
||||
super().insert_mock_review_segment(id, now, now + 2, SeverityEnum.detection)
|
||||
params = {
|
||||
@ -165,7 +177,7 @@ class TestHttpReview(BaseTestHttp):
|
||||
def test_get_review_with_all_filters(self):
|
||||
now = datetime.now().timestamp()
|
||||
|
||||
with TestClient(self.app) as client:
|
||||
with AuthTestClient(self.app) as client:
|
||||
id = "123456.random"
|
||||
super().insert_mock_review_segment(id, now, now + 2)
|
||||
params = {
|
||||
@ -188,7 +200,7 @@ class TestHttpReview(BaseTestHttp):
|
||||
################################### GET /review/summary Endpoint #################################################
|
||||
####################################################################################################################
|
||||
def test_get_review_summary_all_filters(self):
|
||||
with TestClient(self.app) as client:
|
||||
with AuthTestClient(self.app) as client:
|
||||
super().insert_mock_review_segment("123456.random")
|
||||
params = {
|
||||
"cameras": "front_door",
|
||||
@ -219,7 +231,7 @@ class TestHttpReview(BaseTestHttp):
|
||||
self.assertEqual(response_json, expected_response)
|
||||
|
||||
def test_get_review_summary_no_filters(self):
|
||||
with TestClient(self.app) as client:
|
||||
with AuthTestClient(self.app) as client:
|
||||
super().insert_mock_review_segment("123456.random")
|
||||
response = client.get("/review/summary")
|
||||
assert response.status_code == 200
|
||||
@ -247,7 +259,7 @@ class TestHttpReview(BaseTestHttp):
|
||||
now = datetime.now()
|
||||
five_days_ago = datetime.today() - timedelta(days=5)
|
||||
|
||||
with TestClient(self.app) as client:
|
||||
with AuthTestClient(self.app) as client:
|
||||
super().insert_mock_review_segment(
|
||||
"123456.random", now.timestamp() - 2, now.timestamp() - 1
|
||||
)
|
||||
@ -291,7 +303,7 @@ class TestHttpReview(BaseTestHttp):
|
||||
now = datetime.now()
|
||||
five_days_ago = datetime.today() - timedelta(days=5)
|
||||
|
||||
with TestClient(self.app) as client:
|
||||
with AuthTestClient(self.app) as client:
|
||||
super().insert_mock_review_segment("123456.random", now.timestamp())
|
||||
five_days_ago_ts = five_days_ago.timestamp()
|
||||
for i in range(20):
|
||||
@ -342,7 +354,7 @@ class TestHttpReview(BaseTestHttp):
|
||||
def test_get_review_summary_multiple_in_same_day_with_reviewed(self):
|
||||
five_days_ago = datetime.today() - timedelta(days=5)
|
||||
|
||||
with TestClient(self.app) as client:
|
||||
with AuthTestClient(self.app) as client:
|
||||
five_days_ago_ts = five_days_ago.timestamp()
|
||||
for i in range(10):
|
||||
id = f"123456_{i}.random_alert_not_reviewed"
|
||||
@ -393,14 +405,14 @@ class TestHttpReview(BaseTestHttp):
|
||||
####################################################################################################################
|
||||
|
||||
def test_post_reviews_viewed_no_body(self):
|
||||
with TestClient(self.app) as client:
|
||||
with AuthTestClient(self.app) as client:
|
||||
super().insert_mock_review_segment("123456.random")
|
||||
response = client.post("/reviews/viewed")
|
||||
# Missing ids
|
||||
assert response.status_code == 422
|
||||
|
||||
def test_post_reviews_viewed_no_body_ids(self):
|
||||
with TestClient(self.app) as client:
|
||||
with AuthTestClient(self.app) as client:
|
||||
super().insert_mock_review_segment("123456.random")
|
||||
body = {"ids": [""]}
|
||||
response = client.post("/reviews/viewed", json=body)
|
||||
@ -408,7 +420,7 @@ class TestHttpReview(BaseTestHttp):
|
||||
assert response.status_code == 422
|
||||
|
||||
def test_post_reviews_viewed_non_existent_id(self):
|
||||
with TestClient(self.app) as client:
|
||||
with AuthTestClient(self.app) as client:
|
||||
id = "123456.random"
|
||||
super().insert_mock_review_segment(id)
|
||||
body = {"ids": ["1"]}
|
||||
@ -425,7 +437,7 @@ class TestHttpReview(BaseTestHttp):
|
||||
)
|
||||
|
||||
def test_post_reviews_viewed(self):
|
||||
with TestClient(self.app) as client:
|
||||
with AuthTestClient(self.app) as client:
|
||||
id = "123456.random"
|
||||
super().insert_mock_review_segment(id)
|
||||
body = {"ids": [id]}
|
||||
@ -445,14 +457,14 @@ class TestHttpReview(BaseTestHttp):
|
||||
################################### POST reviews/delete Endpoint ################################################
|
||||
####################################################################################################################
|
||||
def test_post_reviews_delete_no_body(self):
|
||||
with TestClient(self.app) as client:
|
||||
with AuthTestClient(self.app) as client:
|
||||
super().insert_mock_review_segment("123456.random")
|
||||
response = client.post("/reviews/delete", headers={"remote-role": "admin"})
|
||||
# Missing ids
|
||||
assert response.status_code == 422
|
||||
|
||||
def test_post_reviews_delete_no_body_ids(self):
|
||||
with TestClient(self.app) as client:
|
||||
with AuthTestClient(self.app) as client:
|
||||
super().insert_mock_review_segment("123456.random")
|
||||
body = {"ids": [""]}
|
||||
response = client.post(
|
||||
@ -462,7 +474,7 @@ class TestHttpReview(BaseTestHttp):
|
||||
assert response.status_code == 422
|
||||
|
||||
def test_post_reviews_delete_non_existent_id(self):
|
||||
with TestClient(self.app) as client:
|
||||
with AuthTestClient(self.app) as client:
|
||||
id = "123456.random"
|
||||
super().insert_mock_review_segment(id)
|
||||
body = {"ids": ["1"]}
|
||||
@ -479,7 +491,7 @@ class TestHttpReview(BaseTestHttp):
|
||||
assert review_ids_in_db_after[0].id == id
|
||||
|
||||
def test_post_reviews_delete(self):
|
||||
with TestClient(self.app) as client:
|
||||
with AuthTestClient(self.app) as client:
|
||||
id = "123456.random"
|
||||
super().insert_mock_review_segment(id)
|
||||
body = {"ids": [id]}
|
||||
@ -495,7 +507,7 @@ class TestHttpReview(BaseTestHttp):
|
||||
assert len(review_ids_in_db_after) == 0
|
||||
|
||||
def test_post_reviews_delete_many(self):
|
||||
with TestClient(self.app) as client:
|
||||
with AuthTestClient(self.app) as client:
|
||||
ids = ["123456.random", "654321.random"]
|
||||
for id in ids:
|
||||
super().insert_mock_review_segment(id)
|
||||
@ -527,7 +539,7 @@ class TestHttpReview(BaseTestHttp):
|
||||
def test_review_activity_motion_no_data_for_time_range(self):
|
||||
now = datetime.now().timestamp()
|
||||
|
||||
with TestClient(self.app) as client:
|
||||
with AuthTestClient(self.app) as client:
|
||||
params = {
|
||||
"after": now,
|
||||
"before": now + 3,
|
||||
@ -540,7 +552,7 @@ class TestHttpReview(BaseTestHttp):
|
||||
def test_review_activity_motion(self):
|
||||
now = int(datetime.now().timestamp())
|
||||
|
||||
with TestClient(self.app) as client:
|
||||
with AuthTestClient(self.app) as client:
|
||||
one_m = int((datetime.now() + timedelta(minutes=1)).timestamp())
|
||||
id = "123456.random"
|
||||
id2 = "123451.random"
|
||||
@ -573,7 +585,7 @@ class TestHttpReview(BaseTestHttp):
|
||||
################################### GET /review/event/{event_id} Endpoint #######################################
|
||||
####################################################################################################################
|
||||
def test_review_event_not_found(self):
|
||||
with TestClient(self.app) as client:
|
||||
with AuthTestClient(self.app) as client:
|
||||
response = client.get("/review/event/123456.random")
|
||||
assert response.status_code == 404
|
||||
response_json = response.json()
|
||||
@ -585,7 +597,7 @@ class TestHttpReview(BaseTestHttp):
|
||||
def test_review_event_not_found_in_data(self):
|
||||
now = datetime.now().timestamp()
|
||||
|
||||
with TestClient(self.app) as client:
|
||||
with AuthTestClient(self.app) as client:
|
||||
id = "123456.random"
|
||||
super().insert_mock_review_segment(id, now + 1, now + 2)
|
||||
response = client.get(f"/review/event/{id}")
|
||||
@ -599,7 +611,7 @@ class TestHttpReview(BaseTestHttp):
|
||||
def test_review_get_specific_event(self):
|
||||
now = datetime.now().timestamp()
|
||||
|
||||
with TestClient(self.app) as client:
|
||||
with AuthTestClient(self.app) as client:
|
||||
event_id = "123456.event.random"
|
||||
super().insert_mock_event(event_id)
|
||||
review_id = "123456.review.random"
|
||||
@ -626,7 +638,7 @@ class TestHttpReview(BaseTestHttp):
|
||||
################################### GET /review/{review_id} Endpoint #######################################
|
||||
####################################################################################################################
|
||||
def test_review_not_found(self):
|
||||
with TestClient(self.app) as client:
|
||||
with AuthTestClient(self.app) as client:
|
||||
response = client.get("/review/123456.random")
|
||||
assert response.status_code == 404
|
||||
response_json = response.json()
|
||||
@ -638,7 +650,7 @@ class TestHttpReview(BaseTestHttp):
|
||||
def test_get_review(self):
|
||||
now = datetime.now().timestamp()
|
||||
|
||||
with TestClient(self.app) as client:
|
||||
with AuthTestClient(self.app) as client:
|
||||
review_id = "123456.review.random"
|
||||
super().insert_mock_review_segment(review_id, now + 1, now + 2)
|
||||
response = client.get(f"/review/{review_id}")
|
||||
@ -662,7 +674,7 @@ class TestHttpReview(BaseTestHttp):
|
||||
####################################################################################################################
|
||||
|
||||
def test_delete_review_viewed_review_not_found(self):
|
||||
with TestClient(self.app) as client:
|
||||
with AuthTestClient(self.app) as client:
|
||||
review_id = "123456.random"
|
||||
response = client.delete(f"/review/{review_id}/viewed")
|
||||
assert response.status_code == 404
|
||||
@ -675,7 +687,7 @@ class TestHttpReview(BaseTestHttp):
|
||||
def test_delete_review_viewed(self):
|
||||
now = datetime.now().timestamp()
|
||||
|
||||
with TestClient(self.app) as client:
|
||||
with AuthTestClient(self.app) as client:
|
||||
review_id = "123456.review.random"
|
||||
super().insert_mock_review_segment(review_id, now + 1, now + 2)
|
||||
self._insert_user_review_status(review_id, reviewed=True)
|
||||
|
||||
@ -109,6 +109,7 @@ class TimelineProcessor(threading.Thread):
|
||||
event_data["region"],
|
||||
),
|
||||
"attribute": "",
|
||||
"score": event_data["score"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@ -348,7 +348,7 @@ def migrate_016_0(config: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]
|
||||
|
||||
|
||||
def migrate_017_0(config: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]]:
|
||||
"""Handle migrating frigate config to 0.16-0"""
|
||||
"""Handle migrating frigate config to 0.17-0"""
|
||||
new_config = config.copy()
|
||||
|
||||
# migrate global to new recording configuration
|
||||
@ -380,7 +380,7 @@ def migrate_017_0(config: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]
|
||||
|
||||
if global_genai:
|
||||
new_genai_config = {}
|
||||
new_object_config = config.get("objects", {})
|
||||
new_object_config = new_config.get("objects", {})
|
||||
new_object_config["genai"] = {}
|
||||
|
||||
for key in global_genai.keys():
|
||||
@ -389,7 +389,8 @@ def migrate_017_0(config: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]
|
||||
else:
|
||||
new_object_config["genai"][key] = global_genai[key]
|
||||
|
||||
config["genai"] = new_genai_config
|
||||
new_config["genai"] = new_genai_config
|
||||
new_config["objects"] = new_object_config
|
||||
|
||||
for name, camera in config.get("cameras", {}).items():
|
||||
camera_config: dict[str, dict[str, Any]] = camera.copy()
|
||||
@ -415,8 +416,9 @@ def migrate_017_0(config: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]
|
||||
camera_genai = camera_config.get("genai", {})
|
||||
|
||||
if camera_genai:
|
||||
new_object_config = config.get("objects", {})
|
||||
new_object_config["genai"] = camera_genai
|
||||
camera_object_config = camera_config.get("objects", {})
|
||||
camera_object_config["genai"] = camera_genai
|
||||
camera_config["objects"] = camera_object_config
|
||||
del camera_config["genai"]
|
||||
|
||||
new_config["cameras"][name] = camera_config
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
import atexit
|
||||
import faulthandler
|
||||
import logging
|
||||
import multiprocessing as mp
|
||||
import os
|
||||
import pathlib
|
||||
import subprocess
|
||||
import threading
|
||||
from logging.handlers import QueueHandler
|
||||
from multiprocessing.synchronize import Event as MpEvent
|
||||
@ -48,6 +51,7 @@ class FrigateProcess(BaseProcess):
|
||||
|
||||
def before_start(self) -> None:
|
||||
self.__log_queue = frigate.log.log_listener.queue
|
||||
self.__memray_tracker = None
|
||||
|
||||
def pre_run_setup(self, logConfig: LoggerConfig | None = None) -> None:
|
||||
os.nice(self.priority)
|
||||
@ -64,3 +68,86 @@ class FrigateProcess(BaseProcess):
|
||||
frigate.log.apply_log_levels(
|
||||
logConfig.default.value.upper(), logConfig.logs
|
||||
)
|
||||
|
||||
self._setup_memray()
|
||||
|
||||
def _setup_memray(self) -> None:
|
||||
"""Setup memray profiling if enabled via environment variable."""
|
||||
memray_modules = os.environ.get("FRIGATE_MEMRAY_MODULES", "")
|
||||
|
||||
if not memray_modules:
|
||||
return
|
||||
|
||||
# Extract module name from process name (e.g., "frigate.capture:camera" -> "frigate.capture")
|
||||
process_name = self.name
|
||||
module_name = (
|
||||
process_name.split(":")[0] if ":" in process_name else process_name
|
||||
)
|
||||
|
||||
enabled_modules = [m.strip() for m in memray_modules.split(",")]
|
||||
|
||||
if module_name not in enabled_modules and process_name not in enabled_modules:
|
||||
return
|
||||
|
||||
try:
|
||||
import memray
|
||||
|
||||
reports_dir = pathlib.Path("/config/memray_reports")
|
||||
reports_dir.mkdir(parents=True, exist_ok=True)
|
||||
safe_name = (
|
||||
process_name.replace(":", "_").replace("/", "_").replace("\\", "_")
|
||||
)
|
||||
|
||||
binary_file = reports_dir / f"{safe_name}.bin"
|
||||
|
||||
self.__memray_tracker = memray.Tracker(str(binary_file))
|
||||
self.__memray_tracker.__enter__()
|
||||
|
||||
# Register cleanup handler to stop tracking and generate HTML report
|
||||
# atexit runs on normal exits and most signal-based terminations (SIGTERM, SIGINT)
|
||||
# For hard kills (SIGKILL) or segfaults, the binary file is preserved for manual generation
|
||||
atexit.register(self._cleanup_memray, safe_name, binary_file)
|
||||
|
||||
self.logger.info(
|
||||
f"Memray profiling enabled for module {module_name} (process: {self.name}). "
|
||||
f"Binary file (updated continuously): {binary_file}. "
|
||||
f"HTML report will be generated on exit: {reports_dir}/{safe_name}.html. "
|
||||
f"If process crashes, manually generate with: memray flamegraph {binary_file}"
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to setup memray profiling: {e}", exc_info=True)
|
||||
|
||||
def _cleanup_memray(self, safe_name: str, binary_file: pathlib.Path) -> None:
|
||||
"""Stop memray tracking and generate HTML report."""
|
||||
if self.__memray_tracker is None:
|
||||
return
|
||||
|
||||
try:
|
||||
self.__memray_tracker.__exit__(None, None, None)
|
||||
self.__memray_tracker = None
|
||||
|
||||
reports_dir = pathlib.Path("/config/memray_reports")
|
||||
html_file = reports_dir / f"{safe_name}.html"
|
||||
|
||||
result = subprocess.run(
|
||||
["memray", "flamegraph", "--output", str(html_file), str(binary_file)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
self.logger.info(f"Memray report generated: {html_file}")
|
||||
else:
|
||||
self.logger.error(
|
||||
f"Failed to generate memray report: {result.stderr}. "
|
||||
f"Binary file preserved at {binary_file} for manual generation."
|
||||
)
|
||||
|
||||
# Keep the binary file for manual report generation if needed
|
||||
# Users can run: memray flamegraph {binary_file}
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
self.logger.error("Memray report generation timed out")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to cleanup memray profiling: {e}", exc_info=True)
|
||||
|
||||
@ -124,6 +124,7 @@ def capture_frames(
|
||||
config_subscriber.check_for_updates()
|
||||
return config.enabled
|
||||
|
||||
try:
|
||||
while not stop_event.is_set():
|
||||
if not get_enabled_state():
|
||||
logger.debug(f"Stopping capture thread for disabled {config.name}")
|
||||
@ -141,7 +142,9 @@ def capture_frames(
|
||||
if stop_event.is_set():
|
||||
break
|
||||
|
||||
logger.error(f"{config.name}: Unable to read frames from ffmpeg process.")
|
||||
logger.error(
|
||||
f"{config.name}: Unable to read frames from ffmpeg process."
|
||||
)
|
||||
|
||||
if ffmpeg_process.poll() is not None:
|
||||
logger.error(
|
||||
@ -163,6 +166,8 @@ def capture_frames(
|
||||
skipped_eps.update()
|
||||
|
||||
frame_index = 0 if frame_index == shm_frame_count - 1 else frame_index + 1
|
||||
finally:
|
||||
config_subscriber.stop()
|
||||
|
||||
|
||||
class CameraWatchdog(threading.Thread):
|
||||
@ -234,6 +239,16 @@ class CameraWatchdog(threading.Thread):
|
||||
else:
|
||||
self.ffmpeg_detect_process.wait()
|
||||
|
||||
# Wait for old capture thread to fully exit before starting a new one
|
||||
if self.capture_thread is not None and self.capture_thread.is_alive():
|
||||
self.logger.info("Waiting for capture thread to exit...")
|
||||
self.capture_thread.join(timeout=5)
|
||||
|
||||
if self.capture_thread.is_alive():
|
||||
self.logger.warning(
|
||||
f"Capture thread for {self.config.name} did not exit in time"
|
||||
)
|
||||
|
||||
self.logger.error(
|
||||
"The following ffmpeg logs include the last 100 lines prior to exit."
|
||||
)
|
||||
|
||||
6
web/package-lock.json
generated
6
web/package-lock.json
generated
@ -4702,9 +4702,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001651",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001651.tgz",
|
||||
"integrity": "sha512-9Cf+Xv1jJNe1xPZLGuUXLNkE1BoDkqRqYyFJ9TDYSqhduqA4hu4oR9HluGoWYQC/aj8WHjsGVV+bwkh0+tegRg==",
|
||||
"version": "1.0.30001757",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001757.tgz",
|
||||
"integrity": "sha512-r0nnL/I28Zi/yjk1el6ilj27tKcdjLsNqAOZr0yVjWPrSQyHgKI2INaEWw21bAQSv2LXRt1XuCS/GomNpWOxsQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
||||
@ -1 +1,5 @@
|
||||
{}
|
||||
{
|
||||
"form": {
|
||||
"user": "Потребителско име"
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,5 +8,8 @@
|
||||
"lastHour_other": "Последните {{count}} часа"
|
||||
},
|
||||
"select": "Избери"
|
||||
},
|
||||
"restart": {
|
||||
"title": "Сигурен ли сте, че искате да рестартирате Frigate?"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1 +1,5 @@
|
||||
{}
|
||||
{
|
||||
"iconPicker": {
|
||||
"selectIcon": "Изберете иконка"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1 +1,7 @@
|
||||
{}
|
||||
{
|
||||
"button": {
|
||||
"downloadVideo": {
|
||||
"label": "Свали видео"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -18,5 +18,6 @@
|
||||
"bicycle": "Велосипед",
|
||||
"skateboard": "Скейтборд",
|
||||
"door": "Врата",
|
||||
"blender": "Блендер"
|
||||
"blender": "Блендер",
|
||||
"person": "Човек"
|
||||
}
|
||||
|
||||
@ -1 +1,3 @@
|
||||
{}
|
||||
{
|
||||
"documentTitle": "Експорт - Frigate"
|
||||
}
|
||||
|
||||
@ -63,5 +63,6 @@
|
||||
},
|
||||
"cameraSettings": {
|
||||
"cameraEnabled": "Камерата е включена"
|
||||
}
|
||||
},
|
||||
"documentTitle": "Наживо - Frigate"
|
||||
}
|
||||
|
||||
@ -104,7 +104,8 @@
|
||||
"anonymous": "Anònim",
|
||||
"logout": "Tanca la sessió",
|
||||
"current": "Usuari actual: {{user}}"
|
||||
}
|
||||
},
|
||||
"classification": "Classificació"
|
||||
},
|
||||
"pagination": {
|
||||
"previous": {
|
||||
@ -197,7 +198,10 @@
|
||||
"formattedTimestampMonthDayYearHourMinute": {
|
||||
"12hour": "MMM d yyyy, h:mm aaa",
|
||||
"24hour": "MMM d yyyy, HH:mm"
|
||||
}
|
||||
},
|
||||
"inProgress": "En curs",
|
||||
"invalidStartTime": "Hora d'inici no vàlida",
|
||||
"invalidEndTime": "Hora de finalització no vàlida"
|
||||
},
|
||||
"unit": {
|
||||
"speed": {
|
||||
@ -221,7 +225,9 @@
|
||||
"back": "Torna enrere",
|
||||
"hide": "Oculta {{item}}",
|
||||
"show": "Mostra {{item}}",
|
||||
"ID": "ID"
|
||||
"ID": "ID",
|
||||
"none": "Cap",
|
||||
"all": "Tots"
|
||||
},
|
||||
"button": {
|
||||
"apply": "Aplicar",
|
||||
@ -258,7 +264,8 @@
|
||||
"off": "APAGAT",
|
||||
"unselect": "Desseleccionar",
|
||||
"enable": "Habilitar",
|
||||
"enabled": "Habilitat"
|
||||
"enabled": "Habilitat",
|
||||
"continue": "Continua"
|
||||
},
|
||||
"toast": {
|
||||
"copyUrlToClipboard": "URL copiada al porta-retalls.",
|
||||
|
||||
@ -17,31 +17,40 @@
|
||||
"categorizedImage": "Imatge classificada amb èxit",
|
||||
"trainedModel": "Model entrenat amb èxit.",
|
||||
"trainingModel": "S'ha iniciat amb èxit la formació de models.",
|
||||
"deletedModel_one": "S'ha suprimit correctament el model {{count}}",
|
||||
"deletedModel_many": "S'han suprimit correctament {{count}} models",
|
||||
"deletedModel_other": "",
|
||||
"updatedModel": "S'ha actualitzat correctament la configuració del model"
|
||||
"deletedModel_one": "S'ha suprimit correctament {{count}} model",
|
||||
"deletedModel_many": "S'han suprimit correctament els {{count}} models",
|
||||
"deletedModel_other": "S'han suprimit correctament els {{count}} models",
|
||||
"updatedModel": "S'ha actualitzat correctament la configuració del model",
|
||||
"renamedCategory": "S'ha canviat el nom de la classe a {{name}}"
|
||||
},
|
||||
"error": {
|
||||
"deleteImageFailed": "No s'ha pogut suprimir: {{errorMessage}}",
|
||||
"deleteCategoryFailed": "No s'ha pogut suprimir la classe: {{errorMessage}}",
|
||||
"categorizeFailed": "No s'ha pogut categoritzar la imatge: {{errorMessage}}",
|
||||
"trainingFailed": "No s'ha pogut iniciar l'entrenament del model: {{errorMessage}}",
|
||||
"trainingFailed": "Ha fallat l'entrenament del model. Comproveu els registres de fragata per a més detalls.",
|
||||
"deleteModelFailed": "No s'ha pogut suprimir el model: {{errorMessage}}",
|
||||
"updateModelFailed": "No s'ha pogut actualitzar el model: {{errorMessage}}"
|
||||
"updateModelFailed": "No s'ha pogut actualitzar el model: {{errorMessage}}",
|
||||
"renameCategoryFailed": "No s'ha pogut canviar el nom de la classe: {{errorMessage}}",
|
||||
"trainingFailedToStart": "Errar en arrencar l'entrenament del model: {{errorMessage}}"
|
||||
}
|
||||
},
|
||||
"deleteCategory": {
|
||||
"title": "Suprimeix la classe",
|
||||
"desc": "Esteu segur que voleu suprimir la classe {{name}}? Això suprimirà permanentment totes les imatges associades i requerirà tornar a entrenar el model."
|
||||
"desc": "Esteu segur que voleu suprimir la classe {{name}}? Això suprimirà permanentment totes les imatges associades i requerirà tornar a entrenar el model.",
|
||||
"minClassesTitle": "No es pot suprimir la classe",
|
||||
"minClassesDesc": "Un model de classificació ha de tenir almenys 2 classes. Afegeix una altra classe abans d'eliminar aquesta."
|
||||
},
|
||||
"deleteDatasetImages": {
|
||||
"title": "Suprimeix les imatges del conjunt de dades",
|
||||
"desc": "Esteu segur que voleu suprimir {{count}} imatges de {{dataset}}? Aquesta acció no es pot desfer i requerirà tornar a entrenar el model."
|
||||
"desc_one": "Esteu segur que voleu suprimir {{count}} imatge de {{dataset}}? Aquesta acció no es pot desfer i requerirà tornar a entrenar el model.",
|
||||
"desc_many": "Esteu segur que voleu suprimir {{count}} imatges de {{dataset}}? Aquesta acció no es pot desfer i requerirà tornar a entrenar el model.",
|
||||
"desc_other": "Esteu segur que voleu suprimir {{count}} imatges de {{dataset}}? Aquesta acció no es pot desfer i requerirà tornar a entrenar el model."
|
||||
},
|
||||
"deleteTrainImages": {
|
||||
"title": "Suprimeix les imatges del tren",
|
||||
"desc": "Esteu segur que voleu suprimir {{count}} imatges? Aquesta acció no es pot desfer."
|
||||
"desc_one": "Esteu segur que voleu suprimir {{count}} imatge? Aquesta acció no es pot desfer.",
|
||||
"desc_many": "Esteu segur que voleu suprimir {{count}} imatges? Aquesta acció no es pot desfer.",
|
||||
"desc_other": "Esteu segur que voleu suprimir {{count}} imatges? Aquesta acció no es pot desfer."
|
||||
},
|
||||
"renameCategory": {
|
||||
"title": "Reanomena la classe",
|
||||
@ -140,13 +149,18 @@
|
||||
"generationFailed": "Ha fallat la generació. Torneu-ho a provar.",
|
||||
"classifyFailed": "No s'han pogut classificar les imatges: {{error}}"
|
||||
},
|
||||
"generateSuccess": "Imatges de mostra generades amb èxit"
|
||||
"generateSuccess": "Imatges de mostra generades amb èxit",
|
||||
"allImagesRequired_one": "Classifiqueu totes les imatges. Queda {{count}} imatge.",
|
||||
"allImagesRequired_many": "Classifiqueu totes les imatges. Queden {{count}} imatges.",
|
||||
"allImagesRequired_other": "Classifiqueu totes les imatges. Queden {{count}} imatges."
|
||||
}
|
||||
},
|
||||
"deleteModel": {
|
||||
"title": "Suprimeix el model de classificació",
|
||||
"single": "Esteu segur que voleu suprimir {{name}}? Això suprimirà permanentment totes les dades associades, incloses les imatges i les dades d'entrenament. Aquesta acció no es pot desfer.",
|
||||
"desc": "Esteu segur que voleu suprimir {{count}} model(s)? Això suprimirà permanentment totes les dades associades, incloses les imatges i les dades d'entrenament. Aquesta acció no es pot desfer."
|
||||
"desc_one": "Esteu segur que voleu suprimir el model {{count}}? Això suprimirà permanentment totes les dades associades, incloses les imatges i les dades d'entrenament. Aquesta acció no es pot desfer.",
|
||||
"desc_many": "Esteu segur que voleu suprimir {{count}} models? Això suprimirà permanentment totes les dades associades, incloses les imatges i les dades d'entrenament. Aquesta acció no es pot desfer.",
|
||||
"desc_other": "Esteu segur que voleu suprimir {{count}} models? Això suprimirà permanentment totes les dades associades, incloses les imatges i les dades d'entrenament. Aquesta acció no es pot desfer."
|
||||
},
|
||||
"menu": {
|
||||
"objects": "Objectes",
|
||||
@ -160,5 +174,11 @@
|
||||
"descriptionState": "Edita les classes per a aquest model de classificació d'estats. Els canvis requeriran tornar a entrenar el model.",
|
||||
"descriptionObject": "Edita el tipus d'objecte i el tipus de classificació per a aquest model de classificació d'objectes.",
|
||||
"stateClassesInfo": "Nota: Canviar les classes d'estat requereix tornar a entrenar el model amb les classes actualitzades."
|
||||
},
|
||||
"tooltip": {
|
||||
"trainingInProgress": "El model s'està entrenant actualment",
|
||||
"noNewImages": "Sense noves imatges per entrenar. Classifica més imatges primer.",
|
||||
"modelNotReady": "El model no está preparat per entrenar",
|
||||
"noChanges": "No hi ha canvis al conjunt de dades des de l'última formació."
|
||||
}
|
||||
}
|
||||
|
||||
@ -39,9 +39,9 @@
|
||||
"threateningActivity": "Activitat amenaçadora",
|
||||
"detail": {
|
||||
"noDataFound": "No hi ha dades detallades a revisar",
|
||||
"trackedObject_one": "objecte",
|
||||
"trackedObject_one": "{{count}} objecte",
|
||||
"aria": "Canvia la vista de detall",
|
||||
"trackedObject_other": "objectes",
|
||||
"trackedObject_other": "{{count}} objectes",
|
||||
"noObjectDetailData": "No hi ha dades de detall d'objecte disponibles.",
|
||||
"label": "Detall",
|
||||
"settings": "Configuració de la vista detallada",
|
||||
|
||||
@ -85,7 +85,8 @@
|
||||
"snapshot": "instantània",
|
||||
"video": "vídeo",
|
||||
"object_lifecycle": "cicle de vida de l'objecte",
|
||||
"thumbnail": "miniatura"
|
||||
"thumbnail": "miniatura",
|
||||
"tracking_details": "detalls del seguiment"
|
||||
},
|
||||
"details": {
|
||||
"timestamp": "Marca temporal",
|
||||
@ -99,7 +100,7 @@
|
||||
"updatedSublabel": "Subetiqueta actualitzada amb èxit.",
|
||||
"updatedLPR": "Matrícula actualitzada amb èxit.",
|
||||
"regenerate": "El {{provider}} ha sol·licitat una nova descripció. En funció de la velocitat del vostre proveïdor, la nova descripció pot trigar un temps a regenerar-se.",
|
||||
"audioTranscription": "Transcripció d'àudio sol·licitada amb èxit."
|
||||
"audioTranscription": "S'ha sol·licitat correctament la transcripció d'àudio. Depenent de la velocitat del vostre servidor Frigate, la transcripció pot trigar una estona a completar-se."
|
||||
},
|
||||
"error": {
|
||||
"regenerate": "No s'ha pogut contactar amb {{provider}} per obtenir una nova descripció: {{errorMessage}}",
|
||||
@ -170,7 +171,9 @@
|
||||
"success": "L'objectes amb seguiment s'ha suprimit correctament.",
|
||||
"error": "No s'ha pogut suprimir l'objecte rastrejat: {{errorMessage}}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"nextTrackedObject": "Següent objecte rastrejat",
|
||||
"previousTrackedObject": "Objecte rastrejat anterior"
|
||||
},
|
||||
"itemMenu": {
|
||||
"downloadVideo": {
|
||||
@ -273,7 +276,7 @@
|
||||
"label": "Òfset d'Anotació",
|
||||
"desc": "Aquestes dades provenen del flux de detecció de la càmera, però se superposen a les imatges del flux de gravació. És poc probable que els dos fluxos estiguin perfectament sincronitzats. Com a resultat, el quadre delimitador i les imatges no s'alinearan perfectament. Tanmateix, es pot utilitzar el camp <code>annotation_offset</code> per ajustar-ho.",
|
||||
"millisecondsToOffset": "Millisegons per l'òfset de detecció d'anotacions per. <em>Per defecte: 0</em>",
|
||||
"tips": "CONSELL: Imagineu-vos que hi ha un clip d'esdeveniment amb una persona caminant d'esquerra a dreta. Si el quadre delimitador de la cronologia de l'esdeveniment està constantment a l'esquerra de la persona, aleshores s'hauria de disminuir el valor. De la mateixa manera, si una persona camina d'esquerra a dreta i el quadre delimitador està constantment per davant de la persona, aleshores s'hauria d'augmentar el valor.",
|
||||
"tips": "Reduïu el valor si la reproducció del vídeo es troba per davant dels quadres i els punts de ruta, i augmenteu-lo si es troba per darrere. Aquest valor pot ser negatiu.",
|
||||
"toast": {
|
||||
"success": "L'Òfset d'anotació per a {{camera}} s'ha desat al fitxer de configuració. Reinicieu Frigate per aplicar els canvis."
|
||||
}
|
||||
|
||||
@ -67,7 +67,7 @@
|
||||
"toast": {
|
||||
"success": {
|
||||
"trainedFace": "Rostre entrenat amb èxit.",
|
||||
"updatedFaceScore": "Puntació de rostre actualitzada amb èxit.",
|
||||
"updatedFaceScore": "S'ha actualitzat correctament la puntuació de la cara a {{name}} ({{score}}).",
|
||||
"uploadedImage": "Imatge pujada amb èxit.",
|
||||
"addFaceLibrary": "{{name}} s'ha afegit amb èxit a la biblioteca de rostres!",
|
||||
"deletedName_one": "{{count}} rostre s'ha suprimit amb èxit.",
|
||||
|
||||
@ -180,6 +180,10 @@
|
||||
"noCameras": {
|
||||
"title": "No s'ha configurat cap càmera",
|
||||
"description": "Comenceu connectant una càmera a Frigate.",
|
||||
"buttonText": "Afegeix una càmera"
|
||||
"buttonText": "Afegeix una càmera",
|
||||
"restricted": {
|
||||
"title": "No hi ha càmeres disponibles",
|
||||
"description": "No teniu permís per veure cap càmera en aquest grup."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
"authentication": "Configuració d'autenticació - Frigate",
|
||||
"camera": "Paràmetres de càmera - Frigate",
|
||||
"masksAndZones": "Editor de màscares i zones - Frigate",
|
||||
"general": "Paràmetres Generals - Frigate",
|
||||
"general": "Configuració de la interfície d'usuari - Fragata",
|
||||
"frigatePlus": "Paràmetres de Frigate+ - Frigate",
|
||||
"notifications": "Paràmetres de notificació - Frigate",
|
||||
"cameraManagement": "Gestionar càmeres - Frigate",
|
||||
@ -39,7 +39,7 @@
|
||||
"noCamera": "Cap càmera"
|
||||
},
|
||||
"general": {
|
||||
"title": "Paràmetres generals",
|
||||
"title": "Paràmetres de la interfície d'usuari",
|
||||
"liveDashboard": {
|
||||
"title": "Panell en directe",
|
||||
"automaticLiveView": {
|
||||
@ -53,6 +53,10 @@
|
||||
"displayCameraNames": {
|
||||
"label": "Mostra sempre els noms de la càmera",
|
||||
"desc": "Mostra sempre els noms de les càmeres en un xip al tauler de visualització en directe multicàmera."
|
||||
},
|
||||
"liveFallbackTimeout": {
|
||||
"label": "Temps d'espera per a la reserva del jugador en directe",
|
||||
"desc": "Quan el flux en viu d'alta qualitat d'una càmera no està disponible, torneu al mode d'amplada de banda baixa després d'aquests molts segons. Per defecte: 3."
|
||||
}
|
||||
},
|
||||
"storedLayouts": {
|
||||
@ -858,9 +862,9 @@
|
||||
"createRole": "Rol {{role}} creat exitosament",
|
||||
"updateCameras": "Càmeres actualitzades per al rol {{role}}",
|
||||
"deleteRole": "Rol {{role}} eliminat exitosament",
|
||||
"userRolesUpdated_one": "{{count}} usuari(s) asignats a aquest rol s'han actualitzat a 'visor', i tenen accés a totes les càmeres.",
|
||||
"userRolesUpdated_many": "",
|
||||
"userRolesUpdated_other": ""
|
||||
"userRolesUpdated_one": "{{count}} l'usuari assignat a aquest rol s'ha actualitzat a 'visor', que té accés a totes les càmeres.",
|
||||
"userRolesUpdated_many": "{{count}} usuaris assignats a aquest rol s'han actualitzat a 'visor', que té accés a totes les càmeres.",
|
||||
"userRolesUpdated_other": "{{count}} usuaris assignats a aquest rol s'han actualitzat a 'visor', que té accés a totes les càmeres."
|
||||
},
|
||||
"error": {
|
||||
"createRoleFailed": "Error al crear el rol: {{errorMessage}}",
|
||||
@ -871,16 +875,17 @@
|
||||
}
|
||||
},
|
||||
"cameraWizard": {
|
||||
"title": "Afegir C àmera",
|
||||
"title": "Afegir Càmera",
|
||||
"description": "Seguiu els passos de sota per afegir una nova càmera a la instal·lació.",
|
||||
"steps": {
|
||||
"nameAndConnection": "Nom i connexió",
|
||||
"streamConfiguration": "Configuració de stream",
|
||||
"validationAndTesting": "Validació i proves"
|
||||
"validationAndTesting": "Validació i proves",
|
||||
"probeOrSnapshot": "Prova o instantània"
|
||||
},
|
||||
"step1": {
|
||||
"cameraBrand": "Marca de la càmera",
|
||||
"description": "Introduïu els detalls de la càmera i proveu la connexió.",
|
||||
"description": "Introduïu els detalls de la càmera i trieu provar la càmera o seleccionar manualment la marca.",
|
||||
"cameraName": "Nom de la càmera",
|
||||
"cameraNamePlaceholder": "p. ex., vista general de la porta davantera o de la barra posterior",
|
||||
"host": "Adreça de l'amfitrió/IP",
|
||||
@ -919,7 +924,16 @@
|
||||
"testing": {
|
||||
"probingMetadata": "S'estan provant les metadades de la càmera...",
|
||||
"fetchingSnapshot": "S'està recuperant la instantània de la càmera..."
|
||||
}
|
||||
},
|
||||
"connectionSettings": "Configuració de la connexió",
|
||||
"detectionMethod": "Mètode de detecció de flux",
|
||||
"onvifPort": "ONVIF Port",
|
||||
"probeMode": "Càmera de prova",
|
||||
"manualMode": "Selecció manual",
|
||||
"detectionMethodDescription": "Proveu la càmera amb ONVIF (si és compatible) per trobar URL de flux de càmera, o seleccioneu manualment la marca de càmera per utilitzar URL predefinits. Per a introduir un URL RTSP personalitzat, trieu el mètode manual i seleccioneu \"Altres\".",
|
||||
"onvifPortDescription": "Per a les càmeres que suporten ONVIF, això sol ser 80 o 8080.",
|
||||
"useDigestAuth": "Utilitza l'autenticació digest",
|
||||
"useDigestAuthDescription": "Usa l'autenticació de resum HTTP per a ONVIF. Algunes càmeres poden requerir un nom d'usuari/contrasenya ONVIF dedicat en lloc de l'usuari administrador estàndard."
|
||||
},
|
||||
"save": {
|
||||
"failure": "SS'ha produït un error en desar {{cameraName}}.",
|
||||
@ -936,7 +950,7 @@
|
||||
"testFailed": "Ha fallat la prova de flux: {{error}}"
|
||||
},
|
||||
"step2": {
|
||||
"description": "Configura els rols de flux i afegeix fluxos addicionals per a la càmera.",
|
||||
"description": "Proveu la càmera per als fluxos disponibles o configureu la configuració manual basada en el mètode de detecció seleccionat.",
|
||||
"streamsTitle": "Fluxos de la càmera",
|
||||
"addStream": "Afegeix un flux",
|
||||
"addAnotherStream": "Afegeix un altre flux",
|
||||
@ -953,8 +967,8 @@
|
||||
"audio": "Àudio"
|
||||
},
|
||||
"testStream": "Prova la connexió",
|
||||
"testSuccess": "Prova de flux amb èxit!",
|
||||
"testFailed": "Ha fallat la prova del flux",
|
||||
"testSuccess": "Prova de connexió correcta!",
|
||||
"testFailed": "Ha fallat la prova de connexió. Si us plau, comproveu la vostra entrada i torneu-ho a provar.",
|
||||
"testFailedTitle": "Ha fallat la prova",
|
||||
"connected": "Connectat",
|
||||
"notConnected": "No connectat",
|
||||
@ -972,7 +986,39 @@
|
||||
"description": "Utilitzeu el restreaming go2rtc per reduir les connexions a la càmera."
|
||||
},
|
||||
"roles": "Rols",
|
||||
"streamUrlPlaceholder": "rtsp://usuari:contrasenya@host:port/ruta"
|
||||
"streamUrlPlaceholder": "rtsp://usuari:contrasenya@host:port/ruta",
|
||||
"streamDetails": "Detalls del flux",
|
||||
"probing": "Provant càmera...",
|
||||
"retry": "Intentar de nou",
|
||||
"testing": {
|
||||
"probingMetadata": "S'estan provant les metadades de la càmera...",
|
||||
"fetchingSnapshot": "S'està recuperant la instantània de la càmera..."
|
||||
},
|
||||
"probeFailed": "No s'ha pogut provar la càmera: {{error}}",
|
||||
"probingDevice": "Provant dispositiu...",
|
||||
"probeSuccessful": "Prova exitosa",
|
||||
"probeError": "Error de prova",
|
||||
"probeNoSuccess": "La prova no ha tingut èxit",
|
||||
"deviceInfo": "Informació del dispositiu",
|
||||
"manufacturer": "Fabricant",
|
||||
"model": "Model",
|
||||
"firmware": "Firmware",
|
||||
"profiles": "Perfils",
|
||||
"ptzSupport": "Suport PTZ",
|
||||
"autotrackingSupport": "Implementació de seguiment automàtic",
|
||||
"presets": "Predefinits",
|
||||
"rtspCandidates": "Candidats RTSP",
|
||||
"rtspCandidatesDescription": "S'han trobat els següents URL RTSP de la sonda de la càmera. Proveu la connexió per a veure les metadades del flux.",
|
||||
"noRtspCandidates": "No s'ha trobat cap URL RTSP a la càmera. Les vostres credencials poden ser incorrectes, o la càmera pot no admetre ONVIF o el mètode utilitzat per recuperar els URL RTSP. Torneu enrere i introduïu l'URL RTSP manualment.",
|
||||
"candidateStreamTitle": "Candidat {{number}}",
|
||||
"useCandidate": "Utilitza",
|
||||
"uriCopy": "Copia",
|
||||
"uriCopied": "URI copiat al porta-retalls",
|
||||
"testConnection": "Prova la connexió",
|
||||
"toggleUriView": "Feu clic per a commutar la vista completa de l'URI",
|
||||
"errors": {
|
||||
"hostRequired": "Es requereix l'adreça de l'amfitrió/IP"
|
||||
}
|
||||
},
|
||||
"step3": {
|
||||
"none": "Cap",
|
||||
@ -996,7 +1042,7 @@
|
||||
"resolutionHigh": "Una resolució de {{resolution}} pot causar un ús més gran dels recursos.",
|
||||
"resolutionLow": "Una resolució de {{resolution}} pot ser massa baixa per a la detecció fiable d'objectes petits."
|
||||
},
|
||||
"description": "Validació i anàlisi final abans de desar la nova càmera. Connecta cada flux abans de desar-lo.",
|
||||
"description": "Configura els rols de flux i afegeix fluxos addicionals per a la càmera.",
|
||||
"validationTitle": "Validació del flux",
|
||||
"connectAllStreams": "Connecta tots els fluxos",
|
||||
"reconnectionSuccess": "S'ha reconnectat correctament.",
|
||||
@ -1016,7 +1062,91 @@
|
||||
"streamValidated": "El flux {{number}} s'ha validat correctament",
|
||||
"streamValidationFailed": "Ha fallat la validació del flux {{number}}",
|
||||
"ffmpegModule": "Usa el mode de compatibilitat del flux",
|
||||
"ffmpegModuleDescription": "Si el flux no es carrega després de diversos intents, proveu d'activar-ho. Quan està activat, Frigate utilitzarà el mòdul ffmpeg amb go2rtc. Això pot proporcionar una millor compatibilitat amb alguns fluxos de càmera."
|
||||
"ffmpegModuleDescription": "Si el flux no es carrega després de diversos intents, proveu d'activar-ho. Quan està activat, Frigate utilitzarà el mòdul ffmpeg amb go2rtc. Això pot proporcionar una millor compatibilitat amb alguns fluxos de càmera.",
|
||||
"streamsTitle": "Fluxos de la càmera",
|
||||
"addStream": "Afegeix un flux",
|
||||
"addAnotherStream": "Afegeix un altre flux",
|
||||
"streamUrl": "URL del flux",
|
||||
"streamUrlPlaceholder": "rtsp://usuari:contrasenya@host:port/ruta",
|
||||
"selectStream": "Selecciona un flux",
|
||||
"searchCandidates": "Cerca candidats...",
|
||||
"noStreamFound": "No s'ha trobat cap flux",
|
||||
"url": "URL",
|
||||
"resolution": "Resolució",
|
||||
"selectResolution": "Selecciona la resolució",
|
||||
"quality": "Qualitat",
|
||||
"selectQuality": "Selecciona la qualitat",
|
||||
"roleLabels": {
|
||||
"detect": "Detecció d'objectes",
|
||||
"record": "Enregistrament",
|
||||
"audio": "Àudio"
|
||||
},
|
||||
"testStream": "Prova la connexió",
|
||||
"testSuccess": "Prova de flux amb èxit!",
|
||||
"testFailed": "Ha fallat la prova del flux",
|
||||
"testFailedTitle": "Ha fallat la prova",
|
||||
"connected": "Connectat",
|
||||
"notConnected": "No connectat",
|
||||
"featuresTitle": "Característiques",
|
||||
"go2rtc": "Redueix les connexions a la càmera",
|
||||
"detectRoleWarning": "Almenys un flux ha de tenir el rol de \"detecte\" per continuar.",
|
||||
"rolesPopover": {
|
||||
"title": "Roles de flux",
|
||||
"detect": "Canal principal per a la detecció d'objectes.",
|
||||
"record": "Desa els segments del canal de vídeo basats en la configuració.",
|
||||
"audio": "Canal per a la detecció basada en àudio."
|
||||
},
|
||||
"featuresPopover": {
|
||||
"title": "Característiques del flux",
|
||||
"description": "Utilitzeu el restreaming go2rtc per reduir les connexions a la càmera."
|
||||
}
|
||||
},
|
||||
"step4": {
|
||||
"description": "Validació i anàlisi final abans de desar la nova càmera. Connecta cada flux abans de desar-lo.",
|
||||
"validationTitle": "Validació del flux",
|
||||
"connectAllStreams": "Connecta tots els fluxos",
|
||||
"reconnectionSuccess": "S'ha reconnectat correctament.",
|
||||
"reconnectionPartial": "Alguns fluxos no s'han pogut tornar a connecta.",
|
||||
"streamUnavailable": "La vista prèvia del flux no està disponible",
|
||||
"reload": "Torna a carregar",
|
||||
"connecting": "S'està connectant...",
|
||||
"streamTitle": "Flux {{number}}",
|
||||
"valid": "Vàlid",
|
||||
"failed": "Ha fallat",
|
||||
"notTested": "No provat",
|
||||
"connectStream": "Connecta",
|
||||
"connectingStream": "Connectant",
|
||||
"disconnectStream": "Desconnecta",
|
||||
"estimatedBandwidth": "Amplada de banda estimada",
|
||||
"roles": "Roles",
|
||||
"ffmpegModule": "Usa el mode de compatibilitat del flux",
|
||||
"ffmpegModuleDescription": "Si el flux no es carrega després de diversos intents, proveu d'activar-ho. Quan està activat, Frigate utilitzarà el mòdul ffmpeg amb go2rtc. Això pot proporcionar una millor compatibilitat amb alguns fluxos de càmera.",
|
||||
"none": "Cap",
|
||||
"error": "Error",
|
||||
"streamValidated": "El flux {{number}} s'ha validat correctament",
|
||||
"streamValidationFailed": "Ha fallat la validació del flux {{number}}",
|
||||
"saveAndApply": "Desa una càmera nova",
|
||||
"saveError": "Configuració no vàlida. Si us plau, comproveu la configuració.",
|
||||
"issues": {
|
||||
"title": "Validació del flux",
|
||||
"videoCodecGood": "El còdec de vídeo és {{codec}}.",
|
||||
"audioCodecGood": "El còdec d'àudio és {{codec}}.",
|
||||
"resolutionHigh": "Una resolució de {{resolution}} pot causar un ús més gran dels recursos.",
|
||||
"resolutionLow": "Una resolució de {{resolution}} pot ser massa baixa per a la detecció fiable d'objectes petits.",
|
||||
"noAudioWarning": "No s'ha detectat cap àudio per a aquest flux, els enregistraments no tindran àudio.",
|
||||
"audioCodecRecordError": "El còdec d'àudio AAC és necessari per a suportar l'àudio en els enregistraments.",
|
||||
"audioCodecRequired": "Es requereix un flux d'àudio per admetre la detecció d'àudio.",
|
||||
"restreamingWarning": "Reduir les connexions a la càmera per al flux de registre pot augmentar lleugerament l'ús de la CPU.",
|
||||
"brands": {
|
||||
"reolink-rtsp": "No és racomana utilitzar Reolink RSTP. Activeu HTTP a la configuració del microprogramari de la càmera i reinicieu l'assistent."
|
||||
},
|
||||
"dahua": {
|
||||
"substreamWarning": "El substream 1 està bloquejat a una resolució baixa. Moltes càmeres Dahua / Amcrest / EmpireTech suporten subfluxos addicionals que han d'estar habilitats a la configuració de la càmera. Es recomana comprovar i utilitzar aquests corrents si estan disponibles."
|
||||
},
|
||||
"hikvision": {
|
||||
"substreamWarning": "El substream 1 està bloquejat a una resolució baixa. Moltes càmeres Hikvision suporten subfluxos addicionals que han d'estar habilitats a la configuració de la càmera. Es recomana comprovar i utilitzar aquests corrents si estan disponibles."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"cameraManagement": {
|
||||
|
||||
@ -76,7 +76,12 @@
|
||||
}
|
||||
},
|
||||
"npuUsage": "Ús de NPU",
|
||||
"npuMemory": "Memòria de NPU"
|
||||
"npuMemory": "Memòria de NPU",
|
||||
"intelGpuWarning": {
|
||||
"title": "Avís d'estadístiques de la GPU d'Intel",
|
||||
"message": "Estadístiques de GPU no disponibles",
|
||||
"description": "Aquest és un error conegut en les eines d'informació de les estadístiques de GPU d'Intel (intel.gpu.top) on es trencarà i retornarà repetidament un ús de GPU del 0% fins i tot en els casos en què l'acceleració del maquinari i la detecció d'objectes s'executen correctament a la (i)GPU. Això no és un error de fragata. Podeu reiniciar l'amfitrió per a corregir temporalment el problema i confirmar que la GPU funciona correctament. Això no afecta el rendiment."
|
||||
}
|
||||
},
|
||||
"otherProcesses": {
|
||||
"title": "Altres processos",
|
||||
@ -179,8 +184,15 @@
|
||||
"plate_recognition_speed": "Velocitat de reconeixement de matrícules",
|
||||
"text_embedding_speed": "Velocitat d'incrustació de text",
|
||||
"yolov9_plate_detection": "Detecció de matrícules YOLOv9",
|
||||
"yolov9_plate_detection_speed": "Velocitat de detecció de matrícules YOLOv9"
|
||||
"yolov9_plate_detection_speed": "Velocitat de detecció de matrícules YOLOv9",
|
||||
"review_description": "Descripció de la revisió",
|
||||
"review_description_speed": "Velocitat de la descripció de la revisió",
|
||||
"review_description_events_per_second": "Descripció de la revisió",
|
||||
"object_description": "Descripció de l'objecte",
|
||||
"object_description_speed": "Velocitat de la descripció de l'objecte",
|
||||
"object_description_events_per_second": "Descripció de l'objecte"
|
||||
},
|
||||
"infPerSecond": "Inferències per segon"
|
||||
"infPerSecond": "Inferències per segon",
|
||||
"averageInf": "Temps mitjà d'inferència"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,31 @@
|
||||
{
|
||||
"documentTitle": "Klasifikační modely",
|
||||
"button": {
|
||||
"deleteClassificationAttempts": "Odstranit Klasifikační obrazy",
|
||||
"renameCategory": "Přejmenovat třídu"
|
||||
"deleteClassificationAttempts": "Odstrániť Klasifikačné obrazy",
|
||||
"renameCategory": "Premenovať triedu",
|
||||
"deleteCategory": "Zmazať triedu",
|
||||
"deleteImages": "Zmazať obrázok",
|
||||
"trainModel": "Trenovací model",
|
||||
"addClassification": "Pridať klasifikáciu",
|
||||
"deleteModels": "Zmazať modeli",
|
||||
"editModel": "Upraviť model"
|
||||
},
|
||||
"details": {
|
||||
"scoreInfo": "Skóre predstavuje priemernú istotu klasifikácie naprieč detekciami tohoto objektu."
|
||||
},
|
||||
"tooltip": {
|
||||
"trainingInProgress": "Model se práve trénuje",
|
||||
"noNewImages": "Žiadne nové obrázky na trénovanie. Najskôr klasifikujte viac obrazkov v datasete.",
|
||||
"noChanges": "Od posledného treningu nedošlo k žiadnym zmenám v datasete.",
|
||||
"modelNotReady": "Model nieje pripravený na trénovanie."
|
||||
},
|
||||
"toast": {
|
||||
"success": {
|
||||
"deletedImage": "Zmazať obrazky",
|
||||
"deletedModel_one": "Úspešne odstranený {{count}} model",
|
||||
"deletedModel_few": "Úspešne odstranené {{count}} modely",
|
||||
"deletedModel_other": "Úspěšne ostranených {{count}} modelov",
|
||||
"deletedCategory": "Zmazať triedu"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -36,5 +36,11 @@
|
||||
"selected_one": "{{count}} vybráno",
|
||||
"selected_other": "{{count}} vybráno",
|
||||
"suspiciousActivity": "Podezřelá aktivita",
|
||||
"threateningActivity": "Ohrožující činnost"
|
||||
"threateningActivity": "Ohrožující činnost",
|
||||
"zoomIn": "Přiblížit",
|
||||
"zoomOut": "Oddálit",
|
||||
"detail": {
|
||||
"label": "Detail",
|
||||
"noDataFound": "Žádná detailní data k prohlédnutí"
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,5 +13,11 @@
|
||||
"error": {
|
||||
"renameExportFailed": "Nepodařilo se přejmenovat export: {{errorMessage}}"
|
||||
}
|
||||
},
|
||||
"tooltip": {
|
||||
"shareExport": "Sdílet export",
|
||||
"downloadVideo": "Stáhnout video",
|
||||
"deleteExport": "Smazat export",
|
||||
"editName": "Upravit jméno"
|
||||
}
|
||||
}
|
||||
|
||||
@ -306,7 +306,8 @@
|
||||
"notifications": "Notifikace",
|
||||
"frigateplus": "Frigate+",
|
||||
"enrichments": "Obohacení",
|
||||
"triggers": "Spouštěče"
|
||||
"triggers": "Spouštěče",
|
||||
"cameraManagement": "Správa"
|
||||
},
|
||||
"dialog": {
|
||||
"unsavedChanges": {
|
||||
|
||||
@ -80,5 +80,9 @@
|
||||
"hammer": "Hammer",
|
||||
"drill": "Bore",
|
||||
"explosion": "Eksplosion",
|
||||
"fireworks": "Nytårskrudt"
|
||||
"fireworks": "Nytårskrudt",
|
||||
"babbling": "Pludren",
|
||||
"yell": "Råb",
|
||||
"whoop": "Jubel",
|
||||
"snicker": "Smålatter"
|
||||
}
|
||||
|
||||
@ -7,7 +7,9 @@
|
||||
"usernameRequired": "Brugernavn kræves",
|
||||
"passwordRequired": "Kodeord kræves",
|
||||
"loginFailed": "Login fejlede",
|
||||
"unknownError": "Ukendt fejl. Tjek logs."
|
||||
}
|
||||
"unknownError": "Ukendt fejl. Tjek logs.",
|
||||
"rateLimit": "Grænsen for forespørgsler er overskredet. Prøv igen senere."
|
||||
},
|
||||
"firstTimeLogin": "Forsøger du at logge ind for første gang? Loginoplysningerne står i Frigate-loggene."
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,7 +11,11 @@
|
||||
}
|
||||
},
|
||||
"name": {
|
||||
"label": "Navn"
|
||||
"label": "Navn",
|
||||
"placeholder": "Indtast et navn…",
|
||||
"errorMessage": {
|
||||
"mustLeastCharacters": "Kameragruppens navn skal være mindst 2 tegn."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,7 +3,23 @@
|
||||
"title": "Er du sikker på at du vil genstarte Frigate?",
|
||||
"button": "Genstart",
|
||||
"restarting": {
|
||||
"title": "Frigate genstarter"
|
||||
"title": "Frigate genstarter",
|
||||
"button": "Gennemtving genindlæsning nu",
|
||||
"content": "Denne side genindlæses om {{countdown}} sekunder."
|
||||
}
|
||||
},
|
||||
"explore": {
|
||||
"plus": {
|
||||
"submitToPlus": {
|
||||
"label": "Indsend til Frigate+",
|
||||
"desc": "Objekter på steder, du ønsker at undgå, er ikke falske positiver. Hvis du indsender dem som falske positiver, vil det forvirre modellen."
|
||||
},
|
||||
"review": {
|
||||
"question": {
|
||||
"label": "Bekræft denne etiket til Frigate Plus",
|
||||
"ask_a": "Er dette objekt et <code>{{label}}</code>?"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,8 +10,10 @@
|
||||
},
|
||||
"labels": {
|
||||
"all": {
|
||||
"short": "Labels"
|
||||
"short": "Labels",
|
||||
"title": "Alle etiketter"
|
||||
},
|
||||
"count_one": "{{count}} Label"
|
||||
"count_one": "{{count}} Label",
|
||||
"label": "Etiketter"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
{
|
||||
"button": {
|
||||
"downloadVideo": {
|
||||
"label": "Download Video"
|
||||
"label": "Download Video",
|
||||
"toast": {
|
||||
"success": "Din video til gennemgang er begyndt at blive downloadet."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,15 @@
|
||||
{
|
||||
"noRecordingsFoundForThisTime": "Ingen optagelser fundet i det angivet tidsrum",
|
||||
"noPreviewFound": "Ingen forhåndsvisning fundet",
|
||||
"cameraDisabled": "Kamera er deaktiveret"
|
||||
"cameraDisabled": "Kamera er deaktiveret",
|
||||
"noPreviewFoundFor": "Ingen forhåndsvisning fundet for {{cameraName}}",
|
||||
"submitFrigatePlus": {
|
||||
"title": "Indsend denne frame til Frigate+?",
|
||||
"submit": "Indsend"
|
||||
},
|
||||
"livePlayerRequiredIOSVersion": "iOS 17.1 eller nyere kræves for denne type livestream.",
|
||||
"streamOffline": {
|
||||
"title": "Stream offline",
|
||||
"desc": "Der er ikke modtaget nogen frames på {{cameraName}}-<code>detect</code>-streamen, tjek fejlloggene."
|
||||
}
|
||||
}
|
||||
|
||||
@ -1 +1,17 @@
|
||||
{}
|
||||
{
|
||||
"documentTitle": "Klassifikationsmodeller",
|
||||
"details": {
|
||||
"scoreInfo": "Scoren repræsenterer den gennemsnitlige klassifikationssikkerhed på tværs af alle registreringer af dette objekt."
|
||||
},
|
||||
"description": {
|
||||
"invalidName": "Ugyldigt navn. Navne må kun indeholde bogstaver, tal, mellemrum, apostroffer, understregninger og bindestreger."
|
||||
},
|
||||
"button": {
|
||||
"deleteClassificationAttempts": "Slet klassifikationsbilleder",
|
||||
"renameCategory": "Omdøb klasse",
|
||||
"deleteCategory": "Slet klasse",
|
||||
"deleteImages": "Slet billeder",
|
||||
"trainModel": "Træn model",
|
||||
"addClassification": "Tilføj klassifikation"
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,5 +2,9 @@
|
||||
"documentTitle": "Konfigurationsstyring - Frigate",
|
||||
"copyConfig": "Kopiér konfiguration",
|
||||
"saveAndRestart": "Gem & Genstart",
|
||||
"saveOnly": "Kun gem"
|
||||
"saveOnly": "Kun gem",
|
||||
"configEditor": "Konfigurationseditor",
|
||||
"safeConfigEditor": "Konfigurationseditor (Sikker tilstand)",
|
||||
"safeModeDescription": "Frigate er i sikker tilstand på grund af en fejl ved validering af konfigurationen.",
|
||||
"confirm": "Afslut uden at gemme?"
|
||||
}
|
||||
|
||||
@ -7,5 +7,10 @@
|
||||
},
|
||||
"allCameras": "Alle kameraer",
|
||||
"timeline": "Tidslinje",
|
||||
"camera": "Kamera"
|
||||
"camera": "Kamera",
|
||||
"empty": {
|
||||
"alert": "Der er ingen advarsler at gennemgå",
|
||||
"detection": "Der er ingen registreringer at gennemgå",
|
||||
"motion": "Ingen bevægelsesdata fundet"
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,7 +13,17 @@
|
||||
"exploreIsUnavailable": {
|
||||
"embeddingsReindexing": {
|
||||
"startingUp": "Starter…",
|
||||
"estimatedTime": "Estimeret tid tilbage:"
|
||||
"estimatedTime": "Estimeret tid tilbage:",
|
||||
"context": "Udforsk kan bruges, når genindekseringen af de sporede objektindlejringer er fuldført.",
|
||||
"finishingShortly": "Afsluttes om lidt",
|
||||
"step": {
|
||||
"thumbnailsEmbedded": "Miniaturer indlejret: "
|
||||
}
|
||||
},
|
||||
"title": "Udforsk er ikke tilgængelig"
|
||||
},
|
||||
"exploreMore": "Udforsk flere {{label}}-objekter",
|
||||
"details": {
|
||||
"timestamp": "Tidsstempel"
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,6 +4,9 @@
|
||||
"deleteExport.desc": "Er du sikker på at du vil slette {{exportName}}?",
|
||||
"editExport": {
|
||||
"title": "Omdøb Eksport",
|
||||
"saveExport": "Gem Eksport"
|
||||
}
|
||||
"saveExport": "Gem Eksport",
|
||||
"desc": "Indtast et nyt navn for denne eksport."
|
||||
},
|
||||
"noExports": "Ingen eksporter fundet",
|
||||
"deleteExport": "Slet eksport"
|
||||
}
|
||||
|
||||
@ -1,10 +1,19 @@
|
||||
{
|
||||
"selectItem": "Vælg {{item}}",
|
||||
"description": {
|
||||
"addFace": "Gennemgang af tilføjelse til ansigts bibliotek",
|
||||
"placeholder": "Angiv et navn for bibliotek"
|
||||
"addFace": "Tilføj en ny samling til ansigtsbiblioteket ved at uploade dit første billede.",
|
||||
"placeholder": "Angiv et navn for bibliotek",
|
||||
"invalidName": "Ugyldigt navn. Navne må kun indeholde bogstaver, tal, mellemrum, apostroffer, understregninger og bindestreger."
|
||||
},
|
||||
"details": {
|
||||
"person": "Person"
|
||||
"person": "Person",
|
||||
"timestamp": "Tidsstempel",
|
||||
"unknown": "Ukendt",
|
||||
"scoreInfo": "Scoren er et vægtet gennemsnit af alle ansigtsscorer, vægtet efter ansigtets størrelse på hvert billede."
|
||||
},
|
||||
"documentTitle": "Ansigtsbibliotek - Frigate",
|
||||
"uploadFaceImage": {
|
||||
"title": "Upload ansigtsbillede",
|
||||
"desc": "Upload et billede for at scanne efter ansigter og inkludere det for {{pageToggle}}"
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,5 +8,14 @@
|
||||
"cameraAudio": {
|
||||
"enable": "Aktivér kameralyd",
|
||||
"disable": "Deaktivér kamera lyd"
|
||||
},
|
||||
"lowBandwidthMode": "Lavbåndbredde-tilstand",
|
||||
"ptz": {
|
||||
"move": {
|
||||
"clickMove": {
|
||||
"label": "Klik i billedrammen for at centrere kameraet",
|
||||
"enable": "Aktivér klik for at flytte"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,7 +5,8 @@
|
||||
"filters": "Filtere",
|
||||
"toast": {
|
||||
"error": {
|
||||
"endTimeMustAfterStartTime": "Sluttidspunkt skal være efter starttidspunkt"
|
||||
"endTimeMustAfterStartTime": "Sluttidspunkt skal være efter starttidspunkt",
|
||||
"noValidTimeSelected": "Intet gyldigt tidsinterval valgt"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
"save": "Gem søgning",
|
||||
"delete": "Slet gemt søgning",
|
||||
"filterInformation": "Filter information",
|
||||
"filterActive": "Filtre aktiv"
|
||||
"filterActive": "Filtre aktiv",
|
||||
"clear": "Ryd søgning"
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,6 +3,12 @@
|
||||
"default": "Indstillinger - Frigate",
|
||||
"authentication": "Bruger Indstillinger - Frigate",
|
||||
"camera": "Kamera indstillinger - Frigate",
|
||||
"object": "Debug - Frigate"
|
||||
"object": "Debug - Frigate",
|
||||
"cameraManagement": "Administrér kameraer - Frigate",
|
||||
"cameraReview": "Indstillinger for kameragennemgang - Frigate",
|
||||
"enrichments": "Indstillinger for berigelser - Frigate",
|
||||
"masksAndZones": "Maske- og zoneeditor - Frigate",
|
||||
"motionTuner": "Bevægelsesjustering - Frigate",
|
||||
"general": "Brugergrænsefladeindstillinger - Frigate"
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,7 +6,98 @@
|
||||
"frigate": "Frigate Logs - Frigate",
|
||||
"go2rtc": "Go2RTC Logs - Frigate",
|
||||
"nginx": "Nginx Logs - Frigate"
|
||||
},
|
||||
"general": "Generelle statistikker - Frigate",
|
||||
"enrichments": "Beredningsstatistikker - Frigate"
|
||||
},
|
||||
"title": "System",
|
||||
"logs": {
|
||||
"copy": {
|
||||
"label": "Kopier til udklipsholder",
|
||||
"success": "Logs er kopieret til udklipsholder",
|
||||
"error": "Kunne ikke kopiere logs til udklipsholder"
|
||||
},
|
||||
"type": {
|
||||
"label": "Type",
|
||||
"timestamp": "Tidsstempel",
|
||||
"message": "Besked",
|
||||
"tag": "Tag"
|
||||
},
|
||||
"tips": "Logs bliver streamet fra serveren",
|
||||
"toast": {
|
||||
"error": {
|
||||
"fetchingLogsFailed": "Fejl ved indhentning af logs: {{errorMessage}}",
|
||||
"whileStreamingLogs": "Fejl ved streaming af logs: {{errorMessage}}"
|
||||
}
|
||||
},
|
||||
"title": "System"
|
||||
"download": {
|
||||
"label": "Download logs"
|
||||
}
|
||||
},
|
||||
"general": {
|
||||
"title": "Generelt",
|
||||
"hardwareInfo": {
|
||||
"gpuUsage": "GPU forbrug",
|
||||
"gpuMemory": "GPU hukommelse",
|
||||
"gpuEncoder": "GPU indkoder",
|
||||
"gpuDecoder": "GPU afkoder",
|
||||
"title": "Hardware information",
|
||||
"gpuInfo": {
|
||||
"closeInfo": {
|
||||
"label": "Luk GPU information"
|
||||
},
|
||||
"copyInfo": {
|
||||
"label": "Kopier GPU information"
|
||||
},
|
||||
"toast": {
|
||||
"success": "Kopierede GPU information til udklipsholder"
|
||||
}
|
||||
},
|
||||
"npuUsage": "NPU forbrug",
|
||||
"npuMemory": "NPU hukommelse"
|
||||
},
|
||||
"detector": {
|
||||
"title": "Detektorer",
|
||||
"inferenceSpeed": "Detektorinferenshastighed",
|
||||
"temperature": "Detektor temperatur",
|
||||
"cpuUsage": "Detektor CPU forbrug",
|
||||
"cpuUsageInformation": "CPU brugt til at forberede input- og outputdata til/fra detektionsmodeller. Denne værdi måler ikke inferensforbrug, selvom der bruges en GPU eller accelerator.",
|
||||
"memoryUsage": "Detektorhummelsesforbrug"
|
||||
},
|
||||
"otherProcesses": {
|
||||
"title": "Andre processer",
|
||||
"processCpuUsage": "Proces CPU forbrug",
|
||||
"processMemoryUsage": "Proceshukommelsesforbrug"
|
||||
}
|
||||
},
|
||||
"metrics": "System metrikker",
|
||||
"storage": {
|
||||
"title": "Lagring",
|
||||
"overview": "Overblik",
|
||||
"recordings": {
|
||||
"title": "Optagelser",
|
||||
"tips": "Denne værdi repræsenterer den samlede lagerplads, der bruges af optagelserne i Frigates database. Frigate sporer ikke lagerpladsforbruget for alle filer på din disk.",
|
||||
"earliestRecording": "Tidligste optagelse til rådighed:"
|
||||
},
|
||||
"shm": {
|
||||
"title": "SHM (delt hukommelse) tildeling",
|
||||
"warning": "Den nuværende SHM størrelse af {{total}}MB er for lille. Øg den til minimum {{min_shm}}MB."
|
||||
},
|
||||
"cameraStorage": {
|
||||
"title": "Kamera lagring",
|
||||
"camera": "Kamera",
|
||||
"unusedStorageInformation": "Ubrugt lagringsinformation",
|
||||
"storageUsed": "Lagring",
|
||||
"percentageOfTotalUsed": "Procentandel af total",
|
||||
"bandwidth": "Båndbredde",
|
||||
"unused": {
|
||||
"title": "Ubrugt",
|
||||
"tips": "Denne værdi repræsenterer muligvis ikke nøjagtigt den ledige plads, der er tilgængelig for Frigate, hvis du har andre filer gemt på dit drev ud over Frigates optagelser. Frigate sporer ikke lagerforbrug ud over sine optagelser."
|
||||
}
|
||||
}
|
||||
},
|
||||
"cameras": {
|
||||
"title": "Kameraer",
|
||||
"overview": "Overblik"
|
||||
}
|
||||
}
|
||||
|
||||
@ -425,5 +425,21 @@
|
||||
"sanding": "Schleifen",
|
||||
"machine_gun": "Maschinengewehr",
|
||||
"boom": "Dröhnen",
|
||||
"field_recording": "Außenaufnahme"
|
||||
"field_recording": "Außenaufnahme",
|
||||
"liquid": "Flüssigkeit",
|
||||
"splash": "Spritzer",
|
||||
"slosh": "Schwenken",
|
||||
"squish": "Quetschen",
|
||||
"drip": "Tropfen",
|
||||
"pour": "Gießen",
|
||||
"trickle": "Tröpfeln",
|
||||
"fill": "Füllen",
|
||||
"spray": "Sprühen",
|
||||
"pump": "Pumpen",
|
||||
"stir": "Umrühren",
|
||||
"boiling": "Köchelnd",
|
||||
"arrow": "Pfeil",
|
||||
"electronic_tuner": "Elektronischer Tuner",
|
||||
"effects_unit": "Effekteinheit",
|
||||
"chorus_effect": "Chorus-Effekt"
|
||||
}
|
||||
|
||||
@ -121,7 +121,12 @@
|
||||
"unsuspended": "fortsetzen"
|
||||
},
|
||||
"label": {
|
||||
"back": "Zurück"
|
||||
"back": "Zurück",
|
||||
"hide": "Verstecke {{item}}",
|
||||
"show": "Zeige {{item}}",
|
||||
"ID": "ID",
|
||||
"none": "Nichts",
|
||||
"all": "Alle"
|
||||
},
|
||||
"menu": {
|
||||
"configurationEditor": "Konfigurationseditor",
|
||||
@ -284,5 +289,14 @@
|
||||
},
|
||||
"information": {
|
||||
"pixels": "{{area}}px"
|
||||
},
|
||||
"field": {
|
||||
"optional": "Optional",
|
||||
"internalID": "Die interne ID, die Frigate in der Konfiguration und Datenbank verwendet"
|
||||
},
|
||||
"list": {
|
||||
"two": "{{0}} und {{1}}",
|
||||
"many": "{{items}}, und {{last}}",
|
||||
"separatorWithSpace": ", "
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
"unknownError": "Unbekannter Fehler. Prüfe Logs."
|
||||
},
|
||||
"user": "Benutzername",
|
||||
"password": "Kennwort"
|
||||
"password": "Kennwort",
|
||||
"firstTimeLogin": "Versuchen Sie sich zum ersten Mal anzumelden? Die Anmeldedaten sind in den Frigate-Logs aufgeführt."
|
||||
}
|
||||
}
|
||||
|
||||
@ -66,7 +66,7 @@
|
||||
"failed": "Fehler beim Starten des Exports: {{error}}",
|
||||
"noVaildTimeSelected": "Kein gültiger Zeitraum ausgewählt"
|
||||
},
|
||||
"success": "Export erfolgreich gestartet. Die Datei befindet sich im Ordner /exports."
|
||||
"success": "Export erfolgreich gestartet. Die Datei befindet sich auf der Exportseite."
|
||||
},
|
||||
"fromTimeline": {
|
||||
"saveExport": "Export speichern",
|
||||
@ -126,6 +126,7 @@
|
||||
"search": {
|
||||
"placeholder": "Nach Label oder Unterlabel suchen..."
|
||||
},
|
||||
"noImages": "Kein Vorschaubild für diese Kamera gefunden"
|
||||
"noImages": "Kein Vorschaubild für diese Kamera gefunden",
|
||||
"unknownLabel": "Gespeichertes Triggerbild"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1 +1,40 @@
|
||||
{}
|
||||
{
|
||||
"documentTitle": "Klassifizierungsmodelle",
|
||||
"details": {
|
||||
"scoreInfo": "Die Punktzahl gibt die durchschnittliche Klassifizierungssicherheit aller Erkennungen dieses Objekts wieder."
|
||||
},
|
||||
"button": {
|
||||
"deleteClassificationAttempts": "Lösche Klassifizierungs-Bilder",
|
||||
"renameCategory": "Klasse umbenennen",
|
||||
"deleteCategory": "Klasse löschen",
|
||||
"deleteImages": "Bilder löschen",
|
||||
"trainModel": "Modell trainieren",
|
||||
"addClassification": "Klassifizierung hinzufügen",
|
||||
"deleteModels": "Modell löschen",
|
||||
"editModel": "Modell bearbeiten"
|
||||
},
|
||||
"tooltip": {
|
||||
"trainingInProgress": "Modell wird gerade trainiert",
|
||||
"noNewImages": "Keine weiteren Bilder zum trainieren. Bitte klassifiziere weitere Bilder im Datensatz.",
|
||||
"noChanges": "Keine Veränderungen des Datensatzes seit dem letzten Training.",
|
||||
"modelNotReady": "Modell ist nicht bereit trainiert zu werden."
|
||||
},
|
||||
"toast": {
|
||||
"success": {
|
||||
"deletedCategory": "Klasse gelöscht",
|
||||
"deletedImage": "Gelöschte Bilder",
|
||||
"deletedModel_one": "{{count}} Model erfolgreich gelöscht",
|
||||
"deletedModel_other": "{{count}} Modelle erfolgreich gelöscht",
|
||||
"categorizedImage": "Bild erfolgreich klassifiziert",
|
||||
"trainedModel": "Modell erfolgreich trainiert.",
|
||||
"trainingModel": "Modelltraining erfolgreich gestartet.",
|
||||
"updatedModel": "Modellkonfiguration erfolgreich aktualisiert",
|
||||
"renamedCategory": "Klasse erfolgreich in {{name}} umbenannt"
|
||||
},
|
||||
"error": {
|
||||
"deleteImageFailed": "Löschen fehlgeschlagen: {{errorMessage}}",
|
||||
"deleteCategoryFailed": "Klasse konnte nicht gelöscht werden: {{errorMessage}}",
|
||||
"deleteModelFailed": "Model konnte nicht gelöscht werden: {{errorMessage}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -36,5 +36,22 @@
|
||||
"selected_other": "{{count}} ausgewählt",
|
||||
"detected": "erkannt",
|
||||
"suspiciousActivity": "Verdächtige Aktivität",
|
||||
"threateningActivity": "Bedrohliche Aktivität"
|
||||
"threateningActivity": "Bedrohliche Aktivität",
|
||||
"zoomIn": "Hereinzoomen",
|
||||
"zoomOut": "Herauszoomen",
|
||||
"detail": {
|
||||
"label": "Detail",
|
||||
"aria": "Detailansicht umschalten",
|
||||
"trackedObject_one": "{{count}} Objekt",
|
||||
"trackedObject_other": "{{count}} Objekte",
|
||||
"noObjectDetailData": "Keine detaillierten Daten des Objekt verfügbar.",
|
||||
"noDataFound": "Keine Detaildaten zur Überprüfung",
|
||||
"settings": "Detailansicht Einstellungen",
|
||||
"alwaysExpandActive": {
|
||||
"desc": "Immer die Objektdetails des aktiven Überprüfungselements erweitern, sofern verfügbar."
|
||||
}
|
||||
},
|
||||
"objectTrack": {
|
||||
"trackedPoint": "Verfolgter Punkt"
|
||||
}
|
||||
}
|
||||
|
||||
@ -158,7 +158,8 @@
|
||||
"details": "Details",
|
||||
"video": "Video",
|
||||
"object_lifecycle": "Objekt-Lebenszyklus",
|
||||
"snapshot": "Snapshot"
|
||||
"snapshot": "Snapshot",
|
||||
"thumbnail": "Vorschaubild"
|
||||
},
|
||||
"itemMenu": {
|
||||
"downloadSnapshot": {
|
||||
@ -195,12 +196,22 @@
|
||||
"addTrigger": {
|
||||
"aria": "Einen Trigger für dieses verfolgte Objekt hinzufügen",
|
||||
"label": "Trigger hinzufügen"
|
||||
},
|
||||
"viewTrackingDetails": {
|
||||
"label": "Details zum Verfolgen anzeigen",
|
||||
"aria": "Details zum Verfolgen anzeigen"
|
||||
},
|
||||
"showObjectDetails": {
|
||||
"label": "Objektpfad anzeigen"
|
||||
},
|
||||
"hideObjectDetails": {
|
||||
"label": "Objektpfad verbergen"
|
||||
}
|
||||
},
|
||||
"dialog": {
|
||||
"confirmDelete": {
|
||||
"title": "Löschen bestätigen",
|
||||
"desc": "Beim Löschen dieses verfolgten Objekts werden der Schnappschuss, alle gespeicherten Einbettungen und alle zugehörigen Objektlebenszykluseinträge entfernt. Aufgezeichnetes Filmmaterial dieses verfolgten Objekts in der Verlaufsansicht wird <em>NICHT</em> gelöscht. <br /><br />Sind Sie sicher, dass Sie fortfahren möchten?"
|
||||
"desc": "Beim Löschen dieses verfolgten Objekts werden der Schnappschuss, alle gespeicherten Einbettungen und alle zugehörigen Verfolgungsdetails entfernt. Aufgezeichnetes Filmmaterial dieses verfolgten Objekts in der Verlaufsansicht wird <em>NICHT</em> gelöscht. <br /><br />Sind Sie sicher, dass Sie fortfahren möchten?"
|
||||
}
|
||||
},
|
||||
"searchResult": {
|
||||
@ -210,7 +221,9 @@
|
||||
"error": "Das verfolgte Objekt konnte nicht gelöscht werden: {{errorMessage}}"
|
||||
}
|
||||
},
|
||||
"tooltip": "Entspricht {{type}} bei {{confidence}}%"
|
||||
"tooltip": "Entspricht {{type}} bei {{confidence}}%",
|
||||
"previousTrackedObject": "Vorheriges verfolgtes Objekt",
|
||||
"nextTrackedObject": "Nächstes verfolgtes Objekt"
|
||||
},
|
||||
"noTrackedObjects": "Keine verfolgten Objekte gefunden",
|
||||
"fetchingTrackedObjectsFailed": "Fehler beim Abrufen von verfolgten Objekten: {{errorMessage}}",
|
||||
@ -222,5 +235,49 @@
|
||||
},
|
||||
"concerns": {
|
||||
"label": "Bedenken"
|
||||
},
|
||||
"trackingDetails": {
|
||||
"noImageFound": "Kein Bild mit diesem Zeitstempel gefunden.",
|
||||
"createObjectMask": "Objekt-Maske erstellen",
|
||||
"scrollViewTips": "Klicke, um die relevanten Momente aus dem Lebenszyklus dieses Objektes zu sehen.",
|
||||
"lifecycleItemDesc": {
|
||||
"visible": "{{label}} erkannt",
|
||||
"entered_zone": "{{label}} betrat {{zones}}",
|
||||
"active": "{{label}} wurde aktiv",
|
||||
"stationary": "{{label}} wurde stationär",
|
||||
"attribute": {
|
||||
"faceOrLicense_plate": "{{attribute}} erkannt für {{label}}",
|
||||
"other": "{{label}} erkannt als {{attribute}}"
|
||||
},
|
||||
"gone": "{{label}} verließ",
|
||||
"heard": "{{label}} wurde gehört",
|
||||
"external": "{{label}} erkannt",
|
||||
"header": {
|
||||
"zones": "Zonen",
|
||||
"ratio": "Verhältnis",
|
||||
"area": "Bereich"
|
||||
}
|
||||
},
|
||||
"annotationSettings": {
|
||||
"title": "Anmerkungseinstellungen",
|
||||
"showAllZones": {
|
||||
"title": "Zeige alle Zonen",
|
||||
"desc": "Immer Zonen auf Rahmen anzeigen, in die Objekte eingetreten sind."
|
||||
},
|
||||
"offset": {
|
||||
"label": "Anmerkungen Versatz",
|
||||
"desc": "Diese Daten stammen aus dem Erkennungsfeed der Kamera, werden jedoch über Bilder aus dem Aufzeichnungsfeed gelegt. Es ist unwahrscheinlich, dass beide Streams perfekt synchron sind. Daher stimmen der Begrenzungsrahmen und das Filmmaterial nicht vollständig überein. Mit dieser Einstellung lassen sich die Anmerkungen zeitlich nach vorne oder hinten verschieben, um sie besser an das aufgezeichnete Filmmaterial anzupassen.",
|
||||
"millisecondsToOffset": "Millisekunden, um Erkennungs-Anmerkungen zu verschieben. <em>Standard: 0</em>",
|
||||
"tips": "Verringere den Wert, wenn die Videowiedergabe den Boxen und Wegpunkten voraus ist, und erhöhe den Wert, wenn die Videowiedergabe hinter ihnen zurückbleibt. Dieser Wert kann negativ sein.",
|
||||
"toast": {
|
||||
"success": "Der Anmerkungs-Offset für {{camera}} wurde in der Konfigurationsdatei gespeichert. Starte Frigate neu, um Ihre Änderungen zu übernehmen."
|
||||
}
|
||||
}
|
||||
},
|
||||
"carousel": {
|
||||
"previous": "Vorherige Anzeige",
|
||||
"next": "Nächste Anzeige"
|
||||
},
|
||||
"title": "Verfolgungsdetails"
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,5 +13,11 @@
|
||||
"error": {
|
||||
"renameExportFailed": "Umbenennen des Exports fehlgeschlagen: {{errorMessage}}"
|
||||
}
|
||||
},
|
||||
"tooltip": {
|
||||
"shareExport": "Export teilen",
|
||||
"downloadVideo": "Video herunterladen",
|
||||
"editName": "Name ändern",
|
||||
"deleteExport": "Export löschen"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"description": {
|
||||
"placeholder": "Gib einen Name für diese Kollektion ein",
|
||||
"addFace": "Anleitung für das Hinzufügen einer neuen Kollektion zur Gesichtsbibliothek.",
|
||||
"addFace": "Füge der Gesichtsbibliothek eine neue Sammlung hinzu, indem ein erstes Bild hochgeladen wird.",
|
||||
"invalidName": "Ungültiger Name. Namen dürfen nur Buchstaben, Zahlen, Leerzeichen, Apostrophe, Unterstriche und Bindestriche enthalten."
|
||||
},
|
||||
"details": {
|
||||
@ -22,7 +22,7 @@
|
||||
"title": "Kollektion erstellen",
|
||||
"new": "Lege ein neues Gesicht an",
|
||||
"desc": "Erstelle eine neue Kollektion",
|
||||
"nextSteps": "Um eine solide Grundlage zu bilden: <li> Benutze den Trainieren Tab, um Bilder für jede erkannte Person auszuwählen und zu trainieren.</li> <li>Konzentriere dich für gute Ergebnisse auf Frontalfotos; vermeide Bilder zu Trainingszwecken, bei denen Gesichter aus einem Winkel erfasst wurden.</li></ul>"
|
||||
"nextSteps": "Um eine solide Grundlage zu bilden: <li> Benutze den \"Aktuelle Erkennungen\" Tab, um Bilder für jede erkannte Person auszuwählen und zu trainieren.</li> <li>Konzentriere dich für gute Ergebnisse auf Frontalfotos; vermeide Bilder zu Trainingszwecken, bei denen Gesichter aus einem Winkel erfasst wurden.</li></ul>"
|
||||
},
|
||||
"documentTitle": "Gesichtsbibliothek - Frigate",
|
||||
"selectItem": "Wähle {{item}}",
|
||||
@ -44,8 +44,8 @@
|
||||
"deleteFace": "Lösche Gesicht"
|
||||
},
|
||||
"train": {
|
||||
"title": "Trainiere",
|
||||
"aria": "Wähle Training",
|
||||
"title": "Aktuelle Erkennungen",
|
||||
"aria": "Wähle aktuelle Erkennungen",
|
||||
"empty": "Es gibt keine aktuellen Versuche zur Gesichtserkennung"
|
||||
},
|
||||
"deleteFaceLibrary": {
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
"camera": "Kameraeinstellungen - Frigate",
|
||||
"masksAndZones": "Masken- und Zonen-Editor – Frigate",
|
||||
"object": "Debug - Frigate",
|
||||
"general": "Allgemeine Einstellungen – Frigate",
|
||||
"general": "UI Einstellungen – Frigate",
|
||||
"frigatePlus": "Frigate+ Einstellungen – Frigate",
|
||||
"classification": "Klassifizierungseinstellungen – Frigate",
|
||||
"motionTuner": "Bewegungserkennungs-Optimierer – Frigate",
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
"loginFailed": "Αποτυχία σύνδεσης",
|
||||
"unknownError": "Άγνωστο σφάλμα. Ελέγξτε το αρχείο καταγραφής.",
|
||||
"webUnknownError": "Άγνωστο σφάλμα. Εξετάστε το αρχείο καταγραφής κονσόλας."
|
||||
}
|
||||
},
|
||||
"firstTimeLogin": "Προσπαθείτε να συνδεθείτε για πρώτη φορά; Τα διαπιστευτήρια είναι τυπωμένα στα logs του Frigate."
|
||||
}
|
||||
}
|
||||
|
||||
@ -53,6 +53,7 @@
|
||||
"selectOrExport": "Select or Export",
|
||||
"toast": {
|
||||
"success": "Successfully started export. View the file in the exports page.",
|
||||
"view": "View",
|
||||
"error": {
|
||||
"failed": "Failed to start export: {{error}}",
|
||||
"endTimeMustAfterStartTime": "End time must be after start time",
|
||||
|
||||
@ -166,6 +166,7 @@
|
||||
"noImages": "No sample images generated",
|
||||
"classifying": "Classifying & Training...",
|
||||
"trainingStarted": "Training started successfully",
|
||||
"modelCreated": "Model created successfully. Use the Recent Classifications view to add images for missing states, then train the model.",
|
||||
"errors": {
|
||||
"noCameras": "No cameras configured",
|
||||
"noObjectLabel": "No object label selected",
|
||||
@ -173,7 +174,11 @@
|
||||
"generationFailed": "Generation failed. Please try again.",
|
||||
"classifyFailed": "Failed to classify images: {{error}}"
|
||||
},
|
||||
"generateSuccess": "Successfully generated sample images"
|
||||
"generateSuccess": "Successfully generated sample images",
|
||||
"missingStatesWarning": {
|
||||
"title": "Missing State Examples",
|
||||
"description": "You haven't selected examples for all states. The model will not be trained until all states have images. After continuing, use the Recent Classifications view to classify images for the missing states, then train the model."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -54,6 +54,7 @@
|
||||
"selected_other": "{{count}} selected",
|
||||
"camera": "Camera",
|
||||
"detected": "detected",
|
||||
"suspiciousActivity": "Suspicious Activity",
|
||||
"threateningActivity": "Threatening Activity"
|
||||
"normalActivity": "Normal",
|
||||
"needsReview": "Needs review",
|
||||
"securityConcern": "Security concern"
|
||||
}
|
||||
|
||||
@ -61,7 +61,8 @@
|
||||
"header": {
|
||||
"zones": "Zones",
|
||||
"ratio": "Ratio",
|
||||
"area": "Area"
|
||||
"area": "Area",
|
||||
"score": "Score"
|
||||
}
|
||||
},
|
||||
"annotationSettings": {
|
||||
|
||||
@ -18,15 +18,19 @@
|
||||
"deletedModel_many": "Borrados con éxito {{count}} modelos",
|
||||
"deletedModel_other": "Borrados con éxito {{count}} modelos",
|
||||
"categorizedImage": "Imagen Clasificada Correctamente",
|
||||
"trainedModel": "Modelo entrenado correctamente."
|
||||
"trainedModel": "Modelo entrenado correctamente.",
|
||||
"trainingModel": "Entrenamiento del modelo iniciado correctamente.",
|
||||
"updatedModel": "Configuración del modelo actualizada correctamente",
|
||||
"renamedCategory": "Clase renombrada correctamente a {{name}}"
|
||||
},
|
||||
"error": {
|
||||
"deleteImageFailed": "Fallo al borrar: {{errorMessage}}",
|
||||
"deleteCategoryFailed": "Fallo al borrar clase: {{errorMessage}}",
|
||||
"deleteModelFailed": "Fallo al borrar modelo: {{errorMessage}}",
|
||||
"categorizeFailed": "Fallo al categorizar imagen: {{errorMessage}}",
|
||||
"trainingFailed": "Fallo al iniciar el entrenamiento del modelo: {{errorMessage}}",
|
||||
"updateModelFailed": "Fallo al actualizar modelo: {{errorMessage}}"
|
||||
"trainingFailed": "El entrenamiento del modelo ha fallado. Revisa los registros de Frigate para más detalles.",
|
||||
"updateModelFailed": "Fallo al actualizar modelo: {{errorMessage}}",
|
||||
"trainingFailedToStart": "No se pudo iniciar el entrenamiento del modelo: {{errorMessage}}"
|
||||
}
|
||||
},
|
||||
"deleteCategory": {
|
||||
@ -35,6 +39,21 @@
|
||||
},
|
||||
"deleteModel": {
|
||||
"title": "Borrar Modelo de Clasificación",
|
||||
"single": "¿Está seguro de que quiere eliminar {{name}}? Esto borrar permanentemente todos los datos asociados incluidas las imágenes y los datos de entrenamiento. Esta acción no se puede deshacer."
|
||||
"single": "¿Está seguro de que quiere eliminar {{name}}? Esto borrar permanentemente todos los datos asociados incluidas las imágenes y los datos de entrenamiento. Esta acción no se puede deshacer.",
|
||||
"desc_one": "¿Estas seguro de que quiere borrar {{count}} modelo? Esto borrara permanentemente todos los datos asociados, incluyendo imágenes y datos de entrenamiento. Esta acción no puede ser desehecha.",
|
||||
"desc_many": "¿Estas seguro de que quiere borrar {{count}} modelos? Esto borrara permanentemente todos los datos asociados, incluyendo imágenes y datos de entrenamiento. Esta acción no puede ser desehecha.",
|
||||
"desc_other": "¿Estas seguro de que quiere borrar {{count}} modelos? Esto borrara permanentemente todos los datos asociados, incluyendo imágenes y datos de entrenamiento. Esta acción no puede ser desehecha."
|
||||
},
|
||||
"edit": {
|
||||
"title": "Editar modelo de clasificación"
|
||||
},
|
||||
"tooltip": {
|
||||
"noChanges": "No se han realizado cambios en el conjunto de datos desde el último entrenamiento.",
|
||||
"modelNotReady": "El modelo no está listo para el entrenamiento",
|
||||
"trainingInProgress": "El modelo está entrenándose actualmente.",
|
||||
"noNewImages": "No hay imágenes nuevas para entrenar. Clasifica antes más imágenes del conjunto de datos."
|
||||
},
|
||||
"details": {
|
||||
"scoreInfo": "La puntuación representa la confianza media de clasificación en todas las detecciones de este objeto."
|
||||
}
|
||||
}
|
||||
|
||||
@ -42,12 +42,19 @@
|
||||
"zoomOut": "Alejar",
|
||||
"detail": {
|
||||
"label": "Detalle",
|
||||
"trackedObject_one": "objeto",
|
||||
"trackedObject_other": "objetos",
|
||||
"trackedObject_one": "{{count}} objeto",
|
||||
"trackedObject_other": "{{count}} objetos",
|
||||
"noObjectDetailData": "No hay datos detallados del objeto.",
|
||||
"settings": "Configuración de la Vista Detalle"
|
||||
"settings": "Configuración de la Vista Detalle",
|
||||
"noDataFound": "No hay datos detallados para revisar",
|
||||
"aria": "Alternar vista de detalles",
|
||||
"alwaysExpandActive": {
|
||||
"title": "Expandir siempre los activos",
|
||||
"desc": "Expandir siempre los detalles del objeto activo cuando esten disponibles."
|
||||
}
|
||||
},
|
||||
"objectTrack": {
|
||||
"clickToSeek": "Clic para ir a este momento"
|
||||
"clickToSeek": "Clic para ir a este momento",
|
||||
"trackedPoint": "Puntro trazado"
|
||||
}
|
||||
}
|
||||
|
||||
@ -111,7 +111,8 @@
|
||||
"video": "vídeo",
|
||||
"object_lifecycle": "ciclo de vida del objeto",
|
||||
"details": "detalles",
|
||||
"thumbnail": "miniatura"
|
||||
"thumbnail": "miniatura",
|
||||
"tracking_details": "detalles de seguimiento"
|
||||
},
|
||||
"objectLifecycle": {
|
||||
"title": "Ciclo de vida del objeto",
|
||||
@ -227,9 +228,14 @@
|
||||
"label": "Preocupaciones"
|
||||
},
|
||||
"trackingDetails": {
|
||||
"title": "Detalles del Seguimiento",
|
||||
"title": "Detalles del seguimiento",
|
||||
"noImageFound": "No se ha encontrado imagen en este momento.",
|
||||
"createObjectMask": "Crear Máscara de Objeto",
|
||||
"adjustAnnotationSettings": "Ajustar configuración de anotaciones"
|
||||
"createObjectMask": "Crear máscara de objeto",
|
||||
"adjustAnnotationSettings": "Ajustar configuración de anotaciones",
|
||||
"scrollViewTips": "Haz clic para ver los momentos relevantes del ciclo de vida de este objeto.",
|
||||
"count": "{{first}} de {{second}}",
|
||||
"lifecycleItemDesc": {
|
||||
"visible": "{{label}} detectado"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -85,7 +85,7 @@
|
||||
"disable": "Ocultar estadísticas de transmisión"
|
||||
},
|
||||
"manualRecording": {
|
||||
"title": "Grabación bajo demanda",
|
||||
"title": "Bajo demanda",
|
||||
"tips": "Iniciar un evento manual basado en la configuración de retención de grabaciones de esta cámara.",
|
||||
"playInBackground": {
|
||||
"label": "Reproducir en segundo plano",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user