mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-29 11:26:49 +03:00
Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0d48c98d9 | ||
|
|
1ba10bce2a | ||
|
|
8120a4b117 | ||
|
|
03e6f54670 | ||
|
|
13a3af1957 | ||
|
|
8aa0ddf740 | ||
|
|
5fc8d4f1c6 | ||
|
|
a8c838aee2 | ||
|
|
4080c46428 | ||
|
|
10a0d5ea37 | ||
|
|
64d6366ac4 | ||
|
|
79ca18d439 | ||
|
|
cabdffea20 | ||
|
|
ba41c90c07 | ||
|
|
0c52a3175d | ||
|
|
4c648f8147 | ||
|
|
e0d4337a25 | ||
|
|
5e689f2d85 | ||
|
|
57a2765d00 | ||
|
|
dd77bae4f7 | ||
|
|
8a98d7c9b1 | ||
|
|
8a8da663c0 | ||
|
|
eddc9fccd1 | ||
|
|
84f981a77c | ||
|
|
4100383738 | ||
|
|
fa30a7e1ae |
@@ -46,9 +46,6 @@ jobs:
|
||||
- name: Build web
|
||||
run: npm run build
|
||||
working-directory: ./web
|
||||
# - name: Test
|
||||
# run: npm run test
|
||||
# working-directory: ./web
|
||||
|
||||
web_e2e:
|
||||
name: Web - E2E Tests
|
||||
|
||||
@@ -5,7 +5,7 @@ aiohttp == 3.12.*
|
||||
starlette == 0.47.*
|
||||
starlette-context == 0.5.*
|
||||
fastapi[standard-no-fastapi-cloud-cli] == 0.116.*
|
||||
uvicorn == 0.46.*
|
||||
uvicorn == 0.52.*
|
||||
slowapi == 0.1.*
|
||||
joserfc == 1.6.*
|
||||
cryptography == 46.0.*
|
||||
@@ -61,7 +61,7 @@ rapidfuzz==3.12.*
|
||||
argcomplete==2.0.*
|
||||
contextlib2==0.6.*
|
||||
future==0.18.*
|
||||
netaddr==0.8.*
|
||||
netaddr==1.3.*
|
||||
netifaces==0.10.*
|
||||
prometheus-client == 0.26.*
|
||||
# TFLite
|
||||
@@ -73,4 +73,4 @@ faster-whisper==1.2.*
|
||||
librosa==0.11.*
|
||||
soundfile==0.13.*
|
||||
# Memory profiling
|
||||
memray == 1.15.*
|
||||
memray == 1.20.*
|
||||
|
||||
@@ -343,13 +343,6 @@ http {
|
||||
add_header Cache-Control "public";
|
||||
}
|
||||
|
||||
location /fonts/ {
|
||||
access_log off;
|
||||
expires 1y;
|
||||
include security_headers.conf;
|
||||
add_header Cache-Control "public";
|
||||
}
|
||||
|
||||
location /locales/ {
|
||||
access_log off;
|
||||
include security_headers.conf;
|
||||
|
||||
@@ -14,4 +14,4 @@ nvidia-nccl-cu12==2.26.2.post1; platform_machine == 'x86_64'
|
||||
nvidia-nvjitlink-cu12==12.8.93; platform_machine == 'x86_64'
|
||||
onnx==1.16.*; platform_machine == 'x86_64'
|
||||
onnxruntime-gpu==1.24.*; platform_machine == 'x86_64'
|
||||
protobuf==5.29.6; platform_machine == 'x86_64'
|
||||
protobuf==3.20.3; platform_machine == 'x86_64'
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
onnx == 1.14.0; platform_machine == 'aarch64'
|
||||
protobuf == 5.29.6; platform_machine == 'aarch64'
|
||||
protobuf == 3.20.3; platform_machine == 'aarch64'
|
||||
|
||||
@@ -28,6 +28,24 @@ WebRTC may use an external STUN server for NAT traversal. MSE and HLS streaming
|
||||
|
||||
:::
|
||||
|
||||
### Selecting a streaming technology
|
||||
|
||||
Frigate [defaults to MSE](#why-does-frigate-prefer-mse-over-webrtc-for-live-view) for restreamed cameras by design. To use WebRTC, select it explicitly from a camera's single-camera Live view settings (the settings menu in the camera's Live view header on desktop, or the settings drawer on mobile). Three related controls work together:
|
||||
|
||||
- **Stream**: _what_ to play. This lists the [streams you've configured](#setting-streams-for-live-ui) (for example `Main Stream` and `Sub Stream`).
|
||||
- **Force low-bandwidth mode**: a switch that always plays Frigate's built-in low-bandwidth feed (the stream assigned the `detect` role, using JSMpeg) instead of the selected stream. It works anywhere without go2rtc and is useful on slow or metered connections. While it is enabled, the stream and streaming technology selectors are disabled; your stream and technology choices are restored when you turn it off.
|
||||
- **Streaming Technology**: _how_ to play the selected stream, listing **MSE** and **WebRTC**. It is only shown for a restreamed stream.
|
||||
|
||||
- The choices are saved **per device, per camera** in your browser's local storage.
|
||||
- **WebRTC is only selectable when it can actually work for that stream.** When it can't, the option is shown disabled with the reason inline, and a more detailed reason (the failing codecs, or why the connectivity check failed) is logged to your browser's console. Common reasons:
|
||||
- **Not configured**: no `candidates` or `ice_servers` are set under `go2rtc.webrtc` (see [WebRTC extra configuration](#webrtc-extra-configuration)).
|
||||
- **Could not connect**: e.g. port `8555` isn't reachable, or a STUN/TURN server is misconfigured. Frigate runs a one-time WebRTC connectivity check when the Live view opens; the option may briefly show as "checking" while it runs.
|
||||
- **Unsupported video codec**: the stream's video codec can't be played over WebRTC in your browser, most commonly H.265/HEVC in Firefox or Edge.
|
||||
- **Unsupported audio codec**: WebRTC needs opus or G.711 audio, so a stream whose playback audio is only AAC (without an added opus/G.711 track) can't carry audio over WebRTC. See [Audio Support](#audio-support) for how to add one.
|
||||
- **Unsupported browser**: the browser doesn't support WebRTC.
|
||||
|
||||
When WebRTC isn't available, Frigate automatically uses MSE (or falls back to JSMpeg), so live view keeps working regardless of the selection.
|
||||
|
||||
### Camera Settings Recommendations
|
||||
|
||||
If you are using go2rtc, you should adjust the following settings in your camera's firmware for the best experience with Live view:
|
||||
@@ -157,6 +175,17 @@ WebRTC works by creating a TCP or UDP connection on port `8555`. However, it req
|
||||
- stun:8555
|
||||
```
|
||||
|
||||
- The web UI uses the STUN and TURN servers in `ice_servers` and falls back to Google's public STUN server when none are set:
|
||||
|
||||
```yaml title="config.yml"
|
||||
go2rtc:
|
||||
webrtc:
|
||||
ice_servers:
|
||||
- urls: [turn:turn.example.com:3478]
|
||||
username: frigate
|
||||
credential: password
|
||||
```
|
||||
|
||||
- For access through Tailscale, the Frigate system's Tailscale IP must be added as a WebRTC candidate. Tailscale IPs all start with `100.`, and are reserved within the `100.64.0.0/10` CIDR block.
|
||||
|
||||
- Note that some browsers may not support H.265 (HEVC). You can check your browser's current version for H.265 compatibility [here](https://github.com/AlexxIT/go2rtc?tab=readme-ov-file#codecs-madness).
|
||||
@@ -206,6 +235,8 @@ For devices that support two way talk, Frigate can be configured to use the feat
|
||||
- Ensure you access Frigate via https (may require [opening port 8971](/frigate/installation/#ports)).
|
||||
- For the Home Assistant Frigate card, [follow the docs](http://card.camera/#/usage/2-way-audio) for the correct source.
|
||||
|
||||
The two-way talk control in the single-camera Live view is only enabled when WebRTC is available; if WebRTC isn't configured or can't connect, the control is shown disabled.
|
||||
|
||||
To use the Reolink Doorbell with two way talk, you should use the [recommended Reolink configuration](/configuration/camera_specific#reolink-cameras)
|
||||
|
||||
As a starting point to check compatibility for your camera, view the list of cameras supported for two-way talk on the [go2rtc repository](https://github.com/AlexxIT/go2rtc?tab=readme-ov-file#two-way-audio). For cameras in the category `ONVIF Profile T`, you can use the [ONVIF Conformant Products Database](https://www.onvif.org/conformant-products/)'s FeatureList to check for the presence of `AudioOutput`. A camera that supports `ONVIF Profile T` _usually_ supports this, but due to inconsistent support, a camera that explicitly lists this feature may still not work. If no entry for your camera exists on the database, it is recommended not to buy it or to consult with the manufacturer's support on the feature availability.
|
||||
|
||||
@@ -204,11 +204,20 @@ Light guidelines and advice:
|
||||
npm run lint
|
||||
```
|
||||
|
||||
- Add to unit tests and ensure they pass. As much as possible, you should strive to _increase_ test coverage whenever making changes. This will help ensure features do not accidentally become broken in the future.
|
||||
- If you run into error messages like "TypeError: Cannot read properties of undefined (reading 'context')" when running tests, this may be due to these issues (https://github.com/vitest-dev/vitest/issues/1910, https://github.com/vitest-dev/vitest/issues/1652) in vitest, but I haven't been able to resolve them.
|
||||
- Ensure the backend [unit tests](#unit-tests) pass. Your PR cannot be merged unless tests pass.
|
||||
|
||||
```shell
|
||||
python3 -u -m unittest
|
||||
```
|
||||
|
||||
- Ensure the end-to-end tests pass. They run in Playwright against a production build with mocked API data, so they don't need a running Frigate instance. Add or update tests in `web/e2e/specs/` when you change UI behavior.
|
||||
|
||||
```console
|
||||
npm run test
|
||||
# First-time setup
|
||||
npx playwright install chromium
|
||||
|
||||
# Build the app and run all tests
|
||||
npm run e2e:build && npm run e2e
|
||||
```
|
||||
|
||||
- Test in different browsers. Firefox, Chrome, and Safari all have different quirks that make them unique targets to interact with.
|
||||
|
||||
@@ -147,7 +147,7 @@ If an [MQTT broker](/integrations/mqtt) is configured, Frigate maintains a conne
|
||||
For [WebRTC live streaming](/configuration/live), Frigate uses STUN for NAT traversal:
|
||||
|
||||
- **go2rtc** defaults to a local STUN listener (`stun:8555`), no internet required.
|
||||
- **The web UI's WebRTC player** includes a fallback to Google's public STUN server (`stun:stun.l.google.com:19302`), which requires internet.
|
||||
- **The web UI** uses the servers in `go2rtc.webrtc.ice_servers` for its WebRTC player and for the WebRTC connectivity check it runs when the Live view loads. If none are set, it uses Google's public STUN server (`stun:stun.l.google.com:19302`), which requires internet access from the browser. Set `ice_servers` to a STUN or TURN server on your network to avoid this.
|
||||
|
||||
## Home Assistant Supervisor
|
||||
|
||||
|
||||
@@ -786,6 +786,13 @@ def auth(request: Request):
|
||||
|
||||
user = token.claims.get("sub")
|
||||
role = token.claims.get("role")
|
||||
|
||||
# the token keeps the role it was issued with, so a role removed from
|
||||
# the config since then must send the user back through login
|
||||
if role not in auth_config.roles:
|
||||
logger.debug("jwt role %s is not in the config", role)
|
||||
return fail_response
|
||||
|
||||
current_time = int(time.time())
|
||||
|
||||
# if the jwt is expired
|
||||
|
||||
+14
-4
@@ -1038,6 +1038,10 @@ def export_recording_custom(
|
||||
if camera_validation_error is not None:
|
||||
return camera_validation_error
|
||||
|
||||
# Validate user-provided ffmpeg args to prevent injection and add to cases.
|
||||
# Admin users are trusted and skip validation.
|
||||
is_admin = request.headers.get("remote-role", "") == "admin"
|
||||
|
||||
playback_source = body.source
|
||||
friendly_name = body.name
|
||||
existing_image, image_validation_error = _sanitize_existing_image(body.image_path)
|
||||
@@ -1048,6 +1052,16 @@ def export_recording_custom(
|
||||
cpu_fallback = body.cpu_fallback
|
||||
|
||||
export_case_id = body.export_case_id
|
||||
|
||||
if export_case_id is not None and not is_admin:
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": "Only admins can attach exports to an existing case.",
|
||||
},
|
||||
status_code=403,
|
||||
)
|
||||
|
||||
case_validation_error = _validate_export_case(export_case_id)
|
||||
if case_validation_error is not None:
|
||||
return case_validation_error
|
||||
@@ -1064,10 +1078,6 @@ def export_recording_custom(
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Validate user-provided ffmpeg args to prevent injection.
|
||||
# Admin users are trusted and skip validation.
|
||||
is_admin = request.headers.get("remote-role", "") == "admin"
|
||||
|
||||
if not is_admin:
|
||||
for args_label, args_value in [
|
||||
("input", ffmpeg_input_args),
|
||||
|
||||
+83
-62
@@ -189,7 +189,7 @@ async def camera_ptz_info(request: Request, camera_name: str):
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
request.app.onvif.get_camera_info(camera_name), request.app.onvif.loop
|
||||
)
|
||||
result = future.result()
|
||||
result = await asyncio.wrap_future(future)
|
||||
return JSONResponse(content=result)
|
||||
else:
|
||||
return JSONResponse(
|
||||
@@ -258,15 +258,15 @@ async def latest_frame(
|
||||
|
||||
frame = request.app.camera_error_image
|
||||
|
||||
height = int(params.height or str(frame.shape[0]))
|
||||
width = int(height * frame.shape[1] / frame.shape[0])
|
||||
|
||||
if frame is None:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Unable to get valid frame"},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
height = int(params.height or str(frame.shape[0]))
|
||||
width = int(height * frame.shape[1] / frame.shape[0])
|
||||
|
||||
if height < 1 or width < 1:
|
||||
return JSONResponse(
|
||||
content="Invalid height / width requested :: {} / {}".format(
|
||||
@@ -886,9 +886,8 @@ async def vod_event(
|
||||
# If the recordings are not found and the event started more than 5 minutes ago, set has_clip to false
|
||||
if (
|
||||
event.start_time < datetime.now().timestamp() - 300
|
||||
and type(vod_response) is tuple
|
||||
and len(vod_response) == 2
|
||||
and vod_response[1] == 404
|
||||
and isinstance(vod_response, JSONResponse)
|
||||
and vod_response.status_code == 404
|
||||
):
|
||||
Event.update(has_clip=False).where(Event.id == event_id).execute()
|
||||
|
||||
@@ -956,64 +955,80 @@ async def event_snapshot(
|
||||
event_complete = False
|
||||
jpg_bytes = None
|
||||
frame_time = 0
|
||||
|
||||
try:
|
||||
event = Event.get(Event.id == event_id, Event.end_time != None)
|
||||
event_complete = True
|
||||
await require_camera_access(event.camera, request=request)
|
||||
except DoesNotExist:
|
||||
event = None
|
||||
|
||||
if event is not None:
|
||||
event_complete = True
|
||||
|
||||
if not event.has_snapshot:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Snapshot not available"},
|
||||
status_code=404,
|
||||
)
|
||||
snapshot_settings = _resolve_snapshot_settings(
|
||||
request.app.frigate_config.cameras[event.camera].snapshots, params
|
||||
)
|
||||
jpg_bytes, frame_time = get_event_snapshot_bytes(
|
||||
event,
|
||||
ext="jpg",
|
||||
timestamp=snapshot_settings["timestamp"],
|
||||
bounding_box=snapshot_settings["bounding_box"],
|
||||
crop=snapshot_settings["crop"],
|
||||
height=snapshot_settings["height"],
|
||||
quality=snapshot_settings["quality"],
|
||||
timestamp_style=request.app.frigate_config.cameras[
|
||||
event.camera
|
||||
].timestamp_style,
|
||||
colormap=request.app.frigate_config.model_for_camera(event.camera).colormap,
|
||||
)
|
||||
except DoesNotExist:
|
||||
# see if the object is currently being tracked
|
||||
|
||||
try:
|
||||
camera_states: list[CameraState] = (
|
||||
request.app.detected_frames_processor.get_camera_states()
|
||||
snapshot_settings = _resolve_snapshot_settings(
|
||||
request.app.frigate_config.cameras[event.camera].snapshots, params
|
||||
)
|
||||
jpg_bytes, frame_time = get_event_snapshot_bytes(
|
||||
event,
|
||||
ext="jpg",
|
||||
timestamp=snapshot_settings["timestamp"],
|
||||
bounding_box=snapshot_settings["bounding_box"],
|
||||
crop=snapshot_settings["crop"],
|
||||
height=snapshot_settings["height"],
|
||||
quality=snapshot_settings["quality"],
|
||||
timestamp_style=request.app.frigate_config.cameras[
|
||||
event.camera
|
||||
].timestamp_style,
|
||||
colormap=request.app.frigate_config.model_for_camera(
|
||||
event.camera
|
||||
).colormap,
|
||||
)
|
||||
for camera_state in camera_states:
|
||||
if event_id in camera_state.tracked_objects:
|
||||
tracked_obj = camera_state.tracked_objects.get(event_id)
|
||||
if tracked_obj is not None:
|
||||
snapshot_settings = _resolve_snapshot_settings(
|
||||
camera_state.camera_config.snapshots, params
|
||||
)
|
||||
jpg_bytes, frame_time = tracked_obj.get_img_bytes(
|
||||
ext="jpg",
|
||||
timestamp=snapshot_settings["timestamp"],
|
||||
bounding_box=snapshot_settings["bounding_box"],
|
||||
crop=snapshot_settings["crop"],
|
||||
height=snapshot_settings["height"],
|
||||
quality=snapshot_settings["quality"],
|
||||
)
|
||||
await require_camera_access(camera_state.name, request=request)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Ongoing event not found"},
|
||||
content={"success": False, "message": "Unknown error occurred"},
|
||||
status_code=404,
|
||||
)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Unknown error occurred"},
|
||||
status_code=404,
|
||||
else:
|
||||
# see if the object is currently being tracked
|
||||
camera_states: list[CameraState] = (
|
||||
request.app.detected_frames_processor.get_camera_states()
|
||||
)
|
||||
|
||||
for camera_state in camera_states:
|
||||
tracked_obj = camera_state.tracked_objects.get(event_id)
|
||||
|
||||
if tracked_obj is None:
|
||||
continue
|
||||
|
||||
await require_camera_access(camera_state.name, request=request)
|
||||
|
||||
try:
|
||||
snapshot_settings = _resolve_snapshot_settings(
|
||||
camera_state.camera_config.snapshots, params
|
||||
)
|
||||
jpg_bytes, frame_time = tracked_obj.get_img_bytes(
|
||||
ext="jpg",
|
||||
timestamp=snapshot_settings["timestamp"],
|
||||
bounding_box=snapshot_settings["bounding_box"],
|
||||
crop=snapshot_settings["crop"],
|
||||
height=snapshot_settings["height"],
|
||||
quality=snapshot_settings["quality"],
|
||||
)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Ongoing event not found"},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
break
|
||||
|
||||
if jpg_bytes is None:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Live frame not available"},
|
||||
@@ -1062,19 +1077,25 @@ async def event_thumbnail(
|
||||
|
||||
if not thumbnail_bytes:
|
||||
# see if the object is currently being tracked
|
||||
try:
|
||||
camera_states = request.app.detected_frames_processor.get_camera_states()
|
||||
for camera_state in camera_states:
|
||||
if event_id in camera_state.tracked_objects:
|
||||
tracked_obj = camera_state.tracked_objects.get(event_id)
|
||||
if tracked_obj is not None:
|
||||
await require_camera_access(camera_state.name, request=request)
|
||||
thumbnail_bytes = tracked_obj.get_thumbnail(extension.value)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Event not found"},
|
||||
status_code=404,
|
||||
)
|
||||
camera_states = request.app.detected_frames_processor.get_camera_states()
|
||||
|
||||
for camera_state in camera_states:
|
||||
tracked_obj = camera_state.tracked_objects.get(event_id)
|
||||
|
||||
if tracked_obj is None:
|
||||
continue
|
||||
|
||||
await require_camera_access(camera_state.name, request=request)
|
||||
|
||||
try:
|
||||
thumbnail_bytes = tracked_obj.get_thumbnail(extension.value)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Event not found"},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
break
|
||||
|
||||
if not thumbnail_bytes:
|
||||
return JSONResponse(
|
||||
|
||||
@@ -412,11 +412,8 @@ async def no_recordings(
|
||||
if not camera_list:
|
||||
return JSONResponse(content=[])
|
||||
|
||||
before = params.before or datetime.datetime.now().timestamp()
|
||||
after = (
|
||||
params.after
|
||||
or (datetime.datetime.now() - datetime.timedelta(hours=1)).timestamp()
|
||||
)
|
||||
before = params.before or datetime.now().timestamp()
|
||||
after = params.after or (datetime.now() - timedelta(hours=1)).timestamp()
|
||||
scale = params.scale
|
||||
|
||||
recordings: list[tuple[float, float]] = []
|
||||
|
||||
@@ -66,6 +66,12 @@ def get_cache_image_name(camera: str, frame_time: float) -> str:
|
||||
)
|
||||
|
||||
|
||||
def is_camera_preview_frame(file_name: str, camera: str) -> bool:
|
||||
"""Check whether a cached preview frame file belongs to the camera."""
|
||||
# camera names may contain "-", so a prefix match would include "front-door"
|
||||
return file_name.rsplit("-", 1)[0] == f"preview_{camera}"
|
||||
|
||||
|
||||
def get_most_recent_preview_frame(
|
||||
camera: str, before: float | None = None
|
||||
) -> str | None:
|
||||
@@ -79,7 +85,7 @@ def get_most_recent_preview_frame(
|
||||
preview_files = [
|
||||
f
|
||||
for f in os.listdir(PREVIEW_CACHE_DIR)
|
||||
if f.startswith(f"preview_{camera}-")
|
||||
if is_camera_preview_frame(f, camera)
|
||||
and f.endswith(f".{PREVIEW_FRAME_TYPE}")
|
||||
]
|
||||
|
||||
@@ -276,7 +282,7 @@ class PreviewRecorder:
|
||||
start_file = f"{file_start}{start_ts}.webp"
|
||||
|
||||
for file in sorted(os.listdir(os.path.join(CACHE_DIR, FOLDER_PREVIEW_FRAMES))):
|
||||
if not file.startswith(file_start):
|
||||
if not is_camera_preview_frame(file, self.camera_name):
|
||||
continue
|
||||
|
||||
if file < start_file:
|
||||
|
||||
@@ -36,6 +36,7 @@ from frigate.ffmpeg_presets import (
|
||||
parse_preset_hardware_acceleration_encode,
|
||||
)
|
||||
from frigate.models import Export, Previews, Recordings, ReviewSegment
|
||||
from frigate.output.preview import is_camera_preview_frame
|
||||
from frigate.util.ffmpeg import run_ffmpeg_with_progress
|
||||
from frigate.util.ownership import chown_to_runtime
|
||||
from frigate.util.recording_coverage import (
|
||||
@@ -1098,7 +1099,7 @@ class RecordingExporter(threading.Thread):
|
||||
fallback_preview = None
|
||||
|
||||
for file in sorted(os.listdir(preview_dir)):
|
||||
if not file.startswith(file_start):
|
||||
if not is_camera_preview_frame(file, self.camera):
|
||||
continue
|
||||
|
||||
if file < start_file:
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Tests that /auth only accepts a JWT whose role is still in the config."""
|
||||
|
||||
import os
|
||||
import time
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
from frigate.api.auth import create_encoded_jwt
|
||||
from frigate.api.fastapi_app import create_fastapi_app
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.config.camera.updater import CameraConfigUpdatePublisher
|
||||
from frigate.const import JWT_SECRET_ENV_VAR
|
||||
from frigate.models import Event, Recordings, ReviewSegment
|
||||
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
|
||||
|
||||
|
||||
@patch.dict(os.environ, {JWT_SECRET_ENV_VAR: "test-secret"})
|
||||
class TestAuthJwtRole(BaseTestHttp):
|
||||
def setUp(self):
|
||||
super().setUp(models=[Event, Recordings, ReviewSegment])
|
||||
self.minimal_config = {
|
||||
"mqtt": {"host": "mqtt"},
|
||||
"auth": {"enabled": True, "roles": {"garage": ["front_door"]}},
|
||||
"networking": {"listen": {"internal": 5000, "external": 8971}},
|
||||
"cameras": {
|
||||
"front_door": {
|
||||
"ffmpeg": {
|
||||
"inputs": [
|
||||
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
|
||||
]
|
||||
},
|
||||
"detect": {
|
||||
"height": 1080,
|
||||
"width": 1920,
|
||||
"fps": 5,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
def _create_app(self):
|
||||
mock_publisher = Mock(spec=CameraConfigUpdatePublisher)
|
||||
mock_publisher.publisher = MagicMock()
|
||||
|
||||
return create_fastapi_app(
|
||||
FrigateConfig(**self.minimal_config),
|
||||
self.db,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
mock_publisher,
|
||||
None,
|
||||
enforce_default_admin=False,
|
||||
)
|
||||
|
||||
def _auth(self, app, role: str):
|
||||
token = create_encoded_jwt("bob", role, int(time.time()) + 3600, app.jwt_token)
|
||||
|
||||
with AuthTestClient(app) as client:
|
||||
return client.get(
|
||||
"/auth",
|
||||
headers={
|
||||
"x-server-port": "8971",
|
||||
"authorization": f"Bearer {token}",
|
||||
},
|
||||
)
|
||||
|
||||
def test_configured_role_is_accepted(self):
|
||||
resp = self._auth(self._create_app(), "garage")
|
||||
|
||||
self.assertEqual(resp.status_code, 202)
|
||||
self.assertEqual(resp.headers["remote-user"], "bob")
|
||||
self.assertEqual(resp.headers["remote-role"], "garage")
|
||||
|
||||
def test_role_removed_from_config_is_rejected(self):
|
||||
resp = self._auth(self._create_app(), "removed_role")
|
||||
|
||||
self.assertEqual(resp.status_code, 401)
|
||||
self.assertNotIn("remote-role", resp.headers)
|
||||
@@ -0,0 +1,193 @@
|
||||
"""Tests for renaming and deleting a zone through config_set's JSON body."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import ruamel.yaml
|
||||
from fastapi import Request
|
||||
|
||||
from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user
|
||||
from frigate.api.fastapi_app import create_fastapi_app
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.config.camera.updater import CameraConfigUpdatePublisher
|
||||
from frigate.models import Event, Recordings, ReviewSegment
|
||||
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
|
||||
|
||||
|
||||
class TestConfigSetZones(BaseTestHttp):
|
||||
def setUp(self):
|
||||
super().setUp(models=[Event, Recordings, ReviewSegment])
|
||||
self.minimal_config = {
|
||||
"mqtt": {"host": "mqtt"},
|
||||
"profiles": {"armed": {"friendly_name": "Armed"}},
|
||||
"snapshots": {"required_zones": ["driveway"]},
|
||||
"cameras": {
|
||||
"front": {
|
||||
"ffmpeg": {
|
||||
"inputs": [
|
||||
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
|
||||
]
|
||||
},
|
||||
"detect": {"height": 1080, "width": 1920, "fps": 5},
|
||||
"zones": {
|
||||
"driveway": {
|
||||
"coordinates": "0,0,1,0,1,1",
|
||||
"inertia": 5,
|
||||
"filters": {"person": {"min_area": 5000}},
|
||||
},
|
||||
"porch": {"coordinates": "0,0,0.5,0,0.5,0.5"},
|
||||
},
|
||||
"review": {
|
||||
"alerts": {
|
||||
"labels": ["person"],
|
||||
"required_zones": ["driveway"],
|
||||
},
|
||||
},
|
||||
"mqtt": {"required_zones": ["driveway", "porch"]},
|
||||
"profiles": {
|
||||
"armed": {
|
||||
"zones": {
|
||||
"driveway": {
|
||||
"coordinates": "0,0,1,0,1,1",
|
||||
"objects": ["car"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
yaml = ruamel.yaml.YAML()
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".yml", delete=False
|
||||
) as config_file:
|
||||
yaml.dump(self.minimal_config, config_file)
|
||||
self.config_path = config_file.name
|
||||
self.addCleanup(os.unlink, self.config_path)
|
||||
|
||||
def _create_app(self):
|
||||
publisher = Mock(spec=CameraConfigUpdatePublisher)
|
||||
publisher.publisher = MagicMock()
|
||||
app = create_fastapi_app(
|
||||
FrigateConfig(**self.minimal_config),
|
||||
self.db,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
publisher,
|
||||
None,
|
||||
enforce_default_admin=False,
|
||||
)
|
||||
|
||||
async def mock_get_current_user(request: Request):
|
||||
return {
|
||||
"username": request.headers.get("remote-user"),
|
||||
"role": request.headers.get("remote-role"),
|
||||
}
|
||||
|
||||
async def mock_get_allowed_cameras_for_filter(request: Request):
|
||||
return ["front"]
|
||||
|
||||
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 _put(self, camera_data: dict):
|
||||
with patch("frigate.api.app.find_config_file", return_value=self.config_path):
|
||||
with AuthTestClient(self._create_app()) as client:
|
||||
return client.put(
|
||||
"/config/set",
|
||||
json={
|
||||
"config_data": {"cameras": {"front": camera_data}},
|
||||
"requires_restart": 0,
|
||||
"update_topic": "config/cameras/front/zones",
|
||||
},
|
||||
)
|
||||
|
||||
def _front(self) -> dict:
|
||||
with open(self.config_path) as f:
|
||||
return ruamel.yaml.YAML().load(f)["cameras"]["front"]
|
||||
|
||||
def test_rename_moves_zone_references_in_one_request(self):
|
||||
"""The new base zone exists when validation checks the moved override."""
|
||||
resp = self._put(
|
||||
{
|
||||
"zones": {
|
||||
"driveway": None,
|
||||
"front_drive": {
|
||||
"coordinates": "0,0,1,0,1,1",
|
||||
"enabled": True,
|
||||
# as /api/config returns them, with defaults filled in
|
||||
"filters": {
|
||||
"person": {
|
||||
"min_area": 5000,
|
||||
"max_area": 24000000,
|
||||
"min_ratio": 0.0,
|
||||
"max_ratio": 24000000.0,
|
||||
"threshold": 0.7,
|
||||
"min_score": 0.5,
|
||||
"mask": {},
|
||||
}
|
||||
},
|
||||
"inertia": 5,
|
||||
},
|
||||
},
|
||||
"review": {"alerts": {"required_zones": ["front_drive"]}},
|
||||
# inherited from the global snapshots config, so no camera key
|
||||
"snapshots": {"required_zones": ["front_drive"]},
|
||||
"mqtt": {"required_zones": ["front_drive", "porch"]},
|
||||
"profiles": {
|
||||
"armed": {
|
||||
"zones": {
|
||||
"driveway": None,
|
||||
"front_drive": {
|
||||
"coordinates": "0,0,1,0,1,1",
|
||||
"objects": ["car"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 200, resp.json())
|
||||
front = self._front()
|
||||
self.assertEqual(list(front["zones"]), ["porch", "front_drive"])
|
||||
self.assertEqual(front["zones"]["front_drive"]["inertia"], 5)
|
||||
self.assertEqual(
|
||||
front["zones"]["front_drive"]["filters"]["person"]["min_area"], 5000
|
||||
)
|
||||
self.assertEqual(front["review"]["alerts"]["labels"], ["person"])
|
||||
self.assertEqual(front["review"]["alerts"]["required_zones"], ["front_drive"])
|
||||
self.assertEqual(front["snapshots"]["required_zones"], ["front_drive"])
|
||||
self.assertEqual(front["mqtt"]["required_zones"], ["front_drive", "porch"])
|
||||
self.assertEqual(
|
||||
dict(front["profiles"]["armed"]["zones"]),
|
||||
{"front_drive": {"coordinates": "0,0,1,0,1,1", "objects": ["car"]}},
|
||||
)
|
||||
|
||||
def test_delete_empties_lists_without_dropping_their_section(self):
|
||||
resp = self._put(
|
||||
{
|
||||
"zones": {"driveway": None},
|
||||
"review": {"alerts": {"required_zones": []}},
|
||||
"snapshots": {"required_zones": []},
|
||||
"mqtt": {"required_zones": ["porch"]},
|
||||
"profiles": {"armed": {"zones": {"driveway": None}}},
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 200, resp.json())
|
||||
front = self._front()
|
||||
self.assertEqual(list(front["zones"]), ["porch"])
|
||||
self.assertEqual(front["review"]["alerts"]["labels"], ["person"])
|
||||
self.assertEqual(front["review"]["alerts"]["required_zones"], [])
|
||||
self.assertEqual(front["mqtt"]["required_zones"], ["porch"])
|
||||
self.assertNotIn("driveway", front["profiles"]["armed"]["zones"] or {})
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Tests for get_dst_transitions."""
|
||||
|
||||
import datetime
|
||||
import unittest
|
||||
|
||||
from frigate.util.time import get_dst_transitions
|
||||
|
||||
|
||||
class TestDstTransitions(unittest.TestCase):
|
||||
def test_dst_transition_splits_periods_at_the_transition(self):
|
||||
start = datetime.datetime(2026, 3, 7, 12, tzinfo=datetime.UTC).timestamp()
|
||||
end = start + 2 * 86400
|
||||
spring = datetime.datetime(2026, 3, 8, 7, tzinfo=datetime.UTC).timestamp()
|
||||
self.assertEqual(
|
||||
get_dst_transitions("America/New_York", start, end),
|
||||
[(start, spring, -18000), (spring, end, -14400)],
|
||||
)
|
||||
|
||||
def test_dst_transition_is_not_reported_a_day_late(self):
|
||||
# local midnight on the day of the change used to report the
|
||||
# transition a full day after it actually happened
|
||||
start = datetime.datetime(2024, 3, 10, 5, tzinfo=datetime.UTC).timestamp()
|
||||
end = start + 3 * 86400
|
||||
spring = datetime.datetime(2024, 3, 10, 7, tzinfo=datetime.UTC).timestamp()
|
||||
self.assertEqual(
|
||||
get_dst_transitions("America/New_York", start, end),
|
||||
[(start, spring, -18000), (spring, end, -14400)],
|
||||
)
|
||||
|
||||
def test_dst_transition_after_the_last_daily_probe_is_found(self):
|
||||
start = datetime.datetime(2024, 11, 2, 12, tzinfo=datetime.UTC).timestamp()
|
||||
end = datetime.datetime(2024, 11, 3, 10, tzinfo=datetime.UTC).timestamp()
|
||||
fall = datetime.datetime(2024, 11, 3, 6, tzinfo=datetime.UTC).timestamp()
|
||||
self.assertEqual(
|
||||
get_dst_transitions("America/New_York", start, end),
|
||||
[(start, fall, -14400), (fall, end, -18000)],
|
||||
)
|
||||
|
||||
def test_no_transition_returns_a_single_period(self):
|
||||
start = datetime.datetime(2026, 6, 1, tzinfo=datetime.UTC).timestamp()
|
||||
end = start + 5 * 86400
|
||||
self.assertEqual(
|
||||
get_dst_transitions("America/New_York", start, end),
|
||||
[(start, end, -14400)],
|
||||
)
|
||||
|
||||
def test_invalid_zone_retains_utc_fallback(self):
|
||||
self.assertEqual(
|
||||
get_dst_transitions("Invalid/Timezone", 100, 200), [(100, 200, 0)]
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
@@ -75,6 +75,21 @@ class TestPreviewLoader(unittest.TestCase):
|
||||
|
||||
self.assertIsNone(get_most_recent_preview_frame(camera))
|
||||
|
||||
def test_get_most_recent_preview_frame_hyphenated_camera(self):
|
||||
for name in ("preview_front-2000.0", "preview_front-door-3000.0"):
|
||||
with open(
|
||||
os.path.join(PREVIEW_CACHE_DIR, f"{name}.{PREVIEW_FRAME_TYPE}"), "w"
|
||||
) as f:
|
||||
f.write("test")
|
||||
|
||||
expected_path = os.path.join(
|
||||
PREVIEW_CACHE_DIR, f"preview_front-2000.0.{PREVIEW_FRAME_TYPE}"
|
||||
)
|
||||
self.assertEqual(get_most_recent_preview_frame("front"), expected_path)
|
||||
self.assertEqual(
|
||||
get_most_recent_preview_frame("front", before=5000.0), expected_path
|
||||
)
|
||||
|
||||
def test_get_most_recent_preview_frame_no_directory(self):
|
||||
shutil.rmtree(PREVIEW_CACHE_DIR)
|
||||
self.assertIsNone(get_most_recent_preview_frame("test_camera"))
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Tests for restarting frigate under s6."""
|
||||
|
||||
import signal
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import psutil
|
||||
|
||||
from frigate.util.services import restart_frigate
|
||||
|
||||
|
||||
class TestRestartFrigate(unittest.TestCase):
|
||||
def _s6_process(self) -> MagicMock:
|
||||
proc = MagicMock()
|
||||
proc.name.return_value = "s6-svscan"
|
||||
return proc
|
||||
|
||||
@patch("frigate.util.services.os.kill")
|
||||
@patch("frigate.util.services.psutil.Process")
|
||||
def test_terminates_s6_when_permitted(self, mock_process, mock_kill):
|
||||
proc = self._s6_process()
|
||||
mock_process.return_value = proc
|
||||
|
||||
restart_frigate()
|
||||
|
||||
proc.terminate.assert_called_once()
|
||||
mock_kill.assert_not_called()
|
||||
|
||||
@patch("frigate.util.services.os.getpid", return_value=99)
|
||||
@patch("frigate.util.services.os.kill")
|
||||
@patch("frigate.util.services.psutil.Process")
|
||||
def test_exits_self_when_s6_signal_is_denied(
|
||||
self, mock_process, mock_kill, _mock_getpid
|
||||
):
|
||||
"""Running unprivileged, frigate cannot signal root's s6-svscan."""
|
||||
proc = self._s6_process()
|
||||
proc.terminate.side_effect = psutil.AccessDenied(pid=1, name="s6-svscan")
|
||||
mock_process.return_value = proc
|
||||
|
||||
restart_frigate()
|
||||
|
||||
mock_kill.assert_called_once_with(99, signal.SIGINT)
|
||||
|
||||
@patch("frigate.util.services.os.getpid", return_value=99)
|
||||
@patch("frigate.util.services.os.kill")
|
||||
@patch("frigate.util.services.psutil.Process")
|
||||
def test_exits_self_without_s6(self, mock_process, mock_kill, _mock_getpid):
|
||||
proc = MagicMock()
|
||||
proc.name.return_value = "init"
|
||||
mock_process.return_value = proc
|
||||
|
||||
restart_frigate()
|
||||
|
||||
proc.terminate.assert_not_called()
|
||||
mock_kill.assert_called_once_with(99, signal.SIGINT)
|
||||
@@ -35,12 +35,19 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
def restart_frigate():
|
||||
proc = psutil.Process(1)
|
||||
|
||||
# if this is running via s6, sigterm pid 1
|
||||
if proc.name() == "s6-svscan":
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.terminate()
|
||||
return
|
||||
except psutil.AccessDenied:
|
||||
# frigate runs unprivileged, so it cannot signal root's s6-svscan.
|
||||
# exiting this process instead runs frigate/finish, which halts s6
|
||||
logger.debug("Not permitted to signal s6-svscan, exiting instead")
|
||||
|
||||
# otherwise, just try and exit frigate
|
||||
else:
|
||||
os.kill(os.getpid(), signal.SIGINT)
|
||||
os.kill(os.getpid(), signal.SIGINT)
|
||||
|
||||
|
||||
def print_stack(sig, frame):
|
||||
|
||||
+40
-19
@@ -2,6 +2,7 @@
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
import math
|
||||
from zoneinfo import ZoneInfoNotFoundError
|
||||
|
||||
import pytz
|
||||
@@ -43,9 +44,33 @@ def is_current_hour(timestamp: int) -> bool:
|
||||
return timestamp < start_of_next_hour
|
||||
|
||||
|
||||
def _utc_offset(tz: datetime.tzinfo, timestamp: float) -> float:
|
||||
dt = datetime.datetime.fromtimestamp(timestamp, tz=datetime.UTC)
|
||||
return dt.astimezone(tz).utcoffset().total_seconds()
|
||||
|
||||
|
||||
def _find_transition(
|
||||
tz: datetime.tzinfo, lo: float, hi: float, lo_offset: float
|
||||
) -> float:
|
||||
"""Bisect (lo, hi] to the second where the UTC offset first differs from lo_offset."""
|
||||
# whole seconds, so the midpoint always advances (a fractional bound can
|
||||
# otherwise leave the midpoint sitting on lo) and lands on the transition
|
||||
low = math.floor(lo)
|
||||
high = math.ceil(hi)
|
||||
|
||||
while high - low > 1:
|
||||
mid = (low + high) // 2
|
||||
if _utc_offset(tz, mid) == lo_offset:
|
||||
low = mid
|
||||
else:
|
||||
high = mid
|
||||
|
||||
return float(high)
|
||||
|
||||
|
||||
def get_dst_transitions(
|
||||
tz_name: str, start_time: float, end_time: float
|
||||
) -> list[tuple[float, float]]:
|
||||
) -> list[tuple[float, float, float]]:
|
||||
"""
|
||||
Find DST transition points and return time periods with consistent offsets.
|
||||
|
||||
@@ -66,28 +91,24 @@ def get_dst_transitions(
|
||||
|
||||
periods = []
|
||||
current = start_time
|
||||
|
||||
# Get initial offset
|
||||
dt = datetime.datetime.utcfromtimestamp(current).replace(tzinfo=pytz.UTC)
|
||||
local_dt = dt.astimezone(tz)
|
||||
prev_offset = local_dt.utcoffset().total_seconds()
|
||||
period_start = start_time
|
||||
prev_offset = _utc_offset(tz, current)
|
||||
|
||||
# Check each day for offset changes
|
||||
while current <= end_time:
|
||||
dt = datetime.datetime.utcfromtimestamp(current).replace(tzinfo=pytz.UTC)
|
||||
local_dt = dt.astimezone(tz)
|
||||
current_offset = local_dt.utcoffset().total_seconds()
|
||||
# Probe at most a day ahead, capped at end_time so a transition after the
|
||||
# last full day is still seen instead of silently kept in the last period.
|
||||
while current < end_time:
|
||||
next_probe = min(current + 86400, end_time)
|
||||
next_offset = _utc_offset(tz, next_probe)
|
||||
|
||||
if current_offset != prev_offset:
|
||||
# Found a transition - close previous period
|
||||
periods.append((period_start, current, prev_offset))
|
||||
period_start = current
|
||||
prev_offset = current_offset
|
||||
if next_offset != prev_offset:
|
||||
transition = _find_transition(tz, current, next_probe, prev_offset)
|
||||
periods.append((period_start, transition, prev_offset))
|
||||
period_start = transition
|
||||
prev_offset = _utc_offset(tz, transition)
|
||||
current = transition
|
||||
else:
|
||||
current = next_probe
|
||||
|
||||
current += 86400 # Check daily
|
||||
|
||||
# Add final period
|
||||
periods.append((period_start, end_time, prev_offset))
|
||||
|
||||
return periods
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
/**
|
||||
* WebRTC streaming-technology availability gating.
|
||||
*
|
||||
* The connectivity probe needs a live go2rtc, so this covers the
|
||||
* statically-determinable gate: with no webrtc candidates/ice_servers
|
||||
* configured, the WebRTC option must be disabled in the stream-technology
|
||||
* selector (label "Streaming Technology").
|
||||
*/
|
||||
import type { Page } from "@playwright/test";
|
||||
import { test, expect } from "../fixtures/frigate-test";
|
||||
import { LivePage } from "../pages/live.page";
|
||||
|
||||
// the mocked profile is admin, so useUserPersistence keys are namespaced
|
||||
const STREAMING_KEY = "streaming-settings:admin";
|
||||
|
||||
async function writeIdb(page: Page, entries: Record<string, unknown>) {
|
||||
await page.evaluate(async (data) => {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const request = indexedDB.open("keyval-store", 1);
|
||||
request.onupgradeneeded = () =>
|
||||
request.result.createObjectStore("keyval");
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => {
|
||||
const tx = request.result.transaction("keyval", "readwrite");
|
||||
const store = tx.objectStore("keyval");
|
||||
Object.entries(data).forEach(([key, value]) => store.put(value, key));
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
};
|
||||
});
|
||||
}, entries);
|
||||
}
|
||||
|
||||
async function readIdb(page: Page, key: string) {
|
||||
return page.evaluate(async (target) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open("keyval-store", 1);
|
||||
request.onupgradeneeded = () =>
|
||||
request.result.createObjectStore("keyval");
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => {
|
||||
const tx = request.result.transaction("keyval", "readonly");
|
||||
const get = tx.objectStore("keyval").get(target);
|
||||
get.onsuccess = () => resolve(get.result ?? null);
|
||||
get.onerror = () => reject(get.error);
|
||||
};
|
||||
});
|
||||
}, key);
|
||||
}
|
||||
|
||||
test.describe("WebRTC availability gating @critical", () => {
|
||||
test("desktop: WebRTC option is disabled when no candidates or ice_servers", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
test.skip(frigateApp.isMobile, "Desktop dropdown only");
|
||||
|
||||
await frigateApp.installDefaults({
|
||||
config: {
|
||||
go2rtc: {
|
||||
streams: { front_door: ["rtsp://127.0.0.1:8554/front_door"] },
|
||||
webrtc: { candidates: [], ice_servers: [] },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// The single-camera view fetches go2rtc stream metadata once restreamed.
|
||||
// The default mock returns {} which lacks `producers` and crashes the
|
||||
// capability parser, so return a minimal valid metadata payload.
|
||||
await frigateApp.page.route("**/api/go2rtc/streams/front_door**", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
producers: [{ medias: ["video, recvonly, H264"] }],
|
||||
consumers: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await frigateApp.goto("/#front_door");
|
||||
|
||||
const live = new LivePage(frigateApp.page, true);
|
||||
await expect(live.backButton).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Open the desktop camera-settings dropdown (the FaCog gear is the last
|
||||
// button-like trigger in the single-camera header).
|
||||
const gearButtons = frigateApp.page.locator("button:has(svg)");
|
||||
await gearButtons.last().click();
|
||||
|
||||
const menu = frigateApp.page
|
||||
.locator('[role="menu"], [data-radix-menu-content]')
|
||||
.first();
|
||||
await expect(menu).toBeVisible({ timeout: 3_000 });
|
||||
|
||||
// Open the "Streaming Technology" select. Anchor on its label, then click
|
||||
// the combobox trigger that follows it within the same field container.
|
||||
const technologyTrigger = menu
|
||||
.locator('div:has(> label[for="streaming-mode"]) [role="combobox"]')
|
||||
.first();
|
||||
await expect(technologyTrigger).toBeVisible({ timeout: 3_000 });
|
||||
await technologyTrigger.click();
|
||||
|
||||
// The Radix select content is portaled to the document body; the WebRTC
|
||||
// option must be present and disabled (aria-disabled="true").
|
||||
const webrtcOption = frigateApp.page.getByRole("option", {
|
||||
name: /WebRTC/,
|
||||
});
|
||||
await expect(webrtcOption).toBeVisible({ timeout: 3_000 });
|
||||
await expect(webrtcOption).toHaveAttribute("aria-disabled", "true");
|
||||
|
||||
// Sanity check: a non-gated option (MSE) is enabled, proving the locator
|
||||
// distinguishes enabled from disabled options.
|
||||
const mseOption = frigateApp.page.getByRole("option", { name: /MSE/ });
|
||||
await expect(mseOption).not.toHaveAttribute("aria-disabled", "true");
|
||||
});
|
||||
|
||||
test("the unavailable reason is logged to the console once", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
// Availability is consumed by several components at once, so the emitter
|
||||
// dedupes; the count asserts that dedupe, not just the message.
|
||||
const warnings: string[] = [];
|
||||
frigateApp.page.on("console", (msg) => {
|
||||
if (msg.type() === "warning" && msg.text().includes("WebRTC unavailable"))
|
||||
warnings.push(msg.text());
|
||||
});
|
||||
|
||||
await frigateApp.installDefaults({
|
||||
config: {
|
||||
go2rtc: {
|
||||
streams: { front_door: ["rtsp://127.0.0.1:8554/front_door"] },
|
||||
webrtc: { candidates: [], ice_servers: [] },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await frigateApp.page.route("**/api/go2rtc/streams/front_door**", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
producers: [{ medias: ["video, recvonly, H264"] }],
|
||||
consumers: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await frigateApp.goto("/#front_door");
|
||||
|
||||
await expect
|
||||
.poll(() => warnings.length, { timeout: 10_000 })
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
expect(warnings).toHaveLength(1);
|
||||
expect(warnings[0]).toContain("WebRTC unavailable 'not-configured'");
|
||||
expect(warnings[0]).toContain("go2rtc.webrtc");
|
||||
expect(warnings[0]).toContain("docs.frigate.video");
|
||||
});
|
||||
|
||||
test("desktop: the WebRTC option reports the pending connectivity check", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
test.skip(frigateApp.isMobile, "Desktop dropdown only");
|
||||
|
||||
// Hold the signaling socket open so the probe stays pending. Left alone it
|
||||
// fails fast against the preview server and resolves to unreachable, which
|
||||
// is the state the first test already covers.
|
||||
await frigateApp.page.routeWebSocket("**/live/webrtc/api/ws**", () => {
|
||||
// never answer the offer
|
||||
});
|
||||
|
||||
// Candidates configured, so the gate reaches the probe rather than
|
||||
// stopping at not-configured.
|
||||
await frigateApp.installDefaults({
|
||||
config: {
|
||||
go2rtc: {
|
||||
streams: { front_door: ["rtsp://127.0.0.1:8554/front_door"] },
|
||||
webrtc: { candidates: ["192.168.1.10:8555"], ice_servers: [] },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await frigateApp.page.route("**/api/go2rtc/streams/front_door**", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
producers: [{ medias: ["video, recvonly, H264"] }],
|
||||
consumers: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await frigateApp.goto("/#front_door");
|
||||
|
||||
const live = new LivePage(frigateApp.page, true);
|
||||
await expect(live.backButton).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
const gearButtons = frigateApp.page.locator("button:has(svg)");
|
||||
await gearButtons.last().click();
|
||||
|
||||
const menu = frigateApp.page
|
||||
.locator('[role="menu"], [data-radix-menu-content]')
|
||||
.first();
|
||||
await expect(menu).toBeVisible({ timeout: 3_000 });
|
||||
|
||||
const technologyTrigger = menu
|
||||
.locator('div:has(> label[for="streaming-mode"]) [role="combobox"]')
|
||||
.first();
|
||||
await expect(technologyTrigger).toBeVisible({ timeout: 3_000 });
|
||||
await technologyTrigger.click();
|
||||
|
||||
// Unselectable while the probe runs, but with the reason stated rather
|
||||
// than a bare greyed-out row.
|
||||
const webrtcOption = frigateApp.page.getByRole("option", {
|
||||
name: /WebRTC/,
|
||||
});
|
||||
await expect(webrtcOption).toBeVisible({ timeout: 3_000 });
|
||||
await expect(webrtcOption).toHaveAttribute("aria-disabled", "true");
|
||||
await expect(webrtcOption).toContainText(/Checking WebRTC availability/i);
|
||||
});
|
||||
|
||||
test("desktop: JSMpeg is no longer offered in the technology selector", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
test.skip(frigateApp.isMobile, "Desktop dropdown only");
|
||||
|
||||
await frigateApp.installDefaults({
|
||||
config: {
|
||||
go2rtc: {
|
||||
streams: { front_door: ["rtsp://127.0.0.1:8554/front_door"] },
|
||||
webrtc: { candidates: [], ice_servers: [] },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await frigateApp.page.route("**/api/go2rtc/streams/front_door**", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
producers: [{ medias: ["video, recvonly, H264"] }],
|
||||
consumers: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await frigateApp.goto("/#front_door");
|
||||
|
||||
const live = new LivePage(frigateApp.page, true);
|
||||
await expect(live.backButton).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
const gearButtons = frigateApp.page.locator("button:has(svg)");
|
||||
await gearButtons.last().click();
|
||||
|
||||
const menu = frigateApp.page
|
||||
.locator('[role="menu"], [data-radix-menu-content]')
|
||||
.first();
|
||||
await expect(menu).toBeVisible({ timeout: 3_000 });
|
||||
|
||||
// Technology selector offers MSE/WebRTC but NOT JSMpeg (replaced by the
|
||||
// "Force low-bandwidth mode" switch).
|
||||
const technologyTrigger = menu
|
||||
.locator('div:has(> label[for="streaming-mode"]) [role="combobox"]')
|
||||
.first();
|
||||
await expect(technologyTrigger).toBeVisible({ timeout: 3_000 });
|
||||
await technologyTrigger.click();
|
||||
await expect(
|
||||
frigateApp.page.getByRole("option", { name: /MSE/ }),
|
||||
).toBeVisible({ timeout: 3_000 });
|
||||
await expect(
|
||||
frigateApp.page.getByRole("option", { name: /JSMpeg/ }),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("desktop: force low-bandwidth switch disables the technology and stream selectors", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
test.skip(frigateApp.isMobile, "Desktop dropdown only");
|
||||
|
||||
await frigateApp.installDefaults({
|
||||
config: {
|
||||
go2rtc: {
|
||||
streams: { front_door: ["rtsp://127.0.0.1:8554/front_door"] },
|
||||
webrtc: { candidates: [], ice_servers: [] },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await frigateApp.page.route("**/api/go2rtc/streams/front_door**", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
producers: [{ medias: ["video, recvonly, H264"] }],
|
||||
consumers: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await frigateApp.goto("/#front_door");
|
||||
|
||||
const live = new LivePage(frigateApp.page, true);
|
||||
await expect(live.backButton).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
const gearButtons = frigateApp.page.locator("button:has(svg)");
|
||||
await gearButtons.last().click();
|
||||
|
||||
const menu = frigateApp.page
|
||||
.locator('[role="menu"], [data-radix-menu-content]')
|
||||
.first();
|
||||
await expect(menu).toBeVisible({ timeout: 3_000 });
|
||||
|
||||
// Both selectors are present and enabled to begin with (the switch is off).
|
||||
await expect(menu.locator('label[for="streaming-mode"]')).toHaveCount(1);
|
||||
const technologyTrigger = menu
|
||||
.locator('div:has(> label[for="streaming-mode"]) [role="combobox"]')
|
||||
.first();
|
||||
const streamTrigger = menu
|
||||
.locator('div:has(> label[for="streaming-method"]) [role="combobox"]')
|
||||
.first();
|
||||
await expect(technologyTrigger).toBeVisible({ timeout: 3_000 });
|
||||
await expect(technologyTrigger).toBeEnabled();
|
||||
await expect(streamTrigger).toBeVisible({ timeout: 3_000 });
|
||||
await expect(streamTrigger).toBeEnabled();
|
||||
|
||||
// Enabling the switch keeps both selectors visible but disables them (the
|
||||
// low-bandwidth feed ignores the chosen stream and technology).
|
||||
const lowBandwidthSwitch = menu.getByRole("switch", {
|
||||
name: "Force low-bandwidth mode",
|
||||
});
|
||||
await expect(lowBandwidthSwitch).toBeVisible({ timeout: 3_000 });
|
||||
await lowBandwidthSwitch.click();
|
||||
|
||||
await expect(menu.locator('label[for="streaming-mode"]')).toHaveCount(1);
|
||||
await expect(technologyTrigger).toBeDisabled();
|
||||
await expect(streamTrigger).toBeDisabled();
|
||||
|
||||
// Toggling it back off re-enables both.
|
||||
await lowBandwidthSwitch.click();
|
||||
await expect(technologyTrigger).toBeEnabled();
|
||||
await expect(streamTrigger).toBeEnabled();
|
||||
});
|
||||
|
||||
test("desktop: saving group streaming settings keeps an unavailable WebRTC choice", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
test.skip(frigateApp.isMobile, "Desktop context menu only");
|
||||
|
||||
await frigateApp.installDefaults({
|
||||
config: {
|
||||
go2rtc: {
|
||||
streams: { front_door: ["rtsp://127.0.0.1:8554/front_door"] },
|
||||
webrtc: { candidates: [], ice_servers: [] },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await frigateApp.page.route("**/api/go2rtc/streams/front_door**", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
producers: [{ medias: ["video, recvonly, H264"] }],
|
||||
consumers: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const saved = {
|
||||
streamName: "front_door",
|
||||
streamType: "smart",
|
||||
playerMode: "webrtc",
|
||||
compatibilityMode: false,
|
||||
playAudio: false,
|
||||
volume: 1,
|
||||
};
|
||||
|
||||
await frigateApp.goto("/");
|
||||
await writeIdb(frigateApp.page, {
|
||||
[STREAMING_KEY]: { outdoor: { front_door: saved } },
|
||||
});
|
||||
await frigateApp.goto("/?group=outdoor");
|
||||
|
||||
// With no candidates the dialog resolves WebRTC to MSE for display, but
|
||||
// saving must not persist that fallback over the user's choice.
|
||||
const live = new LivePage(frigateApp.page, true);
|
||||
const menu = await live.openContextMenuOn("front_door");
|
||||
await expect(menu).toBeVisible({ timeout: 5_000 });
|
||||
await menu.getByText("Streaming Settings").click();
|
||||
|
||||
const dialog = frigateApp.page.getByRole("dialog");
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByRole("button", { name: "Save" }).click();
|
||||
await expect(dialog).toBeHidden();
|
||||
|
||||
await expect
|
||||
.poll(() => readIdb(frigateApp.page, STREAMING_KEY))
|
||||
.toMatchObject({ outdoor: { front_door: { playerMode: "webrtc" } } });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("WebRTC availability gating @critical @mobile", () => {
|
||||
test("mobile: WebRTC option is disabled when no candidates or ice_servers", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
test.skip(!frigateApp.isMobile, "Mobile drawer only");
|
||||
|
||||
await frigateApp.installDefaults({
|
||||
config: {
|
||||
go2rtc: {
|
||||
streams: { front_door: ["rtsp://127.0.0.1:8554/front_door"] },
|
||||
webrtc: { candidates: [], ice_servers: [] },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await frigateApp.page.route("**/api/go2rtc/streams/front_door**", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
producers: [{ medias: ["video, recvonly, H264"] }],
|
||||
consumers: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await frigateApp.goto("/#front_door");
|
||||
|
||||
// Open the mobile camera-settings drawer. The camera controls render a
|
||||
// second dialog-popup trigger (the FaCog settings toggle) after the global
|
||||
// header menu; wait for both to exist, then click the last one.
|
||||
const dialogTriggers = frigateApp.page.locator(
|
||||
'button[aria-haspopup="dialog"]',
|
||||
);
|
||||
await expect(dialogTriggers).toHaveCount(2, { timeout: 10_000 });
|
||||
const settingsTrigger = dialogTriggers.last();
|
||||
await settingsTrigger.scrollIntoViewIfNeeded();
|
||||
await settingsTrigger.click();
|
||||
|
||||
// The drawer renders a "Streaming Technology" label above its select.
|
||||
// Anchor on that label, then open the combobox in the same field block.
|
||||
await expect(
|
||||
frigateApp.page.getByText("Streaming Technology", { exact: true }),
|
||||
).toBeVisible({ timeout: 3_000 });
|
||||
const technologyTrigger = frigateApp.page
|
||||
.locator(
|
||||
'div:has(> div:text-is("Streaming Technology")) [role="combobox"]',
|
||||
)
|
||||
.first();
|
||||
await expect(technologyTrigger).toBeVisible({ timeout: 3_000 });
|
||||
await technologyTrigger.click();
|
||||
|
||||
const webrtcOption = frigateApp.page.getByRole("option", {
|
||||
name: /WebRTC/,
|
||||
});
|
||||
await expect(webrtcOption).toBeVisible({ timeout: 3_000 });
|
||||
await expect(webrtcOption).toHaveAttribute("aria-disabled", "true");
|
||||
|
||||
const mseOption = frigateApp.page.getByRole("option", { name: /MSE/ });
|
||||
await expect(mseOption).not.toHaveAttribute("aria-disabled", "true");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Runtime override tests -- MEDIUM tier.
|
||||
*
|
||||
* Live view, MQTT, and Home Assistant toggles change a camera's running config
|
||||
* without touching yaml, and the change persists across restarts. The settings
|
||||
* form edits yaml, so it keeps showing the saved value. Without a marker on the
|
||||
* field and runtime-aware wording on dependent warnings, the two read as a
|
||||
* contradiction: "audio detection is not enabled" next to a switch that is on.
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { test, expect } from "../../fixtures/frigate-test";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { configFactory } from "../../fixtures/mock-data/config";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const CONFIG_SCHEMA = JSON.parse(
|
||||
readFileSync(
|
||||
resolve(__dirname, "../../fixtures/mock-data/config-schema.json"),
|
||||
"utf-8",
|
||||
),
|
||||
);
|
||||
|
||||
const CAMERA = "front_door";
|
||||
const TRANSCRIPTION_URL = `/settings?page=cameraAudioTranscription&camera=${CAMERA}`;
|
||||
const AUDIO_URL = `/settings?page=cameraAudioEvents&camera=${CAMERA}`;
|
||||
|
||||
const CONFIG_DISABLED = /Audio detection is not enabled for this camera/;
|
||||
const RUNTIME_DISABLED =
|
||||
/Audio detection is enabled in your config, but it is currently turned off/;
|
||||
|
||||
type AudioState = { enabled: boolean; enabled_in_config: boolean };
|
||||
|
||||
async function installRoutes(page: Page, audio: AudioState) {
|
||||
const config = configFactory({
|
||||
cameras: {
|
||||
[CAMERA]: {
|
||||
audio,
|
||||
// audio detection only runs on a stream carrying the audio role
|
||||
ffmpeg: {
|
||||
inputs: [
|
||||
{
|
||||
path: "rtsp://user:pass@host/front",
|
||||
roles: ["record", "detect", "audio"],
|
||||
},
|
||||
],
|
||||
},
|
||||
audio_transcription: { enabled: true, enabled_in_config: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await page.route("**/api/config/raw_paths", (route) =>
|
||||
route.fulfill({
|
||||
json: { cameras: { [CAMERA]: { ffmpeg: { inputs: [] } } } },
|
||||
}),
|
||||
);
|
||||
await page.route("**/api/config/schema.json", (route) =>
|
||||
route.fulfill({ json: CONFIG_SCHEMA }),
|
||||
);
|
||||
await page.route("**/api/config", (route) => {
|
||||
if (route.request().method() === "GET") {
|
||||
return route.fulfill({ json: config });
|
||||
}
|
||||
return route.fulfill({ json: { success: true } });
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("runtime overrides @medium", () => {
|
||||
test("a runtime-only toggle gets its own wording and a field marker", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await installRoutes(frigateApp.page, {
|
||||
enabled: false,
|
||||
enabled_in_config: true,
|
||||
});
|
||||
|
||||
await frigateApp.goto(TRANSCRIPTION_URL);
|
||||
await expect(frigateApp.page.getByText(RUNTIME_DISABLED)).toBeVisible();
|
||||
await expect(frigateApp.page.getByText(CONFIG_DISABLED)).toBeHidden();
|
||||
|
||||
// The audio section still shows the saved value, so the switch stays on and
|
||||
// the marker carries the live state.
|
||||
await frigateApp.goto(AUDIO_URL);
|
||||
await expect(
|
||||
frigateApp.page.getByRole("switch", { name: "Enable audio detection" }),
|
||||
).toHaveAttribute("data-state", "checked");
|
||||
// the boolean layout renders a mobile and a desktop label block, so only
|
||||
// one of the two badges is on screen
|
||||
await expect(
|
||||
frigateApp.page.getByText("Overridden (Live)").filter({ visible: true }),
|
||||
).toHaveCount(1);
|
||||
});
|
||||
|
||||
test("a config-disabled section keeps the original wording", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await installRoutes(frigateApp.page, {
|
||||
enabled: false,
|
||||
enabled_in_config: false,
|
||||
});
|
||||
|
||||
await frigateApp.goto(TRANSCRIPTION_URL);
|
||||
await expect(frigateApp.page.getByText(CONFIG_DISABLED)).toBeVisible();
|
||||
await expect(frigateApp.page.getByText(RUNTIME_DISABLED)).toBeHidden();
|
||||
|
||||
await frigateApp.goto(AUDIO_URL);
|
||||
await expect(
|
||||
frigateApp.page.getByRole("switch", { name: "Enable audio detection" }),
|
||||
).toHaveAttribute("data-state", "unchecked");
|
||||
await expect(
|
||||
frigateApp.page.getByText("Overridden (Live)").filter({ visible: true }),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -18,15 +18,20 @@ const OUTDOOR_LAYOUT = [
|
||||
{ i: "backyard", x: 6, y: 0, w: 6, h: 4 },
|
||||
];
|
||||
|
||||
// a camera as an export from before streaming technology selection carries
|
||||
// it, so tests can assert both what a pre-feature file writes and what the
|
||||
// technology adds on top
|
||||
const FRONT_DOOR_WITHOUT_TECHNOLOGY = {
|
||||
streamName: "front_door",
|
||||
streamType: "smart",
|
||||
compatibilityMode: false,
|
||||
playAudio: false,
|
||||
volume: 1,
|
||||
};
|
||||
|
||||
const STREAMING_SETTINGS = {
|
||||
outdoor: {
|
||||
front_door: {
|
||||
streamName: "front_door",
|
||||
streamType: "smart",
|
||||
compatibilityMode: false,
|
||||
playAudio: false,
|
||||
volume: 1,
|
||||
},
|
||||
front_door: { ...FRONT_DOOR_WITHOUT_TECHNOLOGY, playerMode: "webrtc" },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -340,6 +345,7 @@ test.describe("UI settings import/export @medium", () => {
|
||||
garage: {
|
||||
streamName: "garage",
|
||||
streamType: "continuous",
|
||||
playerMode: "jsmpeg",
|
||||
compatibilityMode: true,
|
||||
playAudio: true,
|
||||
volume: 0.5,
|
||||
@@ -375,6 +381,77 @@ test.describe("UI settings import/export @medium", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("drops only the streaming technology when its value is unrecognized", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
// a hand-edited file, or an export from a future Frigate that added a
|
||||
// technology this build does not know: the camera's other settings still
|
||||
// import rather than the whole file failing validation
|
||||
await frigateApp.goto("/settings?page=uiSettings");
|
||||
|
||||
await chooseImportFile(
|
||||
frigateApp.page,
|
||||
importPayload({
|
||||
sections: {
|
||||
layouts: {},
|
||||
streaming: {
|
||||
outdoor: {
|
||||
front_door: {
|
||||
...FRONT_DOOR_WITHOUT_TECHNOLOGY,
|
||||
playerMode: "quantum",
|
||||
},
|
||||
},
|
||||
},
|
||||
preferences: {},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
frigateApp.page.getByText("Streaming settings (1 camera)"),
|
||||
).toBeVisible();
|
||||
|
||||
await confirmImport(frigateApp.page);
|
||||
|
||||
expect(await readIdb(frigateApp.page, STREAMING_KEY)).toEqual({
|
||||
outdoor: { front_door: FRONT_DOOR_WITHOUT_TECHNOLOGY },
|
||||
});
|
||||
});
|
||||
|
||||
test("clears a stored streaming technology when the file predates it", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
// import replaces whole camera objects, as it already does for streamName
|
||||
// and volume, so a pre-feature export resets the technology rather than
|
||||
// leaving the local choice in place
|
||||
await frigateApp.goto("/settings?page=uiSettings");
|
||||
|
||||
await writeIdb(frigateApp.page, { [STREAMING_KEY]: STREAMING_SETTINGS });
|
||||
await chooseImportFile(
|
||||
frigateApp.page,
|
||||
importPayload({
|
||||
sections: {
|
||||
layouts: {},
|
||||
streaming: {
|
||||
outdoor: { front_door: FRONT_DOOR_WITHOUT_TECHNOLOGY },
|
||||
},
|
||||
preferences: {},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
frigateApp.page.getByText("Streaming settings (1 camera)"),
|
||||
).toBeVisible();
|
||||
|
||||
await confirmImport(frigateApp.page);
|
||||
|
||||
expect(await readIdb(frigateApp.page, STREAMING_KEY)).toEqual({
|
||||
outdoor: { front_door: FRONT_DOOR_WITHOUT_TECHNOLOGY },
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects a file that is not valid JSON", async ({ frigateApp }) => {
|
||||
await frigateApp.goto("/settings?page=uiSettings");
|
||||
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* Zone rename and delete tests -- MEDIUM tier.
|
||||
*
|
||||
* A zone's name also lives in required_zones lists and profile overrides.
|
||||
* These tests pin that renaming or deleting a zone sends one config/set JSON
|
||||
* body that moves or drops every reference, with nothing in the query string,
|
||||
* and flags a restart. Editing the name alone must not rename the zone.
|
||||
*/
|
||||
|
||||
import { test, expect } from "../../fixtures/frigate-test";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { configFactory } from "../../fixtures/mock-data/config";
|
||||
|
||||
const SETTINGS_URL = "/settings?page=masksAndZones&camera=front_door";
|
||||
const COORDINATES = "0.1,0.1,0.5,0.1,0.5,0.5,0.1,0.5";
|
||||
|
||||
// /api/config returns zone filters with defaults filled in
|
||||
const FILTERS = {
|
||||
person: {
|
||||
min_area: 5000,
|
||||
max_area: 24000000,
|
||||
min_ratio: 0,
|
||||
max_ratio: 24000000,
|
||||
threshold: 0.7,
|
||||
min_score: 0.5,
|
||||
mask: {},
|
||||
},
|
||||
};
|
||||
|
||||
type ConfigSetRequest = { url: string; body: Record<string, unknown> };
|
||||
|
||||
async function installRoutes(page: Page) {
|
||||
const config = configFactory({
|
||||
profiles: { armed: { friendly_name: "Armed" } },
|
||||
cameras: {
|
||||
front_door: {
|
||||
zones: {
|
||||
driveway: {
|
||||
coordinates: COORDINATES,
|
||||
enabled: true,
|
||||
inertia: 3,
|
||||
loitering_time: 0,
|
||||
objects: [],
|
||||
filters: FILTERS,
|
||||
color: [128, 128, 0],
|
||||
},
|
||||
},
|
||||
review: { alerts: { required_zones: ["driveway"] } },
|
||||
snapshots: { required_zones: ["driveway"] },
|
||||
mqtt: { required_zones: ["driveway"] },
|
||||
profiles: {
|
||||
armed: {
|
||||
zones: { driveway: { coordinates: COORDINATES, inertia: 6 } },
|
||||
review: { alerts: { required_zones: ["driveway"] } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const requests: ConfigSetRequest[] = [];
|
||||
|
||||
await page.route("**/api/config", (route) => route.fulfill({ json: config }));
|
||||
await page.route("**/api/config/set**", async (route) => {
|
||||
requests.push({
|
||||
url: route.request().url(),
|
||||
body: route.request().postDataJSON(),
|
||||
});
|
||||
await route.fulfill({ json: { success: true } });
|
||||
});
|
||||
|
||||
return requests;
|
||||
}
|
||||
|
||||
async function openZoneAction(
|
||||
page: Page,
|
||||
isMobile: boolean,
|
||||
action: "Edit" | "Delete",
|
||||
) {
|
||||
const row = page.locator("[data-index]", { hasText: "Driveway" });
|
||||
|
||||
if (isMobile) {
|
||||
await row.locator("button[aria-haspopup='menu']").click();
|
||||
await page.getByRole("menuitem", { name: action }).click();
|
||||
return;
|
||||
}
|
||||
|
||||
// Desktop shows the actions on hover
|
||||
await row.hover();
|
||||
await row.getByLabel(action, { exact: true }).click();
|
||||
}
|
||||
|
||||
test.describe("zone rename and delete @medium @mobile", () => {
|
||||
test("editing the name of a referenced zone keeps its id", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
const requests = await installRoutes(frigateApp.page);
|
||||
await frigateApp.goto(SETTINGS_URL);
|
||||
|
||||
await openZoneAction(frigateApp.page, frigateApp.isMobile, "Edit");
|
||||
await frigateApp.page
|
||||
.getByRole("textbox", { name: "Name", exact: true })
|
||||
.fill("Main Driveway");
|
||||
await expect(
|
||||
frigateApp.page.getByRole("textbox", { name: "ID", exact: true }),
|
||||
).toHaveValue("driveway");
|
||||
await frigateApp.page.getByRole("button", { name: "Save" }).click();
|
||||
|
||||
await expect.poll(() => requests.length).toBe(1);
|
||||
expect(new URL(requests[0].url).search).toBe("");
|
||||
expect(requests[0].body).toEqual({
|
||||
requires_restart: 0,
|
||||
update_topic: "config/cameras/front_door/zones",
|
||||
config_data: {
|
||||
cameras: {
|
||||
front_door: {
|
||||
zones: {
|
||||
driveway: {
|
||||
coordinates: COORDINATES,
|
||||
enabled: true,
|
||||
inertia: 3,
|
||||
loitering_time: 0,
|
||||
friendly_name: "Main Driveway",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("turning speed estimation on and off sends no distances delete", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
// The zone has no distances in the YAML, and config/set fails to delete
|
||||
// a missing key
|
||||
const requests = await installRoutes(frigateApp.page);
|
||||
await frigateApp.goto(SETTINGS_URL);
|
||||
|
||||
await openZoneAction(frigateApp.page, frigateApp.isMobile, "Edit");
|
||||
const speedEstimation = frigateApp.page
|
||||
.getByText("Speed Estimation", { exact: true })
|
||||
.locator("..")
|
||||
.getByRole("switch");
|
||||
await speedEstimation.click();
|
||||
for (const line of ["A", "B", "C", "D"]) {
|
||||
await frigateApp.page
|
||||
.getByRole("textbox", { name: new RegExp(`^Line ${line} distance`) })
|
||||
.fill("5");
|
||||
}
|
||||
await speedEstimation.click();
|
||||
await frigateApp.page.getByRole("button", { name: "Save" }).click();
|
||||
|
||||
await expect.poll(() => requests.length).toBe(1);
|
||||
expect(requests[0].body).toMatchObject({
|
||||
config_data: { cameras: { front_door: { zones: { driveway: {} } } } },
|
||||
});
|
||||
const body = requests[0].body as {
|
||||
config_data: {
|
||||
cameras: { front_door: { zones: { driveway: object } } };
|
||||
};
|
||||
};
|
||||
expect(
|
||||
body.config_data.cameras.front_door.zones.driveway,
|
||||
).not.toHaveProperty("distances");
|
||||
});
|
||||
|
||||
test("renaming a referenced zone moves every reference in one request", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
const requests = await installRoutes(frigateApp.page);
|
||||
await frigateApp.goto(SETTINGS_URL);
|
||||
|
||||
await openZoneAction(frigateApp.page, frigateApp.isMobile, "Edit");
|
||||
await frigateApp.page
|
||||
.getByRole("textbox", { name: "ID", exact: true })
|
||||
.fill("front_drive");
|
||||
await frigateApp.page.getByRole("button", { name: "Save" }).click();
|
||||
|
||||
await expect.poll(() => requests.length).toBe(1);
|
||||
expect(new URL(requests[0].url).search).toBe("");
|
||||
expect(requests[0].body).toEqual({
|
||||
requires_restart: 1,
|
||||
update_topic: "config/cameras/front_door/zones",
|
||||
config_data: {
|
||||
cameras: {
|
||||
front_door: {
|
||||
zones: {
|
||||
driveway: null,
|
||||
front_drive: {
|
||||
coordinates: COORDINATES,
|
||||
enabled: true,
|
||||
filters: FILTERS,
|
||||
inertia: 3,
|
||||
loitering_time: 0,
|
||||
},
|
||||
},
|
||||
review: { alerts: { required_zones: ["front_drive"] } },
|
||||
snapshots: { required_zones: ["front_drive"] },
|
||||
mqtt: { required_zones: ["front_drive"] },
|
||||
profiles: {
|
||||
armed: {
|
||||
review: { alerts: { required_zones: ["front_drive"] } },
|
||||
zones: {
|
||||
driveway: null,
|
||||
front_drive: { coordinates: COORDINATES, inertia: 6 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("deleting a referenced zone drops every reference in one request", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
const requests = await installRoutes(frigateApp.page);
|
||||
await frigateApp.goto(SETTINGS_URL);
|
||||
|
||||
await openZoneAction(frigateApp.page, frigateApp.isMobile, "Delete");
|
||||
await frigateApp.page
|
||||
.getByRole("alertdialog")
|
||||
.getByRole("button", { name: "Delete" })
|
||||
.click();
|
||||
|
||||
await expect.poll(() => requests.length).toBe(1);
|
||||
expect(new URL(requests[0].url).search).toBe("");
|
||||
expect(requests[0].body).toEqual({
|
||||
requires_restart: 1,
|
||||
update_topic: "config/cameras/front_door/zones",
|
||||
config_data: {
|
||||
cameras: {
|
||||
front_door: {
|
||||
zones: { driveway: null },
|
||||
review: { alerts: { required_zones: [] } },
|
||||
snapshots: { required_zones: [] },
|
||||
mqtt: { required_zones: [] },
|
||||
profiles: {
|
||||
armed: {
|
||||
review: { alerts: { required_zones: [] } },
|
||||
zones: { driveway: null },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
Generated
+790
-4301
File diff suppressed because it is too large
Load Diff
+56
-69
@@ -12,8 +12,6 @@
|
||||
"lint:fix": "eslint --fix .",
|
||||
"preview": "vite preview",
|
||||
"prettier:write": "prettier -u -w --ignore-path .gitignore \"*.{ts,tsx,js,jsx,css,html}\"",
|
||||
"test": "vitest",
|
||||
"coverage": "vitest run --coverage",
|
||||
"e2e:build": "tsc && vite build --base=/",
|
||||
"e2e": "playwright test --config e2e/playwright.config.ts",
|
||||
"e2e:ui": "playwright test --config e2e/playwright.config.ts --ui",
|
||||
@@ -24,119 +22,108 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@cycjimmy/jsmpeg-player": "^6.1.2",
|
||||
"@hookform/resolvers": "^3.10.0",
|
||||
"@melloware/react-logviewer": "^6.1.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@hookform/resolvers": "^5.9.1",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.23",
|
||||
"@radix-ui/react-aspect-ratio": "^1.1.15",
|
||||
"@radix-ui/react-checkbox": "^1.1.4",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-context-menu": "^2.2.16",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.2",
|
||||
"@radix-ui/react-popover": "^1.1.6",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-radio-group": "^1.2.3",
|
||||
"@radix-ui/react-scroll-area": "^1.2.3",
|
||||
"@radix-ui/react-select": "^2.1.6",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slider": "^1.2.3",
|
||||
"@radix-ui/react-checkbox": "^1.3.11",
|
||||
"@radix-ui/react-collapsible": "^1.1.20",
|
||||
"@radix-ui/react-context-menu": "^2.3.7",
|
||||
"@radix-ui/react-dialog": "^1.1.23",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.24",
|
||||
"@radix-ui/react-hover-card": "^1.1.23",
|
||||
"@radix-ui/react-label": "^2.1.15",
|
||||
"@radix-ui/react-popover": "^1.1.23",
|
||||
"@radix-ui/react-progress": "^1.1.16",
|
||||
"@radix-ui/react-radio-group": "^1.4.7",
|
||||
"@radix-ui/react-scroll-area": "^1.2.18",
|
||||
"@radix-ui/react-select": "^2.3.7",
|
||||
"@radix-ui/react-separator": "^1.1.15",
|
||||
"@radix-ui/react-slider": "^1.4.7",
|
||||
"@radix-ui/react-slot": "1.2.4",
|
||||
"@radix-ui/react-switch": "^1.1.3",
|
||||
"@radix-ui/react-tabs": "^1.1.3",
|
||||
"@radix-ui/react-toggle": "^1.1.2",
|
||||
"@radix-ui/react-toggle-group": "^1.1.2",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@radix-ui/react-switch": "^1.3.7",
|
||||
"@radix-ui/react-tabs": "^1.1.21",
|
||||
"@radix-ui/react-toggle": "^1.1.18",
|
||||
"@radix-ui/react-toggle-group": "^1.1.19",
|
||||
"@radix-ui/react-tooltip": "^1.2.16",
|
||||
"@rjsf/core": "^6.10.0",
|
||||
"@rjsf/shadcn": "^6.10.0",
|
||||
"@rjsf/utils": "^6.10.0",
|
||||
"@rjsf/validator-ajv8": "^6.10.0",
|
||||
"apexcharts": "^7.3.0",
|
||||
"axios": "^1.18.0",
|
||||
"axios": "^1.20.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.0",
|
||||
"copy-to-clipboard": "^3.3.3",
|
||||
"date-fns": "^3.6.0",
|
||||
"copy-to-clipboard": "^4.0.2",
|
||||
"date-fns": "^4.4.0",
|
||||
"date-fns-tz": "^3.2.0",
|
||||
"framer-motion": "^13.2.0",
|
||||
"hls.js": "^1.6.15",
|
||||
"i18next": "^24.2.0",
|
||||
"framer-motion": "^13.3.0",
|
||||
"hls.js": "^1.7.3",
|
||||
"i18next": "^26.4.2",
|
||||
"i18next-http-backend": "^4.0.2",
|
||||
"idb-keyval": "^6.2.1",
|
||||
"js-yaml": "^4.3.2",
|
||||
"konva": "^10.2.3",
|
||||
"idb-keyval": "^6.3.0",
|
||||
"js-yaml": "^5.4.2",
|
||||
"konva": "^10.5.0",
|
||||
"lodash": "^4.18.1",
|
||||
"lucide-react": "^0.577.0",
|
||||
"monaco-yaml": "^5.4.1",
|
||||
"lucide-react": "^1.46.0",
|
||||
"monaco-yaml": "^5.5.1",
|
||||
"next-themes": "^0.4.6",
|
||||
"nosleep.js": "^0.12.0",
|
||||
"react": "^19.2.4",
|
||||
"react": "^19.3.0",
|
||||
"react-apexcharts": "^2.1.1",
|
||||
"react-day-picker": "^9.14.0",
|
||||
"react-device-detect": "^2.2.3",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-dropzone": "^14.3.8",
|
||||
"react-grid-layout": "^2.2.2",
|
||||
"react-hook-form": "^7.72.0",
|
||||
"react-i18next": "^15.2.0",
|
||||
"react-icons": "^5.6.0",
|
||||
"react-konva": "^19.2.3",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-dom": "^19.3.0",
|
||||
"react-dropzone": "^20.1.2",
|
||||
"react-grid-layout": "^2.2.4",
|
||||
"react-hook-form": "^7.88.0",
|
||||
"react-i18next": "^17.0.14",
|
||||
"react-icons": "^5.7.0",
|
||||
"react-konva": "^19.2.7",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^6.30.6",
|
||||
"react-swipeable": "^7.0.2",
|
||||
"react-zoom-pan-pinch": "3.4.4",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"scroll-into-view-if-needed": "^3.1.0",
|
||||
"sonner": "^2.0.7",
|
||||
"sort-by": "^1.2.0",
|
||||
"strftime": "^0.10.3",
|
||||
"swr": "^2.4.1",
|
||||
"sonner": "^2.0.8",
|
||||
"swr": "^2.5.1",
|
||||
"tailwind-merge": "^2.4.0",
|
||||
"tailwind-scrollbar": "^3.1.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"use-long-press": "^3.2.0",
|
||||
"use-long-press": "^3.3.0",
|
||||
"vaul": "^1.1.2",
|
||||
"virtua": "^0.51.3",
|
||||
"vite-plugin-monaco-editor": "^1.1.0",
|
||||
"zod": "^3.23.8"
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@playwright/test": "^1.59.1",
|
||||
"@tailwindcss/forms": "^0.5.9",
|
||||
"@testing-library/jest-dom": "^6.6.2",
|
||||
"@playwright/test": "^1.63.0",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/lodash": "^4.17.12",
|
||||
"@types/node": "^25.9.6",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/strftime": "^0.9.8",
|
||||
"@types/lodash": "^4.17.25",
|
||||
"@types/node": "^26.5.1",
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"@vitest/coverage-v8": "^4.1.11",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"autoprefixer": "^10.6.0",
|
||||
"esbuild": "^0.28.2",
|
||||
"eslint": "^10.10.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.6",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.6",
|
||||
"fake-indexeddb": "^6.0.0",
|
||||
"eslint-plugin-react-refresh": "^0.5.7",
|
||||
"globals": "^17.12.0",
|
||||
"i18next-cli": "^1.5.11",
|
||||
"jest-websocket-mock": "^2.5.0",
|
||||
"jsdom": "^24.1.1",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"msw": "^2.3.5",
|
||||
"patch-package": "^8.0.1",
|
||||
"postcss": "^8.5.12",
|
||||
"prettier": "^3.3.3",
|
||||
"prettier-plugin-tailwindcss": "^0.6.5",
|
||||
"prettier": "^3.9.6",
|
||||
"prettier-plugin-tailwindcss": "^0.8.1",
|
||||
"tailwindcss": "^3.4.9",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.70.0",
|
||||
"vite": "^8.3.0",
|
||||
"vitest": "^4.1.11"
|
||||
"vite": "^8.3.0"
|
||||
},
|
||||
"overrides": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
|
||||
@@ -36,14 +36,6 @@
|
||||
"title": "Bandwidth:",
|
||||
"short": "Bandwidth"
|
||||
},
|
||||
"latency": {
|
||||
"title": "Latency:",
|
||||
"value": "{{seconds}} seconds",
|
||||
"short": {
|
||||
"title": "Latency",
|
||||
"value": "{{seconds}} sec"
|
||||
}
|
||||
},
|
||||
"totalFrames": "Total Frames:",
|
||||
"droppedFrames": {
|
||||
"title": "Dropped Frames:",
|
||||
|
||||
@@ -6,7 +6,12 @@
|
||||
"lowBandwidthMode": "Low-bandwidth Mode",
|
||||
"twoWayTalk": {
|
||||
"enable": "Enable Two Way Talk",
|
||||
"disable": "Disable Two Way Talk"
|
||||
"disable": "Disable Two Way Talk",
|
||||
"requiresWebRTC": "Two-way talk requires WebRTC, which is unavailable",
|
||||
"error": {
|
||||
"microphone": "Two-way talk could not access your microphone",
|
||||
"refused": "Two-way talk failed to start. See the browser console for details."
|
||||
}
|
||||
},
|
||||
"cameraAudio": {
|
||||
"enable": "Enable Camera Audio",
|
||||
@@ -135,7 +140,8 @@
|
||||
"unavailable": "Audio is not available for this stream"
|
||||
},
|
||||
"debug": {
|
||||
"picker": "Stream selection unavailable in debug mode. Debug view always uses the stream assigned the detect role."
|
||||
"picker": "Stream selection unavailable in debug mode. Debug view always uses the stream assigned the detect role.",
|
||||
"technology": "Stream technology selection is unavailable in debug mode."
|
||||
},
|
||||
"twoWayTalk": {
|
||||
"tips": "Your device must support the feature and WebRTC must be configured for two-way talk.",
|
||||
@@ -144,11 +150,36 @@
|
||||
},
|
||||
"lowBandwidth": {
|
||||
"tips": "Live view is in low-bandwidth mode due to buffering or stream errors.",
|
||||
"resetStream": "Reset stream"
|
||||
"resetStream": "Reset stream",
|
||||
"force": {
|
||||
"label": "Force low-bandwidth mode",
|
||||
"desc": "Always play Frigate's built-in low-bandwidth feed instead of the selected stream. Works on any connection, but has lower quality and no audio."
|
||||
}
|
||||
},
|
||||
"playInBackground": {
|
||||
"label": "Play in background",
|
||||
"tips": "Enable this option to continue streaming when the player is hidden."
|
||||
},
|
||||
"mode": "Streaming Technology",
|
||||
"technology": {
|
||||
"description": "Choose your preferred streaming technology. Frigate may still fall back to low bandwidth mode on playback or network errors.",
|
||||
"name": {
|
||||
"mse": "MSE",
|
||||
"webrtc": "WebRTC",
|
||||
"jsmpeg": "JSMpeg"
|
||||
},
|
||||
"tips": {
|
||||
"mse": "Recommended default, broad compatibility and smooth playback",
|
||||
"webrtc": "Needs extra setup and isn't supported on every device"
|
||||
},
|
||||
"unavailable": {
|
||||
"browser": "Your browser does not support WebRTC.",
|
||||
"not-configured": "WebRTC is not configured. Set go2rtc webrtc candidates or ice_servers.",
|
||||
"unreachable": "WebRTC could not connect. Check that port 8555 is reachable and any STUN/TURN server is correct.",
|
||||
"video-codec": "This stream's video codec is not supported by WebRTC in this browser.",
|
||||
"audio-codec": "This stream's audio codec is not supported by WebRTC. Transcode to opus or G.711 with go2rtc.",
|
||||
"checking": "Checking WebRTC availability…"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cameraSettings": {
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
"overriddenGlobalHeading_one": "This camera overrides {{count}} field from the global config:",
|
||||
"overriddenGlobalHeading_other": "This camera overrides {{count}} fields from the global config:",
|
||||
"overriddenGlobalNoDeltas": "This camera overrides the global config, but no field values differ.",
|
||||
"overriddenLive": "Overridden (Live)",
|
||||
"overriddenLiveTooltip": "This camera is running with a different value than the one saved in your config, which is shown here. The live view, MQTT, or an active profile can change it while Frigate is running.",
|
||||
"overriddenLiveValue": "Running value: {{value}}",
|
||||
"overriddenBaseConfig": "Overridden (Base Config)",
|
||||
"overriddenBaseConfigTooltip": "The {{profile}} profile overrides configuration settings in this section",
|
||||
"overriddenBaseConfigHeading_one": "The {{profile}} profile overrides {{count}} field from the base config:",
|
||||
@@ -1941,19 +1944,25 @@
|
||||
"configMessages": {
|
||||
"review": {
|
||||
"recordDisabled": "Recording is disabled, review items will not be generated.",
|
||||
"recordRuntimeDisabled": "Recording is enabled in your config, but it is currently turned off for this camera, so review items will not be generated. Turn it back on from the camera's live view, or check whether an active profile is disabling it.",
|
||||
"detectDisabled": "Object detection is disabled. Review items require detected objects to categorize alerts and detections.",
|
||||
"detectRuntimeDisabled": "Object detection is enabled in your config, but it is currently turned off for this camera. Review items require detected objects to categorize alerts and detections. Turn it back on from the camera's live view, or check whether an active profile is disabling it.",
|
||||
"allNonAlertDetections": "All non-alert activity will be included as detections.",
|
||||
"genaiImageSourceRecordingsRecordDisabled": "Image source is set to 'recordings', but recording is disabled. Frigate will fall back to preview images."
|
||||
"genaiImageSourceRecordingsRecordDisabled": "Image source is set to 'recordings', but recording is disabled. Frigate will fall back to preview images.",
|
||||
"genaiImageSourceRecordingsRecordRuntimeDisabled": "Image source is set to 'recordings', but recording is currently turned off for this camera even though your config enables it. Frigate will fall back to preview images."
|
||||
},
|
||||
"audio": {
|
||||
"noAudioRole": "No streams have the audio role defined. You must enable the audio role for audio detection to function."
|
||||
},
|
||||
"audioTranscription": {
|
||||
"audioDetectionDisabled": "Audio detection is not enabled for this camera. Audio transcription requires audio detection to be active."
|
||||
"audioDetectionDisabled": "Audio detection is not enabled for this camera. Audio transcription requires audio detection to be active.",
|
||||
"audioDetectionRuntimeDisabled": "Audio detection is enabled in your config, but it is currently turned off for this camera, so audio transcription will not run. Turn it back on from the camera's live view, or check whether an active profile is disabling it.",
|
||||
"genaiProviderSelected": "A GenAI provider is selected, so the device and model size settings are ignored."
|
||||
},
|
||||
"detect": {
|
||||
"fpsGreaterThanFive": "Setting the detect FPS higher than 5 is not recommended. Higher values may cause performance issues and will not provide any benefit.",
|
||||
"disabled": "Object detection is disabled. Snapshots, review items, and enrichments such as face recognition, license plate recognition, and Generative AI will not function.",
|
||||
"runtimeDisabled": "Object detection is enabled in your config, but it is currently turned off for this camera. Snapshots, review items, and enrichments such as face recognition, license plate recognition, and Generative AI will not function until it is turned back on from the camera's live view, or until the active profile stops disabling it.",
|
||||
"sceneWithoutModel": "No detection model is configured for this scene, so this camera falls back to the model with a scene of 'All cameras'. Add a model for this scene to give the camera its own.",
|
||||
"resolutionShouldBeMultipleOfFour": "For best results, detect width and height should be multiples of 4. Other even values may produce visual artifacts or slight distortion in the detect stream.",
|
||||
"aspectRatioMismatch": "The width and height you've entered don't match the aspect ratio of your current detect resolution. This may produce a stretched or distorted image.",
|
||||
@@ -1988,10 +1997,12 @@
|
||||
"noRecordSubRole": "No streams have the record_sub role defined. Sub stream recording will not function."
|
||||
},
|
||||
"birdseye": {
|
||||
"objectTrackingDetectDisabled": "Birdseye includes tracked objects, but object detection is disabled for this camera. The camera will not appear in Birdseye."
|
||||
"objectTrackingDetectDisabled": "Birdseye includes tracked objects, but object detection is disabled for this camera. The camera will not appear in Birdseye.",
|
||||
"objectTrackingDetectRuntimeDisabled": "Birdseye includes tracked objects, but object detection is currently turned off for this camera even though your config enables it. The camera will not appear in Birdseye until it is turned back on from the camera's live view, or until the active profile stops disabling it."
|
||||
},
|
||||
"snapshots": {
|
||||
"detectDisabled": "Object detection is disabled. Snapshots are generated from tracked objects and will not be created."
|
||||
"detectDisabled": "Object detection is disabled. Snapshots are generated from tracked objects and will not be created.",
|
||||
"detectRuntimeDisabled": "Object detection is enabled in your config, but it is currently turned off for this camera, so snapshots will not be created. Turn it back on from the camera's live view, or check whether an active profile is disabling it."
|
||||
},
|
||||
"semanticSearch": {
|
||||
"jinav2SmallModelSize": "The 'small' size with the Jina V2 model has high RAM and inference cost. The 'large' model with a discrete GPU is recommended."
|
||||
|
||||
@@ -92,10 +92,12 @@ function PagePanel({ failure }: PanelProps) {
|
||||
|
||||
const stale = isStaleAsset(failure);
|
||||
|
||||
const onCopy = () => {
|
||||
// copy() falls back to a prompt and returns false when the clipboard is
|
||||
const onCopy = async () => {
|
||||
// copy() falls back to a prompt and resolves false when the clipboard is
|
||||
// refused, so a success toast has to wait on the result.
|
||||
const copied = copy(crashReport(failure, stats?.service.version));
|
||||
const copied = await copy(crashReport(failure, stats?.service.version), {
|
||||
fallbackToPrompt: true,
|
||||
});
|
||||
|
||||
if (copied) {
|
||||
toast.success(t("button.copiedToClipboard"), { position: "top-center" });
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEmbeddingsReindexProgress } from "@/api/ws";
|
||||
import {
|
||||
StatusBarMessagesContext,
|
||||
StatusMessage,
|
||||
} from "@/context/statusbar-provider";
|
||||
} from "@/context/statusbar-context";
|
||||
import useStats, { useAutoFrigateStats } from "@/hooks/use-stats";
|
||||
import StatusBarNotices from "@/components/health/StatusBarNotices";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -4,13 +4,15 @@ import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
|
||||
import { CameraConfig } from "@/types/frigateConfig";
|
||||
import { useZoneFriendlyName } from "@/hooks/use-zone-friendly-name";
|
||||
|
||||
interface CameraNameLabelProps
|
||||
extends React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> {
|
||||
interface CameraNameLabelProps extends React.ComponentPropsWithoutRef<
|
||||
typeof LabelPrimitive.Root
|
||||
> {
|
||||
camera?: string | CameraConfig;
|
||||
}
|
||||
|
||||
interface ZoneNameLabelProps
|
||||
extends React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> {
|
||||
interface ZoneNameLabelProps extends React.ComponentPropsWithoutRef<
|
||||
typeof LabelPrimitive.Root
|
||||
> {
|
||||
zone: string;
|
||||
camera?: string;
|
||||
}
|
||||
|
||||
@@ -71,10 +71,10 @@ export function MessageBubble({
|
||||
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = useCallback(() => {
|
||||
const handleCopy = useCallback(async () => {
|
||||
const text = content?.trim() || "";
|
||||
if (!text) return;
|
||||
if (copy(text)) {
|
||||
if (await copy(text)) {
|
||||
setCopied(true);
|
||||
toast.success(t("button.copiedToClipboard", { ns: "common" }));
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
@@ -133,7 +133,7 @@ export function MessageBubble({
|
||||
variant="select"
|
||||
size="icon"
|
||||
className="size-9 rounded-full"
|
||||
disabled={!draftContent.trim()}
|
||||
disabled={!draftContent.trim() || onEditSubmit == null}
|
||||
onClick={handleEditSubmit}
|
||||
aria-label={t("send")}
|
||||
>
|
||||
|
||||
@@ -163,8 +163,18 @@ export default function ClassificationModelEditDialog({
|
||||
}
|
||||
}, [isObjectModel, t]);
|
||||
|
||||
const form = useForm<ObjectFormData | StateFormData>({
|
||||
resolver: zodResolver(formSchema),
|
||||
const form = useForm<
|
||||
ObjectFormData | StateFormData,
|
||||
unknown,
|
||||
ObjectFormData | StateFormData
|
||||
>({
|
||||
resolver: zodResolver(
|
||||
formSchema as z.ZodType<
|
||||
ObjectFormData | StateFormData,
|
||||
z.ZodTypeDef,
|
||||
ObjectFormData | StateFormData
|
||||
>,
|
||||
),
|
||||
defaultValues: isObjectModel
|
||||
? ({
|
||||
enabled: model.enabled,
|
||||
|
||||
@@ -9,6 +9,11 @@ const audioTranscription: SectionConfigOverrides = {
|
||||
health: (ctx) =>
|
||||
ctx.fullCameraConfig?.audio_transcription?.enabled === true,
|
||||
messageKey: "configMessages.audioTranscription.audioDetectionDisabled",
|
||||
runtimeOverride: {
|
||||
section: "audio",
|
||||
messageKey:
|
||||
"configMessages.audioTranscription.audioDetectionRuntimeDisabled",
|
||||
},
|
||||
severity: "warning",
|
||||
condition: (ctx) => {
|
||||
if (ctx.level === "camera" && ctx.fullCameraConfig) {
|
||||
|
||||
@@ -7,6 +7,11 @@ const birdseye: SectionConfigOverrides = {
|
||||
{
|
||||
key: "object-tracking-detect-disabled",
|
||||
messageKey: "configMessages.birdseye.objectTrackingDetectDisabled",
|
||||
runtimeOverride: {
|
||||
section: "detect",
|
||||
messageKey:
|
||||
"configMessages.birdseye.objectTrackingDetectRuntimeDisabled",
|
||||
},
|
||||
severity: "info",
|
||||
condition: (ctx) => {
|
||||
if (ctx.level !== "camera" || !ctx.fullCameraConfig) return false;
|
||||
|
||||
@@ -63,6 +63,10 @@ const objects: SectionConfigOverrides = {
|
||||
{
|
||||
key: "detect-disabled",
|
||||
messageKey: "configMessages.detect.disabled",
|
||||
runtimeOverride: {
|
||||
section: "detect",
|
||||
messageKey: "configMessages.detect.runtimeDisabled",
|
||||
},
|
||||
severity: "info",
|
||||
condition: (ctx) =>
|
||||
ctx.level === "camera" &&
|
||||
|
||||
@@ -7,6 +7,10 @@ const review: SectionConfigOverrides = {
|
||||
{
|
||||
key: "record-disabled",
|
||||
messageKey: "configMessages.review.recordDisabled",
|
||||
runtimeOverride: {
|
||||
section: "record",
|
||||
messageKey: "configMessages.review.recordRuntimeDisabled",
|
||||
},
|
||||
severity: "warning",
|
||||
condition: (ctx) => {
|
||||
if (ctx.level === "camera" && ctx.fullCameraConfig) {
|
||||
@@ -18,6 +22,10 @@ const review: SectionConfigOverrides = {
|
||||
{
|
||||
key: "detect-disabled",
|
||||
messageKey: "configMessages.review.detectDisabled",
|
||||
runtimeOverride: {
|
||||
section: "detect",
|
||||
messageKey: "configMessages.review.detectRuntimeDisabled",
|
||||
},
|
||||
severity: "info",
|
||||
condition: (ctx) => {
|
||||
if (ctx.level === "camera" && ctx.fullCameraConfig) {
|
||||
@@ -64,12 +72,16 @@ const review: SectionConfigOverrides = {
|
||||
field: "genai.image_source",
|
||||
messageKey:
|
||||
"configMessages.review.genaiImageSourceRecordingsRecordDisabled",
|
||||
runtimeOverride: {
|
||||
section: "record",
|
||||
messageKey:
|
||||
"configMessages.review.genaiImageSourceRecordingsRecordRuntimeDisabled",
|
||||
},
|
||||
severity: "warning",
|
||||
position: "after",
|
||||
condition: (ctx) => {
|
||||
const genai = ctx.formData?.genai as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
Record<string, unknown> | undefined;
|
||||
if (genai?.image_source !== "recordings") return false;
|
||||
if (ctx.level === "camera" && ctx.fullCameraConfig) {
|
||||
return ctx.fullCameraConfig.record?.enabled === false;
|
||||
|
||||
@@ -7,6 +7,10 @@ const snapshots: SectionConfigOverrides = {
|
||||
{
|
||||
key: "detect-disabled",
|
||||
messageKey: "configMessages.snapshots.detectDisabled",
|
||||
runtimeOverride: {
|
||||
section: "detect",
|
||||
messageKey: "configMessages.snapshots.detectRuntimeDisabled",
|
||||
},
|
||||
severity: "info",
|
||||
condition: (ctx) => {
|
||||
if (ctx.level !== "camera" || !ctx.fullCameraConfig) return false;
|
||||
|
||||
@@ -28,6 +28,17 @@ export type ConditionalMessage = {
|
||||
values?: Record<string, unknown>;
|
||||
/** Optional documentation path (e.g. "/configuration/object_detectors#model"). */
|
||||
docLink?: string;
|
||||
/**
|
||||
* Alternate wording for when the section this message depends on is enabled
|
||||
* in the config but turned off on the running camera. Without it the message
|
||||
* reads as a contradiction, since the form shows the saved config value.
|
||||
*/
|
||||
runtimeOverride?: {
|
||||
/** Camera section whose runtime state explains the message, e.g. "audio". */
|
||||
section: string;
|
||||
/** Translation key used in place of `messageKey`. */
|
||||
messageKey: string;
|
||||
};
|
||||
/**
|
||||
* Whether the Health tab evaluates this message against the saved config.
|
||||
* Absent or false: form only. true: shown whenever condition() holds. A
|
||||
|
||||
@@ -17,11 +17,9 @@ export function validateDetectDimensions(
|
||||
const height = data.height;
|
||||
|
||||
const widthErrors = errors.width as
|
||||
| { addError?: (message: string) => void }
|
||||
| undefined;
|
||||
{ addError?: (message: string) => void } | undefined;
|
||||
const heightErrors = errors.height as
|
||||
| { addError?: (message: string) => void }
|
||||
| undefined;
|
||||
{ addError?: (message: string) => void } | undefined;
|
||||
|
||||
const message = t("detect.dimensionMustBeEven", { ns: "config/validation" });
|
||||
|
||||
|
||||
@@ -38,8 +38,7 @@ export default function CameraReviewClassification({
|
||||
const cameraName = formContext?.cameraName ?? selectedCamera;
|
||||
const fullFormData = formContext?.formData as JsonObject | undefined;
|
||||
const baselineFormData = formContext?.baselineFormData as
|
||||
| JsonObject
|
||||
| undefined;
|
||||
JsonObject | undefined;
|
||||
const cameraConfig = formContext?.fullCameraConfig;
|
||||
|
||||
const alertsZones = useMemo(
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
|
||||
import { StatusBarMessagesContext } from "@/context/statusbar-context";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import axios from "axios";
|
||||
@@ -371,9 +371,9 @@ export default function NotificationsSettingsExtras({
|
||||
|
||||
const shouldFetchPubKey = Boolean(
|
||||
config &&
|
||||
(config.notifications?.enabled || anyCameraNotificationsEnabled) &&
|
||||
(watchAllEnabled ||
|
||||
(Array.isArray(watchCameras) && watchCameras.length > 0)),
|
||||
(config.notifications?.enabled || anyCameraNotificationsEnabled) &&
|
||||
(watchAllEnabled ||
|
||||
(Array.isArray(watchCameras) && watchCameras.length > 0)),
|
||||
);
|
||||
|
||||
const { data: publicKey } = useSWR(
|
||||
|
||||
@@ -61,7 +61,7 @@ import {
|
||||
JsonValue,
|
||||
} from "@/types/configForm";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
|
||||
import { StatusBarMessagesContext } from "@/context/statusbar-context";
|
||||
import {
|
||||
cameraUpdateTopicMap,
|
||||
globalCameraDefaultSections,
|
||||
@@ -1022,6 +1022,7 @@ export function ConfigSection({
|
||||
formContext={{
|
||||
level: effectiveLevel,
|
||||
cameraName,
|
||||
sectionPath,
|
||||
globalValue,
|
||||
cameraValue,
|
||||
hasChanges,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { FieldPathList, FieldProps } from "@rjsf/utils";
|
||||
import yaml from "js-yaml";
|
||||
import { dump, load, YAMLException } from "js-yaml";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
@@ -16,7 +16,7 @@ function formatYaml(value: unknown): string {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
return yaml.dump(value, { indent: 2, lineWidth: -1 }).trimEnd();
|
||||
return dump(value, { indent: 2, lineWidth: -1 }).trimEnd();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
@@ -31,7 +31,7 @@ function parseYaml(text: string): {
|
||||
return { value: {}, error: undefined };
|
||||
}
|
||||
try {
|
||||
const parsed = yaml.load(trimmed);
|
||||
const parsed = load(trimmed);
|
||||
if (
|
||||
typeof parsed !== "object" ||
|
||||
parsed === null ||
|
||||
@@ -41,7 +41,7 @@ function parseYaml(text: string): {
|
||||
}
|
||||
return { value: parsed as Record<string, unknown>, error: undefined };
|
||||
} catch (e) {
|
||||
const msg = e instanceof yaml.YAMLException ? e.reason : "Invalid YAML";
|
||||
const msg = e instanceof YAMLException ? e.reason : "Invalid YAML";
|
||||
return { value: undefined, error: msg };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,8 +217,7 @@ function PlateCombobox({
|
||||
export function KnownPlatesField(props: FieldProps) {
|
||||
const { schema, formData, onChange, idSchema, disabled, readonly } = props;
|
||||
const formContext = props.registry?.formContext as
|
||||
| ConfigFormContext
|
||||
| undefined;
|
||||
ConfigFormContext | undefined;
|
||||
|
||||
const configNamespace =
|
||||
formContext?.i18nNamespace ??
|
||||
|
||||
@@ -159,8 +159,7 @@ function StreamValueCombobox({
|
||||
export function LiveStreamsField(props: FieldProps) {
|
||||
const { schema, formData, onChange, idSchema, disabled, readonly } = props;
|
||||
const formContext = props.registry?.formContext as
|
||||
| ConfigFormContext
|
||||
| undefined;
|
||||
ConfigFormContext | undefined;
|
||||
|
||||
const configNamespace =
|
||||
formContext?.i18nNamespace ??
|
||||
|
||||
@@ -96,8 +96,7 @@ const getItemProperties = (
|
||||
|
||||
const getSceneOptions = (itemSchema: RJSFSchema | undefined): string[] => {
|
||||
const scene = getItemProperties(itemSchema).scene as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
Record<string, unknown> | undefined;
|
||||
const values = scene?.enum;
|
||||
|
||||
return Array.isArray(values)
|
||||
|
||||
@@ -44,8 +44,7 @@ function getPropertyTitle(itemSchema: RJSFSchema | undefined, key: string) {
|
||||
export function ReplaceRulesField(props: FieldProps) {
|
||||
const { schema, formData, onChange, idSchema, disabled, readonly } = props;
|
||||
const formContext = props.registry?.formContext as
|
||||
| ConfigFormContext
|
||||
| undefined;
|
||||
ConfigFormContext | undefined;
|
||||
|
||||
const configNamespace =
|
||||
formContext?.i18nNamespace ??
|
||||
|
||||
@@ -19,6 +19,8 @@ import { LuExternalLink } from "react-icons/lu";
|
||||
import { useDocDomain } from "@/hooks/use-doc-domain";
|
||||
import { requiresRestartForFieldPath } from "@/utils/configUtil";
|
||||
import RestartRequiredIndicator from "@/components/indicators/RestartRequiredIndicator";
|
||||
import RuntimeOverrideIndicator from "@/components/indicators/RuntimeOverrideIndicator";
|
||||
import { getRuntimeOverride } from "@/utils/runtimeOverrides";
|
||||
import {
|
||||
buildTranslationPath,
|
||||
resolveConfigTranslation,
|
||||
@@ -86,8 +88,7 @@ export function FieldTemplate(props: FieldTemplateProps) {
|
||||
const formContext = registry?.formContext as ConfigFormContext | undefined;
|
||||
const i18nNamespace = formContext?.i18nNamespace as string | undefined;
|
||||
const sectionI18nPrefix = formContext?.sectionI18nPrefix as
|
||||
| string
|
||||
| undefined;
|
||||
string | undefined;
|
||||
const isCameraLevel = formContext?.level === "camera";
|
||||
const effectiveNamespace = isCameraLevel ? "config/cameras" : i18nNamespace;
|
||||
const { t, i18n } = useTranslation([
|
||||
@@ -212,6 +213,18 @@ export function FieldTemplate(props: FieldTemplateProps) {
|
||||
defaultRequiresRestart,
|
||||
);
|
||||
|
||||
// The form shows saved config values, so flag any field the running camera
|
||||
// currently disagrees with. Profile editing shows that profile's overrides
|
||||
// instead, where the comparison does not apply.
|
||||
const runtimeOverride =
|
||||
isCameraLevel && !formContext?.isProfile
|
||||
? getRuntimeOverride(
|
||||
formContext?.fullCameraConfig,
|
||||
formContext?.sectionPath,
|
||||
pathSegments.join("."),
|
||||
)
|
||||
: undefined;
|
||||
|
||||
// Use schema title/description as primary source (from JSON Schema)
|
||||
const schemaTitle = schema.title;
|
||||
const schemaDescription = schema.description;
|
||||
@@ -503,6 +516,12 @@ export function FieldTemplate(props: FieldTemplateProps) {
|
||||
{finalLabel}
|
||||
{required && <span className="ml-1 text-destructive">*</span>}
|
||||
{fieldRequiresRestart && <RestartRequiredIndicator className="ml-2" />}
|
||||
{runtimeOverride && (
|
||||
<RuntimeOverrideIndicator
|
||||
runtimeValue={runtimeOverride.runtime}
|
||||
className="ml-2"
|
||||
/>
|
||||
)}
|
||||
</Label>
|
||||
);
|
||||
};
|
||||
@@ -520,6 +539,12 @@ export function FieldTemplate(props: FieldTemplateProps) {
|
||||
{finalLabel}
|
||||
{required && <span className="ml-1 text-destructive">*</span>}
|
||||
{fieldRequiresRestart && <RestartRequiredIndicator className="ml-2" />}
|
||||
{runtimeOverride && (
|
||||
<RuntimeOverrideIndicator
|
||||
runtimeValue={runtimeOverride.runtime}
|
||||
className="ml-2"
|
||||
/>
|
||||
)}
|
||||
</Label>
|
||||
);
|
||||
};
|
||||
@@ -541,6 +566,12 @@ export function FieldTemplate(props: FieldTemplateProps) {
|
||||
{finalLabel}
|
||||
{required && <span className="ml-1 text-destructive">*</span>}
|
||||
{fieldRequiresRestart && <RestartRequiredIndicator className="ml-2" />}
|
||||
{runtimeOverride && (
|
||||
<RuntimeOverrideIndicator
|
||||
runtimeValue={runtimeOverride.runtime}
|
||||
className="ml-2"
|
||||
/>
|
||||
)}
|
||||
</Label>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -25,8 +25,7 @@ export function MultiSchemaFieldTemplate<
|
||||
const { schema, selector, optionSchemaField, uiSchema } = props;
|
||||
|
||||
const uiOptions = uiSchema?.["ui:options"] as
|
||||
| UiSchema["ui:options"]
|
||||
| undefined;
|
||||
UiSchema["ui:options"] | undefined;
|
||||
const suppressMultiSchema = uiOptions?.suppressMultiSchema === true;
|
||||
|
||||
// Check if this is a simple nullable field that should be handled specially
|
||||
|
||||
@@ -55,8 +55,7 @@ export function CameraPathWidget(props: WidgetProps) {
|
||||
const [showCredentials, setShowCredentials] = useState(false);
|
||||
|
||||
const formContext = props.registry?.formContext as
|
||||
| ConfigFormContext
|
||||
| undefined;
|
||||
ConfigFormContext | undefined;
|
||||
const isCameraLevel = formContext?.level === "camera";
|
||||
const cameraName = formContext?.cameraName;
|
||||
const inputIndex = useMemo(() => getInputIndexFromWidgetId(id), [id]);
|
||||
|
||||
@@ -103,8 +103,7 @@ const normalizeManualText = (value: unknown): string => {
|
||||
|
||||
export function FfmpegArgsWidget(props: WidgetProps) {
|
||||
const formContext = props.registry?.formContext as
|
||||
| ConfigFormContext
|
||||
| undefined;
|
||||
ConfigFormContext | undefined;
|
||||
const i18nNamespace = formContext?.i18nNamespace as string | undefined;
|
||||
const isCameraLevel = formContext?.level === "camera";
|
||||
const effectiveNamespace = isCameraLevel ? "config/cameras" : i18nNamespace;
|
||||
|
||||
@@ -27,8 +27,7 @@ import type { GenAIModelsResponse } from "@/types/chat";
|
||||
import { getSizedFieldClassName } from "../utils";
|
||||
|
||||
type ProbeResponse =
|
||||
| { success: true; models: string[] }
|
||||
| { success: false; message: string };
|
||||
{ success: true; models: string[] } | { success: false; message: string };
|
||||
|
||||
type ProbeStatus = "idle" | "probing" | "success" | "error";
|
||||
|
||||
|
||||
@@ -21,8 +21,7 @@ export function OnvifProfileWidget(props: WidgetProps) {
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
|
||||
const formContext = props.registry?.formContext as
|
||||
| ConfigFormContext
|
||||
| undefined;
|
||||
ConfigFormContext | undefined;
|
||||
const cameraName = formContext?.cameraName;
|
||||
const isCameraLevel = formContext?.level === "camera";
|
||||
const hasOnvifHost = !!formContext?.fullCameraConfig?.onvif?.host;
|
||||
|
||||
@@ -98,8 +98,7 @@ export function SwitchesWidget(props: WidgetProps) {
|
||||
const availableEntities = useMemo(() => {
|
||||
const getEntities =
|
||||
(props.options?.getEntities as
|
||||
| ((context: FormContext) => string[])
|
||||
| undefined) || (() => []);
|
||||
((context: FormContext) => string[]) | undefined) || (() => []);
|
||||
if (context) {
|
||||
return getEntities(context);
|
||||
}
|
||||
@@ -109,8 +108,8 @@ export function SwitchesWidget(props: WidgetProps) {
|
||||
const getDisplayLabel = useMemo(
|
||||
() =>
|
||||
(props.options?.getDisplayLabel as
|
||||
| ((entity: string, context?: FormContext) => string)
|
||||
| undefined) || ((entity: string) => entity),
|
||||
((entity: string, context?: FormContext) => string) | undefined) ||
|
||||
((entity: string) => entity),
|
||||
[props.options],
|
||||
);
|
||||
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
import { useRef, useCallback, useEffect, type ReactNode } from "react";
|
||||
import { ScrollFollow } from "@melloware/react-logviewer";
|
||||
|
||||
export type ScrollFollowProps = {
|
||||
startFollowing?: boolean;
|
||||
render: (renderProps: ScrollFollowRenderProps) => ReactNode;
|
||||
onCustomScroll?: (
|
||||
scrollTop: number,
|
||||
scrollHeight: number,
|
||||
clientHeight: number,
|
||||
) => void;
|
||||
};
|
||||
|
||||
export type ScrollFollowRenderProps = {
|
||||
follow: boolean;
|
||||
onScroll: (args: {
|
||||
scrollTop: number;
|
||||
scrollHeight: number;
|
||||
clientHeight: number;
|
||||
}) => void;
|
||||
startFollowing: () => void;
|
||||
stopFollowing: () => void;
|
||||
onCustomScroll?: (
|
||||
scrollTop: number,
|
||||
scrollHeight: number,
|
||||
clientHeight: number,
|
||||
) => void;
|
||||
};
|
||||
|
||||
const SCROLL_BUFFER = 5;
|
||||
|
||||
export default function EnhancedScrollFollow(props: ScrollFollowProps) {
|
||||
const followRef = useRef(props.startFollowing || false);
|
||||
const prevScrollTopRef = useRef<number | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
prevScrollTopRef.current = undefined;
|
||||
}, []);
|
||||
|
||||
const wrappedRender = useCallback(
|
||||
(renderProps: ScrollFollowRenderProps) => {
|
||||
const wrappedOnScroll = (args: {
|
||||
scrollTop: number;
|
||||
scrollHeight: number;
|
||||
clientHeight: number;
|
||||
}) => {
|
||||
// Check if scrolling up and immediately stop following
|
||||
if (
|
||||
prevScrollTopRef.current !== undefined &&
|
||||
args.scrollTop < prevScrollTopRef.current
|
||||
) {
|
||||
if (followRef.current) {
|
||||
renderProps.stopFollowing();
|
||||
followRef.current = false;
|
||||
}
|
||||
}
|
||||
|
||||
const bottomThreshold =
|
||||
args.scrollHeight - args.clientHeight - SCROLL_BUFFER;
|
||||
const isNearBottom = args.scrollTop >= bottomThreshold;
|
||||
|
||||
if (isNearBottom && !followRef.current) {
|
||||
renderProps.startFollowing();
|
||||
followRef.current = true;
|
||||
} else if (!isNearBottom && followRef.current) {
|
||||
renderProps.stopFollowing();
|
||||
followRef.current = false;
|
||||
}
|
||||
|
||||
prevScrollTopRef.current = args.scrollTop;
|
||||
renderProps.onScroll(args);
|
||||
if (props.onCustomScroll) {
|
||||
props.onCustomScroll(
|
||||
args.scrollTop,
|
||||
args.scrollHeight,
|
||||
args.clientHeight,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return props.render({
|
||||
...renderProps,
|
||||
onScroll: wrappedOnScroll,
|
||||
follow: followRef.current,
|
||||
});
|
||||
},
|
||||
[props],
|
||||
);
|
||||
|
||||
return <ScrollFollow {...props} render={wrappedRender} />;
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { MdHome } from "react-icons/md";
|
||||
import { Button, buttonVariants } from "../ui/button";
|
||||
import {
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
@@ -66,7 +67,8 @@ import { z } from "zod";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { toast } from "sonner";
|
||||
import ActivityIndicator from "../indicators/activity-indicator";
|
||||
import { useUserPersistence } from "@/hooks/use-user-persistence";
|
||||
import { deleteUserNamespacedKey } from "@/hooks/use-user-persistence";
|
||||
import { AuthContext } from "@/context/auth-context";
|
||||
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import * as LuIcons from "react-icons/lu";
|
||||
@@ -499,9 +501,8 @@ function NewGroupDialog({
|
||||
const [editState, setEditState] = useState<"none" | "add" | "edit">("none");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const [, , , deleteGridLayout] = useUserPersistence(
|
||||
`${activeGroup}-draggable-layout`,
|
||||
);
|
||||
const { auth } = useContext(AuthContext);
|
||||
const username = auth?.user?.username;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
@@ -513,15 +514,14 @@ function NewGroupDialog({
|
||||
|
||||
const onDeleteGroup = useCallback(
|
||||
async (name: string) => {
|
||||
deleteGridLayout();
|
||||
deleteGroup();
|
||||
|
||||
await axios
|
||||
.put(`config/set?camera_groups.${name}`, { requires_restart: 0 })
|
||||
.then((res) => {
|
||||
if (res.status === 200) {
|
||||
deleteUserNamespacedKey(`${name}-draggable-layout`, username);
|
||||
if (activeGroup == name) {
|
||||
// deleting current group
|
||||
deleteGroup();
|
||||
setGroup("default");
|
||||
}
|
||||
updateConfig();
|
||||
@@ -557,15 +557,7 @@ function NewGroupDialog({
|
||||
setIsLoading(false);
|
||||
});
|
||||
},
|
||||
[
|
||||
updateConfig,
|
||||
activeGroup,
|
||||
setGroup,
|
||||
setOpen,
|
||||
deleteGroup,
|
||||
deleteGridLayout,
|
||||
t,
|
||||
],
|
||||
[updateConfig, activeGroup, setGroup, setOpen, deleteGroup, username, t],
|
||||
);
|
||||
|
||||
const onSave = () => {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Tooltip, TooltipContent } from "../ui/tooltip";
|
||||
import { TooltipTrigger } from "@radix-ui/react-tooltip";
|
||||
|
||||
type RuntimeOverrideIndicatorProps = {
|
||||
/** The value the camera is running with, which differs from the saved config. */
|
||||
runtimeValue: unknown;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Field-level companion to the section override badges. Marks a field the
|
||||
* running camera has drifted from, so the saved value on screen never reads as
|
||||
* the live one.
|
||||
*/
|
||||
export default function RuntimeOverrideIndicator({
|
||||
runtimeValue,
|
||||
className,
|
||||
}: RuntimeOverrideIndicatorProps) {
|
||||
const { t } = useTranslation(["views/settings", "common"]);
|
||||
|
||||
const displayValue =
|
||||
typeof runtimeValue === "boolean"
|
||||
? t(runtimeValue ? "button.on" : "button.off", { ns: "common" })
|
||||
: Array.isArray(runtimeValue)
|
||||
? runtimeValue.join(", ")
|
||||
: String(runtimeValue);
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
"cursor-default border-2 border-selected text-center align-middle text-xs font-normal text-primary-variant",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{t("button.overriddenLive", { ns: "views/settings" })}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-72">
|
||||
<p>{t("button.overriddenLiveTooltip", { ns: "views/settings" })}</p>
|
||||
<p className="mt-1">
|
||||
{t("button.overriddenLiveValue", {
|
||||
ns: "views/settings",
|
||||
value: displayValue,
|
||||
})}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -27,6 +27,8 @@ type NameAndIdFieldsProps<T extends FieldValues = FieldValues> = {
|
||||
placeholderId?: string;
|
||||
idVisible?: boolean;
|
||||
idDisabled?: boolean;
|
||||
// Derive the id from the name as the user types
|
||||
autoFillId?: boolean;
|
||||
};
|
||||
|
||||
export default function NameAndIdFields<T extends FieldValues = FieldValues>({
|
||||
@@ -43,6 +45,7 @@ export default function NameAndIdFields<T extends FieldValues = FieldValues>({
|
||||
placeholderId,
|
||||
idVisible,
|
||||
idDisabled,
|
||||
autoFillId = true,
|
||||
}: NameAndIdFieldsProps<T>) {
|
||||
const { t } = useTranslation(["common"]);
|
||||
const { watch, setValue, trigger, formState } = useFormContext<T>();
|
||||
@@ -61,7 +64,7 @@ export default function NameAndIdFields<T extends FieldValues = FieldValues>({
|
||||
const effectiveProcessId = processId || defaultProcessId;
|
||||
|
||||
useEffect(() => {
|
||||
if (idDisabled) {
|
||||
if (idDisabled || !autoFillId) {
|
||||
return;
|
||||
}
|
||||
const subscription = watch((value, { name }) => {
|
||||
@@ -81,6 +84,7 @@ export default function NameAndIdFields<T extends FieldValues = FieldValues>({
|
||||
idField,
|
||||
effectiveProcessId,
|
||||
idDisabled,
|
||||
autoFillId,
|
||||
]);
|
||||
|
||||
// Auto-expand if there's an error on the ID field after user has typed
|
||||
|
||||
@@ -179,6 +179,9 @@ export default function LiveContextMenu({
|
||||
],
|
||||
);
|
||||
|
||||
const isForcedLowBandwidth =
|
||||
groupStreamingSettings?.[camera]?.playerMode === "jsmpeg";
|
||||
|
||||
// ui
|
||||
|
||||
const audioControlsUsed = useRef(false);
|
||||
@@ -270,12 +273,14 @@ export default function LiveContextMenu({
|
||||
<div className="text-primary-variant smart-capitalize">
|
||||
<CameraNameLabel camera={camera} />
|
||||
</div>
|
||||
{preferredLiveMode == "jsmpeg" && isRestreamed && (
|
||||
<div className="flex flex-row items-center gap-1">
|
||||
<IoIosWarning className="mr-1 size-4 text-danger" />
|
||||
<p className="mr-2 text-xs">{t("lowBandwidthMode")}</p>
|
||||
</div>
|
||||
)}
|
||||
{preferredLiveMode == "jsmpeg" &&
|
||||
isRestreamed &&
|
||||
!isForcedLowBandwidth && (
|
||||
<div className="flex flex-row items-center gap-1">
|
||||
<IoIosWarning className="mr-1 size-4 text-danger" />
|
||||
<p className="mr-2 text-xs">{t("lowBandwidthMode")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{preferredLiveMode != "jsmpeg" && isRestreamed && supportsAudio && (
|
||||
<>
|
||||
@@ -374,21 +379,23 @@ export default function LiveContextMenu({
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
)}
|
||||
{preferredLiveMode == "jsmpeg" && isRestreamed && (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem disabled={!isEnabled}>
|
||||
<div
|
||||
className="flex w-full cursor-pointer items-center justify-start gap-2"
|
||||
onClick={isEnabled ? resetPreferredLiveMode : undefined}
|
||||
>
|
||||
<div className="text-primary">
|
||||
{t("button.reset", { ns: "common" })}
|
||||
{preferredLiveMode == "jsmpeg" &&
|
||||
isRestreamed &&
|
||||
!isForcedLowBandwidth && (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem disabled={!isEnabled}>
|
||||
<div
|
||||
className="flex w-full cursor-pointer items-center justify-start gap-2"
|
||||
onClick={isEnabled ? resetPreferredLiveMode : undefined}
|
||||
>
|
||||
<div className="text-primary">
|
||||
{t("button.reset", { ns: "common" })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
)}
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
)}
|
||||
{notificationsEnabledInConfig && isEnabled && (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
@@ -548,14 +555,16 @@ export default function LiveContextMenu({
|
||||
</ContextMenu>
|
||||
|
||||
<Dialog open={showSettings} onOpenChange={setShowSettings}>
|
||||
<CameraStreamingDialog
|
||||
camera={camera}
|
||||
groupStreamingSettings={groupStreamingSettings}
|
||||
setGroupStreamingSettings={setGroupStreamingSettings}
|
||||
setIsDialogOpen={setShowSettings}
|
||||
onSave={onSave}
|
||||
streamMetadata={streamMetadata}
|
||||
/>
|
||||
{showSettings && (
|
||||
<CameraStreamingDialog
|
||||
camera={camera}
|
||||
groupStreamingSettings={groupStreamingSettings}
|
||||
setGroupStreamingSettings={setGroupStreamingSettings}
|
||||
setIsDialogOpen={setShowSettings}
|
||||
onSave={onSave}
|
||||
streamMetadata={streamMetadata}
|
||||
/>
|
||||
)}
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -19,7 +19,7 @@ import useNavigation from "@/hooks/use-navigation";
|
||||
import {
|
||||
StatusBarMessagesContext,
|
||||
StatusMessage,
|
||||
} from "@/context/statusbar-provider";
|
||||
} from "@/context/statusbar-context";
|
||||
import { Link } from "react-router-dom";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { isMobile } from "react-device-detect";
|
||||
|
||||
@@ -174,7 +174,6 @@ export default function JSMpegPlayer({
|
||||
streamType: "jsmpeg",
|
||||
bandwidth: Math.round(bitrate),
|
||||
totalFrames: frameCount,
|
||||
latency: undefined,
|
||||
droppedFrames: undefined,
|
||||
decodedFrames: undefined,
|
||||
droppedFrameRate: undefined,
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||
import { useCameraActivity } from "@/hooks/use-camera-activity";
|
||||
import {
|
||||
LivePlayerError,
|
||||
TwoWayTalkError,
|
||||
LivePlayerMode,
|
||||
PlayerStatsType,
|
||||
VideoResolutionType,
|
||||
@@ -51,6 +52,7 @@ type LivePlayerProps = {
|
||||
onClick?: () => void;
|
||||
setFullResolution?: React.Dispatch<React.SetStateAction<VideoResolutionType>>;
|
||||
onError?: (error: LivePlayerError) => void;
|
||||
onMicrophoneError?: (error: TwoWayTalkError) => void;
|
||||
onResetLiveMode?: () => void;
|
||||
};
|
||||
|
||||
@@ -76,6 +78,7 @@ export default function LivePlayer({
|
||||
onClick,
|
||||
setFullResolution,
|
||||
onError,
|
||||
onMicrophoneError,
|
||||
onResetLiveMode,
|
||||
}: LivePlayerProps) {
|
||||
const { t } = useTranslation(["components/player"]);
|
||||
@@ -98,7 +101,6 @@ export default function LivePlayer({
|
||||
const [stats, setStats] = useState<PlayerStatsType>({
|
||||
streamType: "-",
|
||||
bandwidth: 0, // in kBps
|
||||
latency: undefined, // in seconds
|
||||
totalFrames: 0,
|
||||
droppedFrames: undefined,
|
||||
decodedFrames: 0,
|
||||
@@ -272,6 +274,7 @@ export default function LivePlayer({
|
||||
onPlaying={playerIsPlaying}
|
||||
pip={pip}
|
||||
onError={onError}
|
||||
onMicrophoneError={onMicrophoneError}
|
||||
/>
|
||||
);
|
||||
} else if (preferredLiveMode == "mse") {
|
||||
@@ -363,7 +366,11 @@ export default function LivePlayer({
|
||||
{cameraEnabled &&
|
||||
!offline &&
|
||||
(!showStillWithoutActivity || isReEnabling) &&
|
||||
!liveReady && <ActivityIndicator />}
|
||||
!liveReady && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<ActivityIndicator />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{((showStillWithoutActivity && !liveReady) || liveReady) &&
|
||||
objects.length > 0 && (
|
||||
|
||||
@@ -759,15 +759,6 @@ function MSEPlayer({
|
||||
lastLoadedBytes = bytesLoaded;
|
||||
lastTimestamp = now;
|
||||
|
||||
const latency =
|
||||
video.seekable.length > 0
|
||||
? Math.max(
|
||||
0,
|
||||
video.seekable.end(video.seekable.length - 1) -
|
||||
video.currentTime,
|
||||
)
|
||||
: 0;
|
||||
|
||||
const videoQuality = video.getVideoPlaybackQuality();
|
||||
const { totalVideoFrames, droppedVideoFrames } = videoQuality;
|
||||
const droppedFrameRate = totalVideoFrames
|
||||
@@ -777,7 +768,6 @@ function MSEPlayer({
|
||||
setStats?.({
|
||||
streamType: "MSE",
|
||||
bandwidth,
|
||||
latency,
|
||||
totalFrames: totalVideoFrames,
|
||||
droppedFrames: droppedVideoFrames || undefined,
|
||||
decodedFrames: totalVideoFrames - droppedVideoFrames,
|
||||
@@ -793,7 +783,6 @@ function MSEPlayer({
|
||||
setStats?.({
|
||||
streamType: "-",
|
||||
bandwidth: 0,
|
||||
latency: undefined,
|
||||
totalFrames: 0,
|
||||
droppedFrames: undefined,
|
||||
decodedFrames: 0,
|
||||
|
||||
@@ -8,27 +8,21 @@ type PlayerStatsProps = {
|
||||
};
|
||||
|
||||
export function PlayerStats({ stats, minimal }: PlayerStatsProps) {
|
||||
const { t } = useTranslation(["components/player"]);
|
||||
const { t } = useTranslation(["components/player", "views/live"]);
|
||||
const streamTypeLabel = t(
|
||||
`stream.technology.name.${stats.streamType.toLowerCase()}`,
|
||||
{ ns: "views/live", defaultValue: stats.streamType },
|
||||
);
|
||||
const fullStatsContent = (
|
||||
<>
|
||||
<p>
|
||||
<span className="text-white/70">{t("stats.streamType.title")}</span>{" "}
|
||||
<span className="text-white">{stats.streamType}</span>
|
||||
<span className="text-white">{streamTypeLabel}</span>
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-white/70">{t("stats.bandwidth.title")}</span>{" "}
|
||||
<span className="text-white">{stats.bandwidth.toFixed(2)} kBps</span>
|
||||
</p>
|
||||
{stats.latency != undefined && (
|
||||
<p>
|
||||
<span className="text-white/70">{t("stats.latency.title")}</span>{" "}
|
||||
<span
|
||||
className={`text-white ${stats.latency > 2 ? "text-danger" : ""}`}
|
||||
>
|
||||
{t("stats.latency.value", { seconds: stats.latency.toFixed(2) })}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
<p>
|
||||
<span className="text-white/70">{t("stats.totalFrames")}</span>{" "}
|
||||
<span className="text-white">{stats.totalFrames}</span>
|
||||
@@ -62,26 +56,12 @@ export function PlayerStats({ stats, minimal }: PlayerStatsProps) {
|
||||
<div className="flex flex-row items-center justify-center gap-4">
|
||||
<div className="flex flex-col items-center justify-start gap-1">
|
||||
<span className="text-white/70">{t("stats.streamType.short")}</span>
|
||||
<span className="text-white">{stats.streamType}</span>
|
||||
<span className="text-white">{streamTypeLabel}</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<span className="text-white/70">{t("stats.bandwidth.short")}</span>{" "}
|
||||
<span className="text-white">{stats.bandwidth.toFixed(2)} kBps</span>
|
||||
</div>
|
||||
{stats.latency != undefined && (
|
||||
<div className="hidden flex-col items-center gap-1 md:flex">
|
||||
<span className="text-white/70">
|
||||
{t("stats.latency.short.title")}
|
||||
</span>
|
||||
<span
|
||||
className={`text-white ${stats.latency >= 2 ? "text-danger" : ""}`}
|
||||
>
|
||||
{t("stats.latency.short.value", {
|
||||
seconds: stats.latency.toFixed(2),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{stats.droppedFrames != undefined && (
|
||||
<div className="flex flex-col items-center justify-end gap-1">
|
||||
<span className="text-white/70">
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
export abstract class PreviewController {
|
||||
public camera = "";
|
||||
|
||||
constructor(camera: string) {
|
||||
this.camera = camera;
|
||||
}
|
||||
|
||||
abstract scrubToTimestamp(time: number): boolean;
|
||||
|
||||
abstract finishedSeeking(): void;
|
||||
|
||||
abstract setNewPreviewStartTime(time: number): void;
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
} from "@/hooks/use-camera-previews";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
|
||||
import { PreviewController } from "./PreviewController";
|
||||
|
||||
type PreviewPlayerProps = {
|
||||
previewRef?: (ref: HTMLDivElement | null) => void;
|
||||
@@ -102,20 +103,6 @@ export default function PreviewPlayer({
|
||||
);
|
||||
}
|
||||
|
||||
export abstract class PreviewController {
|
||||
public camera = "";
|
||||
|
||||
constructor(camera: string) {
|
||||
this.camera = camera;
|
||||
}
|
||||
|
||||
abstract scrubToTimestamp(time: number): boolean;
|
||||
|
||||
abstract finishedSeeking(): void;
|
||||
|
||||
abstract setNewPreviewStartTime(time: number): void;
|
||||
}
|
||||
|
||||
type PreviewVideoPlayerProps = {
|
||||
visibilityRef?: (ref: HTMLDivElement | null) => void;
|
||||
className?: string;
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LivePlayerMode, WebRTCUnavailableReason } from "@/types/live";
|
||||
import { LuX } from "react-icons/lu";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type StreamTechnologySelectProps = {
|
||||
value: LivePlayerMode;
|
||||
onValueChange: (value: LivePlayerMode) => void;
|
||||
isWebRTCAvailable: boolean;
|
||||
webRTCUnavailableReason?: WebRTCUnavailableReason;
|
||||
disabled: boolean;
|
||||
};
|
||||
|
||||
export default function StreamTechnologySelect({
|
||||
value,
|
||||
onValueChange,
|
||||
isWebRTCAvailable,
|
||||
webRTCUnavailableReason,
|
||||
disabled,
|
||||
}: StreamTechnologySelectProps) {
|
||||
const { t } = useTranslation(["views/live"]);
|
||||
|
||||
const isChecking = webRTCUnavailableReason === "checking";
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={value}
|
||||
onValueChange={onValueChange as (value: string) => void}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<span>{t(`stream.technology.name.${value}`)}</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="w-[var(--radix-select-trigger-width)]">
|
||||
<SelectItem value="mse">
|
||||
<span className="flex flex-col gap-0.5 whitespace-normal">
|
||||
<span>{t("stream.technology.name.mse")}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("stream.technology.tips.mse")}
|
||||
</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
value="webrtc"
|
||||
disabled={!isWebRTCAvailable}
|
||||
className="data-[disabled]:opacity-100"
|
||||
>
|
||||
<span className="flex flex-col gap-0.5 whitespace-normal">
|
||||
<span className={cn(!isWebRTCAvailable && "opacity-50")}>
|
||||
{t("stream.technology.name.webrtc")}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs text-muted-foreground",
|
||||
!isWebRTCAvailable && "opacity-50",
|
||||
)}
|
||||
>
|
||||
{t("stream.technology.tips.webrtc")}
|
||||
</span>
|
||||
{!isWebRTCAvailable && webRTCUnavailableReason && (
|
||||
<span
|
||||
className={cn(
|
||||
"mt-1 flex flex-row items-start gap-1.5 rounded-md border p-2",
|
||||
isChecking
|
||||
? "border-secondary-foreground/20 bg-secondary-foreground/10"
|
||||
: "border-danger/20 bg-danger/10",
|
||||
)}
|
||||
>
|
||||
{isChecking ? (
|
||||
<ActivityIndicator
|
||||
className="mt-0.5 size-3.5 shrink-0"
|
||||
size={14}
|
||||
/>
|
||||
) : (
|
||||
<LuX className="mt-0.5 size-3.5 shrink-0 text-danger" />
|
||||
)}
|
||||
<span className="text-xs text-secondary-foreground">
|
||||
{t(
|
||||
`stream.technology.unavailable.${webRTCUnavailableReason}`,
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,13 @@
|
||||
import { baseUrl } from "@/api/baseUrl";
|
||||
import { LivePlayerError, PlayerStatsType } from "@/types/live";
|
||||
import {
|
||||
LivePlayerError,
|
||||
PlayerStatsType,
|
||||
TwoWayTalkError,
|
||||
} from "@/types/live";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { webRTCIceServers } from "@/utils/webrtcUtil";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import useSWR from "swr";
|
||||
|
||||
type WebRtcPlayerProps = {
|
||||
className?: string;
|
||||
@@ -15,6 +22,7 @@ type WebRtcPlayerProps = {
|
||||
setStats?: (stats: PlayerStatsType) => void;
|
||||
onPlaying?: () => void;
|
||||
onError?: (error: LivePlayerError) => void;
|
||||
onMicrophoneError?: (error: TwoWayTalkError) => void;
|
||||
};
|
||||
|
||||
export default function WebRtcPlayer({
|
||||
@@ -30,9 +38,22 @@ export default function WebRtcPlayer({
|
||||
setStats,
|
||||
onPlaying,
|
||||
onError,
|
||||
onMicrophoneError,
|
||||
}: WebRtcPlayerProps) {
|
||||
// metadata
|
||||
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
|
||||
// Keyed on the serialized list so an unrelated config update doesn't
|
||||
// reconnect every WebRTC player.
|
||||
const iceServersKey = JSON.stringify(
|
||||
config?.go2rtc?.webrtc?.ice_servers ?? [],
|
||||
);
|
||||
const iceServers = useMemo(
|
||||
() => webRTCIceServers(JSON.parse(iceServersKey)),
|
||||
[iceServersKey],
|
||||
);
|
||||
|
||||
const wsURL = useMemo(() => {
|
||||
return `${baseUrl.replace(/^http/, "ws")}live/webrtc/api/ws?src=${camera}`;
|
||||
}, [camera]);
|
||||
@@ -54,6 +75,10 @@ export default function WebRtcPlayer({
|
||||
const pcRef = useRef<RTCPeerConnection | undefined>(undefined);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
// Separate sendonly-audio connection for two-way talk: go2rtc only wires the
|
||||
// backchannel from a connection's initial offer.
|
||||
const micPcRef = useRef<RTCPeerConnection | undefined>(undefined);
|
||||
const micWsRef = useRef<WebSocket | null>(null);
|
||||
const [bufferTimeout, setBufferTimeout] = useState<NodeJS.Timeout>();
|
||||
const videoLoadTimeoutRef = useRef<NodeJS.Timeout>(undefined);
|
||||
|
||||
@@ -65,7 +90,7 @@ export default function WebRtcPlayer({
|
||||
|
||||
const pc = new RTCPeerConnection({
|
||||
bundlePolicy: "max-bundle",
|
||||
iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
|
||||
iceServers,
|
||||
});
|
||||
|
||||
const localTracks = [];
|
||||
@@ -105,7 +130,7 @@ export default function WebRtcPlayer({
|
||||
videoRef.current.srcObject = new MediaStream(localTracks);
|
||||
return pc;
|
||||
},
|
||||
[videoRef],
|
||||
[videoRef, iceServers],
|
||||
);
|
||||
|
||||
async function getMediaTracks(
|
||||
@@ -123,51 +148,57 @@ export default function WebRtcPlayer({
|
||||
}
|
||||
}
|
||||
|
||||
// Offer/answer/ICE exchange over the WebSocket; shared by both connections.
|
||||
const startSignaling = useCallback((pc: RTCPeerConnection, ws: WebSocket) => {
|
||||
ws.addEventListener("open", () => {
|
||||
pc.addEventListener("icecandidate", (ev) => {
|
||||
if (!ev.candidate) return;
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "webrtc/candidate",
|
||||
value: ev.candidate.candidate,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
pc.createOffer()
|
||||
.then((offer) => pc.setLocalDescription(offer))
|
||||
.then(() => {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "webrtc/offer",
|
||||
value: pc.localDescription?.sdp,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
ws.addEventListener("message", (ev) => {
|
||||
const msg = JSON.parse(ev.data);
|
||||
if (msg.type === "webrtc/candidate") {
|
||||
pc.addIceCandidate({ candidate: msg.value, sdpMid: "0" });
|
||||
} else if (msg.type === "webrtc/answer") {
|
||||
pc.setRemoteDescription({ type: "answer", sdp: msg.value });
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const connect = useCallback(
|
||||
async (aPc: Promise<RTCPeerConnection | undefined>) => {
|
||||
if (!aPc) {
|
||||
return;
|
||||
}
|
||||
|
||||
pcRef.current = await aPc;
|
||||
const pc = await aPc;
|
||||
if (!pc) {
|
||||
return;
|
||||
}
|
||||
|
||||
pcRef.current = pc;
|
||||
wsRef.current = new WebSocket(wsURL);
|
||||
const ws = wsRef.current;
|
||||
|
||||
ws.addEventListener("open", () => {
|
||||
pcRef.current?.addEventListener("icecandidate", (ev) => {
|
||||
if (!ev.candidate) return;
|
||||
const msg = {
|
||||
type: "webrtc/candidate",
|
||||
value: ev.candidate.candidate,
|
||||
};
|
||||
ws.send(JSON.stringify(msg));
|
||||
});
|
||||
|
||||
pcRef.current
|
||||
?.createOffer()
|
||||
.then((offer) => pcRef.current?.setLocalDescription(offer))
|
||||
.then(() => {
|
||||
const msg = {
|
||||
type: "webrtc/offer",
|
||||
value: pcRef.current?.localDescription?.sdp,
|
||||
};
|
||||
ws.send(JSON.stringify(msg));
|
||||
});
|
||||
});
|
||||
|
||||
ws.addEventListener("message", (ev) => {
|
||||
const msg = JSON.parse(ev.data);
|
||||
if (msg.type === "webrtc/candidate") {
|
||||
pcRef.current?.addIceCandidate({ candidate: msg.value, sdpMid: "0" });
|
||||
} else if (msg.type === "webrtc/answer") {
|
||||
pcRef.current?.setRemoteDescription({
|
||||
type: "answer",
|
||||
sdp: msg.value,
|
||||
});
|
||||
}
|
||||
});
|
||||
startSignaling(pc, wsRef.current);
|
||||
},
|
||||
[wsURL],
|
||||
[wsURL, startSignaling],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -179,9 +210,8 @@ export default function WebRtcPlayer({
|
||||
return;
|
||||
}
|
||||
|
||||
const aPc = PeerConnection(
|
||||
microphoneEnabled ? "video+audio+microphone" : "video+audio",
|
||||
);
|
||||
// No mic here. It's a separate connection, so toggling talk never reloads.
|
||||
const aPc = PeerConnection("video+audio");
|
||||
connect(aPc);
|
||||
|
||||
return () => {
|
||||
@@ -194,14 +224,80 @@ export default function WebRtcPlayer({
|
||||
pcRef.current = undefined;
|
||||
}
|
||||
};
|
||||
}, [camera, connect, PeerConnection, pcRef, videoRef, playbackEnabled]);
|
||||
|
||||
// Backchannel connection, alive only while the mic is on.
|
||||
useEffect(() => {
|
||||
if (!microphoneEnabled || !playbackEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
const tracks = await getMediaTracks("user", {
|
||||
video: false,
|
||||
audio: true,
|
||||
});
|
||||
|
||||
if (cancelled) {
|
||||
tracks.forEach((track) => track.stop());
|
||||
return;
|
||||
}
|
||||
|
||||
if (tracks.length === 0) {
|
||||
onMicrophoneError?.("microphone");
|
||||
return;
|
||||
}
|
||||
|
||||
const pc = new RTCPeerConnection({
|
||||
bundlePolicy: "max-bundle",
|
||||
iceServers,
|
||||
});
|
||||
tracks.forEach((track) =>
|
||||
pc.addTransceiver(track, { direction: "sendonly" }),
|
||||
);
|
||||
|
||||
micPcRef.current = pc;
|
||||
const ws = new WebSocket(wsURL);
|
||||
micWsRef.current = ws;
|
||||
startSignaling(pc, ws);
|
||||
|
||||
// go2rtc sends an error instead of an answer when it can't attach the
|
||||
// microphone to the camera's backchannel.
|
||||
ws.addEventListener("message", (ev) => {
|
||||
const msg = JSON.parse(ev.data);
|
||||
if (msg.type !== "error" || cancelled) {
|
||||
return;
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
`${camera} - Two-way talk error: ${msg.value} See the documentation: https://docs.frigate.video/configuration/live/#two-way-talk`,
|
||||
);
|
||||
onMicrophoneError?.("refused");
|
||||
});
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
micPcRef.current?.getSenders().forEach((sender) => sender.track?.stop());
|
||||
if (micWsRef.current) {
|
||||
micWsRef.current.close();
|
||||
micWsRef.current = null;
|
||||
}
|
||||
if (micPcRef.current) {
|
||||
micPcRef.current.close();
|
||||
micPcRef.current = undefined;
|
||||
}
|
||||
};
|
||||
}, [
|
||||
camera,
|
||||
connect,
|
||||
PeerConnection,
|
||||
pcRef,
|
||||
videoRef,
|
||||
playbackEnabled,
|
||||
microphoneEnabled,
|
||||
playbackEnabled,
|
||||
wsURL,
|
||||
startSignaling,
|
||||
iceServers,
|
||||
camera,
|
||||
onMicrophoneError,
|
||||
]);
|
||||
|
||||
// ios compat
|
||||
@@ -262,9 +358,7 @@ export default function WebRtcPlayer({
|
||||
const report = await pcRef.current.getStats();
|
||||
let bytesReceived = 0;
|
||||
let timestamp = 0;
|
||||
let roundTripTime = 0;
|
||||
let framesReceived = 0;
|
||||
let framesDropped = 0;
|
||||
let framesDecoded = 0;
|
||||
|
||||
report.forEach((stat) => {
|
||||
@@ -272,12 +366,8 @@ export default function WebRtcPlayer({
|
||||
bytesReceived = stat.bytesReceived;
|
||||
timestamp = stat.timestamp;
|
||||
framesReceived = stat.framesReceived;
|
||||
framesDropped = stat.framesDropped;
|
||||
framesDecoded = stat.framesDecoded;
|
||||
}
|
||||
if (stat.type === "candidate-pair" && stat.state === "succeeded") {
|
||||
roundTripTime = stat.currentRoundTripTime;
|
||||
}
|
||||
});
|
||||
|
||||
const timeDiff = (timestamp - lastTimestamp) / 1000; // in seconds
|
||||
@@ -289,12 +379,10 @@ export default function WebRtcPlayer({
|
||||
setStats?.({
|
||||
streamType: "WebRTC",
|
||||
bandwidth: Math.round(bitrate),
|
||||
latency: roundTripTime,
|
||||
totalFrames: framesReceived,
|
||||
droppedFrames: framesDropped,
|
||||
droppedFrames: undefined,
|
||||
decodedFrames: framesDecoded,
|
||||
droppedFrameRate:
|
||||
framesReceived > 0 ? (framesDropped / framesReceived) * 100 : 0,
|
||||
droppedFrameRate: undefined,
|
||||
});
|
||||
|
||||
lastBytesReceived = bytesReceived;
|
||||
@@ -307,7 +395,6 @@ export default function WebRtcPlayer({
|
||||
setStats?.({
|
||||
streamType: "-",
|
||||
bandwidth: 0,
|
||||
latency: undefined,
|
||||
totalFrames: 0,
|
||||
droppedFrames: undefined,
|
||||
decodedFrames: 0,
|
||||
|
||||
@@ -8,11 +8,7 @@
|
||||
*/
|
||||
|
||||
export type DownswitchReason =
|
||||
| "stall"
|
||||
| "bandwidth"
|
||||
| "fatal-error"
|
||||
| "startup"
|
||||
| "codec";
|
||||
"stall" | "bandwidth" | "fatal-error" | "startup" | "codec";
|
||||
|
||||
// stalls just after a seek are expected on any network (the target
|
||||
// position is rarely buffered), so they get a longer budget and are
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Recording } from "@/types/record";
|
||||
import { DynamicPlayback } from "@/types/playback";
|
||||
import { PreviewController } from "../PreviewPlayer";
|
||||
import { PreviewController } from "../PreviewController";
|
||||
import { TimeRange, TrackingDetailsSequence } from "@/types/timeline";
|
||||
import {
|
||||
calculateInpointOffset,
|
||||
|
||||
@@ -16,7 +16,8 @@ import {
|
||||
RecordingCoverage,
|
||||
} from "@/types/record";
|
||||
import { Preview } from "@/types/preview";
|
||||
import PreviewPlayer, { PreviewController } from "../PreviewPlayer";
|
||||
import PreviewPlayer from "../PreviewPlayer";
|
||||
import { PreviewController } from "../PreviewController";
|
||||
import { DynamicVideoController } from "./DynamicVideoController";
|
||||
import HlsVideoPlayer, { HlsSource } from "../HlsVideoPlayer";
|
||||
import { useDetailStream } from "@/context/detail-stream-context";
|
||||
|
||||
@@ -30,11 +30,14 @@ import ActivityIndicator from "../indicators/activity-indicator";
|
||||
import useSWR from "swr";
|
||||
import { LuCheck, LuExternalLink, LuInfo, LuX } from "react-icons/lu";
|
||||
import { Link } from "react-router-dom";
|
||||
import { LiveStreamMetadata } from "@/types/live";
|
||||
import { LivePlayerMode, LiveStreamMetadata } from "@/types/live";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { useDocDomain } from "@/hooks/use-doc-domain";
|
||||
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
|
||||
import { detectCameraAudioFeatures } from "@/utils/cameraUtil";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useWebRTCAvailableForStream } from "@/hooks/use-webrtc-availability";
|
||||
import StreamTechnologySelect from "@/components/player/StreamTechnologySelect";
|
||||
|
||||
type CameraStreamingDialogProps = {
|
||||
camera: string;
|
||||
@@ -55,7 +58,11 @@ export function CameraStreamingDialog({
|
||||
setIsDialogOpen,
|
||||
onSave,
|
||||
}: CameraStreamingDialogProps) {
|
||||
const { t } = useTranslation(["components/camera", "components/dialog"]);
|
||||
const { t } = useTranslation([
|
||||
"components/camera",
|
||||
"components/dialog",
|
||||
"views/live",
|
||||
]);
|
||||
|
||||
const { getLocaleDocUrl } = useDocDomain();
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
@@ -68,6 +75,8 @@ export function CameraStreamingDialog({
|
||||
Object.entries(config?.cameras[camera]?.live?.streams || {})[0]?.[1] || "",
|
||||
);
|
||||
const [streamType, setStreamType] = useState<StreamType>("smart");
|
||||
const [playerMode, setPlayerMode] = useState<LivePlayerMode>("mse");
|
||||
const [forceLowBandwidth, setForceLowBandwidth] = useState(false);
|
||||
const [compatibilityMode, setCompatibilityMode] = useState(false);
|
||||
|
||||
// metadata
|
||||
@@ -79,13 +88,39 @@ export function CameraStreamingDialog({
|
||||
[config, streamName],
|
||||
);
|
||||
|
||||
const cameraMetadata = streamName ? streamMetadata?.[streamName] : undefined;
|
||||
// Fetch the go2rtc stream metadata directly when the parent didn't provide it
|
||||
// so codec/availability detection works regardless of caller
|
||||
const { data: fetchedMetadata } = useSWR<LiveStreamMetadata>(
|
||||
isRestreamed && streamName && !streamMetadata?.[streamName]
|
||||
? `go2rtc/streams/${streamName}`
|
||||
: null,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
revalidateIfStale: false,
|
||||
dedupingInterval: 60000,
|
||||
},
|
||||
);
|
||||
|
||||
const cameraMetadata = streamName
|
||||
? (streamMetadata?.[streamName] ?? fetchedMetadata)
|
||||
: undefined;
|
||||
|
||||
const { audioOutput: supportsAudioOutput } = useMemo(
|
||||
() => detectCameraAudioFeatures(cameraMetadata),
|
||||
[cameraMetadata],
|
||||
);
|
||||
|
||||
const { available: isWebRTCAvailable, reason: webRTCUnavailableReason } =
|
||||
useWebRTCAvailableForStream(cameraMetadata, streamName);
|
||||
|
||||
// The chosen technology resolved for the currently selected stream, WITHOUT
|
||||
// rewriting the saved choice
|
||||
const resolvedPlayerMode = useMemo<LivePlayerMode>(
|
||||
() => (playerMode === "webrtc" && !isWebRTCAvailable ? "mse" : playerMode),
|
||||
[playerMode, isWebRTCAvailable],
|
||||
);
|
||||
|
||||
// handlers
|
||||
|
||||
useEffect(() => {
|
||||
@@ -107,10 +142,15 @@ export function CameraStreamingDialog({
|
||||
|
||||
setStreamName(streamExists ? streamNameFromSettings : firstStreamEntry);
|
||||
setStreamType(cameraSettings.streamType || "smart");
|
||||
const savedPlayerMode = cameraSettings.playerMode || "mse";
|
||||
setPlayerMode(savedPlayerMode === "jsmpeg" ? "mse" : savedPlayerMode);
|
||||
setForceLowBandwidth(savedPlayerMode === "jsmpeg");
|
||||
setCompatibilityMode(cameraSettings.compatibilityMode || false);
|
||||
} else {
|
||||
setStreamName(firstStreamEntry);
|
||||
setStreamType("smart");
|
||||
setPlayerMode("mse");
|
||||
setForceLowBandwidth(false);
|
||||
setCompatibilityMode(false);
|
||||
}
|
||||
}, [groupStreamingSettings, camera, config]);
|
||||
@@ -122,6 +162,7 @@ export function CameraStreamingDialog({
|
||||
[camera]: {
|
||||
streamName,
|
||||
streamType,
|
||||
playerMode: forceLowBandwidth ? "jsmpeg" : playerMode,
|
||||
compatibilityMode,
|
||||
playAudio: groupStreamingSettings?.[camera]?.playAudio ?? false,
|
||||
volume: groupStreamingSettings?.[camera]?.volume ?? 1,
|
||||
@@ -138,6 +179,8 @@ export function CameraStreamingDialog({
|
||||
camera,
|
||||
streamName,
|
||||
streamType,
|
||||
playerMode,
|
||||
forceLowBandwidth,
|
||||
compatibilityMode,
|
||||
setIsDialogOpen,
|
||||
onSave,
|
||||
@@ -162,10 +205,15 @@ export function CameraStreamingDialog({
|
||||
|
||||
setStreamName(streamExists ? streamNameFromSettings : firstStreamEntry);
|
||||
setStreamType(cameraSettings.streamType || "smart");
|
||||
const savedPlayerMode = cameraSettings.playerMode || "mse";
|
||||
setPlayerMode(savedPlayerMode === "jsmpeg" ? "mse" : savedPlayerMode);
|
||||
setForceLowBandwidth(savedPlayerMode === "jsmpeg");
|
||||
setCompatibilityMode(cameraSettings.compatibilityMode || false);
|
||||
} else {
|
||||
setStreamName(firstStreamEntry);
|
||||
setStreamType("smart");
|
||||
setPlayerMode("mse");
|
||||
setForceLowBandwidth(false);
|
||||
setCompatibilityMode(false);
|
||||
}
|
||||
|
||||
@@ -234,7 +282,11 @@ export function CameraStreamingDialog({
|
||||
<Label htmlFor="stream" className="text-right">
|
||||
{t("group.camera.setting.stream")}
|
||||
</Label>
|
||||
<Select value={streamName} onValueChange={setStreamName}>
|
||||
<Select
|
||||
value={streamName}
|
||||
onValueChange={setStreamName}
|
||||
disabled={forceLowBandwidth}
|
||||
>
|
||||
<SelectTrigger className="">
|
||||
<SelectValue
|
||||
placeholder={t("group.camera.setting.placeholder")}
|
||||
@@ -250,46 +302,86 @@ export function CameraStreamingDialog({
|
||||
),
|
||||
)}
|
||||
</SelectContent>
|
||||
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
|
||||
{supportsAudioOutput ? (
|
||||
<>
|
||||
<LuCheck className="size-4 text-success" />
|
||||
<div>{t("group.camera.setting.audioIsAvailable")}</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LuX className="size-4 text-danger" />
|
||||
<div>{t("group.camera.setting.audioIsUnavailable")}</div>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="cursor-pointer p-0">
|
||||
<LuInfo className="size-4" />
|
||||
<span className="sr-only">
|
||||
{t("button.info", { ns: "common" })}
|
||||
</span>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80 text-xs">
|
||||
{t("group.camera.setting.audio.tips.title")}
|
||||
<div className="mt-2 flex items-center text-primary">
|
||||
<Link
|
||||
to={getLocaleDocUrl("configuration/live")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
>
|
||||
{t("readTheDocumentation", { ns: "common" })}
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!forceLowBandwidth && (
|
||||
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
|
||||
{supportsAudioOutput ? (
|
||||
<>
|
||||
<LuCheck className="size-4 text-success" />
|
||||
<div>{t("group.camera.setting.audioIsAvailable")}</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LuX className="size-4 text-danger" />
|
||||
<div>
|
||||
{t("group.camera.setting.audioIsUnavailable")}
|
||||
</div>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="cursor-pointer p-0">
|
||||
<LuInfo className="size-4" />
|
||||
<span className="sr-only">
|
||||
{t("button.info", { ns: "common" })}
|
||||
</span>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80 text-xs">
|
||||
{t("group.camera.setting.audio.tips.title")}
|
||||
<div className="mt-2 flex items-center text-primary">
|
||||
<Link
|
||||
to={getLocaleDocUrl("configuration/live")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
>
|
||||
{t("readTheDocumentation", { ns: "common" })}
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
{isRestreamed &&
|
||||
Object.entries(config?.cameras[camera].live.streams).length > 0 && (
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<Label htmlFor="streaming-technology" className="text-right">
|
||||
{t("stream.mode", { ns: "views/live" })}
|
||||
</Label>
|
||||
<StreamTechnologySelect
|
||||
value={resolvedPlayerMode}
|
||||
onValueChange={setPlayerMode}
|
||||
isWebRTCAvailable={isWebRTCAvailable}
|
||||
webRTCUnavailableReason={webRTCUnavailableReason}
|
||||
disabled={forceLowBandwidth}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("stream.technology.description", { ns: "views/live" })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{isRestreamed &&
|
||||
Object.entries(config?.cameras[camera].live.streams).length > 0 && (
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<Label htmlFor="force-low-bandwidth" className="cursor-pointer">
|
||||
{t("stream.lowBandwidth.force.label", { ns: "views/live" })}
|
||||
</Label>
|
||||
<Switch
|
||||
id="force-low-bandwidth"
|
||||
checked={forceLowBandwidth}
|
||||
onCheckedChange={setForceLowBandwidth}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("stream.lowBandwidth.force.desc", { ns: "views/live" })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<Label htmlFor="streaming-method" className="text-right">
|
||||
{t("group.camera.setting.streamMethod.label")}
|
||||
|
||||
@@ -65,7 +65,7 @@ import {
|
||||
import { buildConfigDataForPath } from "@/utils/configUtil";
|
||||
import { useConfigSchema } from "@/hooks/use-config-schema";
|
||||
import { useRestart } from "@/api/ws";
|
||||
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
|
||||
import { StatusBarMessagesContext } from "@/context/statusbar-context";
|
||||
import RestartDialog from "@/components/overlay/dialog/RestartDialog";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import SaveAllPreviewPopover from "@/components/overlay/detail/SaveAllPreviewPopover";
|
||||
|
||||
@@ -203,7 +203,8 @@ export function PolygonCanvas({
|
||||
if (stage) {
|
||||
// we add an unfilled line for adding points when finished
|
||||
const index = e.target.index - (activePolygon.isFinished ? 2 : 1);
|
||||
let pos = [e.target._lastPos!.x, e.target._lastPos!.y];
|
||||
const dragged = e.target.getAbsolutePosition();
|
||||
let pos = [dragged.x, dragged.y];
|
||||
|
||||
if (snapPoints) {
|
||||
// Snap to other polygons' edges
|
||||
|
||||
@@ -22,12 +22,12 @@ import { HiOutlineDotsVertical, HiTrash } from "react-icons/hi";
|
||||
import { isMobile } from "react-device-detect";
|
||||
import { toRGBColorString } from "@/utils/canvasUtil";
|
||||
import { Polygon, PolygonType } from "@/types/canvas";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useCallback, useContext, useMemo, useState } from "react";
|
||||
import axios from "axios";
|
||||
import { toast } from "sonner";
|
||||
import useSWR from "swr";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { removeRequiredZoneQuery, reviewQueries } from "@/utils/zoneEdutUtil";
|
||||
import { zoneReferenceUpdates } from "@/utils/zoneEdutUtil";
|
||||
import IconWrapper from "../ui/icon-wrapper";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
@@ -35,6 +35,7 @@ import ActivityIndicator from "../indicators/activity-indicator";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useMotionMaskState, useObjectMaskState, useZoneState } from "@/api/ws";
|
||||
import { getProfileColor } from "@/utils/profileColors";
|
||||
import { StatusBarMessagesContext } from "@/context/statusbar-context";
|
||||
|
||||
type PolygonItemProps = {
|
||||
polygon: Polygon;
|
||||
@@ -72,6 +73,7 @@ export default function PolygonItem({
|
||||
const { t } = useTranslation("views/settings");
|
||||
const { data: config, mutate: updateConfig } =
|
||||
useSWR<FrigateConfig>("config");
|
||||
const statusBar = useContext(StatusBarMessagesContext);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const { payload: motionMaskState, send: sendMotionMaskState } =
|
||||
useMotionMaskState(polygon.camera, polygon.name);
|
||||
@@ -137,148 +139,66 @@ export default function PolygonItem({
|
||||
setIsLoading(true);
|
||||
setLoadingPolygonIndex(index);
|
||||
|
||||
let cameraUpdate: Record<string, unknown>;
|
||||
let needsRestart = false;
|
||||
|
||||
if (polygon.type === "zone") {
|
||||
let url: string;
|
||||
const deleteSection = { zones: { [polygon.name]: null } };
|
||||
|
||||
if (editingProfile) {
|
||||
// Profile mode: just delete the profile zone
|
||||
url = `cameras.${polygon.camera}.profiles.${editingProfile}.zones.${polygon.name}`;
|
||||
cameraUpdate = { profiles: { [editingProfile]: deleteSection } };
|
||||
} else {
|
||||
// Base mode: handle review queries
|
||||
const { alertQueries, detectionQueries } = reviewQueries(
|
||||
polygon.name,
|
||||
false,
|
||||
false,
|
||||
polygon.camera,
|
||||
cameraConfig?.review.alerts.required_zones || [],
|
||||
cameraConfig?.review.detections.required_zones || [],
|
||||
);
|
||||
const genaiQueries = removeRequiredZoneQuery(
|
||||
polygon.name,
|
||||
polygon.camera,
|
||||
"objects.genai",
|
||||
cameraConfig?.objects.genai.required_zones || [],
|
||||
);
|
||||
const snapshotQueries = removeRequiredZoneQuery(
|
||||
polygon.name,
|
||||
polygon.camera,
|
||||
"snapshots",
|
||||
cameraConfig?.snapshots.required_zones || [],
|
||||
);
|
||||
const mqttQueries = removeRequiredZoneQuery(
|
||||
polygon.name,
|
||||
polygon.camera,
|
||||
"mqtt",
|
||||
cameraConfig?.mqtt.required_zones || [],
|
||||
);
|
||||
const autotrackQueries = removeRequiredZoneQuery(
|
||||
polygon.name,
|
||||
polygon.camera,
|
||||
"onvif.autotracking",
|
||||
cameraConfig?.onvif.autotracking.required_zones || [],
|
||||
);
|
||||
// Also delete from profiles that have overrides for this zone
|
||||
let profileQueries = "";
|
||||
const references = zoneReferenceUpdates(cameraConfig, polygon.name);
|
||||
// Processes other than object tracking only reload these on restart
|
||||
needsRestart = Object.keys(references).length > 0;
|
||||
cameraUpdate = { ...deleteSection, ...references };
|
||||
}
|
||||
} else {
|
||||
const deleteSection =
|
||||
polygon.type === "motion_mask"
|
||||
? { motion: { mask: { [polygon.name]: null } } }
|
||||
: !polygon.objects.length
|
||||
? { objects: { mask: { [polygon.name]: null } } }
|
||||
: {
|
||||
objects: {
|
||||
filters: {
|
||||
[polygon.objects[0]]: {
|
||||
mask: { [polygon.name]: null },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
if (editingProfile) {
|
||||
cameraUpdate = { profiles: { [editingProfile]: deleteSection } };
|
||||
} else {
|
||||
// Base mode: also delete from profiles that have overrides for this mask
|
||||
const profileDeletes: Record<string, unknown> = {};
|
||||
if (allProfileNames && cameraConfig) {
|
||||
for (const profileName of allProfileNames) {
|
||||
if (
|
||||
cameraConfig.profiles?.[profileName]?.zones?.[polygon.name] !==
|
||||
undefined
|
||||
) {
|
||||
profileQueries += `&cameras.${polygon.camera}.profiles.${profileName}.zones.${polygon.name}`;
|
||||
const profileData = cameraConfig.profiles?.[profileName];
|
||||
if (!profileData) continue;
|
||||
|
||||
const hasMask =
|
||||
polygon.type === "motion_mask"
|
||||
? profileData.motion?.mask?.[polygon.name] !== undefined
|
||||
: polygon.type === "object_mask"
|
||||
? profileData.objects?.mask?.[polygon.name] !== undefined ||
|
||||
Object.values(profileData.objects?.filters || {}).some(
|
||||
(f) => f?.mask?.[polygon.name] !== undefined,
|
||||
)
|
||||
: false;
|
||||
|
||||
if (hasMask) {
|
||||
profileDeletes[profileName] = deleteSection;
|
||||
}
|
||||
}
|
||||
}
|
||||
url = `cameras.${polygon.camera}.zones.${polygon.name}${alertQueries}${detectionQueries}${genaiQueries}${snapshotQueries}${mqttQueries}${autotrackQueries}${profileQueries}`;
|
||||
cameraUpdate =
|
||||
Object.keys(profileDeletes).length > 0
|
||||
? { ...deleteSection, profiles: profileDeletes }
|
||||
: deleteSection;
|
||||
}
|
||||
|
||||
await axios
|
||||
.put(`config/set?${url}`, {
|
||||
requires_restart: 0,
|
||||
update_topic: updateTopic,
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.status === 200) {
|
||||
toast.success(
|
||||
t("masksAndZones.form.polygonDrawing.delete.success", {
|
||||
name: polygon?.friendly_name ?? polygon?.name,
|
||||
}),
|
||||
{ position: "top-center" },
|
||||
);
|
||||
updateConfig();
|
||||
onDeleted?.();
|
||||
} else {
|
||||
toast.error(
|
||||
t("toast.save.error.title", {
|
||||
ns: "common",
|
||||
errorMessage: res.statusText,
|
||||
}),
|
||||
{ position: "top-center" },
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
const errorMessage =
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
toast.error(
|
||||
t("toast.save.error.title", { errorMessage, ns: "common" }),
|
||||
{ position: "top-center" },
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Motion masks and object masks use JSON body format
|
||||
const deleteSection =
|
||||
polygon.type === "motion_mask"
|
||||
? { motion: { mask: { [polygon.name]: null } } }
|
||||
: !polygon.objects.length
|
||||
? { objects: { mask: { [polygon.name]: null } } }
|
||||
: {
|
||||
objects: {
|
||||
filters: {
|
||||
[polygon.objects[0]]: {
|
||||
mask: { [polygon.name]: null },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
let cameraUpdate: Record<string, unknown>;
|
||||
if (editingProfile) {
|
||||
cameraUpdate = { profiles: { [editingProfile]: deleteSection } };
|
||||
} else {
|
||||
// Base mode: also delete from profiles that have overrides for this mask
|
||||
const profileDeletes: Record<string, unknown> = {};
|
||||
if (allProfileNames && cameraConfig) {
|
||||
for (const profileName of allProfileNames) {
|
||||
const profileData = cameraConfig.profiles?.[profileName];
|
||||
if (!profileData) continue;
|
||||
|
||||
const hasMask =
|
||||
polygon.type === "motion_mask"
|
||||
? profileData.motion?.mask?.[polygon.name] !== undefined
|
||||
: polygon.type === "object_mask"
|
||||
? profileData.objects?.mask?.[polygon.name] !== undefined ||
|
||||
Object.values(profileData.objects?.filters || {}).some(
|
||||
(f) => f?.mask?.[polygon.name] !== undefined,
|
||||
)
|
||||
: false;
|
||||
|
||||
if (hasMask) {
|
||||
profileDeletes[profileName] = deleteSection;
|
||||
}
|
||||
}
|
||||
}
|
||||
cameraUpdate =
|
||||
Object.keys(profileDeletes).length > 0
|
||||
? { ...deleteSection, profiles: profileDeletes }
|
||||
: deleteSection;
|
||||
}
|
||||
|
||||
const configUpdate = {
|
||||
@@ -290,17 +210,29 @@ export default function PolygonItem({
|
||||
await axios
|
||||
.put("config/set", {
|
||||
config_data: configUpdate,
|
||||
requires_restart: 0,
|
||||
requires_restart: needsRestart ? 1 : 0,
|
||||
update_topic: updateTopic,
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.status === 200) {
|
||||
toast.success(
|
||||
t("masksAndZones.form.polygonDrawing.delete.success", {
|
||||
name: polygon?.friendly_name ?? polygon?.name,
|
||||
}),
|
||||
{ position: "top-center" },
|
||||
);
|
||||
if (needsRestart) {
|
||||
statusBar?.addMessage(
|
||||
"config_restart_required",
|
||||
t("configForm.restartRequiredFooter"),
|
||||
undefined,
|
||||
"config_restart_required",
|
||||
);
|
||||
toast.success(t("toast.successRestartRequired"), {
|
||||
position: "top-center",
|
||||
});
|
||||
} else {
|
||||
toast.success(
|
||||
t("masksAndZones.form.polygonDrawing.delete.success", {
|
||||
name: polygon?.friendly_name ?? polygon?.name,
|
||||
}),
|
||||
{ position: "top-center" },
|
||||
);
|
||||
}
|
||||
updateConfig();
|
||||
onDeleted?.();
|
||||
} else {
|
||||
@@ -338,6 +270,7 @@ export default function PolygonItem({
|
||||
editingProfile,
|
||||
allProfileNames,
|
||||
onDeleted,
|
||||
statusBar,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -593,6 +526,7 @@ export default function PolygonItem({
|
||||
<TooltipTrigger asChild>
|
||||
<IconWrapper
|
||||
icon={LuPencil}
|
||||
aria-label={t("button.edit", { ns: "common" })}
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
"size-[15px] cursor-pointer",
|
||||
@@ -637,6 +571,7 @@ export default function PolygonItem({
|
||||
<TooltipTrigger asChild>
|
||||
<IconWrapper
|
||||
icon={HiTrash}
|
||||
aria-label={t("button.delete", { ns: "common" })}
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
"size-[15px] cursor-pointer",
|
||||
|
||||
@@ -11,14 +11,14 @@ import {
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import useSWR from "swr";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { ZoneFormValuesType, Polygon } from "@/types/canvas";
|
||||
import { reviewQueries } from "@/utils/zoneEdutUtil";
|
||||
import { zoneReferenceUpdates } from "@/utils/zoneEdutUtil";
|
||||
import { Switch } from "../ui/switch";
|
||||
import { Label } from "../ui/label";
|
||||
import PolygonEditControls from "./PolygonEditControls";
|
||||
@@ -35,6 +35,7 @@ import { useDocDomain } from "@/hooks/use-doc-domain";
|
||||
import { getTranslatedLabel } from "@/utils/i18n";
|
||||
import NameAndIdFields from "../input/NameAndIdFields";
|
||||
import { useZoneState } from "@/api/ws";
|
||||
import { StatusBarMessagesContext } from "@/context/statusbar-context";
|
||||
|
||||
type ZoneEditPaneProps = {
|
||||
polygons?: Polygon[];
|
||||
@@ -71,6 +72,7 @@ export default function ZoneEditPane({
|
||||
const { getLocaleDocUrl } = useDocDomain();
|
||||
const { data: config, mutate: updateConfig } =
|
||||
useSWR<FrigateConfig>("config");
|
||||
const statusBar = useContext(StatusBarMessagesContext);
|
||||
|
||||
const cameras = useMemo(() => {
|
||||
if (!config) {
|
||||
@@ -95,29 +97,8 @@ export default function ZoneEditPane({
|
||||
|
||||
const isExistingZone = !!polygon && polygon.name.length > 0;
|
||||
|
||||
const idDisabled = useMemo(() => {
|
||||
if (!isExistingZone || !polygon) {
|
||||
return false;
|
||||
}
|
||||
if (editingProfile) {
|
||||
return true;
|
||||
}
|
||||
const cam = config?.cameras[polygon.camera];
|
||||
if (!cam) {
|
||||
return false;
|
||||
}
|
||||
const inRequiredZones =
|
||||
cam.review.alerts.required_zones.includes(polygon.name) ||
|
||||
cam.review.detections.required_zones.includes(polygon.name) ||
|
||||
cam.objects.genai.required_zones.includes(polygon.name) ||
|
||||
cam.snapshots.required_zones.includes(polygon.name) ||
|
||||
cam.mqtt.required_zones.includes(polygon.name) ||
|
||||
cam.onvif.autotracking.required_zones.includes(polygon.name);
|
||||
const hasProfileOverride = Object.values(cam.profiles ?? {}).some(
|
||||
(profile) => profile?.zones && polygon.name in profile.zones,
|
||||
);
|
||||
return inRequiredZones || hasProfileOverride;
|
||||
}, [config, polygon, editingProfile, isExistingZone]);
|
||||
// A profile zone overrides a base zone by name, so its id is fixed
|
||||
const idDisabled = isExistingZone && !!editingProfile;
|
||||
|
||||
const cameraConfig = useMemo(() => {
|
||||
if (polygon?.camera && config) {
|
||||
@@ -316,7 +297,11 @@ export default function ZoneEditPane({
|
||||
return profileZone ?? cam.zones[polygon.name];
|
||||
}, [polygon, config, editingProfile]);
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
const form = useForm<
|
||||
z.input<typeof formSchema>,
|
||||
unknown,
|
||||
z.output<typeof formSchema>
|
||||
>({
|
||||
resolver: zodResolver(formSchema),
|
||||
mode: "onChange",
|
||||
defaultValues: {
|
||||
@@ -366,7 +351,7 @@ export default function ZoneEditPane({
|
||||
}, [polygon?.isFinished, form]);
|
||||
|
||||
const saveToConfig = useCallback(
|
||||
async (
|
||||
(
|
||||
{
|
||||
name: zoneName,
|
||||
friendly_name,
|
||||
@@ -387,169 +372,130 @@ export default function ZoneEditPane({
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine config path prefix based on profile mode
|
||||
const pathPrefix = editingProfile
|
||||
? `cameras.${polygon.camera}.profiles.${editingProfile}.zones.${zoneName}`
|
||||
: `cameras.${polygon.camera}.zones.${zoneName}`;
|
||||
|
||||
const oldPathPrefix = editingProfile
|
||||
? `cameras.${polygon.camera}.profiles.${editingProfile}.zones.${polygon.name}`
|
||||
: `cameras.${polygon.camera}.zones.${polygon.name}`;
|
||||
|
||||
let mutatedConfig: typeof config;
|
||||
let alertQueries = "";
|
||||
let detectionQueries = "";
|
||||
|
||||
const renamingZone = zoneName != polygon.name && polygon.name != "";
|
||||
|
||||
// config/set fails to delete a key the YAML lacks. The base zone comes
|
||||
// back with defaults filled in, so only a non-empty value proves the key
|
||||
// is written. Profile overrides hold only keys set in the YAML.
|
||||
const writtenZone = (
|
||||
renamingZone
|
||||
? undefined
|
||||
: editingProfile
|
||||
? cameraConfig?.profiles?.[editingProfile]?.zones?.[polygon.name]
|
||||
: (cameraConfig?.base_config?.zones ?? cameraConfig?.zones)?.[
|
||||
polygon.name
|
||||
]
|
||||
) as Record<string, unknown> | undefined;
|
||||
const canRemove = (key: string) => {
|
||||
const value = writtenZone?.[key];
|
||||
return value != null && !(Array.isArray(value) && value.length == 0);
|
||||
};
|
||||
|
||||
const zoneData: Record<string, unknown> = {
|
||||
coordinates: flattenPoints(
|
||||
interpolatePoints(polygon.points, scaledWidth, scaledHeight, 1, 1),
|
||||
).join(","),
|
||||
enabled,
|
||||
};
|
||||
|
||||
if (renamingZone) {
|
||||
// rename - delete old zone and replace with new
|
||||
// Only handle review queries for base config (not profiles)
|
||||
if (!editingProfile) {
|
||||
const zoneInAlerts =
|
||||
cameraConfig?.review.alerts.required_zones.includes(polygon.name) ??
|
||||
false;
|
||||
const zoneInDetections =
|
||||
cameraConfig?.review.detections.required_zones.includes(
|
||||
polygon.name,
|
||||
) ?? false;
|
||||
// The form has no filters field, so carry the old zone's over
|
||||
const baseZones = (cameraConfig?.base_config?.zones ??
|
||||
cameraConfig?.zones) as
|
||||
Record<string, { filters?: Record<string, unknown> }> | undefined;
|
||||
const filters = baseZones?.[polygon.name]?.filters;
|
||||
|
||||
const {
|
||||
alertQueries: renameAlertQueries,
|
||||
detectionQueries: renameDetectionQueries,
|
||||
} = reviewQueries(
|
||||
polygon.name,
|
||||
false,
|
||||
false,
|
||||
polygon.camera,
|
||||
cameraConfig?.review.alerts.required_zones || [],
|
||||
cameraConfig?.review.detections.required_zones || [],
|
||||
);
|
||||
|
||||
try {
|
||||
await axios.put(
|
||||
`config/set?${oldPathPrefix}${renameAlertQueries}${renameDetectionQueries}`,
|
||||
{
|
||||
requires_restart: 0,
|
||||
update_topic: `config/cameras/${polygon.camera}/zones`,
|
||||
},
|
||||
);
|
||||
|
||||
// Wait for the config to be updated
|
||||
mutatedConfig = await updateConfig();
|
||||
} catch {
|
||||
toast.error(t("toast.save.error.noMessage", { ns: "common" }), {
|
||||
position: "top-center",
|
||||
});
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// make sure new zone name is readded to review
|
||||
({ alertQueries, detectionQueries } = reviewQueries(
|
||||
zoneName,
|
||||
zoneInAlerts,
|
||||
zoneInDetections,
|
||||
polygon.camera,
|
||||
mutatedConfig?.cameras[polygon.camera]?.review.alerts
|
||||
.required_zones || [],
|
||||
mutatedConfig?.cameras[polygon.camera]?.review.detections
|
||||
.required_zones || [],
|
||||
));
|
||||
} else {
|
||||
// Profile mode: just delete the old profile zone path
|
||||
try {
|
||||
await axios.put(`config/set?${oldPathPrefix}`, {
|
||||
requires_restart: 0,
|
||||
});
|
||||
mutatedConfig = await updateConfig();
|
||||
} catch {
|
||||
toast.error(t("toast.save.error.noMessage", { ns: "common" }), {
|
||||
position: "top-center",
|
||||
});
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
if (filters && Object.keys(filters).length > 0) {
|
||||
zoneData.filters = filters;
|
||||
}
|
||||
}
|
||||
|
||||
const coordinates = flattenPoints(
|
||||
interpolatePoints(polygon.points, scaledWidth, scaledHeight, 1, 1),
|
||||
).join(",");
|
||||
|
||||
let objectQueries = objects
|
||||
.map((object) => `&${pathPrefix}.objects=${object}`)
|
||||
.join("");
|
||||
|
||||
const same_objects =
|
||||
form_objects.length == objects.length &&
|
||||
form_objects.every(function (element, index) {
|
||||
return element === objects[index];
|
||||
});
|
||||
|
||||
// deleting objects
|
||||
if (!objectQueries && !same_objects && !renamingZone) {
|
||||
objectQueries = `&${pathPrefix}.objects`;
|
||||
if (objects.length > 0) {
|
||||
zoneData.objects = objects;
|
||||
} else if (!same_objects && canRemove("objects")) {
|
||||
zoneData.objects = null;
|
||||
}
|
||||
|
||||
let inertiaQuery = "";
|
||||
if (inertia) {
|
||||
inertiaQuery = `&${pathPrefix}.inertia=${inertia}`;
|
||||
zoneData.inertia = inertia;
|
||||
}
|
||||
|
||||
let loiteringTimeQuery = "";
|
||||
if (loitering_time >= 0) {
|
||||
loiteringTimeQuery = `&${pathPrefix}.loitering_time=${loitering_time}`;
|
||||
if (typeof loitering_time === "number") {
|
||||
zoneData.loitering_time = loitering_time;
|
||||
}
|
||||
|
||||
let distancesQuery = "";
|
||||
const distances = [lineA, lineB, lineC, lineD].filter(Boolean).join(",");
|
||||
if (speedEstimation) {
|
||||
distancesQuery = `&${pathPrefix}.distances=${distances}`;
|
||||
} else {
|
||||
if (distances != "") {
|
||||
distancesQuery = `&${pathPrefix}.distances`;
|
||||
}
|
||||
zoneData.distances = distances;
|
||||
} else if (canRemove("distances")) {
|
||||
zoneData.distances = null;
|
||||
}
|
||||
|
||||
let speedThresholdQuery = "";
|
||||
if (speed_threshold >= 0 && speedEstimation) {
|
||||
speedThresholdQuery = `&${pathPrefix}.speed_threshold=${speed_threshold}`;
|
||||
} else {
|
||||
if (resolvedZoneData?.speed_threshold) {
|
||||
speedThresholdQuery = `&${pathPrefix}.speed_threshold`;
|
||||
}
|
||||
if (typeof speed_threshold === "number" && speedEstimation) {
|
||||
zoneData.speed_threshold = speed_threshold;
|
||||
} else if (canRemove("speed_threshold")) {
|
||||
zoneData.speed_threshold = null;
|
||||
}
|
||||
|
||||
let friendlyNameQuery = "";
|
||||
if (friendly_name && friendly_name !== zoneName) {
|
||||
friendlyNameQuery = `&${pathPrefix}.friendly_name=${encodeURIComponent(friendly_name)}`;
|
||||
// The name field falls back to the old id when the zone has no name
|
||||
const unnamed = !polygon.friendly_name && friendly_name === polygon.name;
|
||||
if (friendly_name && friendly_name !== zoneName && !unnamed) {
|
||||
zoneData.friendly_name = friendly_name;
|
||||
}
|
||||
|
||||
const enabledQuery = `&${pathPrefix}.enabled=${enabled ? "True" : "False"}`;
|
||||
const zones = renamingZone
|
||||
? { [polygon.name]: null, [zoneName]: zoneData }
|
||||
: { [zoneName]: zoneData };
|
||||
|
||||
const references =
|
||||
renamingZone && !editingProfile && cameraConfig
|
||||
? zoneReferenceUpdates(cameraConfig, polygon.name, zoneName)
|
||||
: {};
|
||||
// Processes other than object tracking only reload these on restart
|
||||
const needsRestart = Object.keys(references).length > 0;
|
||||
|
||||
const cameraData = editingProfile
|
||||
? { profiles: { [editingProfile]: { zones } } }
|
||||
: { zones, ...references };
|
||||
|
||||
const updateTopic = editingProfile
|
||||
? undefined
|
||||
: `config/cameras/${polygon.camera}/zones`;
|
||||
|
||||
axios
|
||||
.put(
|
||||
`config/set?${pathPrefix}.coordinates=${coordinates}${enabledQuery}${inertiaQuery}${loiteringTimeQuery}${speedThresholdQuery}${distancesQuery}${objectQueries}${friendlyNameQuery}${alertQueries}${detectionQueries}`,
|
||||
{
|
||||
requires_restart: 0,
|
||||
update_topic: updateTopic,
|
||||
},
|
||||
)
|
||||
.put("config/set", {
|
||||
config_data: { cameras: { [polygon.camera]: cameraData } },
|
||||
requires_restart: needsRestart ? 1 : 0,
|
||||
update_topic: updateTopic,
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.status === 200) {
|
||||
toast.success(
|
||||
t("masksAndZones.zones.toast.success", {
|
||||
zoneName: friendly_name || zoneName,
|
||||
}),
|
||||
{
|
||||
if (needsRestart) {
|
||||
statusBar?.addMessage(
|
||||
"config_restart_required",
|
||||
t("configForm.restartRequiredFooter"),
|
||||
undefined,
|
||||
"config_restart_required",
|
||||
);
|
||||
toast.success(t("toast.successRestartRequired"), {
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
});
|
||||
} else {
|
||||
toast.success(
|
||||
t("masksAndZones.zones.toast.success", {
|
||||
zoneName: friendly_name || zoneName,
|
||||
}),
|
||||
{
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
}
|
||||
updateConfig();
|
||||
// Only publish WS state for base config when zone has a name and
|
||||
// wasn't renamed (the hook is bound to the old name).
|
||||
@@ -597,11 +543,11 @@ export default function ZoneEditPane({
|
||||
t,
|
||||
sendZoneState,
|
||||
editingProfile,
|
||||
resolvedZoneData,
|
||||
statusBar,
|
||||
],
|
||||
);
|
||||
|
||||
function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
function onSubmit(values: z.output<typeof formSchema>) {
|
||||
if (activePolygonIndex === undefined || !values || !polygons) {
|
||||
return;
|
||||
}
|
||||
@@ -674,6 +620,7 @@ export default function ZoneEditPane({
|
||||
nameDescription={t("masksAndZones.zones.name.tips")}
|
||||
placeholderName={t("masksAndZones.zones.name.inputPlaceHolder")}
|
||||
idDisabled={idDisabled}
|
||||
autoFillId={!isExistingZone}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
|
||||
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio";
|
||||
|
||||
const AspectRatio = AspectRatioPrimitive.Root
|
||||
const AspectRatio = AspectRatioPrimitive.Root;
|
||||
|
||||
export { AspectRatio }
|
||||
export { AspectRatio };
|
||||
|
||||
@@ -24,7 +24,8 @@ const badgeVariants = cva(
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
extends
|
||||
React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
|
||||
@@ -35,7 +35,8 @@ const buttonVariants = cva(
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
extends
|
||||
React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user