diff --git a/docs/docs/integrations/api.md b/docs/docs/integrations/api.md index 341751ca4..b324aae36 100644 --- a/docs/docs/integrations/api.md +++ b/docs/docs/integrations/api.md @@ -450,6 +450,7 @@ Reviews from the database. Accepts the following query string parameters: | `after` | int | Epoch time | | `cameras` | str | , separated list of cameras | | `labels` | str | , separated list of labels | +| `zones` | str | , separated list of zones | | `reviewed` | int | Include items that have been reviewed (0 or 1) | | `limit` | int | Limit the number of events returned | | `severity` | str | Limit items to severity (alert, detection, significant_motion) | diff --git a/frigate/api/review.py b/frigate/api/review.py index e730573cf..7f17c77e1 100644 --- a/frigate/api/review.py +++ b/frigate/api/review.py @@ -22,6 +22,7 @@ ReviewBp = Blueprint("reviews", __name__) def review(): cameras = request.args.get("cameras", "all") labels = request.args.get("labels", "all") + zones = request.args.get("zones", "all") reviewed = request.args.get("reviewed", type=int, default=0) limit = request.args.get("limit", type=int, default=None) severity = request.args.get("severity", None) @@ -60,6 +61,20 @@ def review(): label_clause = reduce(operator.or_, label_clauses) clauses.append((label_clause)) + if zones != "all": + # use matching so segments with multiple zones + # still match on a search where any zone matches + zone_clauses = [] + filtered_zones = zones.split(",") + + for zone in filtered_zones: + zone_clauses.append( + (ReviewSegment.data["zones"].cast("text") % f'*"{zone}"*') + ) + + zone_clause = reduce(operator.or_, zone_clauses) + clauses.append((zone_clause)) + if reviewed == 0: clauses.append((ReviewSegment.has_been_reviewed == False)) @@ -96,6 +111,7 @@ def review_summary(): cameras = request.args.get("cameras", "all") labels = request.args.get("labels", "all") + zones = request.args.get("zones", "all") clauses = [(ReviewSegment.start_time > day_ago)] @@ -118,6 +134,20 @@ def review_summary(): label_clause = reduce(operator.or_, label_clauses) clauses.append((label_clause)) + if zones != "all": + # use matching so segments with multiple zones + # still match on a search where any zone matches + zone_clauses = [] + filtered_zones = zones.split(",") + + for zone in filtered_zones: + zone_clauses.append( + (ReviewSegment.data["zones"].cast("text") % f'*"{zone}"*') + ) + + zone_clause = reduce(operator.or_, zone_clauses) + clauses.append((zone_clause)) + last_24 = ( ReviewSegment.select( fn.SUM( diff --git a/frigate/review/maintainer.py b/frigate/review/maintainer.py index 32d4628a7..82e5f66bd 100644 --- a/frigate/review/maintainer.py +++ b/frigate/review/maintainer.py @@ -242,6 +242,8 @@ class ReviewSegmentMaintainer(threading.Thread): active_objects = get_active_objects(frame_time, camera_config, objects) if len(active_objects) > 0: + should_update = False + if frame_time > segment.last_update: segment.last_update = frame_time @@ -270,12 +272,16 @@ class ReviewSegmentMaintainer(threading.Thread): ) ): segment.severity = SeverityEnum.alert + should_update = True # keep zones up to date if len(object["current_zones"]) > 0: segment.zones.update(object["current_zones"]) if len(active_objects) > segment.frame_active_count: + should_update = True + + if should_update: try: frame_id = f"{camera_config.name}{frame_time}" yuv_frame = self.frame_manager.get( diff --git a/web/src/components/filter/ReviewFilterGroup.tsx b/web/src/components/filter/ReviewFilterGroup.tsx index 5de010a3d..23d8ac61c 100644 --- a/web/src/components/filter/ReviewFilterGroup.tsx +++ b/web/src/components/filter/ReviewFilterGroup.tsx @@ -30,6 +30,7 @@ import MobileReviewSettingsDrawer, { } from "../overlay/MobileReviewSettingsDrawer"; import useOptimisticState from "@/hooks/use-optimistic-state"; import FilterSwitch from "./FilterSwitch"; +import { FilterList } from "@/types/filter"; const REVIEW_FILTERS = [ "cameras", @@ -53,7 +54,7 @@ type ReviewFilterGroupProps = { reviewSummary?: ReviewSummary; filter?: ReviewFilter; motionOnly: boolean; - filterLabels?: string[]; + filterList?: FilterList; onUpdateFilter: (filter: ReviewFilter) => void; setMotionOnly: React.Dispatch>; }; @@ -64,15 +65,15 @@ export default function ReviewFilterGroup({ reviewSummary, filter, motionOnly, - filterLabels, + filterList, onUpdateFilter, setMotionOnly, }: ReviewFilterGroupProps) { const { data: config } = useSWR("config"); const allLabels = useMemo(() => { - if (filterLabels) { - return filterLabels; + if (filterList?.labels) { + return filterList.labels; } if (!config) { @@ -99,14 +100,43 @@ export default function ReviewFilterGroup({ }); return [...labels].sort(); - }, [config, filterLabels, filter]); + }, [config, filterList, filter]); + + const allZones = useMemo(() => { + if (filterList?.zones) { + return filterList.zones; + } + + if (!config) { + return []; + } + + const zones = new Set(); + const cameras = filter?.cameras || Object.keys(config.cameras); + + cameras.forEach((camera) => { + if (camera == "birdseye") { + return; + } + const cameraConfig = config.cameras[camera]; + cameraConfig.review.alerts.required_zones.forEach((zone) => { + zones.add(zone); + }); + cameraConfig.review.detections.required_zones.forEach((zone) => { + zones.add(zone); + }); + }); + + return [...zones].sort(); + }, [config, filterList, filter]); const filterValues = useMemo( () => ({ cameras: Object.keys(config?.cameras || {}), labels: Object.values(allLabels || {}), + zones: Object.values(allZones || {}), }), - [config, allLabels], + [config, allLabels, allZones], ); const groups = useMemo(() => { @@ -189,12 +219,17 @@ export default function ReviewFilterGroup({ selectedLabels={filter?.labels} currentSeverity={currentSeverity} showAll={filter?.showAll == true} + allZones={filterValues.zones} + selectedZones={filter?.zones} setShowAll={(showAll) => { onUpdateFilter({ ...filter, showAll }); }} updateLabelFilter={(newLabels) => { onUpdateFilter({ ...filter, labels: newLabels }); }} + updateZoneFilter={(newZones) => + onUpdateFilter({ ...filter, zones: newZones }) + } /> )} {isMobile && mobileSettingsFeatures.length > 0 && ( @@ -204,6 +239,7 @@ export default function ReviewFilterGroup({ currentSeverity={currentSeverity} reviewSummary={reviewSummary} allLabels={allLabels} + allZones={allZones} onUpdateFilter={onUpdateFilter} // not applicable as exports are not used camera="" @@ -495,21 +531,30 @@ type GeneralFilterButtonProps = { selectedLabels: string[] | undefined; currentSeverity?: ReviewSeverity; showAll: boolean; + allZones: string[]; + selectedZones?: string[]; setShowAll: (showAll: boolean) => void; updateLabelFilter: (labels: string[] | undefined) => void; + updateZoneFilter: (zones: string[] | undefined) => void; }; function GeneralFilterButton({ allLabels, selectedLabels, currentSeverity, showAll, + allZones, + selectedZones, setShowAll, updateLabelFilter, + updateZoneFilter, }: GeneralFilterButtonProps) { const [open, setOpen] = useState(false); const [currentLabels, setCurrentLabels] = useState( selectedLabels, ); + const [currentZones, setCurrentZones] = useState( + selectedZones, + ); const trigger = (