Compare commits

..
Author SHA1 Message Date
dependabot[bot]andGitHub 6c3b529664 Bump python-multipart from 0.0.26 to 0.0.31 in /docker/main
Bumps [python-multipart](https://github.com/Kludex/python-multipart) from 0.0.26 to 0.0.31.
- [Release notes](https://github.com/Kludex/python-multipart/releases)
- [Changelog](https://github.com/Kludex/python-multipart/blob/main/CHANGELOG.md)
- [Commits](https://github.com/Kludex/python-multipart/compare/0.0.26...0.0.31)

---
updated-dependencies:
- dependency-name: python-multipart
  dependency-version: 0.0.31
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-16 21:47:05 +00:00
35 changed files with 164 additions and 2299 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ joserfc == 1.2.*
cryptography == 44.0.*
pathvalidate == 3.3.*
markupsafe == 3.0.*
python-multipart == 0.0.26
python-multipart == 0.0.31
# Classification Model Training
tensorflow == 2.19.* ; platform_machine == 'aarch64'
tensorflow-cpu == 2.19.* ; platform_machine == 'x86_64'
-2
View File
@@ -30,8 +30,6 @@ 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,5 +432,3 @@ 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).
@@ -198,46 +198,6 @@ When the skip threshold is exceeded, **no motion is reported** for that frame, m
:::
## Using Camera-Side ONVIF Motion Detection
For cameras that publish their own ONVIF cell-motion analytics (e.g. OpenIPC firmware for HiSilicon, Ingenic and SigmaStar SoCs, plus most ONVIF Profile-M devices from Hikvision, Reolink, Foscam, Amcrest, etc.), Frigate can use the camera's hardware motion engine instead of running per-frame analysis on the host CPU. This both removes CPU load from the Frigate machine and gives a more accurate motion signal than encoded-stream analysis can produce.
Frigate consumes the two standard ONVIF transports:
- **PullPoint** event subscription on `tns1:RuleEngine/CellMotionDetector/Motion` carries the binary on/off state (the legacy `tns1:VideoSource/MotionAlarm` payload is also accepted).
- **RTSP analytics metadata stream** (the `application/vnd.onvif.metadata` track on the primary RTSP profile) carries the per-frame cell grid (`tt:MotionInCells`) which Frigate decodes (base64 + PackBits) and maps through the `CellLayout` transformation into Frigate's detect-frame pixel coordinates.
```yaml
cameras:
back_door:
onvif:
host: 10.0.0.10
port: 80
user: root
password: "secret"
events:
# Subscribe to camera-side motion events.
enabled: true
# Seconds before the PullPoint subscription expires (we renew at half this).
subscription_timeout: 60
# Open the RTSP analytics metadata stream for per-cell motion coordinates.
# Disable if your camera only publishes the binary event topic.
use_metadata_stream: true
motion:
# Use the camera's ONVIF events as Frigate's motion signal. The internal
# CPU motion detector is skipped.
source: onvif
detect:
enabled: true
```
When `motion.source: onvif`:
- Frigate's internal `ImprovedMotionDetector` is **not** run on the camera's frames.
- Object detection still runs every detection frame; motion boxes are used for region clustering exactly as with the internal detector.
- If `use_metadata_stream: true` but the camera doesn't advertise the metadata track (or PackBits decoding fails for a frame), Frigate falls back to a full-frame motion box while the binary event signal is active.
- The validator requires `onvif.events.enabled: true` whenever `motion.source: onvif`.
## Reviewing Detected Motion
To review what the detector picked up — or to search past recordings for motion in a specific region — see [Reviewing Motion](/usage/review#reviewing-motion) on the Review page.
+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. 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.)
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>`).
</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 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.
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.
</TabItem>
<TabItem value="yaml">
+10 -19
View File
@@ -10,14 +10,13 @@ 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
@@ -25,26 +24,18 @@ 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
@@ -168,7 +159,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
@@ -212,7 +203,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
}
+1 -74
View File
@@ -12,7 +12,6 @@ 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
@@ -27,11 +26,7 @@ from frigate.api.defs.request.app_body import (
AppPutRoleBody,
)
from frigate.api.defs.tags import Tags
from frigate.api.media_auth import (
check_camera_access,
deny_response_for_media_uri,
is_role_restricted,
)
from frigate.api.media_auth import check_camera_access, deny_response_for_media_uri
from frigate.config import AuthConfig, NetworkingConfig, ProxyConfig
from frigate.const import CONFIG_DIR, JWT_SECRET_ENV_VAR, PASSWORD_HASH_ALGORITHM
from frigate.models import User
@@ -663,10 +658,6 @@ 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
@@ -766,10 +757,6 @@ 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}")
@@ -1125,66 +1112,6 @@ 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,
+1 -3
View File
@@ -309,9 +309,7 @@ class FrigateApp:
self.detection_proxy = DetectorProxy()
def init_onvif(self) -> None:
self.onvif_controller = OnvifController(
self.config, self.ptz_metrics, self.camera_metrics
)
self.onvif_controller = OnvifController(self.config, self.ptz_metrics)
def init_dispatcher(self) -> None:
comms: list[Communicator] = []
+1 -12
View File
@@ -1,6 +1,6 @@
import multiprocessing as mp
import queue
from multiprocessing.managers import ListProxy, SyncManager, ValueProxy
from multiprocessing.managers import SyncManager, ValueProxy
from multiprocessing.sharedctypes import Synchronized
from multiprocessing.synchronize import Event
@@ -23,14 +23,6 @@ class CameraMetrics:
reconnects_last_hour: ValueProxy[int]
stalls_last_hour: ValueProxy[int]
# External motion published by OnvifController when motion.source=onvif.
# external_motion_active mirrors the PullPoint IsMotion state.
# external_motion_boxes carries the per-frame cell-derived rectangles in
# detect-frame pixel coordinates; empty list means no current spatial
# data (consumer should fall back to a full-frame box when active=1).
external_motion_active: ValueProxy[int]
external_motion_boxes: ListProxy
def __init__(self, manager: SyncManager):
self.camera_fps = manager.Value("d", 0)
self.detection_fps = manager.Value("d", 0)
@@ -49,9 +41,6 @@ class CameraMetrics:
self.reconnects_last_hour = manager.Value("i", 0)
self.stalls_last_hour = manager.Value("i", 0)
self.external_motion_active = manager.Value("b", 0)
self.external_motion_boxes = manager.list()
class PTZMetrics:
autotracker_enabled: Synchronized
+1 -12
View File
@@ -1,4 +1,3 @@
from enum import Enum
from typing import Any, Optional
from pydantic import Field, field_serializer
@@ -6,12 +5,7 @@ from pydantic import Field, field_serializer
from ..base import FrigateBaseModel
from .mask import MotionMaskConfig
__all__ = ["MotionConfig", "MotionSourceEnum"]
class MotionSourceEnum(str, Enum):
internal = "internal"
onvif = "onvif"
__all__ = ["MotionConfig"]
class MotionConfig(FrigateBaseModel):
@@ -20,11 +14,6 @@ class MotionConfig(FrigateBaseModel):
title="Enable motion detection",
description="Enable or disable motion detection for all cameras; can be overridden per-camera.",
)
source: MotionSourceEnum = Field(
default=MotionSourceEnum.internal,
title="Motion source",
description="Where motion state comes from: Frigate's internal frame analyser, or the camera's ONVIF cell-motion events (requires onvif.events.enabled).",
)
threshold: int = Field(
default=30,
title="Motion threshold",
+1 -31
View File
@@ -7,12 +7,7 @@ from ..base import FrigateBaseModel
from ..env import EnvString
from .objects import DEFAULT_TRACKED_OBJECTS
__all__ = [
"OnvifConfig",
"OnvifEventsConfig",
"PtzAutotrackConfig",
"ZoomingModeEnum",
]
__all__ = ["OnvifConfig", "PtzAutotrackConfig", "ZoomingModeEnum"]
class ZoomingModeEnum(str, Enum):
@@ -96,26 +91,6 @@ class PtzAutotrackConfig(FrigateBaseModel):
return weights
class OnvifEventsConfig(FrigateBaseModel):
enabled: bool = Field(
default=False,
title="Enable ONVIF events",
description="Subscribe to the camera's ONVIF cell-motion notifications and use them as Frigate's motion signal.",
)
subscription_timeout: int = Field(
default=60,
ge=10,
le=600,
title="Subscription timeout",
description="Seconds before the PullPoint subscription expires and is renewed.",
)
use_metadata_stream: bool = Field(
default=True,
title="Use metadata stream",
description="Open the ONVIF analytics RTSP metadata stream to receive per-cell motion coordinates. Falls back to a full-frame box when disabled or when the camera does not advertise the track.",
)
class OnvifConfig(FrigateBaseModel):
host: EnvString = Field(
default="",
@@ -152,11 +127,6 @@ class OnvifConfig(FrigateBaseModel):
title="Autotracking",
description="Automatically track moving objects and keep them centered in the frame using PTZ camera movements.",
)
events: OnvifEventsConfig = Field(
default_factory=OnvifEventsConfig,
title="ONVIF events",
description="Consume camera-side ONVIF motion notifications instead of Frigate's CPU motion detector.",
)
ignore_time_mismatch: bool = Field(
default=False,
title="Ignore time mismatch",
+2 -11
View File
@@ -47,7 +47,7 @@ from .camera.detect import DetectConfig
from .camera.ffmpeg import FfmpegConfig
from .camera.genai import GenAIConfig, GenAIRoleEnum
from .camera.mask import ObjectMaskConfig
from .camera.motion import MotionConfig, MotionSourceEnum
from .camera.motion import MotionConfig
from .camera.notification import NotificationConfig
from .camera.objects import FilterConfig, ObjectConfig
from .camera.record import RecordConfig
@@ -380,19 +380,10 @@ def verify_autotrack_zones(camera_config: CameraConfig) -> ValueError | None:
def verify_motion_and_detect(camera_config: CameraConfig) -> ValueError | None:
"""Verify that motion detection is not disabled and object detection is enabled."""
motion_via_onvif = camera_config.motion.source == MotionSourceEnum.onvif
if (
camera_config.detect.enabled
and not camera_config.motion.enabled
and not motion_via_onvif
):
if camera_config.detect.enabled and not camera_config.motion.enabled:
raise ValueError(
f"Camera {camera_config.name} has motion detection disabled and object detection enabled but object detection requires motion detection."
)
if motion_via_onvif and not camera_config.onvif.events.enabled:
raise ValueError(
f"Camera {camera_config.name} has motion.source=onvif but onvif.events.enabled is false; enable ONVIF events to use them as the motion source."
)
def verify_objects_track(
+3 -216
View File
@@ -13,39 +13,17 @@ import numpy
from onvif import ONVIFCamera, ONVIFError, ONVIFService
from zeep.exceptions import Fault, TransportError
from frigate.camera import CameraMetrics, PTZMetrics
from frigate.camera import PTZMetrics
from frigate.config import FrigateConfig, ZoomingModeEnum
from frigate.config.camera.updater import (
CameraConfigUpdateEnum,
CameraConfigUpdateSubscriber,
)
from frigate.ptz.onvif_events import run_pullpoint_subscription
from frigate.ptz.onvif_metadata import run_metadata_stream
from frigate.util.builtin import find_by_key
logger = logging.getLogger(__name__)
def _inject_rtsp_credentials(url: str, user: str | None, password: str | None) -> str:
"""Insert user:password into an rtsp:// URL if not already present.
The ONVIF GetStreamUri response typically returns an rtsp URL without
credentials, but downstream consumers (ffmpeg, RTSP libs) need them in
the URL because the camera challenges Basic/Digest on DESCRIBE.
"""
if not user or not password:
return url
if "@" in url.split("://", 1)[-1].split("/", 1)[0]:
# URL already has user:pass — don't touch it.
return url
if "://" not in url:
return url
scheme, rest = url.split("://", 1)
from urllib.parse import quote
return f"{scheme}://{quote(user, safe='')}:{quote(password, safe='')}@{rest}"
class OnvifCommandEnum(str, Enum):
"""Holds all possible move commands"""
@@ -67,10 +45,7 @@ class OnvifController:
ptz_metrics: dict[str, PTZMetrics]
def __init__(
self,
config: FrigateConfig,
ptz_metrics: dict[str, PTZMetrics],
camera_metrics: dict[str, CameraMetrics] | None = None,
self, config: FrigateConfig, ptz_metrics: dict[str, PTZMetrics]
) -> None:
self.cams: dict[str, dict] = {}
self.failed_cams: dict[str, dict] = {}
@@ -78,7 +53,6 @@ class OnvifController:
self.reset_timeout = 900 # 15 minutes
self.config = config
self.ptz_metrics = ptz_metrics
self.camera_metrics = camera_metrics or {}
self.status_locks: dict[str, asyncio.Lock] = {}
@@ -133,28 +107,7 @@ class OnvifController:
async def _close_camera(self, cam_name: str) -> None:
"""Close the ONVIF client session for a camera."""
cam_state = self.cams.get(cam_name)
if not cam_state:
return
# Stop any long-running event-consumption tasks first so they release
# any resources held against the ONVIFCamera session before we close it.
for key in ("pullpoint", "metadata"):
handle = cam_state.get(key)
if not handle:
continue
task, stop_event = handle
try:
stop_event.set()
except Exception:
pass
task.cancel()
try:
await asyncio.wait_for(task, timeout=5.0)
except (asyncio.CancelledError, asyncio.TimeoutError):
pass
except Exception:
logger.debug(f"Error awaiting {key} task for {cam_name}")
cam_state.pop(key, None)
if "onvif" in cam_state:
if cam_state and "onvif" in cam_state:
try:
await cam_state["onvif"].close()
except Exception:
@@ -234,172 +187,6 @@ class OnvifController:
logger.error(f"Onvif connection failed for {camera_name}: {e}")
return False
# Events init runs first, independent of PTZ capability. Many ONVIF
# cameras don't expose PTZ and would otherwise be skipped at the
# get_definition("ptz") check below.
await self._init_onvif_events(camera_name)
return await self._init_onvif_ptz(camera_name)
async def _init_onvif_events(self, camera_name: str) -> None:
"""Subscribe to PullPoint motion events and optionally open the
analytics metadata stream. Failure here is non-fatal — PTZ init still
proceeds and the camera continues to work without external motion."""
cam_cfg = self.config.cameras[camera_name]
if not cam_cfg.onvif.events.enabled:
return
cm = self.camera_metrics.get(camera_name)
if cm is None:
logger.warning(
f"ONVIF events enabled for {camera_name} but no CameraMetrics "
"available; external motion will not be published"
)
return
onvif: ONVIFCamera = self.cams[camera_name]["onvif"]
cell_layout = await self._discover_cell_layout(onvif, camera_name)
self.cams[camera_name]["cell_layout"] = cell_layout
def on_state(active: bool) -> None:
cm.external_motion_active.value = 1 if active else 0
if not active:
# Drop spatial data when motion ends — keep the consumer's
# snapshot consistent with the binary state.
try:
cm.external_motion_boxes[:] = []
except Exception:
pass
pp_stop = asyncio.Event()
pp_task = asyncio.create_task(
run_pullpoint_subscription(
onvif,
camera_name,
cam_cfg.onvif.events.subscription_timeout,
on_state,
pp_stop,
)
)
self.cams[camera_name]["pullpoint"] = (pp_task, pp_stop)
logger.info(f"ONVIF events: PullPoint subscriber started for {camera_name}")
if not cam_cfg.onvif.events.use_metadata_stream or cell_layout is None:
return
rtsp_url = await self._discover_primary_rtsp_url(onvif, camera_name)
if not rtsp_url:
logger.warning(
f"ONVIF events for {camera_name}: no primary RTSP URL "
"available; skipping metadata stream"
)
return
rtsp_url = _inject_rtsp_credentials(
rtsp_url, cam_cfg.onvif.user, cam_cfg.onvif.password
)
detect_size = (cam_cfg.detect.width, cam_cfg.detect.height)
def on_boxes(boxes: list[tuple[int, int, int, int]]) -> None:
try:
cm.external_motion_boxes[:] = boxes
except Exception:
logger.debug(f"Failed to publish boxes for {camera_name}")
md_stop = asyncio.Event()
md_task = asyncio.create_task(
run_metadata_stream(
rtsp_url,
camera_name,
cell_layout,
detect_size,
on_boxes,
md_stop,
)
)
self.cams[camera_name]["metadata"] = (md_task, md_stop)
logger.info(f"ONVIF events: metadata stream consumer started for {camera_name}")
async def _discover_cell_layout(
self, onvif: ONVIFCamera, camera_name: str
) -> tuple[int, int, tuple[float, float], tuple[float, float]] | None:
"""Query AnalyticsService.GetAnalyticsModules and extract the
CellMotionEngine's CellLayout (Columns, Rows, Translate, Scale).
Returns None on failure — caller should fall back to a full-frame box."""
try:
analytics = await onvif.create_analytics_service()
modules = await analytics.GetAnalyticsModules(
{"ConfigurationToken": "VA_CFG_000"}
)
except Exception as e:
logger.debug(f"ONVIF analytics service unavailable for {camera_name}: {e}")
return None
try:
for mod in modules or []:
mod_type = getattr(mod, "Type", None) or getattr(mod, "_attr_1", None)
if mod_type and "CellMotionEngine" not in str(mod_type):
continue
element_items = getattr(mod.Parameters, "ElementItem", None) or []
for item in element_items:
if item.Name != "Layout":
continue
raw = item._value_1
if raw is None or not hasattr(raw, "attrib"):
continue
cols = int(raw.attrib.get("Columns", 0))
rows = int(raw.attrib.get("Rows", 0))
if cols <= 0 or rows <= 0:
continue
tx = ty = 0.0
sx = sy = 0.0
for child in raw.iter():
if child.tag.endswith("}Translate"):
tx = float(child.attrib.get("x", 0))
ty = float(child.attrib.get("y", 0))
elif child.tag.endswith("}Scale"):
sx = float(child.attrib.get("x", 0))
sy = float(child.attrib.get("y", 0))
logger.info(
f"ONVIF cell layout for {camera_name}: {cols}x{rows} "
f"translate=({tx},{ty}) scale=({sx},{sy})"
)
return (cols, rows, (tx, ty), (sx, sy))
except Exception as e:
logger.debug(
f"Failed parsing CellMotionEngine layout for {camera_name}: {e}"
)
return None
async def _discover_primary_rtsp_url(
self, onvif: ONVIFCamera, camera_name: str
) -> str | None:
"""Return the RTSP URL for the primary profile. The ONVIF analytics
metadata track is typically bound to the primary media profile only
(sub-streams may omit it)."""
try:
media = await onvif.create_media_service()
profiles = await media.GetProfiles()
if not profiles:
return None
uri = await media.GetStreamUri(
{
"StreamSetup": {
"Stream": "RTP-Unicast",
"Transport": {"Protocol": "RTSP"},
},
"ProfileToken": profiles[0].token,
}
)
return uri.Uri
except Exception as e:
logger.debug(f"GetStreamUri failed for {camera_name}: {e}")
return None
async def _init_onvif_ptz(self, camera_name: str) -> bool:
onvif: ONVIFCamera = self.cams[camera_name]["onvif"]
# create init services
media: ONVIFService = await onvif.create_media_service()
logger.debug(f"Onvif media xaddr for {camera_name}: {media.xaddr}")
-138
View File
@@ -1,138 +0,0 @@
"""ONVIF PullPoint subscriber for camera-side motion events.
Long-running per-camera coroutine that subscribes to the camera's PullPoint
service via `onvif-zeep-async`'s `PullPointManager` (which owns subscription
creation, renewal, and lifecycle), pulls notification messages, parses
IsMotion/State on each round-trip, and invokes a callback on transitions.
Lives on the OnvifController's dedicated asyncio loop (see
`frigate/ptz/onvif.py` for the loop setup).
"""
from __future__ import annotations
import asyncio
import datetime as dt
import logging
from typing import TYPE_CHECKING, Awaitable, Callable
if TYPE_CHECKING:
from onvif import ONVIFCamera
try:
from zeep.exceptions import Fault
except ImportError: # tests can run without zeep installed
class Fault(Exception): # type: ignore[no-redef]
pass
logger = logging.getLogger(__name__)
# Names of the boolean state SimpleItem we accept inside the message Data
# block. Spec calls it IsMotion; the legacy MotionAlarm topic uses State.
_STATE_NAMES = ("IsMotion", "State")
# Bounds on backoff between subscription failures.
_BACKOFF_INITIAL_S = 1.0
_BACKOFF_MAX_S = 60.0
def _parse_motion_state(msg) -> bool | None:
"""Walk a NotificationMessage and return the IsMotion/State value, or
None if not present. The Message body is often an `lxml.etree._Element`
that python-onvif-zeep returns for ##any wildcards — walk via .iter()."""
body = getattr(msg, "Message", None)
if body is None:
return None
raw = getattr(body, "_value_1", body)
if not hasattr(raw, "iter"):
return None
for el in raw.iter():
if not el.tag.endswith("}SimpleItem"):
continue
name = el.attrib.get("Name", "")
if name not in _STATE_NAMES:
continue
val = el.attrib.get("Value", "").strip().lower()
if val in ("true", "1"):
return True
if val in ("false", "0"):
return False
return None
async def run_pullpoint_subscription(
onvif_cam: "ONVIFCamera",
cam_name: str,
timeout_seconds: int,
on_state: Callable[[bool], None] | Callable[[bool], Awaitable[None]],
stop_event: asyncio.Event,
) -> None:
"""Loop until stop_event: create a PullPointManager, pull messages,
dispatch on_state on transitions, reconnect on Fault with exponential
backoff."""
backoff = _BACKOFF_INITIAL_S
last_state: bool | None = None
while not stop_event.is_set():
manager = None
sub_lost = asyncio.Event()
def _subscription_lost() -> None:
sub_lost.set()
try:
manager = await onvif_cam.create_pullpoint_manager(
dt.timedelta(seconds=timeout_seconds),
_subscription_lost,
)
service = manager.get_service()
logger.info(f"ONVIF PullPoint subscribed for {cam_name}")
while not stop_event.is_set() and not sub_lost.is_set():
# Long-poll up to 10s. The subscription manager keeps the
# subscription itself alive in the background — we just pull.
msgs = await service.PullMessages(
{"Timeout": "PT10S", "MessageLimit": 32}
)
for m in msgs.NotificationMessage or []:
state = _parse_motion_state(m)
if state is None or state == last_state:
continue
last_state = state
try:
result = on_state(state)
if asyncio.iscoroutine(result):
await result
except Exception:
logger.exception(f"on_state callback error for {cam_name}")
if sub_lost.is_set():
raise Fault("PullPoint subscription lost")
# Clean exit (stop_event set) — leave the loop.
backoff = _BACKOFF_INITIAL_S
break
except asyncio.CancelledError:
raise
except Exception as e:
logger.warning(
f"ONVIF PullPoint subscription error for {cam_name}: {e!r}; "
f"reconnecting in {backoff:.1f}s"
)
finally:
if manager is not None:
try:
await manager.shutdown()
except Exception:
pass
if stop_event.is_set():
return
try:
await asyncio.wait_for(stop_event.wait(), timeout=backoff)
return
except asyncio.TimeoutError:
pass
backoff = min(backoff * 2, _BACKOFF_MAX_S)
-357
View File
@@ -1,357 +0,0 @@
"""ONVIF analytics metadata stream consumer.
Per-camera asyncio task that opens an RTSP connection to the camera's
primary profile, extracts the `application/vnd.onvif.metadata` data track
via an ffmpeg subprocess, and converts the per-frame `<tt:MotionInCells>`
bitmap into a list of motion rectangles in Frigate detect-frame pixels.
Why ffmpeg rather than an in-process RTSP client: Frigate already ships
ffmpeg and uses it heavily for video/recording; there is no async RTSP
client in the existing dependency set that handles the `vnd.onvif.metadata`
payload cleanly. The data track is low-bandwidth (~1 packet/sec at idle,
≤300 bytes XML each), so the subprocess cost is negligible.
Wire format (ONVIF Analytics Service Spec, Annex B "Cell Motion Detection"):
- Each RTP packet payload is one complete <tt:MetadataStream> XML doc.
- Cells attribute = base64(PackBits(bit-packed row-major bitmap)).
- Bits: cols*rows total, MSB-first within bytes, zero-padded.
Cell → detect-frame mapping uses the CellLayout transformation discovered
at OnvifController init: Translate(tx, ty) + Scale(sx, sy) maps cell index
(c, r) to normalized ONVIF coords [-1, +1]. We convert that to detect-frame
pixels.
"""
import asyncio
import base64
import logging
from typing import Awaitable, Callable
from xml.etree import ElementTree as ET
import numpy as np
logger = logging.getLogger(__name__)
_TT_NS = "http://www.onvif.org/ver10/schema"
_MIC_TAG = f"{{{_TT_NS}}}MotionInCells"
# ffmpeg's -map 0:d:0 selects the first data track from the input. -c copy
# bypasses any transcode. -f data writes raw packet payloads to stdout.
# -flush_packets 1 disables muxer-side buffering so each metadata frame
# reaches us within ~1 packet of being received from the camera.
_FFMPEG_ARGS_TEMPLATE = (
"-nostdin",
"-loglevel",
"error",
"-rtsp_transport",
"tcp",
"-i",
"{url}",
"-map",
"0:d:0?",
"-c",
"copy",
"-flush_packets",
"1",
"-f",
"data",
"pipe:1",
)
# Each metadata document ends with this closing tag — we split incoming
# stdout on it to recover packet boundaries (no other framing on a `-f data`
# stream).
_DOC_TERMINATOR = b"</tt:MetadataStream>"
_BACKOFF_INITIAL_S = 1.0
_BACKOFF_MAX_S = 60.0
# Stop reading at this many bytes per single document — guards against a
# misbehaving stream filling memory if the terminator never arrives.
_MAX_DOC_BYTES = 64 * 1024
def _packbits_decode(packed: bytes) -> bytes:
"""ISO 12639 / TIFF 6.0 PackBits decoder."""
out = bytearray()
i = 0
n = len(packed)
while i < n:
h = packed[i]
i += 1
if h <= 0x7F:
count = h + 1
out += packed[i : i + count]
i += count
elif h == 0x80:
continue # no-op header
else:
count = 257 - h
if i >= n:
break
out += bytes([packed[i]]) * count
i += 1
return bytes(out)
def _decode_cells(cells_b64: str, cols: int, rows: int) -> np.ndarray | None:
"""Decode the Cells attribute into a 2-D uint8 array shape (rows, cols).
Returns None if the decoded length doesn't match what the layout
expects — caller should treat that as "no spatial data this frame"
and fall back to whatever default (e.g. full-frame box)."""
if not cells_b64:
return None
try:
packed = base64.b64decode(cells_b64, validate=False)
except Exception:
return None
raw = _packbits_decode(packed)
needed_bytes = (cols * rows + 7) // 8
if len(raw) < needed_bytes:
return None
bits = np.unpackbits(np.frombuffer(raw[:needed_bytes], dtype=np.uint8))
bits = bits[: cols * rows]
return bits.reshape((rows, cols)).astype(np.uint8)
def _connected_component_bboxes(
cells: np.ndarray,
) -> list[tuple[int, int, int, int]]:
"""4-connectivity flood fill over a small 0/1 grid; returns list of
(c_left, c_top, c_right, c_bottom) inclusive cell-index bounding boxes
for each connected region.
cv2.connectedComponentsWithStats would be faster, but the cell grid is
tiny (typically 22x18 = 396 cells) and avoiding the cv2 import keeps
this module testable without OpenCV installed.
"""
rows, cols = cells.shape
visited = np.zeros_like(cells, dtype=bool)
out: list[tuple[int, int, int, int]] = []
for r0 in range(rows):
for c0 in range(cols):
if not cells[r0, c0] or visited[r0, c0]:
continue
stack = [(r0, c0)]
cmin = cmax = c0
rmin = rmax = r0
while stack:
r, c = stack.pop()
if r < 0 or r >= rows or c < 0 or c >= cols:
continue
if visited[r, c] or not cells[r, c]:
continue
visited[r, c] = True
if r < rmin:
rmin = r
if r > rmax:
rmax = r
if c < cmin:
cmin = c
if c > cmax:
cmax = c
stack.append((r + 1, c))
stack.append((r - 1, c))
stack.append((r, c + 1))
stack.append((r, c - 1))
out.append((cmin, rmin, cmax, rmax))
return out
def _cells_to_boxes(
cells: np.ndarray,
cell_layout: tuple[int, int, tuple[float, float], tuple[float, float]],
detect_size: tuple[int, int],
) -> list[tuple[int, int, int, int]]:
"""Connected-components on the cell grid → list of detect-frame boxes.
cell_layout = (cols, rows, (tx, ty), (sx, sy)) — the Translate + Scale
from CellLayout.Transformation. detect_size = (width, height) in
detect-frame pixels.
"""
if cells is None or cells.size == 0 or not cells.any():
return []
cols, rows, (tx, ty), (sx, sy) = cell_layout
det_w, det_h = detect_size
if det_w <= 0 or det_h <= 0:
return []
boxes: list[tuple[int, int, int, int]] = []
# Map cell index → detect-frame pixel via the CellLayout transformation:
# cell (c, r) covers normalized [tx + c*sx, tx + (c+1)*sx] horizontally
# and similarly vertically. Convert normalized [-1, +1] → pixel.
def cell_to_px(
c: int, r: int, *, right_edge: bool, bottom_edge: bool
) -> tuple[int, int]:
cx_idx = c + 1 if right_edge else c
cy_idx = r + 1 if bottom_edge else r
nx = tx + cx_idx * sx
ny = ty + cy_idx * sy
px = int(round((nx + 1.0) * 0.5 * det_w))
py = int(round((ny + 1.0) * 0.5 * det_h))
return px, py
for c_left, c_top, c_right, c_bottom in _connected_component_bboxes(cells):
x1, y1 = cell_to_px(c_left, c_top, right_edge=False, bottom_edge=False)
x2, y2 = cell_to_px(c_right, c_bottom, right_edge=True, bottom_edge=True)
x1 = max(0, min(det_w - 1, x1))
y1 = max(0, min(det_h - 1, y1))
x2 = max(0, min(det_w - 1, x2))
y2 = max(0, min(det_h - 1, y2))
if x2 <= x1 or y2 <= y1:
continue
boxes.append((x1, y1, x2, y2))
return boxes
def _extract_cells_from_doc(doc_bytes: bytes) -> tuple[str | None, int, int]:
"""Parse a <tt:MetadataStream> XML doc, return (cells_b64, cols, rows).
Returns (None, 0, 0) if no MotionInCells element is found."""
try:
root = ET.fromstring(doc_bytes)
except ET.ParseError:
return None, 0, 0
for el in root.iter(_MIC_TAG):
cells_b64 = el.attrib.get("Cells")
try:
cols = int(el.attrib.get("Columns", "0"))
rows = int(el.attrib.get("Rows", "0"))
except ValueError:
return None, 0, 0
return cells_b64, cols, rows
return None, 0, 0
async def run_metadata_stream(
rtsp_url: str,
cam_name: str,
cell_layout: tuple[int, int, tuple[float, float], tuple[float, float]],
detect_size: tuple[int, int],
on_boxes: Callable[[list[tuple[int, int, int, int]]], None]
| Callable[[list[tuple[int, int, int, int]]], Awaitable[None]],
stop_event: asyncio.Event,
) -> None:
"""Loop until stop_event: spawn ffmpeg → read XML docs → decode → on_boxes."""
backoff = _BACKOFF_INITIAL_S
while not stop_event.is_set():
proc = None
try:
args = [a.format(url=rtsp_url) for a in _FFMPEG_ARGS_TEMPLATE]
proc = await asyncio.create_subprocess_exec(
"ffmpeg",
*args,
stdin=asyncio.subprocess.DEVNULL,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
logger.info(
f"ONVIF metadata stream: ffmpeg started for {cam_name} pid={proc.pid}"
)
await _consume_ffmpeg(
proc, cam_name, cell_layout, detect_size, on_boxes, stop_event
)
backoff = _BACKOFF_INITIAL_S
except asyncio.CancelledError:
raise
except Exception as e:
logger.warning(
f"ONVIF metadata stream error for {cam_name}: {e!r}; "
f"reconnecting in {backoff:.1f}s"
)
finally:
if proc is not None and proc.returncode is None:
proc.terminate()
try:
await asyncio.wait_for(proc.wait(), timeout=2.0)
except asyncio.TimeoutError:
proc.kill()
await proc.wait()
if stop_event.is_set():
return
try:
await asyncio.wait_for(stop_event.wait(), timeout=backoff)
return
except asyncio.TimeoutError:
pass
backoff = min(backoff * 2, _BACKOFF_MAX_S)
async def _consume_ffmpeg(
proc: asyncio.subprocess.Process,
cam_name: str,
cell_layout: tuple[int, int, tuple[float, float], tuple[float, float]],
detect_size: tuple[int, int],
on_boxes,
stop_event: asyncio.Event,
) -> None:
"""Read XML docs from ffmpeg stdout and dispatch boxes."""
layout_cols, layout_rows, _, _ = cell_layout
assert proc.stdout is not None
buf = bytearray()
while not stop_event.is_set():
chunk = await proc.stdout.read(4096)
if not chunk:
# ffmpeg exited or stream ended.
stderr_tail = b""
if proc.stderr is not None:
try:
stderr_tail = await asyncio.wait_for(
proc.stderr.read(4096), timeout=0.5
)
except asyncio.TimeoutError:
pass
raise RuntimeError(
f"ffmpeg exited for {cam_name} rc={proc.returncode} "
f"stderr={stderr_tail.decode('utf-8', 'replace').strip()[:200]}"
)
buf.extend(chunk)
if len(buf) > _MAX_DOC_BYTES * 4:
# Drop the head to avoid unbounded growth on a wedged stream.
buf = buf[-_MAX_DOC_BYTES:]
while True:
end = buf.find(_DOC_TERMINATOR)
if end < 0:
break
end += len(_DOC_TERMINATOR)
doc = bytes(buf[:end])
del buf[:end]
cells_b64, cols, rows = _extract_cells_from_doc(doc)
if cells_b64 is None:
continue
# Trust the layout we discovered at init; warn (don't fail) if the
# camera reports a different grid mid-stream.
if cols != layout_cols or rows != layout_rows:
logger.debug(
f"{cam_name}: MotionInCells grid {cols}x{rows} differs "
f"from discovered layout {layout_cols}x{layout_rows}"
)
use_layout = (
cols,
rows,
cell_layout[2],
(2.0 / cols if cols else 0, 2.0 / rows if rows else 0),
)
else:
use_layout = cell_layout
cells = _decode_cells(cells_b64, cols, rows)
if cells is None:
continue
boxes = _cells_to_boxes(cells, use_layout, detect_size)
try:
result = on_boxes(boxes)
if asyncio.iscoroutine(result):
await result
except Exception:
logger.exception(f"on_boxes callback error for {cam_name}")
-175
View File
@@ -1,175 +0,0 @@
"""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()
@@ -1,64 +0,0 @@
"""Validator tests for `motion.source=onvif` interactions with
`onvif.events.enabled` and `motion.enabled`. Exercises `verify_motion_and_detect`
directly so we don't need the full FrigateConfig path (which mounts /config)."""
import unittest
from frigate.config.config import verify_motion_and_detect
class _Dummy:
"""Light shim for the nested config attributes the validator reads."""
def __init__(self, **kw):
for k, v in kw.items():
setattr(self, k, v)
def _camera(*, name, detect, motion, onvif):
return _Dummy(name=name, detect=detect, motion=motion, onvif=onvif)
class TestVerifyMotionAndDetect(unittest.TestCase):
def test_internal_motion_with_detect_passes(self):
cam = _camera(
name="c",
detect=_Dummy(enabled=True),
motion=_Dummy(enabled=True, source="internal"),
onvif=_Dummy(events=_Dummy(enabled=False)),
)
# No exception.
self.assertIsNone(verify_motion_and_detect(cam))
def test_detect_with_motion_disabled_rejected(self):
cam = _camera(
name="c",
detect=_Dummy(enabled=True),
motion=_Dummy(enabled=False, source="internal"),
onvif=_Dummy(events=_Dummy(enabled=False)),
)
with self.assertRaisesRegex(ValueError, "object detection requires motion"):
verify_motion_and_detect(cam)
def test_source_onvif_requires_events_enabled(self):
cam = _camera(
name="c",
detect=_Dummy(enabled=True),
motion=_Dummy(enabled=False, source="onvif"),
onvif=_Dummy(events=_Dummy(enabled=False)),
)
with self.assertRaisesRegex(ValueError, "onvif.events.enabled is false"):
verify_motion_and_detect(cam)
def test_source_onvif_with_events_passes_even_with_motion_disabled(self):
cam = _camera(
name="c",
detect=_Dummy(enabled=True),
motion=_Dummy(enabled=False, source="onvif"),
onvif=_Dummy(events=_Dummy(enabled=True)),
)
self.assertIsNone(verify_motion_and_detect(cam))
if __name__ == "__main__":
unittest.main()
-152
View File
@@ -1,152 +0,0 @@
"""Unit tests for the ONVIF analytics metadata decoder + cell→box mapper."""
import base64
import unittest
import numpy as np
from frigate.ptz.onvif_metadata import (
_cells_to_boxes,
_decode_cells,
_extract_cells_from_doc,
_packbits_decode,
)
class TestPackBits(unittest.TestCase):
def test_spec_example(self):
# ONVIF Analytics Annex B worked example:
# raw = ff ff ff f0 f0 f0
# packed (PackBits) = fe ff fe f0
packed = bytes.fromhex("feff fef0".replace(" ", ""))
self.assertEqual(
_packbits_decode(packed),
bytes.fromhex("ffff fff0 f0f0".replace(" ", "")),
)
def test_idle_frame_from_live_camera(self):
# `zwA=` is a representative idle-frame payload from a 22×18 grid:
# 396 bits → 50 bytes after byte-padding; PackBits compresses 50
# zeros to `cf 00` → base64 `zwA=`.
packed = base64.b64decode("zwA=")
self.assertEqual(packed, bytes.fromhex("cf 00".replace(" ", "")))
raw = _packbits_decode(packed)
self.assertEqual(len(raw), 50)
self.assertEqual(raw, b"\x00" * 50)
def test_literal_run(self):
# Header 0x03 → 4 literal bytes follow.
self.assertEqual(_packbits_decode(b"\x03ABCD"), b"ABCD")
def test_noop_header(self):
# 0x80 is no-op per spec; literal after should still decode normally.
# \x80 → no-op; \x02 → literal of 3 bytes follows; "ABC" copied.
self.assertEqual(_packbits_decode(b"\x80\x02ABC"), b"ABC")
class TestDecodeCells(unittest.TestCase):
def test_idle(self):
cells = _decode_cells("zwA=", 22, 18)
self.assertIsNotNone(cells)
self.assertEqual(cells.shape, (18, 22))
self.assertEqual(int(cells.sum()), 0)
def test_invalid_base64(self):
self.assertIsNone(_decode_cells("not-base64!@", 22, 18))
def test_short_payload_returns_none(self):
# `cf 00` decodes to 50 bytes; ask for 100×100 grid (1250 bytes
# needed) → expect None.
self.assertIsNone(_decode_cells("zwA=", 100, 100))
def test_top_left_active(self):
# Build a 22×18 grid with only cell (0,0) active. Raw bitmap byte 0
# = 0x80 (MSB set), bytes 1..49 = 0x00. PackBits of that 50-byte
# sequence: literal-1-byte (header 0x00) of 0x80, then replicate of
# 49 zeros (header 257-49=208=0xD0, byte 0x00).
packed = bytes([0x00, 0x80, 0xD0, 0x00])
b64 = base64.b64encode(packed).decode()
cells = _decode_cells(b64, 22, 18)
self.assertIsNotNone(cells)
self.assertEqual(int(cells[0, 0]), 1)
self.assertEqual(int(cells.sum()), 1)
class TestCellsToBoxes(unittest.TestCase):
"""Verify the cell-grid → detect-frame pixel mapping using a representative
CellLayout (22x18, Translate(-1,-1), Scale(2/22, 2/18))."""
LAYOUT = (22, 18, (-1.0, -1.0), (2.0 / 22, 2.0 / 18))
DETECT = (1280, 720)
def test_empty(self):
cells = np.zeros((18, 22), dtype=np.uint8)
self.assertEqual(_cells_to_boxes(cells, self.LAYOUT, self.DETECT), [])
def test_top_left_cell(self):
cells = np.zeros((18, 22), dtype=np.uint8)
cells[0, 0] = 1
boxes = _cells_to_boxes(cells, self.LAYOUT, self.DETECT)
self.assertEqual(len(boxes), 1)
x1, y1, x2, y2 = boxes[0]
# Cell (0,0) covers normalized [-1, -1+2/22] × [-1, -1+2/18]
# → detect px [0, 1280/22] × [0, 720/18] = [0, ~58] × [0, 40]
self.assertEqual(x1, 0)
self.assertEqual(y1, 0)
self.assertAlmostEqual(x2, round(1280 / 22), delta=2)
self.assertAlmostEqual(y2, round(720 / 18), delta=2)
def test_bottom_right_cell(self):
cells = np.zeros((18, 22), dtype=np.uint8)
cells[17, 21] = 1
boxes = _cells_to_boxes(cells, self.LAYOUT, self.DETECT)
self.assertEqual(len(boxes), 1)
x1, y1, x2, y2 = boxes[0]
# Bottom-right edge clamps to detect_size - 1.
self.assertEqual(x2, self.DETECT[0] - 1)
self.assertEqual(y2, self.DETECT[1] - 1)
self.assertAlmostEqual(x1, round(21 * 1280 / 22), delta=2)
self.assertAlmostEqual(y1, round(17 * 720 / 18), delta=2)
def test_two_separated_regions(self):
cells = np.zeros((18, 22), dtype=np.uint8)
# Region A: top-left 2×2 block
cells[0:2, 0:2] = 1
# Region B: bottom-right 2×2 block (separated by inactive cells)
cells[15:17, 18:20] = 1
boxes = _cells_to_boxes(cells, self.LAYOUT, self.DETECT)
self.assertEqual(len(boxes), 2)
class TestExtractCellsFromDoc(unittest.TestCase):
def test_typical_frame(self):
doc = (
b'<tt:MetadataStream xmlns:tt="http://www.onvif.org/ver10/schema">'
b"<tt:VideoAnalytics>"
b'<tt:Frame UtcTime="2026-05-29T14:12:20Z">'
b"<tt:Extension>"
b'<tt:MotionInCells Columns="22" Rows="18" Cells="zwA="/>'
b"</tt:Extension></tt:Frame></tt:VideoAnalytics></tt:MetadataStream>"
)
cells_b64, cols, rows = _extract_cells_from_doc(doc)
self.assertEqual(cells_b64, "zwA=")
self.assertEqual(cols, 22)
self.assertEqual(rows, 18)
def test_malformed_xml(self):
self.assertEqual(
_extract_cells_from_doc(b"not-xml"),
(None, 0, 0),
)
def test_doc_without_motioncells(self):
doc = (
b'<tt:MetadataStream xmlns:tt="http://www.onvif.org/ver10/schema">'
b"<tt:VideoAnalytics><tt:Frame/></tt:VideoAnalytics>"
b"</tt:MetadataStream>"
)
self.assertEqual(_extract_cells_from_doc(doc), (None, 0, 0))
if __name__ == "__main__":
unittest.main()
-72
View File
@@ -1,72 +0,0 @@
"""Unit tests for the ONVIF PullPoint motion-state parser."""
import unittest
from xml.etree import ElementTree as ET
from frigate.ptz.onvif_events import _parse_motion_state
class FakeMessage:
"""Mimic the zeep NotificationMessage shape: a Message attribute holding
an object whose `_value_1` is an lxml/etree element."""
class _Body:
def __init__(self, element):
self._value_1 = element
def __init__(self, xml: str):
self.Message = self._Body(ET.fromstring(xml))
_NS = 'xmlns:tt="http://www.onvif.org/ver10/schema"'
def _build_msg(name: str, value: str) -> FakeMessage:
xml = (
f"<tt:Message {_NS}>"
"<tt:Source>"
'<tt:SimpleItem Name="Source" Value="VideoSourceToken"/>'
"</tt:Source>"
"<tt:Data>"
f'<tt:SimpleItem Name="{name}" Value="{value}"/>'
"</tt:Data>"
"</tt:Message>"
)
return FakeMessage(xml)
class TestParseMotionState(unittest.TestCase):
def test_is_motion_true(self):
self.assertTrue(_parse_motion_state(_build_msg("IsMotion", "true")))
def test_is_motion_false(self):
self.assertFalse(_parse_motion_state(_build_msg("IsMotion", "false")))
def test_legacy_state_topic_name(self):
# The legacy tns1:VideoSource/MotionAlarm payload uses "State" instead
# of the spec-compliant "IsMotion"; we accept either.
self.assertTrue(_parse_motion_state(_build_msg("State", "true")))
self.assertFalse(_parse_motion_state(_build_msg("State", "false")))
def test_boolean_aliases(self):
self.assertTrue(_parse_motion_state(_build_msg("IsMotion", "1")))
self.assertFalse(_parse_motion_state(_build_msg("IsMotion", "0")))
def test_no_state_returns_none(self):
# Missing the State/IsMotion SimpleItem.
xml = (
f"<tt:Message {_NS}>"
'<tt:Data><tt:SimpleItem Name="Other" Value="yes"/></tt:Data>'
"</tt:Message>"
)
self.assertIsNone(_parse_motion_state(FakeMessage(xml)))
def test_no_message_returns_none(self):
class Empty:
pass
self.assertIsNone(_parse_motion_state(Empty()))
if __name__ == "__main__":
unittest.main()
+1 -17
View File
@@ -14,7 +14,6 @@ from frigate.camera import CameraMetrics, PTZMetrics
from frigate.comms.inter_process import InterProcessRequestor
from frigate.config import CameraConfig, DetectConfig, LoggerConfig, ModelConfig
from frigate.config.camera.camera import CameraTypeEnum
from frigate.config.camera.motion import MotionSourceEnum
from frigate.config.camera.updater import (
CameraConfigUpdateEnum,
CameraConfigUpdateSubscriber,
@@ -301,22 +300,7 @@ def process_frames(
continue
# look for motion if enabled
if camera_config.motion.source == MotionSourceEnum.onvif:
# Motion is supplied by an external ONVIF cell-motion subscriber
# writing to camera_metrics. Skip the per-frame internal detector.
if camera_metrics.external_motion_active.value:
boxes = list(camera_metrics.external_motion_boxes)
if boxes:
motion_boxes = [tuple(b) for b in boxes]
else:
# Active but no spatial data yet — fall back to full frame
# so downstream region clustering still has something to
# scan.
motion_boxes = [(0, 0, frame_shape[1] - 1, frame_shape[0] - 1)]
else:
motion_boxes = []
else:
motion_boxes = motion_detector.detect(frame)
motion_boxes = motion_detector.detect(frame)
regions = []
consolidated_detections = []
File diff suppressed because one or more lines are too long
@@ -111,18 +111,6 @@ 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
@@ -423,7 +411,6 @@ 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())
@@ -1,203 +0,0 @@
/**
* 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");
});
});
-20
View File
@@ -262,10 +262,6 @@
"label": "Enable motion detection",
"description": "Enable or disable motion detection for this camera."
},
"source": {
"label": "Motion source",
"description": "Where motion state comes from: Frigate's internal frame analyser, or the camera's ONVIF cell-motion events (requires onvif.events.enabled)."
},
"threshold": {
"label": "Motion threshold",
"description": "Pixel difference threshold used by the motion detector; higher values reduce sensitivity (range 1-255)."
@@ -843,22 +839,6 @@
"description": "Internal field to track whether autotracking was enabled in configuration."
}
},
"events": {
"label": "ONVIF events",
"description": "Consume camera-side ONVIF motion notifications instead of Frigate's CPU motion detector.",
"enabled": {
"label": "Enable ONVIF events",
"description": "Subscribe to the camera's ONVIF cell-motion notifications and use them as Frigate's motion signal."
},
"subscription_timeout": {
"label": "Subscription timeout",
"description": "Seconds before the PullPoint subscription expires and is renewed."
},
"use_metadata_stream": {
"label": "Use metadata stream",
"description": "Open the ONVIF analytics RTSP metadata stream to receive per-cell motion coordinates. Falls back to a full-frame box when disabled or when the camera does not advertise the track."
}
},
"ignore_time_mismatch": {
"label": "Ignore time mismatch",
"description": "Ignore time synchronization differences between camera and Frigate server for ONVIF communication."
-20
View File
@@ -769,10 +769,6 @@
"label": "Enable motion detection",
"description": "Enable or disable motion detection for all cameras; can be overridden per-camera."
},
"source": {
"label": "Motion source",
"description": "Where motion state comes from: Frigate's internal frame analyser, or the camera's ONVIF cell-motion events (requires onvif.events.enabled)."
},
"threshold": {
"label": "Motion threshold",
"description": "Pixel difference threshold used by the motion detector; higher values reduce sensitivity (range 1-255)."
@@ -1623,22 +1619,6 @@
"description": "Internal field to track whether autotracking was enabled in configuration."
}
},
"events": {
"label": "ONVIF events",
"description": "Consume camera-side ONVIF motion notifications instead of Frigate's CPU motion detector.",
"enabled": {
"label": "Enable ONVIF events",
"description": "Subscribe to the camera's ONVIF cell-motion notifications and use them as Frigate's motion signal."
},
"subscription_timeout": {
"label": "Subscription timeout",
"description": "Seconds before the PullPoint subscription expires and is renewed."
},
"use_metadata_stream": {
"label": "Use metadata stream",
"description": "Open the ONVIF analytics RTSP metadata stream to receive per-cell motion coordinates. Falls back to a full-frame box when disabled or when the camera does not advertise the track."
}
},
"ignore_time_mismatch": {
"label": "Ignore time mismatch",
"description": "Ignore time synchronization differences between camera and Frigate server for ONVIF communication."
+1 -11
View File
@@ -1553,17 +1553,7 @@
}
},
"cameraInputs": {
"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"
}
"itemTitle": "Stream {{index}}"
},
"restartRequiredField": "Restart required",
"restartRequiredFooter": "Configuration changed - Restart required",
@@ -243,7 +243,12 @@ export default function CameraReviewClassification({
handleZoneToggle("alerts.required_zones", zone.name)
}
/>
<Label className="font-normal">
<Label
className={cn(
"font-normal",
!zone.friendly_name && "smart-capitalize",
)}
>
{zone.friendly_name || zone.name}
</Label>
</div>
@@ -29,19 +29,11 @@ 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[] => {
@@ -145,30 +137,7 @@ 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) => {
@@ -202,55 +171,6 @@ 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)
@@ -266,9 +186,8 @@ export function CameraInputsField(props: FieldProps) {
(_, currentIndex) => currentIndex !== index,
);
onChange(nextInputs, fieldPathId.path);
const reindex = <T,>(previous: Record<number, T>): Record<number, T> => {
const next: Record<number, T> = {};
setOpenByIndex((previous) => {
const next: Record<number, boolean> = {};
Object.entries(previous).forEach(([key, value]) => {
const current = Number(key);
if (Number.isNaN(current) || current === index) {
@@ -278,10 +197,7 @@ export function CameraInputsField(props: FieldProps) {
next[current > index ? current - 1 : current] = value;
});
return next;
};
setOpenByIndex(reindex);
setSourceModeByIndex(reindex);
});
},
[fieldPathId.path, inputs, onChange],
);
@@ -438,32 +354,16 @@ export function CameraInputsField(props: FieldProps) {
<CollapsibleContent>
<CardContent className="space-y-4 p-4 pt-0">
<div className="w-full">
<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,
},
{renderField(index, "path", {
extraUiSchema: {
"ui:widget": "CameraPathWidget",
"ui:options": {
size: "full",
splitLayout: false,
},
showSchemaDescription: true,
})}
disabled={disabled}
readonly={readonly}
/>
},
showSchemaDescription: true,
})}
</div>
<div className="w-full">{renderField(index, "roles")}</div>
@@ -1,217 +0,0 @@
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;
@@ -1,33 +0,0 @@
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 the raw zone id verbatim (no friendly_name available)
return String(zoneName);
// Fallback to cleaning up the zone name
return String(zoneName).replace(/_/g, " ");
}
export function ZoneSwitchesWidget(props: WidgetProps) {
@@ -1197,7 +1197,14 @@ function LifecycleIconRow({
backgroundColor: `rgb(${color})`,
}}
/>
<span>{item.data?.zones_friendly_names?.[zidx]}</span>
<span
className={cn(
item.data?.zones_friendly_names?.[zidx] === zone &&
"smart-capitalize",
)}
>
{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);
if (!config) return String(zoneId).replace(/_/g, " ");
if (cameraId) {
const camera = config.cameras?.[String(cameraId)];
const zone = camera?.zones?.[zoneId];
return zone?.friendly_name || String(zoneId);
return zone?.friendly_name || String(zoneId).replace(/_/g, " ");
}
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);
return zone?.friendly_name || String(zoneId).replace(/_/g, " ");
}
}
// Fallback: display the raw zone id verbatim (no friendly_name available)
return String(zoneId);
// Fallback: return a cleaned-up zoneId string
return String(zoneId).replace(/_/g, " ");
}
export function useZoneFriendlyName(zoneId: string, cameraId?: string): string {
+6 -100
View File
@@ -34,8 +34,6 @@ 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";
@@ -662,11 +660,6 @@ 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;
@@ -795,40 +788,6 @@ 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;
@@ -838,13 +797,7 @@ export default function Settings() {
if (cameraCompare !== 0) return cameraCompare;
return left.fieldPath.localeCompare(right.fieldPath);
});
}, [
config,
fullSchema,
pendingDataBySection,
profileFriendlyNames,
rawPaths,
]);
}, [config, fullSchema, pendingDataBySection, profileFriendlyNames]);
// Map a pendingDataKey to SettingsType menu key for clearing section status
const pendingKeyToMenuKey = useCallback(
@@ -916,7 +869,10 @@ export default function Settings() {
// after `mutate("config")` resolves
const keysToClear: string[] = [];
// `detectors` and `model` are owned by DetectorsAndModelSettingsView
// `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
const hasPendingDetectors = "detectors" in pendingDataBySection;
const hasPendingModel = "model" in pendingDataBySection;
if (hasPendingDetectors || hasPendingModel) {
@@ -1019,58 +975,8 @@ 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 !== "go2rtc_streams",
(key) => key !== "detectors" && key !== "model",
);
for (const key of pendingKeys) {
@@ -58,13 +58,8 @@ 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,
@@ -90,8 +85,18 @@ type RawPathsResponse = {
go2rtc: { streams: Record<string, string | string[]> };
};
const SECTION_KEY = "go2rtc_streams";
const EMPTY_PENDING: Record<string, ConfigSectionData> = {};
type Go2RtcStreamsSettingsViewProps = {
setUnsavedChanges: React.Dispatch<React.SetStateAction<boolean>>;
onSectionStatusChange?: (
sectionKey: string,
level: "global" | "camera",
status: {
hasChanges: boolean;
isOverridden: boolean;
hasValidationErrors: boolean;
},
) => void;
};
const STREAM_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/;
@@ -109,11 +114,7 @@ function normalizeStreams(
export default function Go2RtcStreamsSettingsView({
setUnsavedChanges,
onSectionStatusChange,
pendingDataBySection,
onPendingDataChange,
isSavingAll,
onSectionSavingChange,
}: SettingsPageProps) {
}: Go2RtcStreamsSettingsViewProps) {
const { t } = useTranslation(["views/settings", "common"]);
const { getLocaleDocUrl } = useDocDomain();
const { data: config, mutate: updateConfig } =
@@ -121,6 +122,13 @@ 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>
@@ -130,51 +138,34 @@ export default function Go2RtcStreamsSettingsView({
const [addStreamDialogOpen, setAddStreamDialogOpen] = useState(false);
const [newlyAdded, setNewlyAdded] = useState<Set<string>>(new Set());
const childPending = pendingDataBySection ?? EMPTY_PENDING;
// 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;
// Saved/server state. Always read from rawPaths
const serverStreams = useMemo<Record<string, string[]>>(
() => normalizeStreams(rawPaths?.go2rtc?.streams),
[rawPaths],
);
// 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);
// 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],
);
setServerStreams(normalized);
if (!initialized) {
setEditedStreams(normalized);
setInitialized(true);
}
}, [config, rawPaths, initialized]);
// Track unsaved changes
const hasChanges = useMemo(
() => !isEqual(liveStreams, serverStreams),
[liveStreams, serverStreams],
() => initialized && !isEqual(editedStreams, serverStreams),
[editedStreams, serverStreams, initialized],
);
useEffect(() => {
setUnsavedChanges?.(hasChanges);
setUnsavedChanges(hasChanges);
}, [hasChanges, setUnsavedChanges]);
const hasValidationErrors = useMemo(() => {
const names = Object.keys(liveStreams);
const names = Object.keys(editedStreams);
const seenNames = new Set<string>();
for (const name of names) {
@@ -182,43 +173,13 @@ export default function Go2RtcStreamsSettingsView({
if (seenNames.has(name)) return true;
seenNames.add(name);
const urls = liveStreams[name];
const urls = editedStreams[name];
if (!urls || urls.length === 0 || urls.every((u) => !u.trim()))
return true;
}
return false;
}, [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]);
}, [editedStreams]);
// Report status to parent for sidebar red dot
useEffect(() => {
@@ -232,14 +193,13 @@ export default function Go2RtcStreamsSettingsView({
// Save handler
const saveToConfig = useCallback(async () => {
setIsLoading(true);
onSectionSavingChange?.(true);
try {
const streamsPayload: Record<string, string[] | string> = {
...liveStreams,
...editedStreams,
};
const deletedStreamNames = Object.keys(serverStreams).filter(
(name) => !(name in liveStreams),
(name) => !(name in editedStreams),
);
for (const deleted of deletedStreamNames) {
streamsPayload[deleted] = "";
@@ -252,7 +212,7 @@ export default function Go2RtcStreamsSettingsView({
// Update running go2rtc instance
const go2rtcUpdates: Promise<unknown>[] = [];
for (const [streamName, urls] of Object.entries(liveStreams)) {
for (const [streamName, urls] of Object.entries(editedStreams)) {
if (urls[0]) {
go2rtcUpdates.push(
axios.put(
@@ -273,9 +233,9 @@ export default function Go2RtcStreamsSettingsView({
}),
);
await updateConfig();
await updateRawPaths();
onPendingDataChange?.(SECTION_KEY, undefined, null);
setServerStreams(editedStreams);
updateConfig();
updateRawPaths();
} catch {
toast.error(
t("toast.error", {
@@ -285,94 +245,82 @@ export default function Go2RtcStreamsSettingsView({
);
} finally {
setIsLoading(false);
onSectionSavingChange?.(false);
}
}, [
liveStreams,
serverStreams,
t,
updateConfig,
updateRawPaths,
onPendingDataChange,
onSectionSavingChange,
]);
}, [editedStreams, serverStreams, t, updateConfig, updateRawPaths]);
// Reset handler
const onReset = useCallback(() => {
onPendingDataChange?.(SECTION_KEY, undefined, null);
setEditedStreams(serverStreams);
setCredentialVisibility({});
}, [onPendingDataChange]);
}, [serverStreams]);
// Stream CRUD operations
const addStream = useCallback(
(name: string) => {
commitStreams({ ...liveStreams, [name]: [""] });
setNewlyAdded((prev) => new Set(prev).add(name));
setAddStreamDialogOpen(false);
},
[liveStreams, commitStreams],
);
const addStream = useCallback((name: string) => {
setEditedStreams((prev) => ({ ...prev, [name]: [""] }));
setNewlyAdded((prev) => new Set(prev).add(name));
setAddStreamDialogOpen(false);
}, []);
const deleteStream = useCallback(
(streamName: string) => {
const { [streamName]: _removed, ...rest } = liveStreams;
commitStreams(rest);
setDeleteDialog(null);
},
[liveStreams, commitStreams],
);
const deleteStream = useCallback((streamName: string) => {
setEditedStreams((prev) => {
const { [streamName]: _, ...rest } = prev;
return rest;
});
setDeleteDialog(null);
}, []);
const renameStream = useCallback(
(oldName: string, newName: string) => {
if (oldName === newName || !newName.trim()) return;
if (!(oldName in liveStreams)) return;
const renameStream = useCallback((oldName: string, newName: string) => {
if (oldName === newName || !newName.trim()) 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 Object.entries(liveStreams)) {
result[key === oldName ? newName : key] = value;
for (const [key, value] of entries) {
if (key === oldName) {
result[newName] = value;
} else {
result[key] = value;
}
}
commitStreams(result);
},
[liveStreams, commitStreams],
);
return result;
});
}, []);
const updateUrl = useCallback(
(streamName: string, urlIndex: number, newUrl: string) => {
const urls = [...(liveStreams[streamName] || [])];
urls[urlIndex] = newUrl;
commitStreams({ ...liveStreams, [streamName]: urls });
},
[liveStreams, commitStreams],
);
const addUrl = useCallback(
(streamName: string) => {
const urls = [...(liveStreams[streamName] || []), ""];
commitStreams({ ...liveStreams, [streamName]: urls });
},
[liveStreams, commitStreams],
);
const removeUrl = useCallback(
(streamName: string, urlIndex: number) => {
const urls = (liveStreams[streamName] || []).filter(
(_, i) => i !== urlIndex,
);
commitStreams({
...liveStreams,
[streamName]: urls.length > 0 ? urls : [""],
setEditedStreams((prev) => {
const urls = [...(prev[streamName] || [])];
urls[urlIndex] = newUrl;
return { ...prev, [streamName]: urls };
});
},
[liveStreams, commitStreams],
[],
);
const addUrl = useCallback((streamName: string) => {
setEditedStreams((prev) => {
const urls = [...(prev[streamName] || []), ""];
return { ...prev, [streamName]: urls };
});
}, []);
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 toggleCredentialVisibility = useCallback((key: string) => {
setCredentialVisibility((prev) => ({ ...prev, [key]: !prev[key] }));
}, []);
if (!config) return null;
const streamEntries = Object.entries(liveStreams);
const streamEntries = Object.entries(editedStreams);
return (
<div className="flex size-full flex-col lg:pr-2">
@@ -443,12 +391,6 @@ 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">
@@ -456,7 +398,7 @@ export default function Go2RtcStreamsSettingsView({
<Button
onClick={onReset}
variant="outline"
disabled={isLoading || isSavingAll}
disabled={isLoading}
className="flex min-w-36 flex-1 gap-2"
>
{t("button.undo", { ns: "common" })}
@@ -465,9 +407,7 @@ export default function Go2RtcStreamsSettingsView({
<Button
onClick={saveToConfig}
variant="select"
disabled={
!hasChanges || isLoading || isSavingAll || hasValidationErrors
}
disabled={!hasChanges || isLoading || hasValidationErrors}
className="flex min-w-36 flex-1 gap-2"
>
{isLoading ? (
@@ -519,7 +459,7 @@ export default function Go2RtcStreamsSettingsView({
<RenameStreamDialog
open={renameDialog !== null}
streamName={renameDialog ?? ""}
allStreamNames={Object.keys(liveStreams)}
allStreamNames={Object.keys(editedStreams)}
onRename={(oldName, newName) => {
renameStream(oldName, newName);
setRenameDialog(null);
@@ -529,7 +469,7 @@ export default function Go2RtcStreamsSettingsView({
<AddStreamDialog
open={addStreamDialogOpen}
allStreamNames={Object.keys(liveStreams)}
allStreamNames={Object.keys(editedStreams)}
onAdd={addStream}
onClose={() => setAddStreamDialogOpen(false)}
/>