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;