Compare commits

...
Author SHA1 Message Date
dependabot[bot]andGitHub 1ecccc71d7 Merge 781dcfdf6d into d982b3a782 2026-06-21 05:03:00 -10:00
d982b3a782 perf(util): use monotonic clock and bounded deque in EventsPerSecond (#23520)
CI / AMD64 Build (push) Has been cancelled
CI / ARM Build (push) Has been cancelled
CI / Jetson Jetpack 6 (push) Has been cancelled
CI / AMD64 Extra Build (push) Has been cancelled
CI / ARM Extra Build (push) Has been cancelled
CI / Synaptics Build (push) Has been cancelled
CI / Assemble and push default build (push) Has been cancelled
* perf(util): use monotonic clock and bounded deque in EventsPerSecond

EventsPerSecond is updated on every captured frame, every detection and
every processed frame across all cameras and detectors. The previous
implementation derived timestamps from datetime.now().timestamp() (wall
clock), so an NTP or manual clock adjustment could skew the rolling-window
expiry; it also stored timestamps in a list and expired them with
del self._timestamps[0] (O(n) per removal) plus a periodic slice-copy to
cap growth.

Switch to time.monotonic() for the interval math (correct by construction
and immune to wall-clock jumps) and a collections.deque(maxlen=...) so
expiry is O(1) (popleft) and retention is bounded automatically. This
mirrors the deque-based expiry already used in video/ffmpeg.py and
watchdog.py. Observable output is unchanged.

Adds frigate/test/test_builtin.py covering rate calculation, window
expiry and the memory bound.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test: drop test_timestamps_are_memory_bounded

It only asserted that deque(maxlen=) caps length, which is stdlib behavior
rather than something this change needs to verify.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 07:38:41 -06:00
Josh HawkinsandGitHub d036061e3f cache the preview_frames directory listing so concurrent per-camera frame requests share one scan instead of each re-listing the whole directory (#23526)
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
2026-06-20 14:56:05 -05:00
Josh HawkinsandGitHub 5003ab895c add camera search, select-all/clear, and group selection to the multi-camera export dialog (#23516)
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
CI / AMD64 Extra Build (push) Blocked by required conditions
2026-06-19 15:50:19 -06:00
Josh HawkinsandGitHub 652ea2454f Miscellaneous fixes (#23513)
* display zone names consistently using friendly_name or raw id without transformation

* enforce camera-level access on go2rtc live stream websocket endpoints
2026-06-19 10:10:22 -06:00
Josh HawkinsandGitHub 37ea6b46b5 small docs tweaks (#23506) 2026-06-18 12:44:04 -06:00
Josh HawkinsandGitHub 8203e39b7f add go2rtc settings section to the save all flow (#23501)
CI / ARM Build (push) Has been cancelled
CI / Jetson Jetpack 6 (push) Has been cancelled
CI / AMD64 Extra Build (push) Has been cancelled
CI / ARM Extra Build (push) Has been cancelled
CI / Synaptics Build (push) Has been cancelled
CI / Assemble and push default build (push) Has been cancelled
CI / AMD64 Build (push) Has been cancelled
2026-06-17 08:10:23 -06:00
Josh HawkinsandGitHub 282e70d4bf Add go2rtc stream selection to camera configuration (#23496)
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
* add go2rtc stream selection to camera ffmpeg config

* i18n

* add config-schema.json to generated e2e mock data

* e2e test

* docs

* fix test
2026-06-16 16:12:39 -06:00
dependabot[bot]andGitHub 781dcfdf6d Update onnxruntime-gpu requirement in /docker/tensorrt
Updates the requirements on [onnxruntime-gpu](https://github.com/microsoft/onnxruntime) to permit the latest version.
- [Release notes](https://github.com/microsoft/onnxruntime/releases)
- [Changelog](https://github.com/microsoft/onnxruntime/blob/main/docs/ReleaseManagement.md)
- [Commits](https://github.com/microsoft/onnxruntime/commits)

---
updated-dependencies:
- dependency-name: onnxruntime-gpu
  dependency-version: 1.27.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-16 11:33:17 +00:00
25 changed files with 1415 additions and 187 deletions
+1 -1
View File
@@ -14,5 +14,5 @@ nvidia-cusparse-cu12==12.5.8.93; platform_machine == 'x86_64'
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'
onnxruntime-gpu==1.27.*; platform_machine == 'x86_64'
protobuf==3.20.3; platform_machine == 'x86_64'
+2
View File
@@ -30,6 +30,8 @@ Navigate to <NavPath path="Settings > Camera configuration > Streams (FFmpeg)" /
| ----------------- | ------------------------------------------------------------------- |
| **Camera inputs** | List of input stream definitions (paths and roles) for this camera. |
For each input you can choose its source: select **Restream (go2rtc)** to pick an existing [go2rtc stream](restream.md) from a dropdown (Frigate uses the `rtsp://127.0.0.1:8554/<stream>` path and `preset-rtsp-restream` input args for that input automatically), or **Manual input path** to type the stream URL directly.
Navigate to <NavPath path="Settings > Camera configuration > Object detection" />.
| Field | Description |
+2
View File
@@ -432,3 +432,5 @@ When your browser runs into problems playing back your camera streams, it will l
roles:
- detect
```
The same applies to your `record` stream: if its aspect ratio differs from your `detect` stream, your recordings will appear in a different shape than the live view. For consistent framing across live view and recordings, use the same aspect ratio for all of a camera's streams (the resolution can still differ).
+2 -2
View File
@@ -61,7 +61,7 @@ Configure the go2rtc stream and point the camera inputs at the local restream.
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > System > go2rtc streams" /> and add stream entries for each camera. Then navigate to <NavPath path="Settings > Camera configuration > Streams (FFmpeg)" /> for each camera and set the input paths to use the local restream URL (`rtsp://127.0.0.1:8554/<camera_name>`).
Navigate to <NavPath path="Settings > System > go2rtc streams" /> and add stream entries for each camera. Then navigate to <NavPath path="Settings > Camera configuration > Streams (FFmpeg)" /> for each camera. For each input, choose **Restream (go2rtc)** and pick the matching stream from the dropdown — Frigate uses the local restream URL (`rtsp://127.0.0.1:8554/<camera_name>`) and the `preset-rtsp-restream` input args for that input automatically. (Choose **Manual input path** instead to type a URL directly.)
</TabItem>
<TabItem value="yaml">
@@ -111,7 +111,7 @@ Two connections are made to the camera. One for the sub stream, one for the rest
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > System > go2rtc streams" /> and add stream entries for each camera and its sub stream. Then navigate to <NavPath path="Settings > Camera configuration > Streams (FFmpeg)" /> for each camera and configure separate inputs for the main and sub streams using the local restream URLs.
Navigate to <NavPath path="Settings > System > go2rtc streams" /> and add stream entries for each camera and its sub stream. Then navigate to <NavPath path="Settings > Camera configuration > Streams (FFmpeg)" /> for each camera and add separate inputs for the main and sub streams. Set each input's source to **Restream (go2rtc)** and pick the matching stream from the dropdown — Frigate uses the local restream URL and the `preset-rtsp-restream` input args for that input automatically.
</TabItem>
<TabItem value="yaml">
+19 -10
View File
@@ -10,13 +10,14 @@ A reverse proxy is typically needed if you want to set up Frigate on a custom UR
Before setting up a reverse proxy, check if any of the built-in functionality in Frigate suits your needs:
|Topic|Docs|
|-|-|
|TLS|Please see the `tls` [configuration option](../configuration/tls.md)|
|TLS|Please see the `tls` [configuration option](../configuration/tls.md)|
|Authentication|Please see the [authentication](../configuration/authentication.md) documentation|
|IPv6|[Enabling IPv6](../configuration/advanced/system.md#enabling-ipv6)
**Note about TLS**
When using a reverse proxy, the TLS session is usually terminated at the proxy, sending the internal request over plain HTTP. If this is the desired behavior, TLS must first be disabled in Frigate, or you will encounter an HTTP 400 error: "The plain HTTP request was sent to HTTPS port."
**Note about TLS**
When using a reverse proxy, the TLS session is usually terminated at the proxy, sending the internal request over plain HTTP. If this is the desired behavior, TLS must first be disabled in Frigate, or you will encounter an HTTP 400 error: "The plain HTTP request was sent to HTTPS port."
To disable TLS, set the following in your Frigate configuration:
```yml
tls:
enabled: false
@@ -24,18 +25,26 @@ tls:
:::warning
A reverse proxy can be used to secure access to an internal web server, but the user will be entirely reliant on the steps they have taken. You must ensure you are following security best practices.
This page does not attempt to outline the specific steps needed to secure your internal website.
This page does not attempt to outline the specific steps needed to secure your internal website.
Please use your own knowledge to assess and vet the reverse proxy software before you install anything on your system.
:::
## WebSocket support
Frigate relies on WebSockets for real-time communication between the browser and the backend. Features such as camera controls (enabling/disabling a camera, audio, detect, recordings, and other toggles), live stream playback, and other live-updating parts of the UI will not function correctly if WebSocket connections are not proxied.
Your reverse proxy must be configured to forward the `Upgrade` and `Connection` headers so that WebSocket connections can be established. Each proxy example below already includes the directives needed to do this, but if you are adapting your own configuration, ensure these headers are passed through.
Note that some proxies disable WebSocket support by default — for example, Nginx Proxy Manager has a "Websockets Support" toggle that must be enabled.
## Proxies
There are many solutions available to implement reverse proxies and the community is invited to help out documenting others through a contribution to this page.
* [Apache2](#apache2-reverse-proxy)
* [Nginx](#nginx-reverse-proxy)
* [Traefik](#traefik-reverse-proxy)
* [Caddy](#caddy-reverse-proxy)
- [Apache2](#apache2-reverse-proxy)
- [Nginx](#nginx-reverse-proxy)
- [Traefik](#traefik-reverse-proxy)
- [Caddy](#caddy-reverse-proxy)
## Apache2 Reverse Proxy
@@ -159,7 +168,7 @@ The settings below enabled connection upgrade, sets up logging (optional) and pr
## Traefik Reverse Proxy
This example shows how to add a `label` to the Frigate Docker compose file, enabling Traefik to automatically discover your Frigate instance.
This example shows how to add a `label` to the Frigate Docker compose file, enabling Traefik to automatically discover your Frigate instance.
Before using the example below, you must first set up Traefik with the [Docker provider](https://doc.traefik.io/traefik/providers/docker/)
```yml
@@ -203,7 +212,7 @@ This example shows Frigate running under a subdomain with logging and a tls cert
}
frigate.YOUR_DOMAIN.TLD {
reverse_proxy http://localhost:8971
reverse_proxy http://localhost:8971
import tls
import logging frigate.YOUR_DOMAIN.TLD
}
+74 -1
View File
@@ -12,6 +12,7 @@ import time
from datetime import datetime
from pathlib import Path
from typing import List, Optional
from urllib.parse import parse_qs, urlparse
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from fastapi.responses import JSONResponse, RedirectResponse
@@ -26,7 +27,11 @@ from frigate.api.defs.request.app_body import (
AppPutRoleBody,
)
from frigate.api.defs.tags import Tags
from frigate.api.media_auth import check_camera_access, deny_response_for_media_uri
from frigate.api.media_auth import (
check_camera_access,
deny_response_for_media_uri,
is_role_restricted,
)
from frigate.config import AuthConfig, NetworkingConfig, ProxyConfig
from frigate.const import CONFIG_DIR, JWT_SECRET_ENV_VAR, PASSWORD_HASH_ALGORITHM
from frigate.models import User
@@ -658,6 +663,10 @@ def auth(request: Request):
if deny_status is not None:
return Response("", status_code=deny_status)
deny_status = deny_response_for_go2rtc_stream(original_url, role, request)
if deny_status is not None:
return Response("", status_code=deny_status)
return success_response
# now apply authentication
@@ -757,6 +766,10 @@ def auth(request: Request):
if deny_status is not None:
return Response("", status_code=deny_status)
deny_status = deny_response_for_go2rtc_stream(original_url, role, request)
if deny_status is not None:
return Response("", status_code=deny_status)
return success_response
except Exception as e:
logger.error(f"Error parsing jwt: {e}")
@@ -1112,6 +1125,66 @@ def _get_stream_owner_cameras(request: Request, stream_name: str) -> set[str]:
return owner_cameras
# nginx proxies these paths straight to go2rtc with authentication-only checks
# (see auth_request.conf). Each names the desired stream via the `src` query
# param, so the camera-level check must happen here in the `/auth` subrequest —
# `require_go2rtc_stream_access` only guards the REST `/go2rtc/streams/{name}`
# endpoint, not these proxied live-stream paths.
GO2RTC_STREAM_PROXY_PATHS = frozenset(
{
"/live/mse/api/ws",
"/live/webrtc/api/ws",
"/api/go2rtc/webrtc",
}
)
def deny_response_for_go2rtc_stream(
original_url: Optional[str], role: Optional[str], request: Request
) -> Optional[int]:
"""Block role-restricted users from go2rtc live streams they cannot access.
Returns 403 when any `src` stream named in `original_url` resolves to a
camera outside the role's allow-list (or when no `src` is provided on a
stream-proxy path), otherwise None. Mirrors the resolution logic in
`require_go2rtc_stream_access` so substream names map to their owning
camera correctly.
"""
if not original_url:
return None
parsed = urlparse(original_url)
if parsed.path not in GO2RTC_STREAM_PROXY_PATHS:
return None
frigate_config = request.app.frigate_config
# admin and full-access roles (no allow-list) bypass the camera check
if not role or not is_role_restricted(role, frigate_config):
return None
sources = parse_qs(parsed.query).get("src", [])
if not sources:
# a stream-proxy request naming no stream has nothing legitimate to
# show a restricted user
return 403
allowed_cameras = set(
User.get_allowed_cameras(
role,
frigate_config.auth.roles,
set(frigate_config.cameras.keys()),
)
)
# deny if any requested source resolves outside the allow-list
for src in sources:
if not (_get_stream_owner_cameras(request, src) & allowed_cameras):
return 403
return None
async def require_go2rtc_stream_access(
stream_name: Optional[str] = None,
request: Request = None,
+36 -16
View File
@@ -1,7 +1,9 @@
"""Preview apis."""
import bisect
import logging
import os
import threading
from datetime import datetime, timedelta, timezone
import pytz
@@ -133,6 +135,32 @@ def preview_hour(
return preview_ts(camera_name, start_ts, end_ts, allowed_cameras)
# cache one sorted listing of the shared preview_frames dir
_preview_listing_lock = threading.Lock()
_preview_listing_cache: tuple[float, list[str]] = (-1.0, [])
def _get_preview_frame_listing(preview_dir: str) -> list[str]:
"""Return the sorted preview_frames listing, cached until the dir changes."""
global _preview_listing_cache
# mtime bumps when a frame is added or removed, invalidating the cache
mtime = os.stat(preview_dir).st_mtime
cached_mtime, files = _preview_listing_cache
if mtime == cached_mtime:
return files
with _preview_listing_lock:
# another thread may have refreshed the cache while we waited
cached_mtime, files = _preview_listing_cache
if mtime == cached_mtime:
return files
files = sorted(entry.name for entry in os.scandir(preview_dir))
_preview_listing_cache = (mtime, files)
return files
@router.get(
"/preview/{camera_name}/start/{start_ts}/end/{end_ts}/frames",
response_model=PreviewFramesResponse,
@@ -149,23 +177,15 @@ def get_preview_frames_from_cache(camera_name: str, start_ts: float, end_ts: flo
start_file = f"{file_start}{start_ts}.{PREVIEW_FRAME_TYPE}"
end_file = f"{file_start}{end_ts}.{PREVIEW_FRAME_TYPE}"
camera_files = [
entry.name
for entry in os.scandir(preview_dir)
if entry.name.startswith(file_start)
files = _get_preview_frame_listing(preview_dir)
# a camera's frames form a contiguous slice of the sorted listing;
# bisect locates it without scanning the whole directory
left = bisect.bisect_left(files, start_file)
right = bisect.bisect_right(files, end_file)
selected_previews = [
file for file in files[left:right] if file.startswith(file_start)
]
camera_files.sort()
selected_previews = []
for file in camera_files:
if file < start_file:
continue
if file > end_file:
break
selected_previews.append(file)
return JSONResponse(
content=selected_previews,
+41
View File
@@ -0,0 +1,41 @@
"""Tests for frigate.util.builtin helpers."""
import unittest
from unittest.mock import patch
from frigate.util.builtin import EventsPerSecond
class TestEventsPerSecond(unittest.TestCase):
def test_eps_is_zero_before_any_events(self) -> None:
eps = EventsPerSecond()
with patch("frigate.util.builtin.time.monotonic", return_value=100.0):
self.assertEqual(eps.eps(), 0.0)
def test_eps_counts_events_in_window(self) -> None:
eps = EventsPerSecond(last_n_seconds=10)
clock = [1000.0]
with patch("frigate.util.builtin.time.monotonic", side_effect=lambda: clock[0]):
eps.start()
# one event per second for five seconds
for _ in range(5):
clock[0] += 1.0
eps.update()
# five events over the five seconds since start
self.assertAlmostEqual(eps.eps(), 1.0)
def test_old_timestamps_expire_from_window(self) -> None:
eps = EventsPerSecond(last_n_seconds=10)
clock = [0.0]
with patch("frigate.util.builtin.time.monotonic", side_effect=lambda: clock[0]):
eps.start()
for _ in range(10):
clock[0] += 1.0
eps.update()
# jump well past the window so every timestamp ages out
clock[0] += 100.0
self.assertEqual(eps.eps(), 0.0)
if __name__ == "__main__":
unittest.main()
+175
View File
@@ -0,0 +1,175 @@
"""Unit tests for `deny_response_for_go2rtc_stream`.
Covers the camera-level authorization enforced in the `/auth` subrequest for
the nginx-proxied go2rtc live-stream paths (MSE/WebRTC WebSockets and the
WebRTC signaling endpoint). These paths name the stream via the `src` query
param, which the static-media auth in `media_auth` does not inspect.
"""
import types
import unittest
from frigate.api.auth import deny_response_for_go2rtc_stream
from frigate.config import FrigateConfig
_CONFIG = {
"mqtt": {"host": "mqtt"},
"auth": {
"roles": {
"limited_user": ["front_door"],
"dual_user": ["front_door", "back_door"],
}
},
"cameras": {
"front_door": {
"ffmpeg": {
"inputs": [{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}]
},
"detect": {"height": 1080, "width": 1920, "fps": 5},
# go2rtc stream name differs from the camera name (substream)
"live": {"streams": {"Main Stream": "front_door_sub"}},
},
"back_door": {
"ffmpeg": {
"inputs": [{"path": "rtsp://10.0.0.2:554/video", "roles": ["detect"]}]
},
"detect": {"height": 1080, "width": 1920, "fps": 5},
},
"garage": {
"ffmpeg": {
"inputs": [{"path": "rtsp://10.0.0.3:554/video", "roles": ["detect"]}]
},
"detect": {"height": 1080, "width": 1920, "fps": 5},
},
},
}
def _request(config: FrigateConfig) -> types.SimpleNamespace:
return types.SimpleNamespace(app=types.SimpleNamespace(frigate_config=config))
class TestDenyResponseForGo2rtcStream(unittest.TestCase):
def setUp(self) -> None:
self.config = FrigateConfig(**_CONFIG)
self.request = _request(self.config)
def _deny(self, url: str, role: str):
return deny_response_for_go2rtc_stream(url, role, self.request)
# --- non-stream paths pass through ---
def test_non_stream_path_passes_through(self):
self.assertIsNone(
self._deny("http://host/clips/back_door-1.jpg", "limited_user")
)
def test_empty_url_passes_through(self):
self.assertIsNone(self._deny("", "limited_user"))
def test_jsmpeg_path_not_handled_here(self):
# jsmpeg is authorized per-frame in the output pipeline, not here
self.assertIsNone(
self._deny("http://host/live/jsmpeg/back_door", "limited_user")
)
# --- restricted role: allowed vs forbidden cameras ---
def test_mse_allowed_camera(self):
self.assertIsNone(
self._deny("http://host/live/mse/api/ws?src=front_door", "limited_user")
)
def test_mse_forbidden_camera_denied(self):
self.assertEqual(
self._deny("http://host/live/mse/api/ws?src=back_door", "limited_user"),
403,
)
def test_webrtc_ws_forbidden_camera_denied(self):
self.assertEqual(
self._deny("http://host/live/webrtc/api/ws?src=back_door", "limited_user"),
403,
)
def test_webrtc_signaling_forbidden_camera_denied(self):
self.assertEqual(
self._deny("http://host/api/go2rtc/webrtc?src=back_door", "limited_user"),
403,
)
def test_unknown_camera_denied(self):
self.assertEqual(
self._deny("http://host/live/mse/api/ws?src=nonexistent", "limited_user"),
403,
)
def test_missing_src_denied(self):
self.assertEqual(self._deny("http://host/live/mse/api/ws", "limited_user"), 403)
# --- multi-camera role: each assigned camera allowed, others denied ---
def test_multi_camera_role_allows_first_assigned(self):
self.assertIsNone(
self._deny("http://host/live/mse/api/ws?src=front_door", "dual_user")
)
def test_multi_camera_role_allows_second_assigned(self):
self.assertIsNone(
self._deny("http://host/live/mse/api/ws?src=back_door", "dual_user")
)
def test_multi_camera_role_denies_unassigned(self):
# garage is configured but not in dual_user's allow-list
self.assertEqual(
self._deny("http://host/live/mse/api/ws?src=garage", "dual_user"),
403,
)
# --- substream names resolve to their owning camera ---
def test_allowed_substream_resolves_to_owning_camera(self):
# front_door_sub is owned by front_door, which limited_user may access
self.assertIsNone(
self._deny("http://host/live/mse/api/ws?src=front_door_sub", "limited_user")
)
# --- multiple src values: deny if any is forbidden ---
def test_multiple_src_one_forbidden_denied(self):
self.assertEqual(
self._deny(
"http://host/live/mse/api/ws?src=front_door&src=back_door",
"limited_user",
),
403,
)
def test_multiple_src_all_allowed(self):
self.assertIsNone(
self._deny(
"http://host/live/mse/api/ws?src=front_door&src=front_door_sub",
"limited_user",
)
)
# --- privileged roles bypass the check ---
def test_admin_bypasses(self):
self.assertIsNone(
self._deny("http://host/live/mse/api/ws?src=back_door", "admin")
)
def test_builtin_viewer_role_bypasses(self):
# the built-in viewer role is not in the config allow-list map, so it
# is treated as full access
self.assertIsNone(
self._deny("http://host/live/mse/api/ws?src=back_door", "viewer")
)
def test_missing_role_bypasses(self):
self.assertIsNone(self._deny("http://host/live/mse/api/ws?src=back_door", None))
if __name__ == "__main__":
unittest.main()
+7 -9
View File
@@ -2,7 +2,6 @@
import ast
import copy
import datetime
import logging
import math
import multiprocessing.queues
@@ -10,7 +9,9 @@ import queue
import re
import shlex
import struct
import time
import urllib.parse
from collections import deque
from collections.abc import Mapping
from multiprocessing.managers import ValueProxy
from pathlib import Path
@@ -32,23 +33,20 @@ class EventsPerSecond:
self._start = None
self._max_events = max_events
self._last_n_seconds = last_n_seconds
self._timestamps = []
self._timestamps: deque[float] = deque(maxlen=max_events)
def start(self) -> None:
self._start = datetime.datetime.now().timestamp()
self._start = time.monotonic()
def update(self) -> None:
now = datetime.datetime.now().timestamp()
now = time.monotonic()
if self._start is None:
self._start = now
self._timestamps.append(now)
# truncate the list when it goes 100 over the max_size
if len(self._timestamps) > self._max_events + 100:
self._timestamps = self._timestamps[(1 - self._max_events) :]
self.expire_timestamps(now)
def eps(self) -> float:
now = datetime.datetime.now().timestamp()
now = time.monotonic()
if self._start is None:
self._start = now
# compute the (approximate) events in the last n seconds
@@ -63,7 +61,7 @@ class EventsPerSecond:
def expire_timestamps(self, now: float) -> None:
threshold = now - self._last_n_seconds
while self._timestamps and self._timestamps[0] < threshold:
del self._timestamps[0]
self._timestamps.popleft()
class InferenceSpeed:
File diff suppressed because one or more lines are too long
@@ -111,6 +111,18 @@ def generate_config():
return snapshot
def generate_config_schema():
"""Generate the JSON Schema for FrigateConfig from the backend model.
This is what the app fetches from /api/config/schema.json to drive the
RJSF-based config form. Generating it here keeps the e2e fixture in sync
with the backend whenever config models change.
"""
from frigate.config import FrigateConfig
return FrigateConfig.model_json_schema()
def generate_reviews():
"""Generate ReviewSegmentResponse[] validated against Pydantic + Peewee."""
from frigate.api.defs.response.review_response import ReviewSegmentResponse
@@ -411,6 +423,7 @@ def main():
print()
write_json("config-snapshot.json", generate_config())
write_json("config-schema.json", generate_config_schema())
write_json("reviews.json", generate_reviews())
write_json("events.json", generate_events())
write_json("exports.json", generate_exports())
@@ -0,0 +1,203 @@
/**
* Camera ffmpeg streams settings tests -- MEDIUM tier.
*
* Covers the input-path source toggle: each ffmpeg input can either point at a
* go2rtc restream (picked from a dropdown, which writes the rtsp://127.0.0.1:8554
* path plus the preset-rtsp-restream input_args) or use a manually typed path.
*/
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 GO2RTC_STREAMS = {
dome_main: ["rtsp://user:pass@192.168.0.20:554/Stream1"],
dome_sub: ["rtsp://user:pass@192.168.0.20:554/Stream2"],
};
type CameraInput = {
path: string;
roles: string[];
input_args?: string;
};
async function installRoutes(page: Page, frontDoorInputs: CameraInput[]) {
const config = configFactory({
go2rtc: { streams: GO2RTC_STREAMS },
cameras: {
front_door: {
ffmpeg: { inputs: frontDoorInputs },
},
},
});
let lastSavedConfig: unknown = null;
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 } });
});
await page.route("**/api/config/raw_paths", (route) =>
route.fulfill({
json: {
cameras: { front_door: { ffmpeg: { inputs: frontDoorInputs } } },
go2rtc: { streams: GO2RTC_STREAMS },
},
}),
);
await page.route("**/api/config/set", async (route) => {
lastSavedConfig = route.request().postDataJSON();
await route.fulfill({ json: { success: true, require_restart: false } });
});
await page.route("**/api/ffmpeg/presets", (route) =>
route.fulfill({
json: {
hwaccel_args: [],
input_args: ["preset-rtsp-restream", "preset-rtsp-generic"],
output_args: { record: [], detect: [] },
},
}),
);
return { capturedConfig: () => lastSavedConfig };
}
const RESTREAM_RADIO = "Restream (go2rtc)";
const MANUAL_RADIO = "Manual input path";
test.describe("camera ffmpeg input source toggle @medium", () => {
test("manual input defaults to the manual text field", async ({
frigateApp,
}) => {
await installRoutes(frigateApp.page, [
{ path: "rtsp://10.0.0.1:554/video", roles: ["detect"] },
]);
await frigateApp.goto("/settings?page=cameraFfmpeg&camera=front_door");
await expect(
frigateApp.page.getByRole("radio", { name: MANUAL_RADIO }),
).toBeChecked();
await expect(
frigateApp.page.getByRole("textbox", { name: "Input path" }),
).toHaveValue("rtsp://10.0.0.1:554/video");
});
test("an existing restream path auto-detects into restream mode", async ({
frigateApp,
}) => {
await installRoutes(frigateApp.page, [
{
path: "rtsp://127.0.0.1:8554/dome_main",
roles: ["detect"],
input_args: "preset-rtsp-restream",
},
]);
await frigateApp.goto("/settings?page=cameraFfmpeg&camera=front_door");
await expect(
frigateApp.page.getByRole("radio", { name: RESTREAM_RADIO }),
).toBeChecked();
// The dropdown is preselected to the matching go2rtc stream.
await expect(
frigateApp.page.getByRole("combobox", { name: /go2rtc stream/i }),
).toContainText("dome_main");
});
test("selecting a restream writes the path and preset", async ({
frigateApp,
}) => {
const capture = await installRoutes(frigateApp.page, [
{ path: "rtsp://10.0.0.1:554/video", roles: ["detect"] },
]);
await frigateApp.goto("/settings?page=cameraFfmpeg&camera=front_door");
await frigateApp.page.getByRole("radio", { name: RESTREAM_RADIO }).click();
await frigateApp.page
.getByRole("combobox", { name: /go2rtc stream/i })
.click();
// The dropdown is searchable: typing narrows the list to matches only,
// with no option to enter a custom stream name.
await frigateApp.page.getByPlaceholder("Search streams...").fill("sub");
await expect(
frigateApp.page.getByRole("option", { name: "dome_main" }),
).toBeHidden();
await frigateApp.page.getByRole("option", { name: "dome_sub" }).click();
await frigateApp.page.getByRole("button", { name: "Save" }).click();
await expect
.poll(() => capture.capturedConfig(), { timeout: 5_000 })
.toMatchObject({
config_data: {
cameras: {
front_door: {
ffmpeg: {
inputs: [
{
path: "rtsp://127.0.0.1:8554/dome_sub",
input_args: "preset-rtsp-restream",
},
],
},
},
},
},
});
});
test("switching a restream back to manual reverts the preset", async ({
frigateApp,
}) => {
const capture = await installRoutes(frigateApp.page, [
{
path: "rtsp://127.0.0.1:8554/dome_main",
roles: ["detect"],
input_args: "preset-rtsp-restream",
},
]);
await frigateApp.goto("/settings?page=cameraFfmpeg&camera=front_door");
await frigateApp.page.getByRole("radio", { name: MANUAL_RADIO }).click();
// The restream path stays editable in the manual text field.
await expect(
frigateApp.page.getByRole("textbox", { name: "Input path" }),
).toHaveValue("rtsp://127.0.0.1:8554/dome_main");
await frigateApp.page.getByRole("button", { name: "Save" }).click();
await expect
.poll(() => capture.capturedConfig(), { timeout: 5_000 })
.not.toBeNull();
const payload = capture.capturedConfig() as {
config_data?: {
cameras?: {
front_door?: {
ffmpeg?: { inputs?: Array<{ input_args?: unknown }> };
};
};
};
};
const input =
payload?.config_data?.cameras?.front_door?.ffmpeg?.inputs?.[0];
expect(input?.input_args).not.toBe("preset-rtsp-restream");
});
});
@@ -70,6 +70,13 @@
"selectFromTimeline": "Select from Timeline",
"cameraSelection": "Cameras",
"cameraSelectionHelp": "Cameras with tracked objects in this time range are pre-selected",
"searchOrSelectGroup": "Search, or select a camera group...",
"selectAll": "Select all cameras",
"clearSelection": "Clear selection",
"selectWithActivity": "Cameras with tracked objects",
"selectGroup": "Select group",
"noMatchingCameras": "No cameras match your search",
"selectedCount": "{{selected}} / {{total}} selected",
"checkingActivity": "Checking camera activity...",
"noCameras": "No cameras available",
"detectionCount_one": "1 tracked object",
+11 -1
View File
@@ -1553,7 +1553,17 @@
}
},
"cameraInputs": {
"itemTitle": "Stream {{index}}"
"itemTitle": "Stream {{index}}",
"sourceMode": {
"restream": "Restream (go2rtc)",
"manual": "Manual input path",
"go2rtcStreamLabel": "go2rtc stream",
"go2rtcStreamPlaceholder": "Select a go2rtc stream",
"noGo2rtcStreams": "No go2rtc streams configured",
"go2rtcStreamSearch": "Search streams...",
"availableStreams": "Available streams",
"noMatchingStreams": "No matching streams"
}
},
"restartRequiredField": "Restart required",
"restartRequiredFooter": "Configuration changed - Restart required",
@@ -243,12 +243,7 @@ export default function CameraReviewClassification({
handleZoneToggle("alerts.required_zones", zone.name)
}
/>
<Label
className={cn(
"font-normal",
!zone.friendly_name && "smart-capitalize",
)}
>
<Label className="font-normal">
{zone.friendly_name || zone.name}
</Label>
</div>
@@ -29,11 +29,19 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { StreamSourceSelector } from "./StreamSourceSelector";
import {
buildRestreamPath,
parseRestreamStreamName,
RESTREAM_PRESET,
type StreamSourceMode,
} from "./streamSource";
type FfmpegInput = {
path?: string;
roles?: string[];
hwaccel_args?: unknown;
input_args?: unknown;
};
const asInputList = (formData: unknown): FfmpegInput[] => {
@@ -137,7 +145,30 @@ export function CameraInputsField(props: FieldProps) {
);
const SchemaField = registry.fields.SchemaField;
const go2rtcStreamNames = useMemo<string[]>(() => {
const streams = formContext?.fullConfig?.go2rtc?.streams;
if (!streams || typeof streams !== "object") {
return [];
}
return Object.keys(streams).sort();
}, [formContext?.fullConfig?.go2rtc?.streams]);
const [openByIndex, setOpenByIndex] = useState<Record<number, boolean>>({});
const [sourceModeByIndex, setSourceModeByIndex] = useState<
Record<number, StreamSourceMode>
>({});
// Detect whether an existing input path points at a known go2rtc restream so
// the source toggle can default to the right mode for existing configs.
const detectMode = useCallback(
(path: string | undefined): StreamSourceMode => {
const streamName = parseRestreamStreamName(path);
return streamName && go2rtcStreamNames.includes(streamName)
? "restream"
: "manual";
},
[go2rtcStreamNames],
);
useEffect(() => {
setOpenByIndex((previous) => {
@@ -171,6 +202,55 @@ export function CameraInputsField(props: FieldProps) {
[fieldPathId.path, inputs, onChange],
);
// Update several fields of one input in a single change so that path and
// input_args never race on a stale snapshot of inputs.
const handleFieldValuesChange = useCallback(
(index: number, partial: Record<string, unknown>) => {
const nextInputs = cloneDeep(inputs);
const item =
(nextInputs[index] as Record<string, unknown> | undefined) ??
({} as Record<string, unknown>);
Object.assign(item, partial);
nextInputs[index] = item;
onChange(normalizeNonDetectHwaccel(nextInputs), fieldPathId.path);
},
[fieldPathId.path, inputs, onChange],
);
const handleSourceModeChange = useCallback(
(index: number, nextMode: StreamSourceMode) => {
const input = inputs[index];
const currentPath =
typeof input?.path === "string" ? input.path : undefined;
if (nextMode === "manual") {
// Only revert the preset we set ourselves; never clobber custom args.
if (input?.input_args === RESTREAM_PRESET) {
handleFieldValuesChange(index, { input_args: undefined });
}
} else if (!parseRestreamStreamName(currentPath)) {
// Entering restream with a non-restream path: clear it so the dropdown
// shows its placeholder until a stream is chosen.
handleFieldValuesChange(index, { path: undefined });
}
setSourceModeByIndex((previous) => ({ ...previous, [index]: nextMode }));
},
[inputs, handleFieldValuesChange],
);
const handleSelectRestreamStream = useCallback(
(index: number, streamName: string) => {
handleFieldValuesChange(index, {
path: buildRestreamPath(streamName),
input_args: RESTREAM_PRESET,
});
},
[handleFieldValuesChange],
);
const handleAddInput = useCallback(() => {
const base = itemSchema
? (applySchemaDefaults(itemSchema) as FfmpegInput)
@@ -186,8 +266,9 @@ export function CameraInputsField(props: FieldProps) {
(_, currentIndex) => currentIndex !== index,
);
onChange(nextInputs, fieldPathId.path);
setOpenByIndex((previous) => {
const next: Record<number, boolean> = {};
const reindex = <T,>(previous: Record<number, T>): Record<number, T> => {
const next: Record<number, T> = {};
Object.entries(previous).forEach(([key, value]) => {
const current = Number(key);
if (Number.isNaN(current) || current === index) {
@@ -197,7 +278,10 @@ export function CameraInputsField(props: FieldProps) {
next[current > index ? current - 1 : current] = value;
});
return next;
});
};
setOpenByIndex(reindex);
setSourceModeByIndex(reindex);
},
[fieldPathId.path, inputs, onChange],
);
@@ -354,16 +438,32 @@ export function CameraInputsField(props: FieldProps) {
<CollapsibleContent>
<CardContent className="space-y-4 p-4 pt-0">
<div className="w-full">
{renderField(index, "path", {
extraUiSchema: {
"ui:widget": "CameraPathWidget",
"ui:options": {
size: "full",
splitLayout: false,
<StreamSourceSelector
idPrefix={`${baseId}-${index}`}
mode={sourceModeByIndex[index] ?? detectMode(input.path)}
onModeChange={(nextMode) =>
handleSourceModeChange(index, nextMode)
}
streamNames={go2rtcStreamNames}
selectedStreamName={
parseRestreamStreamName(input.path) ?? ""
}
onSelectStream={(streamName) =>
handleSelectRestreamStream(index, streamName)
}
manualField={renderField(index, "path", {
extraUiSchema: {
"ui:widget": "CameraPathWidget",
"ui:options": {
size: "full",
splitLayout: false,
},
},
},
showSchemaDescription: true,
})}
showSchemaDescription: true,
})}
disabled={disabled}
readonly={readonly}
/>
</div>
<div className="w-full">{renderField(index, "roles")}</div>
@@ -0,0 +1,217 @@
import type { ReactNode } from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import { Check, ChevronsUpDown } from "lucide-react";
import type { StreamSourceMode } from "./streamSource";
type Go2rtcStreamComboboxProps = {
id: string;
value: string;
options: string[];
disabled?: boolean;
onSelect: (streamName: string) => void;
};
// Searchable dropdown of existing go2rtc streams
function Go2rtcStreamCombobox({
id,
value,
options,
disabled,
onSelect,
}: Go2rtcStreamComboboxProps) {
const { t } = useTranslation(["views/settings", "common"]);
const [open, setOpen] = useState(false);
const [searchValue, setSearchValue] = useState("");
const commit = (next: string) => {
onSelect(next);
setSearchValue("");
setOpen(false);
};
return (
<Popover
open={open}
onOpenChange={(next) => {
setOpen(next);
if (!next) setSearchValue("");
}}
>
<PopoverTrigger asChild>
<Button
id={id}
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled}
className={cn(
"w-full justify-between font-normal sm:max-w-xs",
!value && "text-muted-foreground",
)}
>
<span className="truncate">
{value ||
t("configForm.cameraInputs.sourceMode.go2rtcStreamPlaceholder")}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[--radix-popover-trigger-width] p-0">
<Command>
<CommandInput
placeholder={t(
"configForm.cameraInputs.sourceMode.go2rtcStreamSearch",
)}
value={searchValue}
onValueChange={setSearchValue}
/>
<CommandList>
<CommandEmpty>
{t("configForm.cameraInputs.sourceMode.noMatchingStreams")}
</CommandEmpty>
<CommandGroup
heading={t("configForm.cameraInputs.sourceMode.availableStreams")}
>
{options.map((option) => (
<CommandItem
key={option}
value={option}
onSelect={() => commit(option)}
>
<Check
className={cn(
"mr-2 h-4 w-4",
value === option ? "opacity-100" : "opacity-0",
)}
/>
{option}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
type StreamSourceSelectorProps = {
idPrefix: string;
mode: StreamSourceMode;
onModeChange: (mode: StreamSourceMode) => void;
streamNames: string[];
selectedStreamName: string;
onSelectStream: (streamName: string) => void;
manualField: ReactNode;
disabled?: boolean;
readonly?: boolean;
};
export function StreamSourceSelector({
idPrefix,
mode,
onModeChange,
streamNames,
selectedStreamName,
onSelectStream,
manualField,
disabled,
readonly,
}: StreamSourceSelectorProps) {
const { t } = useTranslation(["views/settings", "common"]);
const restreamId = `${idPrefix}-source-restream`;
const manualId = `${idPrefix}-source-manual`;
const selectId = `${idPrefix}-restream-select`;
const hasStreams = streamNames.length > 0;
const isDisabled = disabled || readonly;
return (
<div className="space-y-3">
<RadioGroup
value={mode}
onValueChange={(value) => onModeChange(value as StreamSourceMode)}
className="flex flex-col gap-2 sm:flex-row sm:gap-6"
disabled={isDisabled}
>
<div className="flex items-center space-x-2">
<RadioGroupItem
value="restream"
id={restreamId}
className={
mode === "restream"
? "bg-selected from-selected/50 to-selected/90 text-selected"
: "bg-secondary from-secondary/50 to-secondary/90 text-secondary"
}
/>
<label htmlFor={restreamId} className="cursor-pointer text-sm">
{t("configForm.cameraInputs.sourceMode.restream")}
</label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem
value="manual"
id={manualId}
className={
mode === "manual"
? "bg-selected from-selected/50 to-selected/90 text-selected"
: "bg-secondary from-secondary/50 to-secondary/90 text-secondary"
}
/>
<label htmlFor={manualId} className="cursor-pointer text-sm">
{t("configForm.cameraInputs.sourceMode.manual")}
</label>
</div>
</RadioGroup>
{mode === "restream" ? (
<div className="space-y-2 pt-1">
<Label htmlFor={selectId} className="block">
{t("configForm.cameraInputs.sourceMode.go2rtcStreamLabel")}
</Label>
{hasStreams ? (
<Go2rtcStreamCombobox
id={selectId}
value={selectedStreamName}
options={streamNames}
disabled={isDisabled}
onSelect={onSelectStream}
/>
) : (
<p
className={cn(
"rounded-md border border-dashed p-3 text-sm text-muted-foreground sm:max-w-xs",
)}
>
{t("configForm.cameraInputs.sourceMode.noGo2rtcStreams")}
</p>
)}
</div>
) : (
manualField
)}
</div>
);
}
export default StreamSourceSelector;
@@ -0,0 +1,33 @@
export type StreamSourceMode = "restream" | "manual";
// The literal go2rtc restream prefix matches what the camera wizard inlines
// when it builds a restreamed input path. Only this exact host:port is treated
// as a restream so manually typed URLs (including localhost) stay manual.
export const RESTREAM_PREFIX = "rtsp://127.0.0.1:8554/";
export const RESTREAM_PRESET = "preset-rtsp-restream";
/** Build the restream input path for a given go2rtc stream name. */
export function buildRestreamPath(streamName: string): string {
return `${RESTREAM_PREFIX}${streamName}`;
}
/**
* Extract the go2rtc stream name from a restream input path.
*
* Returns the stream name when the path is a well-formed restream URL with no
* extra path segments or query, otherwise undefined.
*/
export function parseRestreamStreamName(
path: string | undefined,
): string | undefined {
if (typeof path !== "string" || !path.startsWith(RESTREAM_PREFIX)) {
return undefined;
}
const name = path.slice(RESTREAM_PREFIX.length);
if (name.length === 0 || /[/?#]/.test(name)) {
return undefined;
}
return name;
}
@@ -29,8 +29,8 @@ function getZoneDisplayName(zoneName: string, context?: FormContext): string {
}
}
}
// Fallback to cleaning up the zone name
return String(zoneName).replace(/_/g, " ");
// Fallback to the raw zone id verbatim (no friendly_name available)
return String(zoneName);
}
export function ZoneSwitchesWidget(props: WidgetProps) {
+191 -9
View File
@@ -39,6 +39,16 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
Command,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from "../ui/command";
import { IconRenderer } from "../icons/IconPicker";
import * as LuIcons from "react-icons/lu";
import { isDesktop, isMobile } from "react-device-detect";
import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
import SaveExportOverlay from "./SaveExportOverlay";
@@ -376,6 +386,9 @@ export function ExportContent({
const [newCaseName, setNewCaseName] = useState("");
const [newCaseDescription, setNewCaseDescription] = useState("");
const [isStartingBatchExport, setIsStartingBatchExport] = useState(false);
const [cameraSearch, setCameraSearch] = useState("");
const [cameraMenuOpen, setCameraMenuOpen] = useState(false);
const cameraMenuRef = useRef<HTMLDivElement>(null);
const multiRangeKey = useMemo(() => {
if (activeTab !== "multi" || !range) {
return undefined;
@@ -577,6 +590,75 @@ export function ExportContent({
);
}, []);
const availableCameraIds = useMemo(
() => cameraActivities.map((activity) => activity.camera),
[cameraActivities],
);
const activeCameraIds = useMemo(
() =>
cameraActivities
.filter((activity) => activity.hasDetections)
.map((activity) => activity.camera),
[cameraActivities],
);
const cameraGroups = useMemo(
() =>
Object.entries(config?.camera_groups ?? {})
.map(([name, group]) => ({
name,
icon: group.icon,
order: group.order,
cameras: group.cameras.filter((cameraId) =>
availableCameraIds.includes(cameraId),
),
}))
.filter((group) => group.cameras.length > 0)
.sort((a, b) => a.order - b.order),
[config?.camera_groups, availableCameraIds],
);
// Filter the rendered camera cards by the search query
const filteredCameraActivities = useMemo(() => {
const query = cameraSearch.trim().toLowerCase();
if (!query) {
return cameraActivities;
}
return cameraActivities.filter((activity) => {
const friendlyName = resolveCameraName(config, activity.camera);
return (
activity.camera.toLowerCase().includes(query) ||
friendlyName.toLowerCase().includes(query)
);
});
}, [cameraActivities, cameraSearch, config]);
// Group/all/activity selection replaces the current selection
const applyCameraSelection = useCallback((cameraIds: string[]) => {
setHasManualCameraSelection(true);
setSelectedCameraIds(cameraIds);
setCameraMenuOpen(false);
}, []);
// Close the dropdown when focus leaves the camera selection control entirely
const handleCameraInputBlur = useCallback((event: React.FocusEvent) => {
if (
cameraMenuRef.current &&
!cameraMenuRef.current.contains(event.relatedTarget as Node)
) {
setCameraMenuOpen(false);
}
}, []);
// Reset the search and dropdown when leaving the multi-camera tab
useEffect(() => {
if (activeTab !== "multi") {
setCameraSearch("");
setCameraMenuOpen(false);
}
}, [activeTab]);
const startBatchExport = useCallback(async () => {
if (isStartingBatchExport) {
return;
@@ -802,7 +884,7 @@ export function ExportContent({
{isAdmin && (
<div className="space-y-2">
<Label className="text-sm text-secondary-foreground">
<Label className="text-sm text-primary">
{t("export.case.label")}
</Label>
<Select
@@ -859,7 +941,7 @@ export function ExportContent({
)}
>
<div className="space-y-2">
<Label className="text-sm text-secondary-foreground">
<Label className="text-sm text-primary">
{t("export.multiCamera.timeRange")}
</Label>
<div className="flex items-center gap-2">
@@ -902,16 +984,109 @@ export function ExportContent({
</div>
<div className="space-y-2">
<Label className="text-sm text-secondary-foreground">
{t("export.multiCamera.cameraSelection")}
</Label>
<div className="flex items-center justify-between gap-2">
<Label className="text-sm text-primary">
{t("export.multiCamera.cameraSelection")}
</Label>
{availableCameraIds.length > 0 && (
<span className="text-xs text-muted-foreground">
{t("export.multiCamera.selectedCount", {
selected: selectedCameraCount,
total: availableCameraIds.length,
})}
</span>
)}
</div>
<div className="text-xs text-muted-foreground">
{t("export.multiCamera.cameraSelectionHelp")}
</div>
{!isEventsLoading && availableCameraIds.length > 0 && (
<div className="relative" ref={cameraMenuRef}>
<Command
shouldFilter={false}
className="overflow-visible rounded-md border bg-secondary/40"
>
<CommandInput
value={cameraSearch}
onValueChange={setCameraSearch}
onFocus={() => setCameraMenuOpen(true)}
onBlur={handleCameraInputBlur}
placeholder={t("export.multiCamera.searchOrSelectGroup")}
/>
{/* Hide the actions/groups menu while a search query is
active so it doesn't cover the filtered camera cards. */}
{cameraMenuOpen && cameraSearch.trim().length === 0 && (
<CommandList className="absolute top-full z-10 mt-1 max-h-72 w-full rounded-md border bg-background shadow-md">
<CommandGroup>
<CommandItem
value="action:select-all"
className="cursor-pointer"
onSelect={() =>
applyCameraSelection(availableCameraIds)
}
>
<span>{t("export.multiCamera.selectAll")}</span>
<span className="ml-auto text-xs text-muted-foreground">
{availableCameraIds.length}
</span>
</CommandItem>
<CommandItem
value="action:clear"
className="cursor-pointer"
onSelect={() => applyCameraSelection([])}
>
{t("export.multiCamera.clearSelection")}
</CommandItem>
<CommandItem
value="action:activity"
className="cursor-pointer"
onSelect={() => applyCameraSelection(activeCameraIds)}
>
<span>
{t("export.multiCamera.selectWithActivity")}
</span>
<span className="ml-auto text-xs text-muted-foreground">
{activeCameraIds.length}
</span>
</CommandItem>
</CommandGroup>
{cameraGroups.length > 0 && (
<>
<CommandSeparator />
<CommandGroup
heading={t("export.multiCamera.selectGroup")}
>
{cameraGroups.map((group) => (
<CommandItem
key={group.name}
value={`group:${group.name}`}
className="cursor-pointer"
onSelect={() =>
applyCameraSelection(group.cameras)
}
>
<IconRenderer
icon={LuIcons[group.icon]}
className="mr-2 size-4 text-secondary-foreground"
/>
<span className="truncate">{group.name}</span>
<span className="ml-auto text-xs text-muted-foreground">
{group.cameras.length}
</span>
</CommandItem>
))}
</CommandGroup>
</>
)}
</CommandList>
)}
</Command>
</div>
)}
<div
className={cn(
"scrollbar-container space-y-2",
isDesktop && "max-h-64 overflow-y-auto pr-1",
isDesktop && "max-h-64 overflow-y-auto p-0.5 pr-1",
)}
>
{isEventsLoading && (
@@ -924,7 +1099,14 @@ export function ExportContent({
{t("export.multiCamera.noCameras")}
</div>
)}
{cameraActivities.map((activity) => {
{!isEventsLoading &&
cameraActivities.length > 0 &&
filteredCameraActivities.length === 0 && (
<div className="px-2 py-4 text-sm text-muted-foreground">
{t("export.multiCamera.noMatchingCameras")}
</div>
)}
{filteredCameraActivities.map((activity) => {
const isSelected = selectedCameraIds.includes(activity.camera);
return (
@@ -981,7 +1163,7 @@ export function ExportContent({
</div>
<div className="space-y-2">
<Label className="text-sm text-secondary-foreground">
<Label className="text-sm text-primary">
{t("export.multiCamera.nameLabel")}
</Label>
<Input
@@ -994,7 +1176,7 @@ export function ExportContent({
{isAdmin && (
<div className="space-y-2">
<Label className="text-sm text-secondary-foreground">
<Label className="text-sm text-primary">
{t("export.case.label")}
</Label>
<Select
@@ -1197,14 +1197,7 @@ function LifecycleIconRow({
backgroundColor: `rgb(${color})`,
}}
/>
<span
className={cn(
item.data?.zones_friendly_names?.[zidx] === zone &&
"smart-capitalize",
)}
>
{item.data?.zones_friendly_names?.[zidx]}
</span>
<span>{item.data?.zones_friendly_names?.[zidx]}</span>
</Badge>
);
})}
+5 -5
View File
@@ -7,12 +7,12 @@ export function resolveZoneName(
zoneId: string,
cameraId?: string,
) {
if (!config) return String(zoneId).replace(/_/g, " ");
if (!config) return String(zoneId);
if (cameraId) {
const camera = config.cameras?.[String(cameraId)];
const zone = camera?.zones?.[zoneId];
return zone?.friendly_name || String(zoneId).replace(/_/g, " ");
return zone?.friendly_name || String(zoneId);
}
for (const camKey in config.cameras) {
@@ -21,12 +21,12 @@ export function resolveZoneName(
if (!cam?.zones) continue;
if (Object.prototype.hasOwnProperty.call(cam.zones, zoneId)) {
const zone = cam.zones[zoneId];
return zone?.friendly_name || String(zoneId).replace(/_/g, " ");
return zone?.friendly_name || String(zoneId);
}
}
// Fallback: return a cleaned-up zoneId string
return String(zoneId).replace(/_/g, " ");
// Fallback: display the raw zone id verbatim (no friendly_name available)
return String(zoneId);
}
export function useZoneFriendlyName(zoneId: string, cameraId?: string): string {
+100 -6
View File
@@ -34,6 +34,8 @@ import { isMobile } from "react-device-detect";
import { FaVideo } from "react-icons/fa";
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
import type { ConfigSectionData, JsonObject } from "@/types/configForm";
import isEqual from "lodash/isEqual";
import { maskCredentials } from "@/utils/credentialMask";
import useSWR from "swr";
import FilterSwitch from "@/components/filter/FilterSwitch";
import { ZoneMaskFilterButton } from "@/components/filter/ZoneMaskFilter";
@@ -660,6 +662,11 @@ export default function Settings() {
const isAdmin = useIsAdmin();
// for unmasked go2rtc stream sources
const { data: rawPaths } = useSWR<{
go2rtc: { streams: Record<string, string | string[]> };
}>(isAdmin ? "config/raw_paths" : null);
const visibleSettingsViews = !isAdmin
? ALLOWED_VIEWS_FOR_VIEWER
: allSettingsViews;
@@ -788,6 +795,40 @@ export default function Settings() {
},
);
// go2rtc streams aren't schema-backed, so build their preview items directly
if ("go2rtc_streams" in pendingDataBySection) {
const live =
(pendingDataBySection["go2rtc_streams"] as Record<string, string[]>) ??
{};
const saved: Record<string, string[]> = {};
for (const [name, urls] of Object.entries(
rawPaths?.go2rtc?.streams ?? {},
)) {
saved[name] = Array.isArray(urls) ? urls : [urls];
}
// Added or changed streams
for (const [name, urls] of Object.entries(live)) {
if (name in saved && isEqual(urls, saved[name])) continue;
const masked = urls.map((url) => maskCredentials(url));
items.push({
scope: "global",
fieldPath: `go2rtc.streams.${name}`,
value: masked.length === 1 ? masked[0] : masked,
});
}
// Deleted streams (present in saved config, absent from pending)
for (const name of Object.keys(saved)) {
if (name in live) continue;
items.push({
scope: "global",
fieldPath: `go2rtc.streams.${name}`,
value: "",
});
}
}
return items.sort((left, right) => {
const scopeCompare = left.scope.localeCompare(right.scope);
if (scopeCompare !== 0) return scopeCompare;
@@ -797,7 +838,13 @@ export default function Settings() {
if (cameraCompare !== 0) return cameraCompare;
return left.fieldPath.localeCompare(right.fieldPath);
});
}, [config, fullSchema, pendingDataBySection, profileFriendlyNames]);
}, [
config,
fullSchema,
pendingDataBySection,
profileFriendlyNames,
rawPaths,
]);
// Map a pendingDataKey to SettingsType menu key for clearing section status
const pendingKeyToMenuKey = useCallback(
@@ -869,10 +916,7 @@ export default function Settings() {
// after `mutate("config")` resolves
const keysToClear: string[] = [];
// `detectors` and `model` are owned by DetectorsAndModelSettingsView,
// which saves them atomically (single combined PUT with a pre-clear when
// detector keys change or the Plus/Custom tab flips). Doing the same here
// keeps Save All consistent with the page's own Save button
// `detectors` and `model` are owned by DetectorsAndModelSettingsView
const hasPendingDetectors = "detectors" in pendingDataBySection;
const hasPendingModel = "model" in pendingDataBySection;
if (hasPendingDetectors || hasPendingModel) {
@@ -975,8 +1019,58 @@ export default function Settings() {
}
}
// go2rtc streams are owned by Go2RtcStreamsSettingsView
if ("go2rtc_streams" in pendingDataBySection) {
try {
const liveStreams =
(pendingDataBySection["go2rtc_streams"] as Record<
string,
string[]
>) ?? {};
const streamsPayload: Record<string, string[] | string> = {
...liveStreams,
};
const deletedStreamNames = Object.keys(
config.go2rtc?.streams ?? {},
).filter((name) => !(name in liveStreams));
for (const deleted of deletedStreamNames) {
streamsPayload[deleted] = "";
}
await axios.put("config/set", {
requires_restart: 0,
config_data: { go2rtc: { streams: streamsPayload } },
});
// Update the running go2rtc instance to match
const go2rtcUpdates: Promise<unknown>[] = [];
for (const [streamName, urls] of Object.entries(liveStreams)) {
if (urls[0]) {
go2rtcUpdates.push(
axios.put(
`go2rtc/streams/${streamName}?src=${encodeURIComponent(urls[0])}`,
),
);
}
}
for (const deleted of deletedStreamNames) {
go2rtcUpdates.push(axios.delete(`go2rtc/streams/${deleted}`));
}
await Promise.allSettled(go2rtcUpdates);
keysToClear.push("go2rtc_streams");
savedKeys.push("go2rtc_streams");
successCount++;
} catch (error) {
// eslint-disable-next-line no-console
console.error("Save All error saving go2rtc streams", error);
failCount++;
}
}
const pendingKeys = Object.keys(pendingDataBySection).filter(
(key) => key !== "detectors" && key !== "model",
(key) =>
key !== "detectors" && key !== "model" && key !== "go2rtc_streams",
);
for (const key of pendingKeys) {
@@ -58,8 +58,13 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import SaveAllPreviewPopover, {
type SaveAllPreviewItem,
} from "@/components/overlay/detail/SaveAllPreviewPopover";
import { useDocDomain } from "@/hooks/use-doc-domain";
import { FrigateConfig } from "@/types/frigateConfig";
import type { SettingsPageProps } from "@/views/settings/SingleSectionPage";
import type { ConfigSectionData } from "@/types/configForm";
import { cn } from "@/lib/utils";
import {
isMaskedPath,
@@ -85,18 +90,8 @@ type RawPathsResponse = {
go2rtc: { streams: Record<string, string | string[]> };
};
type Go2RtcStreamsSettingsViewProps = {
setUnsavedChanges: React.Dispatch<React.SetStateAction<boolean>>;
onSectionStatusChange?: (
sectionKey: string,
level: "global" | "camera",
status: {
hasChanges: boolean;
isOverridden: boolean;
hasValidationErrors: boolean;
},
) => void;
};
const SECTION_KEY = "go2rtc_streams";
const EMPTY_PENDING: Record<string, ConfigSectionData> = {};
const STREAM_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/;
@@ -114,7 +109,11 @@ function normalizeStreams(
export default function Go2RtcStreamsSettingsView({
setUnsavedChanges,
onSectionStatusChange,
}: Go2RtcStreamsSettingsViewProps) {
pendingDataBySection,
onPendingDataChange,
isSavingAll,
onSectionSavingChange,
}: SettingsPageProps) {
const { t } = useTranslation(["views/settings", "common"]);
const { getLocaleDocUrl } = useDocDomain();
const { data: config, mutate: updateConfig } =
@@ -122,13 +121,6 @@ export default function Go2RtcStreamsSettingsView({
const { data: rawPaths, mutate: updateRawPaths } =
useSWR<RawPathsResponse>("config/raw_paths");
const [editedStreams, setEditedStreams] = useState<Record<string, string[]>>(
{},
);
const [serverStreams, setServerStreams] = useState<Record<string, string[]>>(
{},
);
const [initialized, setInitialized] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [credentialVisibility, setCredentialVisibility] = useState<
Record<string, boolean>
@@ -138,34 +130,51 @@ export default function Go2RtcStreamsSettingsView({
const [addStreamDialogOpen, setAddStreamDialogOpen] = useState(false);
const [newlyAdded, setNewlyAdded] = useState<Set<string>>(new Set());
// Initialize from config — wait for both config and rawPaths to avoid
// a mismatch when rawPaths arrives after config with different data
useEffect(() => {
if (!config || !rawPaths) return;
const childPending = pendingDataBySection ?? EMPTY_PENDING;
// Always use rawPaths for go2rtc streams — the /config endpoint masks
// credentials, so using config.go2rtc.streams would save masked values
const normalized = normalizeStreams(rawPaths.go2rtc?.streams);
// Saved/server state. Always read from rawPaths
const serverStreams = useMemo<Record<string, string[]>>(
() => normalizeStreams(rawPaths?.go2rtc?.streams),
[rawPaths],
);
setServerStreams(normalized);
if (!initialized) {
setEditedStreams(normalized);
setInitialized(true);
}
}, [config, rawPaths, initialized]);
// Pending edits live in the parent's store so they survive navigation; fall back to saved state
const liveStreams = useMemo<Record<string, string[]>>(
() =>
(childPending[SECTION_KEY] as Record<string, string[]> | undefined) ??
serverStreams,
[childPending, serverStreams],
);
// Persist edits to the parent store, clearing the entry when an edit returns
// the section to its saved state so Save All and the sidebar dot reset cleanly.
const commitStreams = useCallback(
(next: Record<string, string[]>) => {
if (isEqual(next, serverStreams)) {
onPendingDataChange?.(SECTION_KEY, undefined, null);
} else {
onPendingDataChange?.(
SECTION_KEY,
undefined,
next as ConfigSectionData,
);
}
},
[serverStreams, onPendingDataChange],
);
// Track unsaved changes
const hasChanges = useMemo(
() => initialized && !isEqual(editedStreams, serverStreams),
[editedStreams, serverStreams, initialized],
() => !isEqual(liveStreams, serverStreams),
[liveStreams, serverStreams],
);
useEffect(() => {
setUnsavedChanges(hasChanges);
setUnsavedChanges?.(hasChanges);
}, [hasChanges, setUnsavedChanges]);
const hasValidationErrors = useMemo(() => {
const names = Object.keys(editedStreams);
const names = Object.keys(liveStreams);
const seenNames = new Set<string>();
for (const name of names) {
@@ -173,13 +182,43 @@ export default function Go2RtcStreamsSettingsView({
if (seenNames.has(name)) return true;
seenNames.add(name);
const urls = editedStreams[name];
const urls = liveStreams[name];
if (!urls || urls.length === 0 || urls.every((u) => !u.trim()))
return true;
}
return false;
}, [editedStreams]);
}, [liveStreams]);
// Pending changes for this section's Save All preview popover. Diff the
// pending streams against the saved state and mask credentials for display.
const sectionPreviewItems = useMemo<SaveAllPreviewItem[]>(() => {
if (!hasChanges) return [];
const items: SaveAllPreviewItem[] = [];
// Added or changed streams
for (const [name, urls] of Object.entries(liveStreams)) {
if (name in serverStreams && isEqual(urls, serverStreams[name])) continue;
const masked = urls.map((url) => maskCredentials(url));
items.push({
scope: "global",
fieldPath: `go2rtc.streams.${name}`,
value: masked.length === 1 ? masked[0] : masked,
});
}
// Deleted streams (present in saved config, absent from pending)
for (const name of Object.keys(serverStreams)) {
if (name in liveStreams) continue;
items.push({
scope: "global",
fieldPath: `go2rtc.streams.${name}`,
value: "",
});
}
return items;
}, [hasChanges, liveStreams, serverStreams]);
// Report status to parent for sidebar red dot
useEffect(() => {
@@ -193,13 +232,14 @@ export default function Go2RtcStreamsSettingsView({
// Save handler
const saveToConfig = useCallback(async () => {
setIsLoading(true);
onSectionSavingChange?.(true);
try {
const streamsPayload: Record<string, string[] | string> = {
...editedStreams,
...liveStreams,
};
const deletedStreamNames = Object.keys(serverStreams).filter(
(name) => !(name in editedStreams),
(name) => !(name in liveStreams),
);
for (const deleted of deletedStreamNames) {
streamsPayload[deleted] = "";
@@ -212,7 +252,7 @@ export default function Go2RtcStreamsSettingsView({
// Update running go2rtc instance
const go2rtcUpdates: Promise<unknown>[] = [];
for (const [streamName, urls] of Object.entries(editedStreams)) {
for (const [streamName, urls] of Object.entries(liveStreams)) {
if (urls[0]) {
go2rtcUpdates.push(
axios.put(
@@ -233,9 +273,9 @@ export default function Go2RtcStreamsSettingsView({
}),
);
setServerStreams(editedStreams);
updateConfig();
updateRawPaths();
await updateConfig();
await updateRawPaths();
onPendingDataChange?.(SECTION_KEY, undefined, null);
} catch {
toast.error(
t("toast.error", {
@@ -245,74 +285,86 @@ export default function Go2RtcStreamsSettingsView({
);
} finally {
setIsLoading(false);
onSectionSavingChange?.(false);
}
}, [editedStreams, serverStreams, t, updateConfig, updateRawPaths]);
}, [
liveStreams,
serverStreams,
t,
updateConfig,
updateRawPaths,
onPendingDataChange,
onSectionSavingChange,
]);
// Reset handler
const onReset = useCallback(() => {
setEditedStreams(serverStreams);
onPendingDataChange?.(SECTION_KEY, undefined, null);
setCredentialVisibility({});
}, [serverStreams]);
}, [onPendingDataChange]);
// Stream CRUD operations
const addStream = useCallback((name: string) => {
setEditedStreams((prev) => ({ ...prev, [name]: [""] }));
setNewlyAdded((prev) => new Set(prev).add(name));
setAddStreamDialogOpen(false);
}, []);
const addStream = useCallback(
(name: string) => {
commitStreams({ ...liveStreams, [name]: [""] });
setNewlyAdded((prev) => new Set(prev).add(name));
setAddStreamDialogOpen(false);
},
[liveStreams, commitStreams],
);
const deleteStream = useCallback((streamName: string) => {
setEditedStreams((prev) => {
const { [streamName]: _, ...rest } = prev;
return rest;
});
setDeleteDialog(null);
}, []);
const deleteStream = useCallback(
(streamName: string) => {
const { [streamName]: _removed, ...rest } = liveStreams;
commitStreams(rest);
setDeleteDialog(null);
},
[liveStreams, commitStreams],
);
const renameStream = useCallback((oldName: string, newName: string) => {
if (oldName === newName || !newName.trim()) return;
const renameStream = useCallback(
(oldName: string, newName: string) => {
if (oldName === newName || !newName.trim()) return;
if (!(oldName in liveStreams)) return;
setEditedStreams((prev) => {
const urls = prev[oldName];
if (!urls) return prev;
const entries = Object.entries(prev);
const result: Record<string, string[]> = {};
for (const [key, value] of entries) {
if (key === oldName) {
result[newName] = value;
} else {
result[key] = value;
}
for (const [key, value] of Object.entries(liveStreams)) {
result[key === oldName ? newName : key] = value;
}
return result;
});
}, []);
commitStreams(result);
},
[liveStreams, commitStreams],
);
const updateUrl = useCallback(
(streamName: string, urlIndex: number, newUrl: string) => {
setEditedStreams((prev) => {
const urls = [...(prev[streamName] || [])];
urls[urlIndex] = newUrl;
return { ...prev, [streamName]: urls };
});
const urls = [...(liveStreams[streamName] || [])];
urls[urlIndex] = newUrl;
commitStreams({ ...liveStreams, [streamName]: urls });
},
[],
[liveStreams, commitStreams],
);
const addUrl = useCallback((streamName: string) => {
setEditedStreams((prev) => {
const urls = [...(prev[streamName] || []), ""];
return { ...prev, [streamName]: urls };
});
}, []);
const addUrl = useCallback(
(streamName: string) => {
const urls = [...(liveStreams[streamName] || []), ""];
commitStreams({ ...liveStreams, [streamName]: urls });
},
[liveStreams, commitStreams],
);
const removeUrl = useCallback((streamName: string, urlIndex: number) => {
setEditedStreams((prev) => {
const urls = (prev[streamName] || []).filter((_, i) => i !== urlIndex);
return { ...prev, [streamName]: urls.length > 0 ? urls : [""] };
});
}, []);
const removeUrl = useCallback(
(streamName: string, urlIndex: number) => {
const urls = (liveStreams[streamName] || []).filter(
(_, i) => i !== urlIndex,
);
commitStreams({
...liveStreams,
[streamName]: urls.length > 0 ? urls : [""],
});
},
[liveStreams, commitStreams],
);
const toggleCredentialVisibility = useCallback((key: string) => {
setCredentialVisibility((prev) => ({ ...prev, [key]: !prev[key] }));
@@ -320,7 +372,7 @@ export default function Go2RtcStreamsSettingsView({
if (!config) return null;
const streamEntries = Object.entries(editedStreams);
const streamEntries = Object.entries(liveStreams);
return (
<div className="flex size-full flex-col lg:pr-2">
@@ -391,6 +443,12 @@ export default function Go2RtcStreamsSettingsView({
<span className="text-sm text-unsaved">
{t("unsavedChanges")}
</span>
<SaveAllPreviewPopover
items={sectionPreviewItems}
className="h-7 w-7"
align="start"
side="top"
/>
</div>
)}
<div className="flex w-full flex-col gap-2 sm:flex-row sm:items-center md:w-auto">
@@ -398,7 +456,7 @@ export default function Go2RtcStreamsSettingsView({
<Button
onClick={onReset}
variant="outline"
disabled={isLoading}
disabled={isLoading || isSavingAll}
className="flex min-w-36 flex-1 gap-2"
>
{t("button.undo", { ns: "common" })}
@@ -407,7 +465,9 @@ export default function Go2RtcStreamsSettingsView({
<Button
onClick={saveToConfig}
variant="select"
disabled={!hasChanges || isLoading || hasValidationErrors}
disabled={
!hasChanges || isLoading || isSavingAll || hasValidationErrors
}
className="flex min-w-36 flex-1 gap-2"
>
{isLoading ? (
@@ -459,7 +519,7 @@ export default function Go2RtcStreamsSettingsView({
<RenameStreamDialog
open={renameDialog !== null}
streamName={renameDialog ?? ""}
allStreamNames={Object.keys(editedStreams)}
allStreamNames={Object.keys(liveStreams)}
onRename={(oldName, newName) => {
renameStream(oldName, newName);
setRenameDialog(null);
@@ -469,7 +529,7 @@ export default function Go2RtcStreamsSettingsView({
<AddStreamDialog
open={addStreamDialogOpen}
allStreamNames={Object.keys(editedStreams)}
allStreamNames={Object.keys(liveStreams)}
onAdd={addStream}
onClose={() => setAddStreamDialogOpen(false)}
/>