mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-12-18 19:16:42 +03:00
Compare commits
14 Commits
c04a0841e6
...
2a8ada9a14
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a8ada9a14 | ||
|
|
78d84a35cb | ||
|
|
3551b61ca8 | ||
|
|
cbed989717 | ||
|
|
bfc1a77e9b | ||
|
|
0d3fe70cc7 | ||
|
|
a9d3d9044d | ||
|
|
d6a4bd7598 | ||
|
|
3fc018481c | ||
|
|
30a5b1a640 | ||
|
|
1b57fb15a7 | ||
|
|
cd606ad240 | ||
|
|
de2144f158 | ||
|
|
e79ff9a079 |
@ -81,3 +81,5 @@ librosa==0.11.*
|
|||||||
soundfile==0.13.*
|
soundfile==0.13.*
|
||||||
# DeGirum detector
|
# DeGirum detector
|
||||||
degirum == 0.16.*
|
degirum == 0.16.*
|
||||||
|
# Memory profiling
|
||||||
|
memray == 1.15.*
|
||||||
|
|||||||
@ -75,7 +75,13 @@ audio:
|
|||||||
|
|
||||||
### Audio Transcription
|
### 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
|
```yaml
|
||||||
audio_transcription:
|
audio_transcription:
|
||||||
|
|||||||
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/recordings",
|
||||||
"troubleshooting/gpu",
|
"troubleshooting/gpu",
|
||||||
"troubleshooting/edgetpu",
|
"troubleshooting/edgetpu",
|
||||||
|
"troubleshooting/memory",
|
||||||
],
|
],
|
||||||
Development: [
|
Development: [
|
||||||
"development/contributing",
|
"development/contributing",
|
||||||
|
|||||||
@ -23,7 +23,7 @@ from markupsafe import escape
|
|||||||
from peewee import SQL, fn, operator
|
from peewee import SQL, fn, operator
|
||||||
from pydantic import ValidationError
|
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.query.app_query_parameters import AppTimelineHourlyQueryParameters
|
||||||
from frigate.api.defs.request.app_body import AppConfigSetBody
|
from frigate.api.defs.request.app_body import AppConfigSetBody
|
||||||
from frigate.api.defs.tags import Tags
|
from frigate.api.defs.tags import Tags
|
||||||
@ -56,29 +56,33 @@ logger = logging.getLogger(__name__)
|
|||||||
router = APIRouter(tags=[Tags.app])
|
router = APIRouter(tags=[Tags.app])
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response_class=PlainTextResponse)
|
@router.get(
|
||||||
|
"/", response_class=PlainTextResponse, dependencies=[Depends(allow_public())]
|
||||||
|
)
|
||||||
def is_healthy():
|
def is_healthy():
|
||||||
return "Frigate is running. Alive and 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):
|
def config_schema(request: Request):
|
||||||
return Response(
|
return Response(
|
||||||
content=request.app.frigate_config.schema_json(), media_type="application/json"
|
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():
|
def version():
|
||||||
return VERSION
|
return VERSION
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stats")
|
@router.get("/stats", dependencies=[Depends(allow_any_authenticated())])
|
||||||
def stats(request: Request):
|
def stats(request: Request):
|
||||||
return JSONResponse(content=request.app.stats_emitter.get_latest_stats())
|
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):
|
def stats_history(request: Request, keys: str = None):
|
||||||
if keys:
|
if keys:
|
||||||
keys = keys.split(",")
|
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))
|
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):
|
def metrics(request: Request):
|
||||||
"""Expose Prometheus metrics endpoint and update metrics with latest stats"""
|
"""Expose Prometheus metrics endpoint and update metrics with latest stats"""
|
||||||
# Retrieve the latest statistics and update the Prometheus metrics
|
# 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)
|
return Response(content=content, media_type=content_type)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/config")
|
@router.get("/config", dependencies=[Depends(allow_any_authenticated())])
|
||||||
def config(request: Request):
|
def config(request: Request):
|
||||||
config_obj: FrigateConfig = request.app.frigate_config
|
config_obj: FrigateConfig = request.app.frigate_config
|
||||||
config: dict[str, dict[str, Any]] = config_obj.model_dump(
|
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)
|
return JSONResponse(content=raw_paths)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/config/raw")
|
@router.get("/config/raw", dependencies=[Depends(allow_any_authenticated())])
|
||||||
def config_raw():
|
def config_raw():
|
||||||
config_file = find_config_file()
|
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():
|
def vainfo():
|
||||||
vainfo = vainfo_hwaccel()
|
vainfo = vainfo_hwaccel()
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
@ -472,12 +476,16 @@ def vainfo():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/nvinfo")
|
@router.get("/nvinfo", dependencies=[Depends(allow_any_authenticated())])
|
||||||
def nvinfo():
|
def nvinfo():
|
||||||
return JSONResponse(content=get_nvidia_driver_info())
|
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(
|
async def logs(
|
||||||
service: str = Path(enum=["frigate", "nginx", "go2rtc"]),
|
service: str = Path(enum=["frigate", "nginx", "go2rtc"]),
|
||||||
download: Optional[str] = None,
|
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 = ""):
|
def get_labels(camera: str = ""):
|
||||||
try:
|
try:
|
||||||
if camera:
|
if camera:
|
||||||
@ -603,7 +611,7 @@ def get_labels(camera: str = ""):
|
|||||||
return JSONResponse(content=labels)
|
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):
|
def get_sub_labels(split_joined: Optional[int] = None):
|
||||||
try:
|
try:
|
||||||
events = Event.select(Event.sub_label).distinct()
|
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)
|
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):
|
def plusModels(request: Request, filterByCurrentModelDetector: bool = False):
|
||||||
if not request.app.frigate_config.plus_api.is_active():
|
if not request.app.frigate_config.plus_api.is_active():
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
@ -676,7 +684,9 @@ def plusModels(request: Request, filterByCurrentModelDetector: bool = False):
|
|||||||
return JSONResponse(content=validModels)
|
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):
|
def get_recognized_license_plates(split_joined: Optional[int] = None):
|
||||||
try:
|
try:
|
||||||
query = (
|
query = (
|
||||||
@ -710,7 +720,7 @@ def get_recognized_license_plates(split_joined: Optional[int] = None):
|
|||||||
return JSONResponse(content=recognized_license_plates)
|
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):
|
def timeline(camera: str = "all", limit: int = 100, source_id: Optional[str] = None):
|
||||||
clauses = []
|
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])
|
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()):
|
def hourly_timeline(params: AppTimelineHourlyQueryParameters = Depends()):
|
||||||
"""Get hourly summary for timeline."""
|
"""Get hourly summary for timeline."""
|
||||||
cameras = params.cameras
|
cameras = params.cameras
|
||||||
|
|||||||
@ -32,10 +32,154 @@ from frigate.models import User
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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",
|
||||||
|
"/metrics",
|
||||||
|
# Authenticated user endpoints (allow_any_authenticated)
|
||||||
|
"/stats",
|
||||||
|
"/stats/history",
|
||||||
|
"/config",
|
||||||
|
"/config/raw",
|
||||||
|
"/vainfo",
|
||||||
|
"/nvinfo",
|
||||||
|
"/labels",
|
||||||
|
"/sub_labels",
|
||||||
|
"/plus/models",
|
||||||
|
"/recognized_license_plates",
|
||||||
|
"/timeline",
|
||||||
|
"/timeline/hourly",
|
||||||
|
"/events/summary",
|
||||||
|
"/recordings/storage",
|
||||||
|
"/recordings/summary",
|
||||||
|
"/recordings/unavailable",
|
||||||
|
"/go2rtc/streams",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Path prefixes that should be exempt (for paths with parameters)
|
||||||
|
EXEMPT_PREFIXES = (
|
||||||
|
"/logs/", # /logs/{service}
|
||||||
|
"/review", # /review, /review/{id}, /review_ids, /review/summary, etc.
|
||||||
|
"/reviews/", # /reviews/viewed, /reviews/delete
|
||||||
|
"/events/", # /events/{id}/thumbnail, etc. (camera-scoped)
|
||||||
|
"/go2rtc/streams/", # /go2rtc/streams/{camera}
|
||||||
|
"/users/", # /users/{username}/password (has own auth)
|
||||||
|
"/preview/", # /preview/{file}/thumbnail.jpg
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
# 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="Admin role required for this endpoint",
|
||||||
|
)
|
||||||
|
|
||||||
|
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 = 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):
|
def first_time_login(request: Request):
|
||||||
"""Return whether the admin first-time login help flag is set in config.
|
"""Return whether the admin first-time login help flag is set in config.
|
||||||
|
|
||||||
@ -352,7 +496,7 @@ def resolve_role(
|
|||||||
|
|
||||||
|
|
||||||
# Endpoints
|
# Endpoints
|
||||||
@router.get("/auth")
|
@router.get("/auth", dependencies=[Depends(allow_public())])
|
||||||
def auth(request: Request):
|
def auth(request: Request):
|
||||||
auth_config: AuthConfig = request.app.frigate_config.auth
|
auth_config: AuthConfig = request.app.frigate_config.auth
|
||||||
proxy_config: ProxyConfig = request.app.frigate_config.proxy
|
proxy_config: ProxyConfig = request.app.frigate_config.proxy
|
||||||
@ -478,7 +622,7 @@ def auth(request: Request):
|
|||||||
return fail_response
|
return fail_response
|
||||||
|
|
||||||
|
|
||||||
@router.get("/profile")
|
@router.get("/profile", dependencies=[Depends(allow_any_authenticated())])
|
||||||
def profile(request: Request):
|
def profile(request: Request):
|
||||||
username = request.headers.get("remote-user", "anonymous")
|
username = request.headers.get("remote-user", "anonymous")
|
||||||
role = request.headers.get("remote-role", "viewer")
|
role = request.headers.get("remote-role", "viewer")
|
||||||
@ -492,7 +636,7 @@ def profile(request: Request):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/logout")
|
@router.get("/logout", dependencies=[Depends(allow_any_authenticated())])
|
||||||
def logout(request: Request):
|
def logout(request: Request):
|
||||||
auth_config: AuthConfig = request.app.frigate_config.auth
|
auth_config: AuthConfig = request.app.frigate_config.auth
|
||||||
response = RedirectResponse("/login", status_code=303)
|
response = RedirectResponse("/login", status_code=303)
|
||||||
@ -503,7 +647,7 @@ def logout(request: Request):
|
|||||||
limiter = Limiter(key_func=get_remote_addr)
|
limiter = Limiter(key_func=get_remote_addr)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login")
|
@router.post("/login", dependencies=[Depends(allow_public())])
|
||||||
@limiter.limit(limit_value=rateLimiter.get_limit)
|
@limiter.limit(limit_value=rateLimiter.get_limit)
|
||||||
def login(request: Request, body: AppPostLoginBody):
|
def login(request: Request, body: AppPostLoginBody):
|
||||||
JWT_COOKIE_NAME = request.app.frigate_config.auth.cookie_name
|
JWT_COOKIE_NAME = request.app.frigate_config.auth.cookie_name
|
||||||
@ -578,13 +722,21 @@ def create_user(
|
|||||||
return JSONResponse(content={"username": body.username})
|
return JSONResponse(content={"username": body.username})
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/users/{username}")
|
@router.delete("/users/{username}", dependencies=[Depends(require_role(["admin"]))])
|
||||||
def delete_user(username: str):
|
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)
|
User.delete_by_id(username)
|
||||||
return JSONResponse(content={"success": True})
|
return JSONResponse(content={"success": True})
|
||||||
|
|
||||||
|
|
||||||
@router.put("/users/{username}/password")
|
@router.put(
|
||||||
|
"/users/{username}/password", dependencies=[Depends(allow_any_authenticated())]
|
||||||
|
)
|
||||||
async def update_password(
|
async def update_password(
|
||||||
request: Request,
|
request: Request,
|
||||||
username: str,
|
username: str,
|
||||||
|
|||||||
@ -15,7 +15,11 @@ from onvif import ONVIFCamera, ONVIFError
|
|||||||
from zeep.exceptions import Fault, TransportError
|
from zeep.exceptions import Fault, TransportError
|
||||||
from zeep.transports import AsyncTransport
|
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.api.defs.tags import Tags
|
||||||
from frigate.config.config import FrigateConfig
|
from frigate.config.config import FrigateConfig
|
||||||
from frigate.util.builtin import clean_camera_user_pass
|
from frigate.util.builtin import clean_camera_user_pass
|
||||||
@ -50,7 +54,7 @@ def _is_valid_host(host: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
@router.get("/go2rtc/streams")
|
@router.get("/go2rtc/streams", dependencies=[Depends(allow_any_authenticated())])
|
||||||
def go2rtc_streams():
|
def go2rtc_streams():
|
||||||
r = requests.get("http://127.0.0.1:1984/api/streams")
|
r = requests.get("http://127.0.0.1:1984/api/streams")
|
||||||
if not r.ok:
|
if not r.ok:
|
||||||
@ -66,7 +70,9 @@ def go2rtc_streams():
|
|||||||
return JSONResponse(content=stream_data)
|
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):
|
def go2rtc_camera_stream(request: Request, camera_name: str):
|
||||||
r = requests.get(
|
r = requests.get(
|
||||||
f"http://127.0.0.1:1984/api/streams?src={camera_name}&video=all&audio=allµphone"
|
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):
|
def ffprobe(request: Request, paths: str = "", detailed: bool = False):
|
||||||
path_param = paths
|
path_param = paths
|
||||||
|
|
||||||
|
|||||||
@ -870,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(
|
@router.post(
|
||||||
"/classification/{name}/train/delete",
|
"/classification/{name}/train/delete",
|
||||||
response_model=GenericResponse,
|
response_model=GenericResponse,
|
||||||
|
|||||||
@ -22,6 +22,7 @@ from peewee import JOIN, DoesNotExist, fn, operator
|
|||||||
from playhouse.shortcuts import model_to_dict
|
from playhouse.shortcuts import model_to_dict
|
||||||
|
|
||||||
from frigate.api.auth import (
|
from frigate.api.auth import (
|
||||||
|
allow_any_authenticated,
|
||||||
get_allowed_cameras_for_filter,
|
get_allowed_cameras_for_filter,
|
||||||
require_camera_access,
|
require_camera_access,
|
||||||
require_role,
|
require_role,
|
||||||
@ -808,7 +809,7 @@ def events_search(
|
|||||||
return JSONResponse(content=processed_events)
|
return JSONResponse(content=processed_events)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/events/summary")
|
@router.get("/events/summary", dependencies=[Depends(allow_any_authenticated())])
|
||||||
def events_summary(
|
def events_summary(
|
||||||
params: EventsSummaryQueryParams = Depends(),
|
params: EventsSummaryQueryParams = Depends(),
|
||||||
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
|
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import logging
|
|||||||
import re
|
import re
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import Depends, FastAPI, Request
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from joserfc.jwk import OctKey
|
from joserfc.jwk import OctKey
|
||||||
from playhouse.sqliteq import SqliteQueueDatabase
|
from playhouse.sqliteq import SqliteQueueDatabase
|
||||||
@ -24,7 +24,7 @@ from frigate.api import (
|
|||||||
preview,
|
preview,
|
||||||
review,
|
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 (
|
from frigate.comms.event_metadata_updater import (
|
||||||
EventMetadataPublisher,
|
EventMetadataPublisher,
|
||||||
)
|
)
|
||||||
@ -62,11 +62,15 @@ def create_fastapi_app(
|
|||||||
stats_emitter: StatsEmitter,
|
stats_emitter: StatsEmitter,
|
||||||
event_metadata_updater: EventMetadataPublisher,
|
event_metadata_updater: EventMetadataPublisher,
|
||||||
config_publisher: CameraConfigUpdatePublisher,
|
config_publisher: CameraConfigUpdatePublisher,
|
||||||
|
enforce_default_admin: bool = True,
|
||||||
):
|
):
|
||||||
logger.info("Starting FastAPI app")
|
logger.info("Starting FastAPI app")
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
debug=False,
|
debug=False,
|
||||||
swagger_ui_parameters={"apisSorter": "alpha", "operationsSorter": "alpha"},
|
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
|
# 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 peewee import DoesNotExist, fn, operator
|
||||||
from tzlocal import get_localzone_name
|
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 (
|
from frigate.api.defs.query.media_query_parameters import (
|
||||||
Extension,
|
Extension,
|
||||||
MediaEventsSnapshotQueryParams,
|
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):
|
def get_recordings_storage_usage(request: Request):
|
||||||
recording_stats = request.app.stats_emitter.get_latest_stats()["service"][
|
recording_stats = request.app.stats_emitter.get_latest_stats()["service"][
|
||||||
"storage"
|
"storage"
|
||||||
@ -417,7 +421,7 @@ def get_recordings_storage_usage(request: Request):
|
|||||||
return JSONResponse(content=camera_usages)
|
return JSONResponse(content=camera_usages)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/recordings/summary")
|
@router.get("/recordings/summary", dependencies=[Depends(allow_any_authenticated())])
|
||||||
def all_recordings_summary(
|
def all_recordings_summary(
|
||||||
request: Request,
|
request: Request,
|
||||||
params: MediaRecordingsSummaryQueryParams = Depends(),
|
params: MediaRecordingsSummaryQueryParams = Depends(),
|
||||||
@ -635,7 +639,11 @@ async def recordings(
|
|||||||
return JSONResponse(content=list(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(
|
async def no_recordings(
|
||||||
request: Request,
|
request: Request,
|
||||||
params: MediaRecordingsAvailabilityQueryParams = Depends(),
|
params: MediaRecordingsAvailabilityQueryParams = Depends(),
|
||||||
@ -1053,7 +1061,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(
|
async def event_thumbnail(
|
||||||
request: Request,
|
request: Request,
|
||||||
event_id: str,
|
event_id: str,
|
||||||
@ -1251,7 +1262,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):
|
def event_snapshot_clean(request: Request, event_id: str, download: bool = False):
|
||||||
webp_bytes = None
|
webp_bytes = None
|
||||||
try:
|
try:
|
||||||
@ -1375,7 +1389,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(
|
async def event_clip(
|
||||||
request: Request,
|
request: Request,
|
||||||
event_id: str,
|
event_id: str,
|
||||||
@ -1403,7 +1419,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):
|
def event_preview(request: Request, event_id: str):
|
||||||
try:
|
try:
|
||||||
event: Event = Event.get(Event.id == event_id)
|
event: Event = Event.get(Event.id == event_id)
|
||||||
@ -1756,7 +1774,7 @@ def preview_mp4(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/review/{event_id}/preview")
|
@router.get("/review/{event_id}/preview", dependencies=[Depends(require_camera_access)])
|
||||||
def review_preview(
|
def review_preview(
|
||||||
request: Request,
|
request: Request,
|
||||||
event_id: str,
|
event_id: str,
|
||||||
@ -1782,8 +1800,12 @@ def review_preview(
|
|||||||
return preview_mp4(request, review.camera, start_ts, end_ts)
|
return preview_mp4(request, review.camera, start_ts, end_ts)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/preview/{file_name}/thumbnail.jpg")
|
@router.get(
|
||||||
@router.get("/preview/{file_name}/thumbnail.webp")
|
"/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):
|
def preview_thumbnail(file_name: str):
|
||||||
"""Get a thumbnail from the cached preview frames."""
|
"""Get a thumbnail from the cached preview frames."""
|
||||||
if len(file_name) > 1000:
|
if len(file_name) > 1000:
|
||||||
|
|||||||
@ -14,6 +14,7 @@ from peewee import Case, DoesNotExist, IntegrityError, fn, operator
|
|||||||
from playhouse.shortcuts import model_to_dict
|
from playhouse.shortcuts import model_to_dict
|
||||||
|
|
||||||
from frigate.api.auth import (
|
from frigate.api.auth import (
|
||||||
|
allow_any_authenticated,
|
||||||
get_allowed_cameras_for_filter,
|
get_allowed_cameras_for_filter,
|
||||||
get_current_user,
|
get_current_user,
|
||||||
require_camera_access,
|
require_camera_access,
|
||||||
@ -43,7 +44,11 @@ logger = logging.getLogger(__name__)
|
|||||||
router = APIRouter(tags=[Tags.review])
|
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(
|
async def review(
|
||||||
params: ReviewQueryParams = Depends(),
|
params: ReviewQueryParams = Depends(),
|
||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
@ -152,7 +157,11 @@ async def review(
|
|||||||
return JSONResponse(content=[r for r in review_query])
|
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):
|
async def review_ids(request: Request, ids: str):
|
||||||
ids = ids.split(",")
|
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(
|
async def review_summary(
|
||||||
params: ReviewSummaryQueryParams = Depends(),
|
params: ReviewSummaryQueryParams = Depends(),
|
||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
@ -461,7 +474,11 @@ async def review_summary(
|
|||||||
return JSONResponse(content=data)
|
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(
|
async def set_multiple_reviewed(
|
||||||
request: Request,
|
request: Request,
|
||||||
body: ReviewModifyMultipleBody,
|
body: ReviewModifyMultipleBody,
|
||||||
@ -644,7 +661,11 @@ def motion_activity(
|
|||||||
return JSONResponse(content=normalized)
|
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):
|
async def get_review_from_event(request: Request, event_id: str):
|
||||||
try:
|
try:
|
||||||
review = ReviewSegment.get(
|
review = ReviewSegment.get(
|
||||||
@ -659,7 +680,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):
|
async def get_review(request: Request, review_id: str):
|
||||||
try:
|
try:
|
||||||
review = ReviewSegment.get(ReviewSegment.id == review_id)
|
review = ReviewSegment.get(ReviewSegment.id == review_id)
|
||||||
@ -672,7 +697,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(
|
async def set_not_reviewed(
|
||||||
review_id: str,
|
review_id: str,
|
||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
|
|||||||
@ -375,7 +375,19 @@ class WebPushClient(Communicator):
|
|||||||
ended = state == "end" or state == "genai"
|
ended = state == "end" or state == "genai"
|
||||||
|
|
||||||
if state == "genai" and payload["after"]["data"]["metadata"]:
|
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"]
|
message = payload["after"]["data"]["metadata"]["scene"]
|
||||||
else:
|
else:
|
||||||
title = f"{titlecase(', '.join(sorted_objects).replace('_', ' '))}{' was' if state == 'end' else ''} detected in {titlecase(', '.join(payload['after']['data']['zones']).replace('_', ' '))}"
|
title = f"{titlecase(', '.join(sorted_objects).replace('_', ' '))}{' was' if state == 'end' else ''} detected in {titlecase(', '.join(payload['after']['data']['zones']).replace('_', ' '))}"
|
||||||
|
|||||||
@ -12,6 +12,7 @@ from typing import Any
|
|||||||
|
|
||||||
import cv2
|
import cv2
|
||||||
from peewee import DoesNotExist
|
from peewee import DoesNotExist
|
||||||
|
from titlecase import titlecase
|
||||||
|
|
||||||
from frigate.comms.embeddings_updater import EmbeddingsRequestEnum
|
from frigate.comms.embeddings_updater import EmbeddingsRequestEnum
|
||||||
from frigate.comms.inter_process import InterProcessRequestor
|
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"]):
|
for i, verified_label in enumerate(final_data["data"]["verified_objects"]):
|
||||||
object_type = verified_label.replace("-verified", "").replace("_", " ")
|
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})")
|
unified_objects.append(f"{name} ({object_type})")
|
||||||
|
|
||||||
for label in objects_list:
|
for label in objects_list:
|
||||||
if "-verified" in label:
|
if "-verified" in label:
|
||||||
continue
|
continue
|
||||||
elif label in labelmap_objects:
|
elif label in labelmap_objects:
|
||||||
object_type = label.replace("_", " ").title()
|
object_type = titlecase(label.replace("_", " "))
|
||||||
|
|
||||||
if label in attribute_labels:
|
if label in attribute_labels:
|
||||||
unified_objects.append(f"{object_type} (delivery/service)")
|
unified_objects.append(f"{object_type} (delivery/service)")
|
||||||
|
|||||||
@ -405,9 +405,6 @@ class CustomObjectClassificationProcessor(RealTimeProcessorApi):
|
|||||||
if obj_data.get("end_time") is not None:
|
if obj_data.get("end_time") is not None:
|
||||||
return
|
return
|
||||||
|
|
||||||
if obj_data.get("stationary"):
|
|
||||||
return
|
|
||||||
|
|
||||||
object_id = obj_data["id"]
|
object_id = obj_data["id"]
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|||||||
@ -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).
|
- Group bullets under subheadings when multiple events fall into the same category (e.g., Vehicle Activity, Porch Activity, Unusual Behavior).
|
||||||
|
|
||||||
- Threat levels
|
- 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.
|
- If multiple events at the same time share the same threat level, only state it once.
|
||||||
|
|
||||||
- Final assessment
|
- Final assessment
|
||||||
- End with a Final Assessment section.
|
- 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.
|
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
|
- Conciseness
|
||||||
- Do not repeat benign clothing/appearance details unless they distinguish individuals.
|
- Do not repeat benign clothing/appearance details unless they distinguish individuals.
|
||||||
|
|||||||
@ -3,6 +3,8 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
from peewee_migrate import Router
|
from peewee_migrate import Router
|
||||||
from playhouse.sqlite_ext import SqliteExtDatabase
|
from playhouse.sqlite_ext import SqliteExtDatabase
|
||||||
from playhouse.sqliteq import SqliteQueueDatabase
|
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
|
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):
|
class BaseTestHttp(unittest.TestCase):
|
||||||
def setUp(self, models):
|
def setUp(self, models):
|
||||||
# setup clean database for each test run
|
# setup clean database for each test run
|
||||||
@ -113,7 +129,9 @@ class BaseTestHttp(unittest.TestCase):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
def create_app(self, stats=None, event_metadata_publisher=None):
|
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),
|
FrigateConfig(**self.minimal_config),
|
||||||
self.db,
|
self.db,
|
||||||
None,
|
None,
|
||||||
@ -123,8 +141,33 @@ class BaseTestHttp(unittest.TestCase):
|
|||||||
stats,
|
stats,
|
||||||
event_metadata_publisher,
|
event_metadata_publisher,
|
||||||
None,
|
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(
|
def insert_mock_event(
|
||||||
self,
|
self,
|
||||||
id: str,
|
id: str,
|
||||||
|
|||||||
@ -1,10 +1,8 @@
|
|||||||
from unittest.mock import Mock
|
from unittest.mock import Mock
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
|
|
||||||
from frigate.models import Event, Recordings, ReviewSegment
|
from frigate.models import Event, Recordings, ReviewSegment
|
||||||
from frigate.stats.emitter import StatsEmitter
|
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):
|
class TestHttpApp(BaseTestHttp):
|
||||||
@ -20,7 +18,7 @@ class TestHttpApp(BaseTestHttp):
|
|||||||
stats.get_latest_stats.return_value = self.test_stats
|
stats.get_latest_stats.return_value = self.test_stats
|
||||||
app = super().create_app(stats)
|
app = super().create_app(stats)
|
||||||
|
|
||||||
with TestClient(app) as client:
|
with AuthTestClient(app) as client:
|
||||||
response = client.get("/stats")
|
response = client.get("/stats")
|
||||||
response_json = response.json()
|
response_json = response.json()
|
||||||
assert response_json == self.test_stats
|
assert response_json == self.test_stats
|
||||||
|
|||||||
@ -1,14 +1,13 @@
|
|||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from fastapi import HTTPException, Request
|
from fastapi import HTTPException, Request
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
|
|
||||||
from frigate.api.auth import (
|
from frigate.api.auth import (
|
||||||
get_allowed_cameras_for_filter,
|
get_allowed_cameras_for_filter,
|
||||||
get_current_user,
|
get_current_user,
|
||||||
)
|
)
|
||||||
from frigate.models import Event, Recordings, ReviewSegment
|
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):
|
class TestCameraAccessEventReview(BaseTestHttp):
|
||||||
@ -16,9 +15,17 @@ class TestCameraAccessEventReview(BaseTestHttp):
|
|||||||
super().setUp([Event, ReviewSegment, Recordings])
|
super().setUp([Event, ReviewSegment, Recordings])
|
||||||
self.app = super().create_app()
|
self.app = super().create_app()
|
||||||
|
|
||||||
# Mock get_current_user to return valid user for all tests
|
# Mock get_current_user for all tests
|
||||||
async def mock_get_current_user():
|
async def mock_get_current_user(request: Request):
|
||||||
return {"username": "test_user", "role": "user"}
|
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_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("event1", camera="front_door")
|
||||||
super().insert_mock_event("event2", camera="back_door")
|
super().insert_mock_event("event2", camera="back_door")
|
||||||
|
|
||||||
self.app.dependency_overrides[get_allowed_cameras_for_filter] = lambda: [
|
async def mock_cameras(request: Request):
|
||||||
"front_door"
|
return ["front_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")
|
resp = client.get("/events")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
ids = [e["id"] for e in resp.json()]
|
ids = [e["id"] for e in resp.json()]
|
||||||
assert "event1" in ids
|
assert "event1" in ids
|
||||||
assert "event2" not in ids
|
assert "event2" not in ids
|
||||||
|
|
||||||
self.app.dependency_overrides[get_allowed_cameras_for_filter] = lambda: [
|
async def mock_cameras(request: Request):
|
||||||
"front_door",
|
return [
|
||||||
"back_door",
|
"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")
|
resp = client.get("/events")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
ids = [e["id"] for e in resp.json()]
|
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("rev1", camera="front_door")
|
||||||
super().insert_mock_review_segment("rev2", camera="back_door")
|
super().insert_mock_review_segment("rev2", camera="back_door")
|
||||||
|
|
||||||
self.app.dependency_overrides[get_allowed_cameras_for_filter] = lambda: [
|
async def mock_cameras(request: Request):
|
||||||
"front_door"
|
return ["front_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")
|
resp = client.get("/review")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
ids = [r["id"] for r in resp.json()]
|
ids = [r["id"] for r in resp.json()]
|
||||||
assert "rev1" in ids
|
assert "rev1" in ids
|
||||||
assert "rev2" not in ids
|
assert "rev2" not in ids
|
||||||
|
|
||||||
self.app.dependency_overrides[get_allowed_cameras_for_filter] = lambda: [
|
async def mock_cameras(request: Request):
|
||||||
"front_door",
|
return [
|
||||||
"back_door",
|
"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")
|
resp = client.get("/review")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
ids = [r["id"] for r in resp.json()]
|
ids = [r["id"] for r in resp.json()]
|
||||||
@ -84,7 +99,7 @@ class TestCameraAccessEventReview(BaseTestHttp):
|
|||||||
raise HTTPException(status_code=403, detail="Access denied")
|
raise HTTPException(status_code=403, detail="Access denied")
|
||||||
|
|
||||||
with patch("frigate.api.event.require_camera_access", mock_require_allowed):
|
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")
|
resp = client.get("/events/event1")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert resp.json()["id"] == "event1"
|
assert resp.json()["id"] == "event1"
|
||||||
@ -94,7 +109,7 @@ class TestCameraAccessEventReview(BaseTestHttp):
|
|||||||
raise HTTPException(status_code=403, detail="Access denied")
|
raise HTTPException(status_code=403, detail="Access denied")
|
||||||
|
|
||||||
with patch("frigate.api.event.require_camera_access", mock_require_disallowed):
|
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")
|
resp = client.get("/events/event1")
|
||||||
assert resp.status_code == 403
|
assert resp.status_code == 403
|
||||||
|
|
||||||
@ -108,7 +123,7 @@ class TestCameraAccessEventReview(BaseTestHttp):
|
|||||||
raise HTTPException(status_code=403, detail="Access denied")
|
raise HTTPException(status_code=403, detail="Access denied")
|
||||||
|
|
||||||
with patch("frigate.api.review.require_camera_access", mock_require_allowed):
|
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")
|
resp = client.get("/review/rev1")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert resp.json()["id"] == "rev1"
|
assert resp.json()["id"] == "rev1"
|
||||||
@ -118,7 +133,7 @@ class TestCameraAccessEventReview(BaseTestHttp):
|
|||||||
raise HTTPException(status_code=403, detail="Access denied")
|
raise HTTPException(status_code=403, detail="Access denied")
|
||||||
|
|
||||||
with patch("frigate.api.review.require_camera_access", mock_require_disallowed):
|
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")
|
resp = client.get("/review/rev1")
|
||||||
assert resp.status_code == 403
|
assert resp.status_code == 403
|
||||||
|
|
||||||
@ -126,21 +141,25 @@ class TestCameraAccessEventReview(BaseTestHttp):
|
|||||||
super().insert_mock_event("event1", camera="front_door")
|
super().insert_mock_event("event1", camera="front_door")
|
||||||
super().insert_mock_event("event2", camera="back_door")
|
super().insert_mock_event("event2", camera="back_door")
|
||||||
|
|
||||||
self.app.dependency_overrides[get_allowed_cameras_for_filter] = lambda: [
|
async def mock_cameras(request: Request):
|
||||||
"front_door"
|
return ["front_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"})
|
resp = client.get("/events", params={"cameras": "all"})
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
ids = [e["id"] for e in resp.json()]
|
ids = [e["id"] for e in resp.json()]
|
||||||
assert "event1" in ids
|
assert "event1" in ids
|
||||||
assert "event2" not in ids
|
assert "event2" not in ids
|
||||||
|
|
||||||
self.app.dependency_overrides[get_allowed_cameras_for_filter] = lambda: [
|
async def mock_cameras(request: Request):
|
||||||
"front_door",
|
return [
|
||||||
"back_door",
|
"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"})
|
resp = client.get("/events", params={"cameras": "all"})
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
ids = [e["id"] for e in resp.json()]
|
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("event1", camera="front_door")
|
||||||
super().insert_mock_event("event2", camera="back_door")
|
super().insert_mock_event("event2", camera="back_door")
|
||||||
|
|
||||||
self.app.dependency_overrides[get_allowed_cameras_for_filter] = lambda: [
|
async def mock_cameras(request: Request):
|
||||||
"front_door"
|
return ["front_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")
|
resp = client.get("/events/summary")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
summary_list = resp.json()
|
summary_list = resp.json()
|
||||||
assert len(summary_list) == 1
|
assert len(summary_list) == 1
|
||||||
|
|
||||||
self.app.dependency_overrides[get_allowed_cameras_for_filter] = lambda: [
|
async def mock_cameras(request: Request):
|
||||||
"front_door",
|
return [
|
||||||
"back_door",
|
"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")
|
resp = client.get("/events/summary")
|
||||||
summary_list = resp.json()
|
summary_list = resp.json()
|
||||||
assert len(summary_list) == 2
|
assert len(summary_list) == 2
|
||||||
|
|||||||
@ -2,14 +2,13 @@ from datetime import datetime
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import Mock
|
from unittest.mock import Mock
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
from playhouse.shortcuts import model_to_dict
|
from playhouse.shortcuts import model_to_dict
|
||||||
|
|
||||||
from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user
|
from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user
|
||||||
from frigate.comms.event_metadata_updater import EventMetadataPublisher
|
from frigate.comms.event_metadata_updater import EventMetadataPublisher
|
||||||
from frigate.models import Event, Recordings, ReviewSegment, Timeline
|
from frigate.models import Event, Recordings, ReviewSegment, Timeline
|
||||||
from frigate.stats.emitter import StatsEmitter
|
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
|
from frigate.test.test_storage import _insert_mock_event
|
||||||
|
|
||||||
|
|
||||||
@ -18,14 +17,26 @@ class TestHttpApp(BaseTestHttp):
|
|||||||
super().setUp([Event, Recordings, ReviewSegment, Timeline])
|
super().setUp([Event, Recordings, ReviewSegment, Timeline])
|
||||||
self.app = super().create_app()
|
self.app = super().create_app()
|
||||||
|
|
||||||
# Mock auth to bypass camera access for tests
|
# Mock get_current_user for all tests
|
||||||
async def mock_get_current_user(request: Any):
|
async def mock_get_current_user(request: Request):
|
||||||
return {"username": "test_user", "role": "admin"}
|
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_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):
|
def tearDown(self):
|
||||||
self.app.dependency_overrides.clear()
|
self.app.dependency_overrides.clear()
|
||||||
@ -35,20 +46,20 @@ class TestHttpApp(BaseTestHttp):
|
|||||||
################################### GET /events Endpoint #########################################################
|
################################### GET /events Endpoint #########################################################
|
||||||
####################################################################################################################
|
####################################################################################################################
|
||||||
def test_get_event_list_no_events(self):
|
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()
|
events = client.get("/events").json()
|
||||||
assert len(events) == 0
|
assert len(events) == 0
|
||||||
|
|
||||||
def test_get_event_list_no_match_event_id(self):
|
def test_get_event_list_no_match_event_id(self):
|
||||||
id = "123456.random"
|
id = "123456.random"
|
||||||
with TestClient(self.app) as client:
|
with AuthTestClient(self.app) as client:
|
||||||
super().insert_mock_event(id)
|
super().insert_mock_event(id)
|
||||||
events = client.get("/events", params={"event_id": "abc"}).json()
|
events = client.get("/events", params={"event_id": "abc"}).json()
|
||||||
assert len(events) == 0
|
assert len(events) == 0
|
||||||
|
|
||||||
def test_get_event_list_match_event_id(self):
|
def test_get_event_list_match_event_id(self):
|
||||||
id = "123456.random"
|
id = "123456.random"
|
||||||
with TestClient(self.app) as client:
|
with AuthTestClient(self.app) as client:
|
||||||
super().insert_mock_event(id)
|
super().insert_mock_event(id)
|
||||||
events = client.get("/events", params={"event_id": id}).json()
|
events = client.get("/events", params={"event_id": id}).json()
|
||||||
assert len(events) == 1
|
assert len(events) == 1
|
||||||
@ -58,7 +69,7 @@ class TestHttpApp(BaseTestHttp):
|
|||||||
now = int(datetime.now().timestamp())
|
now = int(datetime.now().timestamp())
|
||||||
|
|
||||||
id = "123456.random"
|
id = "123456.random"
|
||||||
with TestClient(self.app) as client:
|
with AuthTestClient(self.app) as client:
|
||||||
super().insert_mock_event(id, now, now + 1)
|
super().insert_mock_event(id, now, now + 1)
|
||||||
events = client.get(
|
events = client.get(
|
||||||
"/events", params={"max_length": 1, "min_length": 1}
|
"/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):
|
def test_get_event_list_no_match_max_length(self):
|
||||||
now = int(datetime.now().timestamp())
|
now = int(datetime.now().timestamp())
|
||||||
|
|
||||||
with TestClient(self.app) as client:
|
with AuthTestClient(self.app) as client:
|
||||||
id = "123456.random"
|
id = "123456.random"
|
||||||
super().insert_mock_event(id, now, now + 2)
|
super().insert_mock_event(id, now, now + 2)
|
||||||
events = client.get("/events", params={"max_length": 1}).json()
|
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):
|
def test_get_event_list_no_match_min_length(self):
|
||||||
now = int(datetime.now().timestamp())
|
now = int(datetime.now().timestamp())
|
||||||
|
|
||||||
with TestClient(self.app) as client:
|
with AuthTestClient(self.app) as client:
|
||||||
id = "123456.random"
|
id = "123456.random"
|
||||||
super().insert_mock_event(id, now, now + 2)
|
super().insert_mock_event(id, now, now + 2)
|
||||||
events = client.get("/events", params={"min_length": 3}).json()
|
events = client.get("/events", params={"min_length": 3}).json()
|
||||||
@ -88,7 +99,7 @@ class TestHttpApp(BaseTestHttp):
|
|||||||
id = "123456.random"
|
id = "123456.random"
|
||||||
id2 = "54321.random"
|
id2 = "54321.random"
|
||||||
|
|
||||||
with TestClient(self.app) as client:
|
with AuthTestClient(self.app) as client:
|
||||||
super().insert_mock_event(id)
|
super().insert_mock_event(id)
|
||||||
events = client.get("/events").json()
|
events = client.get("/events").json()
|
||||||
assert len(events) == 1
|
assert len(events) == 1
|
||||||
@ -108,14 +119,14 @@ class TestHttpApp(BaseTestHttp):
|
|||||||
def test_get_event_list_no_match_has_clip(self):
|
def test_get_event_list_no_match_has_clip(self):
|
||||||
now = int(datetime.now().timestamp())
|
now = int(datetime.now().timestamp())
|
||||||
|
|
||||||
with TestClient(self.app) as client:
|
with AuthTestClient(self.app) as client:
|
||||||
id = "123456.random"
|
id = "123456.random"
|
||||||
super().insert_mock_event(id, now, now + 2)
|
super().insert_mock_event(id, now, now + 2)
|
||||||
events = client.get("/events", params={"has_clip": 0}).json()
|
events = client.get("/events", params={"has_clip": 0}).json()
|
||||||
assert len(events) == 0
|
assert len(events) == 0
|
||||||
|
|
||||||
def test_get_event_list_has_clip(self):
|
def test_get_event_list_has_clip(self):
|
||||||
with TestClient(self.app) as client:
|
with AuthTestClient(self.app) as client:
|
||||||
id = "123456.random"
|
id = "123456.random"
|
||||||
super().insert_mock_event(id, has_clip=True)
|
super().insert_mock_event(id, has_clip=True)
|
||||||
events = client.get("/events", params={"has_clip": 1}).json()
|
events = client.get("/events", params={"has_clip": 1}).json()
|
||||||
@ -123,7 +134,7 @@ class TestHttpApp(BaseTestHttp):
|
|||||||
assert events[0]["id"] == id
|
assert events[0]["id"] == id
|
||||||
|
|
||||||
def test_get_event_list_sort_score(self):
|
def test_get_event_list_sort_score(self):
|
||||||
with TestClient(self.app) as client:
|
with AuthTestClient(self.app) as client:
|
||||||
id = "123456.random"
|
id = "123456.random"
|
||||||
id2 = "54321.random"
|
id2 = "54321.random"
|
||||||
super().insert_mock_event(id, top_score=37, score=37, data={"score": 50})
|
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):
|
def test_get_event_list_sort_start_time(self):
|
||||||
now = int(datetime.now().timestamp())
|
now = int(datetime.now().timestamp())
|
||||||
|
|
||||||
with TestClient(self.app) as client:
|
with AuthTestClient(self.app) as client:
|
||||||
id = "123456.random"
|
id = "123456.random"
|
||||||
id2 = "54321.random"
|
id2 = "54321.random"
|
||||||
super().insert_mock_event(id, start_time=now + 3)
|
super().insert_mock_event(id, start_time=now + 3)
|
||||||
@ -159,7 +170,7 @@ class TestHttpApp(BaseTestHttp):
|
|||||||
def test_get_good_event(self):
|
def test_get_good_event(self):
|
||||||
id = "123456.random"
|
id = "123456.random"
|
||||||
|
|
||||||
with TestClient(self.app) as client:
|
with AuthTestClient(self.app) as client:
|
||||||
super().insert_mock_event(id)
|
super().insert_mock_event(id)
|
||||||
event = client.get(f"/events/{id}").json()
|
event = client.get(f"/events/{id}").json()
|
||||||
|
|
||||||
@ -171,7 +182,7 @@ class TestHttpApp(BaseTestHttp):
|
|||||||
id = "123456.random"
|
id = "123456.random"
|
||||||
bad_id = "654321.other"
|
bad_id = "654321.other"
|
||||||
|
|
||||||
with TestClient(self.app) as client:
|
with AuthTestClient(self.app) as client:
|
||||||
super().insert_mock_event(id)
|
super().insert_mock_event(id)
|
||||||
event_response = client.get(f"/events/{bad_id}")
|
event_response = client.get(f"/events/{bad_id}")
|
||||||
assert event_response.status_code == 404
|
assert event_response.status_code == 404
|
||||||
@ -180,7 +191,7 @@ class TestHttpApp(BaseTestHttp):
|
|||||||
def test_delete_event(self):
|
def test_delete_event(self):
|
||||||
id = "123456.random"
|
id = "123456.random"
|
||||||
|
|
||||||
with TestClient(self.app) as client:
|
with AuthTestClient(self.app) as client:
|
||||||
super().insert_mock_event(id)
|
super().insert_mock_event(id)
|
||||||
event = client.get(f"/events/{id}").json()
|
event = client.get(f"/events/{id}").json()
|
||||||
assert event
|
assert event
|
||||||
@ -193,7 +204,7 @@ class TestHttpApp(BaseTestHttp):
|
|||||||
def test_event_retention(self):
|
def test_event_retention(self):
|
||||||
id = "123456.random"
|
id = "123456.random"
|
||||||
|
|
||||||
with TestClient(self.app) as client:
|
with AuthTestClient(self.app) as client:
|
||||||
super().insert_mock_event(id)
|
super().insert_mock_event(id)
|
||||||
client.post(f"/events/{id}/retain", headers={"remote-role": "admin"})
|
client.post(f"/events/{id}/retain", headers={"remote-role": "admin"})
|
||||||
event = client.get(f"/events/{id}").json()
|
event = client.get(f"/events/{id}").json()
|
||||||
@ -212,12 +223,11 @@ class TestHttpApp(BaseTestHttp):
|
|||||||
morning = 1656590400 # 06/30/2022 6 am (GMT)
|
morning = 1656590400 # 06/30/2022 6 am (GMT)
|
||||||
evening = 1656633600 # 06/30/2022 6 pm (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(morning_id, morning)
|
||||||
super().insert_mock_event(evening_id, evening)
|
super().insert_mock_event(evening_id, evening)
|
||||||
# both events come back
|
# both events come back
|
||||||
events = client.get("/events").json()
|
events = client.get("/events").json()
|
||||||
print("events!!!", events)
|
|
||||||
assert events
|
assert events
|
||||||
assert len(events) == 2
|
assert len(events) == 2
|
||||||
# morning event is excluded
|
# morning event is excluded
|
||||||
@ -248,7 +258,7 @@ class TestHttpApp(BaseTestHttp):
|
|||||||
|
|
||||||
mock_event_updater.publish.side_effect = update_event
|
mock_event_updater.publish.side_effect = update_event
|
||||||
|
|
||||||
with TestClient(app) as client:
|
with AuthTestClient(app) as client:
|
||||||
super().insert_mock_event(id)
|
super().insert_mock_event(id)
|
||||||
new_sub_label_response = client.post(
|
new_sub_label_response = client.post(
|
||||||
f"/events/{id}/sub_label",
|
f"/events/{id}/sub_label",
|
||||||
@ -285,7 +295,7 @@ class TestHttpApp(BaseTestHttp):
|
|||||||
|
|
||||||
mock_event_updater.publish.side_effect = update_event
|
mock_event_updater.publish.side_effect = update_event
|
||||||
|
|
||||||
with TestClient(app) as client:
|
with AuthTestClient(app) as client:
|
||||||
super().insert_mock_event(id)
|
super().insert_mock_event(id)
|
||||||
client.post(
|
client.post(
|
||||||
f"/events/{id}/sub_label",
|
f"/events/{id}/sub_label",
|
||||||
@ -301,7 +311,7 @@ class TestHttpApp(BaseTestHttp):
|
|||||||
####################################################################################################################
|
####################################################################################################################
|
||||||
def test_get_metrics(self):
|
def test_get_metrics(self):
|
||||||
"""ensure correct prometheus metrics api response"""
|
"""ensure correct prometheus metrics api response"""
|
||||||
with TestClient(self.app) as client:
|
with AuthTestClient(self.app) as client:
|
||||||
ts_start = datetime.now().timestamp()
|
ts_start = datetime.now().timestamp()
|
||||||
ts_end = ts_start + 30
|
ts_end = ts_start + 30
|
||||||
_insert_mock_event(
|
_insert_mock_event(
|
||||||
|
|||||||
@ -1,14 +1,13 @@
|
|||||||
"""Unit tests for recordings/media API endpoints."""
|
"""Unit tests for recordings/media API endpoints."""
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import pytz
|
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.api.auth import get_allowed_cameras_for_filter, get_current_user
|
||||||
from frigate.models import Recordings
|
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):
|
class TestHttpMedia(BaseTestHttp):
|
||||||
@ -19,15 +18,26 @@ class TestHttpMedia(BaseTestHttp):
|
|||||||
super().setUp([Recordings])
|
super().setUp([Recordings])
|
||||||
self.app = super().create_app()
|
self.app = super().create_app()
|
||||||
|
|
||||||
# Mock auth to bypass camera access for tests
|
# Mock get_current_user for all tests
|
||||||
async def mock_get_current_user(request: Any):
|
async def mock_get_current_user(request: Request):
|
||||||
return {"username": "test_user", "role": "admin"}
|
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_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):
|
||||||
"back_door",
|
return ["front_door"]
|
||||||
]
|
|
||||||
|
self.app.dependency_overrides[get_allowed_cameras_for_filter] = (
|
||||||
|
mock_get_allowed_cameras_for_filter
|
||||||
|
)
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
"""Clean up after tests."""
|
"""Clean up after tests."""
|
||||||
@ -52,7 +62,7 @@ class TestHttpMedia(BaseTestHttp):
|
|||||||
# March 11, 2024 at 12:00 PM EDT (after DST)
|
# March 11, 2024 at 12:00 PM EDT (after DST)
|
||||||
march_11_noon = tz.localize(datetime(2024, 3, 11, 12, 0, 0)).timestamp()
|
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
|
# Insert recordings for each day
|
||||||
Recordings.insert(
|
Recordings.insert(
|
||||||
id="recording_march_9",
|
id="recording_march_9",
|
||||||
@ -128,7 +138,7 @@ class TestHttpMedia(BaseTestHttp):
|
|||||||
# November 4, 2024 at 12:00 PM EST (after DST)
|
# November 4, 2024 at 12:00 PM EST (after DST)
|
||||||
nov_4_noon = tz.localize(datetime(2024, 11, 4, 12, 0, 0)).timestamp()
|
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
|
# Insert recordings for each day
|
||||||
Recordings.insert(
|
Recordings.insert(
|
||||||
id="recording_nov_2",
|
id="recording_nov_2",
|
||||||
@ -195,7 +205,15 @@ class TestHttpMedia(BaseTestHttp):
|
|||||||
# March 10, 2024 at 3:00 PM EDT (after DST transition)
|
# March 10, 2024 at 3:00 PM EDT (after DST transition)
|
||||||
march_10_afternoon = tz.localize(datetime(2024, 3, 10, 15, 0, 0)).timestamp()
|
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
|
# Insert recordings for front_door on March 9
|
||||||
Recordings.insert(
|
Recordings.insert(
|
||||||
id="front_march_9",
|
id="front_march_9",
|
||||||
@ -236,6 +254,14 @@ class TestHttpMedia(BaseTestHttp):
|
|||||||
assert summary["2024-03-09"] is True
|
assert summary["2024-03-09"] is True
|
||||||
assert summary["2024-03-10"] 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):
|
def test_recordings_summary_at_dst_transition_time(self):
|
||||||
"""
|
"""
|
||||||
Test recordings that span the exact DST transition time.
|
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
|
# 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()
|
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(
|
Recordings.insert(
|
||||||
id="recording_during_transition",
|
id="recording_during_transition",
|
||||||
path="/media/recordings/transition.mp4",
|
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_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()
|
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(
|
Recordings.insert(
|
||||||
id="recording_march_9_utc",
|
id="recording_march_9_utc",
|
||||||
path="/media/recordings/march_9_utc.mp4",
|
path="/media/recordings/march_9_utc.mp4",
|
||||||
@ -325,7 +351,7 @@ class TestHttpMedia(BaseTestHttp):
|
|||||||
"""
|
"""
|
||||||
Test recordings summary when no recordings exist.
|
Test recordings summary when no recordings exist.
|
||||||
"""
|
"""
|
||||||
with TestClient(self.app) as client:
|
with AuthTestClient(self.app) as client:
|
||||||
response = client.get(
|
response = client.get(
|
||||||
"/recordings/summary",
|
"/recordings/summary",
|
||||||
params={"timezone": "America/New_York", "cameras": "all"},
|
params={"timezone": "America/New_York", "cameras": "all"},
|
||||||
@ -342,7 +368,7 @@ class TestHttpMedia(BaseTestHttp):
|
|||||||
tz = pytz.timezone("America/New_York")
|
tz = pytz.timezone("America/New_York")
|
||||||
march_10_noon = tz.localize(datetime(2024, 3, 10, 12, 0, 0)).timestamp()
|
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
|
# Insert recordings for both cameras
|
||||||
Recordings.insert(
|
Recordings.insert(
|
||||||
id="front_recording",
|
id="front_recording",
|
||||||
|
|||||||
@ -1,12 +1,12 @@
|
|||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi import Request
|
||||||
from peewee import DoesNotExist
|
from peewee import DoesNotExist
|
||||||
|
|
||||||
from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user
|
from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user
|
||||||
from frigate.models import Event, Recordings, ReviewSegment, UserReviewStatus
|
from frigate.models import Event, Recordings, ReviewSegment, UserReviewStatus
|
||||||
from frigate.review.types import SeverityEnum
|
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):
|
class TestHttpReview(BaseTestHttp):
|
||||||
@ -16,14 +16,26 @@ class TestHttpReview(BaseTestHttp):
|
|||||||
self.user_id = "admin"
|
self.user_id = "admin"
|
||||||
|
|
||||||
# Mock get_current_user for all tests
|
# Mock get_current_user for all tests
|
||||||
async def mock_get_current_user():
|
# This mock uses headers set by AuthTestClient
|
||||||
return {"username": self.user_id, "role": "admin"}
|
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_current_user] = mock_get_current_user
|
||||||
|
|
||||||
self.app.dependency_overrides[get_allowed_cameras_for_filter] = lambda: [
|
async def mock_get_allowed_cameras_for_filter(request: Request):
|
||||||
"front_door"
|
return ["front_door"]
|
||||||
]
|
|
||||||
|
self.app.dependency_overrides[get_allowed_cameras_for_filter] = (
|
||||||
|
mock_get_allowed_cameras_for_filter
|
||||||
|
)
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
self.app.dependency_overrides.clear()
|
self.app.dependency_overrides.clear()
|
||||||
@ -57,7 +69,7 @@ class TestHttpReview(BaseTestHttp):
|
|||||||
but ends after is included in the results."""
|
but ends after is included in the results."""
|
||||||
now = datetime.now().timestamp()
|
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)
|
super().insert_mock_review_segment("123456.random", now, now + 2)
|
||||||
response = client.get("/review")
|
response = client.get("/review")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
@ -67,7 +79,7 @@ class TestHttpReview(BaseTestHttp):
|
|||||||
def test_get_review_no_filters(self):
|
def test_get_review_no_filters(self):
|
||||||
now = datetime.now().timestamp()
|
now = datetime.now().timestamp()
|
||||||
|
|
||||||
with TestClient(self.app) as client:
|
with AuthTestClient(self.app) as client:
|
||||||
id = "123456.random"
|
id = "123456.random"
|
||||||
super().insert_mock_review_segment(id, now - 2, now - 1)
|
super().insert_mock_review_segment(id, now - 2, now - 1)
|
||||||
response = client.get("/review")
|
response = client.get("/review")
|
||||||
@ -81,7 +93,7 @@ class TestHttpReview(BaseTestHttp):
|
|||||||
"""Test that review items outside the range are not returned."""
|
"""Test that review items outside the range are not returned."""
|
||||||
now = datetime.now().timestamp()
|
now = datetime.now().timestamp()
|
||||||
|
|
||||||
with TestClient(self.app) as client:
|
with AuthTestClient(self.app) as client:
|
||||||
id = "123456.random"
|
id = "123456.random"
|
||||||
super().insert_mock_review_segment(id, now - 2, now - 1)
|
super().insert_mock_review_segment(id, now - 2, now - 1)
|
||||||
super().insert_mock_review_segment(f"{id}2", now + 4, now + 5)
|
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):
|
def test_get_review_with_time_filter(self):
|
||||||
now = datetime.now().timestamp()
|
now = datetime.now().timestamp()
|
||||||
|
|
||||||
with TestClient(self.app) as client:
|
with AuthTestClient(self.app) as client:
|
||||||
id = "123456.random"
|
id = "123456.random"
|
||||||
super().insert_mock_review_segment(id, now, now + 2)
|
super().insert_mock_review_segment(id, now, now + 2)
|
||||||
params = {
|
params = {
|
||||||
@ -113,7 +125,7 @@ class TestHttpReview(BaseTestHttp):
|
|||||||
def test_get_review_with_limit_filter(self):
|
def test_get_review_with_limit_filter(self):
|
||||||
now = datetime.now().timestamp()
|
now = datetime.now().timestamp()
|
||||||
|
|
||||||
with TestClient(self.app) as client:
|
with AuthTestClient(self.app) as client:
|
||||||
id = "123456.random"
|
id = "123456.random"
|
||||||
id2 = "654321.random"
|
id2 = "654321.random"
|
||||||
super().insert_mock_review_segment(id, now, now + 2)
|
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):
|
def test_get_review_with_severity_filters_no_matches(self):
|
||||||
now = datetime.now().timestamp()
|
now = datetime.now().timestamp()
|
||||||
|
|
||||||
with TestClient(self.app) as client:
|
with AuthTestClient(self.app) as client:
|
||||||
id = "123456.random"
|
id = "123456.random"
|
||||||
super().insert_mock_review_segment(id, now, now + 2, SeverityEnum.detection)
|
super().insert_mock_review_segment(id, now, now + 2, SeverityEnum.detection)
|
||||||
params = {
|
params = {
|
||||||
@ -149,7 +161,7 @@ class TestHttpReview(BaseTestHttp):
|
|||||||
def test_get_review_with_severity_filters(self):
|
def test_get_review_with_severity_filters(self):
|
||||||
now = datetime.now().timestamp()
|
now = datetime.now().timestamp()
|
||||||
|
|
||||||
with TestClient(self.app) as client:
|
with AuthTestClient(self.app) as client:
|
||||||
id = "123456.random"
|
id = "123456.random"
|
||||||
super().insert_mock_review_segment(id, now, now + 2, SeverityEnum.detection)
|
super().insert_mock_review_segment(id, now, now + 2, SeverityEnum.detection)
|
||||||
params = {
|
params = {
|
||||||
@ -165,7 +177,7 @@ class TestHttpReview(BaseTestHttp):
|
|||||||
def test_get_review_with_all_filters(self):
|
def test_get_review_with_all_filters(self):
|
||||||
now = datetime.now().timestamp()
|
now = datetime.now().timestamp()
|
||||||
|
|
||||||
with TestClient(self.app) as client:
|
with AuthTestClient(self.app) as client:
|
||||||
id = "123456.random"
|
id = "123456.random"
|
||||||
super().insert_mock_review_segment(id, now, now + 2)
|
super().insert_mock_review_segment(id, now, now + 2)
|
||||||
params = {
|
params = {
|
||||||
@ -188,7 +200,7 @@ class TestHttpReview(BaseTestHttp):
|
|||||||
################################### GET /review/summary Endpoint #################################################
|
################################### GET /review/summary Endpoint #################################################
|
||||||
####################################################################################################################
|
####################################################################################################################
|
||||||
def test_get_review_summary_all_filters(self):
|
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")
|
super().insert_mock_review_segment("123456.random")
|
||||||
params = {
|
params = {
|
||||||
"cameras": "front_door",
|
"cameras": "front_door",
|
||||||
@ -219,7 +231,7 @@ class TestHttpReview(BaseTestHttp):
|
|||||||
self.assertEqual(response_json, expected_response)
|
self.assertEqual(response_json, expected_response)
|
||||||
|
|
||||||
def test_get_review_summary_no_filters(self):
|
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")
|
super().insert_mock_review_segment("123456.random")
|
||||||
response = client.get("/review/summary")
|
response = client.get("/review/summary")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
@ -247,7 +259,7 @@ class TestHttpReview(BaseTestHttp):
|
|||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
five_days_ago = datetime.today() - timedelta(days=5)
|
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(
|
super().insert_mock_review_segment(
|
||||||
"123456.random", now.timestamp() - 2, now.timestamp() - 1
|
"123456.random", now.timestamp() - 2, now.timestamp() - 1
|
||||||
)
|
)
|
||||||
@ -291,7 +303,7 @@ class TestHttpReview(BaseTestHttp):
|
|||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
five_days_ago = datetime.today() - timedelta(days=5)
|
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())
|
super().insert_mock_review_segment("123456.random", now.timestamp())
|
||||||
five_days_ago_ts = five_days_ago.timestamp()
|
five_days_ago_ts = five_days_ago.timestamp()
|
||||||
for i in range(20):
|
for i in range(20):
|
||||||
@ -342,7 +354,7 @@ class TestHttpReview(BaseTestHttp):
|
|||||||
def test_get_review_summary_multiple_in_same_day_with_reviewed(self):
|
def test_get_review_summary_multiple_in_same_day_with_reviewed(self):
|
||||||
five_days_ago = datetime.today() - timedelta(days=5)
|
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()
|
five_days_ago_ts = five_days_ago.timestamp()
|
||||||
for i in range(10):
|
for i in range(10):
|
||||||
id = f"123456_{i}.random_alert_not_reviewed"
|
id = f"123456_{i}.random_alert_not_reviewed"
|
||||||
@ -393,14 +405,14 @@ class TestHttpReview(BaseTestHttp):
|
|||||||
####################################################################################################################
|
####################################################################################################################
|
||||||
|
|
||||||
def test_post_reviews_viewed_no_body(self):
|
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")
|
super().insert_mock_review_segment("123456.random")
|
||||||
response = client.post("/reviews/viewed")
|
response = client.post("/reviews/viewed")
|
||||||
# Missing ids
|
# Missing ids
|
||||||
assert response.status_code == 422
|
assert response.status_code == 422
|
||||||
|
|
||||||
def test_post_reviews_viewed_no_body_ids(self):
|
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")
|
super().insert_mock_review_segment("123456.random")
|
||||||
body = {"ids": [""]}
|
body = {"ids": [""]}
|
||||||
response = client.post("/reviews/viewed", json=body)
|
response = client.post("/reviews/viewed", json=body)
|
||||||
@ -408,7 +420,7 @@ class TestHttpReview(BaseTestHttp):
|
|||||||
assert response.status_code == 422
|
assert response.status_code == 422
|
||||||
|
|
||||||
def test_post_reviews_viewed_non_existent_id(self):
|
def test_post_reviews_viewed_non_existent_id(self):
|
||||||
with TestClient(self.app) as client:
|
with AuthTestClient(self.app) as client:
|
||||||
id = "123456.random"
|
id = "123456.random"
|
||||||
super().insert_mock_review_segment(id)
|
super().insert_mock_review_segment(id)
|
||||||
body = {"ids": ["1"]}
|
body = {"ids": ["1"]}
|
||||||
@ -425,7 +437,7 @@ class TestHttpReview(BaseTestHttp):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_post_reviews_viewed(self):
|
def test_post_reviews_viewed(self):
|
||||||
with TestClient(self.app) as client:
|
with AuthTestClient(self.app) as client:
|
||||||
id = "123456.random"
|
id = "123456.random"
|
||||||
super().insert_mock_review_segment(id)
|
super().insert_mock_review_segment(id)
|
||||||
body = {"ids": [id]}
|
body = {"ids": [id]}
|
||||||
@ -445,14 +457,14 @@ class TestHttpReview(BaseTestHttp):
|
|||||||
################################### POST reviews/delete Endpoint ################################################
|
################################### POST reviews/delete Endpoint ################################################
|
||||||
####################################################################################################################
|
####################################################################################################################
|
||||||
def test_post_reviews_delete_no_body(self):
|
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")
|
super().insert_mock_review_segment("123456.random")
|
||||||
response = client.post("/reviews/delete", headers={"remote-role": "admin"})
|
response = client.post("/reviews/delete", headers={"remote-role": "admin"})
|
||||||
# Missing ids
|
# Missing ids
|
||||||
assert response.status_code == 422
|
assert response.status_code == 422
|
||||||
|
|
||||||
def test_post_reviews_delete_no_body_ids(self):
|
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")
|
super().insert_mock_review_segment("123456.random")
|
||||||
body = {"ids": [""]}
|
body = {"ids": [""]}
|
||||||
response = client.post(
|
response = client.post(
|
||||||
@ -462,7 +474,7 @@ class TestHttpReview(BaseTestHttp):
|
|||||||
assert response.status_code == 422
|
assert response.status_code == 422
|
||||||
|
|
||||||
def test_post_reviews_delete_non_existent_id(self):
|
def test_post_reviews_delete_non_existent_id(self):
|
||||||
with TestClient(self.app) as client:
|
with AuthTestClient(self.app) as client:
|
||||||
id = "123456.random"
|
id = "123456.random"
|
||||||
super().insert_mock_review_segment(id)
|
super().insert_mock_review_segment(id)
|
||||||
body = {"ids": ["1"]}
|
body = {"ids": ["1"]}
|
||||||
@ -479,7 +491,7 @@ class TestHttpReview(BaseTestHttp):
|
|||||||
assert review_ids_in_db_after[0].id == id
|
assert review_ids_in_db_after[0].id == id
|
||||||
|
|
||||||
def test_post_reviews_delete(self):
|
def test_post_reviews_delete(self):
|
||||||
with TestClient(self.app) as client:
|
with AuthTestClient(self.app) as client:
|
||||||
id = "123456.random"
|
id = "123456.random"
|
||||||
super().insert_mock_review_segment(id)
|
super().insert_mock_review_segment(id)
|
||||||
body = {"ids": [id]}
|
body = {"ids": [id]}
|
||||||
@ -495,7 +507,7 @@ class TestHttpReview(BaseTestHttp):
|
|||||||
assert len(review_ids_in_db_after) == 0
|
assert len(review_ids_in_db_after) == 0
|
||||||
|
|
||||||
def test_post_reviews_delete_many(self):
|
def test_post_reviews_delete_many(self):
|
||||||
with TestClient(self.app) as client:
|
with AuthTestClient(self.app) as client:
|
||||||
ids = ["123456.random", "654321.random"]
|
ids = ["123456.random", "654321.random"]
|
||||||
for id in ids:
|
for id in ids:
|
||||||
super().insert_mock_review_segment(id)
|
super().insert_mock_review_segment(id)
|
||||||
@ -527,7 +539,7 @@ class TestHttpReview(BaseTestHttp):
|
|||||||
def test_review_activity_motion_no_data_for_time_range(self):
|
def test_review_activity_motion_no_data_for_time_range(self):
|
||||||
now = datetime.now().timestamp()
|
now = datetime.now().timestamp()
|
||||||
|
|
||||||
with TestClient(self.app) as client:
|
with AuthTestClient(self.app) as client:
|
||||||
params = {
|
params = {
|
||||||
"after": now,
|
"after": now,
|
||||||
"before": now + 3,
|
"before": now + 3,
|
||||||
@ -540,7 +552,7 @@ class TestHttpReview(BaseTestHttp):
|
|||||||
def test_review_activity_motion(self):
|
def test_review_activity_motion(self):
|
||||||
now = int(datetime.now().timestamp())
|
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())
|
one_m = int((datetime.now() + timedelta(minutes=1)).timestamp())
|
||||||
id = "123456.random"
|
id = "123456.random"
|
||||||
id2 = "123451.random"
|
id2 = "123451.random"
|
||||||
@ -573,7 +585,7 @@ class TestHttpReview(BaseTestHttp):
|
|||||||
################################### GET /review/event/{event_id} Endpoint #######################################
|
################################### GET /review/event/{event_id} Endpoint #######################################
|
||||||
####################################################################################################################
|
####################################################################################################################
|
||||||
def test_review_event_not_found(self):
|
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")
|
response = client.get("/review/event/123456.random")
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
response_json = response.json()
|
response_json = response.json()
|
||||||
@ -585,7 +597,7 @@ class TestHttpReview(BaseTestHttp):
|
|||||||
def test_review_event_not_found_in_data(self):
|
def test_review_event_not_found_in_data(self):
|
||||||
now = datetime.now().timestamp()
|
now = datetime.now().timestamp()
|
||||||
|
|
||||||
with TestClient(self.app) as client:
|
with AuthTestClient(self.app) as client:
|
||||||
id = "123456.random"
|
id = "123456.random"
|
||||||
super().insert_mock_review_segment(id, now + 1, now + 2)
|
super().insert_mock_review_segment(id, now + 1, now + 2)
|
||||||
response = client.get(f"/review/event/{id}")
|
response = client.get(f"/review/event/{id}")
|
||||||
@ -599,7 +611,7 @@ class TestHttpReview(BaseTestHttp):
|
|||||||
def test_review_get_specific_event(self):
|
def test_review_get_specific_event(self):
|
||||||
now = datetime.now().timestamp()
|
now = datetime.now().timestamp()
|
||||||
|
|
||||||
with TestClient(self.app) as client:
|
with AuthTestClient(self.app) as client:
|
||||||
event_id = "123456.event.random"
|
event_id = "123456.event.random"
|
||||||
super().insert_mock_event(event_id)
|
super().insert_mock_event(event_id)
|
||||||
review_id = "123456.review.random"
|
review_id = "123456.review.random"
|
||||||
@ -626,7 +638,7 @@ class TestHttpReview(BaseTestHttp):
|
|||||||
################################### GET /review/{review_id} Endpoint #######################################
|
################################### GET /review/{review_id} Endpoint #######################################
|
||||||
####################################################################################################################
|
####################################################################################################################
|
||||||
def test_review_not_found(self):
|
def test_review_not_found(self):
|
||||||
with TestClient(self.app) as client:
|
with AuthTestClient(self.app) as client:
|
||||||
response = client.get("/review/123456.random")
|
response = client.get("/review/123456.random")
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
response_json = response.json()
|
response_json = response.json()
|
||||||
@ -638,7 +650,7 @@ class TestHttpReview(BaseTestHttp):
|
|||||||
def test_get_review(self):
|
def test_get_review(self):
|
||||||
now = datetime.now().timestamp()
|
now = datetime.now().timestamp()
|
||||||
|
|
||||||
with TestClient(self.app) as client:
|
with AuthTestClient(self.app) as client:
|
||||||
review_id = "123456.review.random"
|
review_id = "123456.review.random"
|
||||||
super().insert_mock_review_segment(review_id, now + 1, now + 2)
|
super().insert_mock_review_segment(review_id, now + 1, now + 2)
|
||||||
response = client.get(f"/review/{review_id}")
|
response = client.get(f"/review/{review_id}")
|
||||||
@ -662,7 +674,7 @@ class TestHttpReview(BaseTestHttp):
|
|||||||
####################################################################################################################
|
####################################################################################################################
|
||||||
|
|
||||||
def test_delete_review_viewed_review_not_found(self):
|
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"
|
review_id = "123456.random"
|
||||||
response = client.delete(f"/review/{review_id}/viewed")
|
response = client.delete(f"/review/{review_id}/viewed")
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
@ -675,7 +687,7 @@ class TestHttpReview(BaseTestHttp):
|
|||||||
def test_delete_review_viewed(self):
|
def test_delete_review_viewed(self):
|
||||||
now = datetime.now().timestamp()
|
now = datetime.now().timestamp()
|
||||||
|
|
||||||
with TestClient(self.app) as client:
|
with AuthTestClient(self.app) as client:
|
||||||
review_id = "123456.review.random"
|
review_id = "123456.review.random"
|
||||||
super().insert_mock_review_segment(review_id, now + 1, now + 2)
|
super().insert_mock_review_segment(review_id, now + 1, now + 2)
|
||||||
self._insert_user_review_status(review_id, reviewed=True)
|
self._insert_user_review_status(review_id, reviewed=True)
|
||||||
|
|||||||
@ -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]]:
|
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()
|
new_config = config.copy()
|
||||||
|
|
||||||
# migrate global to new recording configuration
|
# 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:
|
if global_genai:
|
||||||
new_genai_config = {}
|
new_genai_config = {}
|
||||||
new_object_config = config.get("objects", {})
|
new_object_config = new_config.get("objects", {})
|
||||||
new_object_config["genai"] = {}
|
new_object_config["genai"] = {}
|
||||||
|
|
||||||
for key in global_genai.keys():
|
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:
|
else:
|
||||||
new_object_config["genai"][key] = global_genai[key]
|
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():
|
for name, camera in config.get("cameras", {}).items():
|
||||||
camera_config: dict[str, dict[str, Any]] = camera.copy()
|
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", {})
|
camera_genai = camera_config.get("genai", {})
|
||||||
|
|
||||||
if camera_genai:
|
if camera_genai:
|
||||||
new_object_config = config.get("objects", {})
|
camera_object_config = camera_config.get("objects", {})
|
||||||
new_object_config["genai"] = camera_genai
|
camera_object_config["genai"] = camera_genai
|
||||||
|
camera_config["objects"] = camera_object_config
|
||||||
del camera_config["genai"]
|
del camera_config["genai"]
|
||||||
|
|
||||||
new_config["cameras"][name] = camera_config
|
new_config["cameras"][name] = camera_config
|
||||||
|
|||||||
@ -1,7 +1,10 @@
|
|||||||
|
import atexit
|
||||||
import faulthandler
|
import faulthandler
|
||||||
import logging
|
import logging
|
||||||
import multiprocessing as mp
|
import multiprocessing as mp
|
||||||
import os
|
import os
|
||||||
|
import pathlib
|
||||||
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
from logging.handlers import QueueHandler
|
from logging.handlers import QueueHandler
|
||||||
from multiprocessing.synchronize import Event as MpEvent
|
from multiprocessing.synchronize import Event as MpEvent
|
||||||
@ -48,6 +51,7 @@ class FrigateProcess(BaseProcess):
|
|||||||
|
|
||||||
def before_start(self) -> None:
|
def before_start(self) -> None:
|
||||||
self.__log_queue = frigate.log.log_listener.queue
|
self.__log_queue = frigate.log.log_listener.queue
|
||||||
|
self.__memray_tracker = None
|
||||||
|
|
||||||
def pre_run_setup(self, logConfig: LoggerConfig | None = None) -> None:
|
def pre_run_setup(self, logConfig: LoggerConfig | None = None) -> None:
|
||||||
os.nice(self.priority)
|
os.nice(self.priority)
|
||||||
@ -64,3 +68,86 @@ class FrigateProcess(BaseProcess):
|
|||||||
frigate.log.apply_log_levels(
|
frigate.log.apply_log_levels(
|
||||||
logConfig.default.value.upper(), logConfig.logs
|
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)
|
||||||
|
|||||||
6
web/package-lock.json
generated
6
web/package-lock.json
generated
@ -4702,9 +4702,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/caniuse-lite": {
|
"node_modules/caniuse-lite": {
|
||||||
"version": "1.0.30001651",
|
"version": "1.0.30001757",
|
||||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001651.tgz",
|
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001757.tgz",
|
||||||
"integrity": "sha512-9Cf+Xv1jJNe1xPZLGuUXLNkE1BoDkqRqYyFJ9TDYSqhduqA4hu4oR9HluGoWYQC/aj8WHjsGVV+bwkh0+tegRg==",
|
"integrity": "sha512-r0nnL/I28Zi/yjk1el6ilj27tKcdjLsNqAOZr0yVjWPrSQyHgKI2INaEWw21bAQSv2LXRt1XuCS/GomNpWOxsQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
|
|||||||
@ -58,7 +58,8 @@
|
|||||||
"endTimeMustAfterStartTime": "L'hora de finalització ha de ser posterior a l'hora d'inici",
|
"endTimeMustAfterStartTime": "L'hora de finalització ha de ser posterior a l'hora d'inici",
|
||||||
"noVaildTimeSelected": "No s'ha seleccionat un rang de temps vàlid",
|
"noVaildTimeSelected": "No s'ha seleccionat un rang de temps vàlid",
|
||||||
"failed": "No s'ha pogut inciar l'exportació: {{error}}"
|
"failed": "No s'ha pogut inciar l'exportació: {{error}}"
|
||||||
}
|
},
|
||||||
|
"view": "Vista"
|
||||||
},
|
},
|
||||||
"fromTimeline": {
|
"fromTimeline": {
|
||||||
"saveExport": "Guardar exportació",
|
"saveExport": "Guardar exportació",
|
||||||
|
|||||||
@ -263,7 +263,8 @@
|
|||||||
"header": {
|
"header": {
|
||||||
"zones": "Zones",
|
"zones": "Zones",
|
||||||
"ratio": "Ràtio",
|
"ratio": "Ràtio",
|
||||||
"area": "Àrea"
|
"area": "Àrea",
|
||||||
|
"score": "Puntuació"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"annotationSettings": {
|
"annotationSettings": {
|
||||||
|
|||||||
@ -296,7 +296,7 @@
|
|||||||
"doorbell": "Türklingel",
|
"doorbell": "Türklingel",
|
||||||
"ding-dong": "BimBam",
|
"ding-dong": "BimBam",
|
||||||
"sliding_door": "Schiebetür",
|
"sliding_door": "Schiebetür",
|
||||||
"slam": "Knall",
|
"slam": "zuknallen",
|
||||||
"knock": "Klopfen",
|
"knock": "Klopfen",
|
||||||
"tap": "Schlag",
|
"tap": "Schlag",
|
||||||
"squeak": "Quietschen",
|
"squeak": "Quietschen",
|
||||||
@ -355,7 +355,7 @@
|
|||||||
"shatter": "Zerspringen",
|
"shatter": "Zerspringen",
|
||||||
"silence": "Stille",
|
"silence": "Stille",
|
||||||
"environmental_noise": "Umgebungsgeräusch",
|
"environmental_noise": "Umgebungsgeräusch",
|
||||||
"static": "Rauschen",
|
"static": "Statisch",
|
||||||
"pink_noise": "Rosa Rauschen",
|
"pink_noise": "Rosa Rauschen",
|
||||||
"television": "Fernsehgerät",
|
"television": "Fernsehgerät",
|
||||||
"radio": "Radio",
|
"radio": "Radio",
|
||||||
@ -441,5 +441,32 @@
|
|||||||
"arrow": "Pfeil",
|
"arrow": "Pfeil",
|
||||||
"electronic_tuner": "Elektronischer Tuner",
|
"electronic_tuner": "Elektronischer Tuner",
|
||||||
"effects_unit": "Effekteinheit",
|
"effects_unit": "Effekteinheit",
|
||||||
"chorus_effect": "Chorus-Effekt"
|
"chorus_effect": "Chorus-Effekt",
|
||||||
|
"sodeling": "Verfilzen",
|
||||||
|
"chird": "Akkord",
|
||||||
|
"change_ringing": "Wechsle RingRing",
|
||||||
|
"shofar": "Schofar",
|
||||||
|
"gush": "sprudeln",
|
||||||
|
"sonar": "Sonar",
|
||||||
|
"whoosh": "Rauschen",
|
||||||
|
"thump": "Ruck",
|
||||||
|
"basketball_bounce": "Basketball Abbraller",
|
||||||
|
"bang": "Knall",
|
||||||
|
"slap": "Ohrfeige",
|
||||||
|
"whack": "verhauen",
|
||||||
|
"smash": "zerschlagen",
|
||||||
|
"breaking": "zerbrechen",
|
||||||
|
"bouncing": "Abbraller",
|
||||||
|
"whip": "Peitsche",
|
||||||
|
"flap": "Lasche",
|
||||||
|
"scratch": "Kratzer",
|
||||||
|
"scrape": "Abfall",
|
||||||
|
"rub": "scheuern",
|
||||||
|
"roll": "rollen",
|
||||||
|
"crushing": "Stauchen",
|
||||||
|
"crumpling": "zerknüllen",
|
||||||
|
"tearing": "Reißen",
|
||||||
|
"beep": "Piep",
|
||||||
|
"ping": "Ping",
|
||||||
|
"ding": "klingeln"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -81,7 +81,10 @@
|
|||||||
"formattedTimestampMonthDayYear": {
|
"formattedTimestampMonthDayYear": {
|
||||||
"12hour": "d. MMM yyyy",
|
"12hour": "d. MMM yyyy",
|
||||||
"24hour": "d. MMM yyyy"
|
"24hour": "d. MMM yyyy"
|
||||||
}
|
},
|
||||||
|
"inProgress": "In Bearbeitung",
|
||||||
|
"invalidStartTime": "Ungültige Startzeit",
|
||||||
|
"invalidEndTime": "Ungültige Endzeit"
|
||||||
},
|
},
|
||||||
"button": {
|
"button": {
|
||||||
"save": "Speichern",
|
"save": "Speichern",
|
||||||
@ -118,7 +121,8 @@
|
|||||||
"pictureInPicture": "Bild in Bild",
|
"pictureInPicture": "Bild in Bild",
|
||||||
"on": "AN",
|
"on": "AN",
|
||||||
"suspended": "Pausierte",
|
"suspended": "Pausierte",
|
||||||
"unsuspended": "fortsetzen"
|
"unsuspended": "fortsetzen",
|
||||||
|
"continue": "Weiter"
|
||||||
},
|
},
|
||||||
"label": {
|
"label": {
|
||||||
"back": "Zurück",
|
"back": "Zurück",
|
||||||
|
|||||||
@ -66,7 +66,8 @@
|
|||||||
"failed": "Fehler beim Starten des Exports: {{error}}",
|
"failed": "Fehler beim Starten des Exports: {{error}}",
|
||||||
"noVaildTimeSelected": "Kein gültiger Zeitraum ausgewählt"
|
"noVaildTimeSelected": "Kein gültiger Zeitraum ausgewählt"
|
||||||
},
|
},
|
||||||
"success": "Export erfolgreich gestartet. Die Datei befindet sich auf der Exportseite."
|
"success": "Export erfolgreich gestartet. Die Datei befindet sich auf der Exportseite.",
|
||||||
|
"view": "Ansicht"
|
||||||
},
|
},
|
||||||
"fromTimeline": {
|
"fromTimeline": {
|
||||||
"saveExport": "Export speichern",
|
"saveExport": "Export speichern",
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"documentTitle": "Klassifizierungsmodelle",
|
"documentTitle": "Klassifikation Modelle",
|
||||||
"details": {
|
"details": {
|
||||||
"scoreInfo": "Die Punktzahl gibt die durchschnittliche Klassifizierungssicherheit aller Erkennungen dieses Objekts wieder."
|
"scoreInfo": "Die Punktzahl gibt die durchschnittliche Klassifizierungssicherheit aller Erkennungen dieses Objekts wieder."
|
||||||
},
|
},
|
||||||
"button": {
|
"button": {
|
||||||
"deleteClassificationAttempts": "Lösche Klassifizierungs-Bilder",
|
"deleteClassificationAttempts": "Lösche Klassifizierungsbilder",
|
||||||
"renameCategory": "Klasse umbenennen",
|
"renameCategory": "Klasse umbenennen",
|
||||||
"deleteCategory": "Klasse löschen",
|
"deleteCategory": "Klasse löschen",
|
||||||
"deleteImages": "Bilder löschen",
|
"deleteImages": "Bilder löschen",
|
||||||
@ -14,15 +14,15 @@
|
|||||||
"editModel": "Modell bearbeiten"
|
"editModel": "Modell bearbeiten"
|
||||||
},
|
},
|
||||||
"tooltip": {
|
"tooltip": {
|
||||||
"trainingInProgress": "Modell wird gerade trainiert",
|
"trainingInProgress": "Modell werden trainiert",
|
||||||
"noNewImages": "Keine weiteren Bilder zum trainieren. Bitte klassifiziere weitere Bilder im Datensatz.",
|
"noNewImages": "Keine weiteren Bilder zum trainieren. Bitte klassifiziere weitere Bilder im Datensatz.",
|
||||||
"noChanges": "Keine Veränderungen des Datensatzes seit dem letzten Training.",
|
"noChanges": "Keine Veränderungen des Datensatzes seit dem letzten Training.",
|
||||||
"modelNotReady": "Modell ist nicht bereit trainiert zu werden."
|
"modelNotReady": "Modell ist nicht bereit zum Training"
|
||||||
},
|
},
|
||||||
"toast": {
|
"toast": {
|
||||||
"success": {
|
"success": {
|
||||||
"deletedCategory": "Klasse gelöscht",
|
"deletedCategory": "Gelöschte Klasse",
|
||||||
"deletedImage": "Gelöschte Bilder",
|
"deletedImage": "Bilder gelöscht",
|
||||||
"deletedModel_one": "{{count}} Model erfolgreich gelöscht",
|
"deletedModel_one": "{{count}} Model erfolgreich gelöscht",
|
||||||
"deletedModel_other": "{{count}} Modelle erfolgreich gelöscht",
|
"deletedModel_other": "{{count}} Modelle erfolgreich gelöscht",
|
||||||
"categorizedImage": "Bild erfolgreich klassifiziert",
|
"categorizedImage": "Bild erfolgreich klassifiziert",
|
||||||
@ -34,7 +34,146 @@
|
|||||||
"error": {
|
"error": {
|
||||||
"deleteImageFailed": "Löschen fehlgeschlagen: {{errorMessage}}",
|
"deleteImageFailed": "Löschen fehlgeschlagen: {{errorMessage}}",
|
||||||
"deleteCategoryFailed": "Klasse konnte nicht gelöscht werden: {{errorMessage}}",
|
"deleteCategoryFailed": "Klasse konnte nicht gelöscht werden: {{errorMessage}}",
|
||||||
"deleteModelFailed": "Model konnte nicht gelöscht werden: {{errorMessage}}"
|
"deleteModelFailed": "Model konnte nicht gelöscht werden: {{errorMessage}}",
|
||||||
|
"trainingFailedToStart": "Modelltraining konnte nicht gestartet werden: {{errorMessage}}",
|
||||||
|
"updateModelFailed": "Aktualisierung des Modells fehlgeschlagen: {{errorMessage}}",
|
||||||
|
"renameCategoryFailed": "Umbenennung der Klasse fehlgeschlagen: {{errorMessage}}",
|
||||||
|
"categorizeFailed": "Bildkategorisierung fehlgeschlagen: {{errorMessage}}",
|
||||||
|
"trainingFailed": "Modelltraining fehlgeschlagen. Details sind in den Frigate-Protokollen zu finden."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"deleteCategory": {
|
||||||
|
"title": "Klasse löschen",
|
||||||
|
"desc": "Möchten Sie die Klasse {{name}} wirklich löschen? Dadurch werden alle zugehörigen Bilder dauerhaft gelöscht und das Modell muss neu trainiert werden.",
|
||||||
|
"minClassesTitle": "Klasse kann nicht gelöscht werden",
|
||||||
|
"minClassesDesc": "Ein Klassifizierungsmodell benötigt mindestens zwei Klassen. Fügen Sie eine weitere Klasse hinzu, bevor Sie diese löschen."
|
||||||
|
},
|
||||||
|
"deleteModel": {
|
||||||
|
"title": "Klassifizierungsmodell löschen",
|
||||||
|
"single": "Möchten Sie {{name}} wirklich löschen? Dadurch werden alle zugehörigen Daten, einschließlich Bilder und Trainingsdaten, dauerhaft gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||||
|
"desc_one": "Möchtest du {{count}} Modell wirklich löschen? Dadurch werden alle zugehörigen Daten, einschließlich Bilder und Trainingsdaten, dauerhaft gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||||
|
"desc_other": "Möchtest du {{count}} Modelle wirklich löschen? Dadurch werden alle zugehörigen Daten, einschließlich Bilder und Trainingsdaten, dauerhaft gelöscht. Diese Aktion kann nicht rückgängig gemacht werden."
|
||||||
|
},
|
||||||
|
"edit": {
|
||||||
|
"title": "Klassifikationsmodell bearbeiten",
|
||||||
|
"descriptionState": "Bearbeite die Klassen für dieses Zustandsklassifikationsmodell. Änderungen erfordern erneutes Trainieren des Modells.",
|
||||||
|
"descriptionObject": "Bearbeite den Objekttyp und Klassifizierungstyp für dieses Objektklassifikationsmodell.",
|
||||||
|
"stateClassesInfo": "Hinweis: Die Änderung der Statusklassen erfordert ein erneutes Trainieren des Modells mit den aktualisierten Klassen."
|
||||||
|
},
|
||||||
|
"deleteDatasetImages": {
|
||||||
|
"title": "Datensatz Bilder löschen",
|
||||||
|
"desc_one": "Bist du sicher, dass {{count}} Bild von {{dataset}} gelöscht werden sollen? Diese Aktion kann nicht rückgängig gemacht werden und erfordert ein erneutes Trainieren des Modells.",
|
||||||
|
"desc_other": "Bist du sicher, dass {{count}} Bilder von {{dataset}} gelöscht werden sollen? Diese Aktion kann nicht rückgängig gemacht werden und erfordert ein erneutes Trainieren des Modells."
|
||||||
|
},
|
||||||
|
"deleteTrainImages": {
|
||||||
|
"title": "Trainingsbilder löschen",
|
||||||
|
"desc_one": "Bist du sicher, dass du {{count}} Bild löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||||
|
"desc_other": "Bist du sicher, dass du {{count}} Bilder löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden."
|
||||||
|
},
|
||||||
|
"renameCategory": {
|
||||||
|
"title": "Klasse umbenennen",
|
||||||
|
"desc": "Neuen Namen für {{name}} eingeben. Das Modell muss neu trainiert werden, damit die Änderungen wirksam werden."
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"invalidName": "Ungültiger Name. Namen dürfen nur Buchstaben, Zahlen, Leerzeichen, Apostrophe, Unterstriche und Bindestriche enthalten."
|
||||||
|
},
|
||||||
|
"train": {
|
||||||
|
"title": "Neue Klassifizierungen",
|
||||||
|
"titleShort": "kürzlich",
|
||||||
|
"aria": "Neue Klassifizierungen auswählen"
|
||||||
|
},
|
||||||
|
"categories": "Klassen",
|
||||||
|
"createCategory": {
|
||||||
|
"new": "Neue Klasse erstellen"
|
||||||
|
},
|
||||||
|
"categorizeImageAs": "Bild klassifizieren als:",
|
||||||
|
"categorizeImage": "Bild klassifizieren",
|
||||||
|
"menu": {
|
||||||
|
"objects": "Objekte",
|
||||||
|
"states": "Zustände"
|
||||||
|
},
|
||||||
|
"noModels": {
|
||||||
|
"object": {
|
||||||
|
"title": "Keine Objektklassifikationsmodelle",
|
||||||
|
"description": "Erstelle ein benutzerdefiniertes Modell, um erkannte Objekte zu klassifizieren.",
|
||||||
|
"buttonText": "Objektmodell erstellen"
|
||||||
|
},
|
||||||
|
"state": {
|
||||||
|
"title": "Keine Statusklassifizierungsmodelle",
|
||||||
|
"description": "Erstellen Sie ein benutzerdefiniertes Modell, um Zustandsänderungen in bestimmten Kamerabereichen zu überwachen und zu klassifizieren.",
|
||||||
|
"buttonText": "Zustandsmodell erstellen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"wizard": {
|
||||||
|
"title": "Neue Klassifizierung erstellen",
|
||||||
|
"steps": {
|
||||||
|
"nameAndDefine": "Benennen und definieren",
|
||||||
|
"stateArea": "Gebiet",
|
||||||
|
"chooseExamples": "Beispiel auswählen"
|
||||||
|
},
|
||||||
|
"step1": {
|
||||||
|
"description": "Zustandsmodelle überwachen feste Kamerabereiche auf Veränderungen (z. B. Tür offen/geschlossen). Objektmodelle fügen den erkannten Objekten Klassifizierungen hinzu (z. B. bekannte Tiere, Lieferanten usw.).",
|
||||||
|
"name": "Name",
|
||||||
|
"namePlaceholder": "Eingeben Modell Name...",
|
||||||
|
"type": "Typ",
|
||||||
|
"typeState": "Zustand",
|
||||||
|
"typeObject": "Objekt",
|
||||||
|
"objectLabel": "Objekt Bezeichnung",
|
||||||
|
"objectLabelPlaceholder": "Auswahl Objekt Typ...",
|
||||||
|
"classificationType": "Klassifizierungstyp",
|
||||||
|
"classificationTypeTip": "Etwas über Klassifizierungstyp lernen",
|
||||||
|
"classificationTypeDesc": "Unterbezeichnungen fügen dem Objektnamen zusätzlichen Text hinzu (z. B. „Person: UPS“). Attribute sind durchsuchbare Metadaten, die separat in den Objektmetadaten gespeichert sind.",
|
||||||
|
"classificationSubLabel": "Unterlabel",
|
||||||
|
"classificationAttribute": "Merkmal",
|
||||||
|
"classes": "Klasse",
|
||||||
|
"states": "Gebiet",
|
||||||
|
"classesTip": "Über Klassen lernen",
|
||||||
|
"classesStateDesc": "Definieren Sie die verschiedenen Zustände, in denen sich Ihr Kamerabereich befinden kann. Beispiel: „offen” und „geschlossen” für ein Garagentor.",
|
||||||
|
"classesObjectDesc": "Definieren Sie die verschiedenen Kategorien, in die erkannte Objekte klassifiziert werden sollen. Beispiel: „Lieferant“, „Bewohner“, „Fremder“ für die Klassifizierung von Personen.",
|
||||||
|
"classPlaceholder": "Eingabe Klassenbezeichnung...",
|
||||||
|
"errors": {
|
||||||
|
"nameRequired": "Modellname ist erforderlich",
|
||||||
|
"nameLength": "Der Modellname darf maximal 64 Zeichen lang sein",
|
||||||
|
"nameOnlyNumbers": "Der Modellname darf nicht nur aus Zahlen bestehen",
|
||||||
|
"classRequired": "Mindestens eine Klasse ist erforderlich",
|
||||||
|
"classesUnique": "Klassenname muss eindeutig sein",
|
||||||
|
"stateRequiresTwoClasses": "Gebietsmodelle erfordern mindestens zwei Klassen",
|
||||||
|
"objectLabelRequired": "Bitte wähle eine Objektbeschriftung",
|
||||||
|
"objectTypeRequired": "Bitte wählen Sie einen Klassifizierungstyp aus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"step2": {
|
||||||
|
"description": "Wählen Sie Kameras aus und legen Sie für jede Kamera den zu überwachenden Bereich fest. Das Modell klassifiziert den Zustand dieser Bereiche.",
|
||||||
|
"cameras": "Kameras",
|
||||||
|
"selectCamera": "Kamera auswählen",
|
||||||
|
"noCameras": "Klick + zum hinzufügen der Kameras",
|
||||||
|
"selectCameraPrompt": "Wählen Sie eine Kamera aus der Liste aus, um ihren Überwachungsbereich festzulegen"
|
||||||
|
},
|
||||||
|
"step3": {
|
||||||
|
"selectImagesPrompt": "Wählen sie alle Bilder mit: {{className}}",
|
||||||
|
"selectImagesDescription": "Klicken Sie auf die Bilder, um sie auszuwählen. Klicken Sie auf „Weiter“, wenn Sie mit diesem Kurs fertig sind.",
|
||||||
|
"allImagesRequired_one": "Bitte klassifizieren Sie alle Bilder. {{count}} Bilder verbleiben.",
|
||||||
|
"allImagesRequired_other": "Bitte klassifizieren Sie alle Bilder. {{count}} Bilder verbleiben.",
|
||||||
|
"generating": {
|
||||||
|
"title": "Beispielbilder generieren",
|
||||||
|
"description": "Frigate extrahiert repräsentative Bilder aus Ihren Aufnahmen. Dies kann einen Moment dauern..."
|
||||||
|
},
|
||||||
|
"training": {
|
||||||
|
"title": "Trainingsmodell",
|
||||||
|
"description": "Ihr Modell wird im Hintergrund trainiert. Schließen Sie diesen Dialog, und Ihr Modell wird ausgeführt, sobald das Training abgeschlossen ist."
|
||||||
|
},
|
||||||
|
"retryGenerate": "Generierung wiederholen",
|
||||||
|
"noImages": "Keine Bilder generiert",
|
||||||
|
"classifying": "Klassifizieren und Trainieren...",
|
||||||
|
"trainingStarted": "Training wurde erfolgreich gestartet",
|
||||||
|
"errors": {
|
||||||
|
"noCameras": "Keine Kameras konfiguriert",
|
||||||
|
"noObjectLabel": "Kein Objektlabel ausgewählt",
|
||||||
|
"generateFailed": "Beispiele konnten nicht generiert werden: {{error}}",
|
||||||
|
"generationFailed": "Generierung fehlgeschlagen. Bitte versuchen Sie es erneut.",
|
||||||
|
"classifyFailed": "Bilder konnten nicht klassifiziert werden: {{error}}"
|
||||||
|
},
|
||||||
|
"generateSuccess": "Erfolgreich generierte Beispielbilder"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -48,10 +48,12 @@
|
|||||||
"noDataFound": "Keine Detaildaten zur Überprüfung",
|
"noDataFound": "Keine Detaildaten zur Überprüfung",
|
||||||
"settings": "Detailansicht Einstellungen",
|
"settings": "Detailansicht Einstellungen",
|
||||||
"alwaysExpandActive": {
|
"alwaysExpandActive": {
|
||||||
"desc": "Immer die Objektdetails des aktiven Überprüfungselements erweitern, sofern verfügbar."
|
"desc": "Immer die Objektdetails vom aktivem Überprüfungselement erweitern, sofern verfügbar.",
|
||||||
|
"title": "Immer aktiv erweitern"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"objectTrack": {
|
"objectTrack": {
|
||||||
"trackedPoint": "Verfolgter Punkt"
|
"trackedPoint": "Verfolgter Punkt",
|
||||||
|
"clickToSeek": "Klicke, um zu dieser Zeit zu springen"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,7 +18,7 @@
|
|||||||
"updatedSublabel": "Unterkategorie erfolgreich aktualisiert.",
|
"updatedSublabel": "Unterkategorie erfolgreich aktualisiert.",
|
||||||
"updatedLPR": "Nummernschild erfolgreich aktualisiert.",
|
"updatedLPR": "Nummernschild erfolgreich aktualisiert.",
|
||||||
"regenerate": "Eine neue Beschreibung wurde von {{provider}} angefordert. Je nach Geschwindigkeit des Anbieters kann es einige Zeit dauern, bis die neue Beschreibung generiert ist.",
|
"regenerate": "Eine neue Beschreibung wurde von {{provider}} angefordert. Je nach Geschwindigkeit des Anbieters kann es einige Zeit dauern, bis die neue Beschreibung generiert ist.",
|
||||||
"audioTranscription": "Audio Transkription erfolgreich angefordert."
|
"audioTranscription": "Die Audio-Transkription wurde erfolgreich angefordert. Je nach Geschwindigkeit Ihres Frigate-Servers kann die Transkription einige Zeit in Anspruch nehmen."
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"regenerate": "Der Aufruf von {{provider}} für eine neue Beschreibung ist fehlgeschlagen: {{errorMessage}}",
|
"regenerate": "Der Aufruf von {{provider}} für eine neue Beschreibung ist fehlgeschlagen: {{errorMessage}}",
|
||||||
@ -159,7 +159,8 @@
|
|||||||
"video": "Video",
|
"video": "Video",
|
||||||
"object_lifecycle": "Objekt-Lebenszyklus",
|
"object_lifecycle": "Objekt-Lebenszyklus",
|
||||||
"snapshot": "Snapshot",
|
"snapshot": "Snapshot",
|
||||||
"thumbnail": "Vorschaubild"
|
"thumbnail": "Vorschaubild",
|
||||||
|
"tracking_details": "Nachverfolgungs-Details"
|
||||||
},
|
},
|
||||||
"itemMenu": {
|
"itemMenu": {
|
||||||
"downloadSnapshot": {
|
"downloadSnapshot": {
|
||||||
@ -249,13 +250,14 @@
|
|||||||
"faceOrLicense_plate": "{{attribute}} erkannt für {{label}}",
|
"faceOrLicense_plate": "{{attribute}} erkannt für {{label}}",
|
||||||
"other": "{{label}} erkannt als {{attribute}}"
|
"other": "{{label}} erkannt als {{attribute}}"
|
||||||
},
|
},
|
||||||
"gone": "{{label}} verließ",
|
"gone": "{{label}} hat verlassen",
|
||||||
"heard": "{{label}} wurde gehört",
|
"heard": "{{label}} wurde gehört",
|
||||||
"external": "{{label}} erkannt",
|
"external": "{{label}} erkannt",
|
||||||
"header": {
|
"header": {
|
||||||
"zones": "Zonen",
|
"zones": "Zonen",
|
||||||
"ratio": "Verhältnis",
|
"ratio": "Verhältnis",
|
||||||
"area": "Bereich"
|
"area": "Bereich",
|
||||||
|
"score": "Bewertung"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"annotationSettings": {
|
"annotationSettings": {
|
||||||
@ -278,6 +280,10 @@
|
|||||||
"previous": "Vorherige Anzeige",
|
"previous": "Vorherige Anzeige",
|
||||||
"next": "Nächste Anzeige"
|
"next": "Nächste Anzeige"
|
||||||
},
|
},
|
||||||
"title": "Verfolgungsdetails"
|
"title": "Verfolgungsdetails",
|
||||||
|
"adjustAnnotationSettings": "Anmerkungseinstellungen anpassen",
|
||||||
|
"autoTrackingTips": "Die Positionen der Begrenzungsrahmen sind bei Kameras mit automatischer Verfolgung ungenau.",
|
||||||
|
"count": "{{first}} von {{second}}",
|
||||||
|
"trackedPoint": "Verfolgter Punkt"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -64,7 +64,7 @@
|
|||||||
"deletedName_other": "{{count}} Gesichter wurden erfolgreich gelöscht.",
|
"deletedName_other": "{{count}} Gesichter wurden erfolgreich gelöscht.",
|
||||||
"addFaceLibrary": "{{name}} wurde erfolgreich in die Gesichtsbibliothek aufgenommen!",
|
"addFaceLibrary": "{{name}} wurde erfolgreich in die Gesichtsbibliothek aufgenommen!",
|
||||||
"trainedFace": "Gesicht erfolgreich trainiert.",
|
"trainedFace": "Gesicht erfolgreich trainiert.",
|
||||||
"updatedFaceScore": "Gesichtsbewertung erfolgreich aktualisiert.",
|
"updatedFaceScore": "Gesichtsbewertung erfolgreich auf {{name}} ({{score}}) aktualisiert.",
|
||||||
"renamedFace": "Gesicht erfolgreich in {{name}} umbenannt"
|
"renamedFace": "Gesicht erfolgreich in {{name}} umbenannt"
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
|
|||||||
@ -174,7 +174,11 @@
|
|||||||
"noCameras": {
|
"noCameras": {
|
||||||
"title": "Keine Kameras eingerichtet",
|
"title": "Keine Kameras eingerichtet",
|
||||||
"description": "Beginne indem du eine Kamera anschließt.",
|
"description": "Beginne indem du eine Kamera anschließt.",
|
||||||
"buttonText": "Kamera hinzufügen"
|
"buttonText": "Kamera hinzufügen",
|
||||||
|
"restricted": {
|
||||||
|
"title": "Keine Kamera verfügbar",
|
||||||
|
"description": "Sie haben keine Berechtigung, Kameras in dieser Gruppe anzuzeigen."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"snapshot": {
|
"snapshot": {
|
||||||
"takeSnapshot": "Sofort-Schnappschuss herunterladen",
|
"takeSnapshot": "Sofort-Schnappschuss herunterladen",
|
||||||
|
|||||||
@ -41,7 +41,7 @@
|
|||||||
"noCamera": "Keine Kamera"
|
"noCamera": "Keine Kamera"
|
||||||
},
|
},
|
||||||
"general": {
|
"general": {
|
||||||
"title": "Allgemeine Einstellungen",
|
"title": "Einstellungen der Benutzeroberfläche",
|
||||||
"liveDashboard": {
|
"liveDashboard": {
|
||||||
"title": "Live Übersicht",
|
"title": "Live Übersicht",
|
||||||
"playAlertVideos": {
|
"playAlertVideos": {
|
||||||
@ -51,6 +51,14 @@
|
|||||||
"automaticLiveView": {
|
"automaticLiveView": {
|
||||||
"desc": "Wechsle automatisch zur Live Ansicht der Kamera, wenn einen Aktivität erkannt wurde. Wenn du diese Option deaktivierst, werden die statischen Kamerabilder auf der Liveübersicht nur einmal pro Minute aktualisiert.",
|
"desc": "Wechsle automatisch zur Live Ansicht der Kamera, wenn einen Aktivität erkannt wurde. Wenn du diese Option deaktivierst, werden die statischen Kamerabilder auf der Liveübersicht nur einmal pro Minute aktualisiert.",
|
||||||
"label": "Automatische Live Ansicht"
|
"label": "Automatische Live Ansicht"
|
||||||
|
},
|
||||||
|
"displayCameraNames": {
|
||||||
|
"label": "Immer Namen der Kamera anzeigen",
|
||||||
|
"desc": "Kamerabezeichnung immer im einem Chip im Live-View-Dashboard für mehrere Kameras anzeigen."
|
||||||
|
},
|
||||||
|
"liveFallbackTimeout": {
|
||||||
|
"label": "Live Player Ausfallzeitlimit",
|
||||||
|
"desc": "Wenn der hochwertige Live-Stream einer Kamera nicht verfügbar ist, wechsle nach dieser Anzahl von Sekunden in den Modus für geringe Bandbreite. Standard: 3."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"storedLayouts": {
|
"storedLayouts": {
|
||||||
@ -909,7 +917,8 @@
|
|||||||
"steps": {
|
"steps": {
|
||||||
"nameAndConnection": "Name & Verbindung",
|
"nameAndConnection": "Name & Verbindung",
|
||||||
"streamConfiguration": "Stream Konfiguration",
|
"streamConfiguration": "Stream Konfiguration",
|
||||||
"validationAndTesting": "Überprüfung & Testen"
|
"validationAndTesting": "Überprüfung & Testen",
|
||||||
|
"probeOrSnapshot": "Sondierung oder Momentaufnahme"
|
||||||
},
|
},
|
||||||
"save": {
|
"save": {
|
||||||
"success": "Neue Kamera {{cameraName}} erfolgreich hinzugefügt.",
|
"success": "Neue Kamera {{cameraName}} erfolgreich hinzugefügt.",
|
||||||
@ -926,7 +935,7 @@
|
|||||||
"testFailed": "Stream Test fehlgeschlagen: {{error}}"
|
"testFailed": "Stream Test fehlgeschlagen: {{error}}"
|
||||||
},
|
},
|
||||||
"step1": {
|
"step1": {
|
||||||
"description": "Gib deine Kameradaten ein und teste die Verbindung.",
|
"description": "Geben Sie Ihre Kameradaten ein und wählen Sie, ob Sie die Kamera automatisch erkennen lassen oder die Marke manuell auswählen möchten.",
|
||||||
"cameraName": "Kamera-Name",
|
"cameraName": "Kamera-Name",
|
||||||
"cameraNamePlaceholder": "z.B. vordere_tür oder Hof Übersicht",
|
"cameraNamePlaceholder": "z.B. vordere_tür oder Hof Übersicht",
|
||||||
"host": "Host/IP Adresse",
|
"host": "Host/IP Adresse",
|
||||||
@ -957,14 +966,23 @@
|
|||||||
"nameExists": "Kamera-Name existiert bereits",
|
"nameExists": "Kamera-Name existiert bereits",
|
||||||
"brands": {
|
"brands": {
|
||||||
"reolink-rtsp": "Reolink RTSP wird nicht empfohlen. Es wird empfohlen, http in den Kameraeinstellungen zu aktivieren und den Kamera-Assistenten neu zu starten."
|
"reolink-rtsp": "Reolink RTSP wird nicht empfohlen. Es wird empfohlen, http in den Kameraeinstellungen zu aktivieren und den Kamera-Assistenten neu zu starten."
|
||||||
}
|
},
|
||||||
|
"customUrlRtspRequired": "Benutzerdefinierte URLs müssen mit „rtsp://“ beginnen. Für Nicht-RTSP-Kamerastreams ist eine manuelle Konfiguration erforderlich."
|
||||||
},
|
},
|
||||||
"docs": {
|
"docs": {
|
||||||
"reolink": "https://docs.frigate.video/configuration/camera_specific.html#reolink-cameras"
|
"reolink": "https://docs.frigate.video/configuration/camera_specific.html#reolink-cameras"
|
||||||
}
|
},
|
||||||
|
"connectionSettings": "Verbindungseinstellungen",
|
||||||
|
"detectionMethod": "Stream Erkennungsmethode",
|
||||||
|
"onvifPort": "ONVIF Port",
|
||||||
|
"probeMode": "Sondenkamera",
|
||||||
|
"detectionMethodDescription": "Suchen Sie die Kamera mit ONVIF (sofern unterstützt), um die URLs der Kamerastreams zu finden, oder wählen Sie manuell die Kameramarke aus, um vordefinierte URLs zu verwenden. Um eine benutzerdefinierte RTSP-URL einzugeben, wählen Sie die manuelle Methode und dann „Andere“.",
|
||||||
|
"onvifPortDescription": "Bei Kameras, die ONVIF unterstützen, ist dies in der Regel 80 oder 8080.",
|
||||||
|
"useDigestAuth": "Digest-Authentifizierung verwenden",
|
||||||
|
"useDigestAuthDescription": "Verwenden Sie die HTTP-Digest-Authentifizierung für ONVIF. Einige Kameras erfordern möglicherweise einen speziellen ONVIF-Benutzernamen/ein spezielles ONVIF-Passwort anstelle des Standard-Admin-Benutzers."
|
||||||
},
|
},
|
||||||
"step2": {
|
"step2": {
|
||||||
"description": "Konfigurieren Sie Stream-Rollen und fügen Sie zusätzliche Streams für Ihre Kamera hinzu.",
|
"description": "Suchen Sie in der Kamera nach verfügbaren Streams oder konfigurieren Sie manuelle Einstellungen basierend auf der von Ihnen ausgewählten Erkennungsmethode.",
|
||||||
"streamsTitle": "Kamera Streams",
|
"streamsTitle": "Kamera Streams",
|
||||||
"addStream": "Stream hinzufügen",
|
"addStream": "Stream hinzufügen",
|
||||||
"addAnotherStream": "Weiteren Stream hinzufügen",
|
"addAnotherStream": "Weiteren Stream hinzufügen",
|
||||||
@ -983,8 +1001,8 @@
|
|||||||
"audio": "Audio"
|
"audio": "Audio"
|
||||||
},
|
},
|
||||||
"testStream": "Verbindung testen",
|
"testStream": "Verbindung testen",
|
||||||
"testSuccess": "Stream erfolgreich getestet!",
|
"testSuccess": "Verbindung erfolgreich getestet!",
|
||||||
"testFailed": "Stream-Test fehlgeschlagen",
|
"testFailed": "Verbindungstest fehlgeschlagen. Bitte überprüfen sie Eingabe und versuchen sie es wieder.",
|
||||||
"testFailedTitle": "Test fehlgeschlagen",
|
"testFailedTitle": "Test fehlgeschlagen",
|
||||||
"connected": "Verbunden",
|
"connected": "Verbunden",
|
||||||
"notConnected": "Nicht verbunden",
|
"notConnected": "Nicht verbunden",
|
||||||
@ -1000,7 +1018,27 @@
|
|||||||
"featuresPopover": {
|
"featuresPopover": {
|
||||||
"title": "Stream Funktionen",
|
"title": "Stream Funktionen",
|
||||||
"description": "Verwende go2rtc Restreaming, um die Verbindungen zu deiner Kamera zu reduzieren."
|
"description": "Verwende go2rtc Restreaming, um die Verbindungen zu deiner Kamera zu reduzieren."
|
||||||
}
|
},
|
||||||
|
"streamDetails": "Verbindungsdetails",
|
||||||
|
"probing": "Kamera wird geprüft...",
|
||||||
|
"retry": "Wiederholen",
|
||||||
|
"testing": {
|
||||||
|
"probingMetadata": "Metadaten der Kamera werden überprüft...",
|
||||||
|
"fetchingSnapshot": "Kamera-Schnappschuss wird abgerufen..."
|
||||||
|
},
|
||||||
|
"probeFailed": "Fehler beim Testen der Kamera: {{error}}",
|
||||||
|
"probingDevice": "Prüfung Gerät...",
|
||||||
|
"probeSuccessful": "Sonde erfolgreich",
|
||||||
|
"probeError": "Sondenfehler",
|
||||||
|
"probeNoSuccess": "Sonde erfolglos",
|
||||||
|
"deviceInfo": "Geräteinformationen",
|
||||||
|
"manufacturer": "Hersteller",
|
||||||
|
"model": "Modell",
|
||||||
|
"firmware": "Firmware",
|
||||||
|
"profiles": "Profile",
|
||||||
|
"ptzSupport": "PTZ Unterstützung",
|
||||||
|
"autotrackingSupport": "Unterstützung für Autoverfolgung",
|
||||||
|
"presets": "Voreinstellung"
|
||||||
},
|
},
|
||||||
"step3": {
|
"step3": {
|
||||||
"description": "Endgültige Validierung und Analyse vor dem Speichern Ihrer neuen Kamera. Verbinde jeden Stream vor dem Speichern.",
|
"description": "Endgültige Validierung und Analyse vor dem Speichern Ihrer neuen Kamera. Verbinde jeden Stream vor dem Speichern.",
|
||||||
|
|||||||
@ -31,7 +31,12 @@
|
|||||||
"gpuDecoder": "GPU Decoder",
|
"gpuDecoder": "GPU Decoder",
|
||||||
"gpuEncoder": "GPU Encoder",
|
"gpuEncoder": "GPU Encoder",
|
||||||
"npuUsage": "NPU Verwendung",
|
"npuUsage": "NPU Verwendung",
|
||||||
"npuMemory": "NPU Speicher"
|
"npuMemory": "NPU Speicher",
|
||||||
|
"intelGpuWarning": {
|
||||||
|
"title": "Intel GPU Statistik Warnung",
|
||||||
|
"message": "GPU stats nicht verfügbar",
|
||||||
|
"description": "Dies ist ein bekannter Fehler in den GPU-Statistik-Tools von Intel (intel_gpu_top), bei dem das Tool ausfällt und wiederholt eine GPU-Auslastung von 0 % anzeigt, selbst wenn die Hardwarebeschleunigung und die Objekterkennung auf der (i)GPU korrekt funktionieren. Dies ist kein Fehler von Frigate. Du kannst den Host neu starten, um das Problem vorübergehend zu beheben und zu prüfen, ob die GPU korrekt funktioniert. Dies hat keine Auswirkungen auf die Leistung."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"title": "Allgemein",
|
"title": "Allgemein",
|
||||||
"detector": {
|
"detector": {
|
||||||
@ -167,10 +172,17 @@
|
|||||||
"face_recognition": "Gesichts Erkennung",
|
"face_recognition": "Gesichts Erkennung",
|
||||||
"image_embedding": "Bild Embedding",
|
"image_embedding": "Bild Embedding",
|
||||||
"yolov9_plate_detection_speed": "YOLOv9 Kennzeichenerkennungsgeschwindigkeit",
|
"yolov9_plate_detection_speed": "YOLOv9 Kennzeichenerkennungsgeschwindigkeit",
|
||||||
"yolov9_plate_detection": "YOLOv9 Kennzeichenerkennung"
|
"yolov9_plate_detection": "YOLOv9 Kennzeichenerkennung",
|
||||||
|
"review_description": "Bewertung Beschreibung",
|
||||||
|
"review_description_speed": "Bewertungsbeschreibung Geschwindigkeit",
|
||||||
|
"review_description_events_per_second": "Bewertungsbeschreibung",
|
||||||
|
"object_description": "Objekt Beschreibung",
|
||||||
|
"object_description_speed": "Objektbeschreibung Geschwindigkeit",
|
||||||
|
"object_description_events_per_second": "Objektbeschreibung"
|
||||||
},
|
},
|
||||||
"title": "Optimierungen",
|
"title": "Optimierungen",
|
||||||
"infPerSecond": "Rückschlüsse pro Sekunde"
|
"infPerSecond": "Rückschlüsse pro Sekunde",
|
||||||
|
"averageInf": "Durchschnittliche Inferenzzeit"
|
||||||
},
|
},
|
||||||
"stats": {
|
"stats": {
|
||||||
"healthy": "Das System läuft problemlos",
|
"healthy": "Das System läuft problemlos",
|
||||||
|
|||||||
@ -166,6 +166,7 @@
|
|||||||
"noImages": "No sample images generated",
|
"noImages": "No sample images generated",
|
||||||
"classifying": "Classifying & Training...",
|
"classifying": "Classifying & Training...",
|
||||||
"trainingStarted": "Training started successfully",
|
"trainingStarted": "Training started successfully",
|
||||||
|
"modelCreated": "Model created successfully. Use the Recent Classifications view to add images for missing states, then train the model.",
|
||||||
"errors": {
|
"errors": {
|
||||||
"noCameras": "No cameras configured",
|
"noCameras": "No cameras configured",
|
||||||
"noObjectLabel": "No object label selected",
|
"noObjectLabel": "No object label selected",
|
||||||
@ -173,7 +174,11 @@
|
|||||||
"generationFailed": "Generation failed. Please try again.",
|
"generationFailed": "Generation failed. Please try again.",
|
||||||
"classifyFailed": "Failed to classify images: {{error}}"
|
"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",
|
"selected_other": "{{count}} selected",
|
||||||
"camera": "Camera",
|
"camera": "Camera",
|
||||||
"detected": "detected",
|
"detected": "detected",
|
||||||
"suspiciousActivity": "Suspicious Activity",
|
"normalActivity": "Normal",
|
||||||
"threateningActivity": "Threatening Activity"
|
"needsReview": "Needs review",
|
||||||
|
"securityConcern": "Security concern"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -65,7 +65,8 @@
|
|||||||
"endTimeMustAfterStartTime": "L'heure de fin doit être postérieure à l'heure de début.",
|
"endTimeMustAfterStartTime": "L'heure de fin doit être postérieure à l'heure de début.",
|
||||||
"noVaildTimeSelected": "La plage horaire sélectionnée n'est pas valide."
|
"noVaildTimeSelected": "La plage horaire sélectionnée n'est pas valide."
|
||||||
},
|
},
|
||||||
"success": "Exportation démarrée avec succès. Consultez le fichier sur la page des exportations."
|
"success": "Exportation démarrée avec succès. Consultez le fichier sur la page des exportations.",
|
||||||
|
"view": "Vue"
|
||||||
},
|
},
|
||||||
"select": "Sélectionner",
|
"select": "Sélectionner",
|
||||||
"name": {
|
"name": {
|
||||||
|
|||||||
@ -263,7 +263,8 @@
|
|||||||
"header": {
|
"header": {
|
||||||
"zones": "Zones",
|
"zones": "Zones",
|
||||||
"ratio": "Ratio",
|
"ratio": "Ratio",
|
||||||
"area": "Surface"
|
"area": "Surface",
|
||||||
|
"score": "Score"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"annotationSettings": {
|
"annotationSettings": {
|
||||||
|
|||||||
@ -61,7 +61,8 @@
|
|||||||
"failed": "Klarte ikke å starte eksport: {{error}}",
|
"failed": "Klarte ikke å starte eksport: {{error}}",
|
||||||
"noVaildTimeSelected": "Ingen gyldig tidsperiode valgt",
|
"noVaildTimeSelected": "Ingen gyldig tidsperiode valgt",
|
||||||
"endTimeMustAfterStartTime": "Sluttid må være etter starttid"
|
"endTimeMustAfterStartTime": "Sluttid må være etter starttid"
|
||||||
}
|
},
|
||||||
|
"view": "Vis"
|
||||||
},
|
},
|
||||||
"fromTimeline": {
|
"fromTimeline": {
|
||||||
"previewExport": "Forhåndsvis eksport",
|
"previewExport": "Forhåndsvis eksport",
|
||||||
|
|||||||
@ -23,8 +23,8 @@
|
|||||||
"label": "Sorter",
|
"label": "Sorter",
|
||||||
"dateAsc": "Dato (Stigende)",
|
"dateAsc": "Dato (Stigende)",
|
||||||
"dateDesc": "Dato (Synkende)",
|
"dateDesc": "Dato (Synkende)",
|
||||||
"scoreAsc": "Objektpoengsum (Stigende)",
|
"scoreAsc": "Objektscore (Stigende)",
|
||||||
"scoreDesc": "Objektpoengsum (Synkende)",
|
"scoreDesc": "Objektscore (Synkende)",
|
||||||
"speedAsc": "Estimert hastighet (Stigende)",
|
"speedAsc": "Estimert hastighet (Stigende)",
|
||||||
"speedDesc": "Estimert hastighet (Synkende)",
|
"speedDesc": "Estimert hastighet (Synkende)",
|
||||||
"relevance": "Relevans"
|
"relevance": "Relevans"
|
||||||
@ -104,7 +104,7 @@
|
|||||||
"label": "Underetiketter",
|
"label": "Underetiketter",
|
||||||
"all": "Alle underetiketter"
|
"all": "Alle underetiketter"
|
||||||
},
|
},
|
||||||
"score": "Poengsum",
|
"score": "Score",
|
||||||
"estimatedSpeed": "Estimert hastighet ({{unit}})",
|
"estimatedSpeed": "Estimert hastighet ({{unit}})",
|
||||||
"cameras": {
|
"cameras": {
|
||||||
"all": {
|
"all": {
|
||||||
|
|||||||
@ -12,19 +12,19 @@
|
|||||||
},
|
},
|
||||||
"toast": {
|
"toast": {
|
||||||
"success": {
|
"success": {
|
||||||
"deletedCategory": "Kategori slettet",
|
"deletedCategory": "Klasse slettet",
|
||||||
"deletedImage": "Bilder slettet",
|
"deletedImage": "Bilder slettet",
|
||||||
"categorizedImage": "Bildet ble klassifisert",
|
"categorizedImage": "Klassifiserte bildet",
|
||||||
"trainedModel": "Modellen ble trent.",
|
"trainedModel": "Modellen ble trent.",
|
||||||
"trainingModel": "Modelltrening startet.",
|
"trainingModel": "Modelltrening startet.",
|
||||||
"deletedModel_one": "{{count}} modell ble slettet",
|
"deletedModel_one": "{{count}} modell ble slettet",
|
||||||
"deletedModel_other": "{{count}} modeller ble slettet",
|
"deletedModel_other": "{{count}} modeller ble slettet",
|
||||||
"updatedModel": "Modellkonfigurasjonen ble oppdatert",
|
"updatedModel": "Modellkonfigurasjonen ble oppdatert",
|
||||||
"renamedCategory": "Kategorien ble omdøpt til {{name}}"
|
"renamedCategory": "Klassen ble omdøpt til {{name}}"
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"deleteImageFailed": "Kunne ikke slette: {{errorMessage}}",
|
"deleteImageFailed": "Kunne ikke slette: {{errorMessage}}",
|
||||||
"deleteCategoryFailed": "Kunne ikke slette kategori: {{errorMessage}}",
|
"deleteCategoryFailed": "Kunne ikke slette klasse: {{errorMessage}}",
|
||||||
"categorizeFailed": "Kunne ikke klassifisere bilde: {{errorMessage}}",
|
"categorizeFailed": "Kunne ikke klassifisere bilde: {{errorMessage}}",
|
||||||
"trainingFailed": "Modelltrening mislyktes. Sjekk Frigate-loggene for detaljer.",
|
"trainingFailed": "Modelltrening mislyktes. Sjekk Frigate-loggene for detaljer.",
|
||||||
"deleteModelFailed": "Kunne ikke slette modell: {{errorMessage}}",
|
"deleteModelFailed": "Kunne ikke slette modell: {{errorMessage}}",
|
||||||
@ -34,10 +34,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"deleteCategory": {
|
"deleteCategory": {
|
||||||
"title": "Slett kategori",
|
"title": "Slett klasse",
|
||||||
"desc": "Er du sikker på at du vil slette kategorien {{name}}? Dette vil permanent slette alle tilknyttede bilder og kreve at modellen trenes på nytt.",
|
"desc": "Er du sikker på at du vil slette klassen {{name}}? Dette vil permanent slette alle tilknyttede bilder og kreve at modellen trenes på nytt.",
|
||||||
"minClassesTitle": "Kan ikke slette klasse",
|
"minClassesTitle": "Kan ikke slette klasse",
|
||||||
"minClassesDesc": "En klassifiseringsmodell må ha minst 2 kategorier. Legg til en ny kategori før du sletter denne."
|
"minClassesDesc": "En klassifiseringsmodell må ha minst 2 klasser. Legg til en ny klasse før du sletter denne."
|
||||||
},
|
},
|
||||||
"deleteDatasetImages": {
|
"deleteDatasetImages": {
|
||||||
"title": "Slett datasettbilder",
|
"title": "Slett datasettbilder",
|
||||||
@ -48,7 +48,7 @@
|
|||||||
"desc": "Er du sikker på at du vil slette {{count}} bilder? Denne handlingen kan ikke angres."
|
"desc": "Er du sikker på at du vil slette {{count}} bilder? Denne handlingen kan ikke angres."
|
||||||
},
|
},
|
||||||
"renameCategory": {
|
"renameCategory": {
|
||||||
"title": "Gi nytt navn til kategori",
|
"title": "Omdøp klasse",
|
||||||
"desc": "Skriv inn et nytt navn for {{name}}. Du må trene modellen på nytt for at navneendringen skal tre i kraft."
|
"desc": "Skriv inn et nytt navn for {{name}}. Du må trene modellen på nytt for at navneendringen skal tre i kraft."
|
||||||
},
|
},
|
||||||
"description": {
|
"description": {
|
||||||
@ -59,9 +59,9 @@
|
|||||||
"aria": "Velg nylige klassifiseringer",
|
"aria": "Velg nylige klassifiseringer",
|
||||||
"titleShort": "Nylig"
|
"titleShort": "Nylig"
|
||||||
},
|
},
|
||||||
"categories": "Kategorier",
|
"categories": "Klasser",
|
||||||
"createCategory": {
|
"createCategory": {
|
||||||
"new": "Opprett ny kategori"
|
"new": "Opprett ny klasse"
|
||||||
},
|
},
|
||||||
"categorizeImageAs": "Klassifiser bilde som:",
|
"categorizeImageAs": "Klassifiser bilde som:",
|
||||||
"categorizeImage": "Klassifiser bilde",
|
"categorizeImage": "Klassifiser bilde",
|
||||||
@ -98,18 +98,18 @@
|
|||||||
"classificationTypeDesc": "Underetiketter legger til ekstra tekst på objektetiketten (f.eks. 'Person: Posten'). Attributter er søkbare metadata som lagres separat i objektets metadata.",
|
"classificationTypeDesc": "Underetiketter legger til ekstra tekst på objektetiketten (f.eks. 'Person: Posten'). Attributter er søkbare metadata som lagres separat i objektets metadata.",
|
||||||
"classificationSubLabel": "Underetikett",
|
"classificationSubLabel": "Underetikett",
|
||||||
"classificationAttribute": "Attributt",
|
"classificationAttribute": "Attributt",
|
||||||
"classes": "Kategorier",
|
"classes": "Klasser",
|
||||||
"classesTip": "Lær om kategorier",
|
"classesTip": "Lær om klasser",
|
||||||
"classesStateDesc": "Definer de ulike tilstandene kamerasonen kan være i. For eksempel: 'åpen' og 'lukket' for en garasjeport.",
|
"classesStateDesc": "Definer de ulike tilstandene kamerasonen kan være i. For eksempel: 'åpen' og 'lukket' for en garasjeport.",
|
||||||
"classesObjectDesc": "Definer kategoriene du vil klassifisere oppdagede objekter i. For eksempel: 'bud', 'beboer', 'fremmed' for personklassifisering.",
|
"classesObjectDesc": "Definer klassene du vil klassifisere oppdagede objekter i. For eksempel: 'bud', 'beboer', 'fremmed' for personklassifisering.",
|
||||||
"classPlaceholder": "Skriv inn tilstandsnavn...",
|
"classPlaceholder": "Skriv inn klassenavn...",
|
||||||
"errors": {
|
"errors": {
|
||||||
"nameRequired": "Modellnavn er påkrevd",
|
"nameRequired": "Modellnavn er påkrevd",
|
||||||
"nameLength": "Modellnavn må være på 64 tegn eller mindre",
|
"nameLength": "Modellnavn må være på 64 tegn eller mindre",
|
||||||
"nameOnlyNumbers": "Modellnavn kan ikke bare inneholde tall",
|
"nameOnlyNumbers": "Modellnavn kan ikke bare inneholde tall",
|
||||||
"classRequired": "Minst én kategori er påkrevd",
|
"classRequired": "Minst én klasse er påkrevd",
|
||||||
"classesUnique": "Kategorinavn må være unike",
|
"classesUnique": "Klassenavn må være unike",
|
||||||
"stateRequiresTwoClasses": "Tilstandsmodeller krever minst to kategorier",
|
"stateRequiresTwoClasses": "Tilstandsmodeller krever minst to klasser",
|
||||||
"objectLabelRequired": "Velg en objektetikett",
|
"objectLabelRequired": "Velg en objektetikett",
|
||||||
"objectTypeRequired": "Velg en klassifiseringstype"
|
"objectTypeRequired": "Velg en klassifiseringstype"
|
||||||
},
|
},
|
||||||
@ -124,7 +124,7 @@
|
|||||||
},
|
},
|
||||||
"step3": {
|
"step3": {
|
||||||
"selectImagesPrompt": "Velg alle bilder med: {{className}}",
|
"selectImagesPrompt": "Velg alle bilder med: {{className}}",
|
||||||
"selectImagesDescription": "Klikk på bilder for å velge dem. Klikk Fortsett når du er ferdig med denne kategorien.",
|
"selectImagesDescription": "Klikk på bilder for å velge dem. Klikk Fortsett når du er ferdig med denne klassen.",
|
||||||
"generating": {
|
"generating": {
|
||||||
"title": "Genererer eksempelbilder",
|
"title": "Genererer eksempelbilder",
|
||||||
"description": "Frigate henter representative bilder fra opptakene dine. Dette kan ta litt tid..."
|
"description": "Frigate henter representative bilder fra opptakene dine. Dette kan ta litt tid..."
|
||||||
@ -159,7 +159,7 @@
|
|||||||
"states": "Tilstander"
|
"states": "Tilstander"
|
||||||
},
|
},
|
||||||
"details": {
|
"details": {
|
||||||
"scoreInfo": "Poengsummen representerer gjennomsnittlig klassifiseringskonfidens på tvers av alle deteksjoner av dette objektet."
|
"scoreInfo": "Score representerer gjennomsnittlig klassifiseringskonfidens på tvers av alle deteksjoner av dette objektet."
|
||||||
},
|
},
|
||||||
"tooltip": {
|
"tooltip": {
|
||||||
"trainingInProgress": "Modellen trenes for øyeblikket",
|
"trainingInProgress": "Modellen trenes for øyeblikket",
|
||||||
|
|||||||
@ -90,7 +90,7 @@
|
|||||||
"updatedSublabel": "Underetikett ble oppdatert.",
|
"updatedSublabel": "Underetikett ble oppdatert.",
|
||||||
"updatedLPR": "Vellykket oppdatering av kjennemerke.",
|
"updatedLPR": "Vellykket oppdatering av kjennemerke.",
|
||||||
"regenerate": "En ny beskrivelse har blitt anmodet fra {{provider}}. Avhengig av hastigheten til leverandøren din, kan den nye beskrivelsen ta litt tid å regenerere.",
|
"regenerate": "En ny beskrivelse har blitt anmodet fra {{provider}}. Avhengig av hastigheten til leverandøren din, kan den nye beskrivelsen ta litt tid å regenerere.",
|
||||||
"audioTranscription": "Lydtranskripsjon ble forespurt."
|
"audioTranscription": "Lydtranskripsjon ble forespurt. Avhengig av ytelsen på din Frigate server kan transkripsjonen ta noe tid å fullføre."
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"regenerate": "Feil ved anrop til {{provider}} for en ny beskrivelse: {{errorMessage}}",
|
"regenerate": "Feil ved anrop til {{provider}} for en ny beskrivelse: {{errorMessage}}",
|
||||||
@ -107,8 +107,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"topScore": {
|
"topScore": {
|
||||||
"info": "Den høyeste poengsummen er den høyeste medianverdi for det sporede objektet, så denne kan avvike fra poengsummen som vises på miniatyrbildet for søkeresultatet.",
|
"info": "Toppscoren er den høyeste medianverdien for det sporede objektet, så denne kan avvike fra scoren som vises på miniatyrbildet i søkeresultatet.",
|
||||||
"label": "Høyeste poengsum"
|
"label": "Toppscore"
|
||||||
},
|
},
|
||||||
"estimatedSpeed": "Estimert hastighet",
|
"estimatedSpeed": "Estimert hastighet",
|
||||||
"objects": "Objekter",
|
"objects": "Objekter",
|
||||||
@ -147,10 +147,10 @@
|
|||||||
"descNoLabel": "Angi en ny underetikett for dette sporede objektet"
|
"descNoLabel": "Angi en ny underetikett for dette sporede objektet"
|
||||||
},
|
},
|
||||||
"snapshotScore": {
|
"snapshotScore": {
|
||||||
"label": "Øyeblikksbilde poengsum"
|
"label": "Øyeblikksbilde score"
|
||||||
},
|
},
|
||||||
"score": {
|
"score": {
|
||||||
"label": "Poengsum"
|
"label": "Score"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"itemMenu": {
|
"itemMenu": {
|
||||||
@ -261,7 +261,8 @@
|
|||||||
"header": {
|
"header": {
|
||||||
"zones": "Soner",
|
"zones": "Soner",
|
||||||
"ratio": "Forhold",
|
"ratio": "Forhold",
|
||||||
"area": "Område"
|
"area": "Område",
|
||||||
|
"score": "Score"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"annotationSettings": {
|
"annotationSettings": {
|
||||||
|
|||||||
@ -11,7 +11,7 @@
|
|||||||
"face": "Ansiktsdetaljer",
|
"face": "Ansiktsdetaljer",
|
||||||
"faceDesc": "Detaljer for sporet objekt som genererte dette ansiktet",
|
"faceDesc": "Detaljer for sporet objekt som genererte dette ansiktet",
|
||||||
"timestamp": "Tidsstempel",
|
"timestamp": "Tidsstempel",
|
||||||
"scoreInfo": "Under-merkelappens poengsum er basert på en vektet sum ut ifra hvor sikre gjenkjenningene av ansiktene er, så den kan avvike fra poengsummen som vises på øyeblikksbildet.",
|
"scoreInfo": "Score er et vektet gjennomsnitt av alle ansiktsscorer, vektet etter størrelsen på ansiktet i hvert bilde.",
|
||||||
"subLabelScore": "Poengsum for under-merkelapp",
|
"subLabelScore": "Poengsum for under-merkelapp",
|
||||||
"unknown": "Ukjent"
|
"unknown": "Ukjent"
|
||||||
},
|
},
|
||||||
@ -38,7 +38,7 @@
|
|||||||
"deleteFaceFailed": "Kunne ikke slette: {{errorMessage}}",
|
"deleteFaceFailed": "Kunne ikke slette: {{errorMessage}}",
|
||||||
"uploadingImageFailed": "Kunne ikke laste opp bilde: {{errorMessage}}",
|
"uploadingImageFailed": "Kunne ikke laste opp bilde: {{errorMessage}}",
|
||||||
"trainFailed": "Kunne ikke trene: {{errorMessage}}",
|
"trainFailed": "Kunne ikke trene: {{errorMessage}}",
|
||||||
"updateFaceScoreFailed": "Kunne ikke oppdatere ansiktsskåring: {{errorMessage}}",
|
"updateFaceScoreFailed": "Kunne ikke oppdatere ansiktsscore: {{errorMessage}}",
|
||||||
"addFaceLibraryFailed": "Kunne ikke angi ansiktsnavn: {{errorMessage}}",
|
"addFaceLibraryFailed": "Kunne ikke angi ansiktsnavn: {{errorMessage}}",
|
||||||
"deleteNameFailed": "Kunne ikke slette navn: {{errorMessage}}",
|
"deleteNameFailed": "Kunne ikke slette navn: {{errorMessage}}",
|
||||||
"renameFaceFailed": "Kunne ikke gi nytt navn til ansikt: {{errorMessage}}"
|
"renameFaceFailed": "Kunne ikke gi nytt navn til ansikt: {{errorMessage}}"
|
||||||
@ -49,7 +49,7 @@
|
|||||||
"deletedName_one": "{{count}} ansikt ble slettet.",
|
"deletedName_one": "{{count}} ansikt ble slettet.",
|
||||||
"deletedName_other": "{{count}} ansikter ble slettet.",
|
"deletedName_other": "{{count}} ansikter ble slettet.",
|
||||||
"trainedFace": "Ansiktet ble trent.",
|
"trainedFace": "Ansiktet ble trent.",
|
||||||
"updatedFaceScore": "Ansiktsskåring ble oppdatert til {{name}} ({{score}}).",
|
"updatedFaceScore": "Oppdaterte ansiktsscore for {{name}} ({{score}}).",
|
||||||
"uploadedImage": "Bildet ble lastet opp.",
|
"uploadedImage": "Bildet ble lastet opp.",
|
||||||
"addFaceLibrary": "{{name}} ble lagt til i ansiktsbiblioteket!",
|
"addFaceLibrary": "{{name}} ble lagt til i ansiktsbiblioteket!",
|
||||||
"renamedFace": "Nytt navn ble gitt til ansikt {{name}}"
|
"renamedFace": "Nytt navn ble gitt til ansikt {{name}}"
|
||||||
|
|||||||
@ -15,8 +15,8 @@
|
|||||||
"labels": "Etiketter",
|
"labels": "Etiketter",
|
||||||
"search_type": "Søketype",
|
"search_type": "Søketype",
|
||||||
"after": "Etter",
|
"after": "Etter",
|
||||||
"min_score": "Min. poengsum",
|
"min_score": "Min. score",
|
||||||
"max_score": "Maks. poengsum",
|
"max_score": "Maks. score",
|
||||||
"min_speed": "Min. hastighet",
|
"min_speed": "Min. hastighet",
|
||||||
"zones": "Soner",
|
"zones": "Soner",
|
||||||
"sub_labels": "Underetiketter",
|
"sub_labels": "Underetiketter",
|
||||||
@ -36,8 +36,8 @@
|
|||||||
"minSpeedMustBeLessOrEqualMaxSpeed": "Minimum hastighet 'min_speed' må være mindre enn eller lik maksimum hastighet 'max_speed'.",
|
"minSpeedMustBeLessOrEqualMaxSpeed": "Minimum hastighet 'min_speed' må være mindre enn eller lik maksimum hastighet 'max_speed'.",
|
||||||
"beforeDateBeLaterAfter": "Før-datoen 'before' må være senere enn etter-datoen 'after'.",
|
"beforeDateBeLaterAfter": "Før-datoen 'before' må være senere enn etter-datoen 'after'.",
|
||||||
"afterDatebeEarlierBefore": "Etter-datoen 'after' må være tidligere enn før-datoen 'before'.",
|
"afterDatebeEarlierBefore": "Etter-datoen 'after' må være tidligere enn før-datoen 'before'.",
|
||||||
"minScoreMustBeLessOrEqualMaxScore": "Minimum poengsum 'min_score' må være mindre enn eller lik maksimum poengsum 'max_score'.",
|
"minScoreMustBeLessOrEqualMaxScore": "Minimum score 'min_score' må være mindre enn eller lik maksimum score 'max_score'.",
|
||||||
"maxScoreMustBeGreaterOrEqualMinScore": "Maksimum poengsum 'max_score' må være større enn eller lik minimum poengsum 'min_score'.",
|
"maxScoreMustBeGreaterOrEqualMinScore": "Maksimum score 'max_score' må være større enn eller lik minimum score 'min_score'.",
|
||||||
"maxSpeedMustBeGreaterOrEqualMinSpeed": "Maksimum hastighet 'max_speed' må være større enn eller lik minimum hastighet 'min_speed'."
|
"maxSpeedMustBeGreaterOrEqualMinSpeed": "Maksimum hastighet 'max_speed' må være større enn eller lik minimum hastighet 'min_speed'."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@ -460,7 +460,7 @@
|
|||||||
},
|
},
|
||||||
"objectShapeFilterDrawing": {
|
"objectShapeFilterDrawing": {
|
||||||
"document": "Se dokumentasjonen ",
|
"document": "Se dokumentasjonen ",
|
||||||
"score": "Poengsum",
|
"score": "Score",
|
||||||
"ratio": "Forhold",
|
"ratio": "Forhold",
|
||||||
"area": "Areal",
|
"area": "Areal",
|
||||||
"title": "Tegning av objektformfilter",
|
"title": "Tegning av objektformfilter",
|
||||||
@ -478,7 +478,7 @@
|
|||||||
"audio": {
|
"audio": {
|
||||||
"title": "Lyd",
|
"title": "Lyd",
|
||||||
"noAudioDetections": "Ingen lyddeteksjoner",
|
"noAudioDetections": "Ingen lyddeteksjoner",
|
||||||
"score": "poengsum",
|
"score": "score",
|
||||||
"currentRMS": "Nåværende RMS",
|
"currentRMS": "Nåværende RMS",
|
||||||
"currentdbFS": "Nåværende dbFS"
|
"currentdbFS": "Nåværende dbFS"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -65,7 +65,8 @@
|
|||||||
"noVaildTimeSelected": "Geen geldig tijdsbereik geselecteerd",
|
"noVaildTimeSelected": "Geen geldig tijdsbereik geselecteerd",
|
||||||
"endTimeMustAfterStartTime": "Eindtijd moet na starttijd zijn"
|
"endTimeMustAfterStartTime": "Eindtijd moet na starttijd zijn"
|
||||||
},
|
},
|
||||||
"success": "Export is succesvol gestart. Bekijk het bestand op de exportpagina."
|
"success": "Export is succesvol gestart. Bekijk het bestand op de exportpagina.",
|
||||||
|
"view": "Weergeven"
|
||||||
},
|
},
|
||||||
"fromTimeline": {
|
"fromTimeline": {
|
||||||
"saveExport": "Export opslaan",
|
"saveExport": "Export opslaan",
|
||||||
|
|||||||
@ -261,7 +261,8 @@
|
|||||||
"header": {
|
"header": {
|
||||||
"zones": "Zones",
|
"zones": "Zones",
|
||||||
"ratio": "Verhouding",
|
"ratio": "Verhouding",
|
||||||
"area": "Gebied"
|
"area": "Gebied",
|
||||||
|
"score": "Score"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"annotationSettings": {
|
"annotationSettings": {
|
||||||
|
|||||||
@ -19,19 +19,25 @@
|
|||||||
"trainingModel": "Treinamento do modelo iniciado com sucesso.",
|
"trainingModel": "Treinamento do modelo iniciado com sucesso.",
|
||||||
"deletedModel_one": "{{count}} modelo excluído com sucesso",
|
"deletedModel_one": "{{count}} modelo excluído com sucesso",
|
||||||
"deletedModel_many": "{{count}} modelos excluídos com sucesso",
|
"deletedModel_many": "{{count}} modelos excluídos com sucesso",
|
||||||
"deletedModel_other": "{{count}} modelos excluídos com sucesso"
|
"deletedModel_other": "{{count}} modelos excluídos com sucesso",
|
||||||
|
"updatedModel": "Configuração do modelo atualizada com sucesso",
|
||||||
|
"renamedCategory": "Classe renomeada para {{name}} com sucesso"
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"deleteImageFailed": "Falha ao deletar:{{errorMessage}}",
|
"deleteImageFailed": "Falha ao deletar:{{errorMessage}}",
|
||||||
"deleteCategoryFailed": "Falha ao deletar classe:{{errorMessage}}",
|
"deleteCategoryFailed": "Falha ao deletar classe:{{errorMessage}}",
|
||||||
"categorizeFailed": "Falha ao categorizar imagem:{{errorMessage}}",
|
"categorizeFailed": "Falha ao categorizar imagem:{{errorMessage}}",
|
||||||
"deleteModelFailed": "Falha ao excluir o modelo: {{errorMessage}}",
|
"deleteModelFailed": "Falha ao excluir o modelo: {{errorMessage}}",
|
||||||
"trainingFailed": "Falha ao iniciar o treinamento do modelo: {{errorMessage}}"
|
"trainingFailed": "Falha ao iniciar o treinamento do modelo: {{errorMessage}}",
|
||||||
|
"trainingFailedToStart": "Falha ao iniciar o treinamento do modelo: {{errorMessage}}",
|
||||||
|
"updateModelFailed": "Falha ao atualizar modelo: {{errorMessage}}",
|
||||||
|
"renameCategoryFailed": "Falha ao renomear classe: {{errorMessage}}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"deleteCategory": {
|
"deleteCategory": {
|
||||||
"title": "Excluir Classe",
|
"title": "Excluir Classe",
|
||||||
"desc": "Tem certeza de que deseja excluir a classe {{name}}? Isso excluirá permanentemente todas as imagens associadas e exigirá o treinamento do modelo novamente."
|
"desc": "Tem certeza de que deseja excluir a classe {{name}}? Isso excluirá permanentemente todas as imagens associadas e exigirá o treinamento do modelo novamente.",
|
||||||
|
"minClassesTitle": "Não é possível apagar a classe"
|
||||||
},
|
},
|
||||||
"deleteModel": {
|
"deleteModel": {
|
||||||
"title": "Deletar modelo de classificação",
|
"title": "Deletar modelo de classificação",
|
||||||
|
|||||||
@ -88,7 +88,8 @@
|
|||||||
"failed": "Eroare la pornirea exportului: {{error}}",
|
"failed": "Eroare la pornirea exportului: {{error}}",
|
||||||
"endTimeMustAfterStartTime": "Ora de sfârșit trebuie să fie după ora de început",
|
"endTimeMustAfterStartTime": "Ora de sfârșit trebuie să fie după ora de început",
|
||||||
"noVaildTimeSelected": "Nu a fost selectat un interval de timp valid"
|
"noVaildTimeSelected": "Nu a fost selectat un interval de timp valid"
|
||||||
}
|
},
|
||||||
|
"view": "Vizualizează"
|
||||||
},
|
},
|
||||||
"fromTimeline": {
|
"fromTimeline": {
|
||||||
"saveExport": "Salvează exportul",
|
"saveExport": "Salvează exportul",
|
||||||
|
|||||||
@ -263,7 +263,8 @@
|
|||||||
"header": {
|
"header": {
|
||||||
"zones": "Zone",
|
"zones": "Zone",
|
||||||
"ratio": "Raport",
|
"ratio": "Raport",
|
||||||
"area": "Aria"
|
"area": "Aria",
|
||||||
|
"score": "Scor"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"annotationSettings": {
|
"annotationSettings": {
|
||||||
|
|||||||
@ -445,7 +445,7 @@
|
|||||||
"boiling": "Varenie",
|
"boiling": "Varenie",
|
||||||
"sonar": "Sonar",
|
"sonar": "Sonar",
|
||||||
"arrow": "Šípka",
|
"arrow": "Šípka",
|
||||||
"whoosh": "Whoosh",
|
"whoosh": "Ktoosh",
|
||||||
"thump": "Palec",
|
"thump": "Palec",
|
||||||
"thunk": "Thunk",
|
"thunk": "Thunk",
|
||||||
"electronic_tuner": "Elektronický tuner",
|
"electronic_tuner": "Elektronický tuner",
|
||||||
|
|||||||
@ -248,7 +248,8 @@
|
|||||||
"label": "Dokumentácia Frigate"
|
"label": "Dokumentácia Frigate"
|
||||||
},
|
},
|
||||||
"review": "Recenzia",
|
"review": "Recenzia",
|
||||||
"explore": "Preskúmať"
|
"explore": "Preskúmať",
|
||||||
|
"classification": "Klasifikácia"
|
||||||
},
|
},
|
||||||
"toast": {
|
"toast": {
|
||||||
"copyUrlToClipboard": "Adresa URL bola skopírovaná do schránky.",
|
"copyUrlToClipboard": "Adresa URL bola skopírovaná do schránky.",
|
||||||
|
|||||||
@ -58,7 +58,8 @@
|
|||||||
"failed": "Chyba spustenia exportu: {{error}}",
|
"failed": "Chyba spustenia exportu: {{error}}",
|
||||||
"endTimeMustAfterStartTime": "Čas konca musí byť po čase začiatku",
|
"endTimeMustAfterStartTime": "Čas konca musí byť po čase začiatku",
|
||||||
"noVaildTimeSelected": "Nie je vybrané žiadne platné časové obdobie"
|
"noVaildTimeSelected": "Nie je vybrané žiadne platné časové obdobie"
|
||||||
}
|
},
|
||||||
|
"view": "Zobraziť"
|
||||||
},
|
},
|
||||||
"fromTimeline": {
|
"fromTimeline": {
|
||||||
"saveExport": "Uložiť Export",
|
"saveExport": "Uložiť Export",
|
||||||
|
|||||||
@ -145,7 +145,10 @@
|
|||||||
"generationFailed": "Generovanie zlyhalo. Skúste to znova.",
|
"generationFailed": "Generovanie zlyhalo. Skúste to znova.",
|
||||||
"classifyFailed": "Nepodarilo sa klasifikovať obrázky: {{error}}"
|
"classifyFailed": "Nepodarilo sa klasifikovať obrázky: {{error}}"
|
||||||
},
|
},
|
||||||
"generateSuccess": "Vzorové obrázky boli úspešne vygenerované"
|
"generateSuccess": "Vzorové obrázky boli úspešne vygenerované",
|
||||||
|
"allImagesRequired_one": "Uveďte všetky obrázky. {{count}} obrázok zostáva.",
|
||||||
|
"allImagesRequired_few": "Uveďte všetky obrázky. {{count}} obrázky zostávajú.",
|
||||||
|
"allImagesRequired_other": "Uveďte všetky obrázky. {{count}} obrázkov zostávajú."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"deleteModel": {
|
"deleteModel": {
|
||||||
|
|||||||
@ -21,7 +21,7 @@
|
|||||||
"regenerate": "Od poskytovateľa {{provider}} bol vyžiadaný nový popis. V závislosti od rýchlosti vášho poskytovateľa môže jeho obnovenie chvíľu trvať.",
|
"regenerate": "Od poskytovateľa {{provider}} bol vyžiadaný nový popis. V závislosti od rýchlosti vášho poskytovateľa môže jeho obnovenie chvíľu trvať.",
|
||||||
"updatedSublabel": "Podštítok bol úspešne aktualizovaný.",
|
"updatedSublabel": "Podštítok bol úspešne aktualizovaný.",
|
||||||
"updatedLPR": "ŠPZ bola úspešne aktualizovaná.",
|
"updatedLPR": "ŠPZ bola úspešne aktualizovaná.",
|
||||||
"audioTranscription": "Úspešne požiadané o prepis zvuku."
|
"audioTranscription": "Úspešne požiadané o prepis zvuku. V závislosti od rýchlosti vášho servera Frigate môže dokončenie prepisu trvať určitý čas."
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"regenerate": "Nepodarilo sa zavolať od {{provider}} pre nový popis: {{errorMessage}}",
|
"regenerate": "Nepodarilo sa zavolať od {{provider}} pre nový popis: {{errorMessage}}",
|
||||||
@ -263,7 +263,8 @@
|
|||||||
"header": {
|
"header": {
|
||||||
"zones": "Zóny",
|
"zones": "Zóny",
|
||||||
"ratio": "Pomer",
|
"ratio": "Pomer",
|
||||||
"area": "Oblasť"
|
"area": "Oblasť",
|
||||||
|
"score": "Skóre"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"annotationSettings": {
|
"annotationSettings": {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"description": {
|
"description": {
|
||||||
"addFace": "Sprievodca pridaním novej kolekcie do Knižnice tvárí.",
|
"addFace": "Pridajte novú kolekciu do Face Library nahrať svoj prvý obrázok.",
|
||||||
"invalidName": "Neplatné meno. Mená môžu obsahovať iba písmená, čísla, medzery, apostrofy, podčiarkovníky a spojovníky.",
|
"invalidName": "Neplatné meno. Mená môžu obsahovať iba písmená, čísla, medzery, apostrofy, podčiarkovníky a spojovníky.",
|
||||||
"placeholder": "Zadajte názov pre túto kolekciu"
|
"placeholder": "Zadajte názov pre túto kolekciu"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -551,10 +551,12 @@
|
|||||||
"probeMode": "Probe kamera",
|
"probeMode": "Probe kamera",
|
||||||
"manualMode": "Ručný výber",
|
"manualMode": "Ručný výber",
|
||||||
"detectionMethodDescription": "Vyskúša cez ONVIF (ak je podporovaný) nájsť kamery streamové adresy, alebo ručne vyberte značku kamery a jej preddefinované URL. Ak chcete zadať vlastnú URL RTSP, vyberte manuálne zadanie a označte \"Ostatné\".",
|
"detectionMethodDescription": "Vyskúša cez ONVIF (ak je podporovaný) nájsť kamery streamové adresy, alebo ručne vyberte značku kamery a jej preddefinované URL. Ak chcete zadať vlastnú URL RTSP, vyberte manuálne zadanie a označte \"Ostatné\".",
|
||||||
"onvifPortDescription": "Pre kamery, ktoré podporujú ONVIF, to je zvyčajne 80 alebo 8080."
|
"onvifPortDescription": "Pre kamery, ktoré podporujú ONVIF, to je zvyčajne 80 alebo 8080.",
|
||||||
|
"useDigestAuth": "Použite overenie súhrnu",
|
||||||
|
"useDigestAuthDescription": "Použite HTTP stráviteľné overenie pre ONVIF. Niektoré kamery môžu vyžadovať vyhradený ONVIF užívateľské meno/password namiesto štandardného správcu."
|
||||||
},
|
},
|
||||||
"step2": {
|
"step2": {
|
||||||
"description": "Konfigurovať prúdové role a pridať ďalšie prúdy pre vašu kameru.",
|
"description": "Vyhľadajte dostupné streamy z kamery alebo nakonfigurujte manuálne nastavenia na základe zvolenej metódy detekcie.",
|
||||||
"streamsTitle": "Kamerové prúdy",
|
"streamsTitle": "Kamerové prúdy",
|
||||||
"addStream": "Pridať Stream",
|
"addStream": "Pridať Stream",
|
||||||
"addAnotherStream": "Pridať ďalší Stream",
|
"addAnotherStream": "Pridať ďalší Stream",
|
||||||
@ -573,8 +575,8 @@
|
|||||||
"audio": "Zvuk"
|
"audio": "Zvuk"
|
||||||
},
|
},
|
||||||
"testStream": "Testovacie pripojenie",
|
"testStream": "Testovacie pripojenie",
|
||||||
"testSuccess": "Stream test úspešné!",
|
"testSuccess": "Test pripojenia bol úspešný!",
|
||||||
"testFailed": "Stream test zlyhal",
|
"testFailed": "Test pripojenia zlyhal. Skontrolujte zadané údaje a skúste to znova.",
|
||||||
"testFailedTitle": "Test Zlyhal",
|
"testFailedTitle": "Test Zlyhal",
|
||||||
"connected": "Pripojené",
|
"connected": "Pripojené",
|
||||||
"notConnected": "Nie je pripojený",
|
"notConnected": "Nie je pripojený",
|
||||||
@ -590,6 +592,38 @@
|
|||||||
"featuresPopover": {
|
"featuresPopover": {
|
||||||
"title": "Funkcie streamu",
|
"title": "Funkcie streamu",
|
||||||
"description": "Použite prekrytie go2rtc na zníženie pripojenia k fotoaparátu."
|
"description": "Použite prekrytie go2rtc na zníženie pripojenia k fotoaparátu."
|
||||||
|
},
|
||||||
|
"streamDetails": "Detaily vysielania",
|
||||||
|
"probing": "Skúmajúca kamera...",
|
||||||
|
"retry": "Skúste to znova",
|
||||||
|
"testing": {
|
||||||
|
"probingMetadata": "Skúmanie metadát kamery...",
|
||||||
|
"fetchingSnapshot": "Načítava sa snímka z fotoaparátu..."
|
||||||
|
},
|
||||||
|
"probeFailed": "Nepodarilo sa otestovať kameru: {{error}}",
|
||||||
|
"probingDevice": "Snímacie zariadenie...",
|
||||||
|
"probeSuccessful": "Sonda úspešná",
|
||||||
|
"probeError": "Chyba sondy",
|
||||||
|
"probeNoSuccess": "Sonda neúspešná",
|
||||||
|
"deviceInfo": "Informácie o zariadení",
|
||||||
|
"manufacturer": "Výrobca",
|
||||||
|
"model": "Model",
|
||||||
|
"firmware": "Firmvér",
|
||||||
|
"profiles": "Profily",
|
||||||
|
"ptzSupport": "PTZ Podpora",
|
||||||
|
"autotrackingSupport": "Podpora automatického sledovania",
|
||||||
|
"presets": "Prestavby",
|
||||||
|
"rtspCandidates": "RTSP kandidátov",
|
||||||
|
"rtspCandidatesDescription": "Z kamery boli nájdené nasledujúce adresy URL RTSP. Otestujte pripojenie a zobrazte metadáta streamu.",
|
||||||
|
"noRtspCandidates": "Z kamery sa nenašli žiadne URL adresy RTSP. Vaše prihlasovacie údaje môžu byť nesprávne alebo kamera nepodporuje protokol ONVIF alebo metódu použitú na získanie URL adries RTSP. Vráťte sa späť a zadajte URL adresu RTSP manuálne.",
|
||||||
|
"candidateStreamTitle": "Kandidát {{number}}",
|
||||||
|
"useCandidate": "Použitie",
|
||||||
|
"uriCopy": "Kopírovať",
|
||||||
|
"uriCopied": "URI skopírované do schránky",
|
||||||
|
"testConnection": "Testovacie pripojenie",
|
||||||
|
"toggleUriView": "Kliknutím prepnete zobrazenie celého URI",
|
||||||
|
"errors": {
|
||||||
|
"hostRequired": "Vyžaduje sa hostiteľská/IP adresa"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"step3": {
|
"step3": {
|
||||||
@ -621,6 +655,57 @@
|
|||||||
"resolutionHigh": "Rozlíšenie {{resolution}} môže spôsobiť zvýšenú spotrebu zdrojov.",
|
"resolutionHigh": "Rozlíšenie {{resolution}} môže spôsobiť zvýšenú spotrebu zdrojov.",
|
||||||
"resolutionLow": "Rozlíšenie {{resolution}} môže byť príliš nízka pre spoľahlivú detekciu malých objektov."
|
"resolutionLow": "Rozlíšenie {{resolution}} môže byť príliš nízka pre spoľahlivú detekciu malých objektov."
|
||||||
},
|
},
|
||||||
|
"description": "Nakonfigurujte role streamov a pridajte ďalšie streamy pre vašu kameru.",
|
||||||
|
"validationTitle": "Stream Platnosť",
|
||||||
|
"connectAllStreams": "Pripojte všetky prúdy",
|
||||||
|
"reconnectionSuccess": "Opätovné pripojenie bolo úspešné.",
|
||||||
|
"reconnectionPartial": "Niektoré prúdy sa nepodarilo prepojiť.",
|
||||||
|
"streamUnavailable": "Ukážka streamu nie je k dispozícii",
|
||||||
|
"reload": "Znovu načítať",
|
||||||
|
"connecting": "Pripája...",
|
||||||
|
"streamTitle": "Stream {{number}}",
|
||||||
|
"valid": "Platné",
|
||||||
|
"failed": "Zlyhanie",
|
||||||
|
"notTested": "Netestované",
|
||||||
|
"streamsTitle": "Kamerové prúdy",
|
||||||
|
"addStream": "Pridať Stream",
|
||||||
|
"addAnotherStream": "Pridať ďalší Stream",
|
||||||
|
"streamUrl": "Stream URL",
|
||||||
|
"streamUrlPlaceholder": "rtsp://username:password@host:port/path",
|
||||||
|
"selectStream": "Vyberte stream",
|
||||||
|
"searchCandidates": "Hľadať kandidátov...",
|
||||||
|
"noStreamFound": "Nenašiel sa žiadny stream",
|
||||||
|
"url": "URL",
|
||||||
|
"resolution": "Rozlíšenie",
|
||||||
|
"selectResolution": "Vyberte rozlíšenie",
|
||||||
|
"quality": "Kvalita",
|
||||||
|
"selectQuality": "Vyberte kvalitu",
|
||||||
|
"roleLabels": {
|
||||||
|
"detect": "Detekcia objektov",
|
||||||
|
"record": "Nahrávanie",
|
||||||
|
"audio": "Zvuk"
|
||||||
|
},
|
||||||
|
"testStream": "Testovanie pripojenia",
|
||||||
|
"testSuccess": "Stream test úspešné!",
|
||||||
|
"testFailed": "Stream test zlyhal",
|
||||||
|
"testFailedTitle": "Test Zlyhal",
|
||||||
|
"connected": "Pripojené",
|
||||||
|
"notConnected": "Nie je pripojený",
|
||||||
|
"featuresTitle": "Vlastnosti",
|
||||||
|
"go2rtc": "Znížte počet pripojení ku kamere",
|
||||||
|
"detectRoleWarning": "Aspoň jeden prúd musí mať \"detekt\" úlohu pokračovať.",
|
||||||
|
"rolesPopover": {
|
||||||
|
"title": "Roly streamu",
|
||||||
|
"detect": "Hlavné krmivo pre detekciu objektu.",
|
||||||
|
"record": "Ukladá segmenty video kanála na základe nastavení konfigurácie.",
|
||||||
|
"audio": "Kŕmenie pre detekciu zvuku."
|
||||||
|
},
|
||||||
|
"featuresPopover": {
|
||||||
|
"title": "Funkcie streamu",
|
||||||
|
"description": "Použite prekrytie go2rtc na zníženie pripojenia k fotoaparátu."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"step4": {
|
||||||
"description": "Záverečné overenie a analýza pred uložením nového fotoaparátu. Pripojte každý prúd pred uložením.",
|
"description": "Záverečné overenie a analýza pred uložením nového fotoaparátu. Pripojte každý prúd pred uložením.",
|
||||||
"validationTitle": "Stream Platnosť",
|
"validationTitle": "Stream Platnosť",
|
||||||
"connectAllStreams": "Pripojte všetky prúdy",
|
"connectAllStreams": "Pripojte všetky prúdy",
|
||||||
@ -632,7 +717,40 @@
|
|||||||
"streamTitle": "Stream {{number}}",
|
"streamTitle": "Stream {{number}}",
|
||||||
"valid": "Platné",
|
"valid": "Platné",
|
||||||
"failed": "Zlyhanie",
|
"failed": "Zlyhanie",
|
||||||
"notTested": "Netestované"
|
"notTested": "Netestované",
|
||||||
|
"connectStream": "Pripojiť",
|
||||||
|
"connectingStream": "Pripája",
|
||||||
|
"disconnectStream": "Odpojiť",
|
||||||
|
"estimatedBandwidth": "Odhadovaná šírka pásma",
|
||||||
|
"roles": "Roly",
|
||||||
|
"ffmpegModule": "Použite režim kompatibility prúdu",
|
||||||
|
"ffmpegModuleDescription": "Ak sa stream nenačíta ani po niekoľkých pokusoch, skúste túto funkciu povoliť. Keď je táto funkcia povolená, Frigate použije modul ffmpeg s go2rtc. To môže poskytnúť lepšiu kompatibilitu s niektorými streammi z kamier.",
|
||||||
|
"none": "Žiadne",
|
||||||
|
"error": "Chyba",
|
||||||
|
"streamValidated": "Stream {{number}} úspešne overený",
|
||||||
|
"streamValidationFailed": "Stream {{number}} validácia zlyhala",
|
||||||
|
"saveAndApply": "Uložiť novú kameru",
|
||||||
|
"saveError": "Neplatná konfigurácia. Skontrolujte nastavenia.",
|
||||||
|
"issues": {
|
||||||
|
"title": "Platnosť Streamu",
|
||||||
|
"videoCodecGood": "Kód videa je {{codec}}.",
|
||||||
|
"audioCodecGood": "Audio kódc je {{codec}}.",
|
||||||
|
"resolutionHigh": "Rozlíšenie {{resolution}} môže spôsobiť zvýšenú spotrebu zdrojov.",
|
||||||
|
"resolutionLow": "Rozlíšenie {{resolution}} môže byť príliš nízka pre spoľahlivú detekciu malých objektov.",
|
||||||
|
"noAudioWarning": "Žiadne audio nebolo detekovane pre tento prúd, nahrávanie nebude mať audio.",
|
||||||
|
"audioCodecRecordError": "AAC audio kodek je potrebný na podporu audio v záznamoch.",
|
||||||
|
"audioCodecRequired": "Zvukový prúd je povinný podporovať detekciu zvuku.",
|
||||||
|
"restreamingWarning": "Zníženie pripojenia ku kamery pre rekordný prúd môže mierne zvýšiť využitie CPU.",
|
||||||
|
"brands": {
|
||||||
|
"reolink-rtsp": "Reolink RTSP sa neodporúča. Odporúča sa povoliť HTTP v nastavení kamery a reštartovať sprievodca kamery."
|
||||||
|
},
|
||||||
|
"dahua": {
|
||||||
|
"substreamWarning": "Čiastkový stream 1 je uzamknutý na nízke rozlíšenie. Mnoho kamier Dahua / Amcrest / EmpireTech podporuje ďalšie čiastkové streamy, ktoré je potrebné povoliť v nastaveniach kamery. Odporúča sa skontrolovať a využiť tieto streamy, ak sú k dispozícii."
|
||||||
|
},
|
||||||
|
"hikvision": {
|
||||||
|
"substreamWarning": "Čiastkový stream 1 je uzamknutý na nízke rozlíšenie. Mnoho kamier Hikvision podporuje ďalšie čiastkové streamy, ktoré je potrebné povoliť v nastaveniach kamery. Odporúča sa skontrolovať a využiť tieto streamy, ak sú k dispozícii."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"cameraManagement": {
|
"cameraManagement": {
|
||||||
@ -829,9 +947,9 @@
|
|||||||
"createRole": "Rola {{role}} bola úspešne vytvorená",
|
"createRole": "Rola {{role}} bola úspešne vytvorená",
|
||||||
"updateCameras": "Kamery aktualizované pre rolu {{role}}",
|
"updateCameras": "Kamery aktualizované pre rolu {{role}}",
|
||||||
"deleteRole": "Rola {{role}} bola úspešne odstránená",
|
"deleteRole": "Rola {{role}} bola úspešne odstránená",
|
||||||
"userRolesUpdated_one": "{{count}} užívateľ (y) priradené tejto úlohe boli aktualizované pre \"viewer\", ktorý má prístup ku všetkým kamerám.",
|
"userRolesUpdated_one": "",
|
||||||
"userRolesUpdated_few": "",
|
"userRolesUpdated_few": "",
|
||||||
"userRolesUpdated_other": ""
|
"userRolesUpdated_other": "{{count}} užívatelia priradené tejto úlohe boli aktualizované pre \"viewer\", ktorý má prístup ku všetkým kamerám."
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"createRoleFailed": "Nepodarilo sa vytvoriť rolu: {{errorMessage}}",
|
"createRoleFailed": "Nepodarilo sa vytvoriť rolu: {{errorMessage}}",
|
||||||
|
|||||||
@ -188,7 +188,10 @@
|
|||||||
"yolov9_plate_detection": "YOLOv9 Detekcia ŠPZ",
|
"yolov9_plate_detection": "YOLOv9 Detekcia ŠPZ",
|
||||||
"review_description": "Popis recenzie",
|
"review_description": "Popis recenzie",
|
||||||
"review_description_speed": "Popis recenzie Rýchlosťi",
|
"review_description_speed": "Popis recenzie Rýchlosťi",
|
||||||
"review_description_events_per_second": "Popis"
|
"review_description_events_per_second": "Popis",
|
||||||
|
"object_description": "Popis objektu",
|
||||||
|
"object_description_speed": "Popis objektu Rýchlosť",
|
||||||
|
"object_description_events_per_second": "Popis objektu"
|
||||||
},
|
},
|
||||||
"averageInf": "Priemerný čas inferencie"
|
"averageInf": "Priemerný čas inferencie"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -81,7 +81,10 @@
|
|||||||
"formattedTimestampMonthDayYear": {
|
"formattedTimestampMonthDayYear": {
|
||||||
"12hour": "d MMM, yyyy",
|
"12hour": "d MMM, yyyy",
|
||||||
"24hour": "d MMM, yyyy"
|
"24hour": "d MMM, yyyy"
|
||||||
}
|
},
|
||||||
|
"inProgress": "Devam ediyor",
|
||||||
|
"invalidStartTime": "Geçersiz başlangıç zamanı",
|
||||||
|
"invalidEndTime": "Geçersiz bitiş zamanı"
|
||||||
},
|
},
|
||||||
"button": {
|
"button": {
|
||||||
"off": "KAPALI",
|
"off": "KAPALI",
|
||||||
@ -222,7 +225,12 @@
|
|||||||
"uiPlayground": "UI Deneme Alanı"
|
"uiPlayground": "UI Deneme Alanı"
|
||||||
},
|
},
|
||||||
"label": {
|
"label": {
|
||||||
"back": "Geri"
|
"back": "Geri",
|
||||||
|
"hide": "{{item}} öğesini gizle",
|
||||||
|
"show": "{{item}} öğesini göster",
|
||||||
|
"ID": "ID",
|
||||||
|
"none": "Yok",
|
||||||
|
"all": "Tümü"
|
||||||
},
|
},
|
||||||
"notFound": {
|
"notFound": {
|
||||||
"documentTitle": "Bulunamadı - Frigate",
|
"documentTitle": "Bulunamadı - Frigate",
|
||||||
@ -237,6 +245,14 @@
|
|||||||
"length": {
|
"length": {
|
||||||
"feet": "feet",
|
"feet": "feet",
|
||||||
"meters": "metre"
|
"meters": "metre"
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"kbps": "kB/s",
|
||||||
|
"mbps": "MB/s",
|
||||||
|
"gbps": "GB/s",
|
||||||
|
"kbph": "kB/saat",
|
||||||
|
"mbph": "MB/saat",
|
||||||
|
"gbph": "GB/saat"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pagination": {
|
"pagination": {
|
||||||
@ -273,5 +289,14 @@
|
|||||||
"admin": "Yönetici",
|
"admin": "Yönetici",
|
||||||
"desc": "Yöneticiler Frigate arayüzündeki bütün özelliklere tam erişim sahibidir. Görüntüleyiciler ise yalnızca kameraları, eski görüntüleri ve inceleme öğelerini görüntülemekle sınırlıdır."
|
"desc": "Yöneticiler Frigate arayüzündeki bütün özelliklere tam erişim sahibidir. Görüntüleyiciler ise yalnızca kameraları, eski görüntüleri ve inceleme öğelerini görüntülemekle sınırlıdır."
|
||||||
},
|
},
|
||||||
"readTheDocumentation": "Dökümantasyonu oku"
|
"readTheDocumentation": "Dökümantasyonu oku",
|
||||||
|
"list": {
|
||||||
|
"two": "{{0}} ve {{1}}",
|
||||||
|
"many": "{{items}} ve {{last}}",
|
||||||
|
"separatorWithSpace": ", "
|
||||||
|
},
|
||||||
|
"field": {
|
||||||
|
"optional": "İsteğe bağlı",
|
||||||
|
"internalID": "Frigate’ın yapılandırma ve veritabanında kullandığı Dahili Kimlik"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -64,7 +64,8 @@
|
|||||||
"failed": "Dışa aktarım başlatılamadı: {{error}}",
|
"failed": "Dışa aktarım başlatılamadı: {{error}}",
|
||||||
"endTimeMustAfterStartTime": "Bitiş zamanı başlangıç zamanından sonra olmalıdır",
|
"endTimeMustAfterStartTime": "Bitiş zamanı başlangıç zamanından sonra olmalıdır",
|
||||||
"noVaildTimeSelected": "Geçerli bir zaman aralığı seçilmedi"
|
"noVaildTimeSelected": "Geçerli bir zaman aralığı seçilmedi"
|
||||||
}
|
},
|
||||||
|
"view": "Görüntüle"
|
||||||
},
|
},
|
||||||
"fromTimeline": {
|
"fromTimeline": {
|
||||||
"saveExport": "Dışa Aktarımı Kaydet",
|
"saveExport": "Dışa Aktarımı Kaydet",
|
||||||
@ -117,7 +118,8 @@
|
|||||||
"button": {
|
"button": {
|
||||||
"export": "Dışa Aktar",
|
"export": "Dışa Aktar",
|
||||||
"markAsReviewed": "İncelendi olarak işaretle",
|
"markAsReviewed": "İncelendi olarak işaretle",
|
||||||
"deleteNow": "Şimdi Sil"
|
"deleteNow": "Şimdi Sil",
|
||||||
|
"markAsUnreviewed": "Gözden geçirilmedi olarak işaretle"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"imagePicker": {
|
"imagePicker": {
|
||||||
@ -125,6 +127,7 @@
|
|||||||
"noImages": "Bu kamera için küçük resim bulunamadı",
|
"noImages": "Bu kamera için küçük resim bulunamadı",
|
||||||
"search": {
|
"search": {
|
||||||
"placeholder": "Etiket/alt etiket kullanarak arama yapın..."
|
"placeholder": "Etiket/alt etiket kullanarak arama yapın..."
|
||||||
}
|
},
|
||||||
|
"unknownLabel": "Kaydedilen Tetikleme Görseli"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,44 +10,53 @@
|
|||||||
"deleteImages": "Fotoğrafları Sil",
|
"deleteImages": "Fotoğrafları Sil",
|
||||||
"trainModel": "Modeli Eğit",
|
"trainModel": "Modeli Eğit",
|
||||||
"addClassification": "Sınıflandırma Ekle",
|
"addClassification": "Sınıflandırma Ekle",
|
||||||
"deleteModels": "Modelleri Sil"
|
"deleteModels": "Modelleri Sil",
|
||||||
|
"editModel": "Modeli Düzenle"
|
||||||
},
|
},
|
||||||
"toast": {
|
"toast": {
|
||||||
"success": {
|
"success": {
|
||||||
"deletedCategory": "Silinmiş Sınıf",
|
"deletedCategory": "Silinmiş Sınıf",
|
||||||
"deletedImage": "Silinmiş Fotoğraflar",
|
"deletedImage": "Silinmiş Fotoğraflar",
|
||||||
"deletedModel_one": "{{tane}} model(ler) başarıyla silindi",
|
"deletedModel_one": "{{count}} model başarıyla silindi",
|
||||||
"deletedModel_other": "",
|
"deletedModel_other": "{{count}} model başarıyla silindi",
|
||||||
"categorizedImage": "Fotoğraf Başarıyla Sınıflandırıldı",
|
"categorizedImage": "Fotoğraf Başarıyla Sınıflandırıldı",
|
||||||
"trainedModel": "Model başarıyla eğitildi.",
|
"trainedModel": "Model başarıyla eğitildi.",
|
||||||
"trainingModel": "Model eğitimi başarıyla başladı."
|
"trainingModel": "Model eğitimi başarıyla başladı.",
|
||||||
|
"updatedModel": "Model yapılandırması başarıyla güncellendi",
|
||||||
|
"renamedCategory": "Sınıf başarıyla {{name}} olarak yeniden adlandırıldı"
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"deleteImageFailed": "Silinirken hatayla karşılaşıldı: {{errorMessage}}",
|
"deleteImageFailed": "Silinemedi: {{errorMessage}}",
|
||||||
"deleteModelFailed": "Model silinirken hata oluştu: {{errorMessage}}",
|
"deleteModelFailed": "Model silinirken hata oluştu: {{errorMessage}}",
|
||||||
"categorizeFailed": "Görsel sınıflandırılamadı: {{errorMessage}}",
|
"categorizeFailed": "Görsel sınıflandırılamadı: {{errorMessage}}",
|
||||||
"trainingFailed": "Model eğitimi başlatılamadı: {{errorMessage}}"
|
"trainingFailed": "Model eğitimi başarısız oldu. Ayrıntılar için Frigate günlüklerini kontrol edin.",
|
||||||
|
"deleteCategoryFailed": "Sınıf silinemedi: {{errorMessage}}",
|
||||||
|
"trainingFailedToStart": "Model eğitimi başlatılamadı: {{errorMessage}}",
|
||||||
|
"updateModelFailed": "Model güncellenemedi: {{errorMessage}}",
|
||||||
|
"renameCategoryFailed": "Sınıf yeniden adlandırılamadı: {{errorMessage}}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"deleteCategory": {
|
"deleteCategory": {
|
||||||
"title": "Sınıfı Sil",
|
"title": "Sınıfı Sil",
|
||||||
"desc": "{{name}} adlı sınıfı silmek istediğinizden emin misiniz? Bu işlem, sınıfa ait tüm görselleri kalıcı olarak silecek ve modelin yeniden eğitilmesini gerektirecektir."
|
"desc": "{{name}} adlı sınıfı silmek istediğinizden emin misiniz? Bu işlem, sınıfa ait tüm görselleri kalıcı olarak silecek ve modelin yeniden eğitilmesini gerektirecektir.",
|
||||||
|
"minClassesTitle": "Sınıf Silinemiyor",
|
||||||
|
"minClassesDesc": "Bu sınıfı silmeden önce bir sınıflandırma modelinin en az 2 sınıfa sahip olması gerekir. Bu sınıfı silmeden önce başka bir sınıf ekleyin."
|
||||||
},
|
},
|
||||||
"deleteModel": {
|
"deleteModel": {
|
||||||
"title": "Sınıflandırma Modelini Sil",
|
"title": "Sınıflandırma Modelini Sil",
|
||||||
"single": "{{name}} öğesini silmek istediğinizden emin misiniz? Bu işlem, görseller ve eğitim verileri dâhil olmak üzere tüm ilişkili verileri kalıcı olarak silecektir. Bu işlem geri alınamaz.",
|
"single": "{{name}} öğesini silmek istediğinizden emin misiniz? Bu işlem, görseller ve eğitim verileri dâhil olmak üzere tüm ilişkili verileri kalıcı olarak silecektir. Bu işlem geri alınamaz.",
|
||||||
"desc_one": "{{count}} modeli silmek istediğinizden emin misiniz? Bu işlem, görseller ve eğitim verileri dâhil olmak üzere tüm ilişkili verileri kalıcı olarak silecektir. Bu işlem geri alınamaz.",
|
"desc_one": "{{count}} modeli silmek istediğinizden emin misiniz? Bu işlem, görseller ve eğitim verileri dâhil olmak üzere tüm ilişkili verileri kalıcı olarak silecektir. Bu işlem geri alınamaz.",
|
||||||
"desc_other": ""
|
"desc_other": "{{count}} modeli silmek istediğinizden emin misiniz? Bu işlem, görseller ve eğitim verileri dâhil olmak üzere tüm ilişkili verileri kalıcı olarak silecektir. Bu işlem geri alınamaz."
|
||||||
},
|
},
|
||||||
"deleteDatasetImages": {
|
"deleteDatasetImages": {
|
||||||
"title": "Eğitim verisi görsellerini sil",
|
"title": "Eğitim verisi görsellerini sil",
|
||||||
"desc_one": "{{dataset}} veri kümesinden {{count}} görseli silmek istediğinizden emin misiniz? Bu işlem geri alınamaz ve modelin yeniden eğitilmesini gerektirecektir.",
|
"desc_one": "{{dataset}} veri kümesinden {{count}} görseli silmek istediğinizden emin misiniz? Bu işlem geri alınamaz ve modelin yeniden eğitilmesini gerektirir.",
|
||||||
"desc_other": ""
|
"desc_other": "{{dataset}} veri kümesinden {{count}} görseli silmek istediğinizden emin misiniz? Bu işlem geri alınamaz ve modelin yeniden eğitilmesini gerektirir."
|
||||||
},
|
},
|
||||||
"deleteTrainImages": {
|
"deleteTrainImages": {
|
||||||
"title": "Eğitim Görsellerini Sil",
|
"title": "Eğitim Görsellerini Sil",
|
||||||
"desc_one": "{{count}} görseli silmek istediğinizden emin misiniz? Bu işlem geri alınamaz.",
|
"desc_one": "{{count}} görseli silmek istediğinizden emin misiniz? Bu işlem geri alınamaz.",
|
||||||
"desc_other": ""
|
"desc_other": "{{count}} görseli silmek istediğinizden emin misiniz? Bu işlem geri alınamaz."
|
||||||
},
|
},
|
||||||
"renameCategory": {
|
"renameCategory": {
|
||||||
"title": "Sınıfı Yeniden Adlandır",
|
"title": "Sınıfı Yeniden Adlandır",
|
||||||
@ -76,6 +85,43 @@
|
|||||||
"title": "Nesne sınıflandırma modeli mevcut değil",
|
"title": "Nesne sınıflandırma modeli mevcut değil",
|
||||||
"description": "Algılanan nesneleri sınıflandırmak için özel bir model oluşturun.",
|
"description": "Algılanan nesneleri sınıflandırmak için özel bir model oluşturun.",
|
||||||
"buttonText": "Nesne Modeli Oluştur"
|
"buttonText": "Nesne Modeli Oluştur"
|
||||||
|
},
|
||||||
|
"state": {
|
||||||
|
"title": "Durum Sınıflandırma Modeli Yok",
|
||||||
|
"description": "Belirli kamera alanlarındaki durum değişimlerini izlemek ve sınıflandırmak için özel bir model oluşturun.",
|
||||||
|
"buttonText": "Durum Modeli Oluştur"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"trainingInProgress": "Model şu anda eğitiliyor",
|
||||||
|
"noNewImages": "Eğitilecek yeni görsel bulunmuyor. Önce veri kümesinde daha fazla görseli sınıflandırın.",
|
||||||
|
"noChanges": "Son eğitimden bu yana veri kümesinde herhangi bir değişiklik yapılmadı.",
|
||||||
|
"modelNotReady": "Model eğitim için hazır değil"
|
||||||
|
},
|
||||||
|
"edit": {
|
||||||
|
"title": "Sınıflandırma Modelini Düzenle",
|
||||||
|
"descriptionState": "Bu durum sınıflandırma modeli için sınıfları düzenleyin. Değişiklikler, modelin yeniden eğitilmesini gerektirecektir.",
|
||||||
|
"descriptionObject": "Bu nesne sınıflandırma modeli için nesne türünü ve sınıflandırma türünü düzenleyin.",
|
||||||
|
"stateClassesInfo": "Not: Durum sınıflarını değiştirmek, modelin güncellenmiş sınıflarla yeniden eğitilmesini gerektirir."
|
||||||
|
},
|
||||||
|
"wizard": {
|
||||||
|
"title": "Yeni Sınıflandırma Oluştur",
|
||||||
|
"steps": {
|
||||||
|
"nameAndDefine": "Adlandır ve Tanımla",
|
||||||
|
"stateArea": "Durum Alanı",
|
||||||
|
"chooseExamples": "Örnekleri Seç"
|
||||||
|
},
|
||||||
|
"step1": {
|
||||||
|
"description": "State modelleri, sabit kamera alanlarındaki değişiklikleri (ör. kapının açılması/kapanması) izler. Nesne modelleri ise algılanan nesnelere ek sınıflandırmalar ekler (ör. bilinen hayvanlar, kuryeler vb.).",
|
||||||
|
"name": "Ad",
|
||||||
|
"namePlaceholder": "Model adını girin...",
|
||||||
|
"type": "Tür",
|
||||||
|
"typeState": "Durum",
|
||||||
|
"typeObject": "Nesne",
|
||||||
|
"objectLabel": "Nesne Etiketi",
|
||||||
|
"objectLabelPlaceholder": "Nesne türünü seçin...",
|
||||||
|
"classificationType": "Sınıflandırma Türü",
|
||||||
|
"classificationTypeTip": "Sınıflandırma türleri hakkında bilgi edinin"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -42,14 +42,15 @@
|
|||||||
"detail": {
|
"detail": {
|
||||||
"label": "Detay",
|
"label": "Detay",
|
||||||
"aria": "Ayrıntı görünümünü aç/kapat",
|
"aria": "Ayrıntı görünümünü aç/kapat",
|
||||||
"trackedObject_one": "Nesne",
|
"trackedObject_one": "{{count}} nesne",
|
||||||
"trackedObject_other": "nesneler",
|
"trackedObject_other": "{{count}} nesne",
|
||||||
"noObjectDetailData": "Nesneye ait ayrıntılı veri bulunmuyor.",
|
"noObjectDetailData": "Nesneye ait ayrıntılı veri bulunmuyor.",
|
||||||
"settings": "Ayrıntılı Görünüm Ayarları",
|
"settings": "Ayrıntılı Görünüm Ayarları",
|
||||||
"alwaysExpandActive": {
|
"alwaysExpandActive": {
|
||||||
"title": "Etkin olanı her zaman genişlet",
|
"title": "Etkin olanı her zaman genişlet",
|
||||||
"desc": "Varsa, etkin inceleme öğesinin nesne ayrıntılarını daima göster."
|
"desc": "Varsa, etkin inceleme öğesinin nesne ayrıntılarını daima göster."
|
||||||
}
|
},
|
||||||
|
"noDataFound": "İncelenecek ayrıntılı veri bulunmuyor"
|
||||||
},
|
},
|
||||||
"objectTrack": {
|
"objectTrack": {
|
||||||
"trackedPoint": "Takip edilen nokta",
|
"trackedPoint": "Takip edilen nokta",
|
||||||
|
|||||||
@ -19,7 +19,7 @@
|
|||||||
"updatedSublabel": "Alt etiket başarıyla gücellendi.",
|
"updatedSublabel": "Alt etiket başarıyla gücellendi.",
|
||||||
"regenerate": "Yeni bir açıklama {{provider}} sağlayıcısından talep edildi. Sağlayıcının hızına bağlı olarak yeni açıklamanın oluşturulması biraz zaman alabilir.",
|
"regenerate": "Yeni bir açıklama {{provider}} sağlayıcısından talep edildi. Sağlayıcının hızına bağlı olarak yeni açıklamanın oluşturulması biraz zaman alabilir.",
|
||||||
"updatedLPR": "Plaka başarıyla güncellendi.",
|
"updatedLPR": "Plaka başarıyla güncellendi.",
|
||||||
"audioTranscription": "Ses çözümlemesi başarıyla talep edildi."
|
"audioTranscription": "Ses dökümü başarıyla istendi. Frigate sunucunuzun hızına bağlı olarak döküm işlemi tamamlanması biraz zaman alabilir."
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"updatedSublabelFailed": "Alt etiket güncellenemedi: {{errorMessage}}",
|
"updatedSublabelFailed": "Alt etiket güncellenemedi: {{errorMessage}}",
|
||||||
@ -110,7 +110,8 @@
|
|||||||
"object_lifecycle": "nesne yaşam döngüsü",
|
"object_lifecycle": "nesne yaşam döngüsü",
|
||||||
"snapshot": "fotoğraf",
|
"snapshot": "fotoğraf",
|
||||||
"video": "video",
|
"video": "video",
|
||||||
"thumbnail": "küçük resim"
|
"thumbnail": "küçük resim",
|
||||||
|
"tracking_details": "izleme ayrıntıları"
|
||||||
},
|
},
|
||||||
"objectLifecycle": {
|
"objectLifecycle": {
|
||||||
"title": "Nesne Yaşam Döngüsü",
|
"title": "Nesne Yaşam Döngüsü",
|
||||||
@ -244,8 +245,30 @@
|
|||||||
"external": "{{label}} tespit edildi",
|
"external": "{{label}} tespit edildi",
|
||||||
"header": {
|
"header": {
|
||||||
"zones": "Bölgeler",
|
"zones": "Bölgeler",
|
||||||
"ratio": "Oran"
|
"ratio": "Oran",
|
||||||
|
"area": "Alan",
|
||||||
|
"score": "Skor"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"annotationSettings": {
|
||||||
|
"title": "Etiketleme Ayarları",
|
||||||
|
"showAllZones": {
|
||||||
|
"title": "Tüm Bölgeleri Göster",
|
||||||
|
"desc": "Herhangi bir bölgeye nesne girdiğinde, o karede bölgeleri her zaman göster."
|
||||||
|
},
|
||||||
|
"offset": {
|
||||||
|
"label": "Etiket Kaydırma Değeri",
|
||||||
|
"desc": "Bu veriler kameranızın algılama akışından gelir ancak kayıt akışındaki görüntülerin üzerine bindirilir. İki akışın tamamen senkronize olması pek olası değildir. Bu nedenle sınır kutusu ile görüntü birebir hizalı olmayabilir. Bu ayarı kullanarak anotasyonları zamansal olarak ileri veya geri kaydırabilir ve kaydedilmiş görüntülerle daha iyi hizalayabilirsiniz.",
|
||||||
|
"millisecondsToOffset": "Algılama anotasyonlarının kaydırılacağı milisaniye değeri. <em>Varsayılan: 0</em>",
|
||||||
|
"tips": "Videonun oynatımı kutulardan ve yol noktalarından öndeyse değeri düşürün; geride kalıyorsa değeri artırın. Bu değer negatif olabilir.",
|
||||||
|
"toast": {
|
||||||
|
"success": "{{camera}} için anotasyon kaydırması yapılandırma dosyasına kaydedildi. Değişikliklerin uygulanması için Frigate’i yeniden başlatın."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"carousel": {
|
||||||
|
"previous": "Önceki slayt",
|
||||||
|
"next": "Sonraki slayt"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -27,7 +27,7 @@
|
|||||||
"nextSteps": "Sağlam bir temel oluşturmak için:<li>Her tespit edilen kişi için **Recent Recognitions (Son Tanımalar)** sekmesini kullanarak görüntüleri seçin ve eğitim gerçekleştirin.</li> <li>En iyi sonuçlar için doğrudan önden çekilmiş yüz görüntülerine odaklanın; yüzlerin açılı göründüğü fotoğrafları eğitimde kullanmaktan kaçının.</li>"
|
"nextSteps": "Sağlam bir temel oluşturmak için:<li>Her tespit edilen kişi için **Recent Recognitions (Son Tanımalar)** sekmesini kullanarak görüntüleri seçin ve eğitim gerçekleştirin.</li> <li>En iyi sonuçlar için doğrudan önden çekilmiş yüz görüntülerine odaklanın; yüzlerin açılı göründüğü fotoğrafları eğitimde kullanmaktan kaçının.</li>"
|
||||||
},
|
},
|
||||||
"train": {
|
"train": {
|
||||||
"title": "Son Algılananlar",
|
"title": "Son Tanımalar",
|
||||||
"aria": "Son algılanan nesneleri seç",
|
"aria": "Son algılanan nesneleri seç",
|
||||||
"empty": "Yakın zamanda yüz tanıma denemesi olmadı"
|
"empty": "Yakın zamanda yüz tanıma denemesi olmadı"
|
||||||
},
|
},
|
||||||
@ -61,7 +61,7 @@
|
|||||||
"addFaceLibrary": "{{name}} başarıyla Yüz Kütüphanesi’ne eklendi!",
|
"addFaceLibrary": "{{name}} başarıyla Yüz Kütüphanesi’ne eklendi!",
|
||||||
"trainedFace": "Yüz başarıyla eğitildi.",
|
"trainedFace": "Yüz başarıyla eğitildi.",
|
||||||
"uploadedImage": "Resim başarıyla yüklendi.",
|
"uploadedImage": "Resim başarıyla yüklendi.",
|
||||||
"updatedFaceScore": "Yüz skoru başarıyla güncellendi.",
|
"updatedFaceScore": "Yüz tanıma skoru {{name}} ({{score}}) olarak başarıyla güncellendi.",
|
||||||
"renamedFace": "Yüz başarıyla {{name}} olarak adlandırıldı"
|
"renamedFace": "Yüz başarıyla {{name}} olarak adlandırıldı"
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
|
|||||||
@ -52,7 +52,10 @@
|
|||||||
"label": "Arka planda oynat",
|
"label": "Arka planda oynat",
|
||||||
"tips": "Yayını oynatıcı arkadayken de devam ettirmek için bu seçeneği açın."
|
"tips": "Yayını oynatıcı arkadayken de devam ettirmek için bu seçeneği açın."
|
||||||
},
|
},
|
||||||
"title": "Yayın"
|
"title": "Yayın",
|
||||||
|
"debug": {
|
||||||
|
"picker": "Debug modunda akış seçimi kullanılamaz. Debug görünümü her zaman “detect” rolüne atanmış akışı kullanır."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"cameraSettings": {
|
"cameraSettings": {
|
||||||
"recording": "Kayıt",
|
"recording": "Kayıt",
|
||||||
|
|||||||
@ -8,7 +8,7 @@
|
|||||||
"motionTuner": "Hareket Algılama Ayarları - Frigate",
|
"motionTuner": "Hareket Algılama Ayarları - Frigate",
|
||||||
"frigatePlus": "Frigate+ Ayarları - Frigate",
|
"frigatePlus": "Frigate+ Ayarları - Frigate",
|
||||||
"object": "Hata Ayıklama - Frigate",
|
"object": "Hata Ayıklama - Frigate",
|
||||||
"general": "Genel Ayarlar - Frigate",
|
"general": "Kullanıcı Arayüzü Ayarları – Frigate",
|
||||||
"notifications": "Bildirim Ayarları - Frigate",
|
"notifications": "Bildirim Ayarları - Frigate",
|
||||||
"enrichments": "Zenginleştirme Ayarları - Frigate",
|
"enrichments": "Zenginleştirme Ayarları - Frigate",
|
||||||
"cameraManagement": "Kameraları Yönet - Frigate",
|
"cameraManagement": "Kameraları Yönet - Frigate",
|
||||||
@ -31,7 +31,7 @@
|
|||||||
"roles": "Roller"
|
"roles": "Roller"
|
||||||
},
|
},
|
||||||
"general": {
|
"general": {
|
||||||
"title": "Genel Ayarlar",
|
"title": "Kullanıcı Arayüzü Ayarları",
|
||||||
"liveDashboard": {
|
"liveDashboard": {
|
||||||
"automaticLiveView": {
|
"automaticLiveView": {
|
||||||
"label": "Otomatik Canlı Görünüm",
|
"label": "Otomatik Canlı Görünüm",
|
||||||
@ -45,6 +45,10 @@
|
|||||||
"displayCameraNames": {
|
"displayCameraNames": {
|
||||||
"label": "Kamera Adlarını Her Zaman Göster",
|
"label": "Kamera Adlarını Her Zaman Göster",
|
||||||
"desc": "Çok kameralı canlı izleme panelinde, kamera adlarını her zaman bir etiket içinde göster."
|
"desc": "Çok kameralı canlı izleme panelinde, kamera adlarını her zaman bir etiket içinde göster."
|
||||||
|
},
|
||||||
|
"liveFallbackTimeout": {
|
||||||
|
"label": "Canlı Oynatıcı Yedekleme Zaman Aşımı",
|
||||||
|
"desc": "Bir kameranın yüksek kaliteli canlı akışı kullanılamadığında, belirtilen saniye kadar sonra düşük bant genişliği moduna geç. Varsayılan: 3."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"storedLayouts": {
|
"storedLayouts": {
|
||||||
|
|||||||
@ -43,7 +43,12 @@
|
|||||||
"gpuEncoder": "GPU Kodlayıcı",
|
"gpuEncoder": "GPU Kodlayıcı",
|
||||||
"title": "Donanım Bilgisi",
|
"title": "Donanım Bilgisi",
|
||||||
"npuUsage": "NPU Kullanımı",
|
"npuUsage": "NPU Kullanımı",
|
||||||
"npuMemory": "NPU Bellek Kullanımı"
|
"npuMemory": "NPU Bellek Kullanımı",
|
||||||
|
"intelGpuWarning": {
|
||||||
|
"title": "Intel GPU İstatistik Uyarısı",
|
||||||
|
"message": "GPU istatistikleri kullanılamıyor",
|
||||||
|
"description": "Bu, Intel’in GPU istatistik raporlama araçlarında (intel_gpu_top) bilinen bir hatadır; araç çalışmayı bozarak, donanımsal hızlandırma ve nesne tespiti (i)GPU üzerinde doğru şekilde çalışıyor olsa bile, GPU kullanımını tekrar tekrar %0 olarak döndürür. Bu bir Frigate hatası değildir. Sorunu geçici olarak düzeltmek ve GPU’nun doğru çalıştığını doğrulamak için host sistemini yeniden başlatabilirsiniz. Bu durum performansı etkilemez."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"otherProcesses": {
|
"otherProcesses": {
|
||||||
"title": "Diğer İşlemler",
|
"title": "Diğer İşlemler",
|
||||||
|
|||||||
@ -57,7 +57,8 @@
|
|||||||
"endTimeMustAfterStartTime": "Час закінчення повинен бути після часу початку",
|
"endTimeMustAfterStartTime": "Час закінчення повинен бути після часу початку",
|
||||||
"noVaildTimeSelected": "Не вибрано допустимий діапазон часу"
|
"noVaildTimeSelected": "Не вибрано допустимий діапазон часу"
|
||||||
},
|
},
|
||||||
"success": "Експорт успішно розпочато. Перегляньте файл на сторінці експорту."
|
"success": "Експорт успішно розпочато. Перегляньте файл на сторінці експорту.",
|
||||||
|
"view": "Переглянути"
|
||||||
},
|
},
|
||||||
"fromTimeline": {
|
"fromTimeline": {
|
||||||
"saveExport": "Зберегти експорт",
|
"saveExport": "Зберегти експорт",
|
||||||
|
|||||||
@ -263,7 +263,8 @@
|
|||||||
"header": {
|
"header": {
|
||||||
"zones": "Зони",
|
"zones": "Зони",
|
||||||
"ratio": "Співвідношення",
|
"ratio": "Співвідношення",
|
||||||
"area": "Площа"
|
"area": "Площа",
|
||||||
|
"score": "Рахунок"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"annotationSettings": {
|
"annotationSettings": {
|
||||||
|
|||||||
@ -10,12 +10,8 @@ import useSWR from "swr";
|
|||||||
import { baseUrl } from "@/api/baseUrl";
|
import { baseUrl } from "@/api/baseUrl";
|
||||||
import { isMobile } from "react-device-detect";
|
import { isMobile } from "react-device-detect";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
Tooltip,
|
import { IoIosWarning } from "react-icons/io";
|
||||||
TooltipContent,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "@/components/ui/tooltip";
|
|
||||||
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
|
||||||
|
|
||||||
export type Step3FormData = {
|
export type Step3FormData = {
|
||||||
examplesGenerated: boolean;
|
examplesGenerated: boolean;
|
||||||
@ -145,20 +141,67 @@ export default function Step3ChooseExamples({
|
|||||||
);
|
);
|
||||||
await Promise.all(categorizePromises);
|
await Promise.all(categorizePromises);
|
||||||
|
|
||||||
// Step 3: Kick off training
|
// Step 2.5: Create empty folders for classes that don't have any images
|
||||||
await axios.post(`/classification/${step1Data.modelName}/train`);
|
// This ensures all classes are available in the dataset view later
|
||||||
|
const classesWithImages = new Set(
|
||||||
|
Object.values(classifications).filter((c) => c && c !== "none"),
|
||||||
|
);
|
||||||
|
const emptyFolderPromises = step1Data.classes
|
||||||
|
.filter((className) => !classesWithImages.has(className))
|
||||||
|
.map((className) =>
|
||||||
|
axios.post(
|
||||||
|
`/classification/${step1Data.modelName}/dataset/${className}/create`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await Promise.all(emptyFolderPromises);
|
||||||
|
|
||||||
toast.success(t("wizard.step3.trainingStarted"), {
|
// Step 3: Determine if we should train
|
||||||
closeButton: true,
|
// For state models, we need ALL states to have examples
|
||||||
});
|
// For object models, we need at least 2 classes with images
|
||||||
setIsTraining(true);
|
const allStatesHaveExamplesForTraining =
|
||||||
|
step1Data.modelType !== "state" ||
|
||||||
|
step1Data.classes.every((className) =>
|
||||||
|
classesWithImages.has(className),
|
||||||
|
);
|
||||||
|
const shouldTrain =
|
||||||
|
allStatesHaveExamplesForTraining && classesWithImages.size >= 2;
|
||||||
|
|
||||||
|
// Step 4: Kick off training only if we have enough classes with images
|
||||||
|
if (shouldTrain) {
|
||||||
|
await axios.post(`/classification/${step1Data.modelName}/train`);
|
||||||
|
|
||||||
|
toast.success(t("wizard.step3.trainingStarted"), {
|
||||||
|
closeButton: true,
|
||||||
|
});
|
||||||
|
setIsTraining(true);
|
||||||
|
} else {
|
||||||
|
// Don't train - not all states have examples
|
||||||
|
toast.success(t("wizard.step3.modelCreated"), {
|
||||||
|
closeButton: true,
|
||||||
|
});
|
||||||
|
setIsTraining(false);
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[step1Data, step2Data, t],
|
[step1Data, step2Data, t, onClose],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleContinueClassification = useCallback(async () => {
|
const handleContinueClassification = useCallback(async () => {
|
||||||
// Mark selected images with current class
|
// Mark selected images with current class
|
||||||
const newClassifications = { ...imageClassifications };
|
const newClassifications = { ...imageClassifications };
|
||||||
|
|
||||||
|
// Handle user going back and de-selecting images
|
||||||
|
const imagesToCheck = unknownImages.slice(0, 24);
|
||||||
|
imagesToCheck.forEach((imageName) => {
|
||||||
|
if (
|
||||||
|
newClassifications[imageName] === currentClass &&
|
||||||
|
!selectedImages.has(imageName)
|
||||||
|
) {
|
||||||
|
delete newClassifications[imageName];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Then, add all currently selected images to the current class
|
||||||
selectedImages.forEach((imageName) => {
|
selectedImages.forEach((imageName) => {
|
||||||
newClassifications[imageName] = currentClass;
|
newClassifications[imageName] = currentClass;
|
||||||
});
|
});
|
||||||
@ -329,8 +372,43 @@ export default function Step3ChooseExamples({
|
|||||||
return unclassifiedImages.length === 0;
|
return unclassifiedImages.length === 0;
|
||||||
}, [unclassifiedImages]);
|
}, [unclassifiedImages]);
|
||||||
|
|
||||||
// For state models on the last class, require all images to be classified
|
|
||||||
const isLastClass = currentClassIndex === allClasses.length - 1;
|
const isLastClass = currentClassIndex === allClasses.length - 1;
|
||||||
|
const statesWithExamples = useMemo(() => {
|
||||||
|
if (step1Data.modelType !== "state") return new Set<string>();
|
||||||
|
|
||||||
|
const states = new Set<string>();
|
||||||
|
const allImages = unknownImages.slice(0, 24);
|
||||||
|
|
||||||
|
// Check which states have at least one image classified
|
||||||
|
allImages.forEach((img) => {
|
||||||
|
let className: string | undefined;
|
||||||
|
if (selectedImages.has(img)) {
|
||||||
|
className = currentClass;
|
||||||
|
} else {
|
||||||
|
className = imageClassifications[img];
|
||||||
|
}
|
||||||
|
if (className && allClasses.includes(className)) {
|
||||||
|
states.add(className);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return states;
|
||||||
|
}, [
|
||||||
|
step1Data.modelType,
|
||||||
|
unknownImages,
|
||||||
|
imageClassifications,
|
||||||
|
selectedImages,
|
||||||
|
currentClass,
|
||||||
|
allClasses,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const allStatesHaveExamples = useMemo(() => {
|
||||||
|
if (step1Data.modelType !== "state") return true;
|
||||||
|
return allClasses.every((className) => statesWithExamples.has(className));
|
||||||
|
}, [step1Data.modelType, allClasses, statesWithExamples]);
|
||||||
|
|
||||||
|
// For state models on the last class, require all images to be classified
|
||||||
|
// But allow proceeding even if not all states have examples (with warning)
|
||||||
const canProceed = useMemo(() => {
|
const canProceed = useMemo(() => {
|
||||||
if (step1Data.modelType === "state" && isLastClass) {
|
if (step1Data.modelType === "state" && isLastClass) {
|
||||||
// Check if all 24 images will be classified after current selections are applied
|
// Check if all 24 images will be classified after current selections are applied
|
||||||
@ -353,6 +431,28 @@ export default function Step3ChooseExamples({
|
|||||||
selectedImages,
|
selectedImages,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const hasUnclassifiedImages = useMemo(() => {
|
||||||
|
if (!unknownImages) return false;
|
||||||
|
const allImages = unknownImages.slice(0, 24);
|
||||||
|
return allImages.some((img) => !imageClassifications[img]);
|
||||||
|
}, [unknownImages, imageClassifications]);
|
||||||
|
|
||||||
|
const showMissingStatesWarning = useMemo(() => {
|
||||||
|
return (
|
||||||
|
step1Data.modelType === "state" &&
|
||||||
|
isLastClass &&
|
||||||
|
!allStatesHaveExamples &&
|
||||||
|
!hasUnclassifiedImages &&
|
||||||
|
hasGenerated
|
||||||
|
);
|
||||||
|
}, [
|
||||||
|
step1Data.modelType,
|
||||||
|
isLastClass,
|
||||||
|
allStatesHaveExamples,
|
||||||
|
hasUnclassifiedImages,
|
||||||
|
hasGenerated,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleBack = useCallback(() => {
|
const handleBack = useCallback(() => {
|
||||||
if (currentClassIndex > 0) {
|
if (currentClassIndex > 0) {
|
||||||
const previousClass = allClasses[currentClassIndex - 1];
|
const previousClass = allClasses[currentClassIndex - 1];
|
||||||
@ -399,6 +499,17 @@ export default function Step3ChooseExamples({
|
|||||||
</div>
|
</div>
|
||||||
) : hasGenerated ? (
|
) : hasGenerated ? (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
|
{showMissingStatesWarning && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<IoIosWarning className="size-5" />
|
||||||
|
<AlertTitle>
|
||||||
|
{t("wizard.step3.missingStatesWarning.title")}
|
||||||
|
</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
{t("wizard.step3.missingStatesWarning.description")}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
{!allImagesClassified && (
|
{!allImagesClassified && (
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-medium">
|
<h3 className="text-lg font-medium">
|
||||||
@ -474,35 +585,22 @@ export default function Step3ChooseExamples({
|
|||||||
<Button type="button" onClick={handleBack} className="sm:flex-1">
|
<Button type="button" onClick={handleBack} className="sm:flex-1">
|
||||||
{t("button.back", { ns: "common" })}
|
{t("button.back", { ns: "common" })}
|
||||||
</Button>
|
</Button>
|
||||||
<Tooltip>
|
<Button
|
||||||
<TooltipTrigger asChild>
|
type="button"
|
||||||
<Button
|
onClick={
|
||||||
type="button"
|
allImagesClassified
|
||||||
onClick={
|
? handleContinue
|
||||||
allImagesClassified
|
: handleContinueClassification
|
||||||
? handleContinue
|
}
|
||||||
: handleContinueClassification
|
variant="select"
|
||||||
}
|
className="flex items-center justify-center gap-2 sm:flex-1"
|
||||||
variant="select"
|
disabled={
|
||||||
className="flex items-center justify-center gap-2 sm:flex-1"
|
!hasGenerated || isGenerating || isProcessing || !canProceed
|
||||||
disabled={
|
}
|
||||||
!hasGenerated || isGenerating || isProcessing || !canProceed
|
>
|
||||||
}
|
{isProcessing && <ActivityIndicator className="size-4" />}
|
||||||
>
|
{t("button.continue", { ns: "common" })}
|
||||||
{isProcessing && <ActivityIndicator className="size-4" />}
|
</Button>
|
||||||
{t("button.continue", { ns: "common" })}
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
{!canProceed && (
|
|
||||||
<TooltipPortal>
|
|
||||||
<TooltipContent>
|
|
||||||
{t("wizard.step3.allImagesRequired", {
|
|
||||||
count: unclassifiedImages.length,
|
|
||||||
})}
|
|
||||||
</TooltipContent>
|
|
||||||
</TooltipPortal>
|
|
||||||
)}
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -78,7 +78,7 @@ import { useStreamingSettings } from "@/context/streaming-settings-provider";
|
|||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
import { CameraNameLabel } from "../camera/FriendlyNameLabel";
|
import { CameraNameLabel } from "../camera/FriendlyNameLabel";
|
||||||
import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
|
import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
|
||||||
import { useIsCustomRole } from "@/hooks/use-is-custom-role";
|
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||||
|
|
||||||
type CameraGroupSelectorProps = {
|
type CameraGroupSelectorProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
@ -88,7 +88,7 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
|
|||||||
const { t } = useTranslation(["components/camera"]);
|
const { t } = useTranslation(["components/camera"]);
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
const allowedCameras = useAllowedCameras();
|
const allowedCameras = useAllowedCameras();
|
||||||
const isCustomRole = useIsCustomRole();
|
const isAdmin = useIsAdmin();
|
||||||
|
|
||||||
// tooltip
|
// tooltip
|
||||||
|
|
||||||
@ -124,7 +124,7 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
|
|||||||
const allGroups = Object.entries(config.camera_groups);
|
const allGroups = Object.entries(config.camera_groups);
|
||||||
|
|
||||||
// If custom role, filter out groups where user has no accessible cameras
|
// If custom role, filter out groups where user has no accessible cameras
|
||||||
if (isCustomRole) {
|
if (!isAdmin) {
|
||||||
return allGroups
|
return allGroups
|
||||||
.filter(([, groupConfig]) => {
|
.filter(([, groupConfig]) => {
|
||||||
// Check if user has access to at least one camera in this group
|
// Check if user has access to at least one camera in this group
|
||||||
@ -136,7 +136,7 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return allGroups.sort((a, b) => a[1].order - b[1].order);
|
return allGroups.sort((a, b) => a[1].order - b[1].order);
|
||||||
}, [config, allowedCameras, isCustomRole]);
|
}, [config, allowedCameras, isAdmin]);
|
||||||
|
|
||||||
// add group
|
// add group
|
||||||
|
|
||||||
@ -153,7 +153,7 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
|
|||||||
activeGroup={group}
|
activeGroup={group}
|
||||||
setGroup={setGroup}
|
setGroup={setGroup}
|
||||||
deleteGroup={deleteGroup}
|
deleteGroup={deleteGroup}
|
||||||
isCustomRole={isCustomRole}
|
isAdmin={isAdmin}
|
||||||
/>
|
/>
|
||||||
<Scroller className={`${isMobile ? "whitespace-nowrap" : ""}`}>
|
<Scroller className={`${isMobile ? "whitespace-nowrap" : ""}`}>
|
||||||
<div
|
<div
|
||||||
@ -221,7 +221,7 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{!isCustomRole && (
|
{isAdmin && (
|
||||||
<Button
|
<Button
|
||||||
className="bg-secondary text-muted-foreground"
|
className="bg-secondary text-muted-foreground"
|
||||||
aria-label={t("group.add")}
|
aria-label={t("group.add")}
|
||||||
@ -245,7 +245,7 @@ type NewGroupDialogProps = {
|
|||||||
activeGroup?: string;
|
activeGroup?: string;
|
||||||
setGroup: (value: string | undefined, replace?: boolean | undefined) => void;
|
setGroup: (value: string | undefined, replace?: boolean | undefined) => void;
|
||||||
deleteGroup: () => void;
|
deleteGroup: () => void;
|
||||||
isCustomRole?: boolean;
|
isAdmin?: boolean;
|
||||||
};
|
};
|
||||||
function NewGroupDialog({
|
function NewGroupDialog({
|
||||||
open,
|
open,
|
||||||
@ -254,7 +254,7 @@ function NewGroupDialog({
|
|||||||
activeGroup,
|
activeGroup,
|
||||||
setGroup,
|
setGroup,
|
||||||
deleteGroup,
|
deleteGroup,
|
||||||
isCustomRole,
|
isAdmin,
|
||||||
}: NewGroupDialogProps) {
|
}: NewGroupDialogProps) {
|
||||||
const { t } = useTranslation(["components/camera"]);
|
const { t } = useTranslation(["components/camera"]);
|
||||||
const { mutate: updateConfig } = useSWR<FrigateConfig>("config");
|
const { mutate: updateConfig } = useSWR<FrigateConfig>("config");
|
||||||
@ -390,7 +390,7 @@ function NewGroupDialog({
|
|||||||
>
|
>
|
||||||
<Title>{t("group.label")}</Title>
|
<Title>{t("group.label")}</Title>
|
||||||
<Description className="sr-only">{t("group.edit")}</Description>
|
<Description className="sr-only">{t("group.edit")}</Description>
|
||||||
{!isCustomRole && (
|
{isAdmin && (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute",
|
"absolute",
|
||||||
@ -422,7 +422,7 @@ function NewGroupDialog({
|
|||||||
group={group}
|
group={group}
|
||||||
onDeleteGroup={() => onDeleteGroup(group[0])}
|
onDeleteGroup={() => onDeleteGroup(group[0])}
|
||||||
onEditGroup={() => onEditGroup(group)}
|
onEditGroup={() => onEditGroup(group)}
|
||||||
isReadOnly={isCustomRole}
|
isReadOnly={!isAdmin}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -677,7 +677,7 @@ export function CameraGroupEdit({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const allowedCameras = useAllowedCameras();
|
const allowedCameras = useAllowedCameras();
|
||||||
const isCustomRole = useIsCustomRole();
|
const isAdmin = useIsAdmin();
|
||||||
|
|
||||||
const [openCamera, setOpenCamera] = useState<string | null>();
|
const [openCamera, setOpenCamera] = useState<string | null>();
|
||||||
|
|
||||||
@ -867,7 +867,7 @@ export function CameraGroupEdit({
|
|||||||
<FormMessage />
|
<FormMessage />
|
||||||
{[
|
{[
|
||||||
...(birdseyeConfig?.enabled &&
|
...(birdseyeConfig?.enabled &&
|
||||||
(!isCustomRole || "birdseye" in allowedCameras)
|
(isAdmin || "birdseye" in allowedCameras)
|
||||||
? ["birdseye"]
|
? ["birdseye"]
|
||||||
: []),
|
: []),
|
||||||
...Object.keys(config?.cameras ?? {})
|
...Object.keys(config?.cameras ?? {})
|
||||||
|
|||||||
@ -13,7 +13,7 @@ import { cn } from "@/lib/utils";
|
|||||||
import { isPWA } from "@/utils/isPWA";
|
import { isPWA } from "@/utils/isPWA";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useLocation } from "react-router-dom";
|
import { useHistoryBack } from "@/hooks/use-history-back";
|
||||||
|
|
||||||
const MobilePageContext = createContext<{
|
const MobilePageContext = createContext<{
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@ -24,15 +24,16 @@ type MobilePageProps = {
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
open?: boolean;
|
open?: boolean;
|
||||||
onOpenChange?: (open: boolean) => void;
|
onOpenChange?: (open: boolean) => void;
|
||||||
|
enableHistoryBack?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function MobilePage({
|
export function MobilePage({
|
||||||
children,
|
children,
|
||||||
open: controlledOpen,
|
open: controlledOpen,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
|
enableHistoryBack = true,
|
||||||
}: MobilePageProps) {
|
}: MobilePageProps) {
|
||||||
const [uncontrolledOpen, setUncontrolledOpen] = useState(false);
|
const [uncontrolledOpen, setUncontrolledOpen] = useState(false);
|
||||||
const location = useLocation();
|
|
||||||
|
|
||||||
const open = controlledOpen ?? uncontrolledOpen;
|
const open = controlledOpen ?? uncontrolledOpen;
|
||||||
const setOpen = useCallback(
|
const setOpen = useCallback(
|
||||||
@ -46,33 +47,12 @@ export function MobilePage({
|
|||||||
[onOpenChange, setUncontrolledOpen],
|
[onOpenChange, setUncontrolledOpen],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
// Handle browser back button to close mobile page
|
||||||
let isActive = true;
|
useHistoryBack({
|
||||||
|
enabled: enableHistoryBack,
|
||||||
if (open && isActive) {
|
open,
|
||||||
window.history.pushState({ isMobilePage: true }, "", location.pathname);
|
onClose: () => setOpen(false),
|
||||||
}
|
});
|
||||||
|
|
||||||
const handlePopState = (event: PopStateEvent) => {
|
|
||||||
if (open && isActive) {
|
|
||||||
event.preventDefault();
|
|
||||||
setOpen(false);
|
|
||||||
// Delay replaceState to ensure state updates are processed
|
|
||||||
setTimeout(() => {
|
|
||||||
if (isActive) {
|
|
||||||
window.history.replaceState(null, "", location.pathname);
|
|
||||||
}
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("popstate", handlePopState);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
isActive = false;
|
|
||||||
window.removeEventListener("popstate", handlePopState);
|
|
||||||
};
|
|
||||||
}, [open, setOpen, location.pathname]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MobilePageContext.Provider value={{ open, onOpenChange: setOpen }}>
|
<MobilePageContext.Provider value={{ open, onOpenChange: setOpen }}>
|
||||||
|
|||||||
@ -1,7 +1,11 @@
|
|||||||
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
|
||||||
import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer";
|
import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { ReviewSegment, ThreatLevel } from "@/types/review";
|
import {
|
||||||
|
ReviewSegment,
|
||||||
|
ThreatLevel,
|
||||||
|
THREAT_LEVEL_LABELS,
|
||||||
|
} from "@/types/review";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { isDesktop } from "react-device-detect";
|
import { isDesktop } from "react-device-detect";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@ -55,13 +59,22 @@ export function GenAISummaryDialog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
let concerns = "";
|
let concerns = "";
|
||||||
switch (aiAnalysis.potential_threat_level) {
|
const threatLevel = aiAnalysis.potential_threat_level ?? 0;
|
||||||
case ThreatLevel.SUSPICIOUS:
|
|
||||||
concerns = `• ${t("suspiciousActivity", { ns: "views/events" })}\n`;
|
if (threatLevel > 0) {
|
||||||
break;
|
let label = "";
|
||||||
case ThreatLevel.DANGER:
|
|
||||||
concerns = `• ${t("threateningActivity", { ns: "views/events" })}\n`;
|
switch (threatLevel) {
|
||||||
break;
|
case ThreatLevel.NEEDS_REVIEW:
|
||||||
|
label = t("needsReview", { ns: "views/events" });
|
||||||
|
break;
|
||||||
|
case ThreatLevel.SECURITY_CONCERN:
|
||||||
|
label = t("securityConcern", { ns: "views/events" });
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
label = THREAT_LEVEL_LABELS[threatLevel as ThreatLevel] || "Unknown";
|
||||||
|
}
|
||||||
|
concerns = `• ${label}\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
(aiAnalysis.other_concerns ?? []).forEach((c) => {
|
(aiAnalysis.other_concerns ?? []).forEach((c) => {
|
||||||
|
|||||||
@ -113,7 +113,12 @@ export function PlatformAwareSheet({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={open} onOpenChange={onOpenChange} modal={false}>
|
<Sheet
|
||||||
|
open={open}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
modal={false}
|
||||||
|
enableHistoryBack
|
||||||
|
>
|
||||||
<SheetTrigger asChild className={triggerClassName}>
|
<SheetTrigger asChild className={triggerClassName}>
|
||||||
{trigger}
|
{trigger}
|
||||||
</SheetTrigger>
|
</SheetTrigger>
|
||||||
|
|||||||
@ -1,7 +1,11 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useApiHost } from "@/api";
|
import { useApiHost } from "@/api";
|
||||||
import { isCurrentHour } from "@/utils/dateUtil";
|
import { isCurrentHour } from "@/utils/dateUtil";
|
||||||
import { ReviewSegment } from "@/types/review";
|
import {
|
||||||
|
ReviewSegment,
|
||||||
|
ThreatLevel,
|
||||||
|
THREAT_LEVEL_LABELS,
|
||||||
|
} from "@/types/review";
|
||||||
import { getIconForLabel } from "@/utils/iconUtil";
|
import { getIconForLabel } from "@/utils/iconUtil";
|
||||||
import TimeAgo from "../dynamic/TimeAgo";
|
import TimeAgo from "../dynamic/TimeAgo";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
@ -44,7 +48,7 @@ export default function PreviewThumbnailPlayer({
|
|||||||
onClick,
|
onClick,
|
||||||
onTimeUpdate,
|
onTimeUpdate,
|
||||||
}: PreviewPlayerProps) {
|
}: PreviewPlayerProps) {
|
||||||
const { t } = useTranslation(["components/player"]);
|
const { t } = useTranslation(["components/player", "views/events"]);
|
||||||
const apiHost = useApiHost();
|
const apiHost = useApiHost();
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
const [imgRef, imgLoaded, onImgLoad] = useImageLoaded();
|
const [imgRef, imgLoaded, onImgLoad] = useImageLoaded();
|
||||||
@ -319,11 +323,21 @@ export default function PreviewThumbnailPlayer({
|
|||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
</div>
|
</div>
|
||||||
<TooltipContent className="smart-capitalize">
|
<TooltipContent className="smart-capitalize">
|
||||||
{review.data.metadata.potential_threat_level == 1 ? (
|
{(() => {
|
||||||
<>{t("suspiciousActivity", { ns: "views/events" })}</>
|
const threatLevel =
|
||||||
) : (
|
review.data.metadata.potential_threat_level ?? 0;
|
||||||
<>{t("threateningActivity", { ns: "views/events" })}</>
|
switch (threatLevel) {
|
||||||
)}
|
case ThreatLevel.NEEDS_REVIEW:
|
||||||
|
return t("needsReview", { ns: "views/events" });
|
||||||
|
case ThreatLevel.SECURITY_CONCERN:
|
||||||
|
return t("securityConcern", { ns: "views/events" });
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
THREAT_LEVEL_LABELS[threatLevel as ThreatLevel] ||
|
||||||
|
"Unknown"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})()}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import * as React from "react";
|
|||||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useHistoryBack } from "@/hooks/use-history-back";
|
||||||
|
|
||||||
// Enhanced Dialog with History Support
|
// Enhanced Dialog with History Support
|
||||||
interface HistoryDialogProps extends DialogPrimitive.DialogProps {
|
interface HistoryDialogProps extends DialogPrimitive.DialogProps {
|
||||||
@ -15,51 +16,28 @@ const Dialog = ({
|
|||||||
...props
|
...props
|
||||||
}: HistoryDialogProps) => {
|
}: HistoryDialogProps) => {
|
||||||
const [internalOpen, setInternalOpen] = React.useState(open || false);
|
const [internalOpen, setInternalOpen] = React.useState(open || false);
|
||||||
const historyStateRef = React.useRef<null | {
|
|
||||||
listener: (e: PopStateEvent) => void;
|
|
||||||
}>(null);
|
|
||||||
|
|
||||||
|
// Sync internal state with controlled open prop
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (open !== undefined) {
|
if (open !== undefined) {
|
||||||
setInternalOpen(open);
|
setInternalOpen(open);
|
||||||
}
|
}
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
const handleOpenChange = React.useCallback(
|
||||||
if (enableHistoryBack) {
|
(newOpen: boolean) => {
|
||||||
if (internalOpen) {
|
setInternalOpen(newOpen);
|
||||||
window.history.pushState({ dialogOpen: true }, "");
|
onOpenChange?.(newOpen);
|
||||||
|
},
|
||||||
|
[onOpenChange],
|
||||||
|
);
|
||||||
|
|
||||||
const listener = () => {
|
// Handle browser back button to close dialog
|
||||||
setInternalOpen(false);
|
useHistoryBack({
|
||||||
if (onOpenChange) onOpenChange(false);
|
enabled: enableHistoryBack,
|
||||||
};
|
open: internalOpen,
|
||||||
|
onClose: () => handleOpenChange(false),
|
||||||
historyStateRef.current = { listener };
|
});
|
||||||
window.addEventListener("popstate", listener);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (internalOpen) {
|
|
||||||
window.removeEventListener("popstate", listener);
|
|
||||||
historyStateRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
} else if (historyStateRef.current) {
|
|
||||||
window.removeEventListener(
|
|
||||||
"popstate",
|
|
||||||
historyStateRef.current.listener,
|
|
||||||
);
|
|
||||||
historyStateRef.current = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [enableHistoryBack, internalOpen, onOpenChange]);
|
|
||||||
|
|
||||||
const handleOpenChange = (open: boolean) => {
|
|
||||||
setInternalOpen(open);
|
|
||||||
if (onOpenChange) {
|
|
||||||
onOpenChange(open);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DialogPrimitive.Root
|
<DialogPrimitive.Root
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority";
|
|||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useHistoryBack } from "@/hooks/use-history-back";
|
||||||
|
|
||||||
// Enhanced Sheet with History Support
|
// Enhanced Sheet with History Support
|
||||||
interface HistorySheetProps extends SheetPrimitive.DialogProps {
|
interface HistorySheetProps extends SheetPrimitive.DialogProps {
|
||||||
@ -17,51 +18,28 @@ const Sheet = ({
|
|||||||
...props
|
...props
|
||||||
}: HistorySheetProps) => {
|
}: HistorySheetProps) => {
|
||||||
const [internalOpen, setInternalOpen] = React.useState(open || false);
|
const [internalOpen, setInternalOpen] = React.useState(open || false);
|
||||||
const historyStateRef = React.useRef<null | {
|
|
||||||
listener: (e: PopStateEvent) => void;
|
|
||||||
}>(null);
|
|
||||||
|
|
||||||
|
// Sync internal state with controlled open prop
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (open !== undefined) {
|
if (open !== undefined) {
|
||||||
setInternalOpen(open);
|
setInternalOpen(open);
|
||||||
}
|
}
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
const handleOpenChange = React.useCallback(
|
||||||
if (enableHistoryBack) {
|
(newOpen: boolean) => {
|
||||||
if (internalOpen) {
|
setInternalOpen(newOpen);
|
||||||
window.history.pushState({ sheetOpen: true }, "");
|
onOpenChange?.(newOpen);
|
||||||
|
},
|
||||||
|
[onOpenChange],
|
||||||
|
);
|
||||||
|
|
||||||
const listener = () => {
|
// Handle browser back button to close sheet
|
||||||
setInternalOpen(false);
|
useHistoryBack({
|
||||||
if (onOpenChange) onOpenChange(false);
|
enabled: enableHistoryBack,
|
||||||
};
|
open: internalOpen,
|
||||||
|
onClose: () => handleOpenChange(false),
|
||||||
historyStateRef.current = { listener };
|
});
|
||||||
window.addEventListener("popstate", listener);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (internalOpen) {
|
|
||||||
window.removeEventListener("popstate", listener);
|
|
||||||
historyStateRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
} else if (historyStateRef.current) {
|
|
||||||
window.removeEventListener(
|
|
||||||
"popstate",
|
|
||||||
historyStateRef.current.listener,
|
|
||||||
);
|
|
||||||
historyStateRef.current = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [enableHistoryBack, internalOpen, onOpenChange]);
|
|
||||||
|
|
||||||
const handleOpenChange = (open: boolean) => {
|
|
||||||
setInternalOpen(open);
|
|
||||||
if (onOpenChange) {
|
|
||||||
onOpenChange(open);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SheetPrimitive.Root
|
<SheetPrimitive.Root
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { baseUrl } from "@/api/baseUrl";
|
||||||
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
|
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
|
||||||
import { useCallback, useEffect, useState, useMemo } from "react";
|
import { useCallback, useEffect, useState, useMemo } from "react";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
@ -41,9 +42,12 @@ export default function useCameraLiveMode(
|
|||||||
|
|
||||||
const metadataPromises = streamNames.map(async (streamName) => {
|
const metadataPromises = streamNames.map(async (streamName) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/go2rtc/streams/${streamName}`, {
|
const response = await fetch(
|
||||||
priority: "low",
|
`${baseUrl}api/go2rtc/streams/${streamName}`,
|
||||||
});
|
{
|
||||||
|
priority: "low",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|||||||
74
web/src/hooks/use-history-back.ts
Normal file
74
web/src/hooks/use-history-back.ts
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
interface UseHistoryBackOptions {
|
||||||
|
enabled: boolean;
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook that manages browser history for overlay components (dialogs, sheets, etc.)
|
||||||
|
* When enabled, pressing the browser back button will close the overlay instead of navigating away.
|
||||||
|
*/
|
||||||
|
export function useHistoryBack({
|
||||||
|
enabled,
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
}: UseHistoryBackOptions): void {
|
||||||
|
const historyPushedRef = React.useRef(false);
|
||||||
|
const closedByBackRef = React.useRef(false);
|
||||||
|
const urlWhenOpenedRef = React.useRef<string | null>(null);
|
||||||
|
|
||||||
|
// Keep onClose in a ref to avoid effect re-runs that cause multiple history pushes
|
||||||
|
const onCloseRef = React.useRef(onClose);
|
||||||
|
React.useLayoutEffect(() => {
|
||||||
|
onCloseRef.current = onClose;
|
||||||
|
});
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
|
||||||
|
if (open) {
|
||||||
|
// Only push history state if we haven't already (prevents duplicates in strict mode)
|
||||||
|
if (!historyPushedRef.current) {
|
||||||
|
// Store the current URL (pathname + search, without hash) before pushing history state
|
||||||
|
urlWhenOpenedRef.current =
|
||||||
|
window.location.pathname + window.location.search;
|
||||||
|
window.history.pushState({ overlayOpen: true }, "");
|
||||||
|
historyPushedRef.current = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePopState = () => {
|
||||||
|
closedByBackRef.current = true;
|
||||||
|
historyPushedRef.current = false;
|
||||||
|
urlWhenOpenedRef.current = null;
|
||||||
|
onCloseRef.current();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("popstate", handlePopState);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("popstate", handlePopState);
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Overlay is closing - clean up history if we pushed and it wasn't via back button
|
||||||
|
if (historyPushedRef.current && !closedByBackRef.current) {
|
||||||
|
const currentUrl = window.location.pathname + window.location.search;
|
||||||
|
const urlWhenOpened = urlWhenOpenedRef.current;
|
||||||
|
|
||||||
|
// If the URL has changed (e.g., filters were applied via search params),
|
||||||
|
// don't go back as it would undo the filter update.
|
||||||
|
// The history entry we pushed will remain, but that's acceptable compared
|
||||||
|
// to losing the user's filter changes.
|
||||||
|
if (!urlWhenOpened || currentUrl === urlWhenOpened) {
|
||||||
|
// URL hasn't changed, safe to go back and remove our history entry
|
||||||
|
window.history.back();
|
||||||
|
}
|
||||||
|
// If URL changed, we skip history.back() to preserve the filter updates
|
||||||
|
}
|
||||||
|
historyPushedRef.current = false;
|
||||||
|
closedByBackRef.current = false;
|
||||||
|
urlWhenOpenedRef.current = null;
|
||||||
|
}
|
||||||
|
}, [enabled, open]);
|
||||||
|
}
|
||||||
@ -49,6 +49,7 @@ function ConfigEditor() {
|
|||||||
|
|
||||||
const [restartDialogOpen, setRestartDialogOpen] = useState(false);
|
const [restartDialogOpen, setRestartDialogOpen] = useState(false);
|
||||||
const { send: sendRestart } = useRestart();
|
const { send: sendRestart } = useRestart();
|
||||||
|
const initialValidationRef = useRef(false);
|
||||||
|
|
||||||
const onHandleSaveConfig = useCallback(
|
const onHandleSaveConfig = useCallback(
|
||||||
async (save_option: SaveOptions): Promise<void> => {
|
async (save_option: SaveOptions): Promise<void> => {
|
||||||
@ -171,6 +172,33 @@ function ConfigEditor() {
|
|||||||
};
|
};
|
||||||
}, [rawConfig, apiHost, systemTheme, theme, onHandleSaveConfig]);
|
}, [rawConfig, apiHost, systemTheme, theme, onHandleSaveConfig]);
|
||||||
|
|
||||||
|
// when in safe mode, attempt to validate the existing (invalid) config immediately
|
||||||
|
// so that the user sees the validation errors without needing to press save
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
config?.safe_mode &&
|
||||||
|
rawConfig &&
|
||||||
|
!initialValidationRef.current &&
|
||||||
|
!error
|
||||||
|
) {
|
||||||
|
initialValidationRef.current = true;
|
||||||
|
axios
|
||||||
|
.post(`config/save?save_option=saveonly`, rawConfig, {
|
||||||
|
headers: { "Content-Type": "text/plain" },
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
// if this succeeds while in safe mode, we won't force any UI change
|
||||||
|
})
|
||||||
|
.catch((e: AxiosError<ApiErrorResponse>) => {
|
||||||
|
const errorMessage =
|
||||||
|
e.response?.data?.message ||
|
||||||
|
e.response?.data?.detail ||
|
||||||
|
"Unknown error";
|
||||||
|
setError(errorMessage);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [config?.safe_mode, rawConfig, error]);
|
||||||
|
|
||||||
// monitoring state
|
// monitoring state
|
||||||
|
|
||||||
const [hasChanges, setHasChanges] = useState(false);
|
const [hasChanges, setHasChanges] = useState(false);
|
||||||
|
|||||||
@ -14,12 +14,12 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { useEffect, useMemo, useRef } from "react";
|
import { useEffect, useMemo, useRef } from "react";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
|
import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
|
||||||
import { useIsCustomRole } from "@/hooks/use-is-custom-role";
|
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||||
|
|
||||||
function Live() {
|
function Live() {
|
||||||
const { t } = useTranslation(["views/live"]);
|
const { t } = useTranslation(["views/live"]);
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
const isCustomRole = useIsCustomRole();
|
const isAdmin = useIsAdmin();
|
||||||
|
|
||||||
// selection
|
// selection
|
||||||
|
|
||||||
@ -94,7 +94,7 @@ function Live() {
|
|||||||
|
|
||||||
const includesBirdseye = useMemo(() => {
|
const includesBirdseye = useMemo(() => {
|
||||||
// Restricted users should never have access to birdseye
|
// Restricted users should never have access to birdseye
|
||||||
if (isCustomRole) {
|
if (!isAdmin) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -109,7 +109,7 @@ function Live() {
|
|||||||
} else {
|
} else {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}, [config, cameraGroup, isCustomRole]);
|
}, [config, cameraGroup, isAdmin]);
|
||||||
|
|
||||||
const cameras = useMemo(() => {
|
const cameras = useMemo(() => {
|
||||||
if (!config) {
|
if (!config) {
|
||||||
|
|||||||
@ -87,6 +87,13 @@ export type ZoomLevel = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export enum ThreatLevel {
|
export enum ThreatLevel {
|
||||||
SUSPICIOUS = 1,
|
NORMAL = 0,
|
||||||
DANGER = 2,
|
NEEDS_REVIEW = 1,
|
||||||
|
SECURITY_CONCERN = 2,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const THREAT_LEVEL_LABELS: Record<ThreatLevel, string> = {
|
||||||
|
[ThreatLevel.NORMAL]: "Normal",
|
||||||
|
[ThreatLevel.NEEDS_REVIEW]: "Needs review",
|
||||||
|
[ThreatLevel.SECURITY_CONCERN]: "Security concern",
|
||||||
|
};
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { baseUrl } from "@/api/baseUrl";
|
||||||
import { generateFixedHash, isValidId } from "./stringUtil";
|
import { generateFixedHash, isValidId } from "./stringUtil";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -52,9 +53,12 @@ export async function detectReolinkCamera(
|
|||||||
password,
|
password,
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await fetch(`/api/reolink/detect?${params.toString()}`, {
|
const response = await fetch(
|
||||||
method: "GET",
|
`${baseUrl}api/reolink/detect?${params.toString()}`,
|
||||||
});
|
{
|
||||||
|
method: "GET",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@ -54,7 +54,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { EmptyCard } from "@/components/card/EmptyCard";
|
import { EmptyCard } from "@/components/card/EmptyCard";
|
||||||
import { BsFillCameraVideoOffFill } from "react-icons/bs";
|
import { BsFillCameraVideoOffFill } from "react-icons/bs";
|
||||||
import { AuthContext } from "@/context/auth-context";
|
import { AuthContext } from "@/context/auth-context";
|
||||||
import { useIsCustomRole } from "@/hooks/use-is-custom-role";
|
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||||
|
|
||||||
type LiveDashboardViewProps = {
|
type LiveDashboardViewProps = {
|
||||||
cameras: CameraConfig[];
|
cameras: CameraConfig[];
|
||||||
@ -661,10 +661,10 @@ export default function LiveDashboardView({
|
|||||||
function NoCameraView() {
|
function NoCameraView() {
|
||||||
const { t } = useTranslation(["views/live"]);
|
const { t } = useTranslation(["views/live"]);
|
||||||
const { auth } = useContext(AuthContext);
|
const { auth } = useContext(AuthContext);
|
||||||
const isCustomRole = useIsCustomRole();
|
const isAdmin = useIsAdmin();
|
||||||
|
|
||||||
// Check if this is a restricted user with no cameras in this group
|
// Check if this is a restricted user with no cameras in this group
|
||||||
const isRestricted = isCustomRole && auth.isAuthenticated;
|
const isRestricted = !isAdmin && auth.isAuthenticated;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex size-full items-center justify-center">
|
<div className="flex size-full items-center justify-center">
|
||||||
|
|||||||
@ -35,6 +35,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
|
|
||||||
import { useDocDomain } from "@/hooks/use-doc-domain";
|
import { useDocDomain } from "@/hooks/use-doc-domain";
|
||||||
import { getTranslatedLabel } from "@/utils/i18n";
|
import { getTranslatedLabel } from "@/utils/i18n";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type MasksAndZoneViewProps = {
|
type MasksAndZoneViewProps = {
|
||||||
selectedCamera: string;
|
selectedCamera: string;
|
||||||
@ -697,7 +698,10 @@ export default function MasksAndZonesView({
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="flex max-h-[50%] md:mr-3 md:h-dvh md:max-h-full md:w-7/12 md:grow"
|
className={cn(
|
||||||
|
"flex max-h-[50%] md:h-dvh md:max-h-full md:w-7/12 md:grow",
|
||||||
|
isDesktop && "md:mr-3",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<div className="mx-auto flex size-full flex-row justify-center">
|
<div className="mx-auto flex size-full flex-row justify-center">
|
||||||
{cameraConfig &&
|
{cameraConfig &&
|
||||||
|
|||||||
@ -23,6 +23,8 @@ import { LuExternalLink } from "react-icons/lu";
|
|||||||
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
|
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
import { useDocDomain } from "@/hooks/use-doc-domain";
|
import { useDocDomain } from "@/hooks/use-doc-domain";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { isDesktop } from "react-device-detect";
|
||||||
|
|
||||||
type MotionTunerViewProps = {
|
type MotionTunerViewProps = {
|
||||||
selectedCamera: string;
|
selectedCamera: string;
|
||||||
@ -325,7 +327,12 @@ export default function MotionTunerView({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{cameraConfig ? (
|
{cameraConfig ? (
|
||||||
<div className="flex max-h-[70%] md:mr-3 md:h-dvh md:max-h-full md:w-7/12 md:grow">
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex max-h-[70%] md:h-dvh md:max-h-full md:w-7/12 md:grow",
|
||||||
|
isDesktop && "md:mr-3",
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div className="size-full min-h-10">
|
<div className="size-full min-h-10">
|
||||||
<AutoUpdatingCameraImage
|
<AutoUpdatingCameraImage
|
||||||
camera={cameraConfig.name}
|
camera={cameraConfig.name}
|
||||||
|
|||||||
@ -43,6 +43,7 @@ import { useTriggers } from "@/api/ws";
|
|||||||
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
|
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
|
||||||
import { CiCircleAlert } from "react-icons/ci";
|
import { CiCircleAlert } from "react-icons/ci";
|
||||||
import { useDocDomain } from "@/hooks/use-doc-domain";
|
import { useDocDomain } from "@/hooks/use-doc-domain";
|
||||||
|
import { isDesktop } from "react-device-detect";
|
||||||
|
|
||||||
type ConfigSetBody = {
|
type ConfigSetBody = {
|
||||||
requires_restart: number;
|
requires_restart: number;
|
||||||
@ -440,7 +441,12 @@ export default function TriggerView({
|
|||||||
return (
|
return (
|
||||||
<div className="flex size-full flex-col md:flex-row">
|
<div className="flex size-full flex-col md:flex-row">
|
||||||
<Toaster position="top-center" closeButton={true} />
|
<Toaster position="top-center" closeButton={true} />
|
||||||
<div className="scrollbar-container order-last mb-2 mt-2 flex h-full w-full flex-col overflow-y-auto pb-2 md:order-none md:mr-3 md:mt-0">
|
<div
|
||||||
|
className={cn(
|
||||||
|
"scrollbar-container order-last mb-2 mt-2 flex h-full w-full flex-col overflow-y-auto pb-2",
|
||||||
|
isDesktop && "order-none mr-3 mt-0",
|
||||||
|
)}
|
||||||
|
>
|
||||||
{!isSemanticSearchEnabled ? (
|
{!isSemanticSearchEnabled ? (
|
||||||
<div className="mb-5 flex flex-row items-center justify-between gap-2">
|
<div className="mb-5 flex flex-row items-center justify-between gap-2">
|
||||||
<div className="flex flex-col items-start">
|
<div className="flex flex-col items-start">
|
||||||
@ -651,7 +657,7 @@ export default function TriggerView({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Desktop Table View */}
|
{/* Desktop Table View */}
|
||||||
<div className="scrollbar-container hidden flex-1 overflow-hidden rounded-lg border border-border bg-background_alt md:mr-3 md:block">
|
<div className="scrollbar-container hidden flex-1 overflow-hidden rounded-lg border border-border bg-background_alt md:block">
|
||||||
<div className="h-full overflow-auto">
|
<div className="h-full overflow-auto">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader className="sticky top-0 bg-muted/50">
|
<TableHeader className="sticky top-0 bg-muted/50">
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user