From 9b02c7318d22ed4213a29e3f134e117c2497f360 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Wed, 1 Jul 2026 15:03:34 -0500 Subject: [PATCH] Miscellaneous fixes (#23610) * Handle back seeking going to previous clip * scope /recordings/unavailable query to the caller's allowed cameras * listen for config updates in activity manager * don't set search after awaited request Intentionally do NOT setSearch() to mark the open event submitted. This runs after the awaited request, by which point the user may have closed the dialog; re-setting the parent's selected event would resurrect it and the force-open effect would reopen it (see #23599). The local "submitted" state covers the open card, and mutate() updates the events cache so the grid and any future open reflect the result. * fix ruff #23201 removed pathlib import but for some reason it's just now causing ruff to fail --------- Co-authored-by: Nicolas Mowen --- .../rootfs/usr/local/go2rtc/create_config.py | 1 + frigate/api/record.py | 20 ++++--- frigate/camera/activity_manager.py | 45 ++++++++++++++++ frigate/comms/dispatcher.py | 2 + frigate/test/http_api/test_http_media.py | 52 +++++++++++++++++++ .../overlay/detail/SearchDetailDialog.tsx | 3 +- web/src/components/player/HlsVideoPlayer.tsx | 12 ++++- .../player/dynamic/DynamicVideoPlayer.tsx | 3 ++ web/src/views/recording/RecordingView.tsx | 11 ++++ 9 files changed, 134 insertions(+), 15 deletions(-) diff --git a/docker/main/rootfs/usr/local/go2rtc/create_config.py b/docker/main/rootfs/usr/local/go2rtc/create_config.py index 77cfc1b880..70cb744f13 100644 --- a/docker/main/rootfs/usr/local/go2rtc/create_config.py +++ b/docker/main/rootfs/usr/local/go2rtc/create_config.py @@ -3,6 +3,7 @@ import json import os import sys +from pathlib import Path from typing import Any from ruamel.yaml import YAML diff --git a/frigate/api/record.py b/frigate/api/record.py index 04b81cf38a..5fc3950e20 100644 --- a/frigate/api/record.py +++ b/frigate/api/record.py @@ -269,12 +269,12 @@ async def no_recordings( cameras = params.cameras if cameras != "all": requested = set(unquote(cameras).split(",")) - filtered = requested.intersection(allowed_cameras) - if not filtered: - return JSONResponse(content=[]) - cameras = ",".join(filtered) + camera_list = list(requested.intersection(allowed_cameras)) else: - cameras = allowed_cameras + camera_list = list(allowed_cameras) + + if not camera_list: + return JSONResponse(content=[]) before = params.before or datetime.datetime.now().timestamp() after = ( @@ -283,12 +283,10 @@ async def no_recordings( ) scale = params.scale - clauses = [(Recordings.end_time >= after) & (Recordings.start_time <= before)] - if cameras != "all": - camera_list = cameras.split(",") - clauses.append((Recordings.camera << camera_list)) - else: - camera_list = allowed_cameras + clauses = [ + (Recordings.end_time >= after) & (Recordings.start_time <= before), + (Recordings.camera << camera_list), + ] # Get recording start times data: list[Recordings] = ( diff --git a/frigate/camera/activity_manager.py b/frigate/camera/activity_manager.py index 38425add9a..3952c8b436 100644 --- a/frigate/camera/activity_manager.py +++ b/frigate/camera/activity_manager.py @@ -13,6 +13,10 @@ from frigate.comms.event_metadata_updater import ( EventMetadataTypeEnum, ) from frigate.config import CameraConfig, FrigateConfig +from frigate.config.camera.updater import ( + CameraConfigUpdateEnum, + CameraConfigUpdateSubscriber, +) logger = logging.getLogger(__name__) @@ -29,6 +33,11 @@ class CameraActivityManager: self.zone_all_object_counts: dict[str, Counter] = {} self.zone_active_object_counts: dict[str, Counter] = {} self.all_zone_labels: dict[str, set[str]] = {} + self.config_subscriber = CameraConfigUpdateSubscriber( + config, + config.cameras, + [CameraConfigUpdateEnum.zones, CameraConfigUpdateEnum.objects], + ) for camera_config in config.cameras.values(): if not camera_config.enabled_in_config: @@ -56,7 +65,40 @@ class CameraActivityManager: else camera_config.objects.track ) + def __rebuild_zone_labels(self) -> None: + """Rebuild zone label tracking after a runtime zones/objects change.""" + new_zone_labels: dict[str, set[str]] = {} + + for camera_config in self.config.cameras.values(): + if not camera_config.enabled_in_config or camera_config.name is None: + continue + + for zone, zone_config in camera_config.zones.items(): + new_zone_labels.setdefault(zone, set()).update( + zone_config.objects + if zone_config.objects + else camera_config.objects.track + ) + + # drop counters for zones that no longer exist + for zone in list(self.zone_all_object_counts.keys()): + if zone not in new_zone_labels: + self.zone_all_object_counts.pop(zone, None) + self.zone_active_object_counts.pop(zone, None) + + # ensure counters exist for new zones so the first count is published + for zone in new_zone_labels: + self.zone_all_object_counts.setdefault(zone, Counter()) + self.zone_active_object_counts.setdefault(zone, Counter()) + + self.all_zone_labels = new_zone_labels + def update_activity(self, new_activity: dict[str, dict[str, Any]]) -> None: + updated_topics = self.config_subscriber.check_for_updates() + + if "zones" in updated_topics or "objects" in updated_topics: + self.__rebuild_zone_labels() + all_objects: list[dict[str, Any]] = [] for camera in new_activity.keys(): @@ -161,6 +203,9 @@ class CameraActivityManager: self.publish(f"{camera}/all", sum(list(all_objects.values()))) self.publish(f"{camera}/all/active", sum(list(active_objects.values()))) + def stop(self) -> None: + self.config_subscriber.stop() + class AudioActivityManager: def __init__( diff --git a/frigate/comms/dispatcher.py b/frigate/comms/dispatcher.py index a85e644940..4c6dc6ef98 100644 --- a/frigate/comms/dispatcher.py +++ b/frigate/comms/dispatcher.py @@ -397,6 +397,8 @@ class Dispatcher: comm.publish(topic, payload, retain) def stop(self) -> None: + self.camera_activity.stop() + for comm in self.comms: comm.stop() diff --git a/frigate/test/http_api/test_http_media.py b/frigate/test/http_api/test_http_media.py index 58f9f92477..fa4e69473b 100644 --- a/frigate/test/http_api/test_http_media.py +++ b/frigate/test/http_api/test_http_media.py @@ -475,3 +475,55 @@ class TestHttpMedia(BaseTestHttp): assert response.status_code == 200 assert response.json() == [] + + def test_recordings_unavailable_cameras_all_scopes_to_allowed_cameras(self): + """cameras=all must not error and must only consider allowed cameras. + + allowed_cameras is mocked to ["front_door"]. A back_door recording that + would otherwise fill the gap must be ignored, and the request must not + 500 the way it did when cameras was reassigned to a list. + """ + with AuthTestClient(self.app) as client: + # front_door has a 20s gap (1010-1030). + Recordings.insert( + id="front_a", + path="/media/recordings/front_a.mp4", + camera="front_door", + start_time=1000, + end_time=1010, + duration=10, + motion=0, + ).execute() + Recordings.insert( + id="front_b", + path="/media/recordings/front_b.mp4", + camera="front_door", + start_time=1030, + end_time=1040, + duration=10, + motion=0, + ).execute() + # back_door is not in allowed_cameras; its full-window coverage must + # not mask the front_door gap. + Recordings.insert( + id="back_a", + path="/media/recordings/back_a.mp4", + camera="back_door", + start_time=1000, + end_time=1040, + duration=40, + motion=0, + ).execute() + + response = client.get( + "/recordings/unavailable", + params={ + "after": 1000, + "before": 1040, + "scale": 5, + "cameras": "all", + }, + ) + + assert response.status_code == 200 + assert response.json() == [{"start_time": 1010, "end_time": 1030}] diff --git a/web/src/components/overlay/detail/SearchDetailDialog.tsx b/web/src/components/overlay/detail/SearchDetailDialog.tsx index e8e4368ea4..a19d6c086d 100644 --- a/web/src/components/overlay/detail/SearchDetailDialog.tsx +++ b/web/src/components/overlay/detail/SearchDetailDialog.tsx @@ -1263,7 +1263,6 @@ function ObjectDetailsTab({ } setState("submitted"); - setSearch({ ...search, plus_id: "new_upload" }); mutate( (key) => isEventsKey(key), (currentData: SearchResult[][] | SearchResult[] | undefined) => @@ -1286,7 +1285,7 @@ function ObjectDetailsTab({ ); } }, - [search, mutate, mapSearchResults, setSearch, isEventsKey, t], + [search, mutate, mapSearchResults, isEventsKey, t], ); const popoverContainerRef = useRef(null); diff --git a/web/src/components/player/HlsVideoPlayer.tsx b/web/src/components/player/HlsVideoPlayer.tsx index 91cf0c210d..c1aaa09d68 100644 --- a/web/src/components/player/HlsVideoPlayer.tsx +++ b/web/src/components/player/HlsVideoPlayer.tsx @@ -47,6 +47,7 @@ type HlsVideoPlayerProps = { frigateControls?: boolean; inpointOffset?: number; onClipEnded?: (currentTime: number) => void; + onClipPrevious?: (diff: number) => void; onPlayerLoaded?: () => void; onTimeUpdate?: (time: number) => void; onPlaying?: () => void; @@ -74,6 +75,7 @@ export default function HlsVideoPlayer({ frigateControls = true, inpointOffset = 0, onClipEnded, + onClipPrevious, onPlayerLoaded, onTimeUpdate, onPlaying, @@ -339,11 +341,17 @@ export default function HlsVideoPlayer({ onSeek={(diff) => { const currentTime = videoRef.current?.currentTime; - if (!videoRef.current || !currentTime) { + if (!videoRef.current || currentTime == undefined) { return; } - videoRef.current.currentTime = Math.max(0, currentTime + diff); + const newTime = currentTime + diff; + + if (newTime < 0 && onClipPrevious) { + onClipPrevious(diff); + } else { + videoRef.current.currentTime = Math.max(0, newTime); + } }} onSetPlaybackRate={(rate) => { setPlaybackRate(rate, true); diff --git a/web/src/components/player/dynamic/DynamicVideoPlayer.tsx b/web/src/components/player/dynamic/DynamicVideoPlayer.tsx index 1578f4d1d7..8998be7a60 100644 --- a/web/src/components/player/dynamic/DynamicVideoPlayer.tsx +++ b/web/src/components/player/dynamic/DynamicVideoPlayer.tsx @@ -49,6 +49,7 @@ type DynamicVideoPlayerProps = { onControllerReady: (controller: DynamicVideoController) => void; onTimestampUpdate?: (timestamp: number) => void; onClipEnded?: () => void; + onClipPrevious?: (diff: number) => void; onSeekToTime?: (timestamp: number, play?: boolean) => void; setFullResolution: React.Dispatch>; toggleFullscreen: () => void; @@ -68,6 +69,7 @@ export default function DynamicVideoPlayer({ onControllerReady, onTimestampUpdate, onClipEnded, + onClipPrevious, onSeekToTime, setFullResolution, toggleFullscreen, @@ -343,6 +345,7 @@ export default function DynamicVideoPlayer({ onTimeUpdate={onTimeUpdate} onPlayerLoaded={onPlayerLoaded} onClipEnded={onValidateClipEnd} + onClipPrevious={onClipPrevious} onSeekToTime={(timestamp, play) => { if (onSeekToTime) { onSeekToTime(timestamp, play); diff --git a/web/src/views/recording/RecordingView.tsx b/web/src/views/recording/RecordingView.tsx index 30d3152b4b..924538cff5 100644 --- a/web/src/views/recording/RecordingView.tsx +++ b/web/src/views/recording/RecordingView.tsx @@ -336,6 +336,16 @@ export function RecordingView({ [currentTimeRange, updateSelectedSegment], ); + const onClipPrevious = useCallback( + (diff: number) => { + manuallySetCurrentTime( + currentTime + diff, + mainControllerRef.current?.isPlaying() ?? false, + ); + }, + [currentTime, manuallySetCurrentTime], + ); + const onShareReviewLink = useCallback( (timestamp: number) => { const reviewUrl = createRecordingReviewUrl(location.pathname, { @@ -902,6 +912,7 @@ export function RecordingView({ ); }} onClipEnded={onClipEnded} + onClipPrevious={onClipPrevious} onSeekToTime={manuallySetCurrentTime} onControllerReady={(controller) => { mainControllerRef.current = controller;