From d3f9fd1a607c547f7c3758e00e691073470128a4 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Sun, 3 Mar 2024 17:19:02 -0700 Subject: [PATCH] Review summary (#10196) * Create review summary api to get information about reviewed and unreviewed events on each day * remove unused * Fix tests * Format tests * Fix --- frigate/api/event.py | 4 +- frigate/api/review.py | 103 +++++++++++++++++++++++++- frigate/record/maintainer.py | 8 +- frigate/test/test_record_retention.py | 16 +--- web/src/hooks/use-date-utils.ts | 13 ++++ web/src/pages/Events.tsx | 19 +++++ web/src/types/review.ts | 10 +++ web/src/views/events/EventView.tsx | 48 +++++++++++- 8 files changed, 197 insertions(+), 24 deletions(-) diff --git a/frigate/api/event.py b/frigate/api/event.py index 32aa42146..7cdb933c9 100644 --- a/frigate/api/event.py +++ b/frigate/api/event.py @@ -23,9 +23,7 @@ from frigate.const import ( ) from frigate.models import Event, Timeline from frigate.object_processing import TrackedObject -from frigate.util.builtin import ( - get_tz_modifiers, -) +from frigate.util.builtin import get_tz_modifiers logger = logging.getLogger(__name__) diff --git a/frigate/api/review.py b/frigate/api/review.py index 51e30c520..ad8a5094b 100644 --- a/frigate/api/review.py +++ b/frigate/api/review.py @@ -10,9 +10,10 @@ from flask import ( make_response, request, ) -from peewee import DoesNotExist, operator +from peewee import Case, DoesNotExist, fn, operator from frigate.models import ReviewSegment +from frigate.util.builtin import get_tz_modifiers logger = logging.getLogger(__name__) @@ -70,6 +71,106 @@ def review(): return jsonify([r for r in review]) +@ReviewBp.route("/review/summary") +def review_summary(): + tz_name = request.args.get("timezone", default="utc", type=str) + hour_modifier, minute_modifier, seconds_offset = get_tz_modifiers(tz_name) + month_ago = (datetime.now() - timedelta(days=30)).timestamp() + + groups = ( + ReviewSegment.select( + fn.strftime( + "%Y-%m-%d", + fn.datetime( + ReviewSegment.start_time, + "unixepoch", + hour_modifier, + minute_modifier, + ), + ).alias("day"), + fn.SUM( + Case( + None, + [ + ( + (ReviewSegment.severity == "alert"), + ReviewSegment.has_been_reviewed, + ) + ], + 0, + ) + ).alias("reviewed_alert"), + fn.SUM( + Case( + None, + [ + ( + (ReviewSegment.severity == "detection"), + ReviewSegment.has_been_reviewed, + ) + ], + 0, + ) + ).alias("reviewed_detection"), + fn.SUM( + Case( + None, + [ + ( + (ReviewSegment.severity == "significant_motion"), + ReviewSegment.has_been_reviewed, + ) + ], + 0, + ) + ).alias("reviewed_motion"), + fn.SUM( + Case( + None, + [ + ( + (ReviewSegment.severity == "alert"), + 1, + ) + ], + 0, + ) + ).alias("total_alert"), + fn.SUM( + Case( + None, + [ + ( + (ReviewSegment.severity == "detection"), + 1, + ) + ], + 0, + ) + ).alias("total_detection"), + fn.SUM( + Case( + None, + [ + ( + (ReviewSegment.severity == "significant_motion"), + 1, + ) + ], + 0, + ) + ).alias("total_motion"), + ) + .where(ReviewSegment.start_time > month_ago) + .group_by( + (ReviewSegment.start_time + seconds_offset).cast("int") / (3600 * 24), + ) + .order_by(ReviewSegment.start_time.desc()) + ) + + return jsonify([e for e in groups.dicts().iterator()]) + + @ReviewBp.route("/review//viewed", methods=("POST",)) def set_reviewed(id): try: diff --git a/frigate/record/maintainer.py b/frigate/record/maintainer.py index 2715dec89..13e29f2d0 100644 --- a/frigate/record/maintainer.py +++ b/frigate/record/maintainer.py @@ -38,16 +38,16 @@ QUEUE_READ_TIMEOUT = 0.00001 # seconds class SegmentInfo: def __init__( - self, motion_box_count: int, active_object_count: int, average_dBFS: int + self, motion_area: int, active_object_count: int, average_dBFS: int ) -> None: - self.motion_box_count = motion_box_count + self.motion_area = motion_area self.active_object_count = active_object_count self.average_dBFS = average_dBFS def should_discard_segment(self, retain_mode: RetainModeEnum) -> bool: return ( retain_mode == RetainModeEnum.motion - and self.motion_box_count == 0 + and self.motion_area == 0 and self.average_dBFS == 0 ) or ( retain_mode == RetainModeEnum.active_objects @@ -412,7 +412,7 @@ class RecordingMaintainer(threading.Thread): Recordings.start_time: start_time.timestamp(), Recordings.end_time: end_time.timestamp(), Recordings.duration: duration, - Recordings.motion: segment_info.motion_box_count, + Recordings.motion: segment_info.motion_area, # TODO: update this to store list of active objects at some point Recordings.objects: segment_info.active_object_count, Recordings.dBFS: segment_info.average_dBFS, diff --git a/frigate/test/test_record_retention.py b/frigate/test/test_record_retention.py index dbe115791..81230449d 100644 --- a/frigate/test/test_record_retention.py +++ b/frigate/test/test_record_retention.py @@ -6,28 +6,20 @@ from frigate.record.maintainer import SegmentInfo class TestRecordRetention(unittest.TestCase): def test_motion_should_keep_motion_not_object(self): - segment_info = SegmentInfo( - motion_box_count=1, active_object_count=0, average_dBFS=0 - ) + segment_info = SegmentInfo(motion_area=1, active_object_count=0, average_dBFS=0) assert not segment_info.should_discard_segment(RetainModeEnum.motion) assert segment_info.should_discard_segment(RetainModeEnum.active_objects) def test_object_should_keep_object_not_motion(self): - segment_info = SegmentInfo( - motion_box_count=0, active_object_count=1, average_dBFS=0 - ) + segment_info = SegmentInfo(motion_area=0, active_object_count=1, average_dBFS=0) assert segment_info.should_discard_segment(RetainModeEnum.motion) assert not segment_info.should_discard_segment(RetainModeEnum.active_objects) def test_all_should_keep_all(self): - segment_info = SegmentInfo( - motion_box_count=0, active_object_count=0, average_dBFS=0 - ) + segment_info = SegmentInfo(motion_area=0, active_object_count=0, average_dBFS=0) assert not segment_info.should_discard_segment(RetainModeEnum.all) def test_should_keep_audio_in_motion_mode(self): - segment_info = SegmentInfo( - motion_box_count=0, active_object_count=0, average_dBFS=1 - ) + segment_info = SegmentInfo(motion_area=0, active_object_count=0, average_dBFS=1) assert not segment_info.should_discard_segment(RetainModeEnum.motion) assert segment_info.should_discard_segment(RetainModeEnum.active_objects) diff --git a/web/src/hooks/use-date-utils.ts b/web/src/hooks/use-date-utils.ts index 99ee55f59..14890c5c0 100644 --- a/web/src/hooks/use-date-utils.ts +++ b/web/src/hooks/use-date-utils.ts @@ -1,3 +1,4 @@ +import { FrigateConfig } from "@/types/frigateConfig"; import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; import { useMemo } from "react"; @@ -10,3 +11,15 @@ export function useFormattedTimestamp(timestamp: number, format: string) { return formattedTimestamp; } + +export function useTimezone(config: FrigateConfig | undefined) { + return useMemo(() => { + if (!config) { + return undefined; + } + + return ( + config.ui?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone + ); + }, [config]); +} diff --git a/web/src/pages/Events.tsx b/web/src/pages/Events.tsx index 37462b061..faece1026 100644 --- a/web/src/pages/Events.tsx +++ b/web/src/pages/Events.tsx @@ -1,5 +1,8 @@ +import ActivityIndicator from "@/components/indicators/activity-indicator"; import useApiFilter from "@/hooks/use-api-filter"; +import { useTimezone } from "@/hooks/use-date-utils"; import useOverlayState from "@/hooks/use-overlay-state"; +import { FrigateConfig } from "@/types/frigateConfig"; import { Preview } from "@/types/preview"; import { ReviewFilter, ReviewSegment, ReviewSeverity } from "@/types/review"; import DesktopRecordingView from "@/views/events/DesktopRecordingView"; @@ -12,6 +15,9 @@ import useSWRInfinite from "swr/infinite"; const API_LIMIT = 100; export default function Events() { + const { data: config } = useSWR("config"); + const timezone = useTimezone(config); + // recordings viewer const [severity, setSeverity] = useState("alert"); @@ -100,6 +106,14 @@ export default function Events() { const reloadData = useCallback(() => setBeforeTs(Date.now() / 1000), []); + // review summary + + const { data: reviewSummary } = useSWR([ + "review/summary", + { timezone: timezone }, + { revalidateOnFocus: false }, + ]); + // preview videos const previewTimes = useMemo(() => { @@ -200,6 +214,10 @@ export default function Events() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedReviewId, reviewPages]); + if (!timezone) { + return ; + } + if (selectedData) { return ( (null); const segmentDuration = 60; + // review counts + + const reviewCounts = useMemo(() => { + if (!reviewSummary) { + return { alert: 0, detection: 0, significant_motion: 0 }; + } + + let summary; + if (filter?.before == undefined) { + summary = reviewSummary[0]; + } else { + summary = reviewSummary[0]; + } + + if (filter?.showReviewed == 1) { + return { + alert: summary.total_alert, + detection: summary.total_detection, + significant_motion: summary.total_motion, + }; + } else { + return { + alert: summary.total_alert - summary.reviewed_alert, + detection: summary.total_detection - summary.reviewed_detection, + significant_motion: summary.total_motion - summary.reviewed_motion, + }; + } + }, [filter, reviewSummary]); + // review paging const reviewItems = useMemo(() => { @@ -264,7 +300,7 @@ export default function EventView({ aria-label="Select alerts" > -
Alerts
+
Alerts ∙ {reviewCounts.alert}
-
Detections
+
+ Detections ∙ {reviewCounts.detection} +
-
Motion
+
+ Motion ∙ {reviewCounts.significant_motion} +