Compare commits

...

4 Commits

Author SHA1 Message Date
dependabot[bot]
a82e93cd73
Merge 318c3d0503 into c2e667c0dd 2026-03-06 23:47:32 -05:00
Josh Hawkins
c2e667c0dd
Add dynamic configuration for more fields (#22295)
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
* face recognition dynamic config

* lpr dynamic config

* safe changes for birdseye dynamic config

* bird classification dynamic config

* always assign new config to stats emitter to make telemetry fields dynamic

* add wildcard support for camera config updates in config_set

* update restart required fields for global sections

* add test

* fix rebase issue

* collapsible settings sidebar

use the preexisting control available with shadcn's sidebar (cmd/ctrl-B) to give users more space to set masks/zones on smaller screens

* dynamic ffmpeg

* ensure previews dir exists

when ffmpeg processes restart, there's a brief window where the preview frame generation pipeline is torn down and restarted. before these changes, ffmpeg only restarted on crash/stall recovery or full Frigate restart. Now that ffmpeg restarts happen on-demand via config changes, there's a higher chance a frontend request hits the preview_mp4 or preview_gif endpoints during that brief restart window when the directory might not exist yet. The existing os.listdir() call would throw FileNotFoundError without a directory existence check. this fix just checks if the directory exists and returns 404 if not, exactly how preview_thumbnail already handles the same scenario a few lines below

* global ffmpeg section

* clean up

* tweak

* fix test
2026-03-06 13:45:39 -07:00
Josh Hawkins
c9bd907721
Frontend fixes (#22294)
* fix useImageLoaded hook running on every render

* fix volume not applying for all cameras

* Fix maximum update depth exceeded errors on Review page

- use-overlay-state: use refs for location to keep setter identity
  stable across renders, preventing cascading re-render loops when
  effects depend on the setter. Add Object.is bail-out guard to skip
  redundant navigate calls. Move setPersistedValue after bail-out to
  avoid unnecessary IndexedDB writes.

* don't try to fetch previews when motion search dialog is open

* revert unneeded changes

re-rendering was caused by the overlay state hook, not this one

* filter dicts to only use id field in sync recordings
2026-03-06 13:41:15 -07:00
dependabot[bot]
318c3d0503
Bump minimatch in /web
Bumps  and [minimatch](https://github.com/isaacs/minimatch). These dependencies needed to be updated together.

Updates `minimatch` from 3.1.2 to 3.1.5
- [Changelog](https://github.com/isaacs/minimatch/blob/main/changelog.md)
- [Commits](https://github.com/isaacs/minimatch/compare/v3.1.2...v3.1.5)

Updates `minimatch` from 9.0.4 to 9.0.9
- [Changelog](https://github.com/isaacs/minimatch/blob/main/changelog.md)
- [Commits](https://github.com/isaacs/minimatch/compare/v3.1.2...v3.1.5)

Updates `minimatch` from 9.0.5 to 9.0.9
- [Changelog](https://github.com/isaacs/minimatch/blob/main/changelog.md)
- [Commits](https://github.com/isaacs/minimatch/compare/v3.1.2...v3.1.5)

---
updated-dependencies:
- dependency-name: minimatch
  dependency-version: 3.1.5
  dependency-type: indirect
- dependency-name: minimatch
  dependency-version: 9.0.9
  dependency-type: indirect
- dependency-name: minimatch
  dependency-version: 9.0.9
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-27 04:17:20 +00:00
34 changed files with 567 additions and 192 deletions

View File

@ -589,23 +589,38 @@ def config_set(request: Request, body: AppConfigSetBody):
request.app.frigate_config = config request.app.frigate_config = config
request.app.genai_manager.update_config(config) request.app.genai_manager.update_config(config)
if request.app.stats_emitter is not None:
request.app.stats_emitter.config = config
if body.update_topic: if body.update_topic:
if body.update_topic.startswith("config/cameras/"): if body.update_topic.startswith("config/cameras/"):
_, _, camera, field = body.update_topic.split("/") _, _, camera, field = body.update_topic.split("/")
if field == "add": if camera == "*":
settings = config.cameras[camera] # Wildcard: fan out update to all cameras
elif field == "remove": enum_value = CameraConfigUpdateEnum[field]
settings = old_config.cameras[camera] for camera_name in config.cameras:
settings = config.get_nested_object(
f"config/cameras/{camera_name}/{field}"
)
request.app.config_publisher.publish_update(
CameraConfigUpdateTopic(enum_value, camera_name),
settings,
)
else: else:
settings = config.get_nested_object(body.update_topic) if field == "add":
settings = config.cameras[camera]
elif field == "remove":
settings = old_config.cameras[camera]
else:
settings = config.get_nested_object(body.update_topic)
request.app.config_publisher.publish_update( request.app.config_publisher.publish_update(
CameraConfigUpdateTopic( CameraConfigUpdateTopic(
CameraConfigUpdateEnum[field], camera CameraConfigUpdateEnum[field], camera
), ),
settings, settings,
) )
else: else:
# Generic handling for global config updates # Generic handling for global config updates
settings = config.get_nested_object(body.update_topic) settings = config.get_nested_object(body.update_topic)

View File

@ -1281,6 +1281,13 @@ def preview_gif(
else: else:
# need to generate from existing images # need to generate from existing images
preview_dir = os.path.join(CACHE_DIR, "preview_frames") preview_dir = os.path.join(CACHE_DIR, "preview_frames")
if not os.path.isdir(preview_dir):
return JSONResponse(
content={"success": False, "message": "Preview not found"},
status_code=404,
)
file_start = f"preview_{camera_name}" file_start = f"preview_{camera_name}"
start_file = f"{file_start}-{start_ts}.{PREVIEW_FRAME_TYPE}" start_file = f"{file_start}-{start_ts}.{PREVIEW_FRAME_TYPE}"
end_file = f"{file_start}-{end_ts}.{PREVIEW_FRAME_TYPE}" end_file = f"{file_start}-{end_ts}.{PREVIEW_FRAME_TYPE}"
@ -1456,6 +1463,13 @@ def preview_mp4(
else: else:
# need to generate from existing images # need to generate from existing images
preview_dir = os.path.join(CACHE_DIR, "preview_frames") preview_dir = os.path.join(CACHE_DIR, "preview_frames")
if not os.path.isdir(preview_dir):
return JSONResponse(
content={"success": False, "message": "Preview not found"},
status_code=404,
)
file_start = f"preview_{camera_name}" file_start = f"preview_{camera_name}"
start_file = f"{file_start}-{start_ts}.{PREVIEW_FRAME_TYPE}" start_file = f"{file_start}-{start_ts}.{PREVIEW_FRAME_TYPE}"
end_file = f"{file_start}-{end_ts}.{PREVIEW_FRAME_TYPE}" end_file = f"{file_start}-{end_ts}.{PREVIEW_FRAME_TYPE}"

View File

@ -242,6 +242,14 @@ class CameraConfig(FrigateBaseModel):
def create_ffmpeg_cmds(self): def create_ffmpeg_cmds(self):
if "_ffmpeg_cmds" in self: if "_ffmpeg_cmds" in self:
return return
self._build_ffmpeg_cmds()
def recreate_ffmpeg_cmds(self):
"""Force regeneration of ffmpeg commands from current config."""
self._build_ffmpeg_cmds()
def _build_ffmpeg_cmds(self):
"""Build ffmpeg commands from the current ffmpeg config."""
ffmpeg_cmds = [] ffmpeg_cmds = []
for ffmpeg_input in self.ffmpeg.inputs: for ffmpeg_input in self.ffmpeg.inputs:
ffmpeg_cmd = self._get_ffmpeg_cmd(ffmpeg_input) ffmpeg_cmd = self._get_ffmpeg_cmd(ffmpeg_input)

View File

@ -17,6 +17,7 @@ class CameraConfigUpdateEnum(str, Enum):
birdseye = "birdseye" birdseye = "birdseye"
detect = "detect" detect = "detect"
enabled = "enabled" enabled = "enabled"
ffmpeg = "ffmpeg"
motion = "motion" # includes motion and motion masks motion = "motion" # includes motion and motion masks
notifications = "notifications" notifications = "notifications"
objects = "objects" objects = "objects"
@ -91,6 +92,9 @@ class CameraConfigUpdateSubscriber:
if update_type == CameraConfigUpdateEnum.audio: if update_type == CameraConfigUpdateEnum.audio:
config.audio = updated_config config.audio = updated_config
elif update_type == CameraConfigUpdateEnum.ffmpeg:
config.ffmpeg = updated_config
config.recreate_ffmpeg_cmds()
elif update_type == CameraConfigUpdateEnum.audio_transcription: elif update_type == CameraConfigUpdateEnum.audio_transcription:
config.audio_transcription = updated_config config.audio_transcription = updated_config
elif update_type == CameraConfigUpdateEnum.birdseye: elif update_type == CameraConfigUpdateEnum.birdseye:

View File

@ -12,6 +12,7 @@ from frigate.comms.embeddings_updater import EmbeddingsRequestEnum
from frigate.comms.event_metadata_updater import EventMetadataPublisher from frigate.comms.event_metadata_updater import EventMetadataPublisher
from frigate.comms.inter_process import InterProcessRequestor from frigate.comms.inter_process import InterProcessRequestor
from frigate.config import FrigateConfig from frigate.config import FrigateConfig
from frigate.config.classification import LicensePlateRecognitionConfig
from frigate.data_processing.common.license_plate.mixin import ( from frigate.data_processing.common.license_plate.mixin import (
WRITE_DEBUG_IMAGES, WRITE_DEBUG_IMAGES,
LicensePlateProcessingMixin, LicensePlateProcessingMixin,
@ -47,6 +48,11 @@ class LicensePlatePostProcessor(LicensePlateProcessingMixin, PostProcessorApi):
self.sub_label_publisher = sub_label_publisher self.sub_label_publisher = sub_label_publisher
super().__init__(config, metrics, model_runner) super().__init__(config, metrics, model_runner)
def update_config(self, lpr_config: LicensePlateRecognitionConfig) -> None:
"""Update LPR config at runtime."""
self.lpr_config = lpr_config
logger.debug("LPR config updated dynamically")
def process_data( def process_data(
self, data: dict[str, Any], data_type: PostProcessDataEnum self, data: dict[str, Any], data_type: PostProcessDataEnum
) -> None: ) -> None:

View File

@ -19,6 +19,7 @@ from frigate.comms.event_metadata_updater import (
) )
from frigate.comms.inter_process import InterProcessRequestor from frigate.comms.inter_process import InterProcessRequestor
from frigate.config import FrigateConfig from frigate.config import FrigateConfig
from frigate.config.classification import FaceRecognitionConfig
from frigate.const import FACE_DIR, MODEL_CACHE_DIR from frigate.const import FACE_DIR, MODEL_CACHE_DIR
from frigate.data_processing.common.face.model import ( from frigate.data_processing.common.face.model import (
ArcFaceRecognizer, ArcFaceRecognizer,
@ -95,6 +96,11 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
self.recognizer.build() self.recognizer.build()
def update_config(self, face_config: FaceRecognitionConfig) -> None:
"""Update face recognition config at runtime."""
self.face_config = face_config
logger.debug("Face recognition config updated dynamically")
def __download_models(self, path: str) -> None: def __download_models(self, path: str) -> None:
try: try:
file_name = os.path.basename(path) file_name = os.path.basename(path)

View File

@ -8,6 +8,7 @@ import numpy as np
from frigate.comms.event_metadata_updater import EventMetadataPublisher from frigate.comms.event_metadata_updater import EventMetadataPublisher
from frigate.comms.inter_process import InterProcessRequestor from frigate.comms.inter_process import InterProcessRequestor
from frigate.config import FrigateConfig from frigate.config import FrigateConfig
from frigate.config.classification import LicensePlateRecognitionConfig
from frigate.data_processing.common.license_plate.mixin import ( from frigate.data_processing.common.license_plate.mixin import (
LicensePlateProcessingMixin, LicensePlateProcessingMixin,
) )
@ -40,6 +41,11 @@ class LicensePlateRealTimeProcessor(LicensePlateProcessingMixin, RealTimeProcess
self.camera_current_cars: dict[str, list[str]] = {} self.camera_current_cars: dict[str, list[str]] = {}
super().__init__(config, metrics) super().__init__(config, metrics)
def update_config(self, lpr_config: LicensePlateRecognitionConfig) -> None:
"""Update LPR config at runtime."""
self.lpr_config = lpr_config
logger.debug("LPR config updated dynamically")
def process_frame( def process_frame(
self, self,
obj_data: dict[str, Any], obj_data: dict[str, Any],

View File

@ -99,6 +99,13 @@ class EmbeddingMaintainer(threading.Thread):
self.classification_config_subscriber = ConfigSubscriber( self.classification_config_subscriber = ConfigSubscriber(
"config/classification/custom/" "config/classification/custom/"
) )
self.bird_classification_config_subscriber = ConfigSubscriber(
"config/classification", exact=True
)
self.face_recognition_config_subscriber = ConfigSubscriber(
"config/face_recognition", exact=True
)
self.lpr_config_subscriber = ConfigSubscriber("config/lpr", exact=True)
# Configure Frigate DB # Configure Frigate DB
db = SqliteVecQueueDatabase( db = SqliteVecQueueDatabase(
@ -273,6 +280,9 @@ class EmbeddingMaintainer(threading.Thread):
while not self.stop_event.is_set(): while not self.stop_event.is_set():
self.config_updater.check_for_updates() self.config_updater.check_for_updates()
self._check_classification_config_updates() self._check_classification_config_updates()
self._check_bird_classification_config_updates()
self._check_face_recognition_config_updates()
self._check_lpr_config_updates()
self._process_requests() self._process_requests()
self._process_updates() self._process_updates()
self._process_recordings_updates() self._process_recordings_updates()
@ -284,6 +294,9 @@ class EmbeddingMaintainer(threading.Thread):
self.config_updater.stop() self.config_updater.stop()
self.classification_config_subscriber.stop() self.classification_config_subscriber.stop()
self.bird_classification_config_subscriber.stop()
self.face_recognition_config_subscriber.stop()
self.lpr_config_subscriber.stop()
self.event_subscriber.stop() self.event_subscriber.stop()
self.event_end_subscriber.stop() self.event_end_subscriber.stop()
self.recordings_subscriber.stop() self.recordings_subscriber.stop()
@ -356,6 +369,62 @@ class EmbeddingMaintainer(threading.Thread):
f"Added classification processor for model: {model_name} (type: {type(processor).__name__})" f"Added classification processor for model: {model_name} (type: {type(processor).__name__})"
) )
def _check_bird_classification_config_updates(self) -> None:
"""Check for bird classification config updates."""
topic, classification_config = (
self.bird_classification_config_subscriber.check_for_update()
)
if topic is None:
return
self.config.classification = classification_config
logger.debug("Applied dynamic bird classification config update")
def _check_face_recognition_config_updates(self) -> None:
"""Check for face recognition config updates."""
topic, face_config = self.face_recognition_config_subscriber.check_for_update()
if topic is None:
return
previous_min_area = self.config.face_recognition.min_area
self.config.face_recognition = face_config
for camera_config in self.config.cameras.values():
if camera_config.face_recognition.min_area == previous_min_area:
camera_config.face_recognition.min_area = face_config.min_area
for processor in self.realtime_processors:
if isinstance(processor, FaceRealTimeProcessor):
processor.update_config(face_config)
logger.debug("Applied dynamic face recognition config update")
def _check_lpr_config_updates(self) -> None:
"""Check for LPR config updates."""
topic, lpr_config = self.lpr_config_subscriber.check_for_update()
if topic is None:
return
previous_min_area = self.config.lpr.min_area
self.config.lpr = lpr_config
for camera_config in self.config.cameras.values():
if camera_config.lpr.min_area == previous_min_area:
camera_config.lpr.min_area = lpr_config.min_area
for processor in self.realtime_processors:
if isinstance(processor, LicensePlateRealTimeProcessor):
processor.update_config(lpr_config)
for processor in self.post_processors:
if isinstance(processor, LicensePlatePostProcessor):
processor.update_config(lpr_config)
logger.debug("Applied dynamic LPR config update")
def _process_requests(self) -> None: def _process_requests(self) -> None:
"""Process embeddings requests""" """Process embeddings requests"""

View File

@ -273,17 +273,13 @@ class BirdsEyeFrameManager:
stop_event: mp.Event, stop_event: mp.Event,
): ):
self.config = config self.config = config
self.mode = config.birdseye.mode
width, height = get_canvas_shape(config.birdseye.width, config.birdseye.height) width, height = get_canvas_shape(config.birdseye.width, config.birdseye.height)
self.frame_shape = (height, width) self.frame_shape = (height, width)
self.yuv_shape = (height * 3 // 2, width) self.yuv_shape = (height * 3 // 2, width)
self.frame = np.ndarray(self.yuv_shape, dtype=np.uint8) self.frame = np.ndarray(self.yuv_shape, dtype=np.uint8)
self.canvas = Canvas(width, height, config.birdseye.layout.scaling_factor) self.canvas = Canvas(width, height, config.birdseye.layout.scaling_factor)
self.stop_event = stop_event self.stop_event = stop_event
self.inactivity_threshold = config.birdseye.inactivity_threshold self.last_refresh_time = 0
if config.birdseye.layout.max_cameras:
self.last_refresh_time = 0
# initialize the frame as black and with the Frigate logo # initialize the frame as black and with the Frigate logo
self.blank_frame = np.zeros(self.yuv_shape, np.uint8) self.blank_frame = np.zeros(self.yuv_shape, np.uint8)
@ -426,7 +422,7 @@ class BirdsEyeFrameManager:
and self.config.cameras[cam].enabled and self.config.cameras[cam].enabled
and cam_data["last_active_frame"] > 0 and cam_data["last_active_frame"] > 0
and cam_data["current_frame_time"] - cam_data["last_active_frame"] and cam_data["current_frame_time"] - cam_data["last_active_frame"]
< self.inactivity_threshold < self.config.birdseye.inactivity_threshold
] ]
) )
logger.debug(f"Active cameras: {active_cameras}") logger.debug(f"Active cameras: {active_cameras}")

View File

@ -15,6 +15,7 @@ from ws4py.server.wsgirefserver import (
) )
from ws4py.server.wsgiutils import WebSocketWSGIApplication from ws4py.server.wsgiutils import WebSocketWSGIApplication
from frigate.comms.config_updater import ConfigSubscriber
from frigate.comms.detections_updater import DetectionSubscriber, DetectionTypeEnum from frigate.comms.detections_updater import DetectionSubscriber, DetectionTypeEnum
from frigate.comms.ws import WebSocket from frigate.comms.ws import WebSocket
from frigate.config import FrigateConfig from frigate.config import FrigateConfig
@ -138,6 +139,7 @@ class OutputProcess(FrigateProcess):
CameraConfigUpdateEnum.record, CameraConfigUpdateEnum.record,
], ],
) )
birdseye_config_subscriber = ConfigSubscriber("config/birdseye", exact=True)
jsmpeg_cameras: dict[str, JsmpegCamera] = {} jsmpeg_cameras: dict[str, JsmpegCamera] = {}
birdseye: Birdseye | None = None birdseye: Birdseye | None = None
@ -167,6 +169,20 @@ class OutputProcess(FrigateProcess):
websocket_thread.start() websocket_thread.start()
while not self.stop_event.is_set(): while not self.stop_event.is_set():
update_topic, birdseye_config = (
birdseye_config_subscriber.check_for_update()
)
if update_topic is not None:
previous_global_mode = self.config.birdseye.mode
self.config.birdseye = birdseye_config
for camera_config in self.config.cameras.values():
if camera_config.birdseye.mode == previous_global_mode:
camera_config.birdseye.mode = birdseye_config.mode
logger.debug("Applied dynamic birdseye config update")
# check if there is an updated config # check if there is an updated config
updates = config_subscriber.check_for_updates() updates = config_subscriber.check_for_updates()
@ -297,6 +313,7 @@ class OutputProcess(FrigateProcess):
birdseye.stop() birdseye.stop()
config_subscriber.stop() config_subscriber.stop()
birdseye_config_subscriber.stop()
websocket_server.manager.close_all() websocket_server.manager.close_all()
websocket_server.manager.stop() websocket_server.manager.stop()
websocket_server.manager.join() websocket_server.manager.join()

View File

@ -0,0 +1,261 @@
"""Tests for the config_set endpoint's wildcard camera propagation."""
import os
import tempfile
import unittest
from unittest.mock import MagicMock, Mock, patch
import ruamel.yaml
from frigate.config import FrigateConfig
from frigate.config.camera.updater import (
CameraConfigUpdateEnum,
CameraConfigUpdatePublisher,
CameraConfigUpdateTopic,
)
from frigate.models import Event, Recordings, ReviewSegment
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
class TestConfigSetWildcardPropagation(BaseTestHttp):
"""Test that wildcard camera updates fan out to all cameras."""
def setUp(self):
super().setUp(models=[Event, Recordings, ReviewSegment])
self.minimal_config = {
"mqtt": {"host": "mqtt"},
"cameras": {
"front_door": {
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
]
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
},
"back_yard": {
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.2:554/video", "roles": ["detect"]}
]
},
"detect": {
"height": 720,
"width": 1280,
"fps": 10,
},
},
},
}
def _create_app_with_publisher(self):
"""Create app with a mocked config publisher."""
from fastapi import Request
from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user
from frigate.api.fastapi_app import create_fastapi_app
mock_publisher = Mock(spec=CameraConfigUpdatePublisher)
mock_publisher.publisher = MagicMock()
app = create_fastapi_app(
FrigateConfig(**self.minimal_config),
self.db,
None,
None,
None,
None,
None,
None,
mock_publisher,
None,
enforce_default_admin=False,
)
async def mock_get_current_user(request: Request):
username = request.headers.get("remote-user")
role = request.headers.get("remote-role")
return {"username": username, "role": role}
async def mock_get_allowed_cameras_for_filter(request: Request):
return list(self.minimal_config.get("cameras", {}).keys())
app.dependency_overrides[get_current_user] = mock_get_current_user
app.dependency_overrides[get_allowed_cameras_for_filter] = (
mock_get_allowed_cameras_for_filter
)
return app, mock_publisher
def _write_config_file(self):
"""Write the minimal config to a temp YAML file and return the path."""
yaml = ruamel.yaml.YAML()
f = tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False)
yaml.dump(self.minimal_config, f)
f.close()
return f.name
@patch("frigate.api.app.find_config_file")
def test_wildcard_detect_update_fans_out_to_all_cameras(self, mock_find_config):
"""config/cameras/*/detect fans out to all cameras."""
config_path = self._write_config_file()
mock_find_config.return_value = config_path
try:
app, mock_publisher = self._create_app_with_publisher()
with AuthTestClient(app) as client:
resp = client.put(
"/config/set",
json={
"config_data": {"detect": {"fps": 15}},
"update_topic": "config/cameras/*/detect",
"requires_restart": 0,
},
)
self.assertEqual(resp.status_code, 200)
data = resp.json()
self.assertTrue(data["success"])
# Verify publish_update called for each camera
self.assertEqual(mock_publisher.publish_update.call_count, 2)
published_cameras = set()
for c in mock_publisher.publish_update.call_args_list:
topic = c[0][0]
self.assertIsInstance(topic, CameraConfigUpdateTopic)
self.assertEqual(topic.update_type, CameraConfigUpdateEnum.detect)
published_cameras.add(topic.camera)
self.assertEqual(published_cameras, {"front_door", "back_yard"})
# Global publisher should NOT be called for wildcard
mock_publisher.publisher.publish.assert_not_called()
finally:
os.unlink(config_path)
@patch("frigate.api.app.find_config_file")
def test_wildcard_motion_update_fans_out(self, mock_find_config):
"""config/cameras/*/motion fans out to all cameras."""
config_path = self._write_config_file()
mock_find_config.return_value = config_path
try:
app, mock_publisher = self._create_app_with_publisher()
with AuthTestClient(app) as client:
resp = client.put(
"/config/set",
json={
"config_data": {"motion": {"threshold": 30}},
"update_topic": "config/cameras/*/motion",
"requires_restart": 0,
},
)
self.assertEqual(resp.status_code, 200)
published_cameras = set()
for c in mock_publisher.publish_update.call_args_list:
topic = c[0][0]
self.assertEqual(topic.update_type, CameraConfigUpdateEnum.motion)
published_cameras.add(topic.camera)
self.assertEqual(published_cameras, {"front_door", "back_yard"})
finally:
os.unlink(config_path)
@patch("frigate.api.app.find_config_file")
def test_camera_specific_topic_only_updates_one_camera(self, mock_find_config):
"""config/cameras/front_door/detect only updates front_door."""
config_path = self._write_config_file()
mock_find_config.return_value = config_path
try:
app, mock_publisher = self._create_app_with_publisher()
with AuthTestClient(app) as client:
resp = client.put(
"/config/set",
json={
"config_data": {
"cameras": {"front_door": {"detect": {"fps": 20}}}
},
"update_topic": "config/cameras/front_door/detect",
"requires_restart": 0,
},
)
self.assertEqual(resp.status_code, 200)
# Only one camera updated
self.assertEqual(mock_publisher.publish_update.call_count, 1)
topic = mock_publisher.publish_update.call_args[0][0]
self.assertEqual(topic.camera, "front_door")
self.assertEqual(topic.update_type, CameraConfigUpdateEnum.detect)
# Global publisher should NOT be called
mock_publisher.publisher.publish.assert_not_called()
finally:
os.unlink(config_path)
@patch("frigate.api.app.find_config_file")
def test_wildcard_sends_merged_per_camera_config(self, mock_find_config):
"""Wildcard fan-out sends each camera's own merged config."""
config_path = self._write_config_file()
mock_find_config.return_value = config_path
try:
app, mock_publisher = self._create_app_with_publisher()
with AuthTestClient(app) as client:
resp = client.put(
"/config/set",
json={
"config_data": {"detect": {"fps": 15}},
"update_topic": "config/cameras/*/detect",
"requires_restart": 0,
},
)
self.assertEqual(resp.status_code, 200)
for c in mock_publisher.publish_update.call_args_list:
camera_detect_config = c[0][1]
self.assertIsNotNone(camera_detect_config)
self.assertTrue(hasattr(camera_detect_config, "fps"))
finally:
os.unlink(config_path)
@patch("frigate.api.app.find_config_file")
def test_non_camera_global_topic_uses_generic_publish(self, mock_find_config):
"""Non-camera topics (e.g. config/live) use the generic publisher."""
config_path = self._write_config_file()
mock_find_config.return_value = config_path
try:
app, mock_publisher = self._create_app_with_publisher()
with AuthTestClient(app) as client:
resp = client.put(
"/config/set",
json={
"config_data": {"live": {"height": 720}},
"update_topic": "config/live",
"requires_restart": 0,
},
)
self.assertEqual(resp.status_code, 200)
# Global topic publisher called
mock_publisher.publisher.publish.assert_called_once()
# Camera-level publish_update NOT called
mock_publisher.publish_update.assert_not_called()
finally:
os.unlink(config_path)
if __name__ == "__main__":
unittest.main()

View File

@ -151,7 +151,9 @@ def sync_recordings(
max_inserts = 1000 max_inserts = 1000
for batch in chunked(recordings_to_delete, max_inserts): for batch in chunked(recordings_to_delete, max_inserts):
RecordingsToDelete.insert_many(batch).execute() RecordingsToDelete.insert_many(
[{"id": r["id"]} for r in batch]
).execute()
try: try:
deleted = ( deleted = (

View File

@ -214,7 +214,11 @@ class CameraWatchdog(threading.Thread):
self.config_subscriber = CameraConfigUpdateSubscriber( self.config_subscriber = CameraConfigUpdateSubscriber(
None, None,
{config.name: config}, {config.name: config},
[CameraConfigUpdateEnum.enabled, CameraConfigUpdateEnum.record], [
CameraConfigUpdateEnum.enabled,
CameraConfigUpdateEnum.ffmpeg,
CameraConfigUpdateEnum.record,
],
) )
self.requestor = InterProcessRequestor() self.requestor = InterProcessRequestor()
self.was_enabled = self.config.enabled self.was_enabled = self.config.enabled
@ -254,9 +258,13 @@ class CameraWatchdog(threading.Thread):
self._last_record_status = status self._last_record_status = status
self._last_status_update_time = now self._last_status_update_time = now
def _check_config_updates(self) -> dict[str, list[str]]:
"""Check for config updates and return the update dict."""
return self.config_subscriber.check_for_updates()
def _update_enabled_state(self) -> bool: def _update_enabled_state(self) -> bool:
"""Fetch the latest config and update enabled state.""" """Fetch the latest config and update enabled state."""
self.config_subscriber.check_for_updates() self._check_config_updates()
return self.config.enabled return self.config.enabled
def reset_capture_thread( def reset_capture_thread(
@ -317,7 +325,24 @@ class CameraWatchdog(threading.Thread):
# 1 second watchdog loop # 1 second watchdog loop
while not self.stop_event.wait(1): while not self.stop_event.wait(1):
enabled = self._update_enabled_state() updates = self._check_config_updates()
# Handle ffmpeg config changes by restarting all ffmpeg processes
if "ffmpeg" in updates and self.config.enabled:
self.logger.debug(
"FFmpeg config updated for %s, restarting ffmpeg processes",
self.config.name,
)
self.stop_all_ffmpeg()
self.start_all_ffmpeg()
self.latest_valid_segment_time = 0
self.latest_invalid_segment_time = 0
self.latest_cache_segment_time = 0
self.record_enable_time = datetime.now().astimezone(timezone.utc)
last_restart_time = datetime.now().timestamp()
continue
enabled = self.config.enabled
if enabled != self.was_enabled: if enabled != self.was_enabled:
if enabled: if enabled:
self.logger.debug(f"Enabling camera {self.config.name}") self.logger.debug(f"Enabling camera {self.config.name}")

35
web/package-lock.json generated
View File

@ -5386,9 +5386,9 @@
} }
}, },
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
"version": "2.0.1", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -5396,13 +5396,13 @@
} }
}, },
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
"version": "9.0.4", "version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==", "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"dev": true, "dev": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"brace-expansion": "^2.0.1" "brace-expansion": "^2.0.2"
}, },
"engines": { "engines": {
"node": ">=16 || 14 >=14.17" "node": ">=16 || 14 >=14.17"
@ -9634,9 +9634,10 @@
} }
}, },
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "3.1.2", "version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"license": "ISC",
"dependencies": { "dependencies": {
"brace-expansion": "^1.1.7" "brace-expansion": "^1.1.7"
}, },
@ -12106,9 +12107,9 @@
} }
}, },
"node_modules/test-exclude/node_modules/brace-expansion": { "node_modules/test-exclude/node_modules/brace-expansion": {
"version": "2.0.1", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -12137,13 +12138,13 @@
} }
}, },
"node_modules/test-exclude/node_modules/minimatch": { "node_modules/test-exclude/node_modules/minimatch": {
"version": "9.0.5", "version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"dev": true, "dev": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"brace-expansion": "^2.0.1" "brace-expansion": "^2.0.2"
}, },
"engines": { "engines": {
"node": ">=16 || 14 >=14.17" "node": ">=16 || 14 >=14.17"

View File

@ -25,14 +25,7 @@ const audio: SectionConfigOverrides = {
}, },
}, },
global: { global: {
restartRequired: [ restartRequired: ["num_threads"],
"enabled",
"listen",
"filters",
"min_volume",
"max_not_heard",
"num_threads",
],
}, },
camera: { camera: {
restartRequired: ["num_threads"], restartRequired: ["num_threads"],

View File

@ -28,10 +28,7 @@ const birdseye: SectionConfigOverrides = {
"width", "width",
"height", "height",
"quality", "quality",
"mode",
"layout.scaling_factor", "layout.scaling_factor",
"inactivity_threshold",
"layout.max_cameras",
"idle_heartbeat_fps", "idle_heartbeat_fps",
], ],
uiSchema: { uiSchema: {

View File

@ -3,7 +3,7 @@ import type { SectionConfigOverrides } from "./types";
const classification: SectionConfigOverrides = { const classification: SectionConfigOverrides = {
base: { base: {
sectionDocs: "/configuration/custom_classification/object_classification", sectionDocs: "/configuration/custom_classification/object_classification",
restartRequired: ["bird.enabled", "bird.threshold"], restartRequired: ["bird.enabled"],
hiddenFields: ["custom"], hiddenFields: ["custom"],
advancedFields: [], advancedFields: [],
}, },

View File

@ -30,16 +30,7 @@ const detect: SectionConfigOverrides = {
], ],
}, },
global: { global: {
restartRequired: [ restartRequired: ["width", "height", "min_initialized", "max_disappeared"],
"enabled",
"width",
"height",
"fps",
"min_initialized",
"max_disappeared",
"annotation_offset",
"stationary",
],
}, },
camera: { camera: {
restartRequired: ["width", "height", "min_initialized", "max_disappeared"], restartRequired: ["width", "height", "min_initialized", "max_disappeared"],

View File

@ -32,18 +32,7 @@ const faceRecognition: SectionConfigOverrides = {
"blur_confidence_filter", "blur_confidence_filter",
"device", "device",
], ],
restartRequired: [ restartRequired: ["enabled", "model_size", "device"],
"enabled",
"model_size",
"unknown_score",
"detection_threshold",
"recognition_threshold",
"min_area",
"min_faces",
"save_attempts",
"blur_confidence_filter",
"device",
],
}, },
}; };

View File

@ -116,16 +116,7 @@ const ffmpeg: SectionConfigOverrides = {
}, },
}, },
global: { global: {
restartRequired: [ restartRequired: [],
"path",
"global_args",
"hwaccel_args",
"input_args",
"output_args",
"retry_interval",
"apple_compatibility",
"gpu",
],
fieldOrder: [ fieldOrder: [
"hwaccel_args", "hwaccel_args",
"path", "path",
@ -162,17 +153,7 @@ const ffmpeg: SectionConfigOverrides = {
fieldGroups: { fieldGroups: {
cameraFfmpeg: ["input_args", "hwaccel_args", "output_args"], cameraFfmpeg: ["input_args", "hwaccel_args", "output_args"],
}, },
restartRequired: [ restartRequired: [],
"inputs",
"path",
"global_args",
"hwaccel_args",
"input_args",
"output_args",
"retry_interval",
"apple_compatibility",
"gpu",
],
}, },
}; };

View File

@ -40,21 +40,7 @@ const lpr: SectionConfigOverrides = {
"device", "device",
"replace_rules", "replace_rules",
], ],
restartRequired: [ restartRequired: ["model_size", "enhancement", "device"],
"enabled",
"model_size",
"detection_threshold",
"min_area",
"recognition_threshold",
"min_plate_length",
"format",
"match_distance",
"known_plates",
"enhancement",
"debug_save_plates",
"device",
"replace_rules",
],
uiSchema: { uiSchema: {
format: { format: {
"ui:options": { size: "md" }, "ui:options": { size: "md" },

View File

@ -31,18 +31,7 @@ const motion: SectionConfigOverrides = {
], ],
}, },
global: { global: {
restartRequired: [ restartRequired: ["frame_height"],
"enabled",
"threshold",
"lightning_threshold",
"skip_motion_threshold",
"improve_contrast",
"contour_area",
"delta_alpha",
"frame_alpha",
"frame_height",
"mqtt_off_delay",
],
}, },
camera: { camera: {
restartRequired: ["frame_height"], restartRequired: ["frame_height"],

View File

@ -83,7 +83,7 @@ const objects: SectionConfigOverrides = {
}, },
}, },
global: { global: {
restartRequired: ["track", "alert", "detect", "filters", "genai"], restartRequired: [],
hiddenFields: [ hiddenFields: [
"enabled_in_config", "enabled_in_config",
"mask", "mask",

View File

@ -29,16 +29,7 @@ const record: SectionConfigOverrides = {
}, },
}, },
global: { global: {
restartRequired: [ restartRequired: [],
"enabled",
"expire_interval",
"continuous",
"motion",
"alerts",
"detections",
"preview",
"export",
],
}, },
camera: { camera: {
restartRequired: [], restartRequired: [],

View File

@ -44,7 +44,7 @@ const review: SectionConfigOverrides = {
}, },
}, },
global: { global: {
restartRequired: ["alerts", "detections", "genai"], restartRequired: [],
}, },
camera: { camera: {
restartRequired: [], restartRequired: [],

View File

@ -27,14 +27,7 @@ const snapshots: SectionConfigOverrides = {
}, },
}, },
global: { global: {
restartRequired: [ restartRequired: [],
"enabled",
"bounding_box",
"crop",
"quality",
"timestamp",
"retain",
],
hiddenFields: ["enabled_in_config", "required_zones"], hiddenFields: ["enabled_in_config", "required_zones"],
}, },
camera: { camera: {

View File

@ -3,14 +3,7 @@ import type { SectionConfigOverrides } from "./types";
const telemetry: SectionConfigOverrides = { const telemetry: SectionConfigOverrides = {
base: { base: {
sectionDocs: "/configuration/reference", sectionDocs: "/configuration/reference",
restartRequired: [ restartRequired: ["version_check"],
"network_interfaces",
"stats.amd_gpu_stats",
"stats.intel_gpu_stats",
"stats.intel_gpu_device",
"stats.network_bandwidth",
"version_check",
],
fieldOrder: ["network_interfaces", "stats", "version_check"], fieldOrder: ["network_interfaces", "stats", "version_check"],
advancedFields: [], advancedFields: [],
}, },

View File

@ -56,6 +56,7 @@ import ActivityIndicator from "@/components/indicators/activity-indicator";
import { StatusBarMessagesContext } from "@/context/statusbar-provider"; import { StatusBarMessagesContext } from "@/context/statusbar-provider";
import { import {
cameraUpdateTopicMap, cameraUpdateTopicMap,
globalCameraDefaultSections,
buildOverrides, buildOverrides,
buildConfigDataForPath, buildConfigDataForPath,
sanitizeSectionData as sharedSanitizeSectionData, sanitizeSectionData as sharedSanitizeSectionData,
@ -234,7 +235,10 @@ export function ConfigSection({
? cameraUpdateTopicMap[sectionPath] ? cameraUpdateTopicMap[sectionPath]
? `config/cameras/${cameraName}/${cameraUpdateTopicMap[sectionPath]}` ? `config/cameras/${cameraName}/${cameraUpdateTopicMap[sectionPath]}`
: undefined : undefined
: `config/${sectionPath}`; : globalCameraDefaultSections.has(sectionPath) &&
cameraUpdateTopicMap[sectionPath]
? `config/cameras/*/${cameraUpdateTopicMap[sectionPath]}`
: `config/${sectionPath}`;
// Default: show title for camera level (since it might be collapsible), hide for global // Default: show title for camera level (since it might be collapsible), hide for global
const shouldShowTitle = showTitle ?? effectiveLevel === "camera"; const shouldShowTitle = showTitle ?? effectiveLevel === "camera";
@ -827,7 +831,7 @@ export function ConfigSection({
<div <div
className={cn( className={cn(
"w-full border-t border-secondary bg-background pb-5 pt-0", "w-full border-t border-secondary bg-background pt-0",
!noStickyButtons && "sticky bottom-0 z-50", !noStickyButtons && "sticky bottom-0 z-50",
)} )}
> >

View File

@ -1,4 +1,4 @@
import { useCallback, useContext, useEffect, useMemo } from "react"; import { useCallback, useContext, useEffect, useMemo, useRef } from "react";
import { useLocation, useNavigate, useSearchParams } from "react-router-dom"; import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
import { usePersistence } from "./use-persistence"; import { usePersistence } from "./use-persistence";
import { useUserPersistence } from "./use-user-persistence"; import { useUserPersistence } from "./use-user-persistence";
@ -12,20 +12,28 @@ export function useOverlayState<S>(
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const currentLocationState = useMemo(() => location.state, [location]); const locationRef = useRef(location);
locationRef.current = location;
const setOverlayStateValue = useCallback( const setOverlayStateValue = useCallback(
(value: S, replace: boolean = false) => { (value: S, replace: boolean = false) => {
const newLocationState = { ...currentLocationState }; const loc = locationRef.current;
const currentValue = loc.state?.[key] as S | undefined;
if (Object.is(currentValue, value)) {
return;
}
const newLocationState = { ...loc.state };
newLocationState[key] = value; newLocationState[key] = value;
navigate(location.pathname + (preserveSearch ? location.search : ""), { navigate(loc.pathname + (preserveSearch ? loc.search : ""), {
state: newLocationState, state: newLocationState,
replace, replace,
}); });
}, },
// we know that these deps are correct // locationRef is stable so we don't need it in deps
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
[key, currentLocationState, navigate], [key, navigate, preserveSearch],
); );
const overlayStateValue = useMemo<S | undefined>( const overlayStateValue = useMemo<S | undefined>(
@ -47,7 +55,9 @@ export function usePersistedOverlayState<S extends string>(
] { ] {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const currentLocationState = useMemo(() => location.state, [location]);
const locationRef = useRef(location);
locationRef.current = location;
// currently selected value // currently selected value
@ -63,14 +73,21 @@ export function usePersistedOverlayState<S extends string>(
const setOverlayStateValue = useCallback( const setOverlayStateValue = useCallback(
(value: S | undefined, replace: boolean = false) => { (value: S | undefined, replace: boolean = false) => {
const loc = locationRef.current;
const currentValue = loc.state?.[key] as S | undefined;
if (Object.is(currentValue, value)) {
return;
}
setPersistedValue(value); setPersistedValue(value);
const newLocationState = { ...currentLocationState }; const newLocationState = { ...loc.state };
newLocationState[key] = value; newLocationState[key] = value;
navigate(location.pathname, { state: newLocationState, replace }); navigate(loc.pathname, { state: newLocationState, replace });
}, },
// we know that these deps are correct // locationRef is stable so we don't need it in deps
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
[key, currentLocationState, navigate], [key, navigate, setPersistedValue],
); );
return [ return [
@ -98,7 +115,9 @@ export function useUserPersistedOverlayState<S extends string>(
const { auth } = useContext(AuthContext); const { auth } = useContext(AuthContext);
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const currentLocationState = useMemo(() => location.state, [location]);
const locationRef = useRef(location);
locationRef.current = location;
// currently selected value from URL state // currently selected value from URL state
const overlayStateValue = useMemo<S | undefined>( const overlayStateValue = useMemo<S | undefined>(
@ -112,14 +131,21 @@ export function useUserPersistedOverlayState<S extends string>(
const setOverlayStateValue = useCallback( const setOverlayStateValue = useCallback(
(value: S | undefined, replace: boolean = false) => { (value: S | undefined, replace: boolean = false) => {
const loc = locationRef.current;
const currentValue = loc.state?.[key] as S | undefined;
if (Object.is(currentValue, value)) {
return;
}
setPersistedValue(value); setPersistedValue(value);
const newLocationState = { ...currentLocationState }; const newLocationState = { ...loc.state };
newLocationState[key] = value; newLocationState[key] = value;
navigate(location.pathname, { state: newLocationState, replace }); navigate(loc.pathname, { state: newLocationState, replace });
}, },
// we know that these deps are correct // locationRef is stable so we don't need it in deps
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
[key, currentLocationState, navigate, setPersistedValue], [key, navigate, setPersistedValue],
); );
// Don't return a value until auth has finished loading // Don't return a value until auth has finished loading
@ -142,17 +168,21 @@ export function useHashState<S extends string>(): [
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const locationRef = useRef(location);
locationRef.current = location;
const setHash = useCallback( const setHash = useCallback(
(value: S | undefined) => { (value: S | undefined) => {
const loc = locationRef.current;
if (!value) { if (!value) {
navigate(location.pathname); navigate(loc.pathname);
} else { } else {
navigate(`${location.pathname}#${value}`, { state: location.state }); navigate(`${loc.pathname}#${value}`, { state: loc.state });
} }
}, },
// we know that these deps are correct // locationRef is stable so we don't need it in deps
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
[location, navigate], [navigate],
); );
const hash = useMemo( const hash = useMemo(

View File

@ -479,14 +479,7 @@ const CAMERA_SELECT_BUTTON_PAGES = [
"regionGrid", "regionGrid",
]; ];
const ALLOWED_VIEWS_FOR_VIEWER = ["ui", "debug", "notifications"]; const ALLOWED_VIEWS_FOR_VIEWER = ["profileSettings", "notifications"];
const LARGE_BOTTOM_MARGIN_PAGES = [
"masksAndZones",
"motionTuner",
"mediaSync",
"regionGrid",
];
// keys for camera sections // keys for camera sections
const CAMERA_SECTION_MAPPING: Record<string, SettingsType> = { const CAMERA_SECTION_MAPPING: Record<string, SettingsType> = {
@ -1362,9 +1355,9 @@ export default function Settings() {
)} )}
</div> </div>
</div> </div>
<SidebarProvider> <SidebarProvider className="relative h-full min-h-0 flex-1">
<Sidebar variant="inset" className="relative mb-8 pl-0 pt-0"> <Sidebar variant="inset" className="absolute h-full pl-0 pt-0">
<SidebarContent className="scrollbar-container mb-24 overflow-y-auto border-r-[1px] border-secondary bg-background py-2"> <SidebarContent className="scrollbar-container overflow-y-auto border-r-[1px] border-secondary bg-background py-2">
<SidebarMenu> <SidebarMenu>
{settingsGroups.map((group) => { {settingsGroups.map((group) => {
const filteredItems = group.items.filter((item) => const filteredItems = group.items.filter((item) =>
@ -1452,8 +1445,7 @@ export default function Settings() {
<SidebarInset> <SidebarInset>
<div <div
className={cn( className={cn(
"scrollbar-container mb-16 flex-1 overflow-y-auto p-2 pr-0", "scrollbar-container flex-1 overflow-y-auto pl-2 pr-0 pt-2",
LARGE_BOTTOM_MARGIN_PAGES.includes(pageToggle) && "mb-24",
)} )}
> >
{(() => { {(() => {

View File

@ -54,6 +54,20 @@ export const cameraUpdateTopicMap: Record<string, string> = {
ui: "ui", ui: "ui",
}; };
// Sections where global config serves as the default for per-camera config.
// Global updates to these sections are fanned out to all cameras via wildcard.
export const globalCameraDefaultSections = new Set([
"detect",
"objects",
"motion",
"record",
"snapshots",
"review",
"audio",
"notifications",
"ffmpeg",
]);
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// buildOverrides — pure recursive diff of current vs stored config & defaults // buildOverrides — pure recursive diff of current vs stored config & defaults
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -476,6 +490,9 @@ export function prepareSectionSavePayload(opts: {
if (level === "camera" && cameraName) { if (level === "camera" && cameraName) {
const topic = cameraUpdateTopicMap[sectionPath]; const topic = cameraUpdateTopicMap[sectionPath];
updateTopic = topic ? `config/cameras/${cameraName}/${topic}` : undefined; updateTopic = topic ? `config/cameras/${cameraName}/${topic}` : undefined;
} else if (globalCameraDefaultSections.has(sectionPath)) {
const topic = cameraUpdateTopicMap[sectionPath];
updateTopic = topic ? `config/cameras/*/${topic}` : `config/${sectionPath}`;
} else { } else {
updateTopic = `config/${sectionPath}`; updateTopic = `config/${sectionPath}`;
} }

View File

@ -632,9 +632,10 @@ export default function DraggableGridLayout({
toggleStats={() => toggleStats(camera.name)} toggleStats={() => toggleStats(camera.name)}
volumeState={volumeStates[camera.name]} volumeState={volumeStates[camera.name]}
setVolumeState={(value) => setVolumeState={(value) =>
setVolumeStates({ setVolumeStates((prev) => ({
...prev,
[camera.name]: value, [camera.name]: value,
}) }))
} }
muteAll={muteAll} muteAll={muteAll}
unmuteAll={unmuteAll} unmuteAll={unmuteAll}

View File

@ -131,12 +131,10 @@ export default function MotionSearchView({
); );
// Camera previews defer until dialog is closed // Camera previews defer until dialog is closed
const allPreviews = useCameraPreviews( const allPreviews = useCameraPreviews(timeRange, {
isSearchDialogOpen ? { after: 0, before: 0 } : timeRange, camera: selectedCamera ?? undefined,
{ fetchPreviews: !isSearchDialogOpen,
camera: selectedCamera ?? undefined, });
},
);
// ROI state // ROI state
const [polygonPoints, setPolygonPoints] = useState<number[][]>([]); const [polygonPoints, setPolygonPoints] = useState<number[][]>([]);

View File

@ -210,7 +210,7 @@ export default function UiSettingsView() {
]; ];
return ( return (
<div className="flex size-full flex-col md:pb-8"> <div className="flex size-full flex-col">
<Toaster position="top-center" closeButton={true} /> <Toaster position="top-center" closeButton={true} />
<Heading as="h4" className="mb-3"> <Heading as="h4" className="mb-3">
{t("general.title")} {t("general.title")}